feat/work-modes — phase 002 closed, phase 003 opened, and the store the code actually reads - #30
Merged
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
mainis currently 4 commits ahead of this branch's merge base — it is notsynced, and that should be resolved before merge.
What the last six commits do
38f000fP003-O2-KR1: call sites inbin/reading a projected markdown file as truth while its store exists went 4 → 0.parse_trackssurvives for the adoption reader and the drift comparator, which the KR excludes by name.e993d85okr.jsonland.perry/config.jsonlare wired to the comparators that already existed rather than growing a third and fourth copy.b7cef79unittest.main(), sopython3 tests/test_store_drift.pysilently ran none of them — including every mutation test the spec asked for.2e41336DESIGN-012locked, and the attribution sweep: rows in the never-asked state went 45 → 0.38d7be65cac6b5Both 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 fallbackreached only when the store is absent.
perry-state --json→project.config.tracks[]byte-identical before/after.OKR.md: edit a real metric cell →OKR store: 1 row(s) drifted..perry/config.md: edit a track's WIP cell →config store: 1 row(s) drifted.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.384s→FAILED (failures=8, skipped=1).Each was re-run against a
b288399worktree to separate pre-existing fromintroduced:
test_diagnose::test_perry_itself_passes_its_own_id_checkstest_kr_progress_provenance::test_no_current_in_the_payload_claims_to_be_a_measurementtest_claims.TestNoTestFileEndsEarly×2b7cef79; verified 31/31 greentest_risks_store.TestTheReadersAreOneFunction×3test_diagnose::test_the_queue_register_reconciles_with_the_queue_on_this_repositoryqueue: 2, baseline state →queue: 0, whileperry-taskreports 0 open asks. This is TASK-208.Not verified: the suite has not been re-run since
b7cef79. Six failuresare 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_storefailures were green before this branch.Phase 003 progress, measured at merge
P003-O1-KR1stores exist on diskP003-O1-KR2stores with a drift verdictP003-O1-KR3removed store reportsuncheckedP003-O2-KR1call sites reading markdown as truthP003-O2-KR2adoption reader fenced + guardP003-O2-KR3BOARD.mdboundary markedP003-O3-KR1rows never asked about attributionP003-O3-KR2attribution in the same action asaddNone of these numbers is in
phase/003-linkage.md: its only writer refuses towrite
currenton purpose, so a measured number has no path in either. Thatis TASK-231.
Merging
Per
work/reference/git-boundaries.md, the agent that opened a PR does notmerge it. Merge is the user's.
🤖 Generated with Claude Code