From 10142ed363fa01b2c5e53eca512bd5ddc7d647b4 Mon Sep 17 00:00:00 2001 From: Stu Jordan Date: Tue, 8 Sep 2026 10:06:26 +0700 Subject: [PATCH 1/2] Release BIMRI v5.1.2: defects found by the 7 September execution red team Engine-only, defects-only release. Memory format stays 5.1.0 and the hot grammar stays 5.0.2; no new owner prompt; the tab-separated output of get, recall and search is unchanged. - Refuse U+0085, U+2028 and U+2029 in memory text, rationale, falsifier, question and journal text. The validator accepted them while the parsers split on them, so one proposal could plant a forged confirmed/user entry in the accepted head and push the store into recovery. - A failed full audit at the sync, authority close and resolve boundaries now condemns the audit checkpoint (epoch advance, bytes retained), as doctor already did after the 2 September ruling. Read-only doctor, the quarantine shadow audit and strict restore comparisons keep their exemptions. - The --quiescent attestation is checked before version routing for every existing v5 store; v5.0 and v5.0.1 stores no longer upgrade without it. - --trust contested cannot be authored; it is derived from a conflict record only. Stored records still parse. - Tier 3 proposals no longer write a false drift receipt: the pattern counter advances when the pattern is accepted, inside the write scope. - Orphan candidates are classified by inactivity; the brief prints a count plus the five newest ids above five candidates; hook-start refuses a payload with no session identity (exit 2, [hook-identity-missing], nothing created); hook-close bounds the SessionEnd reason. - get, recall and search accept --json and return the full typed record; tags and falsifiers join the search text. - Installer: the block-merge regex no longer deletes owner text after an orphan START marker; a same-format update receipt reads code-only-update; a command whose stdout pipe closes early exits with its own code. - Engine 5.1.2, authority policy 5.1.2-authority-1 (existing checkpoints re-prove once), preflight receipts accept 5.1.0, 5.1.1 and 5.1.2. - Docs: CHANGELOG 5.1.2 section; REFERENCE, PROTOCOL, MIGRATION and INSTALL updated; MIGRATION's divergent-view paragraph corrected. - Tests: 23 new (18 in tests/test_v512_defects.py, 4 checkpoint boundary tests in test_v511_performance.py, 1 installer test); one existing test updated to pass --quiescent; workflow compile step lists the new module. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/tests.yml | 2 +- AGENTS.md | 2 +- BIMRI-AGENT-BLOCK.md | 2 +- BIMRI-PROTOCOL.md | 147 ++++-- CHANGELOG.md | 205 ++++++-- CLAUDE.md | 2 +- INSTALL.md | 83 ++- MIGRATION.md | 81 ++- REFERENCE.md | 155 ++++-- bimri-engine.py | 404 +++++++++++++-- tests/test_bimri_engine.py | 74 ++- tests/test_v503_release.py | 12 +- tests/test_v510_lifecycle.py | 16 +- tests/test_v511_performance.py | 141 +++++- tests/test_v512_defects.py | 896 +++++++++++++++++++++++++++++++++ 15 files changed, 1965 insertions(+), 257 deletions(-) create mode 100644 tests/test_v512_defects.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 978b6d8..ba19309 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,7 +31,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Compile Python sources - run: python -m py_compile bimri-engine.py tests/test_bimri_engine.py tests/test_v503_release.py tests/test_v510_lifecycle.py tests/test_v511_performance.py tests/crash_worker.py + run: python -m py_compile bimri-engine.py tests/test_bimri_engine.py tests/test_v503_release.py tests/test_v510_lifecycle.py tests/test_v511_performance.py tests/test_v512_defects.py tests/crash_worker.py - name: Run unit tests run: python -m unittest discover -s tests -v - name: Benchmark smoke with canonical gates diff --git a/AGENTS.md b/AGENTS.md index 100301e..83fbf5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -## BIMRI Engine v5.1.1 | Authority Format v5.1.0 | Hot Grammar v5.0.2 +## BIMRI Engine v5.1.2 | Authority Format v5.1.0 | Hot Grammar v5.0.2 This project uses BIMRI portable memory. `bimri.md` is the small, readable current state. Full evidence and history live under `.bimri/`. diff --git a/BIMRI-AGENT-BLOCK.md b/BIMRI-AGENT-BLOCK.md index c64fbd0..d4a6002 100644 --- a/BIMRI-AGENT-BLOCK.md +++ b/BIMRI-AGENT-BLOCK.md @@ -1,4 +1,4 @@ -## BIMRI Engine v5.1.1 | Authority Format v5.1.0 | Hot Grammar v5.0.2 +## BIMRI Engine v5.1.2 | Authority Format v5.1.0 | Hot Grammar v5.0.2 This project uses BIMRI portable memory. `bimri.md` is the small, readable current state. Full evidence and history live under `.bimri/`. diff --git a/BIMRI-PROTOCOL.md b/BIMRI-PROTOCOL.md index e813ea2..a37bd2f 100644 --- a/BIMRI-PROTOCOL.md +++ b/BIMRI-PROTOCOL.md @@ -6,7 +6,7 @@ This document is the normative protocol for a portable, human-governed BIMRI memory folder. `AGENTS.md` is the short runtime adapter. `bimri-engine.py` is the reference implementation. -The reference engine is v5.1.1. The mutable state and new authority-record +The reference engine is v5.1.2. The mutable state and new authority-record format remain v5.1.0, and the readable `bimri.md` line grammar and header remain v5.0.2 because v5.1 changes subject lifecycle and residency rather than the visible line syntax. Frozen v5.0-v5.0.2 artifacts retain their original version @@ -119,25 +119,31 @@ durable records. `index.tsv` is a derived, non-authoritative cache. It MAY be deleted and rebuilt from canonical hot and cold memory, logs, and archives; an index failure MUST NOT alter the outcome of a memory mutation. -Reference-engine note (non-normative): engine v5.1.1 may maintain a compact +Reference-engine note (non-normative): engine v5.1.2 maintains a compact `audit-witness.json` checkpoint and separate `audit-manifest.json` path-and-hash -evidence for a successful full integrity audit. Current-only reads may validate -the checkpoint, accepted head, current state, and one selected cold binding -without traversing historical authority. Authority-changing writes and -explicit audit, review, search, and historical retrieval perform the full -check. The checkpoint is a derived cache: divergence from it is a cache miss -that forces the full semantic audit, never a stored verdict of its own. When -that audit passes over divergence the engine cannot attribute to its own -recorded operation, it first durably records a sealed drift receipt -(bounded to the newest 200, each sealing the diverging paths with prior and -current hashes up to a documented per-section bound, with the complete -delta pinned in a validated attachment when truncated) under -`.bimri/audit-drift/`, then continues; when the audit fails, the store -refuses into damaged-authority recovery exactly as it would without a -checkpoint and invalidates the checkpoint (its bytes remain as the prior -baseline; its epoch binding advances), so later warm reads and starts -re-prove the store and report the recovery condition rather than serving -past it. +evidence for a successful full integrity audit. The checkpoint binds the engine +release and an authority policy version, so an engine update makes the +previous checkpoint a cache miss that the next command re-proves. Current-only +reads may validate the checkpoint, accepted head, current state, and one +selected cold binding without traversing historical authority. +Authority-changing writes (`propose`, `sync`, authority `close`, `resolve`) and +explicit audit, `status`, review, search, historical retrieval, `index`, +`maintain` and `migrate` perform the full check. The checkpoint is a derived +cache: divergence from it is a cache miss that forces the full semantic audit, +never a stored verdict of its own. When that audit passes over divergence the +engine cannot attribute to its own recorded operation, it first durably +records a sealed drift receipt (bounded to the newest 200, each sealing the +diverging paths with prior and current hashes up to a documented per-section +bound, with the complete delta pinned in a validated attachment when +truncated) under `.bimri/audit-drift/`, then continues. When the audit fails +at any of those boundaries, the store refuses into damaged-authority recovery +exactly as it would without a checkpoint and invalidates the checkpoint: at +every boundary except a degraded `propose` its epoch binding advances and its +bytes remain as the prior baseline, while a degraded `propose` deletes the +checkpoint file instead. Read-only `doctor`, the quarantine shadow audit and a +strict restore comparison never invalidate. Either way later warm reads and +starts re-prove the store and report the recovery condition rather than +serving past it. `audit-blocked.json` appears only while an owner-approved quarantine holds its pre-repair baseline; restoration, or a clean doctor pass after it, clears it. The witness-protected roots are flat; an unexpected subdirectory prevents a @@ -190,7 +196,12 @@ new key, unless the versioned event itself is the permanent subject. ``` Each entry occupies exactly one line. New or edited text is single-line UTF-8 -and defaults to a maximum of 500 characters. Migration MAY preserve longer +and defaults to a maximum of 500 characters. Single-line means the value MUST +NOT contain a newline, carriage return, tab, other control character, or the +Unicode line separators U+0085, U+2028 and U+2029; the reference parser splits +the generated view and run logs on those separators, so a value carrying one +would be read back as two entries. The same rule applies to a rationale, +falsifier, conflict question and journal text. Migration MAY preserve longer inherited v1-v4 text without truncation when its complete serialized entry, including metadata, is at most 4,096 characters; Section 14 defines the required overflow behavior. The entire generated view defaults to 49,152 @@ -263,7 +274,9 @@ Tier 1 and Tier 2 claims use these trust values: - `working`: useful provisional memory. - `confirmed`: directly stated or approved by the human, or asserted by a trusted system function. -- `contested`: awaiting a human resolution. +- `contested`: awaiting a human resolution. It is derived from a conflict + record; a proposal MUST NOT be accepted with it, and a stored record that + carries it remains valid. Claims use these source values: @@ -342,8 +355,10 @@ The engine appends: ``` Journal IDs are unique within the run. The log is owned by that run and is -append-only. Journal detail is the durable body behind a small memory -headline. +append-only. Journal text follows the Section 5 single-line rule, including +the Unicode line separators, because a value that parses as two log lines +could forge a `[CLOSED:...]` marker. Journal detail is the durable body behind +a small memory headline. ### 7.3 Propose @@ -387,15 +402,17 @@ unchanged cold subject appear absent or stale. A true same-key generation change still requires `sync` before a new proposal is created. An admitted proposal binds `base_revision` to that current accepted head and -`base_hash` to the exact keyed line hash, or literal `absent`. It MAY also carry -one backward-readable `preflight_receipt` containing the engine release that -created it, accepted-head revision and hash, and observed key hash. The v5.1.1 -engine writes this receipt on every new proposal and stamps v5.1.1; it also -accepts earlier v5.1.0 receipts. Older v5.1.0 engines reject v5.1.1-authored -receipts. When present, the receipt MUST validate against the named immutable -revision before the proposal may create a new concurrent conflict. A -receipt-less legacy proposal cannot create a new concurrent conflict and must -be synced and restaged instead. Proposal records remain immutable. +`base_hash` to the exact keyed line hash, or literal `absent`. It MAY also +carry one backward-readable `preflight_receipt` containing the engine release +that created it, accepted-head revision and hash, and observed key hash. The +v5.1.2 engine writes this receipt on every new proposal and stamps v5.1.2; it +accepts v5.1.0, v5.1.1 and v5.1.2 receipts. An older engine rejects a receipt +stamped by a newer release: v5.1.1 rejects v5.1.2-authored receipts and v5.1.0 +rejects both v5.1.1 and v5.1.2. When present, the receipt MUST validate against +the named immutable revision before the proposal may create a new concurrent +conflict. A receipt-less legacy proposal cannot create a new concurrent +conflict and must be synced and restaged instead. Proposal records remain +immutable. Every v5.1 proposal carries an explicit Boolean `new_subject`. A proposal based on cold-current memory additionally records `base_storage: cold` and binds the @@ -464,7 +481,11 @@ with no new exceptional event SHOULD remain compact. When more than one run is active, an implementation MUST refuse a close that does not identify its target. Closing run A MUST NOT close, stamp, mutate, or classify run B. A stale run MAY be shown as an orphan candidate, but it MUST -NOT be auto-closed. +NOT be auto-closed. The reference engine treats a run as a candidate when its +last recorded activity (`last_activity_at`, maintained by journal, propose and +sync, falling back to `started_at`) is more than 24 hours old, and bounds the +brief line to a count plus the five newest ids once more than five runs +qualify, with `status` carrying the full list. ### 7.5 Owner-Authorized Run Recovery @@ -870,7 +891,10 @@ id key loc trust source status file headline ``` The engine indexes hot memory, keyed cold-current records, journal IDs, and -historical generations. Archived rows MUST retain the stable key. An agent can +historical generations. Archived rows MUST retain the stable key. The index is +an export for external tools: the reference engine's `recall`, `get` and +`search` build their results directly from the accepted head, the cold-current +map, the archive and held decisions and do not consult it. An agent can retrieve an exact subject or discover one from task language without knowing its key: @@ -898,8 +922,8 @@ the index is repaired with: ``` The index MUST NOT be used as authority for commit, conflict, trust, archive, -or recovery decisions. A missing or stale index may reduce retrieval quality -but does not change accepted memory. +or recovery decisions. A missing or stale index does not change accepted +memory, and in the reference engine it changes no retrieval result. ## 12. Validation and Failure Behavior @@ -939,7 +963,7 @@ diverging paths with their prior and current hashes, inline up to the engine's documented per-section bound with any remainder counted explicitly, and a truncated receipt MUST reference a hash-and-size-pinned attachment carrying the complete delta, retained while the receipt is -retained and validated with it — as is every other referenced attachment. +retained and validated with it, as is every other referenced attachment. A receipt that cannot be durably recorded and validated MUST prevent the rebaseline and surface as an error; receipts MUST be seal-validated before their content is trusted or reported; and a sealed witness whose @@ -989,11 +1013,22 @@ then records that external handoff explicitly: bimri-engine.py install --target /absolute/project/path --quiescent ``` +The reference installer checks the attestation before it routes on the stored +version, so it applies to v5.0 and v5.0.1 stores as well as v5.0.2 and v5.1.x, +and a refused install creates nothing in the target. + The installer copies the core files and merges a marked BIMRI block into -existing `AGENTS.md` and `CLAUDE.md`. Fresh and legacy targets initialize or -migrate memory, rebuild the index, and run the self-check. An existing v5 target -uses the non-mutating/lossless update path below and does not rebuild memory or -the index. Installation SHOULD complete without asking setup questions. +existing `AGENTS.md` and `CLAUDE.md`. The merge replaces only the text between +a start marker and the first end marker that follows it with no other start +marker between, so an orphan start marker in owner prose is left in place. +Fresh and legacy targets initialize or migrate memory, rebuild the index, and +run the self-check. An existing v5.0.2 or v5.1.0 target uses the +non-mutating/lossless update path below and does not rebuild memory or the +index. A v5.0 or v5.0.1 target upgrades its state under Sections 14.3 and 14.4 +and then runs the lifecycle repair path: it synchronizes the generated view +through the manual-edit recovery rule, rebuilds the index and runs +repair-capable validation. Installation SHOULD complete without asking setup +questions. Before its first target mutation, the reference installer MUST re-launch itself through its resolved absolute `sys.executable` with a fresh private sentinel, enforce a bounded timeout, and validate the exact non-empty response. @@ -1044,15 +1079,19 @@ records use v5.1.0 while the generated hot-memory grammar remains v5.0.2. The committed state version is the writer fence: v5.0.3 MUST reject it before mutation. -The update receipt mode is `lossless-authority-activation`. It MUST bind the -exact `state-v5.0.2-exact.json` backup, before/after state hashes, the +For a v5.0.2 to v5.1.0 activation the update receipt mode is +`lossless-authority-activation`. It MUST bind the exact +`state-v5.0.2-exact.json` backup, before/after state hashes, the v5.0.2-to-v5.1.0 version transition, and passed hot/head/immutable-evidence -preservation results. Prepared, terminal, and rollback-incomplete receipts are -validated against the engine/version contract that created each receipt, so a -valid interrupted or completed public v5.0.3 update cannot block recovery by a -v5.1 installer. - -For an existing v5 target, `--quiescent` is mandatory and every installer +preservation results. For a same-format update, a v5.1.0 store taking a newer +engine, the mode is `code-only-update`, and terminal validation of that receipt +MUST require the protected tree and state digests to be unchanged. Prepared, +terminal, and rollback-incomplete receipts are validated against the +engine/version contract that created each receipt, so a valid interrupted or +completed public v5.0.3 update cannot block recovery by a v5.1 installer. + +For every existing v5 target, including v5.0 and v5.0.1, `--quiescent` is +mandatory, MUST be checked before version routing, and every installer mutation MUST be serialized by the same engine lock used by runtime commands. The flag records the caller's external handoff; the lock cannot fence an already-loaded old process. Before upgrading v1-v4, every old writer and @@ -1088,8 +1127,14 @@ closed, and run `doctor`. Shared `.claude/settings.json` MUST NOT contain the machine-specific absolute interpreter. A hook session ID maps to one run, so the close hook closes only its own -session. A close hook for an unmapped session MUST be a successful no-op and -MUST NOT close a singleton run by inference. Explicit `close` remains strict. +session. A start hook whose payload carries no stable session identity MUST +NOT open a run under an invented identity; the reference engine exits 2 with a +message ending in `[hook-identity-missing]` and creates nothing, and the +session opens without a brief because the harness does not block on that exit +code. A close hook for an unmapped session MUST be a successful no-op and +MUST NOT close a singleton run by inference, and the harness-supplied end +reason MUST be flattened and bounded before it is recorded so that it cannot +fail the close. Explicit `close` remains strict. Hooks are adapters; they do not change the memory format. The Claude adapter is optional. Any local agent that follows the universal instructions and uses the engine MAY share the same memory within the lock-domain boundary. diff --git a/CHANGELOG.md b/CHANGELOG.md index d6f8703..8866537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,144 @@ This file records the public BIMRI architecture history. Historical instruction files are preserved under [`legacy/`](legacy/) and are not current installers. +## 5.1.2 + +- Kept this release engine-only and defects-only. The authority and + mutable-state formats remain v5.1.0, and the readable hot-memory grammar + remains v5.0.2. No memory file changes shape, no new owner prompt is added, + and the tab-separated output format of `get`, `recall` and `search` is + unchanged. +- Proposal preflight receipts now stamp engine release v5.1.2. This engine + accepts receipts written by v5.1.0, v5.1.1 and v5.1.2, but an older v5.1.1 + engine rejects a store after its first v5.1.2 proposal is staged. Upgrades + therefore require a quiescent, complete copy of `bimri.md` and `.bimri/` + outside the project; that copy is the rollback boundary. +- Advanced the authority policy version bound into the audit checkpoint to + `5.1.2-authority-1`. A checkpoint written by v5.1.1 is a cache miss under + the new policy, so the first command after the update re-proves the store + with one full audit, about 35 seconds on a 700-run store, and then publishes + a fresh checkpoint. `INSTALL.md` step 7 now asks for one normal `doctor` run + from the updated target before any session starts, so that audit is paid + there rather than inside the first hook-start's timeout. +- Memory text, rationale, falsifier, conflict question and journal text may + no longer contain U+0085, U+2028 or U+2029. The text validator accepted + these Unicode line separators while the hot-memory and run-log parsers split + lines on them, so one agent proposal could carry a second, forged entry + line, including a forged `[T:confirmed] [SRC:user]` entry, into the accepted + head; the next full audit then found a decision naming a revision that did + not contain its recorded effect, and the store entered + `AUTHORITY RECOVERY NEEDED` with reads and writes blocked (reproduced + 2026-09-07). The same characters in journal text could make `close` skip + writing its `[OUTCOME:...]` and `[CLOSED:...]` markers. The validator now + refuses any value that would parse as more than one line. Behavior change + for an existing store: a stored value that carries one of these characters + is invalid at its next full audit and needs owner repair through the + quarantine and restore lane. No known store contains one. +- A failed full audit at the `sync`, authority `close` and `resolve` + boundaries now invalidates the audit checkpoint by advancing the state's + audit epoch, with the checkpoint bytes retained as the prior baseline, + exactly as `doctor` and the other full-audit boundaries (`status`, + task-language and historical recall, `search`, `review`, `index`, `maintain` + and `migrate`) already did after the 2026-09-02 ruling. In v5.1.1 those + three boundaries wrote the drift receipt and refused without invalidating, + so the next `start` printed a clean brief and exact reads kept serving a + store whose writes were paused, and a later proposal-free lifecycle `close` + republished the checkpoint over the proven damage. A degraded `propose` + still invalidates by deleting the checkpoint file; that is the second + mechanism and is unchanged. Read-only `doctor`, the quarantine shadow audit, + blocked-quarantine and prior-evidence-invalid issues and a strict restore + comparison keep their exemptions. Behavior change: a refused `sync`, `close` + or `resolve` performs one extra state save, the next `start` prints + `AUTHORITY RECOVERY NEEDED`, exact reads refuse until the store is repaired, + and a `hook-close` at session end exits 2 on a store the engine has already + proved damaged, as in v5.1.0. +- The `--quiescent` attestation is now checked before version routing for + every existing v5 store. In v5.1.1 a v5.0 or v5.0.1 store upgraded to v5.1.0 + without it and ignored the flag when it was passed, which contradicted the + protocol and meant the same store was refused without the flag one command + later, once it was at v5.1.0. Behavior change: an install into a v5.0 or + v5.0.1 store without `--quiescent` exits 2 with the same handoff message the + v5.0.2 and v5.1.0 routes already printed, and creates nothing in the target. +- `--trust contested` can no longer be authored from the CLI or admitted at + proposal preflight. The engine never wrote contested trust itself (only a + human-approved resolution writes `confirmed`), so an agent could render a + `[T:contested]` line that `review` had nothing to resolve. Contested trust + is derived from a conflict record only. Stored records that carry it still + parse and validate, so no existing store needs repair. +- Tier 3 proposals no longer write a false drift receipt. `propose` advanced + the pattern counter in `state.json` outside its declared write scope, so + every Tier 3 proposal left a state change the next audit could not attribute + to a recorded operation and receipted as unexplained drift. The counter now + advances when the pattern is accepted, inside the `sync` or `close` scope, + and pattern IDs remain monotonic. A receipt v5.1.1 already wrote stays on + disk as retained evidence and needs no action. +- Orphan candidates in the start brief are classified by inactivity rather + than start time. A run is a candidate when its last recorded activity, the + `last_activity_at` that `journal`, `propose` and `sync` maintain, falling + back to `started_at`, is more than 24 hours old; v5.1.1 labeled a run an + orphan 24 hours after it started even when it had journaled a second + earlier. The brief prints every id while there are five or fewer + candidates; above that it prints the count plus the five newest ids and + points to `status` for the full list (134 ids cost 500 to 800 tokens on + every start of the development store). This is classification only: nothing + is closed, stamped or mutated, and `recover-run` still needs the owner's + word per run. +- `hook-start` refuses a payload that carries no `session_id` or + `transcript_path` (empty, invalid, non-object or id-less stdin) with exit 2 + and a message ending in `[hook-identity-missing]`, and creates nothing. + v5.1.1 opened a run under an invented identity that the matching + `hook-close` could never close, so every such session left an orphan. + Claude Code does not block a session on a `SessionStart` hook exit code: + the session opens without a brief, the same state as after a lock-busy + start, and the explicit `start --actor` in `AGENTS.md` covers it. + `hook-close` for an unknown or unmapped session remains a successful no-op. + `hook-close` also flattens the `SessionEnd` reason it records (control + characters become spaces, whitespace collapses) and bounds it to 200 + characters, so a long or multi-line reason can no longer make the close + exit 2 and leave the run open. +- `get`, `recall` and `search` accept `--json` and return the full typed + record. The tab-separated output prints seven columns (location, key, id, + reason, trust, source, text), so a pattern lost its confidence, observation + count, evidence ids and falsifier, a Tier 1 or Tier 2 row lost its tier, + kind or importance and status, first and last run, tags and pointer, and + two pattern generations printed byte-identical in `--history`. The JSON + payload is an object with `matches`, `total` and `omitted`; each match + carries the seven fields above (the entry text under `text`) plus `tier`, + then `kind` for Tier 1, `importance`, `status`, `first_run` and `last_run` + for Tier 2, `confidence`, `observations`, `evidence` and `falsifier` for + Tier 3, and `tags` and `pointer` for Tiers 1 and 2; a held candidate also + reports its `operation`. The tab-separated output is unchanged. Tags and + falsifiers are now part of the search text on both output paths, which adds + matches and removes none. Exit codes are unchanged: 0 matched, 1 no match, + 2 error. +- The installer no longer deletes owner text after an orphan + `` marker. The block-merge pattern matched from the + first start marker to the real block's end marker, so a stray marker in + owner prose made the next install delete everything between them (the + install backup still held the text). The merge now pairs a start marker + only with the first end marker that follows it with no other start marker + between, so an orphan marker and the prose after it stay in place. +- The update receipt's `mode` reads `code-only-update` for a same-format + update, a v5.1.0 store taking a newer engine. v5.1.1 wrote + `lossless-authority-activation` for every existing-store update even when + no state was activated, and because terminal receipt validation enforces + the unchanged tree and state digests only for `code-only-update` receipts, + the mislabel loosened later validation of those receipts. A v5.0.2 to + v5.1.0 activation keeps `lossless-authority-activation`. +- A command whose stdout pipe closes early now exits with its own exit code. + On Windows, `start --actor x | head -1` raised at the final stream flush + after the command had completed and exited 120. +- Stated documentation corrections with no engine change. A divergent + `bimri.md` does not stop the v5.1 update: the update completes as + `installed-recovery-required` with the file untouched, and the next `start` + heals the view as a manual-edit conflict, while damaged core state or a + head hash mismatch stops the update before any write (`MIGRATION.md` said + the opposite). The v5.0 and v5.0.1 upgrade route runs the lifecycle repair + path and may heal the view and rebuild the index, unlike the v5.0.2 and + v5.1.0 update path. `.bimri/index.tsv` is a derived export that `recall`, + `get` and `search` do not read. `REFERENCE.md` now names both checkpoint + invalidation mechanisms. + ## 5.1.1 - Kept the performance work as an engine-only patch. The authority and @@ -21,40 +159,37 @@ files are preserved under [`legacy/`](legacy/) and are not current installers. authority-changing writes and explicit audit, review, search, and historical-recall boundaries, so an out-of-engine edit to unrelated history is seen at the next such boundary rather than on every read. -- The checkpoint is a derived cache, never an authority record. Divergence - from it forces the full semantic audit; when that audit passes over changes - the engine cannot attribute to its own recorded operation, the engine - first durably records a sealed drift receipt under `.bimri/audit-drift/` - — the diverging paths with prior and current hashes (inline up to 2,000 - entries per section with any remainder counted, the complete delta - pinned in a hash-and-size-validated attachment when truncated), - unbounded monotonic sequence, bounded to the newest 200 — and only then - publishes the new baseline. The written receipt is re-read and fully - validated (seal, filename binding, attachments, post-prune existence) - before the write counts as success, and deduplication only ever reuses a - receipt that validates. A receipt that cannot be recorded keeps the - prior checkpoint and surfaces as an error; `doctor` validates every - receipt and its attachments and reports damaged evidence instead of - trusting it; attachments cited by retained receipts outlive the - unreferenced-attachment bound; and a checkpoint whose referenced - manifest evidence is missing refuses rebaselining instead of adopting - new bytes with no recorded delta. A failed semantic audit - refuses into the existing damaged-authority recovery lane and - invalidates the checkpoint by advancing the state's audit epoch - (owner-ruled 2026-09-02), so the next `start` prints - `AUTHORITY RECOVERY NEEDED` and exact reads refuse until the store is - repaired, as in v5.1.0. The checkpoint bytes stay on disk as the prior - baseline for receipts, quarantine and restore, the same +- The checkpoint is a derived cache, never an authority record. Divergence from + it forces the full semantic audit; when that audit passes over changes the + engine cannot attribute to its own recorded operation, the engine first + durably records a sealed drift receipt under `.bimri/audit-drift/`, carrying + the diverging paths with prior and current hashes (inline up to 2,000 entries + per section with any remainder counted, the complete delta pinned in a + hash-and-size-validated attachment when truncated), with an unbounded + monotonic sequence number and retention bounded to the newest 200, and only + then publishes the new baseline. The written receipt is re-read and fully + validated (seal, filename binding, attachments, post-prune existence) before + the write counts as success, and deduplication only ever reuses a receipt + that validates. A receipt that cannot be recorded keeps the prior checkpoint + and surfaces as an error; `doctor` validates every receipt and its + attachments and reports damaged evidence instead of trusting it; attachments + cited by retained receipts outlive the unreferenced-attachment bound; and a + checkpoint whose referenced manifest evidence is missing refuses rebaselining + instead of adopting new bytes with no recorded delta. A failed semantic audit + refuses into the existing damaged-authority recovery lane and invalidates the + checkpoint by advancing the state's audit epoch (owner-ruled 2026-09-02), so + the next `start` prints `AUTHORITY RECOVERY NEEDED` and exact reads refuse + until the store is repaired, as in v5.1.0. The checkpoint bytes stay on disk + as the prior baseline for receipts, quarantine and restore, the same retained-but-invalid shape a failed resolution leaves; a blocked receipt - sink, an open quarantine and a strict restore comparison keep their - prior checkpoint readable. Interrupted - operations self-heal exactly as in v5.1.0; no drift or crash state ever - requires hand deletion of derived files. `audit-blocked.json` now appears - only while an owner-approved quarantine holds its pre-repair baseline, and - restoration clears it. Owner-ruled 2026-08-27: this receipts contract - replaces the earlier normative fail-closed rule in the protocol, and the - current-only exact recall plus deferred warm-read verification below are - confirmed semantics. + sink, an open quarantine and a strict restore comparison keep their prior + checkpoint readable. Interrupted operations self-heal exactly as in v5.1.0; + no drift or crash state ever requires hand deletion of derived files. + `audit-blocked.json` now appears only while an owner-approved quarantine + holds its pre-repair baseline, and restoration clears it. Owner-ruled + 2026-08-27: this receipts contract replaces the earlier normative fail-closed + rule in the protocol, and the current-only exact recall plus deferred + warm-read verification below are confirmed semantics. - An obstruction on the audit-transition marker path that is not a regular file or symlink is a hard error on every surface; doctor never reports health past it, and the checkpoint is left untouched until the owner @@ -72,8 +207,8 @@ files are preserved under [`legacy/`](legacy/) and are not current installers. entry-point target and is recorded as a known, owner-accepted deviation (2026-08-27); incremental authenticated manifests are the planned v5.2 fix. Reads and lifecycle bookkeeping are unaffected. -- Unknown files inside witnessed roots — crash-orphaned engine temp files - included — are never deleted or blocked on. They enter the audited +- Unknown files inside witnessed roots, crash-orphaned engine temp files + included, are never deleted or blocked on. They enter the audited inventory, cost at most one full re-audit when they first appear, and stay visible through drift receipts and doctor litter reporting. The engine cannot prove a temp-named file is its own, so it preserves it. diff --git a/CLAUDE.md b/CLAUDE.md index 2ab28bf..720f2bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Claude Code -Use BIMRI engine v5.1.1 and authority format v5.1.0. The readable hot-memory +Use BIMRI engine v5.1.2 and authority format v5.1.0. The readable hot-memory grammar remains v5.0.2. `BIMRI-PROTOCOL.md` is the normative memory protocol. diff --git a/INSTALL.md b/INSTALL.md index 8c7e049..0d83c28 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -70,11 +70,12 @@ two paths to its ignore rules explicitly. artifact somewhere outside the project. For a v5 target, that snapshot MUST include `bimri.md` plus the entire `.bimri/` tree. The lock serializes commands; it cannot fence an already-loaded old process waiting to write - after the installer exits. For v5.1.0 to v5.1.1, that complete external - snapshot is the only rollback after the first v5.1.1 proposal is staged: an - older v5.1.0 engine rejects the newer proposal receipt. A store the new - engine has only started and closed, with no v5.1.1 proposal, remains readable - by v5.1.0. + after the installer exits. For a v5.1.0 or v5.1.1 store taking v5.1.2, that + complete external snapshot is the only rollback after the first v5.1.2 + proposal is staged: v5.1.2 accepts receipts stamped v5.1.0, v5.1.1 and + v5.1.2, but an older engine rejects the newer proposal receipt. A store the + new engine has only started and closed, with no v5.1.2 proposal, remains + readable by the older engine. 3. If Claude Cowork Global Instructions contain a v1-v3 BIMRI block, ask the owner to disable or remove it before installation. Those instructions directly rewrite the old hot-memory file and must not run alongside v5. @@ -92,10 +93,19 @@ two paths to its ignore rules explicitly. bimri-engine.py install --target /absolute/path/to/the/project --quiescent ``` + The installer checks that attestation before it routes on the stored + version, so it applies to v5.0 and v5.0.1 stores as well as v5.0.2 and + v5.1.x. Without the flag the install exits 2 and creates nothing in the + target. + 5. Do not replace existing `AGENTS.md` or `CLAUDE.md`. The installer merges a - marked BIMRI block and backs up files it upgrades. It packages `legacy/` as - inert rollback material and copies BIMRI's MIT notice to `BIMRI-LICENSE`; - neither action replaces the target project's root `LICENSE`. + marked BIMRI block and backs up files it upgrades. The merge replaces only + the text between a `` marker and the first + `` that follows it with no other start marker between; + an orphan start marker in owner prose, and the prose after it, stay in + place. It packages `legacy/` as inert rollback material and copies BIMRI's + MIT notice to `BIMRI-LICENSE`; neither action replaces the target project's + root `LICENSE`. 6. Existing BIMRI v1-v4 memory migrates automatically and idempotently. Migration preserves exact source and backup bytes, records their hashes, and stops without overwriting ambiguous or malformed input. Inherited claim @@ -103,17 +113,27 @@ two paths to its ignore rules explicitly. v5 metadata, fits the 4,096-character safety ceiling. Conversion fails closed rather than truncating anything that cannot fit losslessly. New and edited claims retain the 500-character text limit. - Existing v5.0 and v5.0.1 state also upgrades automatically with - a byte-preserving backup. A v5.0 upgrade adopts the 12,000-token capacity - profile when its limits are still stock; a v5.0.1 upgrade preserves its - configured limits. + Existing v5.0 and v5.0.1 state upgrades during the same `--quiescent` + install with a byte-preserving backup. A v5.0 upgrade adopts the + 12,000-token capacity profile when its limits are still stock; a v5.0.1 + upgrade preserves its configured limits. After the state upgrade this route + runs the lifecycle repair path: it synchronizes the generated view (a + divergent `bimri.md` passes through the manual-edit recovery path and is + healed), rebuilds the index and runs repair-capable validation. An existing v5.0.2 store takes the lossless v5.1 authority-activation path documented below. Root `bimri.md` and every immutable evidence/history artifact remain byte-identical. The mutable state is backed up exactly and transactionally advanced to v5.1.0 so an old engine fails closed. 7. Run ` bimri-engine.py doctor --read-only` from the updated target when you need to repeat the non-mutating audit. Use normal - `doctor` only when repair-capable validation is intended. + `doctor` only when repair-capable validation is intended, with one + exception: after an engine update of a v5.1.x store, run normal `doctor` + once from the updated target before any agent session. The new engine + binds its audit checkpoint to authority policy version `5.1.2-authority-1`, + so the checkpoint the previous engine wrote is a cache miss; that first + `doctor` re-proves the store with one full audit, about 35 seconds on a + 700-run store, and seeds the new checkpoint so the first `hook-start` does + not pay for it inside its 90-second timeout. 8. Confirm the installer wrote both local binding records above; never commit them. 9. Report only: @@ -211,17 +231,28 @@ and then runs the new read-only audit. Success is reported only with an explicit receipt equivalent to: ```text -BIMRI 5.1.1 installed. +BIMRI 5.1.2 installed. Existing v5.0.2 hot memory preserved; authority state activated at v5.1.0. Accepted head unchanged: V...... . Memory preservation: PASSED (bimri.md and immutable evidence unchanged). ``` -If state or accepted-head authority is invalid, package replacement does not -begin. Sound state/head with damaged governance may receive the recovery tools -in explicit `installed-recovery-required` mode. The updater never repairs, -rebuilds, reindexes, truncates, or rewrites memory content as part of -installation or rollback. +A same-format update, a v5.1.0 store taking a newer engine, follows the same +transaction; its second line reads `Existing authority store v5.1.0 verified.` +and the receipt under `.bimri-update-backups//` records its `mode` +as `code-only-update`, while a v5.0.2 activation records +`lossless-authority-activation`. + +If state or accepted-head authority is invalid (unreadable or wrong-version +state, a missing or mismatched accepted head), package replacement does not +begin. Sound state/head with damaged governance records, damaged recovery +evidence, or a divergent `bimri.md` receives the recovery tools in explicit +`installed-recovery-required` mode: the update completes, the divergent bytes +stay untouched, the receipt lists the read-only audit errors, and the next +`start` heals the view as a manual-edit conflict. On this path the updater +never repairs, rebuilds, reindexes, truncates, or rewrites memory content as +part of installation or rollback; only the v5.0 and v5.0.1 route in step 6 +runs the lifecycle repair path. ## Claude Code Hooks @@ -242,9 +273,17 @@ close; then run `doctor`. A zero-output hook is a failed hook even if its process reports success. Confirm that the synthetic run is closed before reporting the installation complete. -An automatic `hook-close` for an unknown or already-unmapped session is a -successful no-op. The explicit `close` command remains strict so operator -mistakes are still visible. +`hook-start` opens a run only when Claude's payload carries a `session_id` or +`transcript_path`. A payload with neither, including empty, invalid or +non-object stdin, is refused with exit 2 and a message ending in +`[hook-identity-missing]`, and nothing is created; a run opened under an +invented identity could never be closed by its `SessionEnd` hook. Claude Code +does not block a session on a `SessionStart` hook exit code, so such a session +opens without a brief and the explicit `start --actor` command in `AGENTS.md` +still applies. An automatic `hook-close` for an unknown or already-unmapped +session is a successful no-op, and the `SessionEnd` reason it records is +flattened to one line and bounded so it cannot fail the close. The explicit +`close` command remains strict so operator mistakes are still visible. The root `hooks-example.json` remains a portable placeholder template. Never paste its unresolved placeholder into Claude settings; use only the local diff --git a/MIGRATION.md b/MIGRATION.md index a3ca755..eb52912 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,18 +1,19 @@ # BIMRI Migration and the v5.1.0 Lifecycle Upgrade -Engine v5.1.1 uses authority format v5.1.0 while retaining the readable v5.0.2 -hot-memory grammar. The upgrade from v5.1.0 is one-way: v5.1.1 stamps its -own engine release into proposal preflight receipts, and every proposal — -pending or decided — remains an immutable authority record that a v5.1.0 -engine keeps validating. Once any v5.1.1 proposal has been staged, the -v5.1.0 engine's `status`, `doctor`, `recall` and `sync` refuse the store +Engine v5.1.2 uses authority format v5.1.0 while retaining the readable v5.0.2 +hot-memory grammar. The upgrade from v5.1.0 or v5.1.1 is one-way: v5.1.2 +stamps its own engine release into proposal preflight receipts, and every +proposal, pending or decided, remains an immutable authority record that the +older engine keeps validating. Once any v5.1.2 proposal has been staged, the +older engine's `status`, `doctor`, `recall` and `sync` refuse the store permanently, and its `start` opens only a degraded run behind the `AUTHORITY RECOVERY NEEDED` banner (writing its run log and state entry, as the protocol's degraded-run lane allows). Safe rollback exists only by -restoring the complete pre-update backup taken before the first v5.1.1 -proposal. A store the v5.1.1 engine has only started and closed, with no -proposal, remains fully readable by v5.1.0, which ignores the derived -`audit-*` files. The +restoring the complete pre-update backup taken before the first v5.1.2 +proposal. A store the v5.1.2 engine has only started and closed, with no +proposal, remains fully readable by v5.1.1, which treats the newer checkpoint +as a cache miss and re-proves the store, and by v5.1.0, which ignores the +derived `audit-*` files. The engine automatically migrates explicitly versioned v1-v3 tiered Markdown and the engine-based v4 format. This canonical repository publicly distributed the original v1 and streamlined v3 instructions; the parser also @@ -39,8 +40,8 @@ Make the project quiescent across every runtime boundary: 2. wait for every BIMRI command to finish; 3. pause any synchronization or copy operation affecting the folder; 4. take a complete copy of the whole project folder (`bimri.md` plus the - entire `.bimri/` tree) somewhere outside it — this snapshot is the only - rollback that exists, because the first v5.1.1 proposal is an + entire `.bimri/` tree) somewhere outside it; this snapshot is the only + rollback that exists, because the first v5.1.2 proposal is an intentional one-way boundary that no older engine can read past; 5. if v1-v3 was installed in Claude Cowork Global Instructions, disable or remove that BIMRI block; and @@ -269,10 +270,19 @@ idempotently from the prepared receipt. Before success, the updater proves that root `bimri.md`, the accepted head and hash, and every immutable/unknown pre-existing path remain byte-identical. It runs the installed engine's read-only doctor against the activated state and -records both the old-state backup and the preservation digests. A divergent -`bimri.md` or damaged core authority stops activation without healing or -rewriting it. See [`INSTALL.md`](INSTALL.md) for the exact transaction and -receipt contract. +records both the old-state backup and the preservation digests. Damaged core +authority (an unreadable or wrong-version state, a missing or mismatched +accepted head, invalid head grammar or pointers) stops the update before any +package or state write. A divergent `bimri.md`, damaged governance records, or +damaged recovery evidence do not stop activation: the updater leaves those +bytes untouched, activates the v5.1 state, and finishes in +`installed-recovery-required` mode with the read-only audit errors listed in +the receipt, so that the repair tools are installed; the next `start` then +heals the divergent view as a manual-edit conflict. The receipt records its +`mode` as `lossless-authority-activation` for this activation and as +`code-only-update` for a same-format update of a v5.1.0 store to a newer +engine. See [`INSTALL.md`](INSTALL.md) for the exact transaction and receipt +contract. The old-process shutdown is a real precondition. The file lock cannot safely upgrade an already-loaded old process that resumes after installation. Once @@ -281,13 +291,19 @@ the unsupported state version before mutation. ### v5.0 and v5.0.1 to v5.1.0 authority -Existing v5.0 and v5.0.1 states upgrade automatically under the engine lock. -Before changing state, the engine validates the complete source state and -accepted head and preserves an exact content-addressed state backup under -`.bimri/backups/`. Accepted historical revisions and existing governance -records are never rewritten. Historical v5.0 and v5.0.1 resolutions retain -their original effect semantics. The readable hot grammar is normalized only -through the existing v5.0.2-compatible metadata path. +Existing v5.0 and v5.0.1 states upgrade under the engine lock during a +`--quiescent` install; the attestation is checked before version routing, and +an install without it exits before creating anything in the target. Before +changing state, the engine validates the complete source state and accepted +head and preserves an exact content-addressed state backup under +`.bimri/backups/`. After the state upgrade this route runs the lifecycle repair +path: it synchronizes the generated view, so a divergent `bimri.md` is healed +through the manual-edit recovery path during installation rather than at the +next `start`, rebuilds the index and runs repair-capable validation. Accepted +historical revisions and existing governance records are never rewritten. +Historical v5.0 and v5.0.1 resolutions retain their original effect semantics. +The readable hot grammar is normalized only through the existing +v5.0.2-compatible metadata path. For v5.0, if the complete stored profile exactly matches the original defaults, the upgrade adopts values of 20 for Tier 1, 40 for Tier 2, 12 for Tier 3, a @@ -314,6 +330,25 @@ old and active profiles, state-backup path, and any metadata-only revision. A full semantic validation runs before `migrate` reports `Validation: PASSED`. State/head/metadata preflight failures stop before upgrade authority changes. +### v5.1.0 and v5.1.1 to engine v5.1.2 + +A v5.1.0 or v5.1.1 store takes the v5.1.2 engine through the same `--quiescent` +code-only update. The memory format stays v5.1.0, so `bimri.md`, `state.json` +and every protected path remain byte-identical and the receipt's `mode` is +`code-only-update`. The first command after the update re-proves the store +under authority policy version `5.1.2-authority-1` with one full audit, about +35 seconds on a 700-run store; run `doctor` once before any session so that +audit seeds the checkpoint outside a hook timeout. + +Two validation changes in v5.1.2 can touch an existing store. A stored value +that contains U+0085, U+2028 or U+2029 in a field the engine validates (memory +text, rationale, falsifier, conflict question, or the journal of an active +run) is invalid at the next full audit and needs owner repair through the +quarantine and restore lane in the next section; no known store carries one. +A stored `contested` trust value still parses and needs nothing, although it +can no longer be authored. A drift receipt that a v5.1.1 Tier 3 proposal wrote +stays on disk as retained evidence and needs nothing. + ### Authority Damage Found During Upgrade Damage to a proposal, decision, conflict, or resolution does not authorize the diff --git a/REFERENCE.md b/REFERENCE.md index 873cf25..8f58d54 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -15,7 +15,7 @@ problems go to [GitHub Issues](https://github.com/EvolutionUnleashed/bimri/issue | Migrate an older BIMRI store | [`MIGRATION.md`](MIGRATION.md) | | Review releases and architecture changes | [`CHANGELOG.md`](CHANGELOG.md) | -The current engine is v5.1.1, the authority format is v5.1.0, and the readable +The current engine is v5.1.2, the authority format is v5.1.0, and the readable hot-memory grammar remains v5.0.2. ## Supported AI Agent Runtimes @@ -107,11 +107,12 @@ For a fresh target or v1-v4 migration, the installer command is: ``` Before any upgrade, make the target quiescent and create a verified, complete -copy of the whole project folder somewhere outside it. For v5.1.0 to v5.1.1, -that complete snapshot is the only rollback after the first v5.1.1 proposal is -staged: an older v5.1.0 engine rejects the newer proposal receipt. A store the -new engine has only started and closed, with no v5.1.1 proposal, remains -readable by v5.1.0. +copy of the whole project folder somewhere outside it. For a v5.1.0 or v5.1.1 +store taking v5.1.2, that complete snapshot is the only rollback after the +first v5.1.2 proposal is staged: v5.1.2 accepts receipts stamped v5.1.0, +v5.1.1 and v5.1.2, but an older engine rejects the newer proposal receipt. A +store the new engine has only started and closed, with no v5.1.2 proposal, +remains readable by the older engine. For any existing v5 store, stop every old engine process first and attest that external handoff explicitly: @@ -120,15 +121,24 @@ external handoff explicitly: bimri-engine.py install --target /absolute/path/to/the/project --quiescent ``` +The installer checks that attestation before it routes on the stored version, +so it applies to v5.0 and v5.0.1 stores as well as v5.0.2 and v5.1.x. An +install into any existing v5 store without `--quiescent` exits 2 and creates +nothing in the target. + Existing BIMRI v1-v4 memory migrates automatically during installation. The installer prints a migration receipt with the detected source version and file, imported counts, converted patterns, backup location, and validation result. See [`MIGRATION.md`](MIGRATION.md) for preservation and rollback details. -Existing v5.0 and v5.0.1 states also upgrade automatically. A stock v5.0 limit -profile expands to the v5.0.1 profile; custom values remain custom and become -soft curation targets. Earlier state bytes are backed up, and accepted -revisions are preserved rather than rewritten. +Existing v5.0 and v5.0.1 states upgrade during the same `--quiescent` install. +A stock v5.0 limit profile expands to the v5.0.1 profile; custom values remain +custom and become soft curation targets. Earlier state bytes are backed up, and +accepted revisions are preserved rather than rewritten. After the state +upgrade this route runs the lifecycle repair path: it synchronizes the +generated view (a divergent `bimri.md` passes through the manual-edit recovery +path and is healed), rebuilds the index and runs repair-capable validation. +The v5.0.2 and v5.1.0 update path below does none of that. Updating an existing v5.0.2 store to the v5.1 authority format uses a dedicated lossless authority-activation operation. Stop every process running the old @@ -185,6 +195,18 @@ back into the hot working set. v5.1.x uses deterministic exact-key and lexical task-language retrieval; it does not require embeddings or claim semantic vector search. +Each command prints one tab-separated row per match with seven columns: +location, key, id, reason, trust, source and text. That output is unchanged +in v5.1.2. Add `--json` to `get`, `recall` or `search` to receive the full +typed record instead: an object with `matches`, `total` and `omitted`, where +each match carries the seven fields above (the entry text under `text`) plus +`tier`, then `kind` for Tier 1, `importance`, `status`, `first_run` and +`last_run` for Tier 2, `confidence`, `observations`, `evidence` and +`falsifier` for Tier 3, and `tags` and `pointer` for Tiers 1 and 2. A held +candidate also reports its `operation`. Task-language matching covers the +key, id, text, reason, location, trust, source, tags and falsifier. Exit codes +are 0 for a match, 1 for no match and 2 for an error. + The agent journals durable detail as work happens: ```text @@ -236,12 +258,25 @@ Proposals are applied by `sync` or `close`: Every agent closes only its own explicit handle. When several runs are active, a handle-free close is refused. -The optional Claude `hook-close` adapter also closes only its mapped session. -If Claude sends `SessionEnd` without a corresponding active mapping, the hook -returns a successful no-op and never guesses another run to close. - -An orphaned run is never reaped automatically. After the owner explicitly -confirms that it should close, an agent can recover it with: +The optional Claude `hook-start` adapter opens a run only when Claude's payload +carries a `session_id` or `transcript_path`. A payload with neither, including +empty, invalid or non-object stdin, is refused with exit 2 and a message ending +in `[hook-identity-missing]`; nothing is created, and because Claude Code does +not block a session on a `SessionStart` hook exit code the session opens +without a brief, where the explicit `start --actor` command still works. The +`hook-close` adapter closes only its mapped session. If Claude sends +`SessionEnd` without a corresponding active mapping, the hook returns a +successful no-op and never guesses another run to close; the reason Claude +supplies is flattened to one line and bounded before it is recorded, so it +cannot fail the close. + +An orphaned run is never reaped automatically. The start brief lists orphan +candidates by inactivity: a run whose last recorded activity +(`last_activity_at`, which `journal`, `propose` and `sync` maintain, falling +back to `started_at`) is more than 24 hours old. With five or fewer candidates +the line names them all; above that it prints the count plus the five newest +ids and refers to `status` for the full list. After the owner explicitly +confirms that a run should close, an agent can recover it with: ```text bimri-engine.py recover-run --run R000042 \ @@ -253,20 +288,26 @@ outcomes. ## Exact Recall and Integrity Performance -Engine v5.1.1 adds a validated fast path for current exact-key retrieval. A -`get --key` or `recall --key` command without `--history` resolves the accepted -hot revision and keyed cold-current storage directly and returns only the -accepted current generation. Held candidates and superseded generations remain -available through `--history` and the review workflow. The current lookup does -not construct one combined collection from every historical generation. +Engine v5.1.1 introduced a validated fast path for current exact-key +retrieval, and v5.1.2 keeps it. A `get --key` or `recall --key` command without +`--history` resolves the accepted hot revision and keyed cold-current storage +directly and returns only the accepted current generation. Held candidates and +superseded generations remain available through `--history` and the review +workflow. The current lookup does not construct one combined collection from +every historical generation. The fast path is gated by `.bimri/audit-witness.json`, an engine-managed, -non-authoritative integrity checkpoint. The compact witness binds the engine, -memory format, validation policy, accepted head, and canonical current-memory -state. Detailed path-and-SHA-256 evidence for the last full audit lives -separately in `.bimri/audit-manifest.json`, so a warm exact lookup does not -parse or hash the historical inventory. Neither file stores memory, conflicts, -or held candidates. +non-authoritative integrity checkpoint. The compact witness binds the engine +release, memory format, authority policy version (`5.1.2-authority-1` in this +release), accepted head, and canonical current-memory state. A checkpoint +written under an earlier engine or policy version is a cache miss: the first +command after an engine update re-proves the store with one full audit, about +35 seconds on a 700-run store, and publishes a fresh checkpoint, which is why +`INSTALL.md` asks for one `doctor` run before the first session. Detailed +path-and-SHA-256 evidence for the last full audit lives separately in +`.bimri/audit-manifest.json`, so a warm exact lookup does not parse or hash the +historical inventory. Neither file stores memory, conflicts, or held +candidates. With a valid checkpoint, a hot exact lookup reads the bounded accepted head. A cold exact lookup additionally validates only the selected subject's archive @@ -274,9 +315,11 @@ month. It does not enumerate run logs, revisions, proposals, decisions, conflicts, resolutions, recovery evidence, or unrelated archives. Normal start and journal commands likewise avoid historical traversal. -Full verification still happens before authority-changing writes and during -explicit audit, historical recall, task-language search, and review. Those -checks compare the live protected inventory with the prior manifest. Protected +Full verification still happens before authority-changing writes (`propose`, +`sync`, authority `close` and `resolve`) and during explicit audit, `status`, +historical recall, task-language search, `review`, `index`, `maintain` and +`migrate`. Those checks compare the live protected inventory with the prior +manifest. Protected roots are flat; an unexpected subdirectory or redirected path prevents a valid audit. Divergence from the prior manifest is a cache miss, never a verdict: the full semantic audit decides. When it passes over changes the engine cannot @@ -290,24 +333,32 @@ attachment's existence, size and hash) before trusting it. A receipt that cannot be written keeps the prior checkpoint as the baseline and surfaces as an error, and a checkpoint whose referenced manifest evidence is missing refuses rebaselining instead of adopting new bytes blind. A failed semantic audit -refuses into damaged-authority recovery and invalidates the checkpoint by -advancing the audit epoch, so the next `start` prints -`AUTHORITY RECOVERY NEEDED` and exact reads refuse until the store is repaired, -exactly as v5.1.0 behaved. The checkpoint bytes stay on disk as the prior -baseline for receipts, quarantine and restore; only a blocked receipt sink, an -open quarantine, or a strict restore comparison keeps the prior checkpoint +refuses into damaged-authority recovery and invalidates the checkpoint at every +boundary that is not read-only. Two mechanisms exist. `doctor`, `status`, +task-language and historical recall, `search`, `review`, `index`, `maintain`, +`migrate`, `sync`, authority `close` and `resolve` advance the state's audit +epoch and leave the checkpoint bytes on disk as the prior baseline for +receipts, quarantine and restore; a degraded `propose` deletes the checkpoint +file instead. Either way the next `start` prints `AUTHORITY RECOVERY NEEDED` +and exact reads refuse until the store is repaired, exactly as v5.1.0 behaved +(v5.1.1 left the checkpoint valid after a refused `sync`, `close` or +`resolve`; v5.1.2 closes that gap). Read-only `doctor`, the quarantine shadow +audit, a blocked-quarantine or prior-evidence-invalid issue and a strict +restore comparison never invalidate; only a blocked receipt sink, an open +quarantine, or a strict restore comparison keeps the prior checkpoint readable. This detects and records standalone or accidental edits; it is not a defense against a coordinated writer editing history and derived evidence together, which no local store can prove from its own bytes. ### Support Envelope -v5.1.1 is a bounded, single-store performance release, validated on a store -shaped like its own development project: roughly 500 runs, a few hundred -revisions and current subjects, one machine, one lock domain. Inside that -shape, warm exact reads run in the low hundreds of milliseconds end to end, -start and journal likewise, authority-changing writes about 1.3 seconds, -and the full audit that seeds the checkpoint runs once at about 30 seconds. +v5.1.1 and v5.1.2 are bounded, single-store releases, validated on a store +shaped like their own development project: roughly 500 to 700 runs, a few +hundred revisions and current subjects, one machine, one lock domain. Inside +that shape, warm exact reads run in the low hundreds of milliseconds end to +end, start and journal likewise, authority-changing writes about 1.3 seconds, +and the full audit that seeds the checkpoint runs once at about 30 to 35 +seconds. Outside that shape the documented ceilings apply until the planned v5.2 work: exact reads scale with total current-state size, a selected cold key scans its whole archive month, every operation serializes behind one @@ -364,7 +415,9 @@ Tier 1 and Tier 2 entries carry a stable key, trust, and source: - `confirmed` means directly stated or approved by the human, or produced by a deterministic system function. - `working` means useful but still provisional. -- `contested` means a conflict is awaiting resolution. +- `contested` means a conflict is awaiting resolution. It is derived from a + conflict record and cannot be authored: `propose --trust contested` is + refused, while a stored record that carries it still parses. - `user`, `agent`, `external`, `system`, and `legacy` record where the claim came from. Human approval may raise trust to `confirmed`; it does not rewrite this immutable origin. @@ -394,7 +447,7 @@ remains a durable held candidate, creates no routine owner conflict, and does not prevent residency maintenance from cooling an eligible Tier 2 subject. Tier 1 admission or promotion likewise requires direct human confirmation; unconfirmed agent/external claims remain current in Tier 2, while an attempted -Tier 1 admission is preserved quietly as a held candidate. +Tier 1 admission is preserved as a quiet held candidate. If the owner directly adopts that change, the agent submits the exact owner statement as a normal `--source user --trust confirmed` update. @@ -475,7 +528,7 @@ Runtime files: | `.bimri/decisions/` | Deterministic outcome of each proposal. | | `.bimri/conflicts/` | Open and historical questions for the human. | | `.bimri/resolutions/` | Durable human choices. | -| `.bimri/index.tsv` | Rebuildable, non-authoritative retrieval index. | +| `.bimri/index.tsv` | Derived, non-authoritative export rebuilt by `index`, `maintain`, `doctor`, install and migrate; `recall`, `get` and `search` do not read it. | | `.bimri/audit-witness.json` | Compact, rebuildable checkpoint for the last successful full integrity audit. | | `.bimri/audit-manifest.json` | Detailed, rebuildable path-and-hash evidence behind that checkpoint. | | `.bimri/audit-manifests/` | Retained manifest generations referenced by live audit evidence. | @@ -516,10 +569,14 @@ the hot view. The current memory authority is the union of the immutable hot revision named by `.bimri/state.json` and the state's archive-bound cold-current mapping. `bimri.md` is generated from the hot revision and `.bimri/index.tsv` is a -derived cache. If a generated-view refresh fails after state commits, the +derived export. If a generated-view refresh fails after state commits, the engine warns; the durable change remains accepted and the next engine command -retries the refresh. An index failure cannot change a memory decision and can -be repaired with `index`. +retries the refresh. `recall`, `get` and `search` build their results from the +accepted head, the cold-current map, the archive and held decisions, so a +missing or stale index changes no retrieval result. `index`, `maintain`, +`doctor`, install and migrate rebuild the index; beyond `doctor` checking its +column shape, nothing reads it. An index failure cannot change a memory +decision and can be repaired with `index`. If any process edits `bimri.md` directly, including CRLF-only changes, invalid UTF-8, or replacing it with an empty file, the next synchronizing command diff --git a/bimri-engine.py b/bimri-engine.py index b2fc6f3..a3c89bd 100644 --- a/bimri-engine.py +++ b/bimri-engine.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -BIMRI Engine v5.1.1 (authority store v5.1.0; hot grammar v5.0.2) +BIMRI Engine v5.1.2 (authority store v5.1.0; hot grammar v5.0.2) Portable, human-governed memory for local agents. The shared memory is a generated Markdown view. Agents work in independent @@ -53,8 +53,9 @@ msvcrt = None -ENGINE_VERSION = "5.1.1" +ENGINE_VERSION = "5.1.2" V5_1_0_ENGINE_VERSION = "5.1.0" +V5_1_1_ENGINE_VERSION = "5.1.1" MEMORY_FORMAT_VERSION = "5.1.0" HOT_FORMAT_VERSION = "5.0.2" AUDIT_WITNESS_SCHEMA = 1 @@ -66,7 +67,10 @@ AUDIT_DRIFT_DELTA_CAP = 2000 AUDIT_DRIFT_BLOB_KEEP = 20 PRIOR_EVIDENCE_INVALID_PREFIX = "prior audit manifest evidence is invalid" -AUTHORITY_POLICY_VERSION = "5.1.1-authority-1" +# 5.1.2 refuses Unicode line separators inside stored text, so a verdict a +# 5.1.1 audit published no longer proves what this engine requires; every +# existing checkpoint re-proves once under the new policy. +AUTHORITY_POLICY_VERSION = "5.1.2-authority-1" HOOK_TIMEOUT_SECONDS = 90 PREVIOUS_V5_VERSION = "5.0" V5_0_1_VERSION = "5.0.1" @@ -132,6 +136,9 @@ LEGACY_BACKUP_NAMES = ("BIMRI-backup.md", "bimri-backup.md") TRUSTS = {"working", "confirmed", "contested"} +# Public authoring may only use these. `contested` describes a candidate that +# a conflict record holds open; the engine derives it and never authors it. +AUTHORABLE_TRUSTS = {"working", "confirmed"} SOURCES = {"user", "agent", "external", "system", "legacy"} OUTCOMES = {"success", "partial", "overflow", "fail"} OPERATIONS = {"set", "touch", "close"} @@ -489,6 +496,11 @@ def clean_scalar(value, name, max_chars=500, allow_empty=False): raise BimriError(f"{name} must be one line without tabs.") if any(ord(ch) < 32 for ch in value): raise BimriError(f"{name} contains control characters.") + if len(value.splitlines()) > 1: + # The hot view and run logs are parsed with str.splitlines(), which + # also breaks on U+0085, U+2028 and U+2029. One authored line must + # stay one parsed line, or a text could smuggle a second entry. + raise BimriError(f"{name} must not contain Unicode line separators.") return value.strip() @@ -6292,7 +6304,15 @@ def governance_snapshot( prior_manifest_override=None, allowed_manifest_paths=None, allow_audit_epoch_advance=False, + condemn_on_failure=None, ): + # Publishing a fresh verdict and invalidating a verdict this audit has + # just proved false are separate obligations. A caller that must not + # publish (the authority-write preflight, the resolution retry gate) + # still owes the invalidation; an explicitly read-only audit owes + # neither, so the default follows write_witness. + if condemn_on_failure is None: + condemn_on_failure = write_witness blocked_issues = load_audit_blocked_issues(paths) if blocked_issues and not audit_blocked: return [], blocked_issues @@ -6387,7 +6407,7 @@ def governance_snapshot( ] if write_witness and prior_witness is None: discard_audit_witness(paths) - elif write_witness and condemned and not strict_prior_comparison: + elif condemn_on_failure and condemned and not strict_prior_comparison: condemn_audit_checkpoint(paths, state) elif write_witness and ( not allow_recoverable_applying @@ -6544,11 +6564,16 @@ def begin_authority_write( # Missing, corrupt or diverged derived evidence has no prior verdict to # reuse. Rebuild trust once with the complete semantic authority audit. + # This boundary never publishes (the post-write refresh does), but a + # failed audit must still condemn the retained checkpoint so the next + # warm start and exact read re-prove the store (owner ruling + # 2026-09-02, BIMRI-PROTOCOL 9.5) instead of serving past the refusal. conflicts, issues = governance_snapshot( paths, state, use_witness=False, write_witness=False, + condemn_on_failure=True, ) if issues and not allow_degraded: raise BimriError( @@ -6725,6 +6750,7 @@ def require_governance_for_resolution_retry( state, use_witness=False, write_witness=False, + condemn_on_failure=True, ) retry_marker = "resolution status is failed; explicit retry is required." target_prefix = f"resolution {conflict_id} (" @@ -7258,7 +7284,9 @@ def next_pattern_id(paths, state, entries): if isinstance(pattern_id, str) and re.fullmatch(r"P\d+", pattern_id): numbers.append(int(pattern_id[1:])) number = max(numbers + [0]) + 1 - state["pattern_count"] = number + # Allocation is derived from immutable proposal files and the hot view; + # state.pattern_count advances only when an accepted apply commits it, + # so a propose never writes undeclared authority-influencing state. return f"P{number:04d}" @@ -7635,6 +7663,11 @@ def preflight_proposal(paths, state, run_meta, proposal): "public proposals cannot use system provenance; use user, agent, " "or external for the actual source." ) + if proposal["trust"] not in AUTHORABLE_TRUSTS: + raise BimriError( + "contested trust is derived from a conflict record and cannot be " + "authored by a public proposal." + ) if proposal.get("needs_human"): raise BimriError( "semantic uncertainty is not a memory conflict; ask the owner " @@ -7895,6 +7928,7 @@ def validate_proposal( raise BimriError("proposal preflight receipt fields are invalid.") accepted_receipt_engines = { ENGINE_VERSION, + V5_1_1_ENGINE_VERSION, V5_1_0_ENGINE_VERSION, } if proposal.get("bimri_version") == V5_0_2_VERSION: @@ -9492,6 +9526,13 @@ def apply_proposal( state["cold_current"][cooled["key"]] = make_cold_record( cooled, proposal["proposal_id"], archive_date ) + if proposal["tier"] == 3 and proposal["operation"] == "set": + # The pattern counter moves inside the declared authority write + # scope, never at propose time (v5.1.2). + state["pattern_count"] = max( + int(state.get("pattern_count", 0)), + int(proposal["pattern_id"][1:]), + ) revision = commit_revision( paths, state, new_content, f"accepted {proposal['proposal_id']}", allow_legacy_overflow=legacy_reduction, @@ -10021,6 +10062,61 @@ def new_conflict_notices(paths, state, results): return notices +ORPHAN_INACTIVITY_SECONDS = 86400 +ORPHAN_BRIEF_LIMIT = 5 + + +def run_last_seen(meta): + """Return a run's last recorded activity as an aware UTC datetime.""" + if not isinstance(meta, dict): + return None + for field in ("last_activity_at", "started_at"): + value = meta.get(field) + if not isinstance(value, str): + continue + try: + seen = dt.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") + except ValueError: + continue + return seen.replace(tzinfo=dt.timezone.utc) + return None + + +def orphan_candidates(state, current_run_id=None, now=None): + """List other active runs idle for longer than the inactivity window. + + Idleness is measured from ``last_activity_at``, which journal, propose + and sync maintain, and falls back to ``started_at`` for records that + lack it. A run with no readable timestamp is a candidate. This is + classification only: nothing here closes, stamps or mutates a run. + """ + if now is None: + now = dt.datetime.now(dt.timezone.utc) + stale = set() + for rid, meta in state["active_runs"].items(): + if rid == current_run_id: + continue + seen = run_last_seen(meta) + if ( + seen is None + or (now - seen).total_seconds() > ORPHAN_INACTIVITY_SECONDS + ): + stale.add(rid) + return sorted(stale) + + +def render_orphan_candidates(stale, limit=ORPHAN_BRIEF_LIMIT): + """One bounded brief line; ``status`` carries every active run.""" + if len(stale) <= limit: + return "ORPHAN CANDIDATES (never auto-closed): " + ", ".join(stale) + return ( + f"ORPHAN CANDIDATES (never auto-closed): {len(stale)} runs inactive " + f"over {ORPHAN_INACTIVITY_SECONDS // 3600}h; newest " + + ", ".join(stale[-limit:]) + + "; full list: status" + ) + + def print_brief( paths, state, @@ -10047,21 +10143,9 @@ def print_brief( if conflicts is None or authority_issues is None: conflicts, authority_issues = governance_snapshot(paths, state) print_authority_recovery(authority_issues) - stale = [] - now = dt.datetime.now(dt.timezone.utc) - for rid, meta in state["active_runs"].items(): - if rid == run_id: - continue - try: - started = dt.datetime.strptime(meta["started_at"], "%Y-%m-%dT%H:%M:%SZ") - started = started.replace(tzinfo=dt.timezone.utc) - if (now - started).total_seconds() > 86400: - stale.append(rid) - except (KeyError, ValueError): - stale.append(rid) - stale = sorted(set(stale)) + stale = orphan_candidates(state, run_id) if stale: - print("ORPHAN CANDIDATES (never auto-closed): " + ", ".join(stale)) + print(render_orphan_candidates(stale)) if errors: print("VALIDATION NEEDED:") for error in errors[:10]: @@ -10225,6 +10309,11 @@ def cmd_propose(paths, args): raise BimriError(f"unsupported source: {source}") if trust not in TRUSTS: raise BimriError(f"unsupported trust: {trust}") + if trust not in AUTHORABLE_TRUSTS: + raise BimriError( + "contested trust is derived from a conflict record and cannot be " + "authored; use working, or confirmed for a direct human statement." + ) if trust == "confirmed" and source not in {"user", "system"}: raise BimriError("only directly human-stated or system memory may be confirmed.") if args.target: @@ -12250,6 +12339,62 @@ def retention_order(entry, state): ) +def typed_recall_fields(entry): + """Full typed fields of one parsed memory entry for machine-readable recall.""" + if not entry: + return {} + tier = int(entry.get("tier", 0) or 0) + fields = {"tier": tier} + if tier == 1: + fields["kind"] = entry.get("kind", "") + elif tier == 2: + importance = str(entry.get("imp", "")) + fields["importance"] = int(importance) if importance.isdigit() else None + fields["status"] = entry.get("status", "") + fields["first_run"] = entry.get("first", "") + fields["last_run"] = entry.get("last", "") + elif tier == 3: + observations = str(entry.get("obs", "")) + fields["confidence"] = entry.get("conf", "") + fields["observations"] = ( + int(observations) if observations.isdigit() else None + ) + fields["evidence"] = [ + item for item in str(entry.get("ev", "")).split(",") if item + ] + fields["falsifier"] = entry.get("falsifier", "") + if tier in {1, 2}: + raw_tags = entry.get("tags", "") or "" + try: + fields["tags"] = clean_tags(raw_tags) + except BimriError: + fields["tags"] = [tag for tag in str(raw_tags).split(",") if tag] + fields["pointer"] = entry.get("ptr") or "" + return fields + + +def typed_proposal_fields(proposal): + """Typed fields of a held proposal, mirroring typed_recall_fields.""" + tier = int(proposal.get("tier", 0) or 0) + fields = {"tier": tier, "operation": proposal.get("operation", "")} + if tier == 1: + fields["kind"] = proposal.get("kind", "") + elif tier == 2: + fields["importance"] = proposal.get("importance") + fields["status"] = proposal.get("status", "") + elif tier == 3: + fields["confidence"] = proposal.get("confidence", "") + fields["observations"] = proposal.get("observations") + fields["evidence"] = list(proposal.get("evidence", []) or []) + fields["falsifier"] = proposal.get("falsifier", "") + if tier in {1, 2}: + try: + fields["tags"] = clean_tags(proposal.get("tags", []) or []) + except BimriError: + fields["tags"] = [] + return fields + + def exact_current_recall_records(paths, state, key, hot_entries=None): """Read one exact subject from current hot/cold storage only.""" if hot_entries is None: @@ -12273,6 +12418,7 @@ def exact_current_recall_records(paths, state, key, hot_entries=None): "reason": "current", "trust": entry.get("trust", ""), "source": entry.get("source", ""), + "typed": typed_recall_fields(entry), }) cold = state.get("cold_current", {}).get(key) if cold is not None: @@ -12285,6 +12431,7 @@ def exact_current_recall_records(paths, state, key, hot_entries=None): "reason": "current", "trust": entry.get("trust", ""), "source": entry.get("source", ""), + "typed": typed_recall_fields(entry), }) return records @@ -12325,6 +12472,7 @@ def held_recall_records(paths, state, key=None): "reason": decision["reason"], "trust": proposal.get("trust", ""), "source": proposal.get("source", ""), + "typed": typed_proposal_fields(proposal), }) return records @@ -12349,6 +12497,7 @@ def recall_records(paths, state): "reason": "current", "trust": entry.get("trust", ""), "source": entry.get("source", ""), + "typed": typed_recall_fields(entry), }) current_cold_effects = set() for key, cold in sorted(state.get("cold_current", {}).items()): @@ -12362,6 +12511,7 @@ def recall_records(paths, state): "reason": "current", "trust": entry.get("trust", ""), "source": entry.get("source", ""), + "typed": typed_recall_fields(entry), }) current_cold_effects.add( (cold["archived_by"], cold["raw_line"], "cooled") @@ -12391,6 +12541,7 @@ def recall_records(paths, state): "reason": record["reason"], "trust": entry.get("trust", ""), "source": entry.get("source", ""), + "typed": typed_recall_fields(entry), }) for path in sorted(paths.decisions.glob("R*-Q*.json")): if path.is_symlink() or not path.is_file(): @@ -12412,11 +12563,16 @@ def recall_records(paths, state): "reason": decision["reason"], "trust": proposal.get("trust", ""), "source": proposal.get("source", ""), + "typed": typed_proposal_fields(proposal), }) return records -def cmd_recall(paths, key=None, query=None, history=True, limit=20): +def cmd_recall( + paths, key=None, query=None, history=True, limit=20, output="tsv" +): + if output not in {"tsv", "json"}: + raise BimriError("recall output must be tsv or json.") if (key is None) == (query is None): raise BimriError("recall requires exactly one of --key or --query.") if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: @@ -12473,12 +12629,17 @@ def cmd_recall(paths, key=None, query=None, history=True, limit=20): if not history and record["location"] == "HISTORY": continue else: + typed = record.get("typed", {}) haystack = " ".join( - str(record.get(field, "")) - for field in ( - "key", "id", "detail", "reason", "location", - "trust", "source", - ) + [ + str(record.get(field, "")) + for field in ( + "key", "id", "detail", "reason", "location", + "trust", "source", + ) + ] + + list(typed.get("tags", []) or []) + + [str(typed.get("falsifier", "") or "")] ).casefold() if not all(token in haystack for token in search_tokens): continue @@ -12492,6 +12653,28 @@ def cmd_recall(paths, key=None, query=None, history=True, limit=20): item["reason"], ) ) + if output == "json": + payload = { + "matches": [ + dict( + { + "location": record["location"], + "key": record["key"], + "id": record["id"], + "reason": record["reason"], + "trust": record["trust"], + "source": record["source"], + "text": record["detail"], + }, + **record.get("typed", {}) + ) + for record in matches[:limit] + ], + "total": len(matches), + "omitted": max(0, len(matches) - limit), + } + print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) + return 0 if matches else 1 for record in matches[:limit]: print( "\t".join( @@ -13486,8 +13669,14 @@ def write_local_runtime_binding( def merged_marked_block_content(path, block): existing = path.read_text(encoding="utf-8") if path.exists() else "" marked = f"{AGENT_BLOCK_START}\n{block.strip()}\n{AGENT_BLOCK_END}" + # An orphan START marker in owner prose must not anchor the match: the + # lazy span may not cross another START, so only the real block (the + # last START before an END) is replaced and the owner text before it + # survives (installer red team 2026-09-07, E6b). pattern = re.compile( - re.escape(AGENT_BLOCK_START) + r".*?" + re.escape(AGENT_BLOCK_END), + re.escape(AGENT_BLOCK_START) + + r"(?:(?!" + re.escape(AGENT_BLOCK_START) + r").)*?" + + re.escape(AGENT_BLOCK_END), re.DOTALL, ) if pattern.search(existing): @@ -14478,6 +14667,7 @@ def code_update_receipt_identity(manifest): if identity not in { ("5.0.3", V5_0_2_VERSION), (V5_1_0_ENGINE_VERSION, MEMORY_FORMAT_VERSION), + (V5_1_1_ENGINE_VERSION, MEMORY_FORMAT_VERSION), (ENGINE_VERSION, MEMORY_FORMAT_VERSION), }: raise BimriError( @@ -15036,13 +15226,14 @@ def activation_manifest_differences(before, after): ] -def cmd_code_only_update( - source_paths, - paths, - python_executable, - quiescent, - source_version, -): +def require_quiescent_v5_handoff(quiescent, source_version): + """Every existing v5 store needs the caller's old-process attestation. + + Both update routes replace the program file and advance the persisted + state version. The lock serializes commands but cannot fence a process + that already loaded the old engine, so the precondition is the same for + the v5.0/v5.0.1 upgrade route and the v5.0.2/v5.1.0 code-only route. + """ if not quiescent: raise BimriError( f"updating an existing v{source_version} store requires an externally verified " @@ -15050,6 +15241,16 @@ def cmd_code_only_update( "retry install with --quiescent. The lock cannot enforce that " "old-process shutdown." ) + + +def cmd_code_only_update( + source_paths, + paths, + python_executable, + quiescent, + source_version, +): + require_quiescent_v5_handoff(quiescent, source_version) backup_root = paths.root / ".bimri-update-backups" policy = CodeUpdateDestinationPolicy(paths) backup_dir = None @@ -15109,7 +15310,14 @@ def cmd_code_only_update( state_backup_path = backup_dir / f"state-v{source_version}-exact.json" policy.copy(paths.state, state_backup_path) manifest.update({ - "mode": "lossless-authority-activation", + # A same-format update touches no memory record, so its receipt + # keeps the stricter code-only-update label; only a store written + # at an older memory format is activated (v5.1.2). + "mode": ( + "code-only-update" + if source_version == MEMORY_FORMAT_VERSION + else "lossless-authority-activation" + ), "source_memory_format": source_version, "state_backup": state_backup_path.relative_to(backup_dir).as_posix(), "state_backup_sha256": sha256_bytes(source_state_bytes), @@ -15331,6 +15539,12 @@ def cmd_install(source_paths, target, quiescent=False): if target_paths.state.exists() or path_is_redirected(target_paths.state): raw_state = read_json_strict(target_paths.state, "target state.json") source_version = raw_state.get("bimri_version") + if source_version in COMPATIBLE_ARTIFACT_VERSIONS: + # Every existing v5 store shares one handoff precondition. Apply it + # before routing by source version so the v5.0/v5.0.1 upgrade + # route cannot bypass the attestation the v5.0.2/v5.1.0 route + # requires. v4 and older keep their own writer-quiescence checks. + require_quiescent_v5_handoff(quiescent, source_version) if source_version in {V5_0_2_VERSION, MEMORY_FORMAT_VERSION}: return cmd_code_only_update( source_paths, @@ -15533,12 +15747,55 @@ def cmd_install(source_paths, target, quiescent=False): print(f"Repair warning: {warning}") -def hook_payload(): +def hook_payload_diagnosed(): + """Parse Claude's hook JSON from stdin and say why it was unusable.""" try: - data = json.load(sys.stdin) - except (json.JSONDecodeError, OSError): - data = {} - return data if isinstance(data, dict) else {} + raw = sys.stdin.read() + except (OSError, UnicodeError, ValueError) as exc: + return {}, f"stdin could not be read ({exc.__class__.__name__})" + if not raw or not raw.strip(): + return {}, "stdin was empty" + try: + data = json.loads(raw) + except json.JSONDecodeError: + return {}, "stdin was not valid JSON" + if not isinstance(data, dict): + return {}, "stdin JSON was not an object" + return data, None + + +def hook_payload(): + payload, _diagnosis = hook_payload_diagnosed() + return payload + + +def hook_session_identity(payload): + """Return the stable session identity Claude sent, or None.""" + for field in ("session_id", "transcript_path"): + value = payload.get(field) + if isinstance(value, bool): + continue + if isinstance(value, int): + value = str(value) + if isinstance(value, str) and value.strip(): + return value + return None + + +def hook_close_summary(reason): + """Bound and flatten the SessionEnd reason so it can never fail a close.""" + if isinstance(reason, str): + text = reason + elif reason is None: + text = "ended" + else: + text = str(reason) + text = " ".join( + "".join(ch if ord(ch) >= 32 else " " for ch in text).split() + ) + if len(text) > 200: + text = text[:197] + "..." + return f"Claude Code SessionEnd: {text or 'ended'}" def build_parser(): @@ -15571,7 +15828,9 @@ def build_parser(): propose.add_argument("--kind", choices=sorted(TIER1_KINDS)) propose.add_argument("--importance", type=int, choices=range(1, 6)) propose.add_argument("--status", choices=sorted(TIER2_STATUSES)) - propose.add_argument("--trust", choices=sorted(TRUSTS), default="working") + propose.add_argument( + "--trust", choices=sorted(AUTHORABLE_TRUSTS), default="working" + ) propose.add_argument("--source", choices=sorted(SOURCES), default="agent") propose.add_argument("--tags") propose.add_argument("--text") @@ -15628,15 +15887,18 @@ def build_parser(): recall_group.add_argument("--query") recall.add_argument("--history", action="store_true") recall.add_argument("--limit", type=int, default=20) + recall.add_argument("--json", action="store_true") get_memory = sub.add_parser("get") get_memory.add_argument("--key", required=True) get_memory.add_argument("--history", action="store_true") get_memory.add_argument("--limit", type=int, default=20) + get_memory.add_argument("--json", action="store_true") search = sub.add_parser("search") search.add_argument("--query", required=True) search.add_argument("--limit", type=int, default=20) + search.add_argument("--json", action="store_true") sub.add_parser("status") doctor = sub.add_parser("doctor") @@ -15758,6 +16020,7 @@ def main(argv=None): query=args.query, history=(args.history if args.key is not None else True), limit=args.limit, + output="json" if args.json else "tsv", ) elif command == "get": return cmd_recall( @@ -15765,6 +16028,7 @@ def main(argv=None): key=args.key, history=args.history, limit=args.limit, + output="json" if args.json else "tsv", ) elif command == "search": return cmd_recall( @@ -15772,6 +16036,7 @@ def main(argv=None): query=args.query, history=True, limit=args.limit, + output="json" if args.json else "tsv", ) elif command == "status": return cmd_status(paths) @@ -15811,18 +16076,30 @@ def main(argv=None): for warning in migration_warnings: print(f"Repair warning: {warning}") elif command == "hook-start": - payload = hook_payload() - cmd_start( - paths, "claude-code", - str(payload.get("session_id") or payload.get("transcript_path") or uuid.uuid4()), - ) + payload, diagnosis = hook_payload_diagnosed() + session = hook_session_identity(payload) + if session is None: + # A run without a stable identity can never be closed by the + # matching SessionEnd hook, so refuse before any mutation. + # SessionStart hooks never block on a non-zero exit: the + # session opens without a brief and the wrapper records the + # exit code for the operator. + raise BimriError( + "hook-start opened no run: " + + ( + diagnosis + or "the payload carries no session_id or transcript_path" + ) + + ". [hook-identity-missing]" + ) + cmd_start(paths, "claude-code", session) elif command == "hook-close": payload = hook_payload() - session = str(payload.get("session_id") or payload.get("transcript_path") or "") cmd_close( - paths, actor="claude-code", session=session or None, + paths, actor="claude-code", + session=hook_session_identity(payload), outcome="partial", - summary=f"Claude Code SessionEnd: {payload.get('reason', 'ended')}", + summary=hook_close_summary(payload.get("reason", "ended")), allow_unmapped=True, ) elif command == "install": @@ -15845,5 +16122,34 @@ def main(argv=None): return 0 +def flush_console_streams(): + """Drain stdout and stderr without turning a closed pipe into exit 120. + + A consumer such as ``start --actor x | head -1`` closes its end of the + pipe early. The interpreter's own flush at shutdown then fails with + OSError (EINVAL on Windows, EPIPE elsewhere) and Python reports exit + status 120 in place of the command's code (retrieval red team + 2026-09-07, RT-8). After a failed flush the buffered bytes are drained + into the null device so the shutdown flush has nothing left to fail on. + """ + for stream in (sys.stdout, sys.stderr): + try: + stream.flush() + continue + except (OSError, ValueError): + pass + try: + null = os.open(os.devnull, os.O_WRONLY) + try: + os.dup2(null, stream.fileno()) + finally: + os.close(null) + stream.flush() + except (OSError, ValueError, AttributeError): + pass + + if __name__ == "__main__": - sys.exit(main()) + exit_code = main() + flush_console_streams() + sys.exit(exit_code) diff --git a/tests/test_bimri_engine.py b/tests/test_bimri_engine.py index fd85bf2..b3e270e 100644 --- a/tests/test_bimri_engine.py +++ b/tests/test_bimri_engine.py @@ -351,7 +351,7 @@ def assert_installed_runtime_binding(self, target, agents_text=None): "engine_path": engine_path, "host_bound": True, "python_executable": python_executable, - "version": "5.1.1", + "version": "5.1.2", }) template = json.loads( @@ -897,7 +897,7 @@ def test_v503_preflight_receipt_binds_current_head_after_unrelated_change(self): receipt = proposal["preflight_receipt"] self.assertEqual(proposal["bimri_version"], "5.1.0") self.assertEqual(proposal["base_revision"], state["head_revision"]) - self.assertEqual(receipt["engine_release"], "5.1.1") + self.assertEqual(receipt["engine_release"], "5.1.2") self.assertEqual(receipt["observed_head_revision"], state["head_revision"]) self.assertEqual(receipt["observed_head_hash"], state["head_hash"]) self.assertEqual(receipt["observed_key_hash"], "absent") @@ -5082,7 +5082,14 @@ def test_install_keeps_v5_0_2_recovery_tools_for_corrupt_v5_0_1_authority(self): corrupt_path.write_bytes(corrupt_bytes) installed = subprocess.run( - [sys.executable, str(ENGINE), "install", "--target", str(target)], + [ + sys.executable, + str(ENGINE), + "install", + "--target", + str(target), + "--quiescent", + ], text=True, capture_output=True, timeout=60, @@ -7982,7 +7989,7 @@ def test_engine_release_is_separate_from_memory_format(self): self.assertIn(" by name.\n\n" + "More owner text that must survive.\n" + ) + (target / "AGENTS.md").write_text(owner_text, "utf-8") + self.install(target) + first = (target / "AGENTS.md").read_text("utf-8") + self.assertTrue(first.startswith(owner_text.rstrip() + "\n\n")) + self.assertEqual(first.count(""), 1) + # The code-only update merges the block again. Before v5.1.2 the + # lazy START.*?END span began at the orphan marker and deleted the + # owner text up to the real block's END. + self.install(target, "--quiescent") + second = (target / "AGENTS.md").read_text("utf-8") + self.assertEqual(second, first) + self.assertIn("More owner text that must survive.", second) + self.assertEqual(second.count(""), 2) + self.assertEqual(second.count(""), 1) + # In-process check of the merge itself: only the real block moves. + engine = load_engine_module("bimri_v512_block_merge") + merged = engine.merged_marked_block_content(target / "AGENTS.md", "NEW BLOCK") + self.assertTrue(merged.startswith(owner_text.rstrip() + "\n\n\nNEW BLOCK\n")) + self.assertEqual(merged.count(""), 2) + + # ---- installer: the receipt mode label follows the source format (F3) ---- + + def test_update_receipt_mode_follows_the_source_memory_format(self): + self.cli("migrate") + self.install(self.root, "--quiescent") + same_format = self.manifests(self.root) + self.assertEqual(len(same_format), 1) + self.assertEqual(same_format[0]["mode"], "code-only-update") + self.assertEqual(same_format[0]["source_memory_format"], "5.1.0") + self.assertFalse(same_format[0]["state_activated"]) + self.assertEqual(same_format[0]["status"], "installed") + + activated = self.workspace / "activate" + shutil.copytree(POPULATED_FIXTURE, activated) + first = self.install(activated, "--quiescent") + self.assertIn("Authority store activated from v5.0.2 to v5.1.0", first.stdout) + self.install(activated, "--quiescent") + receipts = self.manifests(activated) + self.assertEqual( + [(receipt["mode"], receipt["source_memory_format"], receipt["state_activated"]) + for receipt in receipts], + [ + ("lossless-authority-activation", "5.0.2", True), + ("code-only-update", "5.1.0", False), + ], + ) + self.assertEqual({receipt["status"] for receipt in receipts}, {"installed"}) + doctor = self.cli("doctor", "--read-only", root=activated) + self.assertIn("PASSED", doctor.stdout) + + # ---- retrieval RT-8: a consumer that closes the pipe early --------------- + + def test_closed_stdout_pipe_keeps_the_command_exit_code(self): + payload = json.dumps({"session_id": "piped"}) + opened = self.cli("hook-start", input_text=payload) + run_id = RUN_RE.search(opened.stdout).group(1) + # A resumed hook-start prints only the brief, and only into the + # block buffer, so every byte reaches the pipe at exit. Closing the + # read end before stdin is delivered makes that exit-time flush + # fail deterministically: Python 3 reported exit status 120 here. + proc = subprocess.Popen( + [sys.executable, str(ENGINE), "--root", str(self.root), "hook-start"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + proc.stdout.close() + proc.stdin.write(payload) + proc.stdin.close() + stderr = proc.stderr.read() + self.assertEqual(proc.wait(timeout=120), 0, stderr) + self.assertEqual(stderr, "") + self.assertEqual(set(self.state()["active_runs"]), {run_id}) + closed = self.cli("hook-close", input_text=payload) + self.assertIn(f"run {run_id} closed", closed.stdout) + + # ---- release identity ---------------------------------------------------- + + def witness(self, root=None): + return json.loads( + Path(root or self.root) + .joinpath(".bimri", "audit-witness.json") + .read_text("utf-8") + ) + + def test_engine_release_is_5_1_2_and_accepts_prior_patch_receipts(self): + run = self.start("release") + proposal = self.propose(run, "release.key", "Receipt probe.", new_subject=True) + self.cli("sync", "--run", run) + status = self.cli("status") + self.assertIn( + "BIMRI engine v5.1.2 | memory format v5.1.0 | revision V000001", + status.stdout, + ) + self.assertEqual(self.state()["bimri_version"], "5.1.0") + self.assertIn("