From 64f82ab36419eb74a6bd8aae9e8b7c897aa1b16a Mon Sep 17 00:00:00 2001 From: Sollan Systems Date: Sat, 25 Jul 2026 02:10:01 -0400 Subject: [PATCH] =?UTF-8?q?chore(release):=20cut=20v0.10.0=20=E2=80=94=20h?= =?UTF-8?q?ash-linked=20event=20chain=20+=20anchored=20doctor=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version surfaces bumped 0.9.0 -> 0.10.0: pyproject.toml, .claude-plugin/plugin.json, and the four README surfaces (release badge, documented action pin, Status version, Status release tag). scripts/test_docs_version.py retargets the version pin to 0.10.0, adds the "## 0.10.0" CHANGELOG heading assertion, and machine-pins all four README version surfaces against plugin["version"] so a future cut cannot leave them stale. Verified teeth: with plugin.json bumped and README untouched, the new test fails. CHANGELOG 0.10.0 documents the chain fields and store-side computation, loop migrate, the doctor chain block, --expect-chain-head, the four new issue codes, the stricter schema-invalid-event rejection, the sidecar fix (with the 0.9.0 known-limitation line updated in place to point forward, not deleted), the integrity boundary quoting the mid-run-window paragraph verbatim from reference/repo-os-contract.md 16, and the two-directional compatibility rule. --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 86 +++++++++++++++++++++++++++++++++++- README.md | 8 ++-- pyproject.toml | 2 +- scripts/test_docs_version.py | 14 +++++- 5 files changed, 103 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 02a7f5b..a58210b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "loop-engineer", - "version": "0.9.0", + "version": "0.10.0", "description": "Design, launch, verify, repair, and improve agent loops. A Claude-Code-native architect+operator for long-running, verifiable, self-improving agentic-coding systems.", "author": { "name": "Sollan Systems", diff --git a/CHANGELOG.md b/CHANGELOG.md index 258e6ff..fc7f38f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,90 @@ All notable changes to `loop-engineer` are documented here. `WORKFLOW.md` and `README.md` are reworded to describe the mechanism; the 0.3.4 history is left intact. +## 0.10.0 — 2026-07-25 + +**The hash-linked event chain.** Every `loop-engineer/event@1` row now carries +`prev_event_hash` and `event_hash` — a sha256 over a canonical JSON preimage of +the event's hashed fields, `prev_event_hash` among them. Canonical form is +`json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=False, +allow_nan=False)` encoded UTF-8, pinned normatively with conformance vectors in +`reference/repo-os-contract.md` §16. The digest is computed **inside the store on +append**, never supplied by a caller, and the deterministic reducer re-verifies +each link as it folds: a spliced, reordered, or edited row raises +`ChainBreakError` and stops the fold instead of projecting a plausible state. +`loop.chain` is stdlib-only and imports no other module of this package, so +`verify_chain()` re-verifies an exported event stream without the store code +(#82). + +**Store generations and `loop migrate`.** A fresh store stamps `PRAGMA +user_version = 2` and widens the `events` DDL with `event_hash TEXT NOT NULL`. +`loop migrate` is the only store-upgrade path — explicit, idempotent, and +non-rewriting: it adds the two columns and stamps `user_version = 2`, but never +backfills hashes onto existing rows, because the append-only `BEFORE UPDATE` +trigger forbids it. Pre-migration rows therefore stay an **unchained prefix** +that doctor reports rather than elides, and the first post-migration append is a +chain genesis. + +**Anchored doctor gate.** `loop doctor` gains an `event_store.chain` block +(`head`, `unchained_prefix`) and a `--expect-chain-head SHA256` flag — also +accepted by `validate` and `verify` — that fails the gate unless the store's head +equals an externally remembered anchor. Four new issue codes: `event_chain_broken` +(a link does not verify), `chain_anchor_mismatch` (the head differs from the +supplied anchor, or an anchor was supplied with no readable store), +`missing_event_store` (`events.db` is absent while SQLite sidecars remain — the +store was deleted), and `chain_columns_missing` (the store still declares +generation 2 with its chain columns gone — the lazy downgrade). The composite +action publishes the observed head as a `chain-head` output on every run and +optionally enforces one through an `expect-chain-head` input. + +**Integrity boundary.** The chain is **tamper-evident relative to an anchored +head** — a detection property, not a prevention one, and scoped to the anchor. +It detects splicing, reordering, an edit that does not recompute every downstream +digest, and byte corruption of any hashed field; given an anchor it detects *any* +divergence from the head that anchor names, including tail truncation, which is +otherwise invisible because deleting trailing events leaves a shorter but +internally valid chain. It does **not** detect a full in-workspace recompute, a +chain-column downgrade that also resets `user_version`, deletion of the store when +no sidecars remain and no anchor is supplied, well-formed lies (nothing in the +chain judges whether a payload is true), or anything in a never-migrated prefix. +And the window stays open at the head: "An anchor certifies the log only up to the +anchored head. Everything appended after the last externally-read anchor — +including a rewrite of the suffix — is unverified until the next anchor is read +and remembered outside the workspace. The chain narrows the tampering window; it +does not close it." `scripts/test_adversarial_chain.py` pins both sides — the +attacks that are caught and four `PINNED LIMITATION` cases that are not. The full +boundary, with the anchor's trust assumptions, is normative in +`reference/repo-os-contract.md` §16. + +**Stricter reads.** `status`, `replay`, and `doctor` now **reject** a store +containing any schema-invalid event, raising `invalid_event` instead of silently +folding past it as they did in 0.9.0. Validation runs before the fold, so a tamper +that also violates `event@1` surfaces as `invalid_event` rather than +`event_chain_broken`. + +**Sidecars resolved.** Read verbs no longer leave `-wal`/`-shm` files beside a +clean `.loop/events.db` — the 0.9.0 known limitation recorded below. #80 landed +the first half, opening read-only connections with `immutable=1` when no WAL +sidecar exists; this release completes it with a two-stage retry so that a lost +`immutable=1` race against a live writer retries plainly as `mode=ro` before +anything may be called corruption, the same read path in the runner, and a +zero-carve-out tripwire proving every read verb leaves a clean store +byte-identical on both store generations. + +**Compatibility, both directions.** Pre-0.10.0 *readers* can read a v2 store: +their explicit ten-column `SELECT` is unaffected by the two added columns. +Pre-0.10.0 *writers* must not append to a chained store. A fresh v0.10.0 store +refuses such an append at the database, because `event_hash` is `NOT NULL`; a +*migrated* store keeps its columns nullable, so the append succeeds and produces a +permanent, unrepairable `event_chain_broken` — the row cannot be re-linked +afterwards, since `UPDATE` is trigger-blocked. Pin your `loop-engineer` and action +version per store. + +Test baseline: 1021 passed / 16 skipped with the `yaml`+`schemas` extras; +951 / 86 in structural-fallback mode (PyYAML only). Both measured in a fresh +worktree; a live checkout reads +2 passed / −2 skipped through two +checked-when-present tests. + ## 0.9.0 — 2026-07-17 **The event-sourced kernel.** The contract gains a durable runtime substrate: @@ -71,7 +155,7 @@ least-privilege workflow permissions (#46, #47). `-wal`/`-shm` sidecars next to `.loop/events.db` (read-only connections recreate them and cannot checkpoint on close). Store content is never mutated, but tree-byte-identity checks over a store-backed workspace will -notice them. +notice them. *(Resolved in 0.10.0 — see "Sidecars resolved" above.)* Test baseline: 933 passed / 16 skipped with the `yaml`+`schemas` extras; 864 / 85 in structural-fallback mode (PyYAML only). diff --git a/README.md b/README.md index e58dc4b..42d0960 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![CI](https://github.com/SollanSystems/loop-engineer/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/SollanSystems/loop-engineer/actions/workflows/ci.yml) [![Python 3.10–3.12](https://img.shields.io/badge/python-3.10%E2%80%933.12-blue)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) -[![Release](https://img.shields.io/badge/release-0.9.0-blue)](https://github.com/SollanSystems/loop-engineer/tags) +[![Release](https://img.shields.io/badge/release-0.10.0-blue)](https://github.com/SollanSystems/loop-engineer/tags) Long-running agents commit **false completion**. After context compaction they forget what "done" meant, optimize to the visible test, patch in circles, and @@ -332,7 +332,7 @@ present. Recipe: **CI** — one workflow step validates the contract and publishes a scorecard: ```yaml -- uses: SollanSystems/loop-engineer@v0.9.0 +- uses: SollanSystems/loop-engineer@v0.10.0 with: path: "." ``` @@ -425,8 +425,8 @@ doctor` and the contract's own `verify-*` scripts). ## Status -- Version: `0.9.0` -- Release tag: `v0.9.0` (PyPI publish trigger; plugin tags through 0.6.0 used `loop-engineer--v`) +- Version: `0.10.0` +- Release tag: `v0.10.0` (PyPI publish trigger; plugin tags through 0.6.0 used `loop-engineer--v`) - License: MIT - Primary interface: Claude Code plugin - Portable core: Python CLI + JSON schemas diff --git a/pyproject.toml b/pyproject.toml index 2c3c875..fcbc7a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "loop-engineer" -version = "0.9.0" +version = "0.10.0" description = "Portable Loop Contract Core: validate and inspect repo-native operating contracts for agent loops." readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/test_docs_version.py b/scripts/test_docs_version.py index 101271c..870cec1 100644 --- a/scripts/test_docs_version.py +++ b/scripts/test_docs_version.py @@ -15,9 +15,9 @@ def test_readme_has_no_stale_seven_skills(): assert "all 9 skills" in readme -def test_plugin_version_is_0_9_0(): +def test_plugin_version_is_0_10_0(): plugin = json.loads(_read(".claude-plugin/plugin.json")) - assert plugin["version"] == "0.9.0" + assert plugin["version"] == "0.10.0" def test_pyproject_version_matches_plugin(): @@ -28,8 +28,18 @@ def test_pyproject_version_matches_plugin(): assert match.group(1) == plugin["version"] +def test_readme_version_surfaces_match_plugin_version(): + version = json.loads(_read(".claude-plugin/plugin.json"))["version"] + readme = _read("README.md") + assert f"release-{version}-blue" in readme, "README release badge is stale" + assert f"SollanSystems/loop-engineer@v{version}" in readme, "README action pin is stale" + assert f"- Version: `{version}`" in readme, "README Status version is stale" + assert f"- Release tag: `v{version}`" in readme, "README Status release tag is stale" + + def test_changelog_has_current_and_historical_entries(): changelog = _read("CHANGELOG.md") + assert "## 0.10.0" in changelog assert "## 0.9.0" in changelog assert "## 0.8.0" in changelog assert "## 0.7.0" in changelog