Skip to content

feat/work-modes — phase 002 closed, phase 003 opened, and the store the code actually reads - #30

Merged
ranjiao merged 697 commits into
mainfrom
feat/work-modes
Aug 28, 2026
Merged

feat/work-modes — phase 002 closed, phase 003 opened, and the store the code actually reads#30
ranjiao merged 697 commits into
mainfrom
feat/work-modes

Conversation

@ranjiao

@ranjiao ranjiao commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Scope, stated honestly

This branch is 697 commits ahead of main (633 files, +145,801 / −5,598).
I authored and verified the six most recent; the other ~691 predate this
session and I did not review them. Read this description as covering the tip,
not the branch.

main is currently 4 commits ahead of this branch's merge base — it is not
synced, and that should be resolved before merge.

What the last six commits do

Commit
38f000f TASK-095 — the track register is read from the store. P003-O2-KR1: call sites in bin/ reading a projected markdown file as truth while its store exists went 4 → 0. parse_tracks survives for the adoption reader and the drift comparator, which the KR excludes by name.
e993d85 TASK-209 — the store-drift census covers 6 stores, not 2. okr.jsonl and .perry/config.jsonl are wired to the comparators that already existed rather than growing a third and fourth copy.
b7cef79 Fixes a defect the dispatch itself introduced: 289 lines of tests were appended after unittest.main(), so python3 tests/test_store_drift.py silently ran none of them — including every mutation test the spec asked for.
2e41336 Phase 002's retro, DESIGN-012 locked, and the attribution sweep: rows in the never-asked state went 45 → 0.
38d7be6 Three rows for gaps this work exposed (TASK-229/230/231).
5cac6b5 The merge commit.

Both code commits were dispatched to an agent whose run stalled on a watchdog
before it could commit or open a PR
. Every number above was re-measured by
the PMO before committing — not taken on the agent's word:

  • grep -n "parse_tracks(" bin/* → the definition plus one guarded fallback
    reached only when the store is absent.
  • perry-state --jsonproject.config.tracks[] byte-identical before/after.
  • Mutation, OKR.md: edit a real metric cell → OKR store: 1 row(s) drifted.
  • Mutation, .perry/config.md: edit a track's WIP cell → config store: 1 row(s) drifted.
  • Removal: each of the six stores deleted in turn → all six report
    unchecked, not clean. No store and clean stay different answers.

The test suite is RED, and here is every failure attributed

Ran 2786 tests in 693.384sFAILED (failures=8, skipped=1).

Each was re-run against a b288399 worktree to separate pre-existing from
introduced:

Failure Attribution
test_diagnose::test_perry_itself_passes_its_own_id_checks pre-existing — red at baseline
test_kr_progress_provenance::test_no_current_in_the_payload_claims_to_be_a_measurement pre-existing — red at baseline. TASK-231 is the row for it.
test_claims.TestNoTestFileEndsEarly ×2 regression, FIXED in b7cef79; verified 31/31 green
test_risks_store.TestTheReadersAreOneFunction ×3 order-dependent — the file passes 53/53 alone and fails only in the full run. TASK-230's subject.
test_diagnose::test_the_queue_register_reconciles_with_the_queue_on_this_repository state-triggered, not code — proved by running one binary against two roots: current state → queue: 2, baseline state → queue: 0, while perry-task reports 0 open asks. This is TASK-208.

Not verified: the suite has not been re-run since b7cef79. Six failures
are expected to remain; that is an inference, not a measurement. The full suite
was also never run against the baseline, so I cannot claim the three
test_risks_store failures were green before this branch.

Phase 003 progress, measured at merge

KR target baseline now
P003-O1-KR1 stores exist on disk 6/6 4/6 4/6
P003-O1-KR2 stores with a drift verdict 6/6 2/6 4/6
P003-O1-KR3 removed store reports unchecked 6/6 2/6 6/6 ✅
P003-O2-KR1 call sites reading markdown as truth 0 4 0 ✅
P003-O2-KR2 adoption reader fenced + guard guard live none none
P003-O2-KR3 BOARD.md boundary marked marked unmarked unmarked
P003-O3-KR1 rows never asked about attribution 0 45/45 0 ✅
P003-O3-KR2 attribution in the same action as add 100% 0 0%

None of these numbers is in phase/003-linkage.md: its only writer refuses to
write current on purpose, so a measured number has no path in either. That
is TASK-231.

Merging

Per work/reference/git-boundaries.md, the agent that opened a PR does not
merge it. Merge is the user's.

🤖 Generated with Claude Code

ranjiao and others added 30 commits August 21, 2026 11:37
v1 was rendered 2026-08-20 and three of its load-bearing claims were false:

  aiMark reads 1.2      ->  1.9, src/perry-cli.ts:72 CONTRACT_TESTED.task.
                            The 1.2 is a CLOSED TASK'S TITLE at AIMARK.md:80,
                            read as if it were a live pin.
  the contract is 1.11  ->  1.14
  'the pin is scattered ->  it is already exactly one constant, with a doc
   — put it in one          comment explaining why the three payloads keep
   constant'                three separate numbers. v1's acceptance item 3
                            was satisfied before an agent would have started.

v1 also gave `perry-goals/list` as 2.0; it is 2.1.

An agent sent v1 would have found its main deliverable already built and
aimed the catch-up three minors short. v2 states the real job: 1.9 → 1.14
and 2.0 → 2.1, of which only TWO entries need code — 1.12 (`startable`
stopped reading stored `status`) and 1.14 (a `USER-` ask became a node in
the dependency graph). The rest is additive.

1.14 exists BECAUSE of TASK-114: this row was blocked on USER-015 and both
available spellings tripped a check. v1 was rendered the day before and
could not have known.

TASK-114 retitled — "a pin nine versions old" was the same miscount; the
distance is five minors, not nine.

USER-015's `needed` cell still names the v1 path and there is no writer for
it (`perry-task ask` creates, `answer` closes, nothing amends). Retiring it
would need an `answer` the user never gave, so the correction is carried on
TASK-114's `next_action`, which is where whoever acts on it looks.

perry-lint: 0 errors, 3 pre-existing NS-01 warnings. perry-diagnose reports
no new dangling id from the new file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed on

merge-check on fc58731 with TASK-040 alongside: nothing new is red on the
merged result.

Verified independently rather than taken from the agent's report:
  · the whitespace refusal is byte-identical across the merge — md5 of the
    block from '# **Whitespace-only is a refusal on create' through
    'Nothing was written")' is b8f29f90e8c71f941a290acf0792d092 on BOTH
    feat/work-modes and the branch.
  · the second defect it reports is real. On the base, UnrenderableCell is
    caught ONLY at line 2797, inside the 'if __name__' block that starts at
    2790 — outside main() (2616), where args does not exist. So no
    line-break refusal could ever reach the --json branch: exit 1, stdout
    empty. The branch catches it at 2815, inside main() (2721).
  · perry/ untouched, schema/state-schema.json untouched.

45 refusal paths are reachable from a user-supplied value; 32 now name the
flag. The 13 that do not each have a reason: 12 are in 'link', which takes
its values POSITIONALLY, so there is no flag to name; one is about a row's
state rather than about where the user's words went. A track read out of
the file names no flag on purpose, and that is asserted — pointing a user
at a flag they never typed is worse than naming nothing.
merge-check on fc58731 with TASK-037 alongside: nothing new is red on the
merged result. Zero file overlap between the two branches.

Verified independently: perry/ untouched, schema/state-schema.json untouched.

THE ROW DOES NOT CLOSE ON THIS MERGE. `perry/risks.jsonl` is a canonical
record file in the state root, and every one of those is declared in
`schema/state-schema.json § claims` with an owner and an anchor, the way
`tasks.jsonl` is. Writing an undeclared one is what the claim surface exists
to prevent, so `risks-write --from-board` refuses and names the missing
declaration. Everything reachable without it landed: the byte-identity gate,
the reader unification, and the drift report — which is live code gated on
the file existing and today reports `store_present: false,
comparison_performed: false` rather than a clean register it never compared.

Needs one `claims[]` entry from the user:
  {"path": "risks.jsonl", "kind": "file", "owner": "work", "anchor": "state"}

The reader count on the row was wrong and the agent corrected it: not four
implementations but SIX, of THREE questions, across two files — 'is this
table the register', 'is this line a risk bullet', 'does this Status cell
mean cleared'. The row's 'four' counted TOOLS, and perry-state was never an
independent reader; it consumes the Snapshot parsers builds. Now three, one
per question, in viewer/parsers.py — the bottom of the import graph. The
four names in perry-task are BINDINGS, and the tests assert `is` identity
rather than agreement over a corpus, so a future copy is visible.

A real defect fell out: a risk a human retired as `mitigated` or `已缓解`
read as LIVE to `risk-clear` and as CLEARED to everyone else, so clearing it
again overwrote the first clear's date and reason. Refused now, tested.

It also declined to grow the versioned payload. Adding `cleared_items[]` to
`perry-task list --json` took KR-O2.4 from 0 to 22, because
`contract_key_parity § place` refuses to assign one key table to two
containers that fit it equally well — and `items[]` and `cleared_items[]`
are the same eleven keys. It backed the change out of the contract rather
than duplicate a key table or edit the KR's own measuring instrument.
`perry-state --json § risks` gained `cleared_items[]` instead, which is not
versioned; before it, `cleared_on` was emitted by nothing at all.
Post-merge suite, /usr/bin/python3 tests/parallel -j 4:
  79 modules · 2294 tests · 2 red
Both reds were already red on the base — merge-check attributed them to
nobody and re-ran each on the base alone to prove it. Neither merge added
one.

  test_diagnose            TASK-153, plus the ['TASK-007','TASK-9999']
                           dangling pair (TASK-165's shape)
  test_contract_invariance NEW today, and caused by MY three intake rows,
                           not by either branch. Diagnosed rather than
                           papered over — see below.

TASK-037 closed at V3, evidence/2026-08/TASK-037-result.md.
TASK-040 merged, status blocked, waiting on USER-016.

── the invariance gate reads one branch of a union type as the shape ──

  perry-task/list: intake.oldest_undischarged was NoneType, now int

`schema/task-list-contract.md:301` declares the key `int | null`. BOTH types
are contractually correct. `tests/fixtures/contract-shapes.json:135` records
`"NoneType"` — whichever half was live the day the baseline was captured.
Adding three intake rows supplied the other half.

That is the class `tests/live_state_expectations.py` was built to catch, in a
form it does not cover: the literal is a TYPE, not a count. The gate's own
docstring says a golden file "would be regenerated by whoever broke it, which
is how a snapshot stops meaning anything" — and for a union-typed key the
recorded shape IS a golden value, so that is exactly what happens next.

It is five landmines, not one. Every NoneType in the baseline is a
union-typed key:
  perry-task/list  intake.oldest_undischarged   int|null    FIRED
                   risks.items[].age_days       int|null    fires on the
                                                first risk with an `opened`
                                                date — TASK-040's subject
                   tasks[].created              string|null
  perry-goals/list krs[].current, krs[].target  number or absent
The goals pair survived this morning's P-O1.1/P-O1.2 assertion only because
the FIRST KR in the array still carries nulls.

And it is order-sensitive. `shape()` collapses a list to element zero, to
avoid "keying on length would make every board edit a contract change" — but
for an array-nested union that records whichever branch element zero happened
to have, so re-sorting or closing the first row flips a type the contract
never promised was stable. Which is the outcome the docstring set out to
avoid, reached by a narrower and less predictable route.

Full diagnosis, with the commands: evidence/2026-08/contract-invariance-union-types.md
Filed to intake, not fixed here: regenerating the fixture is the move the
docstring predicts, and the fix has to read the declared type from the
contract document instead.

── two more intake rows ──

`status --status blocked` still requires a TASK- dependency. Contract 1.14
made a USER- ask a node in the dependency graph for the READER — TASK-040
now reports `blocked_by: ['USER-016']`, `startable: false`,
`depends_on_unknown: []`, all correct — but the WRITER's guard refuses
`blocked` unless a TASK- id is named, so the row needed `--reason`. Same
asymmetry TASK-162 closed, one layer over.

perry-lint: 0 errors. It now says `no risks.jsonl — drift against the risks
store is unchecked, not clean`, which is TASK-040's honest-reporting landing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
USER-015's first half is done — the v2 prompt went to an aiMark agent on
2026-08-21. The ask stays pending until the result is pasted back, which is
the half it actually blocks TASK-114 on. Recorded on TASK-114's next_action
because `perry-task ask` creates and `answer` closes, and nothing amends.

USER-016 was raised for one `claims[]` entry. Checking the array to write the
patch found that NONE of the three record stores is declared:

    grep -n "okr.jsonl\|config.jsonl\|risks.jsonl" schema/state-schema.json
    (no output)

`tasks.jsonl` is declared, at line 860, with a note explaining exactly why a
canonical record file must be. `okr.jsonl` is not — and it has existed on
disk since this morning, when `perry-okr write --from-file` created it.
`.perry/config.jsonl` is not either, and will land the same way.

So the same situation produced two behaviours today, hours apart:

  perry-tasks risks-write --from-board   REFUSED, named the missing
                                         declaration, said it is the user's
                                         to give                    (TASK-040)
  perry-okr write --from-file            wrote it, said nothing

`bin/perry-okr` and `bin/perry-config` have no claims guard at all — no
"refusing to write" path exists in either file. `perry-lint` does not catch it
from the other side: NS-01 finds FOREIGN files inside a claimed directory, not
a PERRY file outside the claim surface. Filed to intake.

Nothing is broken by the undeclared file — it verifies byte-identical and the
suite is unmoved. What is missing is Perry admitting it owns it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l reads it

Released by the user 2026-08-21. Three entries after `tasks.jsonl`, not the
one USER-016 asked for — none of the three stores was declared:

    grep -n "okr.jsonl\|config.jsonl\|risks.jsonl" schema/state-schema.json
    (no output)

  okr.jsonl            goals / state    — on disk since this morning
  risks.jsonl          work  / state    — TASK-040
  .perry/config.jsonl  perry / project  — declared before it exists, like
                                          `incidents/` and `runbook/`

`.perry/config.jsonl` is anchored at the PROJECT root and owned by `perry`,
not by a lane, for the reason `.perry/events.jsonl` already gives: it is the
file that declares where the state root is, so it cannot sit behind that
pointer.

── the claim exposed a false positive, one store over ──

Adding it took the lint from 3 warnings to 4:

    ⚠ perry/okr.jsonl [NS-01] `okr.jsonl` holds 1 file(s) Perry did not write

`looks_like_perry_record` recognises a store by its RECORD, not its name —
deliberately, because matching the name would make the claim self-defeating.
It knew two shapes: `perry_store.STORED` and `RISK_STORED`. `okr.jsonl` and
`.perry/config.jsonl` hold `perry_md_store.STORED` records, which key on
`kind` rather than `id` — a `setting` record carries `key`, a `track` record
carries `track` — so the `id`-gated branch could not see them at all.

That is exactly what TASK-040's own comment predicted for the risks store:
"reports the risks store as a file Perry did not write, against Perry's own
claim, on every project that has one." Same defect, next store, surfaced by
declaring the claim.

Fixed by reading `perry_md_store.STORED` — the same map, not a copy.
`tests/test_ns_collision.py` builds one record per kind FROM that map, so a
field added there cannot leave the test asserting a shape that no longer
ships, and asserts the other direction too (a `{"kind": "kr", "wat": 1}` is
still foreign). Reverting the fix reddens it.

Back to 3 warnings and 3 collisions — the same as before the declaration.

── and the declaration was NOT sufficient ──

`perry-tasks risks-write --from-board` still refused afterwards, because the
guard was an unconditional `print(RISK_STORE_UNDECLARED); return 1` and
`perry-tasks` never read `claims[]` at all. There is no `cmd_risks_write`:
TASK-040 stopped BEFORE the write path, and the constant was standing in for
a command that does not exist.

So the refusal now READS the schema and names whichever gap is actually open.
A message that keeps naming the declaration would send whoever reads it to
add an entry that is already there — the same wasted trip TASK-114's v1
delegation prompt sent an agent on, for the same reason: a message measured
against a world that moved. An unreadable schema falls to the undeclared
branch, because "I cannot see it" is not "it is there".

Both branches are asserted, not just today's live one.

USER-016 answered. TASK-040 back to not_started; remaining scope is the write
path itself.

Suite: 79 modules · 2297 tests · 2 red — test_contract_invariance and
test_diagnose, both unchanged and both pre-existing. perry-lint 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… all hold

USER-015 answered. Verified here rather than taken from the report:

  acceptance 5   grep -rn '"1.14"\|"2.1"' src/ minus tests returns
                 src/perry-cli.ts:72-73 and nothing else
  acceptance 8   bun test -> 672 pass, 0 fail, 1964 expect() calls, 38 files.
                 The agent's baseline was 654/1 fail, and the failure was
                 perry-conformance.test.ts:212 asserting `changed` is empty —
                 its own guard correctly reporting the anchor was five minors
                 back. Green because the statement became true.

Two of the five minors needed code. 1.10: two fallbacks were reading
`status_text` to recover something `status` could not carry, and since 1.10
there is nothing there — both substituted "" for "" while reading as though
they still rescued the raw cell. 1.11: `work-model.ts:455` built a description
as `[next_action, evidence].join(" · ")` — aiMark's guess wearing the author's
voice, in the slot Perry deliberately refuses to synthesize.

── the four findings ──

1. A REAL consumer bug, and nothing failed. `depends_on_unknown` entries are
   `{id, unknown}` (contract line 206, key table at 236; no `missing` key
   exists anywhere). aiMark parsed `r.missing`, so every entry named the row
   and DROPPED THE IDS — the only actionable part. The array is `[]` on
   Perry's own board today, so it would have looked correct forever. This is
   the class of gap the row existed for, and it is the answer to whether the
   catch-up was worth doing.

2. MY OWN PROMPT carried a stale claim and I called it fine. v2's out-of-scope
   item 1 says the OKR chain view parses OKR.md, BOARD.md and the linkage file
   in process. That code is deleted: `src/perry-adapter.ts` and
   `src/perry-linkage.ts` do not exist, `src/perry-cli.ts:11` says "that
   adapter is gone", `work-model.ts:5` describes both in the past tense.
   Acceptance 6's "proven by the file list" expected a file the diff cannot
   contain.

   How it happened is worth keeping. v1 was withdrawn because three of its
   claims were false. I checked those three — the versions and the pin — and
   then copied v1's out-of-scope items forward SAYING THEY WERE STILL RIGHT.
   I verified the numbers and not the prose: the same defect as v1's, one
   layer down, committed while diagnosing it.

3. Two instructions in v2 contradict. The goals-2.1 section says a KR progress
   indicator is drawing an assertion and 2.1 is what lets it say so;
   acceptance 6 forbids touching the OKR chain view; the KR meter IS the chain
   view. The agent honoured item 6, so a stale number still renders
   identically to a fresh one at the KR itself — the exact thing 2.1 exists to
   prevent. Needs a human to say which wins.

4. `conformance.missing_projection` was never announced by ANY version —
   sharper than reported. Splitting the changelog by version and searching
   each section finds it in none; `git log -S` puts its arrival in
   2af97e4 "read canonical task store". It ships, it has a key table row at
   line 210, and no bump ever mentioned it.

   `tests/contract_key_parity.py` — KR-O2.4's instrument — cannot see this by
   construction: it compares DOCUMENTED against EMITTED, and this key is both.

── on the rung ──

Closed at V3, not the V4 the row carried. No independent fresh-context review
ran on the aiMark diff: one agent did the work and the PMO checked three of
eight acceptance items plus the premises of two findings. Lowered to match
what happened rather than left at a number the process did not earn.

The aiMark diff is uncommitted in that repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TASK-045's precondition verified before dispatch rather than assumed. Its
2026-08-18 note put the blocker on the gate shipping `advisory`; today:

    $ python3 bin/perry-conform status
    ... shape version 2 · gate: enforce

TASK-044 done V4, TASK-047 done V5. The 044 -> 047 -> 045 chain is complete.

Its note's constraint is carried into the spec VERBATIM, because it is the
thing most likely to be lost: 'retire tolerance' does NOT mean delete every
fallback; adopt and diagnose get MORE tolerant, and the branches to retire are
the per-shape ones in the five named tools.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both to claude-subagent, slots 2/2. TASK-045's 'runs alone' caveat no longer
applies the way it was written: it was queued behind 037 and 040 because the
ORIGINAL 040 scope touched perry-task, perry-lint and perry-state — three of
045's five tools. The remaining 040 work is bin/perry-tasks only, which is not
one of the five. Both prompts name the other agent and its file set.

Stale dispatch slots for the closed 037 and 040 rounds released first; they had
been sitting 205m against a 240m reap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 002's first User Commitment discharged. ADR-007 decision 2 made a hand
edit to a rendered file become DRIFT and left the severity unset; it is now
set.

It confirms what ships rather than changing it. Enumerated before recording,
not assumed — every drift finding in bin/perry-lint, across both stores:

    warn  store-drift-uncheckable        warn  risk-store-drift-uncheckable  x2
    warn  store-drift               x3   warn  risk-store-drift              x3

Nine findings, zero at `error`. No code change.

The reasoning accepted is the one the code already gives: a drifted Board
still has a VALID BOARD SHAPE, and the conformance gate's boundary is that
warnings are quality signals while errors are shape violations. The store
stays authoritative, so drift can never change Task truth and re-rendering
restores the projection.

The consequence is accepted with it, and it is the real content of the
decision: a hand edit does NOT fail CI, and an emergency hand edit stays
possible — recorded rather than refused.

P-O1.3's `target` and `current` stay ABSENT in phase/002-linkage.md. Its
metric is the word "reported", and goals/SKILL.md is explicit that a prose
target is never coerced into a number — the frontend draws a progress bar from
that field, and a ceiling shown as progress is a lie about a limit. The
decision lives where the commitment was written.

Filed to intake: nothing PINS the severity. Nine call sites spell `warn` and a
tenth could spell `error` without a test noticing, which is the same shape as
every other rule this project has had to re-state N times. The decision now
exists in prose only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The half TASK-040 stopped before. `cmd_risks_write` did not exist; the
dispatch printed one of two refusals and returned 1. USER-016 released
the `claims[]` declaration, so the second refusal was describing a gap
this commit closes — `RISK_STORE_NO_WRITE_PATH` and the selector that
chose between the two messages are deleted rather than left standing.

`BOARD.md § Top risks` -> `perry/risks.jsonl`, on the model of
`perry-tasks write --from-board` and `perry-okr write --from-file`.
`--from-board` is required: the flag is the consent and there is no
other behaviour.

Four refusals, each writing nothing (ADR-004, asserted by byte-comparing
BOTH the board and the store across a refused call):

  the CLAIM   `risks.jsonl` is read out of `schema/state-schema.json §
              claims` at every call, so withdrawing the entry switches
              the command off rather than leaving it writing.
  the SHAPE   `absent`, `bullets` and `foreign` are sections this tool
              can read and must not derive a store from. `bullets` is
              the one that would otherwise pass the byte gate and be
              wrong: `risk_records` returns [] for it, rendering []
              changes no line, so `cmp` is clean and an empty store
              would be written over a section holding real risks.
  the BYTES   the derived records are rendered back over the section
              and byte-compared BEFORE anything is written, and the
              refusal names the row and the column. Reachable: a
              repeated id is one record but two rendered lines, so the
              second row comes back carrying the first row's `Risk`.
  READ BACK   `validate_risk_records` runs on what is about to be
              written, the check `perry_md_store § main` added one
              document over.

`cleared` is carried across from the store on disk. It is the one
stored field the four columns cannot express, so the board says nothing
about it and `--from-board` cannot be read as saying "". Everything the
section CAN express comes from the section, and what that replaces is
printed rather than swallowed.

THE EVENT LOG: the import appends nothing, stated in `cmd_risks_write`
and tested. `risk-add` / `risk-clear` append events because a risk was
raised or retired that day; an import raises none, and every event it
could append would stamp today on a row that may be nine months old —
the same falsehood `opened: ""` refuses.

Also fixed, because the new command is what makes it reachable:
`cmd_risks_render` read an EMPTY store as NO store, so a register whose
table has a header and no rows reported `source: "board"` about a file
sitting on disk. `risk_table` refuses that conflation one layer down
and `perry-lint` asks the path; this now does too.

17 tests added. Suite on /usr/bin/python3: 79 modules, 2314 tests, red
set unchanged from the measured baseline (test_contract_invariance,
test_diagnose).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hat stay

ADR-004 § *Where tolerance lives now* is two rules, and only the first is the
one this row is named after:

| | Before | After |
|---|---|---|
| `/perry adopt`, `/perry diagnose` | tolerant | **tolerant, and more so** |
| the five tools | tolerant | **strict**, against Perry's declared shape |
| Reading an unmigrated project | tolerant | tolerant enough to *report*; no writes |

Twenty candidate branches were examined across `bin/perry-state`,
`bin/perry-task`, `bin/perry-goals`, `bin/perry-decide` and `bin/perry-lint`.
**One** is a per-shape tolerance branch by the ADR's own definition — a branch
that accepts a shape the declared schema does not describe and silently reads
it anyway — and it is retired here:

`bin/perry-task § Board.find_section_row` fell back to column 0 "for a section
whose header names no id column at all: those tables predate the glossary and
their first cell is the handle by convention". All three sections it serves
declare an id column whose absence `perry-lint` reports as a `table-columns`
ERROR, and `norm()` already resolves `编号`, so the branch was unreachable for
a declared file and was carrying only a guess. Measured cost of the guess, now
a test: on `| Risk | Opened | Status |` it matched the risk *statement* and
wrote the clear into that row.

The refusal names the missing column and the road, per ADR-004 § 4.

The other nineteen stay and are enumerated in the task report. The ones most
easily mistaken for this branch: `--group` and its narrow-section widening
(ADR-004 leaves `--group` *"open, deliberately not decided here"*),
`perry-state § idle_days`' `Idle` fallback and the bullet `## Top risks`
reader (both DECLARED shapes — `optional: true` and an optional column), the
`perry-decide` ADR header reader (`decisions/ADR-*.md` is not in `files[]` at
all), and `perry-lint § present()`'s decoration retry, whose removal
reintroduces the measured defect where `perry-migrate` appended a second empty
`## Top risks` and certified the result.

`tests/test_retired_tolerance.py` is new because the existing suite runs almost
entirely on migrated fixtures: § 2 builds a board that never migrated — the
project's own workstream headings, bullet risks, `Idle` and no `Asked`, a
four-column `## Intake`, prose in `Frequency` — and asserts `perry-lint --root`,
`perry-lint --claims`, `parsers.load_snapshot`, `perry-task list` and
`perry-state` all still read it and write nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge-check on 421a4ea, alone and alongside TASK-045: nothing new is red on
either merged result.

Verified independently: perry/ untouched, schema/ untouched, none of TASK-045's
five tools touched. `RISK_STORE_NO_WRITE_PATH` and `risk_store_refusal` are
gone (grep count 0) and `RISK_STORE_UNDECLARED` survives as a LIVE guard at
bin/perry-tasks:509 — `if not risk_store_is_declared()` — so withdrawing the
claims[] entry switches the command off rather than leaving a stale message.

── what the spec got wrong, which is the valuable half ──

1. The byte gate is REACHABLE and the spec treated it as hypothetical.
   `risk_records` dedupes a repeated id into one record (order counts records,
   not lines) while `risk_plan` looks every row up by id and renders BOTH
   lines from that one record — so the second row comes back carrying the
   first row's Risk. It is now the refusal test, naming row and column.

2. Rule 3 cannot be implemented as a byte check alone. A `bullets` section
   derives [] records, rendering [] changes no line, and cmp is therefore
   CLEAN — a byte-only gate would write an empty store over a section holding
   real risks and report success. The shape check has to come first and be
   separate. Refuses on absent / bullets / foreign, each with its own message.

3. `_would_discard`'s refusal in perry-tasks § write is DEAD CODE: line 483
   returns 1 when --from-board is absent, and line 529 guards the loss refusal
   with "--from-board" not in argv, which cannot be true past 483.
   perry_md_store § main has the correct version. Reported, not fixed here.

4. `cmd_risks_render` read an EMPTY store as NO store — load_risk_store returns
   [] for both and the source branch keyed on the count. A header-only
   register imported fine and then reported source: "board" about a file on
   disk, which is exactly the claim rule 1 says has to mean something. Fixed,
   because the new command is what makes it reachable. The only pre-existing
   behaviour changed; two tests pin both halves.

5. OPERATIONAL, and it decides when perry/ migrates: after an import,
   `perry-task risk-add` produces a drift warning, because risk-add writes the
   board and the event log and NOT risks.jsonl. Measured, not predicted. Do
   not migrate this project's own risks until that lands.

── the decision rule 5 asked for ──

The import appends NO event, stated in the docstring and asserted by
test_the_import_appends_no_event. Reasoning: risk-add/risk-clear append
because a risk was raised or retired THAT DAY; an import raises none, and any
event it appended would stamp today on a row that may be nine months old — the
same falsehood `opened: ""` refuses two rules up, into a log perry-state reads.

`cleared` is carried across from the store on disk: it is the one stored field
the four columns cannot express, so --from-board cannot be read as saying "",
or the second run would delete what the first wrote.
…, 19 named

merge-check alongside TASK-040: nothing new is red.

Verified independently: perry/ and schema/ untouched; the schema DOES declare
the High-stakes prefix (state-schema.json:2068, `^High-stakes|^高风险操作`)
that bin/perry-state:897 claims it does not, so finding 3 holds — the comment
is stale and the code is right.

── the count is the deliverable, and it is 1 ──

  bin/perry-task    6 examined, 1 retired
  bin/perry-state   6 examined, 0
  bin/perry-goals   3 examined, 0
  bin/perry-lint    3 examined, 0
  bin/perry-decide  2 examined, 0

A V4 row that deletes one branch looks like under-delivery and is not. Every
row of ADR-004's own Context table had already been discharged some other way:
the four-column intake was fixed by resolving columns BY NAME, so no branch
survives; TASK-040's two became require_migrated's refusal plus risk-migrate;
M-8 became --group, which the ADR then declined to decide; and the ADR says
parse_due "is NOT covered by this". The row's value is the enumeration.

── the retirement, and why it mattered ──

`Board.find_section_row`'s positional id fallback — `next((… id_column_keys()),
0)`. Its three callers are risk-clear, answer and cadence-done, and all three
sections DECLARE an id column whose absence perry-lint reports as a
table-columns ERROR; norm() already resolves 编号 through the glossary, so it
was not carrying i18n either. What it carried was a GUESS.

The mutation is not a message assertion. With the fallback restored,
`risk-clear "the vendor contract lapses"` against a `| Risk | Opened | Status |`
header MATCHED THE RISK STATEMENT IN COLUMN 0 and wrote
`cleared 2026-08-21 — it stopped` into that row, exit 0. A silent write into
the wrong row of somebody's file.

── the finding that reframes the row ──

The SCHEMA absorbed most of the tolerance rather than the tools shedding it.
`## Top risks` carries "optional": true with a note reading "READING a bullet
list is fine and stays fine"; `Idle` is declared "Read when present so no
existing board breaks"; `Last evidence` was demoted from required. These READ
as tolerance branches in the code and are DECLARED SHAPES in the schema.
Anyone deriving the list from the code alone — exactly what the spec warned
against — would have deleted several of them.

The closest call left standing is named rather than quietly kept:
`parse_tracks`' `col("mode").lower() or "project"`. A Tracks table missing the
required Mode column is a table-columns error and this silently reports every
track as project — but the same expression covers a BLANK Mode cell, which
lints clean, so retiring it would change a declared-legal file.

── verification 2, the one that catches the failure mode ──

A new LEGACY fixture that never migrated — work under `## Open — 投资线`, no
P0/P1/P2, bullet risks with a struck-through cleared entry, a four-column
Intake, prose in Frequency. perry-lint READS the whole file and reports 3
missing-section errors rather than refusing to read it; `perry-lint --claims`
(the one command adoption runs before any goal talk) answers rc 0;
load_snapshot returns risks_source == "bullets" with both risks and the
cleared flag; perry-task list names both project headings in sections_read and
computes idle_days: 12 from Idle; and BOARD.md is byte-identical afterwards.

Also reported, not fixed, and correctly so: `perry-goals § commitment_id_at`
retries `ID` after `Id`, which is dead under squash. Deleting it would redden
nothing, and "retire a tolerance branch" is not "delete dead code".
Post-merge suite, /usr/bin/python3 tests/parallel -j 4:
  80 modules · 2331 tests · 2 red — test_contract_invariance and
  test_diagnose, both pre-existing and both unchanged.

TASK-040 done at V3. TASK-045 done at V3, lowered from V4: no independent
fresh-context review ran on its enumeration, and the enumeration IS its
deliverable, so the rung has to say what happened.

Three intake rows from the two reports:

  · risk-add and risk-clear write BOARD.md and the event log and NOT
    risks.jsonl, so any project that runs the import drifts on its next risk.
    Measured by the agent. This is why perry/ has not been migrated.
  · _would_discard's loss refusal in perry-tasks write is unreachable — line
    483 already returned 1 when --from-board is absent, and line 529 guards on
    it being absent. perry_md_store § main has the correct version.
  · perry-state:897 claims the schema does not declare the High-stakes prefix.
    state-schema.json:2068 declares '^High-stakes|^高风险操作'. Verified here:
    the comment is stale, the code is right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tures

The row sat blocked on two separate things and both cleared today.

1. THE ESCALATION GATE. Pre-flight refuses on the fragment `diagnose`
   (.perry/hook.md:30). Released by the user 2026-08-21, FOR THIS ROW — the
   TASK-126 release of the same false positive on 2026-08-20 did not carry
   over. Recorded in a file, because a release that exists only in a
   conversation cannot be audited later.

2. THE DESIGN CHOICE the spec refused to make for the user, because A and B
   are different claims about what perry-diagnose is FOR. The user chose A:
   a fixture is not project state and diagnose must not count it. The spec
   carries it as settled and forbids the flag that would allow both.

What A does NOT settle is the objection the row was opened with: diagnose runs
on ANY folder, so a literal `tests/fixtures/` is a guess about somebody else's
layout. The spec rules that string out and points at
`bin/perry-explain § is_illustrative` (line 105), which already answers this
shape of question BY NAME rather than by path and already carries `fixtures`,
`tests` and `samples` in ILLUSTRATIVE_PARTS. The agent must say whether that is
the same question — `is_illustrative` asks whether an ID is an example,
this row asks whether a FILE is project state — and reuse rather than re-spell
it if it is.

The spec also warns that the numbers moved: it was recorded as 2 != 1 and is
1 != 0 today, because two real queue rows were answered this morning. Same
defect, different denominator. One agent already misread that as a different
failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pre-flight passed every gate: high-stakes list armed (8 items), no stop
signal, recovery clean, hook's Autopilot defaults identical to the skill's.
Then the plan came out empty.

  no spec                          32 rows
  spec carries no Dispatch mode     1  TASK-050 — an ACCEPTANCE-CRITERIA doc,
                                       not a dispatch spec
  already past dispatch             1  TASK-094 (review)
  already in flight                 1  TASK-153
  blocked on an open dependency     4

69 spec files exist under perry/evidence/. EXACTLY THREE belong to a row that
is still open, and none of the three is dispatchable.

Autopilot's stated precondition is 'you have >= 3 P0/P1 specs ready to
dispatch'. That has never been true here, and the cause is the working pattern
rather than an oversight: the PMO writes a spec AT DISPATCH TIME, for one row,
after re-verifying that row's findings against today's code. Spec and dispatch
are one act, so a queue never accumulates.

Today is the argument for that pattern, three times over: TASK-037's recorded
findings were mostly already fixed by TASK-119's rewrite; TASK-040's title
still described a markdown-table task ADR-007 had made obsolete; TASK-045's
precondition had to be checked at dispatch and its constraint had to reach the
prompt verbatim or the agent would have deleted fallbacks adoption needs.

The first-run marker was NOT written — nothing was dispatched, so the next
invocation is still a first run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`perry-diagnose`'s queue register counted `USER-014` out of
`tests/fixtures/sample-project/BOARD.md` as one of Perry's own open
decisions. `perry-task` reads `perry/BOARD.md`, where every `USER-` row
is answered, and reported 0. The two registers stopped reconciling —
and only after the last real row was answered, so the defect was
observable only once the work was finished.

The user chose on 2026-08-21: diagnose skips test fixtures.

It skips them by asking the rule that already exists.
`perry-explain § is_illustrative` answers *is this file's job to explain
or to track?* — by directory NAME, relative to the root being diagnosed,
never by a hard-coded path. "Is this file part of the project's state?"
is that same question asked from the other side, so `open_user_asks`
calls it rather than spelling it a second time. `perry-diagnose` already
consulted it at three sites; the queue register was the one that had not.

Two ways this could have traded one silent miscount for another, both
closed and both tested:

* a project that itself lives under `tests/fixtures/` — its own paths are
  root-relative, so none of those names is ever seen, and its queue is
  counted in full;
* a row defined in a fixture that sorts ahead of the real board — an id
  that IS a row of this board's queue is this project's row, whatever
  `harvest` picked as its definition point.

The `*/BOARD.md` fallback skips illustrative boards for the same reason:
reading `examples/BOARD.md` hands this project somebody else's queue.

`tests/fixtures/sample-project/BOARD.md` is unchanged and keeps its
`USER-014` row. No record under `perry/` was edited.

Verified: the reconciliation test passes on this repository; reverting
`bin/perry-diagnose` turns it red again and turns nothing else red.
80 modules · 2334 tests, /usr/bin/python3. `test_diagnose` goes from 2
failures to 1 — `test_perry_itself_passes_its_own_id_checks` is TASK-165
and out of scope; `test_contract_invariance` is red for its own
already-diagnosed reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge-check on c3a88e8: nothing new is red.

── the spec's premise was wrong, and the agent said so ──

The spec put the design work on choosing a predicate that does not hard-code
somebody else's layout. There was nothing to choose. `bin/perry-diagnose`
ALREADY imports perry-explain through `load_sibling` — whose docstring already
states the rule, "so the ID scan below shares perry-explain's single
implementation rather than carrying a second copy" — and already called
`explain.is_illustrative` at THREE sites on the base (lines 403, 476, 721).

The queue register was the FOURTH site that had never been wired to it. The
fix is a call, not a rule.

Verified here: `def is_illustrative`, `ILLUSTRATIVE_PARTS` and
`ILLUSTRATIVE_STEMS` each appear exactly once in the tree, all in
bin/perry-explain. The only `tests/fixtures` strings in the branch's
perry-diagnose are in prose at lines 344/355/358 — none in code.

Its answer to the question the spec required: SAME QUESTION.
`is_illustrative`'s docstring is "True for a path whose job is to explain, not
to track", and "is this file part of the project's state" is that sentence read
from the other side.

Identity is asserted rather than agreement over a corpus:
`test_the_queue_register_asks_perry_explains_own_predicate` checks
`"is_illustrative" not in vars(diagnose)`, then REPLACES
`explain.is_illustrative` with a stub and watches the answer flip [] -> [USER-900].

── the mechanism was more subtle than the spec described ──

`harvest` already suppressed `in_tracking_doc` for illustrative files.
USER-014 reached `in_tracking_doc=True` through PERRY'S OWN RECORDS discussing
the fixture in prose — journal 2026-08-21:85, TASK-150-spec:42,
TASK-121-result:62. Those mentions are legitimate; only the row's DEFINITION
POINT is the fixture. Filtering on mentions would have punished writing about
a fixture. Filtering on where the row LIVES is the correct cut.

── it closed a second way to trade one miscount for another ──

`harvest`'s `defined` is first-seen in a SORTED walk, so a fixture sorting
ahead of the real board owns a shared id. Filtering on the definition point
alone would have dropped a real pending USER-001 whose id a fixture had
claimed. The register now keeps any id that IS a row of this board's queue,
whatever `defined` says, and the `*/BOARD.md` fallback skips illustrative
boards — otherwise an `examples/` directory would supply a project's entire
queue.

── the objection the row was opened with, answered by fixture ──

A project whose real root is literally <tmp>/tests/fixtures/vendor-app/ keeps
its queue in full: `{"queue": 1}`, sample naming USER-001. `is_illustrative`
reads paths RELATIVE TO THE ROOT BEING DIAGNOSED, so those directory names are
never seen. That is why by-name beats by-path here.

test_diagnose drops from 2 failures to 1. The survivor is
test_perry_itself_passes_its_own_id_checks — TASK-165, untouched, neither id
added to anything, no evidence record edited.
…worth of two

Post-merge, /usr/bin/python3 tests/parallel -j 4:
  80 modules · 2334 tests · 2 red
test_diagnose goes from 2 failures to 1. The survivor is
test_perry_itself_passes_its_own_id_checks (['TASK-007','TASK-9999']) — that
is TASK-165, untouched, neither id added to an exemption list and no evidence
record edited, exactly as the spec's out-of-scope section required.

The remaining two failures now have one owner each: TASK-165, and the
union-typed key in test_contract_invariance.

Filed to intake, sharpening the row opened this morning: the agent's first
baseline showed a THIRD red, test_host_support's opencode cap at 3 != 2, which
did not reproduce in three later runs. Its reading is better than mine was: a
20-way concurrent perry-dispatch-limit register race at
tests/test_host_support.py:167 LET A THIRD REGISTRATION THROUGH A CAP OF 2
under load. That is a cap leaking a slot, not a flaky assertion — and this
project reserves slots with that command before every dispatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Source: aimark/doc/perry-contract-gaps-4.md, 2026-08-21. Every load-bearing
number in it was re-measured here before a row was opened. Triage:
evidence/2026-08/aimark-contract-gaps-4-triage.md

All six document counts are exact: evidence 210, decisions 9, design 8,
handoff 7, journal 6, knowledge 1.

── the one that changes their plan ──

Their stated top priority — "if you only take one" — is a read surface for
knowledge cards, on the grounds that `perry-knowledge` is propose/promote only
and "there is no read side at all".

It exists. `perry-knowledge list --json` emits contract
`perry-knowledge/list/1.0` with ALL NINE fields they asked for — topic, slug,
claim, kind, owner_role, source, last_verified, invalidated_by, path — plus
the aggregate they called a bonus: a per-card `stale` flag and total/stale
counts. The strip they want is one call away and always was.

Why a careful consumer could not find it: THERE IS NO
`schema/knowledge-list-contract.md`. A tool that emits a `contract:` string
and has no contract page is invisible to anyone reading `schema/`, which is
where a consumer is told to look — and `schema/README.md` still says three
contracts.

Same defect class an aiMark agent surfaced hours earlier about
`conformance.missing_projection`: it ships, it is real, and no page a consumer
reads announces it. `contract_key_parity` cannot see either, because it
compares documented against emitted WITHIN a page that exists.

TASK-169 is therefore a page and an announcement, not a build.

── the rest, all reproduced ──

TASK-168 P1  events returns seq 0-5 dated 2026-08-16 on a 726-event log while
             contract line 3, line 21 and --help all say TAIL. aiMark reads
             437 KB per project to work around it.
TASK-170 P1  an answered `USER-` ask is in NO register: `asks.items` is []
             after USER-015/016 were answered today, while TASK-040 still
             names USER-016 in `depends_on`.
TASK-171 P2  the events key table misses THREE kinds, not the two they named:
             `ask`, `answer`, `intake`.
TASK-172 P2  four of six document collections unreachable; DESIGN-NNN folds
             in. NEEDS A DECISION on whether documents are a lane's business.
TASK-173 P1  an Objective is not a record — all five return `id: ""`,
             `okr.jsonl` holds zero objective rows. NEEDS AN RFC; DESIGN-007
             § 5.3 already plans the Goal store and this is the row it is
             missing.

Intake: DESIGN-007:429 names the store `perry/goals.jsonl`; disk and `claims[]`
both say `perry/okr.jsonl`. DESIGN-007 is the stale one.

Two of their conclusions did not survive measurement — § 3 above, and § 5.2
where they named two missing event kinds and there are three. Everything else
held exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TASK-168 carries a recommendation with the argument rather than an open
choice: take A, make the first page the tail, because three texts, the
contract's own example comment and the presence of `more` all describe
tail-first — the implementation drifted from the design, not the reverse. And
it names what would be a reason not to: A changes the meaning of a shipped
payload, so it needs a minor bump AND a semantics entry, and an in-repo
consumer depending on head-first would stop it.

TASK-169's spec leads with the fact that changes the shape of the work: the
surface aiMark asked to have built already exists and emits all nine fields.
The row is a page and an announcement.

Both specs pin the same baseline and name both pre-existing reds with their
owners, after two agents today misattributed one of them.

The two branches are file-disjoint: 168 is bin/perry-task plus
events-list-contract.md, 169 is a new knowledge page plus schema/README.md.
TASK-171 also lives in events-list-contract.md, so it waits for 168.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Slots 2/2. The four rows are not file-disjoint, so they go in two waves:
  168  bin/perry-task § cmd_events + schema/events-list-contract.md
  169  schema/knowledge-list-contract.md (new) + schema/README.md
  171  schema/events-list-contract.md   -> collides with 168
  170  bin/perry-task § cmd_list asks    -> collides with 168

TASK-172 and TASK-173 are NOT dispatched and it is deliberate: both say on
their own rows that they need a decision first. 172 asks whether documents are
a lane's business at all; 173 asks whether an Objective gets a minted id.
Dispatching either would hand an agent a call that is the user's, which is the
thing this project's escalation and RFC discipline exists to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. TASK-173 — option A. An objective record in okr.jsonl with a MINTED stable
   id. Not a computed key, not a declaration that objectives are
   unaddressable. RFC first.

2. TASK-172 — deferred. aiMark reads the directories directly for now.
   The cost is recorded on the row rather than left in chat: aiMark then owns
   a reader of Perry's LAYOUT, and `perry relocate` moves every claimed path,
   so a consumer holding `perry/design/` breaks silently the first time a
   project moves its state root. aiMark's own document says it did not want
   this — 'a second reader of your layout is the thing this whole integration
   exists to avoid'. The decision overrides that knowingly.

3. Split, as recommended. TASK-174 opened for the cheap half: autopilot reads
   tasks[].startable instead of re-deriving it. Contract 1.12 stopped
   startable reading the stored status and 1.14 made a USER- ask a graph node,
   so that rule has exactly one implementation today and autopilot carries a
   second. The other half — late spec generation, and why an Out of scope
   green-light cannot survive a machine author — goes to the RFC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…K-168)

`perry-task events --json --limit 6` returned seq 0-5, the OLDEST events on a
733-event log, five days stale, with nothing in the payload saying so. Four
texts promised otherwise: the contract's first line, its § Why this exists,
`--help`, and the contract's own paging example (`# newest window is the FIRST
page`). A consumer that trusted them shipped a "recent activity" panel of the
oldest events in the project; the one that caught it works around it by asking
for a window larger than any real log — 437 KB per project — and slicing the
end itself.

**Option A: the implementation is what drifted from the design, so it is the
implementation that moved.** No in-repo consumer depends on head-first —
`perry-events` appears in `bin/perry-task`, the two fixtures and `schema/` and
nowhere else; `viewer/`, `work/reference/` and every other `bin/` tool read
`.perry/events.jsonl` directly and never this payload.

The window is now a half-open `[start, end)` anchored at its END: with no
cursor the end is the log's end. Paging therefore runs backwards — if the
first page is the newest window there is nothing after it to page to — so
`cursor` is the OLDEST event in the window, the boundary the next page ends at
(exclusive, which is what keeps the pages a partition), and `more` asks
whether older events precede it. `seq` is unchanged and still absolute.

**Same key, same type, different rows**, which is the one case "`1.x` only
adds keys" does not cover: minor bumped to 1.1 and the payload now carries
`semantics`, with an entry naming `events`, `cursor` and `more`. Rotation
restarts at the newest window rather than the head, since restarting at the
head would answer a rotation with the oldest events in the project.

`tests/test_events_feed.py` pins the direction. Every case in the new
`TestTheFirstPageIsTheTail` uses a log LONGER than the window — the one thing
the pre-1.1 tests never did, which is why nothing here disagreed with the
code for three days — plus a full-log paging walk asserting the pages are the
log, in order, exactly once each.

Verified: full-log page-through of all 733 events in windows of 100 → 8 pages,
each seq exactly once, reversed concatenation == the log. `perry-lint --root .`
0 errors. Suite 80 modules · 2347 tests · 2 red, the same two as the baseline
(`test_contract_invariance`'s union-typed `intake.oldest_undischarged`,
`test_diagnose`'s TASK-007/TASK-9999) — neither this row's.
`tests/fixtures/contract-shapes.json` deliberately NOT regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge-check on 6cf71c9: nothing new is red.

Option A, and the reasoning held: FOUR texts described the tail and the code
did not — contract line 3, the why-this-exists paragraph, `--help`, and the
paging example's own comment. The implementation is what drifted.

Verified independently rather than taken from the report.

  `events --json --limit 6`  ->  seq [727..732], ts 2026-08-21T15:35, total 733
  contract: perry-events/list/1.1, `semantics` present and announcing the flip

And the check that would catch a bad fix, re-run here from scratch: page the
whole log in windows of 100 through the cursor, reverse and concatenate.

  8 pages: 633-732 / 533-632 / 433-532 / 333-432 / 233-332 / 133-232 /
           33-132 / 0-32
  reassembled == range(733): True   duplicates: 0   gaps: 0

An off-by-one at any page boundary fails that.

── what the direction change forced ──

Paging necessarily runs backwards: if the first page is the newest window
there is nothing AFTER it to page to. So the window became a half-open
[start, end) ANCHORED AT ITS END, `cursor` became the OLDEST event in the
window, and `more` became 'older events precede it'. `seq` is unchanged and
still absolute, so a consumer reassembling pages sorts on `seq` regardless of
direction. Rotation restarts at the NEWEST window — restarting at the head
would answer a rotation with the oldest events in the project, which is the
defect being removed.

1.0 -> 1.1 with a `semantics` entry, not a silent flip: same key, same type,
different rows. No in-repo consumer depends on head-first — `grep -rn
perry-events` outside .git hits only bin/perry-task, the contract, two
fixtures and historic evidence; viewer/ and the other bin/ tools read
`.perry/events.jsonl` DIRECTLY and never this payload.

── why the old suite never caught it ──

Every log in it was SHORTER than the default limit, where head and tail are
the same rows, and the one paging test asserted [0,1,2,3] because that is what
it saw. Every new case uses a log longer than the window.

── the fixture the spec worried about was the wrong one ──

`contract-shapes.json` is structurally incapable of noticing this: its
CONTRACTS dict is perry-task/list, perry-goals/list, perry-decide/list. Not
regenerated, correctly. The one that did need a line is
`contract-key-parity.json`, keyed by full contract id. It was HAND-PATCHED to
the events entry alone rather than re-recorded, because `--record` also
absorbed unrelated live-state churn in perry-task/list that has nothing to do
with this row. Events parity is 27/27, both diff lists empty.

TASK-171's key table in the same file is untouched, as instructed.
The finding worth keeping beyond the row: a stale wait-loop was spinning at
100% CPU on this machine from an earlier task in this same session. Its loop
was

    until ! pgrep -f "tests/parallel"; do sleep 5; done

and ITS OWN COMMAND LINE CONTAINS THE PATTERN, so it can never exit. While it
lived it made pgrep/ps for 'tests/parallel' report a false positive for EVERY
agent on the machine — which is exactly the check every dispatch prompt on
this project tells an agent to run before trusting a suite reading.

It was gone by the time the PMO looked. Filed anyway: the pattern recurs
because the prompt asks for the check and the obvious way to write it is the
broken way.

TASK-171 and TASK-170 unblock now that 168 has merged — both collided with it,
171 on schema/events-list-contract.md and 170 on bin/perry-task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aiMark's round-4 top priority asked for a read surface for knowledge
cards. It shipped long ago: `perry-knowledge list --json` emits
`perry-knowledge/list/1.0` with all nine fields the request names, plus
`total`/`stale` and a per-card `stale` flag. What was missing is the
thing a consumer is actually told to read — a page in `schema/`. A tool
that emits a `contract:` string and has no contract document is
invisible to anyone reading that directory, which is why a careful
reader concluded there was no read side at all.

- `schema/knowledge-list-contract.md` documents all 16 emitted paths,
  six top-level and ten per-card, derived from the live payload rather
  than from the source.

- The `stale` predicate is stated as the code computes it: a strict
  day count on `Last verified` against `thresholds.knowledge_stale_days`
  (90), `false` whenever `lib.is_iso_date` cannot read the cell — so
  `false` means "not measurably stale", not "verified recently" — and
  `invalidated_by` is NOT an input to it. The tripwire is enforced at
  write time by `promote` and reported afterwards by
  `perry-lint --knowledge`; the day count is the schema's declared
  backstop for a tripwire nobody was watching.

- `schema/README.md` stops saying three read contracts, lists all six,
  and says how a reader discovers one: the `schema/*-contract.md` glob,
  not the table. TASK-130's version pins are left for TASK-130.

- `tests/test_contract_key_parity.py § TestThisREADMEAgreesWithTheGlob`
  holds the README's count and its row set to that glob, so the number
  cannot rot again silently.

- `tests/test_knowledge_promotion.py §
  TestTheContractPageStatesTheRealStalePredicate` pins each of the
  page's three claims to the tool at the boundary and on every
  unreadable-date shape, rather than to the one card checked in.
  Mutation-proved in both directions before commit.

`tests/contract_key_parity.py` is untouched: discovery is a glob, so the
new page was picked up on its own and the baseline was re-recorded.
KR-O2.4 stays 0 (0 documented_not_emitted, 0 emitted_not_documented,
across 6 contract files). `bin/perry-knowledge` is unchanged; no field
was added to the payload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lways lacked

merge-check on d280606: nothing new is red.

Verified independently, running the instrument in the worktree rather than
reading the report:

  6 contract pages discovered
  perry-knowledge/list/1.0   doc 16  emit 16   dne 0   end 0
  KR-O2.4 metric: 0

bin/, perry/, schema/state-schema.json and schema/events-list-contract.md are
all untouched — 0 files. The row was a page and an announcement, exactly as
triaged, and the payload gained nothing.

── the stale predicate, stated because a consumer cannot guess it ──

From bin/perry-knowledge, confirmed in the source here:

    stale_days = SCHEMA_THRESHOLDS["knowledge_stale_days"]["value"]   # 90
    "stale": bool(age is not None and age > stale_days)

Three properties, each mutation-proved rather than asserted:
  · STRICTLY greater — 90 days is false, 91 is true. Flipping > to >= reddens
    the boundary test.
  · An UNREADABLE date is false, not true — absent, an em dash, prose, a
    calendar-impossible 2026-02-30, and future dates all yield false. So
    `stale: false` means NOT MEASURABLY STALE, never "verified recently".
  · `invalidated_by` IS NOT AN INPUT. Folding it in reddens the tripwire test.

That third one is what the announcement had to get right. `--help`'s claim
about `Invalidated by` is true and describes a DIFFERENT mechanism: the
tripwire is enforced at WRITE time by `promote` and reported afterwards by
`perry-lint --knowledge`. The `stale` flag is the schema's own stated backstop
— thresholds.knowledge_stale_days' note says so verbatim: "Invalidated by is
the sharper signal — this is the backstop for a tripwire that never fired
because nobody was watching the system it names."

── three findings, all of them left as findings ──

1. THE RECORDED PARITY BASELINE WAS ALREADY STALE AT HEAD, in a state-dependent
   way: 115/115 recorded against 113/113 live, with six `intake` rows traded
   for eight `asks` rows in `unassigned`. Proved not to be this row's doing by
   stashing and re-measuring at HEAD. KR-O2.4 is unaffected — the asserted
   fields are the two diff lists and both stayed empty — but the fixture's
   counts drift with the board and nothing catches it. Same class as
   test_contract_invariance's union-typed key, one fixture over.

2. tests/contract_key_parity.py's docstring still says FIVE contracts; there
   are six. Deliberately not edited in a row whose whole point was not
   touching the instrument.

3. A FIELD THAT PLAINLY SHOULD EXIST, and was not added: the payload emits
   `stale` and NOT the threshold that produced it. Confirmed here — the
   top-level keys are contract, project_root, state_root, cards, total, stale
   and nothing else. So a consumer cannot honestly render "unverified for over
   90 days" without separately reading schema/state-schema.json, which is the
   read-the-schema-not-the-payload dependency this whole arrangement exists to
   remove. A `stale_days` key closes it additively at 1.1. Out of scope, so
   the page tells consumers to read the threshold or to say "past the
   project's threshold".

── the README had TWO stale pins, not the one TASK-130 names ──

  line 86  perry-task/list/1.11   actual 1.14
  line 88  perry-goals/list/1.0   actual 2.1

TASK-130's title names only goals. Both left untouched per instruction; the
page instead states that the table's version column is a convenience nothing
checks, names the payload's own `contract` string as authoritative, and points
the pins at TASK-130 — so the table is no longer quietly wrong while that row
keeps its work.

A new test pins the README's contract COUNT to len(parity.discover()), and
requires every discovered page's filename to appear — a count alone would pass
on six rows for five pages plus an invention.
ranjiao and others added 28 commits August 28, 2026 14:39
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… Input Queue store)

TASK-202's motivating case is measured on gimegime-pmo and is worse than
TASK-200 reported: 5 hook bullets produce 3 fragments and three contribute
zero, unwarned -- including the one naming max_daily_loss and max_position on
a project that handles real money. max_position is the same fragment TASK-200
found the ROLE CARD catching on a seam, so the card was compensating for a
hook that could not see its own rule. Perry's own hook is clean (8 bullets,
35 fragments, 0 dead), which is the trap: a test reading this repository
proves nothing.

TASK-197's judgement call is which columns are stored. Idle is an age, and a
live project deleted the column because a stored age is stale the moment it is
written. TASK-196 answered the mirror question -- discharged is stored because
it rides inside prose the reader cannot re-derive -- so the rule exists; apply
it. Also: Perry's board has six columns and the docstring says five, so one of
them is stale and the spec tells the agent to establish which rather than
trust either.

Both specs point at TASK-196's result as the closer precedent, and TASK-197 is
explicitly told the byte gate may be a tautology here too -- reporting that is
a result, not a failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-measured every claim here rather than accepting it. Two corrections to
their account, one of which makes the finding worse.

They attribute the undocumented migration and migration-correction event kinds
to bin/perry-migrate and ask to widen the derivation to every binary that
appends. No binary writes either kind -- perry-migrate's hits are
schema.get('migration'), a schema section of that name. BOTH WERE HAND-
APPENDED, one of them by me. So the fix they propose has nothing to widen to,
and the real defect is the absence the hand edit papered over: Perry has no
writer for a migration event. TASK-204.

They report semantics on 1 of 4 payloads; it is 2 of 5 -- events carries two
entries. Their consequence stands exactly: CONTRACT_TESTED.goals can never go
red because changed is empty by construction, so it is a comment and not a
guard. TASK-205.

Confirmed independently on ts order: their first perry-time assertion was that
log order and instant order agree, it failed on the backdated migration event,
and they concluded the assertion was wrong rather than the log. I reached the
same conclusion at merge time by checking that the log already carried an
inversion at seq 66-67. Their ask is a page edit and it is right -- the stated
reason for 'do not re-sort' is second-precision ties, which reads as a
rounding problem.

The write contract asks are small and well-argued: seq on a write result
(TASK-206) and an optional --if-seq compare-and-set (TASK-207). seq is not a
stored field -- the feed computes it from position -- which is the right shape
and makes their claim true in substance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`## User Input Queue` becomes a projection of `perry/asks.jsonl`. Four verbs
(`asks-build` / `asks-render` / `asks-write --from-board` / `asks-diff`), the
claim in `schema/state-schema.json`, `check_ask_store_drift` in `perry-lint`,
42 tests. The import was NOT run on this project: `perry/BOARD.md` untouched,
`perry/asks.jsonl` still absent — that write is the user's, as it was for
risks, intake and config.

THE JUDGEMENT CALL: five of six columns are stored and `Idle` is not.

  USER-id           STORED   minted by `mint_user_id`; nothing derives it
  Needed from user  STORED   the human's sentence
  Blocks            STORED   *checked, not assumed* — see below
  Idle              DERIVED  an age; the store makes no claim about it
  Status            STORED   free prose
  Asked             STORED   a date, the INPUT the age comes from
  answered          STORED   the one field with no column, one-way from True
  order             STORED   authored row order, as risks stores it

`Idle` is `today − Asked`, `bin/perry-state § idle_days` already computes it at
read time, and a live project deleted the column because a stored age is stale
the moment it is written. So the column is layout: absent from
`ASK_FIELD_BY_COLUMN`, rendered verbatim, and COUNTED — `cells_verbatim:
{"Idle": 7}` on Perry's own board is the receipt for the decision rather than a
hole in the proof. Measured both ways on a 4-row fixture: editing `Idle`
3d→47d is 0 drifted, editing `Asked` is 1 drifted, and `asks-render --write`
restores every stored cell and leaves the edited `Idle` alone.

`Blocks` was tried as a derived column, because `blocks` on a TASK is derived
(the inverse of `depends_on`), and it does not hold: only 2 of the 7 rows on
Perry's board have an inverse edge in `tasks.jsonl` (TASK-114←USER-015,
TASK-040←USER-016). Deriving it would blank five of seven cells.

THE SPEC IS WRONG ABOUT THE CODE, IN MY FAVOUR ON THE COUNT AND AGAINST THE
DOCSTRING. `viewer/parsers.py § _parse_user_input` says Perry's own board is
"five columns with `Idle`". It is six — `Idle` AND `Asked`. The docstring was
written 2026-08-17 by TASK-039; that same task shipped the `ask` writer, whose
`ensure_section_columns` appended `Asked` the first time it ran here, on
2026-08-19 (9b80ae8). **The comment went stale two days after it was written,
by the writer its own change added.** Perry's board is a fourth shape the
docstring's list of three does not name. Docstring corrected; the spec's count
of six stands.

THE BYTE GATE IS LOAD-BEARING HERE, AND THAT WAS MEASURED. TASK-196 found it a
tautology for intake; its argument turned on intake rows having no id. A
`USER-` row has one, so the risks class is back. Eleven inputs measured —
duplicate id, escaped pipe, ragged short row, ragged long row, missing trailing
pipe, blank first cell, indented line, struck-out id, the five-column shape, an
`Idle` cell with a real number, a `Notes` column: **ten render byte-identical
either way and the duplicate id does not.** Two lines collapse to one record
and the second comes back wearing the first's cells. The gate is kept and the
eight tautological inputs are asserted as tautological, so nobody reads the
green as a general correctness check.

Intake's `n` gate is deliberately NOT copied. Nothing addresses an ask by
position — `perry-task answer USER-016` goes through `find_section_row` and
`perry-explain` resolves handles out of prose — so a row-count gate would be
the tautology in this register.

The writers are NOT converted. `perry-task ask` / `answer` still write the
section, exactly as `risk-add` and `perry-task intake` do; TASK-203 covers all
of them in one row. Three would be worse than two. A test asserts the current
state honestly: `answer` moves the board and the store now REPORTS it.

One rule moved, not changed: `bin/perry-state § answered` now calls
`viewer/parsers.py § ask_is_answered`, so the store is the fourth caller rather
than a fourth copy. Byte-identical behaviour, deliberately.

FOUND AND NOT FIXED, WITH ITS REASON: `bin/perry-diagnose:298` asks the same
question with its own regex and disagrees in BOTH directions — four of eight
real `Status` spellings. `dropped …` / `withdrawn …` are answered by one and
open by the other; `pending — will be resolved by TASK-9` is the reverse. That
is `intake_is_discharged`'s defect one register over. Not fixed because it
moves LOAD-03 counts and `test_diagnose` is the suite's one red module — a
semantic change to a checker under a red test is unreviewable. Recorded in
`ask_is_answered`'s docstring so it has a row.

Verification: byte comparison on this project's own section, count derived and
never hardcoded — 7 records, `identical: true`, all difference sets empty,
`cells_verbatim: {"Idle": 7}`. Suite 90 modules · 2723 tests · one red
(`test_diagnose`, the same 2 failures, same names, before and after).
`perry-lint` identical before and after plus the new honest line `· no
`asks.jsonl` — drift against the ask store is unchecked, not clean`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
perry-state reports history.latest_weekly is empty, so W35 is a first reading
rather than a delta. 31 rows closed this week, 27 today; all five declared
stores now exist, three created today.

Names two things the dashboard surfaced that nobody had looked at: BOARD.md's
Last updated header is twelve days stale while the file is re-rendered dozens
of times a day, and perry-dispatch-limit never registered a slot this session
because the subcommand is 'register' and I called 'acquire' -- which is not
one, so it printed usage and exited 0 every time. The cap was enforced by my
own counting and nothing else. Same tool that reported two dead agents as
running on 2026-08-27.

Both filed. The largest un-owned number on the board is the intake register:
58 undischarged, roughly half filed today by me.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…it was written

Idle is derived, not stored, and the proof runs both ways on one fixture:
editing Idle gives 0 drifted, editing Asked gives 1, and asks-render --write
restores every stored cell while LEAVING the edited Idle -- the renderer does
not repair what it never held. TASK-039's own docstring records what the other
choice cost: both rows on Perry's board carried Idle as an em-dash, the one
field the queue exists for, unfilled.

Blocks was tried as derived and failed on measurement -- only 2 of 7 rows have
an inverse edge, so deriving it would blank five of seven cells.

Six vs five: the docstring is stale, and how it went stale is the finding. It
was written 2026-08-17 by TASK-039 and calls five columns 'Perry's own board'.
The same TASK-039 shipped the ask writer, whose ensure_section_columns
appended Asked here on 2026-08-19. The comment went stale two days after it
was written, by the writer its own task added.

The byte gate is load-bearing here where it was a tautology for intake: eleven
inputs measured, ten byte-identical, and a duplicate USER- id is not. Two
registers, two opposite answers, each measured rather than inherited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dicate

`escalate_unextractable` existed only on a `RoleCard`. `.perry/hook.md` was
read by the same extractor, dropped the same lines, and said nothing — and
role cards are optional while the hook is the half every project has.

Reproduced through the real `--root` seam, on a fixture shaped like
`~/proj/gimegime-pmo`: **5 bullets → 3 fragments**, three silent.

  before   0 findings, from every surface Perry has (default lint,
           `--knowledge`, and the standup). `hook.high_stakes_armed: true`
           was the only thing any of them said about that hook.
  after    3 `hook-escalation-not-extractable` warnings, no flag required,
           plus 3 standup warnings and `hook.high_stakes_unextractable`.

Where it lands: `check_cross_file`, three lines under
`hook-high-stakes-armed`, because those two are one thought — that check says
the gate is loaded, this one says how much of it is blanks. The card's check
stays behind `--knowledge`, which is right for an optional file.

Severity `warn`, equal to the role card's on purpose. A louder hook finding
would say a rule's enforceability depends on which file it was written in,
which is the asymmetry being removed; the difference in consequence is carried
by reach — no flag — not by volume.

Unified, in `viewer/parsers.py`: `unextractable_lines` is the one predicate and
`unextractable_says` the one sentence, for the reason `escalation_fragments`'
docstring gives about extraction. The asymmetry was never in the extractor —
`line_fragments` was already shared. It was in who called it.

## Four things the spec did not have right

1. **The card half had TWO reporters, not one.** `bin/perry-state:1573` also
   warned about a role card's dead line in the standup, and its copy of the
   sentence still said the line **"has no backticked span"** — the exact
   wording TASK-201 falsified when it fixed the linter's copy and left this
   one alone. Three copies is how the third goes stale; there is now one.
2. **`bin/perry-lint` kept its own reader of the hook section**, matched with
   `heading_re`, which has no `## High-stakes` prefix tolerance while
   `P._section` does. A hook headed that way was **armed for the pre-flight
   gate and unarmed for the linter** — the exact thing `heading_re`'s own
   docstring forbids. Left alone it would have become incoherent rather than
   merely inconsistent: one run saying "no high-stakes list" and then quoting
   that list's dead bullets. Both tools read `P.hook_escalation_lines` now.
3. **The files in scope were short two.** `bin/perry-state` for (1) and (2)
   above, and `schema/state-schema.json § cross_file`, which registers
   cross-file rule ids — `test_linkage_task_exists` already asserts the
   registry and the emitter agree on severity, so an unregistered rule would
   have been a documented tool that does not exist.
4. **This repository's own fixtures had the defect.** `tests/test_conformance.py`
   wrote `- anything that spends money` into a fixture hook under a comment
   saying "Armed, so the fixture carries no lint finding of its own" — a
   bullet, so `hook-high-stakes-armed` stayed quiet, and zero fragments, so the
   gate it silenced was empty. The new check found it on its first run, and
   `--strict` went red on it. Fixed by backticking the fragments, which is what
   the fixture meant. (`tests/test_migrate.py:155` carries the same line; it is
   green and untouched.)

## The inversion, and the trap

No `.perry/hook.md` → **zero** findings, not N: no bullets means no dead
bullets. Same for a hook whose section is empty — `hook-high-stakes-armed`
says the one thing there is to say, once, and not once per rule the project
never wrote. Asserted directly (TASK-117, TASK-156).

Perry's own hook is clean — 8 bullets, 35 fragments, 0 dead — so every
assertion is made against a hook the test wrote. The single test that reads a
shipped file asserts `work/state/hook_TEMPLATE.md`'s defaults all arm, which a
bad edit to that file can falsify.

## Verification

- Suite: **89 modules · 2697 tests · one red** (`test_diagnose`, pre-existing).
  16 new tests; `test_conformance` and `test_i18n_one_table` updated for the
  two findings above.
- `perry-lint` on Perry **unchanged**: 0 errors, 3 warnings, store 197 records
  0 drifted, risks store 4 records 0 drifted, honest `no intake.jsonl` line.
  `perry-state` on Perry: same one warning as before.
- **Mutation: 12 written, 12 killed, 0 survived** — predicate off / predicate
  always-on / backticks-are-the-test again / hook loop deleted / absence
  reported as failure / severity raised / sentence re-typed inline / standup
  half dropped / payload key emptied / union key dropped / second hook reader
  restored / the two halves judging one line differently.
- `perry-conform declare` not run. `perry/` unmodified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ved it was armed

The hook's not-extractable finding lands on perry-lint's DEFAULT pass, no flag,
because the card's equivalent runs only under --knowledge and that is
defensible for an optional file a reader goes looking for and wrong for the
hook, which is read on every dispatch of every project. It also reaches the
payload, so a delegation prompt can mark a line unenforced instead of
rendering it to a dispatched agent as a constraint.

Severity stays warn, equal to the card's, and the argument is better than
mine: an error would say a rule's enforceability depends on which file it was
written in, which is the asymmetry being removed.

Four spec corrections, one of which is my chain rather than the agent's.
TASK-201 fixed escalate_unextractable's wording in perry-lint and left the
same falsified sentence in perry-state, because MY TASK-201 spec listed
parsers, schema and tests as files in scope and never mentioned perry-state.
Two reporters, I named one.

perry-lint also kept its own hook-section reader via heading_re, which lacks
the prefix tolerance _section has, so a hook headed '## High-stakes ...' was
armed for the gate and unarmed for the linter -- which heading_re's own
docstring forbids.

And this repository's own fixture had the defect while its comment claimed
the opposite: test_conformance.py:118 says 'Armed, so the fixture carries no
lint finding of its own' above a bullet that yields zero fragments. A
different test's measurability was resting on that belief. The new check found
it on its first run.

The distinction worth keeping: the asymmetry was never in the extractor --
line_fragments was already shared. It was in who called it. A shared extractor
with one caller is how a check covers half of what it describes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nine dropped as verified fixed or superseded -- each verified by running the
thing rather than by trusting today's records: test_contract_invariance is OK,
schema carries four ^P\\d{3}-O patterns, all three headings have zh aliases,
the floor reads len > (2 if ascii else 1).

Seven became rows: TASK-209 (the drift census covers one store of five, so
ADR-007's guarantee is narrower than it reads), TASK-210 (inline code spans
become dangling ids, which narrows TASK-179 before it is decided), TASK-211
(the dispatch cap protected nothing), TASK-212 (a locked decision with no row
does not ship), TASK-213, TASK-214, TASK-215.

Forty-six deferred, each with a condition that can actually arrive -- a named
row landing, a specific payload's next version, a third occurrence -- not
'later'.

And the triage hit a real gap live: 'Routed' is unreachable on a project-mode
board. route refuses unless the track is queue-mode; resolve-intake accepts
only dropped|deferred and refuses with 'routing uses route'. The two tools
point at each other and Perry's own board is project-mode, so the seven rows
that BECAME tasks are recorded as 'deferred' with a reason saying that is the
wrong word. Filed and deferred as intake 62 within the same drain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e spans)

TASK-205 is aiMark's round-5 ask, and its consequence is the sharp part:
CONTRACT_TESTED.goals can never go red because changed is empty by
construction, so it is a comment and not a guard. Empty arrays ship for
decide and knowledge because a consumer checks before it looks.

TASK-210 lands before TASK-179 and TASK-179 now depends on it: the scanner
skips fenced blocks with a line-based test at perry-explain:356 that inline
spans never reach, so Z0-9 -- a fragment of a character class -- is on the
dangling list. Removing the false positives costs nothing and makes TASK-179's
decision about real citations.

Both specs tell the agent to grep for the scan rather than the name, because
an inline-span skip in one scanner and not another is the asymmetry TASK-202
just spent a row removing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… what was asked

P002-O1-KR2 1 → 2. Both stores exist and both round-trip identical; the
config half landed this morning when the user answered USER-903.

P002-O1-KR3 gets a measurable target for the first time, and it is NOT met.
The user asked me to modify it; the measurement says 1 of 3, so that is what
it records. Probed by changing one real cell in each rendered file and
reading the drift count: BOARD.md 2 rows drifted, OKR.md 0, config.md 0.
Both misses are caught by their own diff tools, so the edit is not honoured
-- but a user running perry-lint, which is the tool this KR names, sees
nothing. TASK-209 is the row.

P002-O2-KR2 re-measured and the 0 stands: parse_board's four header calls
all sit behind 'tasks is None', verified per call site by TASK-094, and
parse_okr has none. TASK-050 stays open under this KR because it measures
something wider than the metric -- the KR being met does not close it.

Moving 'updated' re-dates every asserted number in the register, which is
TASK-155's defect. So the other three asserted KRs were re-measured too and
the register-wide stamp is earned: perry-tasks diff identical, grep -c
CLOCK_RE bin/ is 0 (the six hits elsewhere are prose citing the historical
round, including the test that enforces it), key parity 0, contract
invariance OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ut Z0-9

`bin/perry-explain § harvest` skipped a fenced block and read an inline code
span as prose. `found = find_ids(raw)` is now `find_ids(blank_code_spans(raw))`
and the primitive lives in `bin/lib`.

**Three things contradict the spec, and the code wins each time.**

1. `Z0-9` is not dangling and has not been since the evidence note about it was
   written. That note's own `## \`Z0-9\` is not a borderline id` heading DEFINES
   it, so it left the dangling list by being written about. Verification 1 —
   "resolves to nothing at all" — is unreachable for a second reason too: the
   journal spells `Z0-9` in bare prose three times while describing the defect,
   so it is a real mention under any code-span rule. It resolves, defined at
   that heading, mentioned three times in prose. `perry/` is read-only and the
   heading rule is TASK-149's, not this row's.

2. The comment this row deletes claimed exempting inline code "would remove
   most of what the check reads". Measured: of 336 ids mentioned outside a
   fence, 308 appear in prose somewhere; the 28 that appear only inside spans
   are almost all this checker's own finding codes quoted back at it. 4668
   prose occurrences against 981 in spans.

3. The spec says the skip may need to move because `perry-diagnose` might scan
   independently. It does not scan for IDS independently — it calls
   `explain.harvest`. But it DID carry a second implementation of the
   quotation rule, `CODE_SPAN = \`[^\`]*\``, for its TBD scan, under a comment
   already promising the rule this row came to write. That regex reads
   ``a \`b\` c`` as an empty span plus a real one and deletes the wrong halves.
   Both callers now reach `lib.blank_code_spans`; a guard fails on a second
   body. This is TASK-202's finding again: the asymmetry was in who called the
   primitive, not in the primitive.

**The skip is on MENTIONS and never on definitions.** Blanking the whole line
would undefine every finding code in `reference/diagnose.md`, whose glossary
rows read `| \`CTX-01\` | error | … |` — the same false positive from the other
side. A definition is a structural position and `strip_md` already reads
through the backticks around one. Measured: 0 definitions and 0 titles moved.

The two hard cases. Nesting: a run of n opens and only a run of EXACTLY n
closes, so ``a \`b\` c`` is one span blanked whole. Unbalanced: an opener with
no closer is not a span at all — the backtick stays, the text stays prose, and
the scan resumes after the run. Treating it as a span would blind the check to
everything after somebody's typo, which is the unclosed-fence bug in miniature.

An id that appears ONLY inside a code span: if something defines it, it keeps
its definition and shows "mentioned nowhere else"; if nothing does, it leaves
the id set entirely and `perry-explain` cannot resolve it. That is right
because it is the trade the fence rule already made, and safe for the same
reason — an id the project really minted has a definition point, and this
touches none of them. All 12 ids that left are fragments or fixtures:
`ADR-010` out of `DUE-ADR-010`, `ROUND-3` out of "ALL FIVE ROUND-3 FINDINGS",
`RX-005`/`USER-904` quoted as the next id a minter would produce, `FOO-001` an
admitted invented example, `AIM-*`/`LOAD-003`/`RX-3` in illustrative pages,
`ZZZ-404`/`ZZZ-405` fixture ids.

dangling before, 14: ACTION-7 ADR-010 D009-1 D010-2 FOO-001 ROUND-3 RX-005
SPEC-007 TASK-007 TASK-9999 USER-900 USER-902 USER-904 WIT-404
dangling after, 4: ACTION-7 D009-1 D010-2 SPEC-007
Moved to dangling_in_reports: TASK-007 TASK-9999 USER-900 USER-902 WIT-404.
This narrows TASK-179 hard. It does not decide it.

A second baseline red went green on its own:
`test_the_queue_register_reconciles_with_the_queue_on_this_repository` was 3 !=
0, because `USER-900`/`USER-902`/`USER-904` reached the queue register through
code-span mentions in evidence records. Same defect, other half of `user_load`.

Tests changed rather than added-to, and said so: the fence class asserted "only
fences are exempt, never inline code", which is the decision this row reverses.
Its fixtures now carry both spellings. Two report-mark tests wrote their probe
id in backticks; they now write it bare so the report mark is what decides, and
a new test pins that the backticked spelling reaches the same verdict by the
shorter route. `test_heading_defines`'s verbatim offending heading writes its
id in a span, so it now yields no entry at all — kept verbatim, with a prose
sibling for the reachability half and a new class stating the consequence
instead of hiding it.

Fence skip left line-based: a fence starts a line, and nothing structural
survives inside one anyway.

Suite: 90 modules, one red — `test_diagnose`, now ONE failure not two, and it
is `test_perry_itself_passes_its_own_id_checks` asserting dangling == [].
Reported, not adjusted. perry-lint unchanged: 0 errors, 3 warnings, 209
records 0 drifted, risks 4/0. Mutation: 7 mutants, 7 caught.
…the deliverable

`perry-goals/list` → 2.3, `perry-decide/list` → 1.1,
`perry-knowledge/list` → 1.1, `perry-roles/list` → 1.1. Each gains one key,
none removed or retyped, and each version string moved in this same edit.

WHAT THE SPEC GOT WRONG, AND WHERE I FOLLOWED THE CODE INSTEAD

1. **The spec says five payloads. There are six.** Its measurement lists
   `perry-task/list`, `perry-events/list`, `perry-goals/list`,
   `perry-decide/list` and `perry-knowledge/list`, and omits
   `perry-roles/list/1.0` — a versioned payload, frozen at a consumer's
   request, which also carried no `semantics`. It was missed because it is a
   SUBTREE of the unversioned `perry-state --json` rather than its own `list`
   command; `schema/*-contract.md` has six pages and `schema/README.md`
   already says six.

   I shipped it too. Shipping five and writing a test that says "every
   payload" would have left exactly the quiet re-opening the spec warns
   about, and `bin/perry-state` is one key and two test call-sites. The
   `tests/` scope line covers the tests; `bin/perry-state` and
   `schema/roles-list-contract.md` are the two files this commit touches that
   the spec's *Files in scope* does not name, and this paragraph is the
   reason.

2. **The spec asks `perry-goals` for entries at 2.1 AND 2.2. I wrote 2.2
   only.** `2.1` added four keys and moved no value — the contract page says
   so itself ("`current` itself is unchanged in type and in value"), and
   `bin/perry-task § LIST_SEMANTICS` states the rule the spec's own model
   follows: "a pure key addition adds nothing here". Two existing modules
   record that call as a decision for `perry-task`'s own `1.15`
   (`test_answered_ask_is_legible`) and `1.17` (`test_evidence_relation`).

   The concrete harm of a 2.1 entry: a consumer sitting on `2.0` has never
   seen `current_provenance`, `current_staleness` or
   `linked_task_completion`, and an entry would send it to re-check three
   blocks it does not have. The spec conceded the point itself — "the 2.2
   entry is the one that matters". `2.1`'s absence is now asserted, with the
   reason, rather than left as a gap.

3. **Version bumps for decide/knowledge/roles are not in the spec at all.**
   Each page's own rule 2 is "`1.x` → `1.y` only adds keys", so a payload
   that gains a key states that it did. `perry-events/list/1.1` added this
   exact key on this exact reading.

WHAT EACH PAYLOAD CARRIES NOW

- `perry-task/list/1.18` — 9 entries (unchanged, out of scope).
- `perry-events/list/1.2` — 2 entries (unchanged, out of scope).
- `perry-goals/list/2.3` — 1 entry, `2.2`: `krs[].current_provenance.
  asserted_at`, `krs[].current_staleness.since` and
  `krs[].current_staleness.moved_tasks[].at` are UTC with a `Z`, where `at`
  used to be the event log's LOCAL text compared against the register's UTC
  as one clock.
- `perry-decide/list/1.1`, `perry-knowledge/list/1.1`,
  `perry-roles/list/1.1` — `[]`, on every response including an empty store.
  Nothing was invented to fill them: none has had a value change meaning, and
  a fabricated entry would send a consumer to re-check a field that never
  moved. The array is there so the question can be ASKED — a consumer checks
  before it looks, and a key that appears only when there is something to say
  is one it cannot check.

WHERE THE PROSE CAME FROM

The `2.2` note is written from two places that already held it: the changelog
row in `schema/goals-list-contract.md` and the `TASK-144` paragraph above
`LIST_CONTRACT` in `bin/perry-goals`. Nothing was invented; the note adds
only what the entry shape requires and those two did not spell out — what a
consumer that parsed these as naive local timestamps does wrong.

`LIST_SEMANTICS` is APPENDED to, never prepended. Each list is sorted at the
emit site as well, so an entry written in the wrong place cannot ship.

VERIFICATION

- `contract_key_parity`: **0 before, 0 after** (documented_not_emitted 0,
  emitted_not_documented 0, 6 pages, 24 witness-checkable keys, 0
  unobservable). Every new key is documented in the same change.
- Suite: **91 modules, one red** — `test_diagnose`, the standing baseline.
  90 before; the new module is `tests/test_semantics_on_every_payload.py`.
- `perry-lint`: 0 errors, 3 warnings, 209 records 0 drifted, risks 4/0, with
  the honest `no intake.jsonl` / `no asks.jsonl` lines. Unchanged.
- Mutation: **13 written, 13 caught.** Four remove the array from a payload
  (decide, knowledge, roles — all three carry `[]`, all three redden; goals);
  one emits `perry-task`'s list newest-first; one drops a field from the 2.2
  entry; one invents the 2.1 entry this commit declined to write; two un-bump
  a version; four undocument the key or its entry on a contract page. A
  fourteenth attempt removed the knowledge sketch line only and SURVIVED —
  correctly, because that page also declares the key in a table, so it was
  still documented; re-run against both declarations it was caught.

`tests/fixtures/contract-key-parity.json` is edited by hand rather than
re-recorded: `--record` also absorbs an unrelated live-board change
(`conformance.review_idle` is empty today and was not when the baseline was
taken), and laundering that into this commit would hide it. Only the four
contract keys, their `contract` strings, three `documented`/`emitted` pairs
and one `witness` field moved — the deltas `--record` produces for this
change and nothing else.

`perry/` untouched. No `perry-conform declare`. No push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…half the standing red went with it

dangling 14 -> 4. Five entries moved to dangling_in_reports and the rest were
fragments: ADR-010 cut out of DUE-ADR-010, ROUND-3 out of 'ALL FIVE ROUND-3
FINDINGS', RX-005 and USER-904 quoted as next-minted values.

The result nobody asked for: test_diagnose is down from two failures to one.
test_the_queue_register_reconciles was 3 != 0 because USER-900/902/904 reached
the queue register through code-span mentions in evidence records -- the same
defect, on the other half of user_load. It went green on its own, and that red
stood all session while I attributed it entirely to TASK-179.

Three spec corrections, all followed to the code. Z0-9 is not dangling and
never was: my own evidence note's heading '##  is not a borderline id'
DEFINES it under TASK-149's heading rule, so it stopped being dangling by
being written about -- the exact mechanism TASK-179 is about, happening to the
document I wrote as evidence FOR TASK-179. My spec's verification 1 was
unreachable for a second reason too: the journal writes it in bare prose three
times.

The deleted comment claimed exempting inline code would remove most of what
the check reads. Measured false: 308 of 336 ids appear in prose, 4668 prose
occurrences against 981 in spans, and the 28 span-only ids are almost all this
checker's own finding codes.

And there was a second implementation of the quotation rule at
perry-diagnose:268, under a comment at :547 that already promised code spans
are quotations. Both callers reach lib.blank_code_spans now, with a guard that
fails on any second body in bin/.

Blanking is applied to mentions only, never definitions -- blanking the line
would undefine every finding code in reference/diagnose.md, the same false
positive from the other side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correcting the merge commit above, which lost a word to shell command
substitution: unescaped backticks inside a double-quoted -m argument ate
the id out of the sentence. It should read:

  my own evidence note's heading `## `Z0-9` is not a borderline id` DEFINES
  it under TASK-149's heading rule, so Z0-9 stopped being dangling by being
  written about -- the exact mechanism TASK-179 is about, happening to the
  document I wrote as evidence FOR TASK-179.

The merge itself is intact; only its message lost the quoted id. Recorded
here rather than rewritten, because the merge is the thing the history
needs to keep and its content is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Definition of Done 4 of 5. Item 5 (two real projects migrate at V5) is
CARRIED to 003, not waived: the user decided the target project's state is
not suitable for migration and the features land first. So the one item that
would have tested whether this abstraction survives contact with a real
project was not run, and the other four were all measured on Perry itself --
the only project that grew up under these rules.

Scores: O1 0.78, O2 0.89, O3 1.00. Two partials, both honest. O1-KR3 at 0.33
because its metric said 'reported' without saying by what, and nine days
passed before anyone edited a cell in each of the three files to find out --
perry-lint reports one of three. O2-KR3 at 0.68 against a target that was
probably wrong from the start, since driving parser lines to zero would
delete the adoption reader TASK-094 proved must stay.

43 open rows resolve to no KR against 3 that do. Recorded under Unlinked at
scoring and NOT averaged into anything, per the rule that attribution is
resolved by id or asked for. The phase declared 13 tasks and the board ran 47.

Snapshots written for both the phase file and the linkage graph, per the
procedure -- a retro without the graph can say that a KR scored but not what
moved it. Status flipped to scored, phase/CURRENT cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
My spec said five payloads and schema/README.md's own heading says 'The six
read contracts'. perry-roles/list/1.0 was missed because it is a subtree of
perry-state --json rather than its own list command, and it carried no
semantics either. It shipped too, on the agent's argument that a test saying
'every payload' while quietly excluding the one payload that lacks the key is
exactly the re-opening my spec warned about.

And the goals 2.1 entry my spec asked for does not exist, deliberately: 2.1
was a pure key addition, and LIST_SEMANTICS' own rule at perry-task:199 says
a pure key addition adds nothing here. Writing one would send a consumer on
2.0 to re-check fields it does not have. Its absence is now asserted with the
reason.

Final: task 9 entries, events 2, goals 2.3 with one, decide/knowledge/roles
each an empty array shipped on every response. Nothing invented to fill them.
13 mutations, 13 caught; a 14th survived correctly because that page declares
the key in a table too, and was caught when re-run against both declarations.

contract_key_parity 0 before and after, six pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fourth warning is mine, from running the documented procedure. NS-01
counts files in a claimed directory whose names do not match a declared
pattern, and score-phase writes -final and -linkage-final, neither of which
the inventory's pattern covers. evidence/ (12), handoff/ (4) and knowledge/
(1) have been carrying the same shape all session, so this is the standing
issue extending to phase/ rather than a new one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
P003-O2-KR1 — call sites in bin/ that read a projected markdown file as truth
while its store exists. Baseline 4, all parse_tracks: bin/perry-goals:2102,
bin/perry-diagnose:1888, bin/perry-state:139, bin/perry-task:6680. Now 0.

`stored_tracks()` reads `.perry/config.jsonl`; `declared_tracks()` falls back to
`parse_tracks` only when there is no store. `parse_tracks` itself survives —
TASK-094 proved the adoption reader must, and `perry-config diff` compares
against it. The KR excludes both roles by name.

Verified by the PMO before commit, not taken on the agent's word:
- `grep -n "parse_tracks(" bin/*` returns the definition and one guarded
  fallback at bin/perry-state:781, reached only when `stored_tracks` is None.
- `perry-state --json` → project.config.tracks[] unchanged: main/project/V3 and
  intake/queue/5d/V3, the same two rows as before the change.

Dispatched to claude-subagent; the run stalled on a watchdog before it could
commit or open a PR. Committed here on the user's explicit instruction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
P003-O1-KR2 — stores for which one run of `perry-lint --root .` prints a drift
verdict. Baseline 2 of 6 (tasks, risks); okr.jsonl and .perry/config.jsonl were
printing nothing at all while `perry-okr diff` and `perry-config diff` both
worked and the census called neither.

Verified by the PMO before commit, each one measured on a scratch copy of the
state root rather than asserted:
- The census prints a line for all six declared stores.
- MUTATION, OKR.md: edit KR-O1.1's metric cell → `OKR store: 36 record(s),
  1 row(s) drifted`, warnings 4 → 5. It goes red.
- MUTATION, .perry/config.md: edit the intake track's WIP cell → `config store:
  9 record(s), 1 row(s) drifted`. It goes red.
- REMOVAL: delete okr.jsonl → "no `okr.jsonl` — drift against the OKR store is
  unchecked, not clean". *No store* and *clean* stay different answers.

A gate whose green is a tautology is worse than no gate (phase 002, lesson 4),
so the red was demonstrated before the green was believed.

Dispatched to claude-subagent; the run stalled on a watchdog before it could
commit or open a PR. Committed here on the user's explicit instruction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The PMO half of 2026-08-28. Nothing here is code.

goals/SKILL.md:126 and work/reference/subcommands.md:902 both claimed
evidence/retro.md for the wrong lane; the correction had already landed in
goals/reference/phases.md:229 and never reached the index. TASK-216 is the
guard that could not see it — the foreign-write scan reads reference pages
only, and matches `write` but not `writes`. Both holes mutation-proven.

- evidence/2026-08/retro.md — phase 002's work-lane retro. Per-KR outcome with
  the evidence path behind each, and the number the goals-lane retro could not
  see: 10 of 100 closed rows resolved to a phase KR.
- evidence/2026-08/health-check-2026-08-28.md — three of five sub-scans have no
  organ in this project, recorded as unavailable rather than as 0.
- design/DESIGN-012-close-phase.md — locked. Four User Decisions resolved;
  §5.5 alternatives and §5.6 blast radius. The close is four commands and
  nothing owns their order.
- design/README.md — the ID convention and title house style; the index stays a
  pointer at `perry-state --section design`, because an index maintained by
  appending drifts from the files it indexes.
- knowledge/goals/linkage-graph-before-first-add.md + INDEX.md.
- phase/003-linkage.md — 9 edges, 50 declared unlinked. P003-O3-KR1 went from
  45 of 45 in the never-asked state to 0. The first attempt wrote 48 ids as one
  string because word splitting is off in this shell; repaired to `unlinked: []`
  by hand — the one removal the tool cannot do — and every declaration re-run
  through `perry-goals link`. TASK-227 and TASK-228 are what fell out.
- TASK-216 … TASK-229 and their specs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…andalone

The dispatched run appended 289 lines of test classes AFTER
`if __name__ == "__main__": unittest.main()` at line 673 of a 963-line file.
`unittest discover` still collected them — the guard block does not execute
under import — but `python3 tests/test_store_drift.py` ran only the first
674 lines and silently skipped the rest, including every mutation test the
spec asked for.

That is `test_claims.TestNoTestFileEndsEarly`, and it went red on both of its
assertions. It is also, for the seventh time on 2026-08-28, the same shape this
project keeps finding: a check that covers a narrower surface than the one it
appears to cover — this time authored into the suite by the agent whose job was
to prove its own gate could go red.

Verified: test_claims 31/31 OK; test_store_drift 47/47 OK with the classes
collected in both run modes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- TASK-229 — *no store* and *clean* are different answers, measured for two of
  six. P003-O1-KR3's metric says "measured by removing each one" and four had
  never been removed. Linked to that KR, which had zero tasks.
- TASK-230 — the full suite takes eleven minutes, and that cost has started
  changing behaviour: both of today's dispatches were killed by a 600s watchdog
  at the moment they started it. 2786 tests, 76 of 91 files spawning
  subprocesses, 276 call sites. Parallel-safety checked and mostly clear — no
  test writes into the live repo — but pytest/xdist are absent and a dependency
  is a decision under the phase Cost Ceiling, so the default is a stdlib runner.
- TASK-231 — a measured KR number has no way into the register that does not
  break one of its two rules. The file says never by hand; its only writer
  refuses to write `current` on purpose, so an invented number cannot get in.
  A measured one has nowhere to go either, and the register reports
  `asserted: 0` while four KRs moved today.

Also: P003-O1-KR3 measured at 6 of 6 — every store reports `unchecked, not
clean` when removed. It was reached as a side effect of TASK-209's census work
and would have gone unnoticed; the identically-numbered KR one phase ago scored
0.33 for exactly the opposite reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e 2026-08-28 PMO record

Five commits. Two are dispatched code deliverables whose runs stalled on a
watchdog before they could commit; the PMO verified each objectively and
committed them. One fixes a defect the dispatch itself introduced. Two are the
day's PMO record.

Known state at merge: the full suite is RED. 8 failures, each attributed
against a b288399 baseline:
  · 2 pre-existed (test_diagnose id checks, test_kr_progress_provenance current)
  · 2 were the entry-point regression — fixed in b7cef79, verified green
  · 3 are order-dependent: test_risks_store passes 53/53 alone and fails only in
    the full run. TASK-230's subject.
  · 1 is state-triggered, not code: diagnose and perry-task disagree about the
    queue count. TASK-208, proved by running one binary against two roots.
@ranjiao
ranjiao merged commit 45a355d into main Aug 28, 2026
1 of 3 checks passed
@ranjiao
ranjiao deleted the feat/work-modes branch August 28, 2026 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant