From 6affa560b339635eef39e309a9057ac06a23470b Mon Sep 17 00:00:00 2001 From: Ran Jiao Date: Thu, 20 Aug 2026 18:55:51 +0800 Subject: [PATCH 1/5] record: close TASK-092 at V4, drop TASK-070, answer USER-004 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TASK-092's reviewer answered the question the row was dispatched on: byte identity is real, not echoed, so the argument that overrode a locked not-dispatchable verdict holds. It also found the repair path the tools advertise leaves trailing whitespace, and that the tests cannot catch it — TASK-122. And that the goals writer takes the file as truth rather than the store, which means phase 002's P-O1.2 may not be met by that row alone — TASK-123, where the direction is the user's to choose. TASK-070 is dropped on the evidence it asked for. USER-004 is answered. The queue is empty for the first time today. Co-Authored-By: Claude Opus 5 --- .perry/events.jsonl | 6 + perry/BOARD.md | 7 +- .../TASK-092-dispatch-2026-08-20-1654.md | 114 ++++++++++++++++++ perry/journal/2026-08/2026-08-20.md | 49 ++++++++ perry/tasks.jsonl | 28 +++-- 5 files changed, 187 insertions(+), 17 deletions(-) diff --git a/.perry/events.jsonl b/.perry/events.jsonl index 97a074c..70dadae 100644 --- a/.perry/events.jsonl +++ b/.perry/events.jsonl @@ -496,3 +496,9 @@ {"ts": "2026-08-20T17:58:10", "event": "add", "id": "TASK-120", "title": "the linkage edges are read but never folded into KR progress", "track": "main", "mode": "project", "priority": "P1", "actor": "agent", "summary": "", "depends_on": [], "from": null, "to": "not_started"} {"ts": "2026-08-20T18:13:04", "event": "done", "id": "TASK-113", "title": "three checks read the project living around them, so ordinary progress turns them red", "track": "main", "owner": "Coding Agent", "role": "", "actor": "agent", "from": "in_progress", "to": "done", "evidence": "evidence/2026-08/TASK-113-dispatch-2026-08-20-1813.md", "rung": "V3"} {"ts": "2026-08-20T18:13:04", "event": "add", "id": "TASK-121", "title": "the sweep that found four more live-state assertions runs once and then is thrown away", "track": "main", "mode": "project", "priority": "P1", "actor": "agent", "summary": "", "depends_on": [], "from": null, "to": "not_started"} +{"ts": "2026-08-20T18:30:27", "event": "done", "id": "TASK-092", "title": "OKR.md and .perry/config.md become stores with renderers", "track": "main", "owner": "Coding Agent", "role": "", "actor": "agent", "from": "review", "to": "done", "evidence": "evidence/2026-08/TASK-092-dispatch-2026-08-20-1654.md", "rung": "V4"} +{"ts": "2026-08-20T18:30:44", "event": "add", "id": "TASK-122", "title": "the repair path the tools advertise leaves the file needing a whitespace fix", "track": "main", "mode": "project", "priority": "P1", "actor": "agent", "summary": "", "depends_on": [], "from": null, "to": "not_started"} +{"ts": "2026-08-20T18:30:45", "event": "add", "id": "TASK-123", "title": "the goals writer takes the file as truth and derives the store, which is the opposite direction from the KR", "track": "main", "mode": "project", "priority": "P1", "actor": "agent", "summary": "", "depends_on": [], "from": null, "to": "not_started"} +{"ts": "2026-08-20T18:54:21", "event": "done", "id": "TASK-110", "title": "measure what Perry state costs and propose a retention policy, deleting nothing", "track": "main", "owner": "Coding Agent", "role": "", "actor": "agent", "from": "review", "to": "done", "evidence": "evidence/2026-08/TASK-110-dispatch-2026-08-20-1725.md", "rung": "V3"} +{"ts": "2026-08-20T18:54:29", "event": "drop", "id": "TASK-070", "title": "Perry's own state is 19.5% of the tracked repo and grows unbounded", "track": "main", "owner": "Coding Agent", "role": "", "stage": "", "actor": "agent", "from": "not_started", "to": "dropped", "reason": "The measurement this row asked for was made (TASK-110) and it argues against the row. The whole proposal recovers 560,089 bytes — 1.4 days of growth at the measured 405,349 bytes/day — once, and nothing in it keeps pace. The cost is also not paid where the bytes are: perry-lint reads all 135 evidence files in 8ms and no agent loads evidence/ per turn. Rule B's selector decays; rule A deletes the argument history a project that reviews this hard depends on. The lever the measurement does point at is how much prose a V4 round emits, which is a product decision, not this row. Dropped on the evidence, not for lack of interest."} +{"ts": "2026-08-20T18:55:51", "event": "answer", "id": "USER-004", "title": "When migration encounters a file the user has chmod-ed read-only, should it refuse to touch that file, or migrate it and name the overridden permission in the plan? Today it migrates silently: write_atomic renames over the target, and a rename needs write permission on the directory, not on the file.", "answer": "Migrate and name the override; do not refuse. Reasoning recorded because the row was minted for it: refusing would block a whole migration for a reason unrelated to shape, and migration is the one road ADR-004 gives an undeclared project — a refusal there is the wall with no door that this project rejects everywhere else. The override is also reversible: the restore point carries the file's original bytes, verified under TASK-079. The 'at least name it' half of the ADR-004 posture is already satisfied by what shipped in PR #6, and TASK-115 added the guard that keeps that wording an observation rather than advice. The read-only bit stays a signal Perry reports and does not act on.", "actor": "agent", "from": "pending", "to": "answered"} diff --git a/perry/BOARD.md b/perry/BOARD.md index 1349a79..27f0b3a 100644 --- a/perry/BOARD.md +++ b/perry/BOARD.md @@ -24,19 +24,18 @@ | ID | Title | Owner | Status | Next action | Evidence | Verification | Depends on | Track | Stage | Stage since | Arrived | Parent | Commitment | Role | |---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| | TASK-077 | DESIGN-006 F — a finance-shaped role runs one real task end to end | Coding Agent | not_started | Run the finance-shaped role end to end on a copy of gimegime-pmo, then write the extraction report. | evidence/2026-08/TASK-077-context.md | V5 | TASK-073, TASK-075, TASK-076 | main | | | | | | | -| TASK-092 | OKR.md and .perry/config.md become stores with renderers | Coding Agent | review | V4 review re-dispatched after the first reviewer stalled at 600s; PR #16 is merged and nine rows wait on this row closing | evidence/2026-08/TASK-092-dispatch-2026-08-20-1654.md | V4 | TASK-090 | main | | | | | | | | TASK-094 | Delete the header rule and the row splitter for the three stores | Coding Agent | not_started | — | — | V3 | TASK-090, TASK-092 | main | | | | | | | | TASK-095 | Remove the parser for the three stores; keep what adoption needs | Coding Agent | not_started | — | — | V4 | TASK-094 | main | | | | | | | | TASK-097 | Migrate the two real projects to the store, at V5 | Coding Agent | not_started | — | — | V5 | TASK-092 | main | | | | | | | | TASK-099 | Sweep bin/, viewer/ and tests/ for document handling that ADR-007 made dead | Coding Agent | not_started | — | — | V4 | TASK-095 | main | | | | | | | | TASK-102 | Evidence becomes a typed relation: {path, kind, round}, not one prose cell | Coding Agent | not_started | — | — | V4 | TASK-090, TASK-092 | main | | | | | | | -| TASK-070 | Perry's own state is 19.5% of the tracked repo and grows unbounded | Coding Agent | not_started | decide the retention proposal in TASK-110's evidence; nothing is deleted until then | evidence/2026-08/TASK-070-context.md | V3 | — | main | | | | | | | -| TASK-110 | measure what Perry state costs and propose a retention policy, deleting nothing | Coding Agent | review | the user decides the retention policy; the measurement says the whole proposal recovers 1.4 days of growth | evidence/2026-08/TASK-110-dispatch-2026-08-20-1725.md | V3 | — | main | | | | | | | | TASK-111 | a test reads two files outside the repository, so it is green here and red on CI forever | Coding Agent | not_started | CI red on every run since the corpus silently shrinks; see tests/test_goals_writer.py ELSEWHERE | — | V3 | — | main | | | | | | | | TASK-114 | aiMark reads Perry through the current contracts instead of a pin nine versions old | Coding Agent | in_progress | delegated to an aiMark coding agent; awaiting paste-back | evidence/2026-08/TASK-114-delegation-prompt.md | V4 | — | main | | | | | | | | TASK-119 | the linkage graph is documented as machine-written and no tool writes it | Coding Agent | not_started | — | — | V3 | | main | | | | | | | | TASK-120 | the linkage edges are read but never folded into KR progress | Coding Agent | not_started | — | — | V3 | | main | | | | | | | | TASK-121 | the sweep that found four more live-state assertions runs once and then is thrown away | Coding Agent | not_started | — | — | V3 | | main | | | | | | | +| TASK-122 | the repair path the tools advertise leaves the file needing a whitespace fix | Coding Agent | not_started | — | — | V3 | | main | | | | | | | +| TASK-123 | the goals writer takes the file as truth and derives the store, which is the opposite direction from the KR | Coding Agent | not_started | — | — | V4 | | main | | | | | | | ## P2 @@ -65,7 +64,7 @@ | USER-001 | Staleness threshold N | TASK-005 | — | **answered 2026-08-16: 30 days** | | | USER-002 | `--claims` vs `--strict` | — | — | **answered 2026-08-16: exempt** | | | USER-003 | Please confirm whether Perry may make tasks.jsonl the authoritative Task record, with BOARD.md becoming a generated view whose direct edits are reported instead of accepted. | TASK-038 | | answered 2026-08-20: Already decided in ADR-007 decision 2 on 2026-08-19 (Deciders: Ran Jiao): BOARD.md becomes rendered output and a hand edit becomes drift. This row was minted the same day and duplicates that decision; recorded here so the queue matches the record. TASK-038 is unblocked, and still needs its V5 signature, which is a different act from this permission. | 2026-08-19 | -| USER-004 | When migration encounters a file the user has chmod-ed read-only, should it refuse to touch that file, or migrate it and name the overridden permission in the plan? Today it migrates silently: write_atomic renames over the target, and a rename needs write permission on the directory, not on the file. | TASK-079 | | pending | 2026-08-20 | +| USER-004 | When migration encounters a file the user has chmod-ed read-only, should it refuse to touch that file, or migrate it and name the overridden permission in the plan? Today it migrates silently: write_atomic renames over the target, and a rename needs write permission on the directory, not on the file. | TASK-079 | | answered 2026-08-20: Migrate and name the override; do not refuse. Reasoning recorded because the row was minted for it: refusing would block a whole migration for a reason unrelated to shape, and migration is the one road ADR-004 gives an undeclared project — a refusal there is the wall with no door that this project rejects everywhere else. The override is also reversible: the restore point carries the file's original bytes, verified under TASK-079. The 'at least name it' half of the ADR-004 posture is already satisfied by what shipped in PR #6, and TASK-115 added the guard that keeps that wording an observation rather than advice. The read-only bit stays a signal Perry reports and does not act on. | 2026-08-20 | ## Done this period (leaves the board at next triage) diff --git a/perry/evidence/2026-08/TASK-092-dispatch-2026-08-20-1654.md b/perry/evidence/2026-08/TASK-092-dispatch-2026-08-20-1654.md index f3c15b1..ad682d3 100644 --- a/perry/evidence/2026-08/TASK-092-dispatch-2026-08-20-1654.md +++ b/perry/evidence/2026-08/TASK-092-dispatch-2026-08-20-1654.md @@ -117,3 +117,117 @@ Most likely cause is size: 10 files, 2,188 insertions, including a 1,055-line new module and 695 lines of new tests, in one review. The re-dispatch is staged so the reviewer banks each criterion as it is scored rather than holding the whole judgement to the end. + +## V4 rubric review — independent, fresh context (attempt 2) + +Run 2026-08-20 by a second agent given the deliverable and verification sections +and the diff, and **not** the author's RESULT, its notes, or this file. It +reviewed from `git archive` extractions in a scratchpad; the working tree was +never modified. It did **not** inherit the stalled attempt's one banked finding — +it re-verified it. + +**Verdict: PASS WITH FINDINGS.** Deliverables 1, 2, 4, 5 `MET`; 3 `PARTIAL`. +Verifications 1–5 `MET`, 6 `PARTIAL` (pre-existing red, since fixed by TASK-113). +Out of scope `CLEAN`. + +### The question this review existed to answer + +`TASK-037-spec` refuses automation for this risk class because *"the risk is not +one a test catches"*. The row was dispatched anyway on the argument that a +byte-identical render **is** that test. + +> **Is byte identity real or echoed: REAL. The argument that dispatched this row +> holds.** + +Four independent checks, none of them the tool's own `identical: true`: +sha256 of the rendered output against the live files; mutating +`KR-O1.1.metric` moved the render at **both** places that KR appears and the +drift report named the cell; the store holds **zero prose**, and +`code_repo_path` is stored `""` while the file shows `—`, which an echo cannot +produce. Most convincingly, **the tool refuses to certify itself**: `diff` exits +2 with *"Rendering OKR.md from a store built out of that same file proves +nothing"* until an explicit import runs. + +And the guard is not vacuous: patching `describe_cell` into a pure echo turned +**18 of 27** tests red with *"cells came out of the FILE rather than the store — +byte-identity that proves nothing about the store"*. Two further mutations — +absorbing hand edits silently, echoing whole bullet lines — were also caught. + +### One cell model, verified rather than asserted + +`bin/perry_md_store.py:64` imports `perry_store`; `:663`, `:667`, `:721` call +`row_descriptor`, `slot_descriptor`, `render_lines`. It defines no +`describe_cell`, `render_line`, `cell_spans` or `cell_text`. `row_descriptor` +was **extracted** from `plan()` — the inline span loop is deleted in the diff, +not duplicated. BOARD.md and both new documents now share one implementation. + +### Finding 1 — the repair path damages the file it repairs + +`render --write` is **not byte-faithful on non-table lines**. Repairing a +hand-edited bullet restores the right value with the wrong bytes: + +``` +- State root: perry → trailing space +- KR1: → doubled space after the colon, and trailing space +git diff --check flags both +``` + +Root cause `bin/perry_store.py:344-345`: the disagree branch forces +`lead or " "` / `trail or " "`, which is correct padding for a table cell and +wrong for a `slot_descriptor` slot on a line with no cell boundaries. Confirmed +at the source by the PMO. + +**This is the path both tools advertise in their own refusal messages** — +`bin/perry_md_store.py:972` prints *"run ` render --write` to bring the +file back in line"*. So the tool instructs the user into the damage. Bounded: it +stabilises after one write, does not affect the clean round-trip, and +`perry-goals commit` cannot reach it. + +The reviewer names the irony precisely: **this defect is a consequence of the +single cell model.** One rule inside it is table-shaped and leaks into bullets. +`escape` was already parameterised for that table-vs-bullet split; the padding +rule was not. + +### Finding 2 — the tests cannot catch finding 1 + +`tests/test_md_store.py:288` and `:313` assert with `assertIn`, and substring +matching passes straight through spacing damage. The only `render --write` +round-trip test, `:401`, targets a **table cell**, where the forced padding +happens to be correct. An `assertEqual` on the restored text, or a bullet case +in that test, would have caught it. + +### Finding 3 — deliverable 3's direction of authority is inverted + +`bin/perry-goals:534-616` writes the store transactionally with the file, gated +on a byte comparison — but it targets the **file**: `OKR.md` is edited in place +and the store is *derived* from the resulting text. On a hand edit the writer +states *"The file's values are taken as the new truth"*, which is the opposite +of *"the file becomes the projection"*. Deliberate and loudly documented against +DESIGN-005 § 5.5, but a literal deviation from the spec's words. + +**Consequence for the phase**: `P-O1.2` reads *"`OKR.md` and `.perry/config.md` +likewise"* — likewise meaning rendered from a store that is the only thing +writers write. On this reading the KR is **not yet met** by this row alone. +Recorded rather than resolved; it is the user's to read. + +### Finding 4 — a defect in the written criterion, not the change + +Verification 2's `## Tracks` clause is vacuous on this machine: **neither real +project declares a `## Tracks` table**. Covered on the new +`tests/fixtures/second-project/` fixture (3 tracks) and on gimegime's config +instead. The PMO wrote that criterion. + +### Config decision — left open, verified + +Nothing writes `.perry/config.jsonl` without an explicit +`perry-config write --from-file`; `render --write` exits 2 while no store +exists; no reader in `bin/` or `viewer/` prefers the store; no lane writer +touches the file; `SKILL.md` is unmodified. `bin/perry-config`'s docstring names +the tension and says the row *"leaves the decision with them rather than making +it silently"*. + +## Close + +`done` at **V4**. The rubric is `TASK-092-spec.md § Deliverable` and +`§ Verification`; the reviewer scored it without having seen the reasoning that +produced the artifact. Findings 1–2 are TASK-122; finding 3 is TASK-123. diff --git a/perry/journal/2026-08/2026-08-20.md b/perry/journal/2026-08/2026-08-20.md index 1cffdc1..ce19aa5 100644 --- a/perry/journal/2026-08/2026-08-20.md +++ b/perry/journal/2026-08/2026-08-20.md @@ -96,6 +96,12 @@ - [TASK-120] — → not_started · the linkage edges are read but never folded into KR progress · owner: Coding Agent · priority: P1 - [TASK-113] in_progress → done · closed · evidence: `evidence/2026-08/TASK-113-dispatch-2026-08-20-1813.md` · verification: V3 - [TASK-121] — → not_started · the sweep that found four more live-state assertions runs once and then is thrown away · owner: Coding Agent · priority: P1 +- [TASK-092] review → done · closed · evidence: `evidence/2026-08/TASK-092-dispatch-2026-08-20-1654.md` · verification: V4 +- [TASK-122] — → not_started · the repair path the tools advertise leaves the file needing a whitespace fix · owner: Coding Agent · priority: P1 +- [TASK-123] — → not_started · the goals writer takes the file as truth and derives the store, which is the opposite direction from the KR · owner: Coding Agent · priority: P1 +- [TASK-110] review → done · closed · evidence: `evidence/2026-08/TASK-110-dispatch-2026-08-20-1725.md` · verification: V3 +- [TASK-070] not_started → dropped · reason: The measurement this row asked for was made (TASK-110) and it argues against the row. The whole proposal recovers 560,089 bytes — 1.4 days of growth at the measured 405,349 bytes/day — once, and nothing in it keeps pace. The cost is also not paid where the bytes are: perry-lint reads all 135 evidence files in 8ms and no agent loads evidence/ per turn. Rule B's selector decays; rule A deletes the argument history a project that reviews this hard depends on. The lever the measurement does point at is how much prose a V4 round emits, which is a product decision, not this row. Dropped on the evidence, not for lack of interest. +- [USER-004] pending → answered · Migrate and name the override; do not refuse. Reasoning recorded because the row was minted for it: refusing would block a whole migration for a reason unrelated to shape, and migration is the one road ADR-004 gives an undeclared project — a refusal there is the wall with no door that this project rejects everywhere else. The override is also reversible: the restore point carries the file's original bytes, verified under TASK-079. The 'at least name it' half of the ADR-004 posture is already satisfied by what shipped in PR #6, and TASK-115 added the guard that keeps that wording an observation rather than advice. The read-only bit stays a signal Perry reports and does not act on. ## Notes @@ -438,6 +444,28 @@ - **Out of scope**: — - **KR linkage**: unlinked +### TASK-122 — the repair path the tools advertise leaves the file needing a whitespace fix + +- **Owner**: Coding Agent +- **Priority**: P1 +- **Track / mode**: main / project +- **Deliverable**: render --write restores a hand-edited non-table line byte-faithfully, so the command both tools name in their refusal messages does not itself leave trailing or doubled whitespace; the padding rule that is correct for a table cell stops applying to a slot on a line with no cell boundaries, in the same way escape was already parameterised for that split +- **Verification**: repairing a hand-edited State root bullet and a bullet-form KR each restore the file byte-identically, proven by cmp rather than substring matching; git diff --check is clean after the repair; the round-trip test covers a bullet case as well as a table cell and asserts full text equality; and the table-cell padding behaviour is unchanged, proven by the board round-trip staying byte-identical +- **Dependencies**: — +- **Out of scope**: — +- **KR linkage**: unlinked + +### TASK-123 — the goals writer takes the file as truth and derives the store, which is the opposite direction from the KR + +- **Owner**: Coding Agent +- **Priority**: P1 +- **Track / mode**: main / project +- **Deliverable**: either perry-goals writes the store and renders OKR.md from it, so the file is the projection the phase KR describes — or the KR is amended to say what the project actually decided, with the reason recorded; the two must agree, and which way they agree is the user decision this row exists to force +- **Verification**: whichever direction is chosen, a hand edit to OKR.md and a write through the tool both end with the store and the file agreeing, and the drift report says which one moved; the perry-goals list payload does not change shape either way; and phase 002 P-O1.2 can be answered yes or no by a command rather than by reading the writer source +- **Dependencies**: — +- **Out of scope**: — +- **KR linkage**: unlinked + ## V5 sign-off **TASK-107 — V5 sign-off. Ran Jiao, 2026-08-20.** @@ -503,3 +531,24 @@ fresh context rather than resuming, because a reviewer told what it had already concluded is no longer the fresh-context reviewer V4 requires. That is the one place where resuming is the wrong move and re-dispatching is right. +- TASK-070 dropped on its own evidence. TASK-110 measured what the row asked for + and the measurement argues against it: the whole retention proposal recovers + 560,089 bytes against a measured growth of 405,349 bytes/day — **1.4 days, + once** — and `perry-lint` reads all 135 evidence files in 8ms, so the cost is + not being paid where the bytes are. The measurement's own strongest argument + was against itself: the handoff document that reopened the `.perry/` question + would have been deleted by the age-based rule it weakly proposed. + Recorded as dropped **on the evidence, not for lack of interest** — the + distinction is what a future reader needs, because "nobody got to it" and + "we measured it and it was not worth doing" look identical on a closed row. + The lever the measurement does point at — 65% of `evidence/` is review + transcripts from one dispatch campaign — is how much prose a V4 round is asked + to emit. That is a product decision and was left where it belongs. +- USER-004 answered: migrate and name the override, do not refuse. Two things + changed since it was minted that made the answer easier. The report half + shipped in PR #6, so the choice was no longer "silent overwrite versus + refusal" but "report versus refusal"; and TASK-115 added the guard keeping + that wording an observation rather than advice, which is what lets Perry + report a signal without acting on it. The deciding argument is ADR-004's own + shape: migration is the one road an undeclared project has, and a refusal + there is the wall with no door this project rejects everywhere else. diff --git a/perry/tasks.jsonl b/perry/tasks.jsonl index 0805193..833b07d 100644 --- a/perry/tasks.jsonl +++ b/perry/tasks.jsonl @@ -73,11 +73,11 @@ {"id": "TASK-087", "title": "Contract invariance gate: the three list payloads are byte-identical before and after any store change", "owner": "", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "tests/test_contract_invariance.py", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-19T10:27:37", "order": null, "summary": ""} {"id": "TASK-088", "title": "Renderer: BOARD.md is generated from tasks.jsonl, byte-identical to today's file", "owner": "", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-088-renderer.md", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-19T10:27:37", "order": null, "summary": ""} {"id": "TASK-089", "title": "perry-task writes the store, not the board", "owner": "", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-089-v4-review-r4.md", "next_action": "", "depends_on": ["TASK-088"], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-19T10:27:37", "order": null, "summary": ""} -{"id": "TASK-094", "title": "Delete the header rule and the row splitter for the three stores", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-090", "TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:51", "order": 2, "summary": ""} -{"id": "TASK-095", "title": "Remove the parser for the three stores; keep what adoption needs", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-094"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:28:03", "order": 3, "summary": ""} -{"id": "TASK-097", "title": "Migrate the two real projects to the store, at V5", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "—", "next_action": "—", "depends_on": ["TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:28:04", "order": 4, "summary": ""} +{"id": "TASK-094", "title": "Delete the header rule and the row splitter for the three stores", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-090", "TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:51", "order": 1, "summary": ""} +{"id": "TASK-095", "title": "Remove the parser for the three stores; keep what adoption needs", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-094"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:28:03", "order": 2, "summary": ""} +{"id": "TASK-097", "title": "Migrate the two real projects to the store, at V5", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "—", "next_action": "—", "depends_on": ["TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:28:04", "order": 3, "summary": ""} {"id": "TASK-098", "title": "--reviews cannot see a row waiting on a round nobody sent", "owner": "", "status": "done", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-098-symmetric-half.md", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-19T11:20:23", "order": null, "summary": ""} -{"id": "TASK-099", "title": "Sweep bin/, viewer/ and tests/ for document handling that ADR-007 made dead", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-095"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T11:31:24", "order": 5, "summary": ""} +{"id": "TASK-099", "title": "Sweep bin/, viewer/ and tests/ for document handling that ADR-007 made dead", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-095"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T11:31:24", "order": 4, "summary": ""} {"id": "TASK-103", "title": "Lock DESIGN-007 — the entity model", "owner": "", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "evidence/2026-08/TASK-103-design-007-lock.md", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-19T14:15:15", "order": null, "summary": ""} {"id": "TASK-090", "title": "perry-task reads the store, not the board", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-090-v4-review.md", "next_action": "After checkpoint ownership is established, cut every Task read over to tasks.jsonl under TASK-090-spec; keep non-Task board exceptions explicit", "depends_on": ["TASK-089"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:37", "order": null, "summary": ""} {"id": "TASK-104", "title": "Projection report treats terminal store records as missing board rows", "owner": "Coding Agent", "status": "done", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "tests/test_board_render.py::TestTheBytesComeFromTheStore::test_missing_projection_excludes_terminal_and_deduplicates_tables", "next_action": "Add a focused renderer-report regression; do not change task truth, Board contents or list contract", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-19T19:55:14", "order": null, "summary": ""} @@ -89,30 +89,32 @@ {"id": "TASK-105", "title": "perry-explain resolves TASK ids from the typed Task store", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-105-v3-verification.md", "next_action": "Implement typed Task lookup and focused behavioral tests from TASK-105-spec.md.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T22:57:50", "order": null, "summary": ""} {"id": "TASK-093", "title": "A hand edit to a rendered file is reported rather than honoured", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-093-final-v4-review.md", "next_action": "Implement the bounded M2-M7 store-drift contract in TASK-093-spec.md; keep typed due, migration safety, claims and parser deletion out of scope, then dispatch fresh mutation-sensitive V4.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:50", "order": null, "summary": ""} {"id": "TASK-106", "title": "Task summary is optional, explicit and preserved end to end", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-106-final-v4-review.md", "next_action": "Implement the optional explicit summary field end to end from TASK-106-spec.md, including store validation, writer/list contract, migration preservation and perry-explain output; infer nothing for legacy tasks.", "depends_on": ["TASK-105", "TASK-044"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T22:58:11", "order": null, "summary": ""} -{"id": "TASK-102", "title": "Evidence becomes a typed relation: {path, kind, round}, not one prose cell", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-090", "TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T13:57:15", "order": 6, "summary": ""} +{"id": "TASK-102", "title": "Evidence becomes a typed relation: {path, kind, round}, not one prose cell", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-090", "TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T13:57:15", "order": 5, "summary": ""} {"id": "TASK-040", "title": "perry-task: Top risks becomes a table with id / opened / cleared", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Risks still read from a markdown table with empty opened/cleared; make them records in the store.", "depends_on": ["TASK-089"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-17T12:14:37", "order": 1, "summary": ""} {"id": "TASK-077", "title": "DESIGN-006 F — a finance-shaped role runs one real task end to end", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "evidence/2026-08/TASK-077-context.md", "next_action": "Run the finance-shaped role end to end on a copy of gimegime-pmo, then write the extraction report.", "depends_on": ["TASK-073", "TASK-075", "TASK-076"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-18T07:31:46", "order": 0, "summary": ""} {"id": "TASK-066", "title": "Split perry-task by subcommand group", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-066-context.md", "next_action": "Re-size the split after the markdown reader, row renderer and cell escaping are gone.", "depends_on": ["TASK-065", "TASK-038"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-17T23:23:02", "order": 3, "summary": ""} {"id": "TASK-047", "title": "Flip the conformance gate to enforce", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "evidence/2026-08/TASK-047-dispatch-2026-08-20-1416.md", "next_action": "PR #8 merged and the gate enforces on this branch; awaiting the user's V5 sign-off text (name, date, what was checked)", "depends_on": ["TASK-044"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-17T17:46:26", "order": null, "summary": ""} -{"id": "TASK-111", "title": "a test reads two files outside the repository, so it is green here and red on CI forever", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "CI red on every run since the corpus silently shrinks; see tests/test_goals_writer.py ELSEWHERE", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T16:38:31", "order": 9} +{"id": "TASK-111", "title": "a test reads two files outside the repository, so it is green here and red on CI forever", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "CI red on every run since the corpus silently shrinks; see tests/test_goals_writer.py ELSEWHERE", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T16:38:31", "order": 6} {"id": "TASK-107", "title": "the dispatch safety gate matches its fragments as bare substrings, so ordinary English trips it", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "evidence/2026-08/TASK-107-spec.md", "next_action": "V5 sign-off: a human names the date and what they checked", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T15:08:07", "order": null} {"id": "TASK-085", "title": "Decision status has no word for a proposal, and lives in three places", "owner": "Coding Agent", "status": "done", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V2", "evidence": "evidence/2026-08/TASK-085-dispatch-2026-08-20-1607.md", "next_action": "merge PR #11; the index gains a Proposed section only when a project has one", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-19T09:53:50", "order": null, "summary": ""} {"id": "TASK-086", "title": "DESIGN-002 decision 4 says lint warns on a collision; lint does not emit NS-01", "owner": "Coding Agent", "status": "done", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V2", "evidence": "evidence/2026-08/TASK-086-dispatch-2026-08-20-1429.md", "next_action": "user confirms the strict-flag contract change (the namespace warning is no longer promoted to a failure), then merge PR #9; PR #7 is superseded", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-19T10:09:57", "order": null, "summary": ""} {"id": "TASK-038", "title": "tasks: the task store becomes canonical, BOARD.md becomes a projection", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "evidence/2026-08/TASK-038-v5-signoff-request.md", "next_action": "V5 sign-off pending; PR #12 lands the selection-based signature and TASK-038 is its first real use", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-17T00:55:19", "order": null, "summary": ""} {"id": "TASK-109", "title": "a V5 sign-off is composed by selection from measured facts, not authored from memory", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "evidence/2026-08/TASK-109-dispatch-2026-08-20-1623.md", "next_action": "merge PR #12; the first V5 close after it lands is the real test of the selection prompt", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T15:49:41", "order": null} {"id": "TASK-112", "title": "the sign-off drafting guard cannot describe itself, so a true statement about it is refused", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-20T16:52:29", "order": 5} -{"id": "TASK-114", "title": "aiMark reads Perry through the current contracts instead of a pin nine versions old", "summary": "", "owner": "Coding Agent", "status": "in_progress", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-114-delegation-prompt.md", "next_action": "delegated to an aiMark coding agent; awaiting paste-back", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T17:05:48", "order": 10} +{"id": "TASK-114", "title": "aiMark reads Perry through the current contracts instead of a pin nine versions old", "summary": "", "owner": "Coding Agent", "status": "in_progress", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-114-delegation-prompt.md", "next_action": "delegated to an aiMark coding agent; awaiting paste-back", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T17:05:48", "order": 7} {"id": "TASK-079", "title": "Migration writes a file the user marked read-only, via rename", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-079-dispatch-2026-08-20-1345.md", "next_action": "user verifies: merge PR #6, and decide USER-004 (refuse vs report) which this task deliberately left open", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-18T16:13:35", "order": null, "summary": ""} {"id": "TASK-108", "title": "LOAD-03 counts prose about a decision, so documenting an open question makes the count go up", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-108-dispatch-2026-08-20-1547.md", "next_action": "merge PR #10, then swap the literal GATE_OFF constant in tests/test_diagnose.py for the shared import", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T15:13:45", "order": null} {"id": "TASK-116", "title": "the mention list is write-only, mislabelled, and two of its carve-outs are unpinned", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-20T17:24:46", "order": 6} -{"id": "TASK-110", "title": "measure what Perry state costs and propose a retention policy, deleting nothing", "summary": "", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-110-dispatch-2026-08-20-1725.md", "next_action": "the user decides the retention policy; the measurement says the whole proposal recovers 1.4 days of growth", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T16:23:45", "order": 8} -{"id": "TASK-070", "title": "Perry's own state is 19.5% of the tracked repo and grows unbounded", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-070-context.md", "next_action": "decide the retention proposal in TASK-110's evidence; nothing is deleted until then", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-18T00:13:21", "order": 7, "summary": ""} {"id": "TASK-117", "title": "two tools disagree about whether the board has drifted when the event log is absent", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-20T17:25:40", "order": 7} {"id": "TASK-118", "title": "the id minters read three sources and the canonical store is not one of them", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-20T17:25:40", "order": 8} {"id": "TASK-100", "title": "tasks.jsonl is in no claims[] entry, so a namespace collision on it cannot be reported", "owner": "Coding Agent", "status": "review", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-100-dispatch-2026-08-20-1730.md", "next_action": "merge PR #14; the shape predicate added to bin/perry-lint is outside the declared scope and was flagged for a reviewer", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-19T11:44:02", "order": 4, "summary": ""} -{"id": "TASK-092", "title": "OKR.md and .perry/config.md become stores with renderers", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-092-dispatch-2026-08-20-1654.md", "next_action": "V4 review re-dispatched after the first reviewer stalled at 600s; PR #16 is merged and nine rows wait on this row closing", "depends_on": ["TASK-090"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:50", "order": 1, "summary": ""} {"id": "TASK-115", "title": "two guards on the read-only report have a hole beside them, found by mutation not by review", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-115-dispatch-2026-08-20-1751.md", "next_action": "dispatched 17:27 via claude-subagent; worktree pinned to feat/work-modes; awaiting completion", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-20T17:15:57", "order": null} -{"id": "TASK-119", "title": "the linkage graph is documented as machine-written and no tool writes it", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T17:58:10", "order": 11} -{"id": "TASK-120", "title": "the linkage edges are read but never folded into KR progress", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T17:58:10", "order": 12} +{"id": "TASK-119", "title": "the linkage graph is documented as machine-written and no tool writes it", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T17:58:10", "order": 8} +{"id": "TASK-120", "title": "the linkage edges are read but never folded into KR progress", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T17:58:10", "order": 9} {"id": "TASK-113", "title": "three checks read the project living around them, so ordinary progress turns them red", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-113-dispatch-2026-08-20-1813.md", "next_action": "dispatched 17:09 via claude-subagent; worktree pinned to feat/work-modes; awaiting completion", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T17:04:40", "order": null} -{"id": "TASK-121", "title": "the sweep that found four more live-state assertions runs once and then is thrown away", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T18:13:04", "order": 13} +{"id": "TASK-121", "title": "the sweep that found four more live-state assertions runs once and then is thrown away", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T18:13:04", "order": 10} +{"id": "TASK-092", "title": "OKR.md and .perry/config.md become stores with renderers", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-092-dispatch-2026-08-20-1654.md", "next_action": "V4 review re-dispatched after the first reviewer stalled at 600s; PR #16 is merged and nine rows wait on this row closing", "depends_on": ["TASK-090"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:50", "order": null, "summary": ""} +{"id": "TASK-122", "title": "the repair path the tools advertise leaves the file needing a whitespace fix", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T18:30:44", "order": 11} +{"id": "TASK-123", "title": "the goals writer takes the file as truth and derives the store, which is the opposite direction from the KR", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T18:30:45", "order": 12} +{"id": "TASK-110", "title": "measure what Perry state costs and propose a retention policy, deleting nothing", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-110-dispatch-2026-08-20-1725.md", "next_action": "the user decides the retention policy; the measurement says the whole proposal recovers 1.4 days of growth", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T16:23:45", "order": null} +{"id": "TASK-070", "title": "Perry's own state is 19.5% of the tracked repo and grows unbounded", "owner": "Coding Agent", "status": "dropped", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-070-context.md", "next_action": "decide the retention proposal in TASK-110's evidence; nothing is deleted until then", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-18T00:13:21", "order": null, "summary": ""} From a9d393deed328e01a756a8d9a51c8210c550f321 Mon Sep 17 00:00:00 2001 From: Ran Jiao Date: Thu, 20 Aug 2026 19:00:08 +0800 Subject: [PATCH 2/5] record: dispatch TASK-094 and TASK-111 with -j 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The concurrency lesson was the wrong fix stated the right way: the cap counts agents, tests/parallel counts 8 workers, and 3 x 8 on 14 cores is what stalled two agents. Narrowing the workers rather than the agents keeps the third slot. TASK-094 is the chain head — two P0s say in their own cells to stay open until it lands, because four review rounds went into hardening a category ADR-007 decision 4 removes rather than fixes. Co-Authored-By: Claude Opus 5 --- .perry/events.jsonl | 2 + perry/BOARD.md | 4 +- perry/evidence/2026-08/TASK-094-spec.md | 80 +++++++++++++++++++++++++ perry/evidence/2026-08/TASK-111-spec.md | 80 +++++++++++++++++++++++++ perry/journal/2026-08/2026-08-20.md | 2 + perry/tasks.jsonl | 4 +- 6 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 perry/evidence/2026-08/TASK-094-spec.md create mode 100644 perry/evidence/2026-08/TASK-111-spec.md diff --git a/.perry/events.jsonl b/.perry/events.jsonl index 70dadae..5fd5d25 100644 --- a/.perry/events.jsonl +++ b/.perry/events.jsonl @@ -502,3 +502,5 @@ {"ts": "2026-08-20T18:54:21", "event": "done", "id": "TASK-110", "title": "measure what Perry state costs and propose a retention policy, deleting nothing", "track": "main", "owner": "Coding Agent", "role": "", "actor": "agent", "from": "review", "to": "done", "evidence": "evidence/2026-08/TASK-110-dispatch-2026-08-20-1725.md", "rung": "V3"} {"ts": "2026-08-20T18:54:29", "event": "drop", "id": "TASK-070", "title": "Perry's own state is 19.5% of the tracked repo and grows unbounded", "track": "main", "owner": "Coding Agent", "role": "", "stage": "", "actor": "agent", "from": "not_started", "to": "dropped", "reason": "The measurement this row asked for was made (TASK-110) and it argues against the row. The whole proposal recovers 560,089 bytes — 1.4 days of growth at the measured 405,349 bytes/day — once, and nothing in it keeps pace. The cost is also not paid where the bytes are: perry-lint reads all 135 evidence files in 8ms and no agent loads evidence/ per turn. Rule B's selector decays; rule A deletes the argument history a project that reviews this hard depends on. The lever the measurement does point at is how much prose a V4 round emits, which is a product decision, not this row. Dropped on the evidence, not for lack of interest."} {"ts": "2026-08-20T18:55:51", "event": "answer", "id": "USER-004", "title": "When migration encounters a file the user has chmod-ed read-only, should it refuse to touch that file, or migrate it and name the overridden permission in the plan? Today it migrates silently: write_atomic renames over the target, and a rename needs write permission on the directory, not on the file.", "answer": "Migrate and name the override; do not refuse. Reasoning recorded because the row was minted for it: refusing would block a whole migration for a reason unrelated to shape, and migration is the one road ADR-004 gives an undeclared project — a refusal there is the wall with no door that this project rejects everywhere else. The override is also reversible: the restore point carries the file's original bytes, verified under TASK-079. The 'at least name it' half of the ADR-004 posture is already satisfied by what shipped in PR #6, and TASK-115 added the guard that keeps that wording an observation rather than advice. The read-only bit stays a signal Perry reports and does not act on.", "actor": "agent", "from": "pending", "to": "answered"} +{"ts": "2026-08-20T18:58:42", "event": "start", "id": "TASK-094", "title": "Delete the header rule and the row splitter for the three stores", "track": "main", "actor": "agent", "from": "not_started", "to": "in_progress"} +{"ts": "2026-08-20T18:58:42", "event": "start", "id": "TASK-111", "title": "a test reads two files outside the repository, so it is green here and red on CI forever", "track": "main", "actor": "agent", "from": "not_started", "to": "in_progress"} diff --git a/perry/BOARD.md b/perry/BOARD.md index 27f0b3a..48723f4 100644 --- a/perry/BOARD.md +++ b/perry/BOARD.md @@ -24,12 +24,12 @@ | ID | Title | Owner | Status | Next action | Evidence | Verification | Depends on | Track | Stage | Stage since | Arrived | Parent | Commitment | Role | |---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| | TASK-077 | DESIGN-006 F — a finance-shaped role runs one real task end to end | Coding Agent | not_started | Run the finance-shaped role end to end on a copy of gimegime-pmo, then write the extraction report. | evidence/2026-08/TASK-077-context.md | V5 | TASK-073, TASK-075, TASK-076 | main | | | | | | | -| TASK-094 | Delete the header rule and the row splitter for the three stores | Coding Agent | not_started | — | — | V3 | TASK-090, TASK-092 | main | | | | | | | +| TASK-094 | Delete the header rule and the row splitter for the three stores | Coding Agent | in_progress | dispatched 18:58 via claude-subagent; worktree pinned to feat/work-modes; awaiting completion | — | V3 | TASK-090, TASK-092 | main | | | | | | | | TASK-095 | Remove the parser for the three stores; keep what adoption needs | Coding Agent | not_started | — | — | V4 | TASK-094 | main | | | | | | | | TASK-097 | Migrate the two real projects to the store, at V5 | Coding Agent | not_started | — | — | V5 | TASK-092 | main | | | | | | | | TASK-099 | Sweep bin/, viewer/ and tests/ for document handling that ADR-007 made dead | Coding Agent | not_started | — | — | V4 | TASK-095 | main | | | | | | | | TASK-102 | Evidence becomes a typed relation: {path, kind, round}, not one prose cell | Coding Agent | not_started | — | — | V4 | TASK-090, TASK-092 | main | | | | | | | -| TASK-111 | a test reads two files outside the repository, so it is green here and red on CI forever | Coding Agent | not_started | CI red on every run since the corpus silently shrinks; see tests/test_goals_writer.py ELSEWHERE | — | V3 | — | main | | | | | | | +| TASK-111 | a test reads two files outside the repository, so it is green here and red on CI forever | Coding Agent | in_progress | dispatched 18:58 via claude-subagent; worktree pinned to feat/work-modes; awaiting completion | — | V3 | — | main | | | | | | | | TASK-114 | aiMark reads Perry through the current contracts instead of a pin nine versions old | Coding Agent | in_progress | delegated to an aiMark coding agent; awaiting paste-back | evidence/2026-08/TASK-114-delegation-prompt.md | V4 | — | main | | | | | | | | TASK-119 | the linkage graph is documented as machine-written and no tool writes it | Coding Agent | not_started | — | — | V3 | | main | | | | | | | | TASK-120 | the linkage edges are read but never folded into KR progress | Coding Agent | not_started | — | — | V3 | | main | | | | | | | diff --git a/perry/evidence/2026-08/TASK-094-spec.md b/perry/evidence/2026-08/TASK-094-spec.md new file mode 100644 index 0000000..dea118b --- /dev/null +++ b/perry/evidence/2026-08/TASK-094-spec.md @@ -0,0 +1,80 @@ +# TASK-094 — Delete the header rule and the row splitter for the three stores + +> Source: `perry/decisions/ADR-007-fields-are-typed-prose-is-not.md` decision 4; DESIGN-005 § 5b +> Dispatch mode: auto +> Executor: claude-subagent +> Estimated cycle: large +> Subjective verification: (none) +> Touches architecture: (none) +> Deployed: no + +## Schema + +- **Owner**: Coding Agent +- **Priority**: P1 +- **Attribution**: P-O2.2 +- **Unblocks**: TASK-050 (P0), TASK-067 (P0), TASK-095 → TASK-099 + +## Why this is a deletion and not a fix + +ADR-007 decision 4 settled it: the readers for `BOARD.md`, `OKR.md` and +`.perry/config.md` **go** when those become stores. All three are stores now — +TASK-088/089/090 for the board, TASK-092 for the other two, each verified +byte-identical. So none of the three is a table any more, and code that resolves +a header cell or splits a row for them is serving a shape that no longer exists. + +This is also what unblocks the two P0s. `TASK-050` ("one normalization for a +header cell, not two") and `TASK-067` ("the writer can destroy the table it +writes to") both say in their own cells: **keep the row open until TASK-094 +lands, then re-scope to whatever adoption still needs.** Four review rounds went +into hardening a category that is being removed rather than fixed. + +## Deliverable + +1. No reader resolves a header cell or splits a row **for those three files**. + The call sites that do are deleted, not disabled. +2. What adoption needs survives. Adoption of a foreign project parses by + definition — that is the one place markdown reading is still the job, and + ADR-007 decision 4 says so explicitly. +3. `tests/test_one_header_rule.py` and `tests/test_row_integrity.py` shrink to + what adoption still needs. They do not simply lose their assertions: what is + removed is the coverage of a shape that no longer exists, and what remains + must still fail when adoption's parsing breaks. +4. The three stores render byte-identically before and after. This change + removes readers; it must move no output. + +## Verification — V3 + +1. **0 header-resolution and 0 row-split call sites reach the three stores**, + shown by a mechanical check rather than by reading — the deliverable is a + count and the verification should be the same count. +2. `perry-tasks diff`, `perry-okr diff` and `perry-config diff` each report + `identical` on this repository before and after, with `cells_verbatim {}` and + `cells_wearing_decoration {}`. +3. Adoption still parses a foreign project: run it against + `tests/fixtures/sample-project` and against a **copy** of + `~/proj/gimegime-pmo`, never the original, and show the result is unchanged. +4. Break adoption's parsing on purpose and show the shrunken tests still fail. + A test file that gets smaller and stops discriminating is the failure mode + here. +5. `python3 tests/parallel -j 4`, `bash tests/run`, `python3 bin/perry-lint`, + `git diff --check`. + +## Files in scope + +- `viewer/tables.py`, `viewer/parsers.py` — the header rule and the row splitter +- `bin/perry_store.py`, `bin/perry_md_store.py` — only where they reach those readers +- `tests/test_one_header_rule.py`, `tests/test_row_integrity.py` + +## Out of scope + +- **Deleting the parser wholesale.** That is TASK-095, and it depends on this. + This row removes header resolution and row splitting for the three stores; + the remaining parser body is the next row's. +- `bin/perry-lint`, `bin/perry-diagnose`, `tests/test_migrate.py`, + `tests/test_goals_writer.py`, `bin/perry-state-cost` — each is carried by an + open unmerged branch or a live dispatch. +- `schema/state-schema.json`, `claims`, anything under `perry/`. +- Re-scoping TASK-050 or TASK-067. They unblock when this lands; the re-scope is + theirs. +- Closing without the V3 evidence above. diff --git a/perry/evidence/2026-08/TASK-111-spec.md b/perry/evidence/2026-08/TASK-111-spec.md new file mode 100644 index 0000000..845ce32 --- /dev/null +++ b/perry/evidence/2026-08/TASK-111-spec.md @@ -0,0 +1,80 @@ +# TASK-111 — A test reads two files outside the repository + +> Source: found 2026-08-20 while diagnosing why PR #12's CI was red +> Dispatch mode: auto +> Executor: claude-subagent +> Estimated cycle: small +> Subjective verification: (none) +> Touches architecture: (none) +> Deployed: no + +## Schema + +- **Owner**: Coding Agent +- **Priority**: P1 +- **Attribution**: unlinked + +## The defect, and the reason it is worse than one red test + +`tests/test_goals_writer.py` builds its byte-identity corpus from five files: + +```python +IN_REPO = [ROOT/"perry"/"OKR.md", ROOT/"tests"/"fixtures"/"sample-project"/"OKR.md", + ROOT/"goals"/"state"/"OKR_TEMPLATE.md"] +ELSEWHERE = [Path.home()/"proj"/"gimegime-pmo"/"OKR.md", + Path.home()/"proj"/"aimark"/"perry"/"OKR.md"] +texts = [p.read_text() for p in IN_REPO + ELSEWHERE if p.exists()] +``` + +The last two live **outside the repository**. On CI they are absent, `if +p.exists()` skips them silently, the corpus shrinks, and +`test_the_corpus_actually_disagrees` fails its assertion that the corpus holds at +least two distinct version counts. + +The test's own docstring says it exists so the corpus *"cannot quietly become +uniform"*. `if p.exists()` is precisely how it quietly becomes uniform on every +machine but one. It is **green here and red on CI forever**, which is the state +in which a real regression stops being visible — and this repository has been +reading past a red CI all day because of it. + +## Deliverable + +1. The corpus no longer changes strength with the machine. Either the shapes it + needs are committed as fixtures, or the test **skips loudly with the reason + named** — never silently narrows its own assertion. +2. `test_the_corpus_actually_disagrees` passes on a checkout with no home + directory projects present. +3. The corpus still exercises what it was written to cover: the multi-version + shape and the objective-heading shape. Whatever replaces the missing files + must carry those, or the fix is a relaxation wearing a fixture's clothes. +4. No other test in the suite silently narrows an assertion when an optional + path is absent. + +## Verification — V3 + +1. Run the affected test with `HOME` pointed at an empty directory. It passes, + or it skips with a message naming what is missing and why. +2. Remove the multi-version shape from the corpus and assert the test **fails** — + the guard must still discriminate. +3. Same for the objective-heading shape. +4. A mechanical check over `tests/` naming any test that reads a path outside the + repository root without either committing the file or skipping with a stated + reason. Report what it finds; fix what is in this row's scope. +5. `python3 tests/parallel`, `bash tests/run`, `python3 bin/perry-lint`, + `git diff --check`. + +## Files in scope + +- `tests/test_goals_writer.py` +- a committed fixture, if that is the route chosen + +## Out of scope + +- `bin/perry-goals` and the writer itself. This row is about the corpus the test + reads, not the code it tests. +- Anything under `perry/`. +- The other tests the sweep names, unless one is trivially the same edit. +- `viewer/`, `bin/perry_store.py`, `bin/perry-lint`, `bin/perry-diagnose`, + `tests/test_migrate.py`, `schema/state-schema.json` — each is carried by a + live dispatch or an open unmerged branch. +- Closing without the V3 evidence above. diff --git a/perry/journal/2026-08/2026-08-20.md b/perry/journal/2026-08/2026-08-20.md index ce19aa5..2ed34d6 100644 --- a/perry/journal/2026-08/2026-08-20.md +++ b/perry/journal/2026-08/2026-08-20.md @@ -102,6 +102,8 @@ - [TASK-110] review → done · closed · evidence: `evidence/2026-08/TASK-110-dispatch-2026-08-20-1725.md` · verification: V3 - [TASK-070] not_started → dropped · reason: The measurement this row asked for was made (TASK-110) and it argues against the row. The whole proposal recovers 560,089 bytes — 1.4 days of growth at the measured 405,349 bytes/day — once, and nothing in it keeps pace. The cost is also not paid where the bytes are: perry-lint reads all 135 evidence files in 8ms and no agent loads evidence/ per turn. Rule B's selector decays; rule A deletes the argument history a project that reviews this hard depends on. The lever the measurement does point at is how much prose a V4 round emits, which is a product decision, not this row. Dropped on the evidence, not for lack of interest. - [USER-004] pending → answered · Migrate and name the override; do not refuse. Reasoning recorded because the row was minted for it: refusing would block a whole migration for a reason unrelated to shape, and migration is the one road ADR-004 gives an undeclared project — a refusal there is the wall with no door that this project rejects everywhere else. The override is also reversible: the restore point carries the file's original bytes, verified under TASK-079. The 'at least name it' half of the ADR-004 posture is already satisfied by what shipped in PR #6, and TASK-115 added the guard that keeps that wording an observation rather than advice. The read-only bit stays a signal Perry reports and does not act on. +- [TASK-094] not_started → in_progress · started +- [TASK-111] not_started → in_progress · started ## Notes diff --git a/perry/tasks.jsonl b/perry/tasks.jsonl index 833b07d..84b1777 100644 --- a/perry/tasks.jsonl +++ b/perry/tasks.jsonl @@ -73,7 +73,6 @@ {"id": "TASK-087", "title": "Contract invariance gate: the three list payloads are byte-identical before and after any store change", "owner": "", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "tests/test_contract_invariance.py", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-19T10:27:37", "order": null, "summary": ""} {"id": "TASK-088", "title": "Renderer: BOARD.md is generated from tasks.jsonl, byte-identical to today's file", "owner": "", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-088-renderer.md", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-19T10:27:37", "order": null, "summary": ""} {"id": "TASK-089", "title": "perry-task writes the store, not the board", "owner": "", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-089-v4-review-r4.md", "next_action": "", "depends_on": ["TASK-088"], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-19T10:27:37", "order": null, "summary": ""} -{"id": "TASK-094", "title": "Delete the header rule and the row splitter for the three stores", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-090", "TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:51", "order": 1, "summary": ""} {"id": "TASK-095", "title": "Remove the parser for the three stores; keep what adoption needs", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-094"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:28:03", "order": 2, "summary": ""} {"id": "TASK-097", "title": "Migrate the two real projects to the store, at V5", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "—", "next_action": "—", "depends_on": ["TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:28:04", "order": 3, "summary": ""} {"id": "TASK-098", "title": "--reviews cannot see a row waiting on a round nobody sent", "owner": "", "status": "done", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-098-symmetric-half.md", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-19T11:20:23", "order": null, "summary": ""} @@ -94,7 +93,6 @@ {"id": "TASK-077", "title": "DESIGN-006 F — a finance-shaped role runs one real task end to end", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "evidence/2026-08/TASK-077-context.md", "next_action": "Run the finance-shaped role end to end on a copy of gimegime-pmo, then write the extraction report.", "depends_on": ["TASK-073", "TASK-075", "TASK-076"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-18T07:31:46", "order": 0, "summary": ""} {"id": "TASK-066", "title": "Split perry-task by subcommand group", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-066-context.md", "next_action": "Re-size the split after the markdown reader, row renderer and cell escaping are gone.", "depends_on": ["TASK-065", "TASK-038"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-17T23:23:02", "order": 3, "summary": ""} {"id": "TASK-047", "title": "Flip the conformance gate to enforce", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "evidence/2026-08/TASK-047-dispatch-2026-08-20-1416.md", "next_action": "PR #8 merged and the gate enforces on this branch; awaiting the user's V5 sign-off text (name, date, what was checked)", "depends_on": ["TASK-044"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-17T17:46:26", "order": null, "summary": ""} -{"id": "TASK-111", "title": "a test reads two files outside the repository, so it is green here and red on CI forever", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "CI red on every run since the corpus silently shrinks; see tests/test_goals_writer.py ELSEWHERE", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T16:38:31", "order": 6} {"id": "TASK-107", "title": "the dispatch safety gate matches its fragments as bare substrings, so ordinary English trips it", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "evidence/2026-08/TASK-107-spec.md", "next_action": "V5 sign-off: a human names the date and what they checked", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T15:08:07", "order": null} {"id": "TASK-085", "title": "Decision status has no word for a proposal, and lives in three places", "owner": "Coding Agent", "status": "done", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V2", "evidence": "evidence/2026-08/TASK-085-dispatch-2026-08-20-1607.md", "next_action": "merge PR #11; the index gains a Proposed section only when a project has one", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-19T09:53:50", "order": null, "summary": ""} {"id": "TASK-086", "title": "DESIGN-002 decision 4 says lint warns on a collision; lint does not emit NS-01", "owner": "Coding Agent", "status": "done", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V2", "evidence": "evidence/2026-08/TASK-086-dispatch-2026-08-20-1429.md", "next_action": "user confirms the strict-flag contract change (the namespace warning is no longer promoted to a failure), then merge PR #9; PR #7 is superseded", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-19T10:09:57", "order": null, "summary": ""} @@ -118,3 +116,5 @@ {"id": "TASK-123", "title": "the goals writer takes the file as truth and derives the store, which is the opposite direction from the KR", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T18:30:45", "order": 12} {"id": "TASK-110", "title": "measure what Perry state costs and propose a retention policy, deleting nothing", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-110-dispatch-2026-08-20-1725.md", "next_action": "the user decides the retention policy; the measurement says the whole proposal recovers 1.4 days of growth", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T16:23:45", "order": null} {"id": "TASK-070", "title": "Perry's own state is 19.5% of the tracked repo and grows unbounded", "owner": "Coding Agent", "status": "dropped", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-070-context.md", "next_action": "decide the retention proposal in TASK-110's evidence; nothing is deleted until then", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-18T00:13:21", "order": null, "summary": ""} +{"id": "TASK-094", "title": "Delete the header rule and the row splitter for the three stores", "owner": "Coding Agent", "status": "in_progress", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "dispatched 18:58 via claude-subagent; worktree pinned to feat/work-modes; awaiting completion", "depends_on": ["TASK-090", "TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:51", "order": 1, "summary": ""} +{"id": "TASK-111", "title": "a test reads two files outside the repository, so it is green here and red on CI forever", "summary": "", "owner": "Coding Agent", "status": "in_progress", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "dispatched 18:58 via claude-subagent; worktree pinned to feat/work-modes; awaiting completion", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T16:38:31", "order": 6} From 131930e3dd4bf7d16c3237b091fbf7e2d61805d9 Mon Sep 17 00:00:00 2001 From: Ran Jiao Date: Thu, 20 Aug 2026 19:24:25 +0800 Subject: [PATCH 3/5] record: TASK-111 to review; open TASK-124 and TASK-125 The sweep in TASK-111 found one sibling with zero CI coverage (tests/test_conformance.py:440) and left one shape uncovered on purpose; both are now rows rather than notes in an evidence file. Co-Authored-By: Claude Opus 5 --- .perry/events.jsonl | 4 + perry/BOARD.md | 4 +- .../TASK-111-dispatch-2026-08-20-1930.md | 82 +++++++++++++++++++ perry/journal/2026-08/2026-08-20.md | 26 ++++++ perry/tasks.jsonl | 4 +- 5 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 perry/evidence/2026-08/TASK-111-dispatch-2026-08-20-1930.md diff --git a/.perry/events.jsonl b/.perry/events.jsonl index 5fd5d25..1c55c02 100644 --- a/.perry/events.jsonl +++ b/.perry/events.jsonl @@ -504,3 +504,7 @@ {"ts": "2026-08-20T18:55:51", "event": "answer", "id": "USER-004", "title": "When migration encounters a file the user has chmod-ed read-only, should it refuse to touch that file, or migrate it and name the overridden permission in the plan? Today it migrates silently: write_atomic renames over the target, and a rename needs write permission on the directory, not on the file.", "answer": "Migrate and name the override; do not refuse. Reasoning recorded because the row was minted for it: refusing would block a whole migration for a reason unrelated to shape, and migration is the one road ADR-004 gives an undeclared project — a refusal there is the wall with no door that this project rejects everywhere else. The override is also reversible: the restore point carries the file's original bytes, verified under TASK-079. The 'at least name it' half of the ADR-004 posture is already satisfied by what shipped in PR #6, and TASK-115 added the guard that keeps that wording an observation rather than advice. The read-only bit stays a signal Perry reports and does not act on.", "actor": "agent", "from": "pending", "to": "answered"} {"ts": "2026-08-20T18:58:42", "event": "start", "id": "TASK-094", "title": "Delete the header rule and the row splitter for the three stores", "track": "main", "actor": "agent", "from": "not_started", "to": "in_progress"} {"ts": "2026-08-20T18:58:42", "event": "start", "id": "TASK-111", "title": "a test reads two files outside the repository, so it is green here and red on CI forever", "track": "main", "actor": "agent", "from": "not_started", "to": "in_progress"} +{"ts": "2026-08-20T19:23:44", "event": "status", "id": "TASK-111", "title": "a test reads two files outside the repository, so it is green here and red on CI forever", "track": "main", "actor": "agent", "depends_on": [], "from": "in_progress", "to": "review", "reason": ""} +{"ts": "2026-08-20T19:23:44", "event": "evidence", "id": "TASK-111", "title": "a test reads two files outside the repository, so it is green here and red on CI forever", "track": "main", "actor": "agent", "from": "—", "to": "evidence/2026-08/TASK-111-dispatch-2026-08-20-1930.md"} +{"ts": "2026-08-20T19:23:55", "event": "add", "id": "TASK-124", "title": "the conformance corpus reads a project outside the repo and has no committed substitute", "track": "main", "mode": "project", "priority": "P2", "actor": "agent", "summary": "", "depends_on": [], "from": null, "to": "not_started"} +{"ts": "2026-08-20T19:23:55", "event": "add", "id": "TASK-125", "title": "the Anti-Goals-inside-a-version insert case runs only on the author machine", "track": "main", "mode": "project", "priority": "P2", "actor": "agent", "summary": "", "depends_on": [], "from": null, "to": "not_started"} diff --git a/perry/BOARD.md b/perry/BOARD.md index 48723f4..b8ce5a4 100644 --- a/perry/BOARD.md +++ b/perry/BOARD.md @@ -29,7 +29,7 @@ | TASK-097 | Migrate the two real projects to the store, at V5 | Coding Agent | not_started | — | — | V5 | TASK-092 | main | | | | | | | | TASK-099 | Sweep bin/, viewer/ and tests/ for document handling that ADR-007 made dead | Coding Agent | not_started | — | — | V4 | TASK-095 | main | | | | | | | | TASK-102 | Evidence becomes a typed relation: {path, kind, round}, not one prose cell | Coding Agent | not_started | — | — | V4 | TASK-090, TASK-092 | main | | | | | | | -| TASK-111 | a test reads two files outside the repository, so it is green here and red on CI forever | Coding Agent | in_progress | dispatched 18:58 via claude-subagent; worktree pinned to feat/work-modes; awaiting completion | — | V3 | — | main | | | | | | | +| TASK-111 | a test reads two files outside the repository, so it is green here and red on CI forever | Coding Agent | review | PR #19 review: the corpus is IN_REPO-only and CI-covered; merge after origin is reconciled | evidence/2026-08/TASK-111-dispatch-2026-08-20-1930.md | V3 | — | main | | | | | | | | TASK-114 | aiMark reads Perry through the current contracts instead of a pin nine versions old | Coding Agent | in_progress | delegated to an aiMark coding agent; awaiting paste-back | evidence/2026-08/TASK-114-delegation-prompt.md | V4 | — | main | | | | | | | | TASK-119 | the linkage graph is documented as machine-written and no tool writes it | Coding Agent | not_started | — | — | V3 | | main | | | | | | | | TASK-120 | the linkage edges are read but never folded into KR progress | Coding Agent | not_started | — | — | V3 | | main | | | | | | | @@ -50,6 +50,8 @@ | TASK-116 | the mention list is write-only, mislabelled, and two of its carve-outs are unpinned | Coding Agent | not_started | — | — | V3 | | | TASK-117 | two tools disagree about whether the board has drifted when the event log is absent | Coding Agent | not_started | — | — | V3 | | | TASK-118 | the id minters read three sources and the canonical store is not one of them | Coding Agent | not_started | — | — | V3 | | +| TASK-124 | the conformance corpus reads a project outside the repo and has no committed substitute | Coding Agent | not_started | — | — | V3 | | +| TASK-125 | the Anti-Goals-inside-a-version insert case runs only on the author machine | Coding Agent | not_started | — | — | V3 | | ## Cadence (recurring; doesn't consume P0 slots) diff --git a/perry/evidence/2026-08/TASK-111-dispatch-2026-08-20-1930.md b/perry/evidence/2026-08/TASK-111-dispatch-2026-08-20-1930.md new file mode 100644 index 0000000..e7c48f5 --- /dev/null +++ b/perry/evidence/2026-08/TASK-111-dispatch-2026-08-20-1930.md @@ -0,0 +1,82 @@ +# TASK-111 — dispatch record + +> Date: 2026-08-20 · Executor: claude-subagent · PR: https://github.com/ranjiao/Perry/pull/19 +> Branch: `coding/task-111-corpus-outside-repo` · Cycle time: ~35 min +> 1 file: `tests/test_goals_writer.py` + +## The route taken, and why the other one was refused + +The row offered two exits — commit fixtures, or skip out loud. The agent took +**commit fixtures**, on the ground that skipping leaves CI's coverage of the +byte-identity property at zero. The cost it would have paid — a new committed +snapshot of someone else's project — turned out not to exist: both shapes the +out-of-repo files carried are **already committed**. + +- `tests/fixtures/second-project/OKR.md` — the objective-heading shape + (`### Objective N:` plus `### Anti-Goals` nested inside `## v2`). It was the + *only* source of that shape; the three original `IN_REPO` files have zero + objective headings between them. +- `tests/fixtures/sample-project-zh/OKR.md` — one version where every other + corpus file has two, which is the multi-version *disagreement* itself. + +Both round-trip byte-for-byte through `G.Okr(...).render()`, so +`test_the_in_repo_files_round_trip` now covers them too. + +## What changed in the guard + +`test_the_corpus_actually_disagrees` now reads `IN_REPO` and only `IN_REPO`, +with the `exists()` filter **deleted** — a missing committed file is an error, +not a smaller corpus. It excludes `ELSEWHERE` even when those files are present, +so the gate cannot pass here for a reason it would not have on CI. `ELSEWHERE` +survives for the three tests that widen the round-trip on the author's machine +and skip out loud, naming both paths, when it is absent. + +Added `test_the_corpus_is_entirely_inside_the_repository`, which fails if a path +outside `ROOT` is ever put back on the list. + +## Not vacuous — proved by mutating the fixtures, not by asserting a test exists + +- `### Objective N:` → `### Goal N is` in the second-project fixture → + *"no objective heading in the corpus at all"*. +- A second `## v2:` block inserted into the zh fixture so every file counted + alike → *"every file in the corpus has the same number of versions, so the + multi-version shape is not being exercised"*. + +Both reverted with `git checkout --`. + +`env HOME= python3 -m unittest tests.test_goals_writer` → +**114 tests, OK, 3 skipped** — and `test_the_corpus_actually_disagrees` +**passes** rather than skipping, which is the row's whole point. + +## The sweep, and the one sibling it found + +Regex over `tests/` for `Path.home()`, `expanduser`, `"~/`, and absolute +`/Users|/home|/opt|/var|/etc|/usr` literals. Four other uses, three of them fine: + +| Where | Verdict | +|---|---| +| `tests/test_md_store.py:53` | `@unittest.skipUnless` naming the path *and* the committed fixture carrying its shape. The correct pattern, and the precedent followed here | +| `tests/test_escalation_boundaries.py:118` | `"~/.claude/skills"` is a string in a policy table, never opened. Not an instance | +| `tests/test_i18n.py:43,263` | `PATH` for a subprocess env. Not an instance | +| **`tests/test_conformance.py:440`** | `~/proj/gimegime-pmo`. Skips loudly, but has **zero CI coverage and no committed substitute** — unlike `test_md_store.py`. Its own row | + +## The baseline correction, and where it actually comes from + +The agent reported three modules red at `6affa56` — `test_diagnose`, +`test_one_line_break_rule`, `test_v5_signoff` — and was right about the tree it +measured. The cause is not its change and not new drift: **PR #17 and PR #18 +merged on the remote at 10:51Z and were never merged down.** Local +`feat/work-modes` and `origin/feat/work-modes` have diverged 2 commits each way, +and the three fixes those PRs carry — TASK-113's, which is exactly the +live-state-assertion class these three modules are instances of — are on origin +only. Confirmed: `bin/perry-diagnose` differs between the two. + +`python3 bin/perry-lint`: 0 errors, 3 pre-existing NS-01 warnings. +`git diff --check`: clean. + +## One gap left open on purpose + +The "`### Anti-Goals` nested inside a version" **insert** case still runs only +where `~/proj` exists. The second-project fixture carries that shape but already +has a `## Commitments` section, so it cannot drop into those two tests +unmodified — it needs a purpose-built variant, which is a separate row. diff --git a/perry/journal/2026-08/2026-08-20.md b/perry/journal/2026-08/2026-08-20.md index 2ed34d6..ff7136e 100644 --- a/perry/journal/2026-08/2026-08-20.md +++ b/perry/journal/2026-08/2026-08-20.md @@ -104,6 +104,10 @@ - [USER-004] pending → answered · Migrate and name the override; do not refuse. Reasoning recorded because the row was minted for it: refusing would block a whole migration for a reason unrelated to shape, and migration is the one road ADR-004 gives an undeclared project — a refusal there is the wall with no door that this project rejects everywhere else. The override is also reversible: the restore point carries the file's original bytes, verified under TASK-079. The 'at least name it' half of the ADR-004 posture is already satisfied by what shipped in PR #6, and TASK-115 added the guard that keeps that wording an observation rather than advice. The read-only bit stays a signal Perry reports and does not act on. - [TASK-094] not_started → in_progress · started - [TASK-111] not_started → in_progress · started +- [TASK-111] in_progress → review +- [TASK-111] evidence · — → evidence/2026-08/TASK-111-dispatch-2026-08-20-1930.md +- [TASK-124] — → not_started · the conformance corpus reads a project outside the repo and has no committed substitute · owner: Coding Agent · priority: P2 +- [TASK-125] — → not_started · the Anti-Goals-inside-a-version insert case runs only on the author machine · owner: Coding Agent · priority: P2 ## Notes @@ -468,6 +472,28 @@ - **Out of scope**: — - **KR linkage**: unlinked +### TASK-124 — the conformance corpus reads a project outside the repo and has no committed substitute + +- **Owner**: Coding Agent +- **Priority**: P2 +- **Track / mode**: main / project +- **Deliverable**: tests/test_conformance.py:440 stops depending on ~/proj/gimegime-pmo for its only coverage: either a trimmed fixture carrying the shapes those four tests need is committed and they run everywhere, or the row records why the shape cannot be committed and the skip names that reason +- **Verification**: with HOME pointed at an empty directory and PERRY_TEST_CORPUS unset, the four tests in those two classes run rather than skip; mutating the committed fixture reproduces each of their failures; reverting the fix breaks the new test +- **Dependencies**: — +- **Out of scope**: — +- **KR linkage**: unlinked + +### TASK-125 — the Anti-Goals-inside-a-version insert case runs only on the author machine + +- **Owner**: Coding Agent +- **Priority**: P2 +- **Track / mode**: main / project +- **Deliverable**: a purpose-built OKR fixture carrying `### Anti-Goals` nested inside a version WITHOUT a `## Commitments` section, so test_a_declared_queue_track_makes_the_section_land_cleanly and its sibling insert test run from the repository rather than from ~/proj +- **Verification**: with HOME at an empty directory both tests run rather than skip; removing the nested Anti-Goals heading from the new fixture reddens them +- **Dependencies**: — +- **Out of scope**: — +- **KR linkage**: unlinked + ## V5 sign-off **TASK-107 — V5 sign-off. Ran Jiao, 2026-08-20.** diff --git a/perry/tasks.jsonl b/perry/tasks.jsonl index 84b1777..5e9e2f5 100644 --- a/perry/tasks.jsonl +++ b/perry/tasks.jsonl @@ -117,4 +117,6 @@ {"id": "TASK-110", "title": "measure what Perry state costs and propose a retention policy, deleting nothing", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-110-dispatch-2026-08-20-1725.md", "next_action": "the user decides the retention policy; the measurement says the whole proposal recovers 1.4 days of growth", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T16:23:45", "order": null} {"id": "TASK-070", "title": "Perry's own state is 19.5% of the tracked repo and grows unbounded", "owner": "Coding Agent", "status": "dropped", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-070-context.md", "next_action": "decide the retention proposal in TASK-110's evidence; nothing is deleted until then", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-18T00:13:21", "order": null, "summary": ""} {"id": "TASK-094", "title": "Delete the header rule and the row splitter for the three stores", "owner": "Coding Agent", "status": "in_progress", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "dispatched 18:58 via claude-subagent; worktree pinned to feat/work-modes; awaiting completion", "depends_on": ["TASK-090", "TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:51", "order": 1, "summary": ""} -{"id": "TASK-111", "title": "a test reads two files outside the repository, so it is green here and red on CI forever", "summary": "", "owner": "Coding Agent", "status": "in_progress", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "dispatched 18:58 via claude-subagent; worktree pinned to feat/work-modes; awaiting completion", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T16:38:31", "order": 6} +{"id": "TASK-111", "title": "a test reads two files outside the repository, so it is green here and red on CI forever", "summary": "", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-111-dispatch-2026-08-20-1930.md", "next_action": "PR #19 review: the corpus is IN_REPO-only and CI-covered; merge after origin is reconciled", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T16:38:31", "order": 6} +{"id": "TASK-124", "title": "the conformance corpus reads a project outside the repo and has no committed substitute", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-20T19:23:55", "order": 9} +{"id": "TASK-125", "title": "the Anti-Goals-inside-a-version insert case runs only on the author machine", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-20T19:23:55", "order": 10} From b2f621d6346a45fad3ab2cadb6589d39d946b200 Mon Sep 17 00:00:00 2001 From: Ran Jiao Date: Thu, 20 Aug 2026 19:50:53 +0800 Subject: [PATCH 4/5] record: TASK-094 to review with its scope gap named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #20 lands the store-backed read for the fifteen task columns and the KR tables. Verification item 1 asked for 0 call sites across all three stores; OKR.md and .perry/config.md reach 0, BOARD.md does not — four registers have no store. The row does not close on this. Co-Authored-By: Claude Opus 5 --- .perry/events.jsonl | 4 + perry/BOARD.md | 6 +- .../TASK-094-dispatch-2026-08-20-1958.md | 103 ++++++++++++++++++ perry/journal/2026-08/2026-08-20.md | 4 + perry/tasks.jsonl | 6 +- 5 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 perry/evidence/2026-08/TASK-094-dispatch-2026-08-20-1958.md diff --git a/.perry/events.jsonl b/.perry/events.jsonl index 1c55c02..15010d8 100644 --- a/.perry/events.jsonl +++ b/.perry/events.jsonl @@ -508,3 +508,7 @@ {"ts": "2026-08-20T19:23:44", "event": "evidence", "id": "TASK-111", "title": "a test reads two files outside the repository, so it is green here and red on CI forever", "track": "main", "actor": "agent", "from": "—", "to": "evidence/2026-08/TASK-111-dispatch-2026-08-20-1930.md"} {"ts": "2026-08-20T19:23:55", "event": "add", "id": "TASK-124", "title": "the conformance corpus reads a project outside the repo and has no committed substitute", "track": "main", "mode": "project", "priority": "P2", "actor": "agent", "summary": "", "depends_on": [], "from": null, "to": "not_started"} {"ts": "2026-08-20T19:23:55", "event": "add", "id": "TASK-125", "title": "the Anti-Goals-inside-a-version insert case runs only on the author machine", "track": "main", "mode": "project", "priority": "P2", "actor": "agent", "summary": "", "depends_on": [], "from": null, "to": "not_started"} +{"ts": "2026-08-20T19:50:42", "event": "status", "id": "TASK-094", "title": "Delete the header rule and the row splitter for the three stores", "track": "main", "actor": "agent", "depends_on": [], "from": "in_progress", "to": "review", "reason": ""} +{"ts": "2026-08-20T19:50:42", "event": "evidence", "id": "TASK-094", "title": "Delete the header rule and the row splitter for the three stores", "track": "main", "actor": "agent", "from": "—", "to": "evidence/2026-08/TASK-094-dispatch-2026-08-20-1958.md"} +{"ts": "2026-08-20T19:50:53", "event": "next", "id": "TASK-050", "title": "One normalization for a header cell, not two", "track": "main", "actor": "agent", "from": "After TASK-094 lands, rescope this task to header handling still required by adoption", "to": "unblocks on PR #20; re-scope to the adoption reader (parse_board/parse_okr with no store, parse_tracks, read_conformance, parse_phase/parse_decisions) — the fifth hardening round should be a mutation harness, not another regex"} +{"ts": "2026-08-20T19:50:53", "event": "next", "id": "TASK-067", "title": "The writer can destroy the table it writes to, and perry-lint cannot see it", "track": "main", "actor": "agent", "from": "After TASK-094 and TASK-095 land, retain only foreign-project adoption coverage and the escaped-pipe behavioural corpus", "to": "unblocks on PR #20 but does not become empty: perry-decide still writes DECISIONS.md, perry-goals still writes OKR.md § Commitments in place, perry-migrate still rewrites a stranger files, and ragged-row is still the only catch"} diff --git a/perry/BOARD.md b/perry/BOARD.md index b8ce5a4..085fda3 100644 --- a/perry/BOARD.md +++ b/perry/BOARD.md @@ -16,15 +16,15 @@ | ID | Title | Owner | Status | Next action | Evidence | Verification | Depends on | |---|---|---|---|---|---|---|---| -| TASK-050 | One normalization for a header cell, not two | Coding Agent | blocked | After TASK-094 lands, rescope this task to header handling still required by adoption | — | V4 | TASK-094 | -| TASK-067 | The writer can destroy the table it writes to, and perry-lint cannot see it | Coding Agent | blocked | After TASK-094 and TASK-095 land, retain only foreign-project adoption coverage and the escaped-pipe behavioural corpus | evidence/2026-08/TASK-067-finding.md | V4 | TASK-094, TASK-095 | +| TASK-050 | One normalization for a header cell, not two | Coding Agent | blocked | unblocks on PR #20; re-scope to the adoption reader (parse_board/parse_okr with no store, parse_tracks, read_conformance, parse_phase/parse_decisions) — the fifth hardening round should be a mutation harness, not another regex | — | V4 | TASK-094 | +| TASK-067 | The writer can destroy the table it writes to, and perry-lint cannot see it | Coding Agent | blocked | unblocks on PR #20 but does not become empty: perry-decide still writes DECISIONS.md, perry-goals still writes OKR.md § Commitments in place, perry-migrate still rewrites a stranger files, and ragged-row is still the only catch | evidence/2026-08/TASK-067-finding.md | V4 | TASK-094, TASK-095 | ## P1 | ID | Title | Owner | Status | Next action | Evidence | Verification | Depends on | Track | Stage | Stage since | Arrived | Parent | Commitment | Role | |---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| | TASK-077 | DESIGN-006 F — a finance-shaped role runs one real task end to end | Coding Agent | not_started | Run the finance-shaped role end to end on a copy of gimegime-pmo, then write the extraction report. | evidence/2026-08/TASK-077-context.md | V5 | TASK-073, TASK-075, TASK-076 | main | | | | | | | -| TASK-094 | Delete the header rule and the row splitter for the three stores | Coding Agent | in_progress | dispatched 18:58 via claude-subagent; worktree pinned to feat/work-modes; awaiting completion | — | V3 | TASK-090, TASK-092 | main | | | | | | | +| TASK-094 | Delete the header rule and the row splitter for the three stores | Coding Agent | review | scope decision: PR #20 leaves 13 splits / 87 resolutions on four storeless BOARD registers, so verification item 1 (0 call sites) is unmet as written | evidence/2026-08/TASK-094-dispatch-2026-08-20-1958.md | V3 | TASK-090, TASK-092 | main | | | | | | | | TASK-095 | Remove the parser for the three stores; keep what adoption needs | Coding Agent | not_started | — | — | V4 | TASK-094 | main | | | | | | | | TASK-097 | Migrate the two real projects to the store, at V5 | Coding Agent | not_started | — | — | V5 | TASK-092 | main | | | | | | | | TASK-099 | Sweep bin/, viewer/ and tests/ for document handling that ADR-007 made dead | Coding Agent | not_started | — | — | V4 | TASK-095 | main | | | | | | | diff --git a/perry/evidence/2026-08/TASK-094-dispatch-2026-08-20-1958.md b/perry/evidence/2026-08/TASK-094-dispatch-2026-08-20-1958.md new file mode 100644 index 0000000..5521a39 --- /dev/null +++ b/perry/evidence/2026-08/TASK-094-dispatch-2026-08-20-1958.md @@ -0,0 +1,103 @@ +# TASK-094 — dispatch record + +> Date: 2026-08-20 · Executor: claude-subagent · PR: https://github.com/ranjiao/Perry/pull/20 +> Branch: `coding/task-094-header-rule-row-splitter` · Cycle time: ~95 min +> 5 code files: `viewer/parsers.py`, `bin/perry-task`, `bin/perry-state`, +> `tests/test_row_integrity.py`, `tests/test_one_header_rule.py` + +## The count, which is both the deliverable and the verification + +One `load_snapshot` of this project, measured by wrapping `split_row` / `squash` +where `viewer/parsers.py` reaches them: + +| | row splits | header resolutions | +|---|---|---| +| before | 96 | 759 | +| after, as the repo ships (`tasks.jsonl` only) | 68 | 634 | +| after, all three stores imported | 44 | 381 | + +Per file: `OKR.md` **24 → 0** and **253 → 0**. `.perry/config.md` was 0 → 0 in +`parsers.py` all along — `resolve_state_root` reads one setting with a regex. +`BOARD.md` **41 → 13** and **212 → 87**. + +## Verification item 1 is not met, and this is the reason + +The spec asks for **0** call sites across all three stores. `OKR.md` and +`.perry/config.md` reach it. `BOARD.md` does not: the residual 13 splits and 87 +resolutions are four registers — `## Cadence`, `## Intake`, +`## User Input Queue`, `## Top risks` — that **have no store to read from**. +`TASK-090-spec § 5` bounded them explicitly and DESIGN-007 orders the entity +work that ends them. + +The agent did not excuse them: they are **pinned by name in the guard**, so a +fifth reader has to be somebody's decision rather than a silent addition. It +also named the second half of the gap — `parse_board` / `parse_okr` are called +by five out-of-scope tools and five out-of-scope test modules that pass raw +markdown, so 0 was not reachable inside the row's own file scope. + +**This row does not close on this PR.** Either its target moves from 0 to +"0 for the store-backed columns, with the four storeless registers pinned", or +it stays open behind DESIGN-007. That is a scope decision, not a closing note. + +## What did land + +The fifteen task columns and the KR tables are read out of the store. The +markdown reader survives under exactly one caller — a project with no store, +which is adoption. + +Byte identity held: `perry-tasks diff`, `perry-okr diff` and `perry-config diff` +all report `identical: true`, `cells_verbatim {}`, `cells_wearing_decoration {}`, +before *and* after. No renderer was touched. (`okr.jsonl` / `config.jsonl` had to +be imported with `write --from-file` to run the diff at all; that was done in the +worktree and undone — importing them is TASK-092/097's act, and they are not in +the PR.) + +Drift still fires: the detector was given `PMOSnapshot.board_as_authored` rather +than being allowed to compare the store against the log, which is ADR-007 +decision 2. + +## The fourth blind spot in a guard hardened three times + +Four mutations were planted. Three reddened immediately. The fourth — +`_parse_task_table` resolving its header by the old second rule — came up +**green**, and that is the finding: + +``` +SECOND_RULE = r"=\s*\[[^\]]*?\.lower\(\)[^\]]*?\bfor\b\s+\w+\s+in\s+…" +live shape: header = ([c.strip().lower() for c in split_row(prev)] +``` + +One leading paren between `=` and `[`. The comment block above that pattern +records three previous rounds of hardening, all of them **spelling**-shaped +(`for c in cells` caught, `for h in header` not). This one is **shape**-shaped +and walked past all three. Widened to `=\s*[(\[\s]*\[`, re-planted, red. + +The lesson belongs on TASK-050: the fifth round should be a mutation harness, +not another regex. + +## A field moved, and the store was right + +TASK-050 and TASK-067 sit in a `## P0` table that **has no `Track` column**, so +the board read them as trackless while `perry/tasks.jsonl` has held +`track: "main"` for both all along. Confirmed independently. Store-backed +reading surfaces the disagreement — which is the exact silent-divergence class +ADR-007 targets, showing up on the two rows that were waiting on this one. + +## Two defects reported, one reproduced and one not + +1. **The baseline is not green.** 63 modules / 1846 tests, 4 red at `6affa56`: + `test_diagnose`, `test_host_support`, `test_one_line_break_rule`, + `test_v5_signoff`. Confirmed, and the cause is now known: PR #17 and PR #18 + merged on the remote and were never merged down — see + `TASK-111-dispatch-2026-08-20-1930.md`. `test_host_support` is separate, a + dispatch-limiter race that passes when not competing for cores. +2. **`perry-state --json`'s `design.by_status` key order is nondeterministic.** + *Not reproduced here* — five consecutive runs all returned `['locked']`. All + seven designs currently share one status, so key order cannot vary in this + project. Recorded as unconfirmed rather than dismissed. + +## Process note + +`bash tests/run` invokes `tests/parallel` with its own default worker count (8), +**not** the `-j 4` the dispatch prompt specified. It briefly put 12 workers on a +14-core machine alongside the agent's own run. diff --git a/perry/journal/2026-08/2026-08-20.md b/perry/journal/2026-08/2026-08-20.md index ff7136e..2d12ed8 100644 --- a/perry/journal/2026-08/2026-08-20.md +++ b/perry/journal/2026-08/2026-08-20.md @@ -108,6 +108,10 @@ - [TASK-111] evidence · — → evidence/2026-08/TASK-111-dispatch-2026-08-20-1930.md - [TASK-124] — → not_started · the conformance corpus reads a project outside the repo and has no committed substitute · owner: Coding Agent · priority: P2 - [TASK-125] — → not_started · the Anti-Goals-inside-a-version insert case runs only on the author machine · owner: Coding Agent · priority: P2 +- [TASK-094] in_progress → review +- [TASK-094] evidence · — → evidence/2026-08/TASK-094-dispatch-2026-08-20-1958.md +- [TASK-050] next action · unblocks on PR #20; re-scope to the adoption reader (parse_board/parse_okr with no store, parse_tracks, read_conformance, parse_phase/parse_decisions) — the fifth hardening round should be a mutation harness, not another regex +- [TASK-067] next action · unblocks on PR #20 but does not become empty: perry-decide still writes DECISIONS.md, perry-goals still writes OKR.md § Commitments in place, perry-migrate still rewrites a stranger files, and ragged-row is still the only catch ## Notes diff --git a/perry/tasks.jsonl b/perry/tasks.jsonl index 5e9e2f5..9255a92 100644 --- a/perry/tasks.jsonl +++ b/perry/tasks.jsonl @@ -39,7 +39,6 @@ {"id": "TASK-046", "title": "A queue track must declare an SLA at creation — no default", "owner": "", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "modes/queue.md, bin/perry-lint, schema/state-schema.json, tests/test_work_modes.py", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-17T17:45:07", "order": null, "summary": ""} {"id": "TASK-048", "title": "perry-diagnose's own output does not validate against Perry's schema", "owner": "", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "bin/perry-lint, schema/state-schema.json, tests/test_diagnose.py", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-17T17:57:06", "order": null, "summary": ""} {"id": "TASK-049", "title": "Migration's injected header block merges into an adjacent blockquote, and mixes languages", "owner": "", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "bin/perry-migrate + tests/test_migrate.py TestAHeaderBlockIsNotAnyQuotedText", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-17T17:59:22", "order": null, "summary": ""} -{"id": "TASK-050", "title": "One normalization for a header cell, not two", "owner": "Coding Agent", "status": "blocked", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "After TASK-094 lands, rescope this task to header handling still required by adoption", "depends_on": ["TASK-094"], "commitment": "", "parent": "", "group": "P0 (must finish this period)", "role": "", "created": "2026-08-17T19:24:52", "order": 0, "summary": ""} {"id": "TASK-051", "title": "Migration recognizes a table by shape, not by vocabulary", "owner": "", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-051-052-v4-review.md", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-17T19:24:52", "order": null, "summary": ""} {"id": "TASK-052", "title": "The losslessness assertions ask whether the bytes survived, never what the file now says", "owner": "", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-052-053-fixes.md", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-17T19:24:52", "order": null, "summary": ""} {"id": "TASK-053", "title": "route ignores --group, and the refusal that recommends it is mine", "owner": "", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-052-053-fixes.md", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-17T19:24:52", "order": null, "summary": ""} @@ -55,7 +54,6 @@ {"id": "TASK-063", "title": "A dependency is not queryable — blocked says that, never on what", "owner": "", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "schema/task-list-contract.md, bin/perry-task, tests/test_task_writer.py", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-17T22:54:30", "order": null, "summary": ""} {"id": "TASK-064", "title": "SKILL.md is 62 KB and loads on every invocation — cut it under 20 KB", "owner": "", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "SKILL.md, reference/router-subcommands.md, reference/style.md, reference/snapshot.md, reference/config.md, reference/first-run.md, reference/hand-off-contract.md, tests/test_router_budget.py", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-17T23:21:01", "order": null, "summary": ""} {"id": "TASK-065", "title": "bin/lib/ — six primitives, fourteen implementations, no shared module", "owner": "", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-065-extraction.md", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-17T23:23:02", "order": null, "summary": ""} -{"id": "TASK-067", "title": "The writer can destroy the table it writes to, and perry-lint cannot see it", "owner": "Coding Agent", "status": "blocked", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-067-finding.md", "next_action": "After TASK-094 and TASK-095 land, retain only foreign-project adoption coverage and the escaped-pipe behavioural corpus", "depends_on": ["TASK-094", "TASK-095"], "commitment": "", "parent": "", "group": "P0 (must finish this period)", "role": "", "created": "2026-08-17T23:34:14", "order": 1, "summary": ""} {"id": "TASK-068", "title": "Migration joins its header block onto the author's prose — the fourth instance of one defect class", "owner": "", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-068-position-is-not-evidence.md", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-18T00:02:16", "order": null, "summary": ""} {"id": "TASK-069", "title": "The i18n alias table is read by five tools and hand-carried by the three that read state", "owner": "", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-069-i18n-one-table.md", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-18T00:13:10", "order": null, "summary": ""} {"id": "TASK-071", "title": "A task's priority cannot be changed — triage's central act has no writer", "owner": "", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "tests/test_prioritize.py", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-18T00:14:21", "order": null, "summary": ""} @@ -116,7 +114,9 @@ {"id": "TASK-123", "title": "the goals writer takes the file as truth and derives the store, which is the opposite direction from the KR", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T18:30:45", "order": 12} {"id": "TASK-110", "title": "measure what Perry state costs and propose a retention policy, deleting nothing", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-110-dispatch-2026-08-20-1725.md", "next_action": "the user decides the retention policy; the measurement says the whole proposal recovers 1.4 days of growth", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T16:23:45", "order": null} {"id": "TASK-070", "title": "Perry's own state is 19.5% of the tracked repo and grows unbounded", "owner": "Coding Agent", "status": "dropped", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-070-context.md", "next_action": "decide the retention proposal in TASK-110's evidence; nothing is deleted until then", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-18T00:13:21", "order": null, "summary": ""} -{"id": "TASK-094", "title": "Delete the header rule and the row splitter for the three stores", "owner": "Coding Agent", "status": "in_progress", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "dispatched 18:58 via claude-subagent; worktree pinned to feat/work-modes; awaiting completion", "depends_on": ["TASK-090", "TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:51", "order": 1, "summary": ""} {"id": "TASK-111", "title": "a test reads two files outside the repository, so it is green here and red on CI forever", "summary": "", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-111-dispatch-2026-08-20-1930.md", "next_action": "PR #19 review: the corpus is IN_REPO-only and CI-covered; merge after origin is reconciled", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T16:38:31", "order": 6} {"id": "TASK-124", "title": "the conformance corpus reads a project outside the repo and has no committed substitute", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-20T19:23:55", "order": 9} {"id": "TASK-125", "title": "the Anti-Goals-inside-a-version insert case runs only on the author machine", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-20T19:23:55", "order": 10} +{"id": "TASK-094", "title": "Delete the header rule and the row splitter for the three stores", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-094-dispatch-2026-08-20-1958.md", "next_action": "scope decision: PR #20 leaves 13 splits / 87 resolutions on four storeless BOARD registers, so verification item 1 (0 call sites) is unmet as written", "depends_on": ["TASK-090", "TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:51", "order": 1, "summary": ""} +{"id": "TASK-050", "title": "One normalization for a header cell, not two", "owner": "Coding Agent", "status": "blocked", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "unblocks on PR #20; re-scope to the adoption reader (parse_board/parse_okr with no store, parse_tracks, read_conformance, parse_phase/parse_decisions) — the fifth hardening round should be a mutation harness, not another regex", "depends_on": ["TASK-094"], "commitment": "", "parent": "", "group": "P0 (must finish this period)", "role": "", "created": "2026-08-17T19:24:52", "order": 0, "summary": ""} +{"id": "TASK-067", "title": "The writer can destroy the table it writes to, and perry-lint cannot see it", "owner": "Coding Agent", "status": "blocked", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-067-finding.md", "next_action": "unblocks on PR #20 but does not become empty: perry-decide still writes DECISIONS.md, perry-goals still writes OKR.md § Commitments in place, perry-migrate still rewrites a stranger files, and ragged-row is still the only catch", "depends_on": ["TASK-094", "TASK-095"], "commitment": "", "parent": "", "group": "P0 (must finish this period)", "role": "", "created": "2026-08-17T23:34:14", "order": 1, "summary": ""} From 731affe70f1ff1d9e96e22b4f8a835e42f9f8799 Mon Sep 17 00:00:00 2001 From: Ran Jiao Date: Thu, 20 Aug 2026 20:18:09 +0800 Subject: [PATCH 5/5] feat(tests): diff each contract document against the payload it describes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KR-O2.4's metric is a count of contract-payload keys documented but not emitted, or emitted but not documented, and nothing in the repository computed it. `test_contract_invariance` records the payload's SHAPE against a baseline, so an emitted-and-undocumented key passes it cleanly and a documented-and-never-emitted key was never in its baseline to begin with; the seven other modules that read a contract file each assert that one named field appears in the prose. Seven spot checks are not a count. `tests/contract_key_parity.py` reads each page's `jsonc` payload sketch and its key tables, runs the invocation the page states in its own `# ` heading, and diffs the two sets of field paths in both directions. Discovery is `schema/*-contract.md` and the run prints how many files it matched — the KR text says "all three contracts" and the glob finds FIVE. The per-contract result is recorded in `tests/fixtures/contract-key-parity.json` so the number is comparable by someone who was not here. Measured today: 0 documented-not-emitted, 17 emitted-not-documented. Neither is asserted to be zero. This row measures the gap; closing it is whatever rows the measurement produces. What could not be compared is named rather than dropped: 20 documented keys across `perry-task/list` and `perry-roles/list` sit in collections Perry's own state leaves empty (`intake.rows`, `asks.items`, `roles.cards`), and 3 more in `expired_sunsets`. A silently narrowed denominator is worse than a smaller one that is stated. Proved by mutation, both directions: removing `startable` from its emit sites names `tasks[].startable` in `documented_not_emitted`; planting a key names `tasks[].undeclared_key` in `emitted_not_documented`; moving one page out of `schema/` takes the discovered count from 5 to 4; a sixth page declaring an unemitted key is reported with no edit to the check. Co-Authored-By: Claude Opus 5 --- schema/README.md | 18 ++ tests/contract_key_parity.py | 393 ++++++++++++++++++++++++ tests/fixtures/contract-key-parity.json | 112 +++++++ tests/test_contract_key_parity.py | 196 ++++++++++++ 4 files changed, 719 insertions(+) create mode 100644 tests/contract_key_parity.py create mode 100644 tests/fixtures/contract-key-parity.json create mode 100644 tests/test_contract_key_parity.py diff --git a/schema/README.md b/schema/README.md index f89c671..0738525 100644 --- a/schema/README.md +++ b/schema/README.md @@ -103,6 +103,24 @@ project: `perry-state --json` remains the agent-facing combined read. It is **not** a frozen contract — treat anything taken from it as best-effort. +### Where the parity number lives + +Each page above declares keys and each tool emits them, and until TASK-127 +**nothing diffed the two against each other.** `tests/contract_key_parity.py` +does, in both directions — `documented_not_emitted` and +`emitted_not_documented` — and records the per-contract result in +**`tests/fixtures/contract-key-parity.json`**, which is the file to read when +someone asks what the number is. + + python3 tests/contract_key_parity.py # per-contract counts + python3 tests/contract_key_parity.py --record # after a reviewed change + +Two things about it are deliberate. **Discovery is a glob** — +`schema/*-contract.md`, and the run prints how many files it matched — so a +page added later is measured without anyone remembering to add it to a list. +And **neither count is asserted to be zero**: the baseline is what makes a +change to either one visible, not a claim that the gap is closed. + ## Consumers The schema is a **cross-repo contract**, not a Perry-internal detail. Four diff --git a/tests/contract_key_parity.py b/tests/contract_key_parity.py new file mode 100644 index 0000000..f30df1e --- /dev/null +++ b/tests/contract_key_parity.py @@ -0,0 +1,393 @@ +"""Two-way key diff: what each contract DOCUMENT declares vs what the tool EMITS. + +**The number KR-O2.4 asks for and nothing computed.** The KR's metric is a +count of *contract-payload keys documented but not emitted, or emitted but not +documented*. `tests/test_contract_invariance.py` is the nearest thing and it +answers a different question: it records the payload's SHAPE and compares it to +a recorded baseline, so a key that is emitted and undocumented passes cleanly, +and a key documented and never emitted was never in the baseline to begin with. +The seven other modules that read a contract file each assert that **one named +field** appears in the prose. Seven spot checks are not a count. + +**Discovery is a glob, on purpose.** `schema/*-contract.md`. A hand-written +list of contracts is the failure this check exists to prevent — the KR text +says "all three contracts" and there are **five**. The run prints how many +files it found, so a sixth cannot be silently skipped. + +## How a document is read + +Every contract page states its own invocation in its `# ` heading, inside +backticks: ``perry-task list --json``, ``perry-state --json § roles``. That is +the command this check runs, and the `§ ` suffix selects the subtree the +page is about. Nothing here holds a table of tools. + +Declared paths come from two places, both mechanical: + +1. **The `jsonc` payload sketch.** Comments and `/* below */` placeholders are + stripped and the rest is parsed as JSON, so nesting survives — these are + real paths, not a bag of names. +2. **The key tables.** A row whose first cell is nothing but backticked + identifiers (``| `id` | string | … |``, ``| `from`, `to` | string | … |``) + declares those keys. A first cell carrying prose is not a key table, which + is how the changelog and `decide § Reading is tolerant` tables stay out. + +A table says nothing about *where* its keys hang. That is resolved by matching +the table's key set against the emitted containers and taking the best fit — +with a floor, so a single coincidental overlap cannot claim a table. **A table +that finds no container is reported by name as unassigned rather than dropped**: +a collection this project's own state leaves empty (`intake.rows`, `asks.items`, +`roles.cards`) offers no children to match against, and a silently narrowed +denominator is worse than a smaller one that is stated. + +## What the two numbers mean + +- `documented_not_emitted` — the page promises a key the payload does not carry. +- `emitted_not_documented` — the payload carries a key the page never declares. + +Neither is asserted to be zero. This check measures the gap; closing it is +whatever rows the measurement produces. The baseline in +`tests/fixtures/contract-key-parity.json` is what makes the number comparable +across runs by someone who was not here. + +Run: + + python3 tests/contract_key_parity.py # per-contract counts + python3 tests/contract_key_parity.py --json # the same, machine-readable + python3 tests/contract_key_parity.py --record # rewrite the baseline +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import subprocess +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +GLOB = "schema/*-contract.md" +BASELINE = ROOT / "tests" / "fixtures" / "contract-key-parity.json" + +#: A key, or a dotted path of keys. Lower-case with underscores, which is every +#: key in every one of these payloads and is not any of the prose, versions +#: (`1.11`) or ids (`ADR-002`) that also appear in a first table cell. +IDENT = re.compile(r"^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)*$") + +#: The invocation, out of the page's own `# ` heading. `§ roles` selects the +#: subtree of a larger payload that the page is the contract for. +SUBTREE = re.compile(r"§\s*([A-Za-z_][A-Za-z0-9_]*)") + +#: `perry-task/list/1.11`. The page states it either in the heading or in the +#: `> Contract:` line; the first occurrence is the page's own. +CONTRACT_ID = re.compile(r"`(perry-[a-z]+/[a-z]+/\d+\.\d+)`") + +#: A table row's first cell must be backticked names and nothing else. What is +#: allowed BETWEEN them: commas, slashes and whitespace. +SEPARATORS = re.compile(r"[\s,/]+") + + +# ---------------------------------------------------------------- the payload + +def paths(value, prefix: str = "") -> dict[str, str]: + """Every field path in a payload, with the type at it. + + Lists collapse to their first element, exactly as + `test_contract_invariance.shape` does: a contract promises what an entry + looks like, not how many there are. + """ + out: dict[str, str] = {} + if isinstance(value, dict): + for key, child in value.items(): + here = f"{prefix}.{key}" if prefix else key + out[here] = type(child).__name__ + out.update(paths(child, here)) + elif isinstance(value, list) and value: + out.update(paths(value[0], f"{prefix}[]")) + return out + + +def empty_lists(value, prefix: str = "") -> set[str]: + """List paths with no item to inspect. Their entry shape is unobservable in + this run — which is a different fact from a key being absent, and the two + must not be reported as one.""" + out: set[str] = set() + if isinstance(value, dict): + for key, child in value.items(): + out.update(empty_lists(child, f"{prefix}.{key}" if prefix else key)) + elif isinstance(value, list): + if not value: + out.add(prefix) + else: + out.update(empty_lists(value[0], f"{prefix}[]")) + return out + + +def under_empty(path: str, empties: set[str]) -> str: + """The empty list `path` sits inside, or `""`.""" + for parent in empties: + if path.startswith(f"{parent}[]."): + return parent + return "" + + +# --------------------------------------------------------------- the document + +def strip_comments(block: str) -> str: + """`// …` to end of line and `/* … */` inline, so the sketch parses. + + Both are outside strings in every one of these blocks — the sketches carry + no URL and no path with a `//` in it — and this is asserted by the parse + succeeding, not assumed: a block that does not parse is reported, never + skipped. + """ + block = re.sub(r"/\*.*?\*/", "", block, flags=re.S) + return re.sub(r"//[^\n]*", "", block) + + +def sketch_paths(text: str) -> tuple[dict[str, str], list[str]]: + """Paths from the page's ```jsonc``` payload sketch, and any block that + would not parse.""" + found: dict[str, str] = {} + unparsed: list[str] = [] + for tag, block in re.findall(r"^```(json[c]?)\n(.*?)^```", text, + flags=re.S | re.M): + body = strip_comments(block).strip().rstrip(",") + if not body.startswith("{"): + # `schema/roles-list-contract.md` sketches its own subtree as a + # bare `"roles": { … }` member rather than a whole object. + body = "{" + body + "}" + try: + found.update(paths(json.loads(body))) + except json.JSONDecodeError as exc: + unparsed.append(f"{tag} block: {exc}") + return found, unparsed + + +def key_tables(text: str) -> list[tuple[str, list[str]]]: + """`(heading, keys)` for every table whose first column is keys. + + The heading is the nearest one above the table, and is only ever used to + NAME an unassigned table in the report — never to place it. Placing by + heading would mean inventing a mapping from English to payload structure, + which is the hand-written list this check refuses to carry. + """ + tables: list[tuple[str, list[str]]] = [] + heading, current, fenced = "", [], False + for line in text.splitlines(): + if line.startswith("```"): + fenced = not fenced + continue + if fenced: + continue + if line.startswith("#"): + heading = line.lstrip("#").strip() + if line.startswith("|"): + cells = [c.strip() for c in line.strip().strip("|").split("|")] + names = re.findall(r"`([^`]+)`", cells[0]) + residue = SEPARATORS.sub("", re.sub(r"`[^`]+`", "", cells[0])) + if names and not residue and all(IDENT.match(n) for n in names): + current.extend(names) + elif current: + tables.append((heading, current)) + current = [] + if current: + tables.append((heading, current)) + return tables + + +def containers(emitted: dict[str, str], empties: set[str]) -> dict[str, set[str]]: + """Every path a key could hang under, with the child keys seen there. + + The root is `""`. An empty list contributes a container with no children — + it exists, and that is why a table matching nothing can be reported as + *unobservable here* rather than as an error. + """ + out: dict[str, set[str]] = {"": set()} + for path in emitted: + parent, _, leaf = path.rpartition(".") + out.setdefault(parent, set()).add(leaf) + for parent in empties: + out.setdefault(f"{parent}[]", set()) + return out + + +def place(keys: list[str], boxes: dict[str, set[str]]) -> str | None: + """The container a key table describes, or `None`. + + Scored on **coverage** — how much of the table the container accounts for — + and broken on **precision**, how much of the container the table accounts + for. Both are needed and neither alone is enough: + + - Coverage alone put `asks`'s eight fields on `tasks[]`, which shares + `id`, `blocks`, `status` and `priority` with them, and filed four + phantom findings against the wrong object. + - Coverage alone cannot separate `asks` from `risks`: both carry `items` + and `open`, so a table of exactly those two covers each of them + completely, and precision is what breaks the tie. + + A table that clears neither floor is left unplaced and reported by name. + """ + heads = {k.split(".")[0] for k in keys} + scored = sorted( + ((len(heads & children) / len(heads), + len(heads & children) / len(children) if children else 0.0, + len(heads & children), path) + for path, children in boxes.items()), + key=lambda s: (-s[0], -s[1], s[3])) + best = scored[0] + runner = scored[1] if len(scored) > 1 else (0.0, 0.0, 0, "") + if best[0] < 0.6 or best[2] < 2: + return None + if (best[0], best[1]) == (runner[0], runner[1]): + return None + return best[3] + + +# ------------------------------------------------------------------ the check + +def discover(schema_home: pathlib.Path | None = None) -> list[pathlib.Path]: + """Every contract page, by glob. + + `schema_home` exists so a test can point the glob at a directory it built + — which is how "a sixth contract is picked up without editing this file" + is proved rather than asserted. + """ + if schema_home is not None: + return sorted(schema_home.glob("*-contract.md")) + return sorted(ROOT.glob(GLOB)) + + +def invoke(text: str) -> tuple[list[str], str]: + """The argv and subtree key the page's own heading states.""" + heading = text.splitlines()[0] + quoted = re.search(r"`([^`]+)`", heading) + if not quoted: + raise ValueError("the `# ` heading names no command in backticks") + command = quoted.group(1) + subtree = SUBTREE.search(command) + if subtree: + command = command[:subtree.start()] + return command.split(), (subtree.group(1) if subtree else "") + + +def compare(path: pathlib.Path, root: str = "") -> dict: + """`root` is the PROJECT the tools read. It defaults to Perry's own + repository, which is the representative fixture — the same choice + `test_contract_invariance` makes, and for the same reason: it is the only + checked-in project carrying a real board, a real OKR and a real event log. + Its own state is what decides which collections are observable, and the + ones it leaves empty are named in the report rather than dropped.""" + text = path.read_text() + argv, subtree = invoke(text) + proc = subprocess.run( + [sys.executable, f"bin/{argv[0]}", *argv[1:]] + + (["--root", root] if root else []), + capture_output=True, text=True, cwd=ROOT) + if proc.returncode != 0: + raise RuntimeError(f"{path.name}: `{' '.join(argv)}` exited " + f"{proc.returncode}: {proc.stderr[-300:]}") + payload = json.loads(proc.stdout) + if subtree: + payload = {subtree: payload[subtree]} + + emitted = paths(payload) + empties = empty_lists(payload) + boxes = containers(emitted, empties) + + documented, unparsed = sketch_paths(text) + unassigned: list[str] = [] + for heading, keys in key_tables(text): + box = place(keys, boxes) + if box is None: + unassigned.extend(f"{heading} § {k}" for k in keys) + continue + for key in keys: + documented[f"{box}.{key}" if box else key] = "documented" + + not_observable = {p: under_empty(p, empties) for p in documented + if under_empty(p, empties)} + gone = sorted(p for p in documented + if p not in emitted and p not in not_observable) + extra = sorted(p for p in emitted if p not in documented) + identifier = CONTRACT_ID.search(text) + return { + "contract": identifier.group(1) if identifier else "", + "file": (path.relative_to(ROOT).as_posix() + if path.is_relative_to(ROOT) else str(path)), + "command": " ".join(argv) + (f" § {subtree}" if subtree else ""), + "documented": len(documented), + "emitted": len(emitted), + "documented_not_emitted": gone, + "emitted_not_documented": extra, + "not_observable": {p: f"inside `{v}`, which is empty in this run" + for p, v in sorted(not_observable.items())}, + "unassigned": sorted(unassigned), + "unparsed_sketches": unparsed, + } + + +def measure(root: str = "") -> dict: + files = discover() + return { + "glob": GLOB, + "root": root or ".", + "contract_files_discovered": len(files), + "contracts": {c["contract"] or c["file"]: c + for c in (compare(f, root) for f in files)}, + } + + +def report(result: dict) -> str: + lines = [f"contract files discovered: " + f"{result['contract_files_discovered']} ({result['glob']})", + f"project read: {result['root']}", ""] + gone = extra = 0 + for name, c in sorted(result["contracts"].items()): + gone += len(c["documented_not_emitted"]) + extra += len(c["emitted_not_documented"]) + lines.append(f"{name} {c['file']}") + lines.append(f" `{c['command']}` " + f"{c['documented']} documented / {c['emitted']} emitted") + for label, key in (("documented_not_emitted", "documented_not_emitted"), + ("emitted_not_documented", "emitted_not_documented")): + lines.append(f" {label}: {len(c[key])}") + lines.extend(f" {p}" for p in c[key]) + if c["not_observable"]: + lines.append(f" not observable here: {len(c['not_observable'])}") + for p, why in c["not_observable"].items(): + lines.append(f" {p} — {why}") + if c["unassigned"]: + lines.append(f" table matched no emitted collection: " + f"{len(c['unassigned'])}") + lines.extend(f" {p}" for p in c["unassigned"]) + for problem in c["unparsed_sketches"]: + lines.append(f" SKETCH DID NOT PARSE: {problem}") + lines.append("") + lines.append(f"TOTAL documented_not_emitted: {gone} " + f"emitted_not_documented: {extra} " + f"(KR-O2.4 metric: {gone + extra})") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--json", action="store_true", help="machine-readable") + ap.add_argument("--record", action="store_true", + help=f"rewrite {BASELINE.relative_to(ROOT)}") + ap.add_argument("--root", default="", + help="the project to read (default: Perry's own repo)") + args = ap.parse_args(argv) + result = measure(args.root) + if args.record: + BASELINE.parent.mkdir(parents=True, exist_ok=True) + BASELINE.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n") + print(f"recorded {BASELINE.relative_to(ROOT)}") + return 0 + print(json.dumps(result, indent=2, sort_keys=True) if args.json + else report(result)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/fixtures/contract-key-parity.json b/tests/fixtures/contract-key-parity.json new file mode 100644 index 0000000..2038833 --- /dev/null +++ b/tests/fixtures/contract-key-parity.json @@ -0,0 +1,112 @@ +{ + "contract_files_discovered": 5, + "contracts": { + "perry-decide/list/1.0": { + "command": "perry-decide list --json", + "contract": "perry-decide/list/1.0", + "documented": 27, + "documented_not_emitted": [], + "emitted": 24, + "emitted_not_documented": [], + "file": "schema/decide-list-contract.md", + "not_observable": { + "expired_sunsets[].id": "inside `expired_sunsets`, which is empty in this run", + "expired_sunsets[].sunset": "inside `expired_sunsets`, which is empty in this run", + "expired_sunsets[].title": "inside `expired_sunsets`, which is empty in this run" + }, + "unassigned": [], + "unparsed_sketches": [] + }, + "perry-events/list/1.0": { + "command": "perry-task events --json", + "contract": "perry-events/list/1.0", + "documented": 23, + "documented_not_emitted": [], + "emitted": 23, + "emitted_not_documented": [], + "file": "schema/events-list-contract.md", + "not_observable": {}, + "unassigned": [], + "unparsed_sketches": [] + }, + "perry-goals/list/2.0": { + "command": "perry-goals list --json", + "contract": "perry-goals/list/2.0", + "documented": 54, + "documented_not_emitted": [], + "emitted": 59, + "emitted_not_documented": [ + "okr.objectives[].id", + "phase.objectives", + "phase.objectives[].id", + "phase.objectives[].krs", + "phase.objectives[].title" + ], + "file": "schema/goals-list-contract.md", + "not_observable": {}, + "unassigned": [], + "unparsed_sketches": [] + }, + "perry-roles/list/1.0": { + "command": "perry-state --json \u00a7 roles", + "contract": "perry-roles/list/1.0", + "documented": 4, + "documented_not_emitted": [], + "emitted": 4, + "emitted_not_documented": [], + "file": "schema/roles-list-contract.md", + "not_observable": {}, + "unassigned": [ + "A card \u2014 the six frozen fields \u00a7 accepted_by", + "A card \u2014 the six frozen fields \u00a7 default_rung", + "A card \u2014 the six frozen fields \u00a7 must_escalate.fragments", + "A card \u2014 the six frozen fields \u00a7 must_escalate.unextractable", + "A card \u2014 the six frozen fields \u00a7 name", + "A card \u2014 the six frozen fields \u00a7 path" + ], + "unparsed_sketches": [] + }, + "perry-task/list/1.11": { + "command": "perry-task list --json", + "contract": "perry-task/list/1.11", + "documented": 87, + "documented_not_emitted": [], + "emitted": 99, + "emitted_not_documented": [ + "conformance.evidence_not_found[].id", + "conformance.evidence_not_found[].paths", + "conformance.next_action_cites_closed[].cites", + "conformance.next_action_cites_closed[].id", + "conformance.next_action_cites_closed[].status", + "conformance.sections_read[].heading", + "conformance.sections_read[].priority", + "conformance.sections_read[].rows", + "semantics", + "semantics[].fields", + "semantics[].note", + "semantics[].version" + ], + "file": "schema/task-list-contract.md", + "not_observable": {}, + "unassigned": [ + "`asks` \u2014 `## User Input Queue`, the **needs-you** list \u00a7 asked", + "`asks` \u2014 `## User Input Queue`, the **needs-you** list \u00a7 blocks", + "`asks` \u2014 `## User Input Queue`, the **needs-you** list \u00a7 id", + "`asks` \u2014 `## User Input Queue`, the **needs-you** list \u00a7 idle", + "`asks` \u2014 `## User Input Queue`, the **needs-you** list \u00a7 idle_days", + "`asks` \u2014 `## User Input Queue`, the **needs-you** list \u00a7 needed", + "`asks` \u2014 `## User Input Queue`, the **needs-you** list \u00a7 priority", + "`asks` \u2014 `## User Input Queue`, the **needs-you** list \u00a7 status", + "`intake` \u2014 queue mode's inbox \u00a7 age_days", + "`intake` \u2014 queue mode's inbox \u00a7 arrived", + "`intake` \u2014 queue mode's inbox \u00a7 discharged", + "`intake` \u2014 queue mode's inbox \u00a7 n", + "`intake` \u2014 queue mode's inbox \u00a7 outcome", + "`intake` \u2014 queue mode's inbox \u00a7 request" + ], + "unparsed_sketches": [] + } + }, + "glob": "schema/*-contract.md", + "root": "." +} diff --git a/tests/test_contract_key_parity.py b/tests/test_contract_key_parity.py new file mode 100644 index 0000000..1ee50c6 --- /dev/null +++ b/tests/test_contract_key_parity.py @@ -0,0 +1,196 @@ +"""KR-O2.4's number: keys documented but not emitted, or emitted but not documented. + +`tests/contract_key_parity.py` computes it; this holds it to a recorded +baseline so the count is comparable across runs by someone who was not here. + +**What this is not.** `tests/test_contract_invariance.py` records the payload's +SHAPE and forbids a removal or a retype. It says nothing about the *document*: +a key emitted and never documented passes it cleanly, and a key documented and +never emitted was never in its baseline to begin with. This module reads the +markdown and the payload and diffs them against each other, which is the only +way the KR's metric can be a measurement rather than a hand count. + +**Neither direction is asserted to be zero.** The repository's real numbers +today are 0 documented-not-emitted and 17 emitted-not-documented, and pinning +them to the baseline is what makes a change to either one visible. Closing the +gap is whatever rows the measurement produces; this row measures it. + +Proved by mutation, in both directions, before it was committed: + +- removing `startable` from both of its emit sites in `bin/perry-task` puts + `tasks[].startable` in `documented_not_emitted` and names it here; +- adding an undeclared key to the payload puts `tasks[].` in + `emitted_not_documented` and names it here; +- moving one file out of `schema/` changes `contract_files_discovered` from 5 + to 4 and fails `test_the_glob_still_finds_every_contract_on_disk`. + +Run: python3 tests/parallel test_contract_key_parity +""" + +from __future__ import annotations + +import json +import pathlib +import sys +import tempfile +import unittest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +import contract_key_parity as parity # noqa: E402 + + +BASELINE = parity.BASELINE + + +def recorded() -> dict: + return json.loads(BASELINE.read_text()) + + +#: A whole contract page, in the shape the check reads: a `# ` heading naming +#: the invocation, and a ```jsonc``` sketch of the payload. `{extra}` is a key +#: no tool emits. +SIXTH = """# `perry-decide list --json` — `perry-sixth/list/1.0` + +A contract page that did not exist when the check was written. + +## The payload + +```jsonc +{{ + "contract": "perry-sixth/list/1.0", + "{extra}": "never emitted by anything" +}} +``` +""" + + +class TestTheBaselineIsAFile(unittest.TestCase): + """Point 3 of the deliverable: the number lives on disk, not in a memory + or a docstring, or it cannot be compared across runs.""" + + def test_the_baseline_exists_and_parses(self): + self.assertTrue( + BASELINE.exists(), + f"no baseline at {BASELINE} — run " + f"`python3 tests/contract_key_parity.py --record`") + self.assertIn("contracts", recorded()) + + def test_the_baseline_records_a_count_per_contract(self): + for name, entry in recorded()["contracts"].items(): + for field in ("documented_not_emitted", "emitted_not_documented"): + self.assertIsInstance(entry[field], list, f"{name}: {field}") + + +class TestDiscoveryIsAGlob(unittest.TestCase): + """A hand-written list of contracts is the defect this row exists about: + KR-O2.4 says "all three contracts" and there are five.""" + + def test_the_glob_still_finds_every_contract_on_disk(self): + found = parity.discover() + self.assertEqual( + recorded()["contract_files_discovered"], len(found), + "the number of contract files changed — " + + ", ".join(p.name for p in found)) + + def test_the_glob_finds_a_contract_that_did_not_exist_before(self): + """Point 2, proved rather than asserted: a sixth page is picked up with + no edit to the check.""" + with tempfile.TemporaryDirectory() as tmp: + home = pathlib.Path(tmp) + for existing in parity.discover(): + (home / existing.name).write_text(existing.read_text()) + before = len(parity.discover(home)) + (home / "sixth-contract.md").write_text(SIXTH.format(extra="ghost")) + self.assertEqual(before + 1, len(parity.discover(home))) + + +class TestTheTwoWayDiffIsHeldToItsBaseline(unittest.TestCase): + + def setUp(self): + self.recorded = recorded() + self.live = parity.measure() + + def test_the_same_contracts_are_measured(self): + self.assertEqual(set(self.recorded["contracts"]), + set(self.live["contracts"]), + "a contract appeared or vanished between runs") + + def test_no_documented_key_stopped_being_emitted(self): + for name, entry in self.live["contracts"].items(): + if name not in self.recorded["contracts"]: + continue # a new contract: the test above names it + was = self.recorded["contracts"][name]["documented_not_emitted"] + now = entry["documented_not_emitted"] + self.assertEqual( + was, now, + f"{name}: keys the document declares and the tool does not " + f"emit changed\n was: {was}\n now: {now}\n " + f"appeared: {sorted(set(now) - set(was))}") + + def test_no_emitted_key_stopped_being_documented(self): + for name, entry in self.live["contracts"].items(): + if name not in self.recorded["contracts"]: + continue # a new contract: the test above names it + was = self.recorded["contracts"][name]["emitted_not_documented"] + now = entry["emitted_not_documented"] + self.assertEqual( + was, now, + f"{name}: keys the tool emits and the document does not " + f"declare changed\n was: {was}\n now: {now}\n " + f"appeared: {sorted(set(now) - set(was))}") + + def test_every_payload_sketch_still_parses(self): + """A sketch the check cannot read would silently shrink the documented + side, which is the narrowed denominator this row is against.""" + for name, entry in self.live["contracts"].items(): + self.assertEqual([], entry["unparsed_sketches"], name) + + +class TestTheCheckDiscriminatesInBothDirections(unittest.TestCase): + """Anti-vacuity. A check written and never proved to fire is the failure + this project keeps finding, so both directions are exercised against a + page built for the purpose — no repository file is touched.""" + + def compare(self, text: str) -> dict: + with tempfile.TemporaryDirectory() as tmp: + page = pathlib.Path(tmp) / "sixth-contract.md" + page.write_text(text) + return parity.compare(page) + + def test_a_documented_key_nothing_emits_is_named(self): + result = self.compare(SIXTH.format(extra="no_such_key")) + self.assertIn("no_such_key", result["documented_not_emitted"]) + + def test_an_emitted_key_the_page_never_declares_is_named(self): + """The same page declares two keys and `perry-decide list` emits eight + at the top level, so the other six are the other direction.""" + result = self.compare(SIXTH.format(extra="no_such_key")) + for emitted in ("decisions", "active", "total"): + self.assertIn(emitted, result["emitted_not_documented"]) + + def test_a_page_naming_no_command_is_refused_rather_than_scored_zero(self): + with self.assertRaises(ValueError): + parity.invoke("# a contract page with no command\n") + + +class TestWhatCouldNotBeComparedIsNamed(unittest.TestCase): + """Point 4 of the deliverable. A key the check could not place is reported + by name with its reason; it is never dropped, because a silently narrowed + denominator is worse than a smaller one that is stated.""" + + def test_every_unobservable_key_carries_a_reason(self): + for name, entry in recorded()["contracts"].items(): + for key, why in entry["not_observable"].items(): + self.assertTrue(why.strip(), f"{name}: {key} has no reason") + + def test_the_report_prints_the_file_count_and_a_total(self): + text = parity.report(recorded()) + self.assertIn("contract files discovered: ", text) + self.assertIn("KR-O2.4 metric: ", text) + for name in recorded()["contracts"]: + self.assertIn(name, text) + + +if __name__ == "__main__": + unittest.main()