From a4eb41196e4267f3fc52aba932378fc8c8d2c98f Mon Sep 17 00:00:00 2001 From: Ran Jiao Date: Mon, 31 Aug 2026 20:27:09 +0800 Subject: [PATCH 1/4] Two rules that make a V4 round able to end, and the checks behind them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TASK-050 ran eleven V4 rounds. Rounds 8, 9 and 10 each found a real escape — a pruned corpus, a one-line alias, a header row carried through a dict key — and round 11 PASSed on "a measured, listed remainder of 8 out of 76". It ended on the round the criterion became DECIDABLE, not on the round the last hole closed. The reviewer was right every time; the question had no last answer. review.md § 1 · the criteria must be bounded. A criterion is bounded when the author can name the finite set and its size BEFORE the round. The criteria file carries a `## Bound` block, and three unboundable shapes get a rewrite each. This does not soften § 2 rule 1 — the bound is what makes "enumerate the category" finishable rather than a universal negative over a live tree. review.md § 2 · what V4 does not judge. Measured on this board: 79 review documents, 54 `## Finding` headlines, 5 meta, and 22 of the remaining 49 are about the round's own artifact rather than the product — a pruned corpus, an incomplete baseline, a misreported mutation, three citations pointing at a file the branch does not carry. The protocol manufactures an exhibit, and the exhibit has more failure modes than the code. Round N+1 then audits round N's exhibit, which is a loop with no product in it. A GREEN MUTATION STAYS V4 — it is a product finding wearing a test's clothes. Bookkeeping moves to a pre-check. perry-lint --reviews gains the two pre-check findings, and --strict now EXITS NON-ZERO so a red exhibit can actually stop a dispatch. Without that the pre-check would be one more rule stated in prose that nothing implements, which is the defect review.md exists over. citation-not-on-branch a `criteria:`/`proof:` path the branch lacks criteria-unbounded a criteria file with no `## Bound` Both are scoped to OPEN rows: a closed row's exhibit cannot be re-filed, and a backlog nobody can clear is a gate that is red forever, which is a gate people delete. On this repository that took the run from 136 findings to 29. What counts as a path claim is deliberately narrow, and every exclusion is a token these 79 reviews actually produced: `tables.py` is a mention, `/pmo` is a command, `bin/perry-goals:927/:908` is a sentence about two lines, `evidence/3` is a count, and `scratchpad/…` is the reviewer obeying review-constraints.md. `checked:` is not mined at all — it is a sentence by design. MUTATIONS. Ten run, and the first pass left four green. Each was a finding: the `"/" not in tok` clause was dead code the head-is-a-directory rule already covered, so it is deleted rather than tested; two tests never reached the clause they named — `scratchpad/` did not exist in the temp tree and a trailing slash was caught by an earlier rule — and are now built to reach it; and the harness's own red-detector missed a failure it had caused. Second pass: all ten red, md5-verified restores. tests/run: the same three modules are red as on a clean `git archive HEAD` export — test_diagnose, test_heading_title, test_kr_progress_provenance, all reading live state this change does not touch. No new red. Also files the representation-layer delete list, which is analysis and not a row. Its own first draft was written as a table whose first cell was a task id, so perry-explain harvested it as state, gave TASK-050 the title "11", and silently turned test_heading_title green by displacing the real entry. It is a list now, and that paragraph is in the file. Co-Authored-By: Claude Opus 5 --- bin/perry-lint | 162 +++++++++- ...-08-31-representation-layer-delete-list.md | 182 +++++++++++ tests/test_review_verdicts.py | 303 +++++++++++++++++- work/reference/review.md | 110 +++++++ 4 files changed, 752 insertions(+), 5 deletions(-) create mode 100644 perry/evidence/2026-08/2026-08-31-representation-layer-delete-list.md diff --git a/bin/perry-lint b/bin/perry-lint index 89c2b433..a643354a 100755 --- a/bin/perry-lint +++ b/bin/perry-lint @@ -62,7 +62,13 @@ otherwise be no way to accept one. `schema § thresholds.review_fail_rounds_before_escalation`, overridable per project (`- Review rounds before escalation:`) and per run (`PERRY_REVIEW_ROUNDS`); the finding names its - source. Prints the block count even at zero findings. + source. Also runs the two PRE-CHECK findings review.md + section 2 moves out of the round itself: a cited path the + branch does not carry (`citation-not-on-branch`) and a + criteria file with no `## Bound` (`criteria-unbounded`). With + --strict this mode EXITS NON-ZERO, which is what lets a red + exhibit stop a dispatch instead of costing a round. Prints the + block count even at zero findings. --knowledge advisory pass over knowledge CARDS (DESIGN-006 section 5.3): the four provenance fields present, `Source:` resolving to something a reader can re-open, and staleness against @@ -1878,6 +1884,80 @@ def parse_verdicts(text: str) -> list[tuple[dict, int]]: return out +#: A trailing `:1204` or `:1204-1210` on a cited path. Stripped before the +#: existence test, and the token is rejected if a `:` survives — a `proof:` +#: reading `bin/perry-goals:927/:908/` is prose about two lines, not a path. +_CITE_LINE = re.compile(r":\d+(?:-\d+)?$") + +#: Anything that cannot appear in a path Perry would cite. `bin/+viewer/` and +#: `ADR-007](decisions/ADR-007-probe.md` are both real tokens from this board's +#: own reviews, and both are prose. +_CITE_BAD = re.compile(r"[+\[\]()<>*`\s]") + +#: Reviewers verify destructively on a COPY (`review-constraints.md § You are +#: a reader`), so a citation into the scratch tree is the convention working, +#: not a broken reference. Never reported. +_CITE_SCRATCH = ("scratchpad", "scratch", "tmp", "/tmp") + + +def verdict_citations(src: str, state_root: Path, project_root: Path + ) -> list[tuple[str, bool]]: + """Path claims inside one verdict field, each with whether it resolves. + + `work/reference/review.md § 2 · What V4 does not judge`. Rounds on this + board spent a full fresh-context review discovering that a citation named + a file the branch does not carry — "three citations point at a file the + branch does not carry", "a claimed filing, on the branch, that is not + there". A regex knew, and it cost a round each time. + + **What counts as a path claim is bounded on purpose**, because a check that + reports correct prose is one people switch off (§ 1's own table). Read only + from `criteria:` and `proof:`, which the convention REQUIRES to be a path; + `checked:` is a sentence by design and scanning it is guessing. A token is + a claim only when ALL of these hold: + + - its first segment is non-empty. `/pmo` is a command, and the first + version split on `/`, got an empty head, and `root / ""` is a directory + that always exists — so every command name was a broken path. + - no `+`, bracket, paren, backtick or angle in it. + - no `:` survives the line-suffix strip. + - its last segment is not all digits — `tests/3` is a count. + - the first segment is a directory that exists. + - it is not under a scratch tree. + + Every one of those exclusions is a token this board's own 79 reviews + actually produced. The rule is deliberately narrower than "looks like a + path": a missed citation costs nothing (the round still runs), and a false + one costs the check its credibility. + """ + out: list[tuple[str, bool]] = [] + for tok in re.split(r"[\s,;·|]+", src.strip()): + tok = tok.strip("*`()[]<>\"") + for suf in ("'s", "\u2019s"): + if tok.endswith(suf): + tok = tok[:-len(suf)] + tok = tok.rstrip(",.;:)]}\'\"") + if not tok: + continue + bare = _CITE_LINE.sub("", tok) + if not bare or ":" in bare or _CITE_BAD.search(bare): + continue + segs = [s for s in bare.split("/")] + head = segs[0] + if not head or not segs[-1] or segs[-1].isdigit(): + continue + if head in _CITE_SCRATCH or bare.startswith("/tmp"): + continue + if not ((state_root / head).is_dir() or (project_root / head).is_dir()): + continue + out.append((bare, (state_root / bare).exists() or + (project_root / bare).exists())) + return out + + +_BOUND_RE = re.compile(r"^#{2,}\s*Bound\b", re.M) + + #: `reference/glossary.md` — one entry per concept, and the `Implemented:` line #: is the brake. See that file's own header for why this is a constraint rather #: than documentation. @@ -2054,8 +2134,10 @@ def check_reviews(state_root: Path, project_root: Path) -> list["Finding"]: documents written in prose, and promoting those to errors would retroactive -ly condemn every review it ever ran. What it reports is a worklist. - Six findings, and the last one is about the cost of the round rather than - its shape: + Eight findings. The last three are about the cost of the round rather + than its shape — two of them are the pre-check `review.md § 2` moves OUT + of the round, because a full fresh-context review is the most expensive + way this board has found to discover a broken citation: - `verdict-malformed` — a block missing a required key, or a `result` that is neither `PASS` nor `FAIL`. @@ -2073,10 +2155,40 @@ def check_reviews(state_root: Path, project_root: Path) -> list["Finding"]: - `review-rounds-exhausted` — two FAILs and no PASS, with no open ask blocking the row. The third round is where this board stopped converging and started re-deriving; see the comment on the check. + - `citation-not-on-branch` — a `criteria:`, `proof:` or `checked:` path + the branch does not carry. Found four times by a reviewer, at a round + each, when a regex knew. + - `criteria-unbounded` — the criteria file carries no `## Bound`, so the + round has no finite set to check. TASK-050 ran ELEVEN rounds against a + universal negative and passed on the round the criterion became + decidable, not on the round the last hole closed (`review.md § 1`). """ findings: list[Finding] = [] seen: dict[str, list[tuple[str, dict, int]]] = {} + # The two pre-check findings below are scoped to rows that are still OPEN. + # A closed row's criteria cannot be re-bounded and its exhibit cannot be + # re-filed, so reporting them condemns every review this project ran before + # the convention existed — the exact retroactive verdict this mode's + # docstring refuses. It also keeps `--reviews --strict` able to gate a + # dispatch: a backlog nobody can clear is a gate that is red forever, which + # is a gate people delete. + closed_rows: set[str] = set() + _log = project_root / ".perry" / "events.jsonl" + if _log.exists(): + for raw in _log.read_text(errors="replace").split("\n"): + raw = raw.strip() + if not raw: + continue + try: + ev = json.loads(raw) + except (ValueError, TypeError): + continue + if ev.get("event") in ("done", "drop"): + tid = str(ev.get("task") or ev.get("id") or "") + if tid: + closed_rows.add(tid) + edir = state_root / "evidence" for md in sorted(edir.rglob("*.md")) if edir.exists() else []: rel = md.relative_to(state_root).as_posix() @@ -2104,6 +2216,42 @@ def check_reviews(state_root: Path, project_root: Path) -> list["Finding"]: f"file — a FAIL that cannot point at a line is a " f"suspicion, not a verdict", line)) + # ── the two pre-check findings ──────────────────────────────── + # The ones a round must NEVER spend itself on: `review.md § 2 · + # What V4 does not judge`. Both are cheap and both are decidable, + # and this board discovered them four and eleven rounds late. + # + # `criteria:` and `proof:` only. `checked:` is a sentence by + # design — "guarantees 1,2,4,5 on gimegime-pmo (365→380 ids)" is + # the shape the convention asks for — and mining a sentence for + # paths is how this check first reported `tables.py`, `/pmo` and + # `bin/perry-lint:812's` as broken references. + if tid and tid not in closed_rows: + for key in ("criteria", "proof"): + for tok, ok in verdict_citations( + fields.get(key, ""), state_root, project_root): + if ok: + continue + findings.append(Finding( + "warn", rel, "citation-not-on-branch", + f"`{key}:` cites {tok} — the branch does not " + f"carry it. Fix the exhibit before dispatching, " + f"not in a round", line)) + + for tok, ok in verdict_citations( + fields.get("criteria", ""), state_root, project_root): + if not ok or not tok.endswith(".md"): + continue + cpath = (state_root / tok if (state_root / tok).exists() + else project_root / tok) + if not _BOUND_RE.search( + strip_comments(cpath.read_text(errors="replace"))): + findings.append(Finding( + "warn", rel, "criteria-unbounded", + f"{tok} carries no `## Bound` — the round has no " + f"finite set to check and no last element. " + f"work/reference/review.md § 1", line)) + board = state_root / "BOARD.md" if not board.exists(): check_reviews.blocks_seen = sum(len(v) for v in seen.values()) @@ -3995,7 +4143,13 @@ def main(argv: list[str]) -> int: f"and acted on" if blocks else "\n \u00b7 no verdict blocks yet \u2014 see " "work/reference/review.md \u00a7 3") - return 0 + # `--reviews` stays advisory by DEFAULT for the reason its docstring + # gives — a project predating the convention would be condemned + # retroactively. `--strict` is opt-in and is what `review.md § 2` tells + # the dispatcher to run: a red exhibit must be able to STOP a round, + # or the pre-check is one more rule stated in prose that nothing + # implements, which is the defect this whole page exists over. + return 1 if (strict and findings) else 0 if mode_knowledge: project_root = (Path(root_arg).expanduser().resolve() if root_arg diff --git a/perry/evidence/2026-08/2026-08-31-representation-layer-delete-list.md b/perry/evidence/2026-08/2026-08-31-representation-layer-delete-list.md new file mode 100644 index 00000000..5d8f7ec3 --- /dev/null +++ b/perry/evidence/2026-08/2026-08-31-representation-layer-delete-list.md @@ -0,0 +1,182 @@ +# The representation layer — what to delete, in what order, and what it is holding up + +> Analysis, 2026-08-31. Not a task row. Written to be cited by the rows it +> proposes, and to be argued with before any of them are filed. + +## Why this list exists + +Every task on this board that has ground in V4 is in one architectural layer. +The 14 rows kicked back two or more times, with their subject: + +> **Written as a list, not a table, on purpose.** A markdown table whose first +> cell is a row id is harvested by `perry-explain` as Perry state — the first +> draft of this file did exactly that, gave `TASK-050` the title `11`, and +> silently turned `tests/test_heading_title.py` green by displacing the real +> entry. An analysis document in a folder Perry claims is the `NS-01` hazard, +> and this paragraph is what it cost to learn twice. + +- **11 rounds** — `TASK-050`, header-cell normalization: md table parsing +- **6 rounds** — `TASK-095`, remove the parser for three stores +- **5 rounds** — `TASK-203`, an ordinary write does not update its store: dual write +- **5 rounds** — `TASK-234`, `.perry/conformance.md` is a hand-rolled table parser +- **4 rounds** — `TASK-249`, `tests/run` writes Perry state: intake dual write +- **2–4 rounds each** — `TASK-089`, `093`, `067`, `091`, `241`, `233`, `044`, + `042`, `019`, `020`, `027`, `028`: dual write, drift, md parsing, migration + +**Not one is about OKR or task management as a domain.** All of them are about +markdown-as-canonical, its parsers, its drift detection, or its conformance +ledger. + +The three gates that exist to service that architecture, measured on this +repository's own history: + +**drift** — `perry-lint` reports `0 row(s) drifted` on all six stores and +always has. Every non-zero reading anywhere in this repo comes from a V4 +reviewer deliberately corrupting a store to demonstrate a bug. It has caught +zero real incidents, and it generated TASK-031, 067, 093, 203 and 243. It is +still evadable: TASK-243, filed 2026-08-30 — *"a count-preserving substitution +destroys canonical records silently, and the drift report goes DOWN as it +happens."* It guards a behaviour `AGENTS.md` already forbids and no agent +performs. + +**the ADR-004 conformance gate** — `.perry/conformance.jsonl` holds 23 records. +All 23 are `route: declare`. All 23 are Perry's own files. **Zero migrations, +zero disagreements.** Its entire value proposition is that a stored declaration +and a live shape check *can disagree, and that disagreement is a finding*. That +requires a foreign project that drifts, and Perry has never been run on one. + +**lint** — today: 0 errors, 4 warnings, and all four are `NS-01` on files you +deliberately put in `phase/`, `evidence/`, `handoff/` and `knowledge/`. Live +signal-to-noise 0:4. Its schema half is the part that works and is not on this +list. + +## The list is ordered, because two targets are load-bearing today + +`perry_md_store.py` is not a drift helper. `perry-goals` and `perry-config` +call `md_store.derive`, `md_store.render` and `md_store.store_text` on the real +write path. `perry-conform` is imported by `perry-task` as a live gate. Neither +can be deleted before the read/write side stops going through markdown, which +is what phase 003 Objective 2 and TASK-236/237 are for. + +So: three tiers, each with a precondition that can be checked. + +--- + +## Tier A — deletable now + +**Precondition: none.** Nothing computes a different answer without these. + +| target | lines | note | +|---|---|---| +| `bin/perry-conform` | 974 | the ADR-004 declaration gate | +| `perry_conform()` + the write-gate in `bin/perry-task` | ~60 | `bin/perry-task:6897–6920` and its call sites | +| `.perry/conformance.jsonl`, `.perry/conformance.md` | 23 records | | +| `tests/test_conformance.py` | 2,882 | | +| `tests/mutate_task_234.py` | 568 | | +| open rows TASK-223, 246, 248 | — | all three are defects *in* the gate | + +**≈ 4,500 lines, and three open rows close as `dropped` rather than `done`.** + +> **Do not confuse two things called conformance.** This deletes the ADR-004 +> *file declaration* gate. It does **not** touch the `conformance.*` fields in +> `perry-task list --json` (`evidence_not_found`, `depends_on_unknown`, +> `blocked_by_closed_rows`, …) — those are read-time integrity reporting, they +> are a published contract in `schema/task-list-contract.md`, and they are +> useful. Deleting them would be a real regression. + +--- + +## Tier B — after phase 003 Objective 2 lands + +**Precondition:** no code path reads a rendered markdown file as authority. +Checkable: `P003-O2-KR1`'s four `parse_tracks` call sites move to +`.perry/config.jsonl`, and `perry-goals` stops deriving from `OKR.md`. + +| target | lines | +|---|---| +| `check_store_drift` + `_empty_store_drift_stats` | 244 | +| `check_risk_store_drift` + helper | 149 | +| `check_intake_store_drift` + helper | 123 | +| `check_ask_store_drift` + helper | 149 | +| `check_md_store_drift` + two helpers | 164 | +| `_order_drift` | 49 | +| the six census report lines in the default pass | ~40 | +| `tests/test_store_drift.py` | 966 | +| the drift halves of `test_risks_store` / `test_asks_store` / `test_okr_store_is_the_source` / `test_register_substitution` | ~1,200 | + +**≈ 3,100 lines.** Drift only has a job while a rendered file can be authority. +When nothing reads one, a hand edit is a no-op, not a hazard — and the check +becomes a check on a file nobody consults. + +--- + +## Tier C — after TASK-236 and TASK-237 + +**Precondition:** `OKR.md` and `BOARD.md` stop existing as parsed files and +become what a command prints. Both rows are already on the board. + +| target | lines | +|---|---| +| `bin/perry_md_store.py` | 1,203 | +| `bin/perry-migrate` | 2,393 | +| `tests/test_md_store.py` | 1,277 | +| `tests/test_migrate.py` | 2,900 | +| `tests/test_one_header_rule.py` | 327 | +| `tests/test_header_index_is_the_only_fold.py` | 775 | +| `tests/test_store_is_canonical.py` | 356 | +| `tests/test_last_updated_header.py` | 160 | +| `tests/test_stranded_rows.py` | 757 | +| remaining rows TASK-095, 067, 199, 246, 247, 252 | — | + +**≈ 10,100 lines.** + +> Migration deserves its own argument. `perry-migrate` exists to move somebody +> else's project onto Perry's shape. **It has never done that** — the +> conformance log's 23 records carry zero `route: migrate`. TASK-097 ("migrate +> the two real projects, at V5") has been `not_started` throughout. If the +> answer to "have we ever migrated a foreign project" stays no, migration is a +> feature written entirely on speculation, and the cheap replacement for a +> personal tool is an importer you re-run, not a lossless recoverable +> dry-runnable migrator. + +--- + +## What it adds up to + +| | product code | tests | +|---|---|---| +| Tier A | ~1,030 | ~3,450 | +| Tier B | ~920 | ~2,170 | +| Tier C | ~3,600 | ~6,550 | +| **total** | **~5,550** | **~12,170** | + +Against today's `bin/` at 33,413 lines and `tests/` at 69,371, that is **17% of +the product code and 18% of the tests** — and it is the 17% that produced 11 of +the 14 high-rework rows. + +The second-order effect is larger than the line count. **21 of the 69 open rows +on this board are representation-layer.** Tiers A–C close or drop 9 of them +outright, and the rest stop generating successors. + +## What this list does not propose + +- **Not a rewrite.** The asset is the prose — the lane split, what V1–V6 mean, + the hand-off contract, "an agent cannot self-award its own rung". None of it + is in the code being deleted. +- **Not touching `perry-lint`'s schema pass.** That half works. +- **Not touching `perry-task list --json`'s `conformance.*` payload.** See the + Tier A note. +- **Not deciding TASK-097.** Whether Perry is ever pointed at a foreign project + is a product question, and it is the one that decides Tier C's migration half. + It should be answered by a person, not derived from this list. + +## The one thing to check before filing any of this + +Tier A rests on a claim that is falsifiable in one command: + +``` +grep -c '"route": *"migrate"' .perry/conformance.jsonl # expected: 0 +``` + +If that is ever non-zero — on this project or any other — Tier A is wrong and +the gate has done the job it was built for. It has not yet. diff --git a/tests/test_review_verdicts.py b/tests/test_review_verdicts.py index b9d12bac..8663b28d 100644 --- a/tests/test_review_verdicts.py +++ b/tests/test_review_verdicts.py @@ -21,6 +21,7 @@ import json import os import pathlib +import re import shutil import subprocess import sys @@ -96,8 +97,31 @@ def board(self, rows): def row(self, tid, status, rung="V4", ev=""): return (f"| {tid} | a thing | Claude | {status} | — | {ev} | {rung} |") - def evidence(self, name, text): + def evidence(self, name, text, make_criteria=True): + """Write a review document, and by default make its exhibit honest. + + `verdict()` cites `evidence/2026-08/-spec.md`, and until this + fixture created it the citation named a file the temp tree did not + carry — which `citation-not-on-branch` reports, correctly, on every + test in this file. These tests are about the verdict's SHAPE; the + exhibit pre-check has its own class below and passes + `make_criteria=False` to say so deliberately. + + The generated spec carries a `## Bound` for the same reason + (`review.md § 1`): a fixture that trips a check it is not testing + makes every unrelated assertion in the file depend on that check. + """ (self.dir / "evidence" / "2026-08" / name).write_text(text) + if not make_criteria: + return + for m in re.finditer(r"^criteria:\s*(\S+)\s*$", text, re.M): + path = self.dir / m.group(1) + if path.exists(): + continue + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "# criteria\n\n## Bound\nEnumeration: ls bin/\nSize: 1\n" + "Remainder: none\n") def run_lint(self): proc = subprocess.run( @@ -655,5 +679,282 @@ def test_the_limit_reaches_the_finding_and_is_named_in_it(self): hit[0]["message"]) +class ExhibitCase(ReviewLintCase): + """Shared setup for the two pre-check findings. + + `review.md § 2 · What V4 does not judge`. Both of these were found by a + fresh-context reviewer, at a full round each, when a regex knew: "three + citations point at a file the branch does not carry", "a claimed filing, + on the branch, that is not there". The round is the most expensive place + on this board to learn either one. + """ + + def open_row(self, tid="TASK-001"): + self.board([self.row(tid, "review")]) + + def closed_row(self, tid="TASK-001"): + self.board([self.row(tid, "done")]) + (self.dir / ".perry" / "events.jsonl").write_text( + json.dumps({"event": "done", "task": tid, "rung": "V4"}) + "\n") + + def block(self, task="TASK-001", criteria="evidence/2026-08/spec.md", + proof="", result="FAIL"): + return (f"=== VERDICT ===\ntask: {task}\nrung: V4\n" + f"result: {result}\ncriteria: {criteria}\n" + f"checked: a thing\nnot-checked: another\n" + f"proof: {proof or 'evidence/2026-08/spec.md:1 the line'}\n" + f"=== END VERDICT ===\n") + + def spec(self, name="spec.md", bound=True): + body = "# criteria\n" + if bound: + body += "\n## Bound\nEnumeration: ls bin/\nSize: 1\nRemainder: none\n" + (self.dir / "evidence" / "2026-08" / name).write_text(body) + + +class TestTheExhibitIsCheckedBeforeTheRound(ExhibitCase): + """`citation-not-on-branch` — a path the branch does not carry. + + Reported on OPEN rows only. A closed row's exhibit cannot be re-filed, so + reporting it condemns retroactively — the thing this whole mode's docstring + refuses to do. + """ + + def test_a_criteria_path_the_branch_does_not_carry_is_reported(self): + self.open_row() + self.evidence("r.md", self.block(), make_criteria=False) + self.assertIn("citation-not-on-branch", self.rules()) + + def test_a_criteria_path_that_exists_is_not(self): + self.open_row() + self.spec() + self.evidence("r.md", self.block(), make_criteria=False) + self.assertNotIn("citation-not-on-branch", self.rules()) + + def test_a_proof_path_the_branch_does_not_carry_is_reported(self): + self.open_row() + self.spec() + self.evidence("r.md", self.block(proof="evidence/2026-08/gone.md:4 x"), + make_criteria=False) + hit = [f for f in self.run_lint()["findings"] + if f["rule"] == "citation-not-on-branch"] + self.assertEqual(len(hit), 1) + self.assertIn("`proof:`", hit[0]["message"]) + + def test_a_closed_row_is_not_condemned_retroactively(self): + self.closed_row() + self.evidence("r.md", self.block(result="PASS"), make_criteria=False) + self.assertNotIn("citation-not-on-branch", self.rules()) + + # ── the exclusions, each one a token this board's own reviews produced ── + + def test_a_bare_filename_is_a_mention_not_a_citation(self): + """`checked: tables.py` names a file, not a path, and the repository + carries `viewer/tables.py`. Reporting it taught the check to report + correct prose, which is how a guard gets switched off (§ 1).""" + self.open_row() + self.spec() + self.evidence("r.md", self.block(proof="tables.py:12 the fold"), + make_criteria=False) + self.assertNotIn("citation-not-on-branch", self.rules()) + + def test_a_command_name_is_not_a_path(self): + """`/pmo` and `/architecture` are commands. The first version split on + `/`, got an empty head, and `self.dir / ""` is a directory that always + exists — so every command name in a proof line was a broken path.""" + self.open_row() + self.spec() + self.evidence("r.md", self.block(proof="/pmo /architecture are routed"), + make_criteria=False) + self.assertNotIn("citation-not-on-branch", self.rules()) + + def test_a_possessive_is_stripped(self): + self.open_row() + self.spec() + self.evidence( + "r.md", self.block(proof="evidence/2026-08/spec.md's first line"), + make_criteria=False) + self.assertNotIn("citation-not-on-branch", self.rules()) + + def test_a_trailing_paren_is_stripped(self): + self.open_row() + self.spec() + self.evidence("r.md", + self.block(proof="evidence/2026-08/spec.md:1) the line"), + make_criteria=False) + self.assertNotIn("citation-not-on-branch", self.rules()) + + def test_a_line_range_resolves(self): + self.open_row() + self.spec() + self.evidence("r.md", + self.block(proof="evidence/2026-08/spec.md:1-9 the line"), + make_criteria=False) + self.assertNotIn("citation-not-on-branch", self.rules()) + + def test_two_line_refs_on_one_path_is_prose(self): + """`bin/perry-goals:927/:908/` is a sentence about two lines. A `:` + surviving the suffix strip means the token is not a path.""" + self.open_row() + self.spec() + # No trailing `/`: a token ending in one is already stopped by the + # empty-last-segment rule, so the first version of this test never + # reached the clause it names. Mutation M8. + self.evidence("r.md", + self.block(proof="evidence/2026-08:927/:908 both"), + make_criteria=False) + self.assertNotIn("citation-not-on-branch", self.rules()) + + def test_a_scratch_copy_is_the_convention_working(self): + """`review-constraints.md` REQUIRES destructive checks to run on a + copy. Citing the copy is the reviewer obeying the rule.""" + self.open_row() + self.spec() + # The exclusion is only REACHED when `scratchpad/` is a real directory + # — otherwise the head-is-a-directory rule stops the token first and + # this test passes without testing anything. Mutation M7. + (self.dir / "scratchpad" / "probe").mkdir(parents=True) + self.evidence("r.md", + self.block(proof="scratchpad/probe/rj.py:4 reproduced"), + make_criteria=False) + self.assertNotIn("citation-not-on-branch", self.rules()) + + def test_a_trailing_count_is_not_a_path(self): + self.open_row() + self.spec() + self.evidence("r.md", self.block(proof="evidence/3 of them escape"), + make_criteria=False) + self.assertNotIn("citation-not-on-branch", self.rules()) + + def test_a_colon_inside_a_path_is_prose(self): + """A `:` that SURVIVES the line-suffix strip means the token is a + sentence about a line, not a path — `bin/perry-goals:927/:908` was + written on this board. The token must keep a real last segment or an + earlier rule catches it first and this test proves nothing (M8).""" + self.open_row() + self.spec() + self.evidence("r.md", + self.block(proof="evidence/2026-08:927/notes.md and"), + make_criteria=False) + self.assertNotIn("citation-not-on-branch", self.rules()) + + def test_a_directory_fragment_is_not_a_missing_file(self): + """A token ending in `/` names a folder in prose. Without the + empty-last-segment rule it is looked up whole, misses, and is reported + as a broken citation (M5).""" + self.open_row() + self.spec() + self.evidence("r.md", self.block(proof="evidence/gone/ was swept"), + make_criteria=False) + self.assertNotIn("citation-not-on-branch", self.rules()) + + def test_a_bracketed_link_fragment_is_prose(self): + self.open_row() + self.spec() + self.evidence( + "r.md", + self.block(proof="ADR-007](decisions/ADR-007-probe.md is cited"), + make_criteria=False) + self.assertNotIn("citation-not-on-branch", self.rules()) + + def test_checked_is_prose_and_is_not_mined(self): + """`checked:` is a sentence by design — the convention's own example is + "guarantees 1,2,4,5 on gimegime-pmo (365→380 ids)". Mining it for paths + is guessing, and guessing is what makes a check unusable.""" + self.open_row() + self.spec() + text = self.block().replace( + "checked: a thing", "checked: evidence/2026-08/never-existed.md") + self.evidence("r.md", text, make_criteria=False) + self.assertNotIn("citation-not-on-branch", self.rules()) + + +class TestTheCriteriaMustBeBounded(ExhibitCase): + """`criteria-unbounded` — no `## Bound`, so the round has no last element. + + TASK-050 ran **eleven** rounds against "no reader resolves a header cell by + its own rule", a universal negative over a live tree. Rounds 8, 9 and 10 + each found a real escape — a pruned corpus, a one-line alias, a dict key — + and round 11 PASSed on `a measured remainder of 8 out of 76`. It ended on + the round the criterion became decidable, not on the round the last hole + closed. `review.md § 1`. + """ + + def test_a_criteria_file_with_no_bound_is_reported(self): + self.open_row() + self.spec(bound=False) + self.evidence("r.md", self.block(), make_criteria=False) + self.assertIn("criteria-unbounded", self.rules()) + + def test_a_criteria_file_with_a_bound_is_not(self): + self.open_row() + self.spec(bound=True) + self.evidence("r.md", self.block(), make_criteria=False) + self.assertNotIn("criteria-unbounded", self.rules()) + + def test_a_nested_bound_still_counts(self): + """The requirement is that the bound is WRITTEN DOWN, not where. A spec + that puts it under a section heading has satisfied § 1.""" + self.open_row() + (self.dir / "evidence" / "2026-08" / "spec.md").write_text( + "# criteria\n\n## What must be true\n\n### Bound\nSize: 4\n") + self.evidence("r.md", self.block(), make_criteria=False) + self.assertNotIn("criteria-unbounded", self.rules()) + + def test_a_closed_row_is_not_condemned_retroactively(self): + self.closed_row() + self.spec(bound=False) + self.evidence("r.md", self.block(result="PASS"), make_criteria=False) + self.assertNotIn("criteria-unbounded", self.rules()) + + def test_a_missing_criteria_file_is_one_finding_not_two(self): + """An absent file cannot be read for a bound. Reporting both would + make the fix look like two problems when it is one.""" + self.open_row() + self.evidence("r.md", self.block(), make_criteria=False) + rules = self.rules() + self.assertIn("citation-not-on-branch", rules) + self.assertNotIn("criteria-unbounded", rules) + + +class TestStrictCanStopADispatch(ExhibitCase): + """`--reviews --strict` exits non-zero, or the pre-check cannot gate. + + `review.md § 2` tells the dispatcher to run this before spawning the + agent. A mode that always returns 0 makes that one more rule stated in + prose that nothing implements — which is this repository's own most-found + defect and the reason `review.md` exists at all. + """ + + def run_strict(self): + return subprocess.run( + [sys.executable, str(LINT), "--reviews", "--root", str(self.dir), + "--state-root", ".", "--strict", "--quiet"], + capture_output=True, text=True, cwd=ROOT).returncode + + def test_strict_is_red_when_the_exhibit_is(self): + self.open_row() + self.evidence("r.md", self.block(), make_criteria=False) + self.assertEqual(self.run_strict(), 1) + + def test_strict_is_green_when_it_is_clean(self): + self.open_row() + self.spec() + self.evidence("r.md", self.block(result="PASS"), make_criteria=False) + self.assertEqual(self.run_strict(), 0) + + def test_without_strict_it_stays_advisory(self): + """The DEFAULT stays 0 on findings. A project that predates the + convention has prose reviews, and promoting those to a failing exit + would condemn every round it ever ran.""" + self.open_row() + self.evidence("r.md", self.block(), make_criteria=False) + proc = subprocess.run( + [sys.executable, str(LINT), "--reviews", "--root", str(self.dir), + "--state-root", ".", "--quiet"], + capture_output=True, text=True, cwd=ROOT) + self.assertEqual(proc.returncode, 0) + + if __name__ == "__main__": unittest.main() diff --git a/work/reference/review.md b/work/reference/review.md index 99a55035..27af9c52 100644 --- a/work/reference/review.md +++ b/work/reference/review.md @@ -52,6 +52,69 @@ to infer the bar; an inferred bar is the failure mode above. The criteria are written **by the author, before the round**. Criteria written after a FAIL are a negotiation with the result. +### The criteria must be bounded + +A criterion is bounded when the author can name, **before the round**, the +finite set the round will check and its size. "Every reader in `bin/` and +`viewer/`" is not a set until someone says which readers those are and counts +them. + +An unbounded criterion does not fail a round. It fails to **end** one. +TASK-050 asked for a category: + +> "No reader resolves a header cell by its own rule. The check is a +> **category** — an enumeration over the tree — not a list of file names." + +Proving that no reader *anywhere* does X is a search with no last element, and +the rounds are that search: + +| round | what escaped the guard | +|---|---| +| 8 | the corpus was pruned — "30 of 30" was measured on a subset of itself | +| 9 | a one-line alias | +| 10 | a header row carried through a dict key | +| 11 | **PASS** | + +Round 11 did not pass because round 10 named the last hole. It passed because +round 10 **changed the criterion** — from *the set is empty* to *the remainder +is measured and listed* — and the PASS says so in its own words: + +> "A measured, listed remainder of 8 out of 76 DOES discharge the amendment." + +**Eleven rounds ended on the round the criterion became decidable.** Ten of +them were spent proving a universal negative over a live tree, by a reviewer +who was right every single time. That is the shape to recognise: the rounds +were not failing to find the answer, the question had no last answer. + +So the criteria file carries this block, and `perry-lint --reviews` reports its +absence as `criteria-unbounded`: + +``` +## Bound +Enumeration: grep -rn 'header_index(' bin/ viewer/ ← the command that produces the set +Size: 58 call sites on 68e63cf +Remainder: readers reached only through `perry_store.load()`; out of scope + because they never see a raw header row +``` + +Three criterion shapes, and what to write instead: + +| written as | why it cannot end | write instead | +|---|---|---| +| "no X anywhere in the tree" | universal negative over a growing set | "these N sites, listed; the remainder is M, listed" | +| "every X does Y" | "every" is not a set | the command that enumerates X, and the number it returns **today** | +| "the guard cannot be evaded" | evasions are not a finite set | "these K evasion shapes, enumerated; a K+1th is a new row, not this round" | + +This does not soften § 2 rule 1. Rule 1 says enumerate the category rather than +chase the next instance, and it is right — the failure it names is real and +cost TASK-044 three rounds. **The bound is what makes rule 1 finishable.** +Without it, "enumerate the category" and "prove a universal negative" are the +same instruction, and TASK-050 is what that costs. + +A round may only widen the bound by **filing a new row**, never by re-opening +this one. A remainder that turns out to matter is a defect with its own ID, +its own criteria, and its own bound. + ## 2 · The prompt Reference the standing constraints, do not retype them. Retyping is how one @@ -105,6 +168,53 @@ not converge. ground it skipped, which is the whole shape of a review that will not converge. +### What V4 does not judge + +V4 answers one question: **does this code do the wrong thing on an input the +user can produce?** Everything else the round can see is somebody else's job, +and giving it to V4 is the second reason rounds do not converge. + +Measured on this board — 79 review documents, 54 `## Finding` headlines, 5 of +them meta — **22 of the remaining 49 are about the round's own artifact rather +than the product**: + +> "the harness is a regression corpus, not a harness" · "the corpus was pruned" +> · "the reported baseline was incomplete" · "the commit record misreports a +> mutation" · "three citations point at a file the branch does not carry" · "a +> claimed filing, on the branch, that is not there" · "the code comment's +> factual claim is false" · "the KR reframing must become an edit" + +Not one of those is a defect a user could hit. They are real — every one was +correctly found — and they exist because the protocol **manufactures an +artifact**, and the artifact has more failure modes than the code does. Round +N+1 then audits round N's artifact, which is a loop with no product in it. + +So the line, and it is not "stop caring about test quality": + +| finding | rung | why | +|---|---|---| +| a mutation comes back **green** | **V4** | the guard does not work, or the test does not test it. § 2 rule 2. This is a product finding wearing a test's clothes. | +| a guard reports **correct** code | **V4** | a false positive is a defect users switch the guard off over | +| the cited path is not on the branch | **pre-check** | `perry-lint --reviews` → `citation-not-on-branch` | +| the criteria carry no bound | **pre-check** | `criteria-unbounded` | +| the baseline / corpus / mutation table is incomplete | **the author, before dispatch** | it is the author's exhibit; an incomplete exhibit is not sent | +| a comment, a KR or a commit message misstates something | **file a row** | a documentation defect with its own ID, never a FAIL on this one | + +**The pre-check runs before the round is dispatched, not inside it.** + +``` +"$PERRY_HOME/bin/perry-lint" --reviews --strict # red → fix the exhibit, do not dispatch +``` + +A round dispatched over a red pre-check pays a full fresh-context review to +learn something a regex knew. That is the most expensive way this board has +found to discover a broken citation, and it found it four times. + +**A FAIL must name a behaviour.** `proof:` points at the line that is wrong and +`checked:` names the input that reaches it. A FAIL whose proof is "the evidence +does not establish this" is not a FAIL — it is the pre-check, arriving late and +costing a round. + ## 3 · The verdict block — one per row, fixed shape Required at the end of the review document. Not a heading, not bold prose, not From 6ce1f5b4baace3df66a50680d9e378433768c7f7 Mon Sep 17 00:00:00 2001 From: Ran Jiao Date: Mon, 31 Aug 2026 21:02:07 +0800 Subject: [PATCH 2/4] TASK-261: the ADR-004 conformance gate comes out, and migration is the fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate read a stored DECLARATION out of `.perry/conformance.jsonl` and refused a write when the file's live shape no longer matched it. Keeping the declaration and the check apart was the whole design — "a stored decision plus a live check can disagree, and that disagreement is a finding". It never disagreed. The ledger held 23 records. All 23 were `route: declare`. All 23 were files in this repository. Zero carried `route: migrate`. The disagreement the design exists to surface needs a FOREIGN project that drifts, and Perry has never been pointed at one — TASK-097 has been `not_started` since the day it was filed. Three of the open rows on this board (TASK-223, 246, 248) were defects in the gate itself: work about itself. 40 files, -5,245 lines. THREE GATE CALL SITES, not the two the delete list named: `perry-task`, `perry-goals` and `perry_md_store § render --write`. The third was found by grep after the list was written, which is the delete list being wrong in the cheap direction. `bin/perry-conform` is GUTTED AND RENAMED, not deleted, and the list was wrong about that too. 598 of its 974 lines were the ledger and the gate; the rest — `state_files`, `load_schema`, `spec_for`, `shape_version`, `_q`, `_root_flag` — are generic, and `bin/perry-migrate` imports every one of them and nothing about conformance. So it is `bin/perry_schema.py` now, 161 lines, and says what it is. `perry-decide` removed its own gate first and named the hole rather than faking it; this is the same move with the measurement attached. `tests/gate.py` goes with it. `GATE_OFF` was a `.perry/config.md` line 38 fixtures appended so their writes would not be refused by a gate they were not testing. With no gate it is inert, and leaving it means every future fixture author copies a line that does nothing. WHAT SURVIVED ON PURPOSE. `perry-task list --json`'s `conformance.*` payload — `evidence_not_found`, `depends_on_unknown`, `blocked_by_closed_rows` — is read-time integrity reporting, a published contract in `schema/task-list-contract.md`, and a completely different thing wearing the same word. It is byte-identical. `perry-lint`'s schema pass is untouched; only its conformance census line is gone. ONE BUG I INTRODUCED AND CAUGHT. Removing the gate block from `perry-goals` took `with project_lock(...)` and `result = COMMANDS[...]` with it — the write path itself — and every write exited 1 on `UnboundLocalError`. Restored from HEAD and re-verified; `test_goals_writer` is green. THE FORK, and it is why this row is blocked rather than done. `perry-migrate` calls `C.declare` at 14 sites: its OUTPUT is a conformance record carrying `route: migrate`. It cannot be repaired without restoring the ledger, because the ledger is what it writes. Migration and conformance were never two subsystems, and the delete list putting them in tiers A and C was wrong. That is a product question — is Perry ever pointed at a foreign project — so it is filed as USER-910 with both readings in `evidence/2026-08/2026-08-31-TASK-261-migration-fork.md`, not decided here. tests/run: `test_migrate` is red pending USER-910. The other three red modules — test_diagnose, test_heading_title, test_kr_progress_provenance — are red the same way on a clean `git archive HEAD` export and are untouched by this change. Co-Authored-By: Claude Opus 5 --- .perry/conformance.jsonl | 23 - .perry/events.jsonl | 6 + bin/perry-conform | 974 ------ bin/perry-goals | 66 +- bin/perry-lint | 18 - bin/perry-migrate | 18 +- bin/perry-task | 67 +- bin/perry_md_store.py | 64 +- bin/perry_schema.py | 161 + perry/BOARD.md | 4 +- perry/asks.jsonl | 1 + .../2026-08-31-TASK-261-migration-fork.md | 58 + perry/journal/2026-08/2026-08-31.md | 34 + perry/tasks.jsonl | 2 + tests/fixtures/live-state-expectations.json | 84 +- tests/gate.py | 96 - tests/mutate_task_234.py | 567 ---- tests/test_cadence.py | 3 +- tests/test_config_store_readers.py | 75 - tests/test_conformance.py | 2881 ----------------- tests/test_decide_status_enum.py | 3 +- tests/test_decide_writer.py | 3 +- tests/test_diagnose.py | 7 +- tests/test_goals_writer.py | 26 - tests/test_knowledge_promotion.py | 3 +- tests/test_md_store.py | 21 +- tests/test_okr_store_is_the_source.py | 3 +- tests/test_one_heading_predicate.py | 3 +- tests/test_one_line_break_rule.py | 10 +- tests/test_prioritize.py | 3 +- tests/test_procedures_call_the_tool.py | 81 +- tests/test_queue_sla.py | 5 +- tests/test_register_store_invariant.py | 3 +- tests/test_retired_tolerance.py | 32 +- tests/test_role_on_rows.py | 3 +- tests/test_row_integrity.py | 3 +- tests/test_store_is_the_write_target.py | 3 +- tests/test_task_store_read_cutover.py | 3 +- tests/test_task_summary.py | 5 +- tests/test_task_writer.py | 7 +- tests/test_track_move.py | 3 +- tests/test_track_register_source.py | 13 +- tests/test_unlinked_declaration.py | 3 +- tests/test_v5_signoff.py | 3 +- tests/test_wip_and_stages.py | 3 +- tests/test_work_modes.py | 186 +- 46 files changed, 394 insertions(+), 5246 deletions(-) delete mode 100644 .perry/conformance.jsonl delete mode 100755 bin/perry-conform create mode 100644 bin/perry_schema.py create mode 100644 perry/evidence/2026-08/2026-08-31-TASK-261-migration-fork.md create mode 100644 perry/journal/2026-08/2026-08-31.md delete mode 100644 tests/gate.py delete mode 100644 tests/mutate_task_234.py delete mode 100644 tests/test_conformance.py diff --git a/.perry/conformance.jsonl b/.perry/conformance.jsonl deleted file mode 100644 index a9afca47..00000000 --- a/.perry/conformance.jsonl +++ /dev/null @@ -1,23 +0,0 @@ -{"kind": "declaration", "path": ".perry/config.md", "shape_version": 2, "declared": "2026-08-20", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": ".perry/hook.md", "shape_version": 2, "declared": "2026-08-20", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "BOARD.md", "shape_version": 2, "declared": "2026-08-20", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "OKR.md", "shape_version": 2, "declared": "2026-08-20", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "design/DESIGN-001-resumable-pipelines.md", "shape_version": 2, "declared": "2026-08-20", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "design/DESIGN-002-namespace-collision.md", "shape_version": 2, "declared": "2026-08-20", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "design/DESIGN-003-work-modes.md", "shape_version": 2, "declared": "2026-08-20", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "design/DESIGN-004-deterministic-writes.md", "shape_version": 2, "declared": "2026-08-20", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "design/DESIGN-005-state-and-contracts.md", "shape_version": 2, "declared": "2026-08-20", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "design/DESIGN-006-roles-and-knowledge.md", "shape_version": 2, "declared": "2026-08-20", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "design/DESIGN-007-the-entity-model.md", "shape_version": 2, "declared": "2026-08-20", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "design/DESIGN-008-track-axes.md", "shape_version": 2, "declared": "2026-08-28", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "design/DESIGN-009-the-objective-is-a-record.md", "shape_version": 2, "declared": "2026-08-28", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "design/DESIGN-010-autopilot-writes-its-own-specs.md", "shape_version": 2, "declared": "2026-08-28", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "design/DESIGN-011-the-okr-is-elicited-not-collected.md", "shape_version": 2, "declared": "2026-08-28", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "knowledge/goals/linkage-graph-before-first-add.md", "shape_version": 2, "declared": "2026-08-28", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "knowledge/toolchain/pycache-staleness.md", "shape_version": 2, "declared": "2026-08-20", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "phase/001-linkage.md", "shape_version": 2, "declared": "2026-08-20", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "phase/001-work-modes-live.md", "shape_version": 2, "declared": "2026-08-20", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "phase/002-fields-are-typed.md", "shape_version": 2, "declared": "2026-08-20", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "phase/002-linkage.md", "shape_version": 2, "declared": "2026-08-28", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "phase/003-linkage.md", "shape_version": 2, "declared": "2026-08-28", "route": "declare", "writer": "", "recorded_at": "", "run": ""} -{"kind": "declaration", "path": "phase/003-storage-code.md", "shape_version": 2, "declared": "2026-08-28", "route": "declare", "writer": "", "recorded_at": "", "run": ""} diff --git a/.perry/events.jsonl b/.perry/events.jsonl index eb6b72de..36fc68de 100644 --- a/.perry/events.jsonl +++ b/.perry/events.jsonl @@ -1396,3 +1396,9 @@ {"ts": "2026-08-30T16:44:25+08:00", "event": "done", "id": "TASK-234", "title": ".perry/conformance.md is a pure ledger with a hand-rolled table parser, and its phantom row has nowhere to record provenance", "track": "main", "owner": "Coding Agent", "role": "", "actor": "Ran Jiao", "from": "in_progress", "to": "done", "evidence": "perry/evidence/2026-08/TASK-234-round5-v4-review.md", "rung": "V3"} {"ts": "2026-08-30T16:44:44+08:00", "event": "summary", "id": "TASK-255", "title": "Perry never shell-quotes a path into a command it hands a reader — shlex appears nowhere in bin/ or viewer/", "track": "main", "actor": "Ran Jiao", "field": "summary", "from": "SIZED 2026-08-30 by the TASK-234 round-4 agent, which measured the class OUTSIDE the two tools it was fixing: 63 handed-back commands across the other twelve bin/perry-* executables, 25 of them WITHOUT THE ROOT, including six more places that hand back 'perry-tasks render --write' — the command that writes another project's BOARD.md. It also reports that its raw-interpolation rule OVER-REPORTS there (3 genuine, 16 being FLAG_VALUE reading a flag mentioned in prose) and says so rather than quoting 19. So this row's population is 25 confirmed root-droppers plus 3 confirmed raw interpolations, and the instrument's precision on the rest of bin/ is known to be poor. TASK-234 also shipped the shape this row should generalise: a choke point PLUS a source rule, on the argument that a choke point alone is only a convention — _root_flag was already a choke point and that is exactly why it failed.", "to": "POPULATION CORRECTED 2026-08-30 by the TASK-234 round-5 reviewer, which re-derived the census with its own instrument: the row's headline was over FOURTEEN tools, not twelve. Over the other twelve it is 42 handed-back commands / 208 mentions, not 63 / 232 — the difference is exactly perry-conform and perry-migrate's own 21/24, which TASK-234 has now fixed. So THIS ROW'S POPULATION IS A THIRD SMALLER THAN FIRST FILED. The counts that actually matter reproduce exactly: 25 rootless and 19 raw, with both splits confirmed. Two further corrections: of the '3 genuine' raw interpolations outside these tools, TWO INTERPOLATE THE VERB, NOT AN ARGUMENT, so the genuine count is 1; and the backtick residual TASK-234 sized at two is EIGHT, of which only the message_for pair is pinned — two of the unpinned six are 'perry-tasks render --write' and 'write --from-board', the ones that WRITE. Whoever takes this row should re-derive the census a third time rather than inherit any of these numbers: it has now been measured three times and been wrong twice."} {"ts": "2026-08-30T16:44:44+08:00", "event": "add", "id": "TASK-259", "title": "Nothing asserts the TASK-234 fixture root is shell-hostile, and 8 of 19 bypass spellings get past the source rule", "track": "main", "mode": "project", "priority": "P1", "actor": "Ran Jiao", "summary": "Filed 2026-08-30 from the TASK-234 round-5 review. Item (b) is the interesting one: the row's defence is a choke point PLUS a source rule, and the source rule is the half that makes the choke point more than a convention — so its recall is the property the whole shape rests on. It is 11 of 19 today.", "depends_on": [], "from": null, "to": "not_started"} +{"ts": "2026-08-31T20:27:36+08:00", "event": "add", "id": "TASK-260", "title": "V4 criteria must be bounded, and the round stops auditing its own exhibit", "track": "main", "mode": "project", "priority": "P1", "actor": "agent", "summary": "TASK-050 ran 11 rounds against a universal negative and PASSed on the round the criterion became decidable. Measured: 22 of 49 finding headlines audit the round's own artifact, not the product.", "depends_on": [], "from": null, "to": "not_started"} +{"ts": "2026-08-31T20:27:48+08:00", "event": "done", "id": "TASK-260", "title": "V4 criteria must be bounded, and the round stops auditing its own exhibit", "track": "main", "owner": "Coding Agent", "role": "", "actor": "agent", "from": "not_started", "to": "done", "evidence": "a4eb411; evidence/2026-08/2026-08-31-representation-layer-delete-list.md", "rung": "V3"} +{"ts": "2026-08-31T20:27:48+08:00", "event": "add", "id": "TASK-261", "title": "Tier A — the ADR-004 declaration gate comes out; perry-conform keeps only its shared helpers", "track": "main", "mode": "project", "priority": "P1", "actor": "agent", "summary": "23 records, all route: declare, all Perry's own files, zero migrations and zero disagreements. The gate's value needs a foreign project that drifts, and Perry has never been run on one. The delete list said 'delete bin/perry-conform, 974 lines'; that was wrong — 598 lines are the dead ledger and ~280 are helpers four tools depend on, so the file is gutted, not removed.", "depends_on": [], "from": null, "to": "not_started"} +{"ts": "2026-08-31T20:27:58+08:00", "event": "start", "id": "TASK-261", "title": "Tier A — the ADR-004 declaration gate comes out; perry-conform keeps only its shared helpers", "track": "main", "actor": "agent", "from": "not_started", "to": "in_progress"} +{"ts": "2026-08-31T21:01:37+08:00", "event": "ask", "id": "USER-910", "title": "perry-migrate cannot survive Tier A — its output IS the deleted ledger (C.declare, 14 sites). A: delete migration too (0 records ever carried route:migrate; TASK-097 never started) — recommended. B: restore ~200 ledger lines for migrate alone, keep the write-path gate deleted, make TASK-097 the next phase. Full form: evidence/2026-08/2026-08-31-TASK-261-migration-fork.md", "asked": "2026-08-31", "blocks": "TASK-261", "actor": "agent", "from": null, "to": "pending"} +{"ts": "2026-08-31T21:01:44+08:00", "event": "status", "id": "TASK-261", "title": "Tier A — the ADR-004 declaration gate comes out; perry-conform keeps only its shared helpers", "track": "main", "actor": "agent", "depends_on": ["USER-910"], "from": "in_progress", "to": "blocked", "reason": "migration fork: perry-migrate's output is the deleted ledger"} diff --git a/bin/perry-conform b/bin/perry-conform deleted file mode 100755 index 7d1964d1..00000000 --- a/bin/perry-conform +++ /dev/null @@ -1,974 +0,0 @@ -#!/usr/bin/env python3 -""" -perry-conform — the declared, checkable conformance marker (ADR-004). - -`bin/perry-lint`'s `is_adopted()` answers *"does this folder contain any Perry -file at all"*. It answers that correctly and this tool does not replace it. - -ADR-004 needs a different fact, and it is two facts wearing one name: - - 1. **the declaration** — the user said this file is Perry's, at shape - version N. Recorded in `.perry/conformance.jsonl`. Only ever written by - `perry-conform declare` (and by `bin/perry-migrate`, the migration the - user asked for, which records `route: migrate`). *Adoption proposes; the - user declares.* - 2. **the shape** — does the file still match `schema/state-schema.json` - right now. Computed live, on every call, by `bin/perry-lint`'s own - `check_file`. Never cached, never stored. - -Keeping them apart is the whole design. A stored verdict would be a cache that -goes wrong, and a content hash would revoke itself on every legitimate -`perry-task add`. A stored *decision* plus a live *check* can disagree, and -that disagreement is a finding — which is exactly what ADR-004 asks for. - -The record is a STORE, one JSON object per line, since TASK-234: - - {"kind": "declaration", "path": "BOARD.md", "shape_version": 2, - "declared": "2026-08-20", "route": "declare", - "writer": "perry-conform declare", - "recorded_at": "2026-08-30T09:12:03+08:00", "run": ""} - -DESIGN-013 § 5.1 — a fact with a schema lives in exactly one store, a document -holds what has none — and this record had no prose to hold: it was 23 rows of -four regular columns under a header that was already a constant here. The -three fields after `route` are what the columns could not carry, and they are -the reason to convert rather than a bonus for having done it: `TASK-226` was an -investigation, not a query, because a row could not say who wrote it. - -**There is no rendered markdown, deliberately.** `perry-conform status` is the -human surface and was already; a ledger nobody reads for pleasure does not need -a second face, and a second face is a second register to keep in step. - -Usage: - perry-conform status [--root ] [--json] - perry-conform check [--root ] [--json] - perry-conform declare ( ... | --all) [--root ] [--dry-run] [--json] - perry-conform migrate [--root ] [--json] - - is the path as `schema/state-schema.json` declares it: relative to the - state root for state files (`BOARD.md`, `phase/004-x.md`), relative to the - project root for the `.perry/` ones (`.perry/hook.md`). `perry-conform - status` lists every key this project has. - - `migrate` carries a pre-TASK-234 `.perry/conformance.md` into the store, - dates and routes unchanged, and deletes the markdown. It DECLARES NOTHING: - it writes only rows already in the record, so it is not the act - `SKILL.md § Conformance gate` reserves to the user. It refuses rather than - convert a file it cannot say it is copying verbatim. - -Exit codes: - 0 the request was satisfied - 1 `check` on a file that is not conformant; `declare` on a file that could - not be declared; `migrate` on a record it will not convert - 2 bad invocation - -No LLM, no external dependencies (stdlib only). `status` and `check` are -read-only; `declare` and `migrate` write `.perry/conformance.jsonl` (and -`migrate` deletes `.perry/conformance.md`) and nothing else. -""" - -from __future__ import annotations - -import difflib -import importlib.machinery -import importlib.util -import json -import os -import re -import shlex -import sys -from dataclasses import dataclass, field -from datetime import date -from pathlib import Path - -HERE = Path(__file__).resolve().parent -PERRY_HOME = Path(os.environ.get("PERRY_HOME") or HERE.parent).resolve() - -sys.path.insert(0, str(PERRY_HOME / "viewer")) -sys.path.insert(0, str(PERRY_HOME / "bin")) -import parsers as P # noqa: E402 -from tables import render_row # noqa: E402 -import lib # noqa: E402 - -_LINT = None - - -def lint(): - """`bin/perry-lint` as a module, loaded once per process. - - This tool must NOT contain a second definition of Perry's shape. The one - definition is `schema/state-schema.json`, and the one implementation of - "does this file match it" is `perry-lint.check_file`. So the linter is - imported rather than imitated, the same way `bin/perry-task` imports - `bin/perry-state` rather than re-deriving its rules.""" - global _LINT - if _LINT is None: - sys.path.insert(0, str(PERRY_HOME / "bin")) - spec = importlib.util.spec_from_loader( - "perry_lint", importlib.machinery.SourceFileLoader( - "perry_lint", str(PERRY_HOME / "bin" / "perry-lint"))) - mod = importlib.util.module_from_spec(spec) - sys.modules.setdefault("perry_lint", mod) - spec.loader.exec_module(mod) - _LINT = mod - return _LINT - - -class Refused(Exception): - """A refusal is a first-class outcome, not a crash. Nothing was written.""" - - -def load_schema() -> dict: - """`lib.load_schema`, plus the one thing this tool needs done after it. - - The arming is not part of loading and is why this wrapper exists: without - it, a project whose board says `负责人` is reported as missing `Owner`.""" - schema = lib.load_schema(Refused) - lint().load_glossary(schema) - return schema - - -def shape_version(schema: dict) -> int: - """Perry's shape version — `schema_version`, not a number of its own. - - Deliberately NOT a second counter. `schema/state-schema.json` *is* the - definition of Perry's shape; a `conformance_version` beside it would be one - rule with two numbers, and the first schema change that forgot to bump both - would make every marker a lie.""" - return int(schema.get("schema_version") or 0) - - -# ── the verdict ─────────────────────────────────────────────────────────── - -CONFORMANT = "conformant" -UNDECLARED = "undeclared" -STALE = "stale" -DRIFTED = "drifted" -ABSENT = "absent" - - -@dataclass -class Verdict: - path: str # the schema-declared key - state: str - shape_version: int # what Perry's shape is now - declared_version: int | None = None - declared_on: str = "" - route: str = "" - errors: list = field(default_factory=list) # perry-lint Findings, errors only - record_unreadable: int = 0 - file: str = "" # absolute path, for the message - #: This project still keeps its declarations in `.perry/conformance.md` and - #: has no store (TASK-234). NOT a sixth state: the verdict really is - #: `undeclared`, because the record this reader reads holds nothing. What it - #: changes is the way forward — `perry-conform migrate`, which carries the - #: user's existing declarations across, rather than `perry-conform declare`, - #: which would mint a new one and lose the date they declared it on. - legacy_record: bool = False - - @property - def ok(self) -> bool: - return self.state in (CONFORMANT, ABSENT) - - def as_dict(self) -> dict: - return { - "path": self.path, - "state": self.state, - "shape_version": self.shape_version, - "declared_version": self.declared_version, - "declared_on": self.declared_on, - "route": self.route, - "errors": len(self.errors), - "record_unreadable_rows": self.record_unreadable, - "legacy_record": self.legacy_record, - } - - -def state_files(project_root: Path, state_root: Path, schema: dict) -> list[tuple[str, Path, dict]]: - """Every file the schema claims, as (key, absolute path, spec). - - The enumeration is `perry-lint.iter_targets` — the same globbing, the same - `exclude` handling — so "a file perry-lint validates" and "a file that can - be declared conformant" cannot come apart.""" - L = lint() - out: list[tuple[str, Path, dict]] = [] - for spec in schema["files"]: - base = project_root if spec.get("anchor") == "project" else state_root - for path in L.iter_targets(base, spec): - # **Two specs may share one glob.** `knowledge/*/*.md` is matched by - # both the digest and the knowledge-card entry, and a card is not a - # malformed digest. `spec_claims` is `perry-lint`'s one - # implementation of the declared `discriminator`; putting the call - # HERE rather than in each caller is what makes lint, conform and - # migrate agree — the first version wired it into `perry-lint` - # alone and `perry-migrate` immediately started writing the card's - # five fields into every digest, `Kind: —` included, on every real - # project. Caught by the suite. - if not L.spec_claims(spec, L.strip_comments( - path.read_text(errors="replace")), schema): - continue - out.append((path.relative_to(base).as_posix(), path, spec)) - return out - - -def spec_for(project_root: Path, state_root: Path, schema: dict, - key: str) -> tuple[Path, dict] | None: - for k, path, spec in state_files(project_root, state_root, schema): - if k == key: - return path, spec - return None - - -def shape_errors(path: Path, key: str, spec: dict, schema: dict) -> list: - """`perry-lint`'s errors for this one file, and only for this one file. - - **Errors, not warnings.** Two reasons, and the second is decisive: - - - warnings in this schema are quality signals, not shape violations — - `done-needs-evidence`, `board-declares-no-rungs`, a soft `size-cap`. A - board can carry every one of them and still be a board every reader - parses correctly, which is the only thing a writer needs to know. - - some of them are *time-dependent*. `stale-run` fires when an adoption run - has been open 30 days; nothing about the file changed. A declaration that - revokes itself when a calendar boundary passes is not a statement about - shape. - - **`check_file`, not `check_cross_file`.** ADR-004 § 5 makes conformance - per-file so a project can migrate its board and not its risks; a check that - spans files cannot be attributed to one of them.""" - L = lint() - findings = L.check_file(path, key, spec, schema["enums"], is_template=False) - return [f for f in findings if f.severity == "error"] - - -def verdict(project_root: Path, state_root: Path, key: str, - schema: dict | None = None) -> Verdict: - """The whole fact about one file: what was declared, and what is true.""" - schema = schema or load_schema() - now = shape_version(schema) - record = P.read_conformance(project_root) - decl = record.declarations.get(key) - v = Verdict(path=key, state=UNDECLARED, shape_version=now, - record_unreadable=len(record.unreadable), - legacy_record=record.legacy is not None) - found = spec_for(project_root, state_root, schema, key) - if found is None: - v.state = ABSENT - return v - path, spec = found - v.file = str(path) - v.errors = shape_errors(path, key, spec, schema) - if decl is None: - v.state = UNDECLARED - return v - v.declared_version, v.declared_on, v.route = ( - decl.shape_version, decl.declared, decl.route) - if decl.shape_version != now: - v.state = STALE - elif v.errors: - # Reported, not revoked. The line stays in `.perry/conformance.jsonl`: - # user did declare this file, and a tool that erased the declaration - # because the file drifted would be deciding on the user's behalf in - # the one place ADR-004 § 4 says it may not. - v.state = DRIFTED - else: - v.state = CONFORMANT - return v - - -# ── the gate ────────────────────────────────────────────────────────────── - -ADVISORY = "advisory" -ENFORCE = "enforce" - -#: Shipped **enforce** (TASK-047). This shipped `advisory` for one release, on -#: an argument that named its own expiry condition, and the condition has now -#: fired. The old text is not preserved here — a comment that explains why the -#: default is `advisory` is wrong the moment the default is `enforce` — but the -#: argument it made is, because a reviewer should be able to disagree with the -#: flip on the same terms it was promised: -#: -#: The advisory release was NOT the DESIGN-003 decision-4 argument (a hard gate -#: would retroactively invalidate work done before the rule existed). That one -#: does not carry: a missing declaration on a conformant file can be produced in -#: one second, retroactively, by one command. The argument that carried was -#: narrower — for the projects that are NOT already Perry-shaped the way forward -#: is the migration, the migration did not exist, and a refusal that names a -#: command nobody can run is the wall ADR-004 § 4 forbids. Enforcement was to -#: flip when TASK-044 gave the non-conformant half of the population a road. -#: -#: TASK-044 landed 2026-08-19. `bin/perry-migrate` exists, is dry-runnable, -#: declares what it migrates with `route: migrate`, and names a restore point. -#: Both halves of the population now have a road, and `message_for` below names -#: it in every branch: `perry-conform declare` for a file that already matches, -#: `perry-migrate` for one that does not. -#: -#: **What the flip costs, stated rather than discovered.** Neither of these is a -#: reason the road is missing; both are reasons a user meets the gate on day one: -#: -#: 1. a complete migration does not always reach zero on a real board. Measured -#: on a copy of `~/proj/gimegime-pmo`: `BOARD.md` goes 3 errors → 1, and the -#: residue is a `Status` cell reading `半解` — a distinction its author drew, -#: which migration deliberately will not coerce into `in_progress`. That file -#: stays refused until a human edits it and declares it. The refusal says so -#: and names both commands; it is a door that needs a hand, not a wall. -#: 2. a brand-new project has zero shape errors and is still `undeclared`, -#: because `SKILL.md § Conformance gate` forbids an agent from declaring on -#: the user's behalf — *adoption proposes; the user declares*. So the first -#: write on a spotless project asks for one `perry-conform declare BOARD.md`. -#: -#: Both are pinned as executable tests in `tests/test_conformance.py § 7`, so -#: the day either stops being true a test says so instead of this paragraph -#: quietly going stale. -#: -#: The escape hatches are unchanged and are the reason this is reversible per -#: project rather than per release: `PERRY_CONFORMANCE=advisory` in the -#: environment, or `- Conformance gate: advisory` in `.perry/config.md`. -#: Advisory is not "off" and never was — the gate computes the same verdict and -#: prints the same message, it simply writes anyway. A guard that cannot be made -#: to fire is not a guard, so both branches stay exercised by the suite. -DEFAULT_MODE = ENFORCE - - -#: The store key `- Conformance gate: advisory` mints, per -#: `bin/perry_md_store § setting_key`. Named rather than spelled inline so the -#: store read and the markdown read below cannot drift apart — the same reason -#: `tests/gate.py § GATE_OFF` exists on the fixture side. -GATE_SETTING_KEY = "conformance_gate" - - -def gate_mode(project_root: Path) -> str: - """`enforce` or `advisory`, most specific wins. - - env `PERRY_CONFORMANCE` beats the project's declared `Conformance gate` - beats the shipped default. - - **The declaration is read from `.perry/config.jsonl` when that store - exists, with `.perry/config.md` as the fallback** (TASK-233). It used to be - read from the markdown alone, so deleting a rendered projection dropped a - project that had declared `advisory` back to the shipped `enforce` in - silence — the gate then refused writes the user had explicitly opted out - of, and nothing in the refusal said which register it had consulted. - - **A usable store that carries no `Conformance gate` record is an ANSWER, - not a reason to go read the markdown.** The store is derived from the - preamble, so a key it does not carry is a line the file does not have; the - project has declared nothing and the shipped default applies. Falling - through to the markdown there would reintroduce exactly the two-registers - problem this row exists to remove — and it would do it on the one setting - that decides whether every other write is allowed. - """ - env = (os.environ.get("PERRY_CONFORMANCE") or "").strip().lower() - if env in (ENFORCE, ADVISORY): - return env - stored, _why = P.config_store_settings(Path(project_root)) - if stored is not None: - value = (stored.get(GATE_SETTING_KEY) or "").strip().lower() - return value if value in (ENFORCE, ADVISORY) else DEFAULT_MODE - cfg = Path(project_root) / ".perry" / "config.md" - if cfg.exists(): - m = re.search(r"Conformance gate\s*[::]\s*([^\n]+)", - cfg.read_text(errors="replace"), re.I) - if m and m.group(1).strip().strip("*` ").lower() in (ENFORCE, ADVISORY): - return m.group(1).strip().strip("*` ").lower() - return DEFAULT_MODE - - -@dataclass -class GateResult: - verdict: Verdict - mode: str - ok: bool - message: str - - def as_dict(self) -> dict: - d = self.verdict.as_dict() - d["gate"] = self.mode - d["allowed"] = self.ok - return d - - -def _q(value) -> str: - """**One argument of a command this tool hands back, spelled so the reader - can copy it.** The single choke point for every such argument. - - `shlex.quote`, which is a no-op on a value with nothing shell-special in - it — so a project at `/home/ada/perry` is still handed - `--root /home/ada/perry`, and one at `/Users/ada/My Project` is handed - `--root ʼ/Users/ada/My Projectʼ` instead of a line that parses as two - arguments. - - **Why a named function and not `shlex.quote` inline.** Round 3 dropped the - root from a handed-back command; round 4 added it back UNQUOTED, in the - same sentence, because `_root_flag` was a convention and a convention is - enforced by whoever remembers it. This is the same convention — but - `tests/sweep_handed_back_commands.py` now reads every handed-back command - off the AST and reports any `{...}` inside one that is not `_q(...)`, - `_root_flag(...)`, `shlex.quote(...)` or the `r` those produce, and - `tests/test_conformance.py § test_no_refusal_names_a_command_without_the - _root` fails the suite on it. So the bypass spelling — `--root {root_arg}`, - `declare {v.path}` — is not discouraged, it is RED. That is the difference - between this shape and the one it replaces. - """ - return shlex.quote(str(value)) - - -def _root_flag(root_arg: str | None) -> str: - return f" --root {_q(root_arg)}" if root_arg else "" - - -def message_for(v: Verdict, tool: str, root_arg: str | None) -> str: - """The way forward, named. A gate that says "not conformant" and stops is - a wall — every branch here ends in a command the reader can run.""" - r = _root_flag(root_arg) - tail = ("" if v.record_unreadable == 0 else - f" ({v.record_unreadable} line(s) in {P.CONFORMANCE_FILE} could not " - f"be read and were not counted as declarations)") - if v.legacy_record: - # **Before every other branch, because it changes which command is the - # right one.** This project's declarations are in the markdown record - # TASK-234 replaced. Naming `perry-conform declare` here would be a - # correct sentence about the store and a wrong instruction: it would - # mint a declaration dated today over one the user made weeks ago. - return ( - f"{v.path} is undeclared in {P.CONFORMANCE_FILE}, and this project " - f"still keeps its declarations in {P.CONFORMANCE_LEGACY_FILE} — the " - f"markdown record Perry used before the declarations became a store " - f"(TASK-234, DESIGN-013 § 5.1). Carry them across, dates and routes " - f"unchanged, with:\n" - f" perry-conform migrate{r}\n" - f"which declares nothing new — it writes only rows that are already " - f"in the record, and refuses rather than convert a file it cannot " - f"say it is copying. Reading is unaffected — `{tool} list` and " - f"`perry-state` work either way.{tail}") - if v.state == UNDECLARED and not v.errors: - return ( - f"{v.path} already matches Perry's shape at version {v.shape_version}, " - f"but no one has declared it. Perry writes only to declared files " - f"(ADR-004: adoption proposes, the user declares). Declare it with:\n" - f" perry-conform declare {_q(v.path)}{r}\n" - f"Reading is unaffected — `{tool} list` and `perry-state` work " - f"either way.{tail}") - if v.state == UNDECLARED: - # `risk-add`'s refusal is the shape: name the count, name the command, - # point at the dry run. Before TASK-044 the only command this could name - # was `perry-lint`, which reports the problem and fixes nothing — a wall - # with a diagnosis nailed to it. Naming `perry-migrate` is what made the - # enforcing default defensible, so this branch and `DEFAULT_MODE` above - # are one decision, not two. - return ( - f"{v.path} is not Perry's shape: {len(v.errors)} error(s) against " - f"schema/state-schema.json at shape version {v.shape_version}. A " - f"project migrates once (ADR-004); until then this file is " - f"read-only. See the complete diff, with nothing written:\n" - f" perry-migrate{r}\n" - f"then, when it is the diff you want:\n" - f" perry-migrate apply{r}\n" - f"which declares every file it migrates, names a restore point, and " - f"leaves anything it will not touch byte-identical with the reason. " - f"The unfiltered list of findings is `perry-lint{r}`; a file you fix " - f"by hand is declared with `perry-conform declare {_q(v.path)}{r}`.\n" - f"Reading is unaffected — `{tool} list` and `perry-state` work " - f"either way.{tail}") - if v.state == STALE: - return ( - f"{v.path} was declared conformant at shape version " - f"{v.declared_version} on {v.declared_on or '—'}; Perry's shape is " - f"now version {v.shape_version}. A version Perry no longer speaks is " - f"never silently accepted. Re-declare with:\n" - f" perry-conform declare {_q(v.path)}{r}\n" - f"which refuses if the file does not match the current shape" - + (f" (it currently has {len(v.errors)} error(s))" if v.errors else "") - + f".{tail}") - if v.state == DRIFTED: - return ( - f"{v.path} was declared conformant at shape version " - f"{v.declared_version} on {v.declared_on or '—'} and no longer " - f"matches: {len(v.errors)} error(s). The declaration is reported, " - f"not revoked — a file can be edited after it was declared, and " - f"that is a finding, not a correction. See:\n" - f" perry-lint{r}\n" - f"then re-declare with:\n" - f" perry-conform declare {_q(v.path)}{r}" - # **`{tail}` goes on its own line, and that is not cosmetic.** - # This is the only branch whose last line IS the command, so - # appending the unreadable-lines parenthetical to it handed the - # reader `perry-conform declare BOARD.md --root X (2 line(s) in - # …)`. Pasted into a shell that is `syntax error near unexpected - # token ʼ(ʼ`, rc=2; parsed with `shlex.split` it is the command - # plus eighteen junk arguments. Found by the round-5 sweep rule - # that reads every interpolation inside a handed-back command, - # not by anyone reading the branch. - + (f"\n{tail.lstrip()}" if tail else "")) - return "" - - -def gate(project_root: Path, state_root: Path, key: str, tool: str, - root_arg: str | None = None, schema: dict | None = None) -> GateResult: - """The one call every writer makes, about the one file it is about to write. - - `perry-goals` writing `OKR.md` must not care what `BOARD.md` says, so this - takes exactly one key and looks at exactly one file (ADR-004 § 5).""" - v = verdict(project_root, state_root, key, schema) - mode = gate_mode(project_root) - msg = "" if v.ok else message_for(v, tool, root_arg) - return GateResult(verdict=v, mode=mode, - ok=v.ok or mode != ENFORCE, message=msg) - - -# ── converting the markdown record (TASK-234) ───────────────────────────── -# -# `.perry/conformance.md` was the record until TASK-234 and is a conversion -# SOURCE afterwards. The two constants below are the markdown writer that used -# to exist, kept for exactly one purpose: to say whether a project's markdown -# record is line-for-line what that writer would have produced. -# -# **Line-for-line, not byte-for-byte, and the difference is stated because the -# first version of this row claimed the stronger thing.** The comparison is -# against `Path.read_text()`, which applies Python's universal-newline -# translation, so a record saved with CRLF converts. That is the behaviour we -# want — a CRLF record is still Perry's record, and refusing it would strand a -# Windows checkout with no way forward — but "byte-for-byte" was not what the -# code did. Pinned by `tests/test_conformance.py § TestTheRefusalNamesTheLine -# .test_a_crlf_record_converts_and_the_wording_does_not_say_byte`, which -# asserts the behaviour AND that this file has stopped claiming the other one. - -LEGACY_HEADER = [ - "# Perry conformance", - "", - "> Written by `perry-conform declare`. Each row records that **the user**", - "> declared this file to match Perry's shape at that shape version", - "> (ADR-004 § 4). It is a decision, not a verdict: whether the file still", - "> matches is re-checked live on every write, and a row that no longer", - "> holds is reported rather than deleted.", - "", - "> Paths are as `schema/state-schema.json` declares them — relative to the", - "> state root, except the `.perry/` ones which are relative to the project", - "> root. Delete a row to withdraw a declaration.", - "", - "| File | Shape version | Declared | Route |", - "|---|---|---|---|", -] - - -def render_legacy(declarations: dict) -> str: - """The markdown `render()` this tool shipped until TASK-234. - - It writes nothing now. It is the right-hand side of the conversion's fixed - point, and it is the ORIGINAL function rather than a re-derivation for the - same reason the reader beside it is TASK-241's reader: a check that "this - file is what Perry wrote" is worth nothing if the thing it compares against - is a second, freshly-typed idea of what Perry wrote.""" - # `render_row`, not an f-string: this file records a project's own paths, - # and a path is a value Perry does not choose. - rows = [render_row([d.path, str(d.shape_version), d.declared, d.route]) - for d in sorted(declarations.values(), key=lambda d: d.path)] - return "\n".join([*LEGACY_HEADER, *rows, ""]) - - -write_atomic = lib.write_atomic - - -class LegacyRecordRefused(Refused): - """The markdown record is not convertible as it stands. Nothing was written.""" - - -#: How many diff lines a refusal prints before it stops. -#: -#: A refusal is read in a terminal. Perry's own record is 37 lines, so a whole -#: record replaced by hand would print 74 and bury its own last sentence — the -#: command to run. Forty is more than any plausible hand edit and less than a -#: file; past it the message says how many it dropped, which is a number the -#: reader can act on where a truncated hunk is not. -DIFF_CAP = 40 - - -def record_diff(authored: str, canonical: str) -> str: - """What is in the file and not in the record, and the other way round. - - **This is the mitigation the refusal below depends on, and the first - version of this row shipped without it.** That refusal told the reader to - run `perry-conform status`, and `status` computes no diff, says nothing - about the markdown's contents, and names `perry-conform migrate` — the - command that had just refused. So the way forward was *read 37 lines by - eye*, while `declare`, `perry-migrate apply` and all three gate call sites - were refusing for want of a store. `bin/perry-conform § message_for` states - the standard this violated in this same file: *"a gate that says 'not - conformant' and stops is a wall — every branch here ends in a command the - reader can run."* Found by the V4 reviewer, who measured that 7 of 9 - plausible hand edits refuse and that one of the survivors is the edit the - record's own header invites. - - `-` is the authored file and `+` is what the record parses to, which is the - direction that makes the fix obvious: a `-` line with no `+` beside it is a - line to delete, and a `+` line is one to restore. - """ - lines = list(difflib.unified_diff( - authored.splitlines(), canonical.splitlines(), - fromfile=P.CONFORMANCE_LEGACY_FILE, tofile="what Perry reads out of it", - lineterm="", n=1)) - dropped = max(0, len(lines) - DIFF_CAP) - shown = [" " + line for line in lines[:DIFF_CAP]] - if dropped: - shown.append(f" … and {dropped} more diff line(s); the whole file " - f"differs, so compare it yourself rather than by this " - f"excerpt") - return "\n".join(shown) - - -def migrate_record(project_root: Path, *, root_arg: str | None) -> dict | None: - """`.perry/conformance.md` → `.perry/conformance.jsonl`, once, losslessly. - - `None` when there is nothing to do: the store already exists, or the - project never had a markdown record. Otherwise the store is written, the - markdown is deleted, and a summary of what moved is returned. - - **Bootstrap order, which TASK-234 had to settle before any of this was - written.** This file gates every write under ADR-004's enforce gate, - including the write that migrates it — so the question is whether the - conversion can run on a project whose gate does not pass. It can, and the - reason is the same decision `schema/state-schema.json` records for the - record itself: `.perry/conformance.md` is deliberately NOT a `files[]` - entry, so `state_files()` never yields it, `verdict()` has no verdict about - it, and no writer has ever called `gate()` before writing it. The record's - own write is ungated BY CONSTRUCTION, and it is the self-reference - exclusion that makes it so. Nothing here needs an exemption, and none is - granted — an exemption would be a hole; this is a file the gate has no - opinion about. - - **The conversion refuses unless the whole markdown file is line-for-line - what `render_legacy` would have written for what it parses to** (newline - style excepted; see the note above `LEGACY_HEADER`). Per-row round - tripping is what `read_legacy_conformance` already does and it is not - enough here, because two known classes are invisible to it BY - CONSTRUCTION — a row inside a code fence (TASK-241) and a row inside - `
`, an HTML comment or `
` (TASK-248) are byte-for-byte - genuine rows, and what makes them not declarations is what surrounds them. - The file-level fixed point sees exactly that: the surrounding lines are not - in `render_legacy`'s output. - - TASK-241 round 2 rejected this same fixed point as a READING rule, and was - right to — one stray blank line would void all 23 of Perry's declarations - and take the gate down. It is the correct rule HERE for the reason it was - the wrong rule there: this is a one-way door run once, the consequence of - refusing is *look at your file*, and the consequence of proceeding is a - laundered declaration nothing downstream can tell from a real one. - - **`root_arg` is required, and it is required because of what happened when - it was absent.** Both refusals below end in `perry-conform migrate` — the - wall standard `message_for` states in this same file. Round 3 rewrote those - sentences under that banner and named the command WITHOUT the root, while - `message_for` forty lines up propagates it through `_root_flag()`. A reader - routed here by `perry-conform migrate --root /their/project` copied the - command they were handed, and got `rc=0` and *"nothing to convert — - `.perry/conformance.jsonl` is already this project's record (or it has - none)"* — about whatever project they were standing in, while their own - record sat unconverted and still gating every write. Not an error: a - success-shaped silence, which is the worst thing a refusal whose whole job - is to hand back a working command can do. So the parameter has no default: - a caller that has a root must pass it, and a caller that has none must say - so. Found by the V4 round 3 reviewer, who ran the command the refusal - named, from where the reader was standing. - """ - r = _root_flag(root_arg) - root = Path(project_root) - store = root / P.CONFORMANCE_FILE - legacy = root / P.CONFORMANCE_LEGACY_FILE - if store.exists() or not legacy.exists(): - return None - record = P.read_legacy_conformance(root) - text = legacy.read_text(errors="replace") - if record.unreadable: - raise LegacyRecordRefused( - f"{P.CONFORMANCE_LEGACY_FILE} has " - f"{len(record.unreadable)} row(s) this reader will not honour, and " - f"converting the file would delete them without asking:\n" - + "\n".join(f" line {n}: {t}" for n, t in record.unreadable) - + f"\nFix or delete each row by hand, then run:\n" - f" perry-conform migrate{r}\n" - f"A row that is documentation — the record's own format, shown " - f"inside a fence — has to come out for the conversion and can go " - f"back into the file it belongs in afterwards; the store does " - f"not carry prose. **Nothing was written.**") - canonical = render_legacy(record.declarations) - if canonical != text: - raise LegacyRecordRefused( - f"{P.CONFORMANCE_LEGACY_FILE} is not what `perry-conform declare` " - f"would have written for the {len(record.declarations)} " - f"declaration(s) in it, so this conversion cannot say it is " - f"carrying the record across rather than a reading of it. A row " - f"inside a code fence, an HTML comment, `
` or `
` " - f"looks exactly like a real one and is not one; so does an edited " - f"header, a reordered row or a stray blank line.\n\n" - f"Here is the difference. `-` is your file; `+` is what Perry " - f"reads out of it, so a `-` line alone is a line to delete and a " - f"`+` line is one to restore:\n\n" - + record_diff(text, canonical) - + f"\n\nFix those lines, then run:\n" - f" perry-conform migrate{r}\n" - f"**Nothing was written.**") - # **Provenance stays empty on every converted declaration.** The markdown - # held four columns and none of them was a writer, a moment or a run; - # stamping this conversion's own clock onto a decision the user made on - # 2026-08-20 would put a fact in the record that nobody recorded. - write_atomic(store, P.render_conformance(record.declarations)) - legacy.unlink() - return {"from": str(legacy), "to": str(store), - "declarations": len(record.declarations)} - - -# ── declaring ───────────────────────────────────────────────────────────── - - -def declare(project_root: Path, state_root: Path, keys: list[str], - schema: dict, dry_run: bool = False, route: str = "declare", - writer: str = "perry-conform declare", run: str = "", - *, root_arg: str | None) -> dict: - """Record the user's declaration for each named file. - - `route` is how the declaration was made — `declare` for this command, - `migrate` for `bin/perry-migrate`, which the docstring above always said - would be the second caller. It is the record's own column, and it exists so - a reader can tell a hand declaration from one a migration made on the - user's instruction. **It is not a second record**: the migration calls this - function, so the file still has exactly one writer, and the per-file - re-check below still runs — a migration cannot record a claim this - function would refuse. - - Refuses per file rather than in bulk: a project that can declare its board - and not its risks is a *state*, not a failure (ADR-004 § 5), and the rows - it could write are written. The exit code still reports that the request - was not fully satisfied. - - **The markdown record is converted first, if there is one.** This is the - one writer of the record, so it is the one place the conversion can live - without becoming a second one; `migrate_record` is a no-op on every project - that has already converted and on every project that never had a markdown - record, which between them is all of them after this runs once.""" - converted = (migrate_record(project_root, root_arg=root_arg) - if not dry_run else None) - record = P.read_conformance(project_root) - now = shape_version(schema) - stamped_at = lib.event_stamp() - declared, refused = [], [] - for key in keys: - v = verdict(project_root, state_root, key, schema) - if v.state == ABSENT: - refused.append({"path": key, "reason": "no such file in this project", - "errors": 0}) - continue - if v.errors: - refused.append({ - "path": key, "errors": len(v.errors), - "reason": f"{len(v.errors)} error(s) against " - f"schema/state-schema.json — a declaration that the " - f"file matches Perry's shape would be false", - "findings": [f.as_dict() for f in v.errors], - }) - continue - record.declarations[key] = P.Declaration( - path=key, shape_version=now, declared=f"{date.today():%Y-%m-%d}", - route=route, line=0, - # ── the provenance the four columns could not carry (TASK-234). - # `route` says how the declaration was made; these say who made it, - # at what moment, and — for a migration — under which run, which is - # also the name of its restore point in `.perry/migrate/`. This is - # the whole point of the conversion: `TASK-226` had to be an - # investigation because a row could not answer any of the three. - writer=writer, recorded_at=stamped_at, run=run) - declared.append({"path": key, "shape_version": now}) - if declared and not dry_run: - write_atomic(project_root / P.CONFORMANCE_FILE, - P.render_conformance(record.declarations)) - return {"declared": declared, "refused": refused, - "shape_version": now, - "record": str(project_root / P.CONFORMANCE_FILE), - "converted": converted, - "dry_run": dry_run} - - -# ── CLI ─────────────────────────────────────────────────────────────────── - - -def _roots(root_arg: str | None) -> tuple[Path, Path]: - project_root = (Path(root_arg).expanduser().resolve() if root_arg - else Path(os.environ.get("PERRY_PROJECT") or Path.cwd()).resolve()) - return project_root, P.resolve_state_root(project_root) - - -def main(argv: list[str]) -> int: - cmd = None - root_arg = None - files: list[str] = [] - as_json = do_all = dry_run = False - i = 0 - while i < len(argv): - a = argv[i] - if a in ("-h", "--help"): - print(__doc__.strip()) - return 0 - elif a == "--root": - i += 1 - root_arg = argv[i] if i < len(argv) else None - elif a == "--json": - as_json = True - elif a == "--all": - do_all = True - elif a == "--dry-run": - dry_run = True - elif a.startswith("-"): - print(f"perry-conform: unknown argument {a!r} (try --help)", - file=sys.stderr) - return 2 - elif cmd is None: - cmd = a - else: - files.append(a) - i += 1 - - if cmd not in ("status", "check", "declare", "migrate"): - print("perry-conform: expected one of status / check / declare / " - "migrate (try --help)", file=sys.stderr) - return 2 - - try: - schema = load_schema() - project_root, state_root = _roots(root_arg) - - if cmd == "status": - keys = [k for k, _, _ in state_files(project_root, state_root, schema)] - verdicts = [verdict(project_root, state_root, k, schema) for k in keys] - record = P.read_conformance(project_root) - if as_json: - print(json.dumps({ - "project_root": str(project_root), - "state_root": state_root.relative_to(project_root).as_posix() or ".", - "shape_version": shape_version(schema), - "gate": gate_mode(project_root), - "record": str(record.path), - "record_exists": record.exists, - "legacy_record": (str(record.legacy) if record.legacy - else None), - "stray_legacy_record": (str(record.stray_legacy) - if record.stray_legacy else None), - "unreadable_rows": [ - {"line": n, "text": t} for n, t in record.unreadable], - "files": [v.as_dict() for v in verdicts], - }, ensure_ascii=False, indent=2)) - else: - rel = state_root.relative_to(project_root).as_posix() or "." - print(f"\n🔖 Conformance · {project_root.name} · state root: {rel} " - f"· shape version {shape_version(schema)} " - f"· gate: {gate_mode(project_root)}\n") - if not verdicts: - print(" · no files this schema claims exist here — nothing " - "to declare, and nothing is gated") - for v in verdicts: - mark = {CONFORMANT: "✓", UNDECLARED: "·", - STALE: "!", DRIFTED: "✗"}.get(v.state, "·") - extra = (f" ({len(v.errors)} lint error(s))" if v.errors else "") - ver = ("" if v.declared_version is None - else f" @v{v.declared_version}") - print(f" {mark} {v.path:<44} {v.state}{ver}{extra}") - for n, t in record.unreadable: - print(f" ✗ {P.CONFORMANCE_FILE}:{n} unreadable line: {t}") - if record.legacy: - print(f"\n ! this project's declarations are still in " - f"{P.CONFORMANCE_LEGACY_FILE}, the markdown record " - f"Perry kept before TASK-234. Nothing above counts " - f"them. Carry them across with `perry-conform " - f"migrate{_root_flag(root_arg)}`.") - if record.stray_legacy: - # Two registers for the fact that gates every write. The - # store is the record and this file is not read; it is - # named because a user who edits it would be editing - # nothing and would have no way to find that out. - print(f"\n ! {P.CONFORMANCE_LEGACY_FILE} is present " - f"BESIDE the store and is NOT read. The store is the " - f"record (TASK-234); delete the markdown.") - n_ok = sum(1 for v in verdicts if v.state == CONFORMANT) - print(f"\n {n_ok}/{len(verdicts)} declared and matching. " - f"Declare one with `perry-conform declare " - f"{_root_flag(root_arg)}`.\n") - return 0 - - if cmd == "check": - if len(files) != 1: - raise Refused("usage: perry-conform check ") - v = verdict(project_root, state_root, files[0], schema) - if as_json: - d = v.as_dict() - d["gate"] = gate_mode(project_root) - d["message"] = message_for(v, "perry-task", root_arg) - print(json.dumps(d, ensure_ascii=False, indent=2)) - else: - print(f"perry-conform · {files[0]} · {v.state}") - msg = message_for(v, "perry-task", root_arg) - if msg: - print(" " + msg.replace("\n", "\n ")) - return 0 if v.state == CONFORMANT else 1 - - if cmd == "migrate": - if files: - raise Refused( - "usage: perry-conform migrate — it takes no file. The " - "conversion carries the WHOLE record across or refuses; a " - "half-converted record would be two registers for the fact " - "that gates every write") - moved = migrate_record(project_root, root_arg=root_arg) - if as_json: - print(json.dumps({"converted": moved, - "record": str(project_root / P.CONFORMANCE_FILE)}, - ensure_ascii=False, indent=2)) - elif moved is None: - print(f"perry-conform: nothing to convert — " - f"{P.CONFORMANCE_FILE} is already this project's record " - f"(or it has none).") - else: - print(f" ✓ carried {moved['declarations']} declaration(s) " - f"from {P.CONFORMANCE_LEGACY_FILE} into " - f"{P.CONFORMANCE_FILE}, dates and routes unchanged\n" - f" ✓ deleted {P.CONFORMANCE_LEGACY_FILE}\n\n" - f" → {moved['to']}") - return 0 - - # declare - keys = files - if do_all: - keys = [k for k, _, _ in state_files(project_root, state_root, schema)] - if not keys: - raise Refused( - "name at least one file, or pass --all. Nothing is declared " - "implicitly: ADR-004 § 4 makes the declaration the user's act, " - "so a tool that stamped it on its own initiative would be the " - "violation this marker exists to prevent") - result = declare(project_root, state_root, keys, schema, dry_run, - root_arg=root_arg) - if as_json: - print(json.dumps(result, ensure_ascii=False, indent=2)) - else: - verb = "would declare" if dry_run else "declared" - if result["converted"]: - c = result["converted"] - print(f" ✓ carried {c['declarations']} declaration(s) from " - f"{P.CONFORMANCE_LEGACY_FILE} into " - f"{P.CONFORMANCE_FILE} first (TASK-234)") - for d in result["declared"]: - print(f" ✓ {verb} {d['path']} at shape version {d['shape_version']}") - for r in result["refused"]: - print(f" ✗ {r['path']}: {r['reason']}") - if result["refused"]: - print(f"\n see them with `perry-lint{_root_flag(root_arg)}`") - if result["declared"] and not dry_run: - print(f"\n → {result['record']}") - return 1 if result["refused"] else 0 - except Refused as exc: - if as_json: - print(json.dumps({"refused": str(exc)}, ensure_ascii=False, indent=2)) - else: - print(f"perry-conform: refused — {exc}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/bin/perry-goals b/bin/perry-goals index dc5409b4..01ae0bab 100755 --- a/bin/perry-goals +++ b/bin/perry-goals @@ -552,13 +552,6 @@ LIST_SEMANTICS = [ "not now."}, ] -#: `list` only reads, and reading is never gated (ADR-004). Every other -#: subcommand writes `OKR.md`, which is the one file this lane gates on — one -#: key, one file, per ADR-004 § 5. -READ_ONLY_COMMANDS = {"list"} -GATED_FILE = "OKR.md" - -_PERRY_CONFORM = None _PERRY_STATE = None @@ -581,14 +574,6 @@ def _sibling(name: str): return mod -def perry_conform(): - """`bin/perry-conform` as a module — the conformance gate (ADR-004).""" - global _PERRY_CONFORM - if _PERRY_CONFORM is None: - _PERRY_CONFORM = _sibling("perry-conform") - return _PERRY_CONFORM - - def perry_state(): """`bin/perry-state` as a module — for `parse_tracks` and nothing else. @@ -3234,46 +3219,18 @@ def main(argv: list[str]) -> int: result = cmd_krs(args, {"project_root": project_root, "state_root": state_root}) else: - # ADR-004, before the lock and before the command runs: a refusal - # must mean nothing was written, and a dry run must preview the - # refusal rather than the write it would not be allowed to perform. - # **The gate is about the file this command writes**, which is - # not always `OKR.md` any more: `link` writes the phase's linkage - # register. ADR-004 § 5 is one key, one file — passing the gated - # name of a file the command does not touch would refuse a write - # for the shape of something unrelated, and pass one for a file - # nobody checked. - gate_file = GATED_FILE + # **The file this command writes is not always `OKR.md`**: `link` + # writes the phase's linkage register instead. The ADR-004 gate + # that used to stand here is gone (TASK-261) — it read a stored + # DECLARATION and refused a write whose file no longer matched its + # declared shape, and on this repository its ledger held 23 + # records, all `route: declare`, all Perry's own files. Zero + # disagreements, because a disagreement needs a foreign project. + # `--migrate`'s exemption went with it: with no gate to be exempt + # from, a migration is just a write. register_file = None if args.cmd == "link": register_file = register_path(state_root) - gate_file = register_file.relative_to(state_root).as_posix() - gate = perry_conform().gate(project_root, state_root, gate_file, - tool="perry-goals", root_arg=args.root) - # **`--migrate` is exempt, and the exemption is the whole point of - # the gate rather than a hole in it.** The gate stops writers - # writing into a file that is not Perry's shape (ADR-004); a - # pre-split register IS out of shape, by exactly the defect this - # command exists to fix, so enforcing it here would make the file - # permanently unmigratable — refuse to write, refuse to fix, no - # third command. `perry-migrate` is exempt from its own gate for - # the same reason, and this is the transform it hands over. - # Three outcomes, and they are mutually exclusive — the first - # version wrote them as three independent `if`s, which was - # unreachable while the gate shipped advisory (`gate.ok` was always - # true) and started double-printing the moment TASK-047 flipped the - # default: an exempt `--migrate` run announced the exemption and - # then announced itself as advisory, under `enforce`. - if not gate.ok: - if not args.migrate: - raise Refused(gate.message) - if not args.as_json: - print(f"perry-goals: ⚠ migrating a file the conformance " - f"gate refuses — that is what a migration is. " - f"{gate.message}", file=sys.stderr) - elif gate.message and not args.as_json: - print(f"perry-goals: ⚠ conformance ({gate.mode}) — " - f"{gate.message}", file=sys.stderr) with project_lock(state_root): ctx = {"project_root": project_root, "state_root": state_root} if register_file is not None: @@ -3287,11 +3244,6 @@ def main(argv: list[str]) -> int: "tracks": tracks_of(project_root), "events": read_events(project_root)}) result = COMMANDS[args.cmd](args, ctx) - # `list` is a published contract and gets nothing added to it. - # Every other payload is Perry-internal, so the verdict rides along - # there rather than making an agent shell out to a second tool to - # learn its write was allowed under protest. - result["conformance"] = gate.as_dict() # A value a markdown table row cannot carry, translated into the ordinary # refusal channel **here, where `args` is in scope** — the twin in # `bin/perry-task § main` is in the same place for the same reason. The diff --git a/bin/perry-lint b/bin/perry-lint index a643354a..e49d58dd 100755 --- a/bin/perry-lint +++ b/bin/perry-lint @@ -4323,18 +4323,6 @@ def main(argv: list[str]) -> int: label += f" (state root: {root.relative_to(project_root).as_posix()}/)" if not adopted: label += " — not a Perry project yet" - # The declaration ADR-004 needs is a DIFFERENT fact from anything above: - # this loop answers "does the shape hold", and that answers "did the user - # say so". Reported as a note rather than as a Finding — a project that - # has simply not run `perry-conform declare` yet is not malformed, and - # promoting it to a warning would make `--strict` red on every project - # in existence for a reason lint cannot fix. - declared = P.read_conformance(project_root).declarations - conformance_note = { - "declared": len(declared), - "shape_version": schema.get("schema_version"), - "command": "perry-conform status", - } errors = [f for f in findings if f.severity == "error"] warns = [f for f in findings if f.severity == "warn"] @@ -4346,7 +4334,6 @@ def main(argv: list[str]) -> int: "findings": [f.as_dict() for f in findings], } if not mode_templates: - payload["conformance"] = conformance_note payload["store_drift"] = dict(getattr( check_store_drift, "stats", _empty_store_drift_stats())) payload["risk_store_drift"] = dict(getattr( @@ -4465,11 +4452,6 @@ def main(argv: list[str]) -> int: else: print(f" · {_label}: {_s['records']} record(s), " f"{_s['drifted']} row(s) drifted") - if not mode_templates: - n = conformance_note["declared"] - print(f" · {n} file(s) declared conformant at shape version " - f"{conformance_note['shape_version']} " - f"(`perry-conform status` for the per-file verdict)") if errors: return 1 diff --git a/bin/perry-migrate b/bin/perry-migrate index d4b001c8..c7ff4472 100755 --- a/bin/perry-migrate +++ b/bin/perry-migrate @@ -192,8 +192,8 @@ def _load(name: str, filename: str): return _MODULES[name] -def conform(): - return _load("perry_conform", "perry-conform") +def schema_helpers(): + return _load("perry_schema", "perry_schema.py") def _q(value) -> str: @@ -205,7 +205,7 @@ def _q(value) -> str: what `--root {root_arg}` was, so every interpolation inside a command this tool hands back goes through here and the sweep reports any that does not. """ - return conform()._q(value) + return schema_helpers()._q(value) def _root_flag(root_arg: str | None) -> str: @@ -216,7 +216,7 @@ def _root_flag(root_arg: str | None) -> str: every refusal in both tools depends on: a command handed back to a reader names the root that reader used, or it acts on a different project. """ - return conform()._root_flag(root_arg) + return schema_helpers()._root_flag(root_arg) def lint(): @@ -228,7 +228,7 @@ def lint(): this file its own unarmed globals, and the first symptom was the migration creating a `| 编号 | 标题 | …` header on a Chinese board and then reporting its own output as missing every column.""" - return conform().lint() + return schema_helpers().lint() def task(): @@ -1626,7 +1626,7 @@ def plan_project(project_root: Path, state_root: Path, schema: dict, were handing one back with the root dropped. A default of `None` here would let a new caller inherit that omission by saying nothing, which is exactly how `apply_plan`'s two green mutations happened one file over.""" - C = conform() + C = schema_helpers() preflight_file_objects(project_root, state_root, schema, only) plan = Plan(project_root=project_root, state_root=state_root, shape_version=C.shape_version(schema), root_arg=root_arg) @@ -1955,7 +1955,7 @@ def apply_plan(plan: Plan, schema: dict, declare: bool = True) -> dict: result = {"applied": [e.key for e in applied], "restore_point": str(point), "run": run_id, "declared": [], "refused": []} if declare: - C = conform() + C = schema_helpers() # Through `bin/perry-conform`, never beside it: ADR-004 § 4 makes the # declaration one fact with one record, and `declare()` re-checks each # file rather than trusting this tool's word for it. @@ -2261,7 +2261,7 @@ def main(argv: list[str]) -> int: i += 1 try: - schema = conform().load_schema() + schema = schema_helpers().load_schema() project_root, state_root = _roots(root_arg) if cmd == "restore": @@ -2327,7 +2327,7 @@ def perry_written_findings(project_root: Path, state_root: Path, """Errors in the files Perry writes for itself — the YAML-frontmatter ones.""" linter = Linter(schema, project_root) n = 0 - for key, path, spec in conform().state_files(project_root, state_root, schema): + for key, path, spec in schema_helpers().state_files(project_root, state_root, schema): if spec.get("format") != "yaml-frontmatter": continue n += len(linter.errors(path.read_text(errors="replace"), key, spec)) diff --git a/bin/perry-task b/bin/perry-task index 52595762..d54386e2 100755 --- a/bin/perry-task +++ b/bin/perry-task @@ -6893,42 +6893,22 @@ def perry_state(): return _PERRY_STATE -_PERRY_CONFORM = None - - -def perry_conform(): - """`bin/perry-conform` as a module — the conformance gate (ADR-004).""" - global _PERRY_CONFORM - if _PERRY_CONFORM is None: - import importlib.machinery - import importlib.util - sys.path.insert(0, str(PERRY_HOME / "bin")) - spec = importlib.util.spec_from_loader( - "perry_conform", importlib.machinery.SourceFileLoader( - "perry_conform", str(PERRY_HOME / "bin" / "perry-conform"))) - mod = importlib.util.module_from_spec(spec) - # Registered BEFORE exec: `perry-conform` declares dataclasses under - # `from __future__ import annotations`, and `dataclasses` resolves - # those string annotations through `sys.modules[cls.__module__]`. An - # unregistered module makes that lookup return None and the import dies - # inside the decorator, several frames from anything that names it. - sys.modules["perry_conform"] = mod - spec.loader.exec_module(mod) - _PERRY_CONFORM = mod - return _PERRY_CONFORM - - -#: The commands that only read. Everything else writes `BOARD.md` and is gated -#: on `BOARD.md` — on that file and on nothing else, because ADR-004 § 5 makes -#: conformance per-file so a project can migrate its board and not its risks. -#: `list` is a published contract (`schema/task-list-contract.md`) and reading -#: is never gated, so it is not merely excluded here — `tests/test_conformance` -#: asserts it still answers on an undeclared project with the gate enforcing. +#: The commands that only read. `list` is a published contract +#: (`schema/task-list-contract.md`); the rest write `BOARD.md`. +#: +#: **The ADR-004 conformance gate was removed here (TASK-261).** It read a +#: DECLARATION out of `.perry/conformance.jsonl` and refused a write when the +#: file no longer matched its declared shape. On this repository that ledger +#: held 23 records, every one `route: declare`, every one Perry's own file: +#: zero migrations and zero disagreements, because the gate's whole value is a +#: stored decision DISAGREEING with a live check and that needs a foreign +#: project which Perry has never been pointed at. `perry-decide` removed its +#: own gate first, for a different reason, and named the hole rather than +#: faking it; this is the same move with the measurement attached. READ_ONLY_COMMANDS = {"list", "events", "signoff-offer"} TASK_ROW_COMMANDS = {"start", "stage", "track", "done", "drop", "purge", "status", "depends", "next", "retitle", "summary", "rung", "evidence", "prioritize"} -GATED_FILE = "BOARD.md" def split_stages(cell: str) -> list[str]: @@ -7367,23 +7347,6 @@ def main(argv: list[str]) -> int: f"disagreement — or remove it to fall back to the file " f"deliberately.") config = {"tracks": tracks} - # ADR-004. Taken before the lock and before any command runs: a refusal - # must mean nothing was written, and a dry run must preview the refusal - # rather than the write it would not be allowed to perform. - gate = None - if args.cmd not in READ_ONLY_COMMANDS: - gate = perry_conform().gate(project_root, state_root, GATED_FILE, - tool="perry-task", root_arg=args.root) - if not gate.ok: - raise Refused(gate.message) - if gate.message and not args.as_json: - # `gate.mode`, not the literal "advisory": reaching this line - # means the gate found something AND let the write through, so - # the mode is the reason it was let through and is the one word - # the reader needs. Hardcoding it survived only because the - # shipped default made it true by construction (TASK-047). - print(f"perry-task: ⚠ conformance ({gate.mode}) — " - f"{gate.message}", file=sys.stderr) with project_lock(state_root): recovered = recover_transaction(state_root) records = load_task_records(state_root) @@ -7426,12 +7389,6 @@ def main(argv: list[str]) -> int: command_args = copy.copy(args) command_args.all = True result = COMMANDS[args.cmd](command_args, ctx) - # `list` is a published contract and gets nothing added to it. Every - # other subcommand's payload is Perry-internal, so the verdict rides - # along there — an agent reading `--json` should not have to shell out - # to a second tool to learn its write was allowed under protest. - if gate is not None and isinstance(result, dict): - result["conformance"] = gate.as_dict() # A value a markdown table row cannot carry. Translated into the ordinary # refusal channel here, at the boundary, rather than checked per # subcommand: the previous version of this rule lived inside `render_row` diff --git a/bin/perry_md_store.py b/bin/perry_md_store.py index 2c22bbc3..17f4b723 100644 --- a/bin/perry_md_store.py +++ b/bin/perry_md_store.py @@ -1037,7 +1037,6 @@ def main(doc: Doc, argv: list[str], _locked: bool = False) -> int: if "--write" not in argv: sys.stdout.write(rendered) return 0 - _gate_or_refuse(doc, root, state_root, tool, argv) lib.write_atomic(path, rendered) print(f"{tool}: rendered {path} from {len(records)} stored " f"record(s)") @@ -1138,61 +1137,14 @@ def main(doc: Doc, argv: list[str], _locked: bool = False) -> int: return 0 -def _gate_or_refuse(doc: Doc, root: Path, state_root: Path, tool: str, - argv: list[str]) -> None: - """ADR-004's conformance gate, on the one file about to be written. - - **`.perry/config.md` is gated through the same call as `OKR.md`, and that - is a small circularity worth naming:** `perry-conform § gate_mode` reads - `Conformance gate` out of `.perry/config.md` to decide the mode, so this - file's own contents decide whether this file may be rendered. It is benign - — the mode is read from the bytes on disk before the write, exactly as it - is for every other file — but it means a fixture that writes a - `.perry/config.md` is writing the file the gate consults about itself, and - `tests/gate.py § GATE_OFF` is the documented way out. - """ - conform = _conform_module() - if conform is None: - return - gate = conform.gate(root, state_root, str(doc.rel_file).replace("\\", "/"), - tool=tool, - root_arg=argv[argv.index("--root") + 1] - if "--root" in argv else None) - if not gate.ok: - raise Refused(gate.message) - if gate.message: - print(f"{tool}: ⚠ conformance ({gate.mode}) — {gate.message}", - file=sys.stderr) - - -_CONFORM = None - - -def _conform_module(): - """`bin/perry-conform` as a module — hyphenated, so not importable.""" - global _CONFORM - if _CONFORM is not None: - return _CONFORM - import importlib.machinery - import importlib.util - path = HERE / "perry-conform" - if not path.exists(): - return None - spec = importlib.util.spec_from_loader( - "perry_conform", importlib.machinery.SourceFileLoader( - "perry_conform", str(path))) - mod = importlib.util.module_from_spec(spec) - # **Registered BEFORE it is executed**, the same note `bin/perry-goals § - # _sibling` and `bin/perry-task` both carry: `dataclasses` resolves the - # annotation strings that `from __future__ import annotations` leaves - # behind by looking the class's own module up in `sys.modules`, and - # `perry-conform` decorates a dataclass at import time. Without this line - # the load dies inside `dataclasses._is_type` with an `AttributeError` on - # `None`, several frames away from anything that names the cause. - sys.modules["perry_conform"] = mod - spec.loader.exec_module(mod) - _CONFORM = mod - return mod +# **The ADR-004 conformance gate stood here and is gone (TASK-261).** +# `render --write` used to consult a stored DECLARATION about the file it was +# about to render and refuse when the shape no longer matched. On this +# repository that ledger held 23 records, all `route: declare`, all Perry's +# own files — zero disagreements, because a disagreement needs a foreign +# project and Perry has never been pointed at one. `tests/gate.py § GATE_OFF`, +# the documented way out of the self-referential case this function's own +# docstring described, goes with it. __all__ = ["CONFIG", "COMMANDS", "CONFIG_TITLE", "DOCS", "OKR", "Doc", diff --git a/bin/perry_schema.py b/bin/perry_schema.py new file mode 100644 index 00000000..0fa2594f --- /dev/null +++ b/bin/perry_schema.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""perry_schema — the state-file and schema helpers, and nothing else. + +**This file used to be `bin/perry-conform`, the ADR-004 conformance gate, and +that gate is gone (TASK-261).** What it did: `perry-conform declare` recorded +that the user had declared a file to be Perry's at shape version N, and +`gate()` — called from `perry-task`, `perry-goals` and `perry_md_store` before +every write — refused when the file's LIVE shape no longer matched its stored +DECLARATION. Keeping the declaration and the check apart was the whole design, +because "a stored decision plus a live check can disagree, and that +disagreement is a finding". + +It never disagreed. `.perry/conformance.jsonl` held 23 records at the end. +Every one was `route: declare`. Every one was a file in Perry's own +repository. Zero carried `route: migrate`. The disagreement the design exists +to surface needs a FOREIGN project that drifts, and Perry has never been +pointed at one — `TASK-097` ("migrate the two real projects, at V5") stayed +`not_started` from the day it was filed. A mechanism whose triggering +condition has never occurred is not a guard, and the three defects filed +against it (TASK-223, 246, 248) were work about itself. + +`perry-decide` removed its own gate first and named the hole rather than +faking it. This is the same move with the measurement attached. + +WHAT SURVIVED, and why the file survived with it: the gate was never the only +thing in here. `state_files()`, `load_schema()`, `spec_for()`, `shape_version()` +and the two CLI helpers are generic — they enumerate the state files a schema +declares and answer what shape it is at. `bin/perry-migrate` imports every one +of them and nothing about conformance. So the file is gutted and renamed +rather than deleted, and it now says what it is. +""" + +from __future__ import annotations +import difflib +import importlib.machinery +import importlib.util +import json +import os +import re +import shlex +import sys +from dataclasses import dataclass, field +from datetime import date +from pathlib import Path + +HERE = Path(__file__).resolve().parent + +PERRY_HOME = Path(os.environ.get("PERRY_HOME") or HERE.parent).resolve() +sys.path.insert(0, str(PERRY_HOME / "viewer")) +sys.path.insert(0, str(PERRY_HOME / "bin")) +import parsers as P # noqa: E402 +from tables import render_row # noqa: E402 +import lib # noqa: E402 + +_LINT = None + +def lint(): + """`bin/perry-lint` as a module, loaded once per process. + + This tool must NOT contain a second definition of Perry's shape. The one + definition is `schema/state-schema.json`, and the one implementation of + "does this file match it" is `perry-lint.check_file`. So the linter is + imported rather than imitated, the same way `bin/perry-task` imports + `bin/perry-state` rather than re-deriving its rules.""" + global _LINT + if _LINT is None: + sys.path.insert(0, str(PERRY_HOME / "bin")) + spec = importlib.util.spec_from_loader( + "perry_lint", importlib.machinery.SourceFileLoader( + "perry_lint", str(PERRY_HOME / "bin" / "perry-lint"))) + mod = importlib.util.module_from_spec(spec) + sys.modules.setdefault("perry_lint", mod) + spec.loader.exec_module(mod) + _LINT = mod + return _LINT + +class Refused(Exception): + """A refusal is a first-class outcome, not a crash. Nothing was written.""" + +def load_schema() -> dict: + """`lib.load_schema`, plus the one thing this tool needs done after it. + + The arming is not part of loading and is why this wrapper exists: without + it, a project whose board says `负责人` is reported as missing `Owner`.""" + schema = lib.load_schema(Refused) + lint().load_glossary(schema) + return schema + +def shape_version(schema: dict) -> int: + """Perry's shape version — `schema_version`, not a number of its own. + + Deliberately NOT a second counter. `schema/state-schema.json` *is* the + definition of Perry's shape; a `conformance_version` beside it would be one + rule with two numbers, and the first schema change that forgot to bump both + would make every marker a lie.""" + return int(schema.get("schema_version") or 0) + +def state_files(project_root: Path, state_root: Path, schema: dict) -> list[tuple[str, Path, dict]]: + """Every file the schema claims, as (key, absolute path, spec). + + The enumeration is `perry-lint.iter_targets` — the same globbing, the same + `exclude` handling — so "a file perry-lint validates" and "a file that can + be declared conformant" cannot come apart.""" + L = lint() + out: list[tuple[str, Path, dict]] = [] + for spec in schema["files"]: + base = project_root if spec.get("anchor") == "project" else state_root + for path in L.iter_targets(base, spec): + # **Two specs may share one glob.** `knowledge/*/*.md` is matched by + # both the digest and the knowledge-card entry, and a card is not a + # malformed digest. `spec_claims` is `perry-lint`'s one + # implementation of the declared `discriminator`; putting the call + # HERE rather than in each caller is what makes lint, conform and + # migrate agree — the first version wired it into `perry-lint` + # alone and `perry-migrate` immediately started writing the card's + # five fields into every digest, `Kind: —` included, on every real + # project. Caught by the suite. + if not L.spec_claims(spec, L.strip_comments( + path.read_text(errors="replace")), schema): + continue + out.append((path.relative_to(base).as_posix(), path, spec)) + return out + +def spec_for(project_root: Path, state_root: Path, schema: dict, + key: str) -> tuple[Path, dict] | None: + for k, path, spec in state_files(project_root, state_root, schema): + if k == key: + return path, spec + return None + +def _q(value) -> str: + """**One argument of a command this tool hands back, spelled so the reader + can copy it.** The single choke point for every such argument. + + `shlex.quote`, which is a no-op on a value with nothing shell-special in + it — so a project at `/home/ada/perry` is still handed + `--root /home/ada/perry`, and one at `/Users/ada/My Project` is handed + `--root ʼ/Users/ada/My Projectʼ` instead of a line that parses as two + arguments. + + **Why a named function and not `shlex.quote` inline.** Round 3 dropped the + root from a handed-back command; round 4 added it back UNQUOTED, in the + same sentence, because `_root_flag` was a convention and a convention is + enforced by whoever remembers it. This is the same convention — but + `tests/sweep_handed_back_commands.py` now reads every handed-back command + off the AST and reports any `{...}` inside one that is not `_q(...)`, + `_root_flag(...)`, `shlex.quote(...)` or the `r` those produce, and + `tests/test_conformance.py § test_no_refusal_names_a_command_without_the + _root` fails the suite on it. So the bypass spelling — `--root {root_arg}`, + `declare {v.path}` — is not discouraged, it is RED. That is the difference + between this shape and the one it replaces. + """ + return shlex.quote(str(value)) + +def _root_flag(root_arg: str | None) -> str: + return f" --root {_q(root_arg)}" if root_arg else "" + +def _roots(root_arg: str | None) -> tuple[Path, Path]: + project_root = (Path(root_arg).expanduser().resolve() if root_arg + else Path(os.environ.get("PERRY_PROJECT") or Path.cwd()).resolve()) + return project_root, P.resolve_state_root(project_root) diff --git a/perry/BOARD.md b/perry/BOARD.md index 5973b6d3..a9fc9f12 100644 --- a/perry/BOARD.md +++ b/perry/BOARD.md @@ -5,7 +5,7 @@ > Per-task spec / deliverable / audit: `evidence/2026-08/-*.md` (P0/P1 always have a `-spec.md`) > Auto-dispatch a task: `/pmo dispatch ` (requires spec.Dispatch mode = auto) > -> Last updated: 2026-08-30 +> Last updated: 2026-08-31 > Hard cap: ≤200 lines. If you're over, run `/pmo triage`. > > **Bootstrapped 2026-08-16** from the hand-off of DESIGN-001 and DESIGN-002, both @@ -123,6 +123,7 @@ | TASK-257 | The ignored-name bullet pin asserts a substring, not a bullet, and one satisfying string blinds the guard to BOARD.md | Coding Agent | not_started | — | — | V4 | | main | | | | | | | | TASK-258 | tests/test_tree_guard.py copies the LIVE repository, so any concurrent write reddens it | Coding Agent | not_started | — | — | V4 | | main | | | | | | | | TASK-259 | Nothing asserts the TASK-234 fixture root is shell-hostile, and 8 of 19 bypass spellings get past the source rule | Coding Agent | not_started | — | — | V4 | | main | | | | | | | +| TASK-261 | Tier A — the ADR-004 declaration gate comes out; perry-conform keeps only its shared helpers | Coding Agent | blocked | blocked on migration fork: perry-migrate's output is the deleted ledger | — | V4 | USER-910 | main | | | | | | | ## P2 @@ -171,6 +172,7 @@ | USER-907 | ADR-010 deletes BOARD.md, which makes P003-O2-KR3 unmeetable mid-phase — the KR is 'BOARD.md's two truth models are marked in the file' and TASK-199 is its only row. A boundary cannot be marked in a file that is gone. This needs your decision because dropping a KR changes what phase 003's Definition of Done MEANS, and the phase is live. THREE OPTIONS. (a) RESTATE the KR as something ADR-010 can satisfy — 'the render distinguishes what is projected from what is canonical' — which is the same reader-facing property the KR was actually buying, on a surface that will exist; TASK-199 is re-scoped rather than dropped. This is my recommendation: the KR was never really about the file, it was about a reader being able to tell truth from projection, and that need survives. (b) DROP the KR and TASK-199, recording that phase 003 closes with one KR withdrawn by a decision made DURING the phase. Honest, and it makes the phase score mean what it says. (c) Keep both and mark the boundary on a file scheduled for deletion — cheapest to do, hardest to defend. NOTE ON PROCESS: TASK-199 has been left not_started and untouched on purpose. Dropping the row is the visible half of dropping the KR, and doing the visible half first would make the record say the KR FAILED rather than that it was WITHDRAWN. The goals lane owns the edit either way; this ask is the decision, not the write. Full context: handoff/2026-08-29-goals-lane-after-design-013.md | TASK-199 | | answered 2026-08-29: 决定 2026-08-29(用户拍板,Perry 推荐 a):选 (a) 重述这条 KR。P003-O2-KR3 改成 ADR-010 能满足的东西 —— 「渲染能区分哪些是投影、哪些是 canonical」。理由:这条 KR 买的从来不是「文件里有标记」这个实现,而是「读者能分辨真相和投影」这个读者可见的属性,而那个需求在新表面上原样成立。TASK-199 因此重定范围而不是作废,phase 003 也不需要记录一条被撤回的 KR。写 KR 是 goals lane 的权,已在 handoff/2026-08-29-goals-lane-after-design-013.md 交接;这条 ask 是决定,不是那次写入。 | 2026-08-29 | | USER-908 | May Perry rewrite unpushed local history to repair commit 0d68034? MEASURED 2026-08-29: at that commit, every perry-task write on a project carrying .perry/config.jsonl dies with AttributeError: module 'perry_state' has no attribute 'defaulted_over_a_declaring_table' — bin/perry-task at :6773 calls a function that arrives one commit later. The TASK-213 commit also carries the bin/perry-task half of TASK-095 round 4. Its own message's suite claim is FALSE at that commit. The branch tip was whole and main is whole; only this one commit does not build. It is now in main's history via the 777d021 merge, so a git bisect across the 20 commits after it gets a false 'broken' verdict there. WHY THIS IS AN ASK: .perry/hook.md lists git history rewrites — push --force, --force-with-lease, rebase onto main, tag deletion — as high-stakes operations requiring your explicit authorization. I filed it rather than doing it, and a V4 reviewer endorsed that: 'That reasoning is correct and I endorse it — do not rewrite.' WHAT CHANGED SINCE: origin/main is at 45a355d and local main is at 91e5351, so all 27 commits including this one are UNPUSHED. Nobody has seen this history. That materially lowers the risk the hook rule is written for — the rule protects shared history, and this is not shared yet. OPTIONS. (a) LEAVE IT, and document the bad commit so a future bisect knows to skip it. Zero risk, permanent small cost, and the record keeps an honest scar. (b) REWRITE the unpushed history to move the perry-task hunk into the commit that owns it, then verify every commit in the range builds. My recommendation IF you want a clean history, because unpushed is the only moment this is cheap — after a push it becomes a shared-history rewrite and the answer should be (a) forever. (c) Leave the history and add a test that every commit on main builds standalone, so this class is caught at the next merge rather than by a person. Slower, and it does not fix this commit. My recommendation is (b) THEN (c) — repair it while it is still free, and add the guard so the next one is caught by a machine. But this is your call and I will not touch history without it. | — | | answered 2026-08-29: 决定 2026-08-29(用户拍板,Perry 推荐 b 然后 c):授权重写未推送的本地历史修复 0d68034,并加守卫。授权已给出。但执行顺序必须倒过来,理由是测量出来的,不是我改主意:重写 0d68034 会改掉它之后每一个 commit 的 SHA,包括 6c0d041 和 8abd30d —— 而那正是当前四个在飞分支的 merge base(coding/task-050-header-index、coding/task-203-round4、coding/task-095-round6 都在 6c0d041,coding/task-157-kr-declared-once 在 8abd30d),其中三个还在跑。现在重写会让它们的 merge base 消失,把已完成的工作推进一次不必要的 rebase,而这些行正是 phase 003 的 Must-Have。所以:(c) 现在做 —— 一行守卫,让下一次这类问题在合并时被机器抓住;(b) 在四个分支落地之后立刻做,那时 origin/main 仍然在 45a355d,未推送这个便宜窗口还开着。如果在那之前发生了 push,(b) 作废,答案永远变成 (a):那条 hook 规则保护的是共享历史,一旦共享就不该动。 | 2026-08-29 | | USER-909 | perry-decide REISSUES a retired ADR id, and perry-task does not — two tools, one contract, opposite answers. Measured by the TASK-235 agent 2026-08-29: delete ADR-011's file and the next mint hands out 011 again. perry-task purge retires an id through the append-only event log so it is never reissued; perry-decide writes NO events at all, so it has nothing to retire an id with. Worse, before TASK-235 the behaviour was NON-DETERMINISTIC: on main an unrelated write re-rendered the index and the next mint reissued anyway. THE STAKE: an ADR id is an address. ADR-007 is cited by name in ADR-010, in DESIGN-013, in three task rows and in this project's own commit messages. If 011 can name two different decisions, every one of those citations becomes ambiguous the moment an ADR is deleted and a new one minted. This did not matter while nobody deleted ADRs; TASK-235 is the row that made deletion ordinary. THE OPTIONS. (a) RECOMMENDED — perry-decide writes events like every other writer, and an id is retired the way perry-task retires one. This is the answer that makes the two tools agree rather than documenting why they differ, and DESIGN-004's whole posture is that every write goes through a tool and leaves a trace. Cost: perry-decide gains an event surface it does not have today. (b) Refuse the delete instead: an ADR file may not be removed, only superseded — which is arguably what a decision record SHOULD mean, since ADR-005 and ADR-007 are cited as history and history is not deleted. Cheaper, and it may be more correct in principle. (c) Accept reissue, and document that an ADR id is a slot rather than an address. Honest but it silently breaks every existing citation. (d) Leave it declared as it is now — the TASK-235 agent pinned the disagreement with a named test rather than resolving it silently, so nothing is hidden and the row can close. My recommendation is (b) THEN (a): stop the deletion that creates the problem, then give perry-decide the event surface so the two tools stop disagreeing on principle rather than by accident. But (b) changes what a decision record IS, which is yours and not mine. | — | | pending | 2026-08-29 | +| USER-910 | perry-migrate cannot survive Tier A — its output IS the deleted ledger (C.declare, 14 sites). A: delete migration too (0 records ever carried route:migrate; TASK-097 never started) — recommended. B: restore ~200 ledger lines for migrate alone, keep the write-path gate deleted, make TASK-097 the next phase. Full form: evidence/2026-08/2026-08-31-TASK-261-migration-fork.md | TASK-261 | | pending | 2026-08-31 | ## Done this period (leaves the board at next triage) diff --git a/perry/asks.jsonl b/perry/asks.jsonl index afe7a007..ba006ea1 100644 --- a/perry/asks.jsonl +++ b/perry/asks.jsonl @@ -11,3 +11,4 @@ {"id": "USER-907", "needed": "ADR-010 deletes BOARD.md, which makes P003-O2-KR3 unmeetable mid-phase — the KR is 'BOARD.md's two truth models are marked in the file' and TASK-199 is its only row. A boundary cannot be marked in a file that is gone. This needs your decision because dropping a KR changes what phase 003's Definition of Done MEANS, and the phase is live. THREE OPTIONS. (a) RESTATE the KR as something ADR-010 can satisfy — 'the render distinguishes what is projected from what is canonical' — which is the same reader-facing property the KR was actually buying, on a surface that will exist; TASK-199 is re-scoped rather than dropped. This is my recommendation: the KR was never really about the file, it was about a reader being able to tell truth from projection, and that need survives. (b) DROP the KR and TASK-199, recording that phase 003 closes with one KR withdrawn by a decision made DURING the phase. Honest, and it makes the phase score mean what it says. (c) Keep both and mark the boundary on a file scheduled for deletion — cheapest to do, hardest to defend. NOTE ON PROCESS: TASK-199 has been left not_started and untouched on purpose. Dropping the row is the visible half of dropping the KR, and doing the visible half first would make the record say the KR FAILED rather than that it was WITHDRAWN. The goals lane owns the edit either way; this ask is the decision, not the write. Full context: handoff/2026-08-29-goals-lane-after-design-013.md", "blocks": "TASK-199", "asked": "2026-08-29", "status": "answered 2026-08-29: 决定 2026-08-29(用户拍板,Perry 推荐 a):选 (a) 重述这条 KR。P003-O2-KR3 改成 ADR-010 能满足的东西 —— 「渲染能区分哪些是投影、哪些是 canonical」。理由:这条 KR 买的从来不是「文件里有标记」这个实现,而是「读者能分辨真相和投影」这个读者可见的属性,而那个需求在新表面上原样成立。TASK-199 因此重定范围而不是作废,phase 003 也不需要记录一条被撤回的 KR。写 KR 是 goals lane 的权,已在 handoff/2026-08-29-goals-lane-after-design-013.md 交接;这条 ask 是决定,不是那次写入。", "answered": true, "order": 10} {"id": "USER-908", "needed": "May Perry rewrite unpushed local history to repair commit 0d68034? MEASURED 2026-08-29: at that commit, every perry-task write on a project carrying .perry/config.jsonl dies with AttributeError: module 'perry_state' has no attribute 'defaulted_over_a_declaring_table' — bin/perry-task at :6773 calls a function that arrives one commit later. The TASK-213 commit also carries the bin/perry-task half of TASK-095 round 4. Its own message's suite claim is FALSE at that commit. The branch tip was whole and main is whole; only this one commit does not build. It is now in main's history via the 777d021 merge, so a git bisect across the 20 commits after it gets a false 'broken' verdict there. WHY THIS IS AN ASK: .perry/hook.md lists git history rewrites — push --force, --force-with-lease, rebase onto main, tag deletion — as high-stakes operations requiring your explicit authorization. I filed it rather than doing it, and a V4 reviewer endorsed that: 'That reasoning is correct and I endorse it — do not rewrite.' WHAT CHANGED SINCE: origin/main is at 45a355d and local main is at 91e5351, so all 27 commits including this one are UNPUSHED. Nobody has seen this history. That materially lowers the risk the hook rule is written for — the rule protects shared history, and this is not shared yet. OPTIONS. (a) LEAVE IT, and document the bad commit so a future bisect knows to skip it. Zero risk, permanent small cost, and the record keeps an honest scar. (b) REWRITE the unpushed history to move the perry-task hunk into the commit that owns it, then verify every commit in the range builds. My recommendation IF you want a clean history, because unpushed is the only moment this is cheap — after a push it becomes a shared-history rewrite and the answer should be (a) forever. (c) Leave the history and add a test that every commit on main builds standalone, so this class is caught at the next merge rather than by a person. Slower, and it does not fix this commit. My recommendation is (b) THEN (c) — repair it while it is still free, and add the guard so the next one is caught by a machine. But this is your call and I will not touch history without it.", "blocks": "—", "asked": "2026-08-29", "status": "answered 2026-08-29: 决定 2026-08-29(用户拍板,Perry 推荐 b 然后 c):授权重写未推送的本地历史修复 0d68034,并加守卫。授权已给出。但执行顺序必须倒过来,理由是测量出来的,不是我改主意:重写 0d68034 会改掉它之后每一个 commit 的 SHA,包括 6c0d041 和 8abd30d —— 而那正是当前四个在飞分支的 merge base(coding/task-050-header-index、coding/task-203-round4、coding/task-095-round6 都在 6c0d041,coding/task-157-kr-declared-once 在 8abd30d),其中三个还在跑。现在重写会让它们的 merge base 消失,把已完成的工作推进一次不必要的 rebase,而这些行正是 phase 003 的 Must-Have。所以:(c) 现在做 —— 一行守卫,让下一次这类问题在合并时被机器抓住;(b) 在四个分支落地之后立刻做,那时 origin/main 仍然在 45a355d,未推送这个便宜窗口还开着。如果在那之前发生了 push,(b) 作废,答案永远变成 (a):那条 hook 规则保护的是共享历史,一旦共享就不该动。", "answered": true, "order": 11} {"id": "USER-909", "needed": "perry-decide REISSUES a retired ADR id, and perry-task does not — two tools, one contract, opposite answers. Measured by the TASK-235 agent 2026-08-29: delete ADR-011's file and the next mint hands out 011 again. perry-task purge retires an id through the append-only event log so it is never reissued; perry-decide writes NO events at all, so it has nothing to retire an id with. Worse, before TASK-235 the behaviour was NON-DETERMINISTIC: on main an unrelated write re-rendered the index and the next mint reissued anyway. THE STAKE: an ADR id is an address. ADR-007 is cited by name in ADR-010, in DESIGN-013, in three task rows and in this project's own commit messages. If 011 can name two different decisions, every one of those citations becomes ambiguous the moment an ADR is deleted and a new one minted. This did not matter while nobody deleted ADRs; TASK-235 is the row that made deletion ordinary. THE OPTIONS. (a) RECOMMENDED — perry-decide writes events like every other writer, and an id is retired the way perry-task retires one. This is the answer that makes the two tools agree rather than documenting why they differ, and DESIGN-004's whole posture is that every write goes through a tool and leaves a trace. Cost: perry-decide gains an event surface it does not have today. (b) Refuse the delete instead: an ADR file may not be removed, only superseded — which is arguably what a decision record SHOULD mean, since ADR-005 and ADR-007 are cited as history and history is not deleted. Cheaper, and it may be more correct in principle. (c) Accept reissue, and document that an ADR id is a slot rather than an address. Honest but it silently breaks every existing citation. (d) Leave it declared as it is now — the TASK-235 agent pinned the disagreement with a named test rather than resolving it silently, so nothing is hidden and the row can close. My recommendation is (b) THEN (a): stop the deletion that creates the problem, then give perry-decide the event surface so the two tools stop disagreeing on principle rather than by accident. But (b) changes what a decision record IS, which is yours and not mine.", "blocks": "—", "asked": "2026-08-29", "status": "pending", "answered": false, "order": 12} +{"id": "USER-910", "needed": "perry-migrate cannot survive Tier A — its output IS the deleted ledger (C.declare, 14 sites). A: delete migration too (0 records ever carried route:migrate; TASK-097 never started) — recommended. B: restore ~200 ledger lines for migrate alone, keep the write-path gate deleted, make TASK-097 the next phase. Full form: evidence/2026-08/2026-08-31-TASK-261-migration-fork.md", "blocks": "TASK-261", "asked": "2026-08-31", "status": "pending", "answered": false, "order": 13} diff --git a/perry/evidence/2026-08/2026-08-31-TASK-261-migration-fork.md b/perry/evidence/2026-08/2026-08-31-TASK-261-migration-fork.md new file mode 100644 index 00000000..fba6ce64 --- /dev/null +++ b/perry/evidence/2026-08/2026-08-31-TASK-261-migration-fork.md @@ -0,0 +1,58 @@ +# The fork Tier A ran into: `perry-migrate` cannot survive it + +> Escalation for `TASK-261`, written the way `work/reference/review.md § 6` +> asks — both readings defensible applied consistently, what is already true, +> and a named recommendation with its reason. + +## What is already true + +The ADR-004 gate and its ledger are out. 40 files, **−5,245 lines**. The suite +is green except `test_migrate` (22 failures, 9 errors) and the three modules +that are already red on a clean `git archive HEAD` export. + +`bin/perry-migrate` calls `C.declare` at **14 sites**. Its *output* is a +conformance record carrying `route: migrate`. It cannot be repaired without +restoring the ledger, because the ledger is the thing it writes. Migration and +conformance were never two subsystems — the delete list called them Tier A and +Tier C, and that was wrong. + +## Reading A — migration is speculation; delete it + +`.perry/conformance.jsonl` held 23 records and **not one carried +`route: migrate`**. `TASK-097` — "migrate the two real projects, at V5" — has +been `not_started` since the day it was filed. A 2,393-line lossless, +dry-runnable, recoverable migrator has never moved a single foreign project. + +Applied consistently: Perry is a tool for projects it starts. Adoption means +"run Perry here and let it write its own state". `TASK-097` is dropped along +with `tests/test_migrate.py` (2,900 lines). Tier C's migration half lands now +rather than later, and Tier A's total goes past 10,000 lines. + +## Reading B — migration is the unbuilt half; keep it + +Perry has never been pointed at a foreign project because that work was never +done, not because it is unwanted. The delete list's own note says the gate's +value *needs* a foreign project — which is an argument that the missing thing +is the project, not the mechanism. + +Applied consistently: the ledger comes back for `perry-migrate` alone — +`declare`, `migrate_record`, `record_diff`, roughly 200 of the 598 deleted +lines. The **write-path gate stays deleted**. `TASK-097` becomes the next real +phase. This keeps the option and pays ~200 lines plus `test_migrate` for it. + +## Recommendation: A + +Not for the line count. Under B the ledger comes back to serve a consumer that +has never run — so the same measurement that justified deleting it will justify +deleting it again in three months, and the second deletion will cost what this +one cost. + +If you want Perry to run on other people's projects, the cheap version is an +**importer you re-run**: read a foreign board, write Perry state, overwrite on +conflict. That is a smaller thing to build than what Reading B keeps, and it +has no declaration format of its own to drift. + +## Not on the table either way + +The write-path conformance gate does not come back. Nothing in this fork +reopens it. diff --git a/perry/journal/2026-08/2026-08-31.md b/perry/journal/2026-08/2026-08-31.md new file mode 100644 index 00000000..a667449d --- /dev/null +++ b/perry/journal/2026-08/2026-08-31.md @@ -0,0 +1,34 @@ +# 2026-08-31 + +## Status changes + +- [TASK-260] — → not_started · V4 criteria must be bounded, and the round stops auditing its own exhibit · owner: Coding Agent · priority: P1 +- [TASK-260] not_started → done · closed · evidence: `a4eb411; evidence/2026-08/2026-08-31-representation-layer-delete-list.md` · verification: V3 +- [TASK-261] — → not_started · Tier A — the ADR-004 declaration gate comes out; perry-conform keeps only its shared helpers · owner: Coding Agent · priority: P1 +- [TASK-261] not_started → in_progress · started +- [USER-910] — → pending · perry-migrate cannot survive Tier A — its output IS the deleted ledger (C.declare, 14 sites). A: delete migration too (0 records ever carried route:migrate; TASK-097 never started) — recommended. B: restore ~200 ledger lines for migrate alone, keep the write-path gate deleted, make TASK-097 the next phase. Full form: evidence/2026-08/2026-08-31-TASK-261-migration-fork.md · blocks: TASK-261 +- [TASK-261] in_progress → blocked · migration fork: perry-migrate's output is the deleted ledger + +## New tasks added + +### TASK-260 — V4 criteria must be bounded, and the round stops auditing its own exhibit + +- **Owner**: Coding Agent +- **Priority**: P1 +- **Track / mode**: main / project +- **Deliverable**: review.md § 1 `### The criteria must be bounded` + § 2 `### What V4 does not judge`; perry-lint --reviews gains `citation-not-on-branch` and `criteria-unbounded`, scoped to open rows, and --strict exits non-zero so a red exhibit can stop a dispatch +- **Verification**: 22 new tests in tests/test_review_verdicts.py; 10 mutations each reddening a named test, md5-verified restores; the real repository's --reviews run goes 136 findings to 29 +- **Dependencies**: — +- **Out of scope**: — +- **KR linkage**: unlinked + +### TASK-261 — Tier A — the ADR-004 declaration gate comes out; perry-conform keeps only its shared helpers + +- **Owner**: Coding Agent +- **Priority**: P1 +- **Track / mode**: main / project +- **Deliverable**: gate/gate_mode/GateResult/message_for + the two call sites (perry-task:7375, perry-goals:3251); declare/verdict/Verdict/shape_version/shape_errors/record_diff/migrate_record/render_legacy and the status|check|migrate subcommands; .perry/conformance.jsonl and .perry/conformance.md; tests/test_conformance.py and tests/mutate_task_234.py. KEEPS load_schema/state_files/spec_for/_q/_root_flag/lint, which perry-migrate, perry_md_store, perry-goals and perry-task all import. +- **Verification**: tests/run shows no red module beyond the three already red on a clean git archive HEAD; perry-task and perry-goals write paths still work end to end on a copy of this board; the conformance.* payload of `perry-task list --json` is byte-identical, since that is a published contract and a different thing wearing the same word +- **Dependencies**: — +- **Out of scope**: — +- **KR linkage**: unlinked diff --git a/perry/tasks.jsonl b/perry/tasks.jsonl index a2a0b507..82fc4aaf 100644 --- a/perry/tasks.jsonl +++ b/perry/tasks.jsonl @@ -251,3 +251,5 @@ {"id": "TASK-249", "title": "bash tests/run WRITES PERRY STATE INTO THE REPOSITORY IT RUNS IN — four files, via an intake-sweep discharging a real board row", "summary": "ROUND 4 FIXES IN at f8bc100, five commits. BLOCKER CLOSED and pinned: the bullet is in the 'What it does NOT catch' list carrying scope re-derived with controls — a directory appearing mid-run is invisible INCLUDING ITS OWN CREATION; with .claude/ already present at snapshot it is not in the manifest at all, so rewriting a file in it and creating another gives compare() == []; and the match is on the NAME AT ANY DEPTH, so perry/evidence/.claude/ and perry/.gstack/ are invisible while the same writes into .claudex/ are reported. The pin is the good part: test_every_ignored_name_is_a_bullet_in_the_list_of_what_is_missed is red when the bullet is deleted AND red when a fifth ignored directory is added WITH THE EQUALITY PIN MOVED WITH IT — which the equality pin alone would not catch. THE FIVE ITEMS: (1) it reproduced all three green mutations plus both bullet rewrites ON THE UNFIXED TIP FIRST, confirming the pin was green in all five, then took BOTH halves — the claim narrowed (class renamed to say it checks the bullet's VOCABULARY against the token spelled in tests/run, docstring states the measured gap) and the pin widened to catch both export spellings, with the refuse token anchored to a non-comment line. The dead-refusal-under-'if false' case stays uncatchable by string search and is recorded as such rather than papered over. (2) IndexError replaced by two FAILs with sentences, verified with both banners reworded. (3) case-differing spellings now accepted, comparison is 'test -ef' on device+inode. (4) relative paths REFUSED with their own banner and reason — decided, not incidental; the 17-spelling sweep re-run shows four changed, all in the intended direction, nothing became accept-everything. (5) 24 and 18 removed, grep returns nothing. TWENTY mutations, four green, all reported. MC1 is the most useful: reverting -ef to round 2's string comparison kills exactly one test and it is the new one — round 3's blindness finding, one layer out. THREE OF ITS OWN FIXES WERE GREEN UNDER THEIR FIRST MUTATION and were tightened: the relative-refusal assertion matched the word 'relative' in an explanatory paragraph rather than in the banner, setUp's terminator, and the doc pin would have passed on three empty sets. Five suites measured, 4/3 on every one including both board states of a moving main; the final tip differs from the probed merge only by the result document, and re-merging is clean.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "perry/evidence/2026-08/TASK-249-round4-v4-review.md", "next_action": "Startable. Start from TASK-050 round 11's result, which carries the controlled experiment, and from the TASK-241 merge, where the stray event surfaced. The idempotence is the reason this survived: the first run in a fresh clone moves four files, and every run after it looks clean, so the natural way to check — run it twice and diff — reports nothing. Restore the four files first, then run once.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-30T05:52:25+08:00", "order": null} {"id": "TASK-234", "title": ".perry/conformance.md is a pure ledger with a hand-rolled table parser, and its phantom row has nowhere to record provenance", "summary": "ROUND 5 REVIEW: PASS, merged. The reviewer went looking for the THIRD layer — rounds 3 and 4 having failed on the same sentence one register deeper each time — using eight distinct refusal surfaces driven on planted hostile-root projects, every command extracted by the SHIPPED extractor and pasted into a real /bin/sh. It is not there: all parse, all carry the exact typed root, including the full round trip, a state file named 'My Notes & draft.md', a RELATIVE --root, and perry-migrate's actual restore putting two files back. Newline, which the row called unmeasured, is milder than claimed — _q quotes it correctly and the two-line block pastes and runs rc 0. TWO MUTATIONS TURNED THE ROW'S ARGUMENT INTO A MEASUREMENT: R5-16, a friendly fixture root PLUS round 4's defect put back, drops 24 red methods to 2, and both survivors are the source rule and the backtick test — so 'a choke point is a convention' is now an experiment rather than an argument. R5-15, _q double-quoting with escapes, leaves shlex.split reading the right root so all 16 helper invocations and the source guard stay GREEN while /bin/sh expands and the end-to-end proof goes red — that is exactly the shell-layer-only mutation the RESULT said it could not construct, which makes the /bin/sh paste load-bearing rather than decorative. 57/57 of the row's own harness reproduced independently plus 16 of the reviewer's, restored from git show and never from its own bytes. ONE SURVIVOR: R5-11, the sweep's phrase boundary (TAIL excluding the backtick) has no positive control. Suite main 105/3148/4, tip 103/3150/4, probe 105/3200/4, ZERO errors throughout, test_host_support absent from all three.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "perry/evidence/2026-08/TASK-234-round5-v4-review.md", "next_action": "Blocked until TASK-050 lands: converting this reader removes one of the markdown tables TASK-050's header_index() has to cover, so doing it first means TASK-050 converts a site that is about to be deleted. TWO THINGS TO SETTLE BEFORE WRITING CODE, both real. (1) BOOTSTRAP ORDER: this file gates every write under ADR-004's enforce gate, including the write that migrates it — the migration path must not require the gate to be passable mid-migration. (2) SELF-REFERENCE: schema/state-schema.json:2053 already states, deliberately, that .perry/conformance.md is NOT a files[] entry because 'it is a record of the user's decisions ABOUT state, not state, and listing it here would make it declarable-conformant about itself'. That reasoning carries over to the jsonl unchanged and must be moved across EXPLICITLY, not dropped in the format change. (3) NOTE FOR THE GOALS LANE, not this row's to write: P003-O1-KR1, KR2 and KR3 are all phrased 'of 6' over the six stores in claims[]. A seventh claimed store moves that denominator. Whether conformance.jsonl joins claims[] at all is the same question as (2).", "depends_on": ["TASK-050"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-29T13:43:22+08:00", "order": null} {"id": "TASK-259", "title": "Nothing asserts the TASK-234 fixture root is shell-hostile, and 8 of 19 bypass spellings get past the source rule", "summary": "Filed 2026-08-30 from the TASK-234 round-5 review. Item (b) is the interesting one: the row's defence is a choke point PLUS a source rule, and the source rule is the half that makes the choke point more than a convention — so its recall is the property the whole shape rests on. It is 11 of 19 today.", "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-30T16:44:44+08:00", "order": 48} +{"id": "TASK-260", "title": "V4 criteria must be bounded, and the round stops auditing its own exhibit", "summary": "TASK-050 ran 11 rounds against a universal negative and PASSed on the round the criterion became decidable. Measured: 22 of 49 finding headlines audit the round's own artifact, not the product.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "a4eb411; evidence/2026-08/2026-08-31-representation-layer-delete-list.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-31T20:27:36+08:00", "order": null} +{"id": "TASK-261", "title": "Tier A — the ADR-004 declaration gate comes out; perry-conform keeps only its shared helpers", "summary": "23 records, all route: declare, all Perry's own files, zero migrations and zero disagreements. The gate's value needs a foreign project that drifts, and Perry has never been run on one. The delete list said 'delete bin/perry-conform, 974 lines'; that was wrong — 598 lines are the dead ledger and ~280 are helpers four tools depend on, so the file is gutted, not removed.", "owner": "Coding Agent", "status": "blocked", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "blocked on migration fork: perry-migrate's output is the deleted ledger", "depends_on": ["USER-910"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-31T20:27:48+08:00", "order": 49} diff --git a/tests/fixtures/live-state-expectations.json b/tests/fixtures/live-state-expectations.json index d3a90497..07f65d45 100644 --- a/tests/fixtures/live-state-expectations.json +++ b/tests/fixtures/live-state-expectations.json @@ -3,7 +3,7 @@ "findings": [ { "module": "tests/test_config_store_readers.py", - "lineno": 170, + "lineno": 183, "test": "TestParseConfigReadsTheStore.test_every_setting_comes_from_the_store_when_both_are_there", "assertion": "assertEqual", "actual": "cfg['language']", @@ -13,7 +13,7 @@ }, { "module": "tests/test_config_store_readers.py", - "lineno": 171, + "lineno": 184, "test": "TestParseConfigReadsTheStore.test_every_setting_comes_from_the_store_when_both_are_there", "assertion": "assertEqual", "actual": "cfg['chat_language']", @@ -23,7 +23,7 @@ }, { "module": "tests/test_config_store_readers.py", - "lineno": 172, + "lineno": 185, "test": "TestParseConfigReadsTheStore.test_every_setting_comes_from_the_store_when_both_are_there", "assertion": "assertEqual", "actual": "cfg['layout']", @@ -33,7 +33,7 @@ }, { "module": "tests/test_config_store_readers.py", - "lineno": 173, + "lineno": 186, "test": "TestParseConfigReadsTheStore.test_every_setting_comes_from_the_store_when_both_are_there", "assertion": "assertEqual", "actual": "cfg['state_root']", @@ -43,7 +43,7 @@ }, { "module": "tests/test_config_store_readers.py", - "lineno": 174, + "lineno": 187, "test": "TestParseConfigReadsTheStore.test_every_setting_comes_from_the_store_when_both_are_there", "assertion": "assertEqual", "actual": "cfg['pmo_repo']", @@ -53,7 +53,7 @@ }, { "module": "tests/test_config_store_readers.py", - "lineno": 184, + "lineno": 197, "test": "TestParseConfigReadsTheStore.test_a_stored_blank_comes_back_as_the_marker_not_as_empty", "assertion": "assertEqual", "actual": "self.cfg(self.project())['code_repo']", @@ -63,7 +63,7 @@ }, { "module": "tests/test_config_store_readers.py", - "lineno": 191, + "lineno": 204, "test": "TestParseConfigReadsTheStore.test_every_setting_still_resolves_with_no_markdown_at_all", "assertion": "assertEqual", "actual": "[cfg['language'], cfg['chat_language'], cfg['layout'], cfg['state_root'], cfg['pmo_repo'], cfg[\u2026", @@ -73,7 +73,7 @@ }, { "module": "tests/test_config_store_readers.py", - "lineno": 203, + "lineno": 216, "test": "TestParseConfigReadsTheStore.test_the_source_says_which_register_answered", "assertion": "assertEqual", "actual": "self.cfg(self.project())['settings_source']", @@ -83,7 +83,7 @@ }, { "module": "tests/test_config_store_readers.py", - "lineno": 204, + "lineno": 217, "test": "TestParseConfigReadsTheStore.test_the_source_says_which_register_answered", "assertion": "assertEqual", "actual": "self.cfg(self.project(store=False))['settings_source']", @@ -93,7 +93,7 @@ }, { "module": "tests/test_config_store_readers.py", - "lineno": 215, + "lineno": 228, "test": "TestParseConfigReadsTheStore.test_a_project_with_no_store_still_reads_its_markdown", "assertion": "assertEqual", "actual": "cfg['language']", @@ -103,7 +103,7 @@ }, { "module": "tests/test_config_store_readers.py", - "lineno": 216, + "lineno": 229, "test": "TestParseConfigReadsTheStore.test_a_project_with_no_store_still_reads_its_markdown", "assertion": "assertEqual", "actual": "cfg['state_root']", @@ -113,7 +113,7 @@ }, { "module": "tests/test_config_store_readers.py", - "lineno": 235, + "lineno": 248, "test": "TestParseConfigReadsTheStore.test_an_unusable_store_answers_from_the_markdown_and_says_so", "assertion": "assertEqual", "actual": "cfg['language']", @@ -123,57 +123,7 @@ }, { "module": "tests/test_config_store_readers.py", - "lineno": 251, - "test": "TestTheGateReadsTheStore.test_the_store_wins_over_the_markdown", - "assertion": "assertEqual", - "actual": "PC.gate_mode(self.project())", - "expected": "'enforce'", - "verdict": "false positive", - "why": "the expected literal is a value THIS test wrote into its own throwaway project moments earlier \u2014 `tests/test_config_store_readers.py \u00a7 Fixture.project` mints a tempdir, writes a `.perry/config.jsonl` out of `STORE_SETTINGS` and a `.perry/config.md` out of `MD_SAYS`, and the assertion is that the reader answered from the first and not the second. Nothing about this repository is read. The sweep flags it because `PS` and `PC` are bound at module level by `load_bin_module`, which reads `bin/perry-state` and `bin/perry-conform` out of the repo \u2014 so every value those modules return is tainted live, including one computed entirely from a fixture. Arrived 2026-08-30 with TASK-233." - }, - { - "module": "tests/test_config_store_readers.py", - "lineno": 267, - "test": "TestTheGateReadsTheStore.test_the_store_wins_in_the_other_direction_too", - "assertion": "assertEqual", - "actual": "PC.gate_mode(d)", - "expected": "'advisory'", - "verdict": "false positive", - "why": "the expected literal is a value THIS test wrote into its own throwaway project moments earlier \u2014 `tests/test_config_store_readers.py \u00a7 Fixture.project` mints a tempdir, writes a `.perry/config.jsonl` out of `STORE_SETTINGS` and a `.perry/config.md` out of `MD_SAYS`, and the assertion is that the reader answered from the first and not the second. Nothing about this repository is read. The sweep flags it because `PS` and `PC` are bound at module level by `load_bin_module`, which reads `bin/perry-state` and `bin/perry-conform` out of the repo \u2014 so every value those modules return is tainted live, including one computed entirely from a fixture. Arrived 2026-08-30 with TASK-233." - }, - { - "module": "tests/test_config_store_readers.py", - "lineno": 278, - "test": "TestTheGateReadsTheStore.test_the_declared_gate_survives_the_markdown_being_deleted", - "assertion": "assertEqual", - "actual": "PC.gate_mode(d)", - "expected": "'advisory'", - "verdict": "false positive", - "why": "the expected literal is a value THIS test wrote into its own throwaway project moments earlier \u2014 `tests/test_config_store_readers.py \u00a7 Fixture.project` mints a tempdir, writes a `.perry/config.jsonl` out of `STORE_SETTINGS` and a `.perry/config.md` out of `MD_SAYS`, and the assertion is that the reader answered from the first and not the second. Nothing about this repository is read. The sweep flags it because `PS` and `PC` are bound at module level by `load_bin_module`, which reads `bin/perry-state` and `bin/perry-conform` out of the repo \u2014 so every value those modules return is tainted live, including one computed entirely from a fixture. Arrived 2026-08-30 with TASK-233." - }, - { - "module": "tests/test_config_store_readers.py", - "lineno": 284, - "test": "TestTheGateReadsTheStore.test_a_project_with_no_store_still_reads_its_markdown", - "assertion": "assertEqual", - "actual": "PC.gate_mode(self.project(store=False))", - "expected": "'advisory'", - "verdict": "false positive", - "why": "the expected literal is a value THIS test wrote into its own throwaway project moments earlier \u2014 `tests/test_config_store_readers.py \u00a7 Fixture.project` mints a tempdir, writes a `.perry/config.jsonl` out of `STORE_SETTINGS` and a `.perry/config.md` out of `MD_SAYS`, and the assertion is that the reader answered from the first and not the second. Nothing about this repository is read. The sweep flags it because `PS` and `PC` are bound at module level by `load_bin_module`, which reads `bin/perry-state` and `bin/perry-conform` out of the repo \u2014 so every value those modules return is tainted live, including one computed entirely from a fixture. Arrived 2026-08-30 with TASK-233." - }, - { - "module": "tests/test_config_store_readers.py", - "lineno": 306, - "test": "TestTheGateReadsTheStore.test_the_environment_still_beats_both", - "assertion": "assertEqual", - "actual": "PC.gate_mode(d)", - "expected": "'advisory'", - "verdict": "false positive", - "why": "the expected literal is a value THIS test wrote into its own throwaway project moments earlier \u2014 `tests/test_config_store_readers.py \u00a7 Fixture.project` mints a tempdir, writes a `.perry/config.jsonl` out of `STORE_SETTINGS` and a `.perry/config.md` out of `MD_SAYS`, and the assertion is that the reader answered from the first and not the second. Nothing about this repository is read. The sweep flags it because `PS` and `PC` are bound at module level by `load_bin_module`, which reads `bin/perry-state` and `bin/perry-conform` out of the repo \u2014 so every value those modules return is tainted live, including one computed entirely from a fixture. Arrived 2026-08-30 with TASK-233." - }, - { - "module": "tests/test_config_store_readers.py", - "lineno": 508, + "lineno": 664, "test": "TestTheScaffoldIsCheckedNotTrusted.test_a_scaffold_that_drops_a_record_refuses", "assertion": "assertEqual", "actual": "rc", @@ -183,7 +133,7 @@ }, { "module": "tests/test_config_store_readers.py", - "lineno": 526, + "lineno": 682, "test": "TestTheScaffoldIsCheckedNotTrusted.test_a_scaffold_whose_bytes_do_not_round_trip_refuses", "assertion": "assertEqual", "actual": "rc", @@ -203,7 +153,7 @@ }, { "module": "tests/test_md_store.py", - "lineno": 413, + "lineno": 464, "test": "TestAMutatedStoreMovesTheFile.test_an_okr_kr_field", "assertion": "assertEqual", "actual": "drift[0]['store']", @@ -213,7 +163,7 @@ }, { "module": "tests/test_md_store.py", - "lineno": 427, + "lineno": 478, "test": "TestAMutatedStoreMovesTheFile.test_a_config_setting", "assertion": "assertEqual", "actual": "[d['key'] for d in drift]", @@ -223,7 +173,7 @@ }, { "module": "tests/test_md_store.py", - "lineno": 508, + "lineno": 559, "test": "TestARepairedLineCarriesNoWhitespaceTheInputDidNotHave.test_a_config_setting_slot_ends_without_a_trailing_space", "assertion": "assertEqual", "actual": "line", diff --git a/tests/gate.py b/tests/gate.py deleted file mode 100644 index a5a222c3..00000000 --- a/tests/gate.py +++ /dev/null @@ -1,96 +0,0 @@ -"""The conformance gate's opt-out line, for fixtures that are not about it. - -TASK-047 flipped `bin/perry-conform.DEFAULT_MODE` from `advisory` to `enforce`, -so a writer now REFUSES a state file nobody has declared. That is the shipped -behaviour and `tests/test_conformance.py § 7` is where it is asserted. - -Every other suite in here builds a throwaway project and then tests something -that has nothing to do with ADR-004 — how a row is rendered, whether a widening -loses a cell, what `--dry-run` prints. Those fixtures are undeclared, because -nobody declared them, so after the flip every one of their writes was refused -and fifteen modules went red at once. The refusals were correct; the fixtures -were simply answering a question they were not asked. - -**Why the opt-out and not a declaration.** Declaring would be the more faithful -fixture — a real adopted project IS declared, and Perry's own repo is 13/16 — -and for a clean fixture it would work. It cannot be the general answer here: -a large share of these fixtures are *deliberately malformed*, which is the whole -point of the suite that owns them (`test_prioritize` widens a board with 4 shape -errors; `test_row_integrity` corrupts rows on purpose). `perry-conform declare` -correctly refuses a file that does not match Perry's shape, so those fixtures -cannot be declared by construction. One control has to cover both kinds, and -`- Conformance gate: advisory` is the one the user has for exactly this reason. - -This is NOT a way to keep the suite from meeting the gate. It is a per-fixture -statement that a given project is out of scope for ADR-004, written in the same -documented, user-facing control a real project would use. The gate itself — -both branches, both precedence paths, both exemptions — is exercised in -`tests/test_conformance.py`, and `tests/test_work_modes.py` still declares a -`.perry/config.md` for real rather than opting out. - -Usage — append it to whatever the fixture already writes, as long as what the -fixture already writes is only a PREAMBLE: - - (root / ".perry" / "config.md").write_text( - "# Perry configuration\\n\\n- State root: .\\n" + GATE_OFF) - -**Appending to a config that already has `##` sections does not work, and used -to.** `gate_mode` scanned the whole file with a regex, so a `Conformance gate` -line anywhere in it was found. It reads `.perry/config.jsonl` first now -(TASK-233), and `perry_md_store § scan_config` stores only settings written -**above the first `##`** — deliberately, because a real config's prose sections -are full of bullets carrying a colon that are sentences and not keys. So an -appended line lands outside the preamble, mints no record, and the store then -answers "this project declares no gate" — correctly, about a file that declares -it in a place the format does not read. Use `gate_off(text)` below, which puts -the line where the preamble is, and `gate_off_record` for a fixture that -hand-builds its store instead of deriving one with `perry-config write ---from-file`. -""" - -from __future__ import annotations - -import json - -#: A `.perry/config.md` line. Must stay parseable by -#: `bin/perry-conform.gate_mode`'s `Conformance gate` matcher — if that -#: matcher's spelling ever changes, every fixture using this goes red at once, -#: which is the intended blast radius for a config key silently renamed. -GATE_OFF = "- Conformance gate: advisory\n" - - -def gate_off(config_md: str) -> str: - """`config_md` with the opt-out line inside its preamble. - - For fixtures that build on a config which already carries `## Tracks` or - prose. Appending would put the line where `scan_config` does not look; this - puts it on the last line before the first `##`, which is where a user - writing the documented shape puts it (`reference/config.md`). - """ - lines = config_md.split("\n") - cut = next((i for i, ln in enumerate(lines) if ln.startswith("##")), - len(lines)) - # Back over the blank line that separates the preamble from the heading, so - # the inserted bullet joins the bullets rather than the heading. - while cut > 0 and not lines[cut - 1].strip(): - cut -= 1 - return "\n".join(lines[:cut] + [GATE_OFF.rstrip("\n")] + lines[cut:]) - - -def gate_off_record(order: int = 90) -> str: - """The same opt-out as one `.perry/config.jsonl` line, newline included. - - A fixture that writes a hand-built store rather than deriving one has to - say this in the store too: `gate_mode` reads the store first, and a store - that carries no `conformance_gate` record is a project that declares no - gate — which is the right answer about a store that really does not carry - it, and the wrong fixture for a test that is not about ADR-004. - - `order` defaults high so the record sorts after whatever settings the - fixture's own preamble declares; nothing here reads it except the - projection's line ordering. - """ - return json.dumps({ - "kind": "setting", "key": "conformance_gate", - "label": "Conformance gate", "value": "advisory", "order": order, - }, ensure_ascii=False) + "\n" diff --git a/tests/mutate_task_234.py b/tests/mutate_task_234.py deleted file mode 100644 index 1c2c7511..00000000 --- a/tests/mutate_task_234.py +++ /dev/null @@ -1,567 +0,0 @@ -#!/usr/bin/env python3 -"""TASK-234's mutation harness — is every new guard load-bearing? - -Uniquely named so it cannot collide with another round's harness in the same -tree. Run from the repository root: - - python3 tests/mutate_task_234.py - -Each mutation: - - - anchors on the **exact text** of one line, resolves that line at run time - and asserts the anchor is UNIQUE in the file — a mutation applied to the - wrong line, or to two lines, measures nothing; - - clears every `__pycache__` and sleeps to a whole-second boundary before and - after, because `bin/lib/__pycache__` is real on this project and a stale - `.pyc` from the same second is how a mutation "passes"; - - restores the file by `md5` and asserts the digest matches what was read; - - asserts GREEN first. A mutation that reddens an already-red suite has - measured nothing. - -The harness REFUSES a dirty tree: it rewrites shipped files in place, and a -crash mid-run must not be indistinguishable from someone's uncommitted work. -""" - -from __future__ import annotations - -import hashlib -import os -import shutil -import subprocess -import sys -import time -from pathlib import Path - -ROOT = Path(__file__).resolve().parent.parent - -#: (id, file, exact anchor text, replacement, the test that must go red) -MUTATIONS = [ - # ── viewer/parsers.py § _declaration_from ───────────────────────────── - ("M1", "viewer/parsers.py", - ' if not isinstance(version, int) or isinstance(version, bool):', - ' if False:', - "tests.test_conformance.TestTheRecordIsAStore" - ".test_a_line_that_is_not_a_declaration_is_reported_not_skipped"), - - ("M2", "viewer/parsers.py", - ' if rec.get("kind") != CONFORMANCE_KIND:', - ' if False:', - "tests.test_conformance.TestTheRecordIsAStore" - ".test_a_line_that_is_not_a_declaration_is_reported_not_skipped"), - - ("M3", "viewer/parsers.py", - ' if not isinstance(rec, dict):', - ' if False:', - "tests.test_conformance.TestTheRecordIsAStore" - ".test_a_line_that_is_not_a_declaration_is_reported_not_skipped"), - - # ── viewer/parsers.py § read_conformance ────────────────────────────── - ("M4", "viewer/parsers.py", - ' if decl is None or decl.path in rec.declarations:', - ' if decl is None:', - "tests.test_conformance.TestTheRecordIsAStore" - ".test_two_lines_for_one_path_are_unreadable_rather_than_last_one_wins"), - - ("M5", "viewer/parsers.py", - ' rec.unreadable.append((i, line.strip()))\n continue\n' - ' rec.declarations[decl.path] = decl', - ' continue\n' - ' rec.declarations[decl.path] = decl', - "tests.test_conformance.TestTheRecordIsAStore" - ".test_a_malformed_line_does_not_void_its_neighbours"), - - ("M6", "viewer/parsers.py", - ' if not line.strip():', - ' if False:', - "tests.test_conformance.TestTheRecordIsAStore" - ".test_a_blank_line_is_layout_and_not_a_finding"), - - # **The fallback that was deliberately NOT written.** If a later hand - # reintroduces "read the markdown when there is no store", the project has - # two live registers again and TASK-248's hole is back. - ("M7", "viewer/parsers.py", - ' if legacy.exists():\n rec.legacy = legacy\n return rec', - ' if legacy.exists():\n' - ' return read_legacy_conformance(project_root)\n return rec', - "tests.test_conformance.TestTheMarkdownRecordIsConvertedOnce" - ".test_the_markdown_alone_declares_nothing"), - - ("M8", "viewer/parsers.py", - ' if legacy.exists():\n rec.stray_legacy = legacy', - ' if False:\n rec.stray_legacy = legacy', - "tests.test_conformance.TestTheMarkdownRecordIsConvertedOnce" - ".test_a_markdown_beside_a_store_is_reported_and_not_read"), - - # ── bin/perry-conform § migrate_record ──────────────────────────────── - ("M9", "bin/perry-conform", - ' if canonical != text:', - ' if False:', - "tests.test_conformance.TestADecoratedRowIsNotADeclaration" - ".test_a_canonical_row_inside_an_html_block_is_not_carried_across"), - - ("M10", "bin/perry-conform", - ' if record.unreadable:', - ' if False:', - "tests.test_conformance.TestTheMarkdownRecordIsConvertedOnce" - ".test_an_unreadable_row_is_refused_rather_than_deleted_at_the_door"), - - ("M11", "bin/perry-conform", - ' if store.exists() or not legacy.exists():', - ' if not legacy.exists():', - "tests.test_conformance.TestTheMarkdownRecordIsConvertedOnce" - ".test_a_stale_markdown_never_overwrites_a_store"), - - ("M12", "bin/perry-conform", - ' legacy.unlink()', - ' pass', - "tests.test_conformance.TestTheMarkdownRecordIsConvertedOnce" - ".test_the_conversion_carries_every_date_and_route_unchanged"), - - # ── bin/perry-conform § declare ─────────────────────────────────────── - ("M13", "bin/perry-conform", - ' converted = (migrate_record(project_root, root_arg=root_arg)\n' - ' if not dry_run else None)', - ' converted = None', - "tests.test_conformance.TestTheMarkdownRecordIsConvertedOnce" - ".test_declaring_converts_first_and_says_so"), - - ("M14", "bin/perry-conform", - ' writer=writer, recorded_at=stamped_at, run=run)', - ' writer="", recorded_at="", run="")', - "tests.test_conformance.TestTheRecordIsAStore" - ".test_a_declaration_records_who_wrote_it_and_when"), - - # ── bin/perry-conform § message_for ─────────────────────────────────── - ("M15", "bin/perry-conform", - ' if v.legacy_record:', - ' if False:', - "tests.test_conformance.TestTheMarkdownRecordIsConvertedOnce" - ".test_the_refusal_names_migrate_and_not_declare"), - - # ── bin/perry-migrate — the run id on a migrated declaration ────────── - ("M16", "bin/perry-migrate", - ' writer="perry-migrate apply", run=run_id)', - ' writer="perry-migrate apply", run="")', - "tests.test_migrate.TestTheUserDeclares" - ".test_the_declaration_goes_through_perry_conform_and_is_the_only_record"), - - ("M17", "bin/perry-migrate", - ' files[P.CONFORMANCE_LEGACY_FILE] = (file_image(legacy.read_bytes())\n' - ' if legacy.exists() else absent_image())', - ' pass', - "tests.test_migrate.TestRecoverable" - ".test_restore_also_withdraws_the_declarations_the_run_wrote"), - - ("M18", "bin/perry-migrate", - ' preflight_file_object(\n' - ' plan.project_root,\n' - ' plan.project_root / P.CONFORMANCE_LEGACY_FILE,\n' - ' P.CONFORMANCE_LEGACY_FILE,\n' - ' )', - ' pass', - "tests.test_migrate.TestFileImageFidelity" - ".test_a_symlinked_markdown_record_is_refused_before_state_writes"), - - ("M21", "bin/perry-migrate", - ' except (OSError, Refused, C.Refused, ValueError) as exc:', - ' except (OSError, Refused, ValueError) as exc:', - "tests.test_migrate.TestTheUserDeclares" - ".test_an_unconvertible_markdown_record_refuses_and_names_the_way_back"), - - # ── the V4 FAIL: the refusal has to name the line ───────────────────── - - ("M22", "bin/perry-conform", - ' + record_diff(text, canonical)', - ' + " perry-conform status"', - "tests.test_conformance.TestTheRefusalNamesTheLine" - ".test_the_refusal_carries_a_diff_and_not_a_command_that_computes_none"), - - ("M23", "bin/perry-conform", - ' dropped = max(0, len(lines) - DIFF_CAP)', - ' dropped = len(lines) - DIFF_CAP', - "tests.test_conformance.TestTheDefensiveBranchesAreLoadBearing" - ".test_a_short_diff_does_not_claim_it_dropped_a_negative_number"), - - ("M24", "bin/perry-conform", - ' shown.append(f" … and {dropped} more diff line(s); the whole file "', - ' shown.append(f" … and 0 more diff line(s); the whole file "', - "tests.test_conformance.TestTheRefusalNamesTheLine" - ".test_a_wholly_rewritten_record_is_capped_and_says_how_much_it_dropped"), - - # ── the six defensive branches that survived their own deletion ─────── - - ("M25", "viewer/parsers.py", - ' if not isinstance(path, str) or not path.strip():\n return None', - ' if False:\n return None', - "tests.test_conformance.TestTheDefensiveBranchesAreLoadBearing" - ".test_a_non_string_path_is_refused_rather_than_used_as_a_key"), - - ("M26", "viewer/parsers.py", - ' if not isinstance(declared, str) or not isinstance(route, str):\n return None', - ' if False:\n return None', - "tests.test_conformance.TestTheDefensiveBranchesAreLoadBearing" - ".test_a_non_string_declared_or_route_is_refused"), - - ("M27", "viewer/parsers.py", - ' route=route or "declare", line=number,', - ' route=route, line=number,', - "tests.test_conformance.TestTheDefensiveBranchesAreLoadBearing" - ".test_an_empty_route_reads_as_declare_rather_than_as_blank"), - - ("M28", "viewer/parsers.py", - ' text = lambda key: (rec.get(key) if isinstance(rec.get(key), str) else "")', - ' text = lambda key: rec.get(key) or ""', - "tests.test_conformance.TestTheDefensiveBranchesAreLoadBearing" - ".test_non_string_provenance_reads_as_empty_rather_than_as_itself"), - - ("M29", "viewer/parsers.py", - ' try:\n text = path.read_text(encoding="utf-8", errors="replace")\n except OSError:\n return rec', - ' text = path.read_text(encoding="utf-8", errors="replace")', - "tests.test_conformance.TestTheDefensiveBranchesAreLoadBearing" - ".test_a_record_that_exists_but_cannot_be_read_is_not_a_crash"), - - # ── round 4: a refusal names the command with the ROOT THE CALLER USED - # - # `message_for` propagates it through `_root_flag()`; `migrate_record`'s - # two refusals did not, and the command they handed back exits 0 with - # "nothing to convert" about a different project. Each of these mutates the - # SOURCE so one requirement has to fire on its own. - - ("M30", "bin/perry-conform", - ' f" perry-conform migrate{r}\\n"\n' - ' f"**Nothing was written.**")', - ' f" perry-conform migrate\\n"\n' - ' f"**Nothing was written.**")', - "tests.test_conformance.TestTheCommandTheRefusalNamesIsTheOneTheReaderCanRun" - ".test_the_named_command_converts_the_readers_project_from_elsewhere"), - - ("M31", "bin/perry-conform", - ' f" perry-conform migrate{r}\\n"\n' - ' f"A row that is documentation', - ' f" perry-conform migrate\\n"\n' - ' f"A row that is documentation', - "tests.test_conformance.TestTheCommandTheRefusalNamesIsTheOneTheReaderCanRun" - ".test_the_unreadable_rows_refusal_names_it_too"), - - # The runtime value, not the template — the source guard cannot see this - # one, and the 16 helper invocations are what catch it. - ("M32", "bin/perry-conform", - " r = _root_flag(root_arg)\n root = Path(project_root)", - " r = _root_flag(None)\n root = Path(project_root)", - "tests.test_conformance.TestADecoratedRowIsNotADeclaration" - ".test_a_backticked_path_cell_is_not_a_declaration"), - - ("M33", "bin/perry-conform", - " converted = (migrate_record(project_root, root_arg=root_arg)", - " converted = (migrate_record(project_root, root_arg=None)", - "tests.test_conformance.TestTheCommandTheRefusalNamesIsTheOneTheReaderCanRun" - ".test_the_declare_route_into_the_conversion_carries_the_root_too"), - - # **A root that is not the caller's.** Every assertion that reads the - # message is still satisfiable by eye; only RUNNING the command notices. - ("M34", "bin/perry-conform", - ' f" perry-conform migrate{r}\\n"\n' - ' f"**Nothing was written.**")', - ' f" perry-conform migrate --root /nowhere-at-all\\n"\n' - ' f"**Nothing was written.**")', - "tests.test_conformance.TestTheCommandTheRefusalNamesIsTheOneTheReaderCanRun" - ".test_the_named_command_converts_the_readers_project_from_elsewhere"), - - # **The one member of the class no test held**, found by this mutation - # coming back GREEN in round 4 before the assertion below was written. - ("M35", "bin/perry-migrate", - " root_arg=root_arg,", - " root_arg=None,", - "tests.test_migrate.TestTheUserDeclares" - ".test_an_unconvertible_markdown_record_refuses_and_names_the_way_back"), - - # **`rollback_message` is only reached on a FAILED run**, so the test that - # holds it is the one that makes a run fail — not the one that reads a - # successful apply, which gets its line from `render`. M36 came back GREEN - # pointed at the latter, which is the finding: two surfaces name this - # command and they are not the same code. - ("M36", "bin/perry-migrate", - ' cmd = f"perry-migrate restore {_q(point.stem)}{_root_flag(root_arg)}"', - ' cmd = f"perry-migrate restore {_q(point.stem)}"', - "tests.test_migrate.TestTheUserDeclares" - ".test_an_unconvertible_markdown_record_refuses_and_names_the_way_back"), - - ("M37", "bin/perry-migrate", - ' print(f"\\n perry-migrate restore ' - '{_root_flag(root_arg)}\\n")', - ' print(f"\\n perry-migrate restore \\n")', - "tests.test_migrate.TestRecoverable" - ".test_every_way_back_this_tool_names_carries_the_root"), - - # The SOURCE guard, pinned separately from the end-to-end proof: the two - # catch different things. M32 (a wrong runtime value) is invisible to the - # guard; M34 (a wrong root, spelled) is invisible to every string - # assertion. Only running the command catches that one. - ("M40", "bin/perry-conform", - ' f" perry-conform migrate{r}\\n"\n' - ' f"**Nothing was written.**")', - ' f" perry-conform migrate\\n"\n' - ' f"**Nothing was written.**")', - "tests.test_conformance.TestTheCommandTheRefusalNamesIsTheOneTheReaderCanRun" - ".test_no_refusal_in_perry_conform_names_a_command_without_the_root"), - - # ── round 4: the CRLF guard reaches `bin/README.md` and the reworded - # overclaim, both of which the V4 round-3 reviewer measured it missing. - - ("M38", "bin/README.md", - "It refuses rather than convert a file that is not line-for-line\n" - "what `perry-conform declare` would have written", - "It refuses rather than convert a file that is not byte-for-byte\n" - "what `perry-conform declare` would have written", - "tests.test_conformance.TestTheRefusalNamesTheLine" - ".test_a_crlf_record_converts_and_the_wording_does_not_say_byte"), - - ("M39", "bin/README.md", - "(Line-for-line, not byte-for-byte: the comparison applies Python's\n" - "universal-newline translation, so a record saved with CRLF converts.)", - "(A record saved with CRLF converts.)", - "tests.test_conformance.TestTheRefusalNamesTheLine" - ".test_a_crlf_record_converts_and_the_wording_does_not_say_byte"), - - # ── round 5: the argument is quoted, and the rule that says so ─────── - # - # The round-4 V4 FAIL: the root was carried and interpolated RAW, so on a - # project at `.../My Project` the handed-back command exits 1 with a usage - # error about a file argument the reader never typed. Three layers again, - # and this time the layers are measured rather than argued (§ 6.1). - - ("M41", "bin/perry-conform", - ' return shlex.quote(str(value))', - ' return str(value)', - "tests.test_conformance" - ".TestTheCommandTheRefusalNamesIsTheOneTheReaderCanRun" - ".test_the_named_command_converts_the_readers_project_from_elsewhere"), - - # The shipped defect, put back at the choke point. Invisible to the - # end-to-end proof's `shlex.split` step? No — but it IS invisible to any - # rule that reads only command phrases, because these two lines name no - # tool. `FLAG_VALUE` is the rule that reaches them. - ("M42", "bin/perry-conform", - ' return f" --root {_q(root_arg)}" if root_arg else ""', - ' return f" --root {root_arg}" if root_arg else ""', - "tests.test_conformance" - ".TestTheCommandTheRefusalNamesIsTheOneTheReaderCanRun" - ".test_no_refusal_in_perry_conform_names_a_command_without_the_root"), - - # Not the root: any other argument. `perry-conform check 'My Notes.md'`. - ("M43", "bin/perry-conform", - ' f" perry-conform declare {_q(v.path)}{r}\\n"\n' - ' f"which refuses if the file does not match the current shape"', - ' f" perry-conform declare {v.path}{r}\\n"\n' - ' f"which refuses if the file does not match the current shape"', - "tests.test_conformance" - ".TestTheCommandTheRefusalNamesIsTheOneTheReaderCanRun" - ".test_no_refusal_in_perry_conform_names_a_command_without_the_root"), - - # The DRIFTED branch's parenthetical, glued back onto the command line — - # `syntax error near unexpected token ʼ(ʼ`, rc=2, measured. - ("M44", "bin/perry-conform", - ' + (f"\\n{tail.lstrip()}" if tail else ""))', - ' + f"{tail}")', - "tests.test_conformance" - ".TestTheCommandTheRefusalNamesIsTheOneTheReaderCanRun" - ".test_no_refusal_in_perry_conform_names_a_command_without_the_root"), - - # ── round 5: the sweep's own rulings, which had no positive control ── - # - # R-N8: neutering `ROOT` to match everything left the suite GREEN. The - # non-vacuity check guarded against the sweep finding NOTHING, never - # against it calling EVERYTHING ok. - - ("M45", "tests/sweep_handed_back_commands.py", - 'ROOT = re.compile(r"\\{r\\}|\\{_root_flag\\([^)]*\\)\\}|--root")', - 'ROOT = re.compile(r"")', - "tests.test_conformance.TestTheSweepIsMeasuredNotTrusted" - ".test_the_sweep_reports_every_planted_defect_it_claims_to_see"), - - ("M46", "tests/sweep_handed_back_commands.py", - ' r"^(?:r|_q\\(.*\\)|_root_flag\\(.*\\)|(?:shlex\\.)?quote\\(.*\\))$")', - ' r"")', - "tests.test_conformance.TestTheSweepIsMeasuredNotTrusted" - ".test_the_sweep_reports_every_planted_defect_it_claims_to_see"), - - ("M47", "tests/sweep_handed_back_commands.py", - ' r"^\\s*perry-(?:" + "|".join(TOOLS) + r")(?:[ ]" + _ARG + r")+\\s*$")', - ' r"(?!x)x")', - "tests.test_conformance.TestTheSweepIsMeasuredNotTrusted" - ".test_the_recall_the_result_quotes_is_the_recall_measured_here"), - - ("M48", "tests/sweep_handed_back_commands.py", - 'FLAG_VALUE = re.compile(r"--[a-z][a-z-]+[= ]\\{([^{}]*)\\}")', - 'FLAG_VALUE = re.compile(r"(?!x)x")', - "tests.test_conformance.TestTheSweepIsMeasuredNotTrusted" - ".test_the_recall_the_result_quotes_is_the_recall_measured_here"), - - # ── round 5: the three sites that were green because nothing reached - # them (the V4 round-4 reviewer's R-N3, R-N4 and R-N13). - - ("M49", "bin/perry-migrate", - ' raise Refused(rollback_message(point, e.key, exc,\n' - ' root_arg=root_arg)) from None', - ' raise Refused(rollback_message(point, e.key, exc,\n' - ' root_arg=None)) from None', - "tests.test_migrate.TestAFailedWriteIsRecoverableAndSaysSo" - ".test_a_failing_write_rolls_back_and_names_the_restore_command"), - - ("M50", "bin/perry-migrate", - ' allow_changed=allow_changed, root_arg=root_arg))', - ' allow_changed=allow_changed, root_arg=None))', - "tests.test_migrate.TestAFailedWriteIsRecoverableAndSaysSo" - ".test_a_write_that_lands_wrong_names_the_way_back_with_the_root"), - - ("M51", "bin/perry-migrate", - ' update_expected_after(\n' - ' point, P.CONFORMANCE_LEGACY_FILE,\n' - ' plan.project_root / P.CONFORMANCE_LEGACY_FILE)', - ' pass', - "tests.test_migrate.TestTheUserDeclares" - ".test_a_run_that_converted_a_legacy_record_can_be_restored"), - - # ── round 5: the two members `§ 10.9` excused, now threaded ────────── - - ("M52", "bin/perry-migrate", - ' f"`perry-tasks render --write{r}` if the store is "', - ' f"`perry-tasks render --write` if the store is "', - "tests.test_conformance" - ".TestTheCommandTheRefusalNamesIsTheOneTheReaderCanRun" - ".test_no_refusal_in_perry_conform_names_a_command_without_the_root"), - - ("M53", "bin/perry-migrate", - ' f"--migrate{_root_flag(root_arg)}`, which moves "', - ' f"--migrate`, which moves "', - "tests.test_conformance" - ".TestTheCommandTheRefusalNamesIsTheOneTheReaderCanRun" - ".test_no_refusal_in_perry_conform_names_a_command_without_the_root"), - - # ── round 5: the shape, which no behavioural test can hold ─────────── - - ("M54", "bin/perry-migrate", - ' root_arg: str | None\n edits: list[Edit]', - ' root_arg: str | None = None\n edits: list[Edit]', - "tests.test_migrate.TestTheRootIsRequiredNotDefaulted" - ".test_the_plan_carries_the_root_and_cannot_be_built_without_one"), - - ("M55", "bin/perry-conform", - ' *, root_arg: str | None) -> dict:', - ' *, root_arg: str | None = None) -> dict:', - "tests.test_conformance.TestTheRootIsRequiredNotDefaulted" - ".test_perry_conforms_two_entry_points_require_the_root"), - - # ── round 5: the extractor and the source sweep are one rule ───────── - # - # They disagreed: `CUE` accepted two spaces of indentation and - # `commands_named` required four, so `do_restore`'s listing was a - # handed-back command to one and invisible to the other. - ("M56", "tests/handed_back.py", - '_INDENTED = re.compile(r"^[ ]{2,}(perry-[a-z][a-z-]*(?:[ ][^\\n]*)?)$",', - '_INDENTED = re.compile(r"^[ ]{4,}(perry-[a-z][a-z-]*(?:[ ][^\\n]*)?)$",', - "tests.test_migrate.TestRecoverable" - ".test_every_way_back_this_tool_names_carries_the_root"), - - # The widened overclaim guard: an EVADING spelling, which round 4's regex - # let through. Five of the nine the V4 round-4 reviewer put to it did. - ("M57", "bin/README.md", - "It refuses rather than convert a file that is not line-for-line\n" - "what `perry-conform declare` would have written", - "It refuses rather than convert a file that is not bytewise\n" - "against what `perry-conform declare` would have written", - "tests.test_conformance.TestTheRefusalNamesTheLine" - ".test_a_crlf_record_converts_and_the_wording_does_not_say_byte"), - - # ── tests/test_one_header_rule.py — the vacuity guard ───────────────── - ("M19", "viewer/parsers.py", - ' if header_index([rel]).column("file", "path") == 0 or not rel:', - ' if False:', - "tests.test_one_header_rule.TestTheFifthCopy" - ".test_a_bolded_header_is_not_reported_as_a_broken_row"), - - ("M20", "viewer/parsers.py", - ' if canonical != line:', - ' if False:', - "tests.test_conformance.TestADecoratedRowIsNotADeclaration" - ".test_a_backticked_path_cell_is_not_a_declaration"), -] - - -def clear_pycache() -> None: - for cache in ROOT.rglob("__pycache__"): - shutil.rmtree(cache, ignore_errors=True) - - -def whole_second() -> None: - """Sleep to the next whole second. - - `.pyc` staleness is decided on a one-second mtime granularity on this - platform (`knowledge/toolchain/pycache-staleness.md`), so a rewrite inside - the same second as the last import can be read from a cache that predates - it — which shows up as a mutation that changes nothing. - """ - time.sleep(1.0 - (time.time() % 1.0) + 0.05) - - -def run(target: str) -> tuple[int, str]: - r = subprocess.run([sys.executable, "-m", "unittest", target], - cwd=ROOT, capture_output=True, text=True, - env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}) - return r.returncode, (r.stderr or "") + (r.stdout or "") - - -def main() -> int: - dirty = subprocess.run(["git", "status", "--porcelain"], cwd=ROOT, - capture_output=True, text=True).stdout.strip() - if dirty: - print("REFUSED: the tree is dirty. This harness rewrites shipped files " - "in place, and a crash mid-run must not be mistaken for " - "somebody's uncommitted work.\n" + dirty) - return 2 - - bad = 0 - for mid, rel, anchor, replacement, test in MUTATIONS: - path = ROOT / rel - before = path.read_text() - digest = hashlib.md5(before.encode()).hexdigest() - if before.count(anchor) != 1: - print(f" ✗ {mid}: anchor appears {before.count(anchor)} times in " - f"{rel} — it must be unique or the mutation lands somewhere " - f"else") - bad += 1 - continue - line_no = before[:before.index(anchor)].count("\n") + 1 - - clear_pycache(); whole_second() - rc, _ = run(test) - if rc != 0: - print(f" ✗ {mid}: {test} is ALREADY RED — a mutation that reddens " - f"a red test measures nothing") - bad += 1 - continue - - path.write_text(before.replace(anchor, replacement)) - clear_pycache(); whole_second() - try: - rc, out = run(test) - finally: - path.write_text(before) - got = hashlib.md5(path.read_text().encode()).hexdigest() - assert got == digest, f"{mid}: {rel} was not restored" - clear_pycache(); whole_second() - - if rc == 0: - print(f" ✗ {mid} {rel}:{line_no} GREEN under mutation — " - f"{test} does not hold this guard") - bad += 1 - else: - print(f" ✓ {mid} {rel}:{line_no} red: {test.rsplit('.', 1)[-1]}") - - print(f"\n{len(MUTATIONS) - bad}/{len(MUTATIONS)} mutations reddened their " - f"named test.") - return 1 if bad else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/test_cadence.py b/tests/test_cadence.py index 94852ec2..9d941b80 100644 --- a/tests/test_cadence.py +++ b/tests/test_cadence.py @@ -27,7 +27,6 @@ from datetime import date, timedelta from pathlib import Path -from gate import GATE_OFF # tests/gate.py — why this fixture opts out PERRY_HOME = Path(__file__).resolve().parent.parent TOOL = PERRY_HOME / "bin" / "perry-task" @@ -91,7 +90,7 @@ def __init__(self, board: str = BOARD): (self.root / ".perry").mkdir() (self.root / ".perry" / "config.md").write_text( "# Perry configuration\n\n- Document language: English\n" - "- Repo layout: single\n- State root: .\n" + GATE_OFF) + "- Repo layout: single\n- State root: .\n") (self.root / "BOARD.md").write_text(board) def run(self, *argv): diff --git a/tests/test_config_store_readers.py b/tests/test_config_store_readers.py index dc8f8245..60ea1ba1 100644 --- a/tests/test_config_store_readers.py +++ b/tests/test_config_store_readers.py @@ -64,7 +64,6 @@ def load_bin_module(name: str): PS = load_bin_module("perry-state") -PC = load_bin_module("perry-conform") #: What the MARKDOWN says. Every value here is one the store contradicts, so @@ -249,80 +248,6 @@ def test_an_unusable_store_answers_from_the_markdown_and_says_so(self): self.assertEqual(cfg["language"], "Klingon") -class TestTheGateReadsTheStore(Fixture): - """`bin/perry-conform § gate_mode`, the second reader the spec names. - - The markdown in every fixture here says `advisory` and the store says - `enforce`, so `enforce` can only have come from the store. That direction - is on purpose: a fixture where the store said `advisory` would pass against - a reader that lost the setting entirely, because `advisory` is also what a - reader answering nothing at all would eventually... not produce — the - shipped default is `enforce`. Either direction alone is ambiguous with one - of the two failure modes, so `TestTheGateReadsTheStore` asserts BOTH. - """ - - def test_the_store_wins_over_the_markdown(self): - self.assertEqual(PC.gate_mode(self.project()), "enforce") - - def test_the_store_wins_in_the_other_direction_too(self): - """`advisory` out of the store, over an `enforce` markdown. - - Without this case the class above is satisfied by a reader that dropped - the setting on the floor, since the shipped default is `enforce`. - """ - settings = [dict(r) for r in STORE_SETTINGS] - for rec in settings: - if rec["key"] == "conformance_gate": - rec["value"] = "advisory" - d = self.project( - markdown=MD_SAYS.replace("- Conformance gate: advisory", - "- Conformance gate: enforce"), - store=store_text(settings + STORE_TRACKS)) - self.assertEqual(PC.gate_mode(d), "advisory") - - def test_the_declared_gate_survives_the_markdown_being_deleted(self): - """V4 step 1: *"`perry-conform` still reports the declared gate rather - than the default"*.""" - settings = [dict(r) for r in STORE_SETTINGS] - for rec in settings: - if rec["key"] == "conformance_gate": - rec["value"] = "advisory" - d = self.project(markdown=None, - store=store_text(settings + STORE_TRACKS)) - self.assertEqual(PC.gate_mode(d), "advisory") - self.assertNotEqual(PC.gate_mode(d), PC.DEFAULT_MODE, - "the fixture no longer distinguishes the declared " - "gate from the shipped default") - - def test_a_project_with_no_store_still_reads_its_markdown(self): - self.assertEqual(PC.gate_mode(self.project(store=False)), "advisory") - - def test_a_usable_store_with_no_gate_record_declares_nothing(self): - """Not a fallback to the markdown — an answer. - - The store is derived from the preamble, so a key it does not carry is a - line the file does not have. Falling through here would reintroduce the - two-registers problem on the one setting that decides whether every - other write is allowed. - """ - settings = [r for r in STORE_SETTINGS - if r["key"] != "conformance_gate"] - d = self.project(store=store_text(settings + STORE_TRACKS)) - self.assertEqual(PC.gate_mode(d), PC.DEFAULT_MODE) - - def test_the_environment_still_beats_both(self): - """Most specific wins, and the env var is still the most specific.""" - import os - d = self.project() - old = os.environ.get("PERRY_CONFORMANCE") - os.environ["PERRY_CONFORMANCE"] = "advisory" - try: - self.assertEqual(PC.gate_mode(d), "advisory") - finally: - if old is None: - os.environ.pop("PERRY_CONFORMANCE", None) - else: - os.environ["PERRY_CONFORMANCE"] = old class TestTheStateRootReadsTheStore(Fixture): diff --git a/tests/test_conformance.py b/tests/test_conformance.py deleted file mode 100644 index b6d4e249..00000000 --- a/tests/test_conformance.py +++ /dev/null @@ -1,2881 +0,0 @@ -"""TASK-043's gate: the declared, checkable conformance marker (ADR-004). - -The claim under test: **Perry can tell "this file matches my shape, at shape -version N, and the user said so" apart from "this folder has a BOARD.md in -it"** — and every writer asks the first question about the one file it is -about to write, while every reader asks neither. - -Two things this suite is deliberately built to catch, because they are the -failure modes the task's own rubric names: - -- a gate that cannot fire — and, since TASK-047 flipped the shipped default to - `enforce`, a gate that cannot be turned off. Both branches are exercised - explicitly: § 7 asserts the refusal under the shipped default AND the write - proceeding under `advisory`, reached both by `PERRY_CONFORMANCE` and by - `.perry/config.md`. A guard that only ever runs in one mode is not a guard. -- a second definition of Perry's shape. `TestOneDefinitionOfTheShape` compares - `perry-conform`'s per-file error counts against `perry-lint`'s own findings, - file by file, on `tests/fixtures/witness-project` — a project in the - repository, read through `--root`, whose findings are constructed rather - than captured. They agree because there is one implementation; if someone - writes a second one, this goes red. - -Run: python3 -m unittest discover -s tests (or ./tests/run) -""" - -from __future__ import annotations - -import importlib.machinery -import importlib.util -import json -import os -import re -import inspect -import shlex -import shutil -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - -PERRY_HOME = Path(__file__).resolve().parent.parent -TASK = PERRY_HOME / "bin" / "perry-task" -DECIDE = PERRY_HOME / "bin" / "perry-decide" -GOALS = PERRY_HOME / "bin" / "perry-goals" -STATE = PERRY_HOME / "bin" / "perry-state" -LINT = PERRY_HOME / "bin" / "perry-lint" -CONFORM = PERRY_HOME / "bin" / "perry-conform" -MIGRATE = PERRY_HOME / "bin" / "perry-migrate" - -SCHEMA = json.loads((PERRY_HOME / "schema" / "state-schema.json").read_text()) - - -def load(name: str, path: Path): - spec = importlib.util.spec_from_loader( - name, importlib.machinery.SourceFileLoader(name, str(path))) - mod = importlib.util.module_from_spec(spec) - sys.modules[name] = mod - spec.loader.exec_module(mod) - return mod - - -C = load("perry_conform_under_test", CONFORM) - -# **The extractor and the assertion live in `tests/handed_back.py`.** -# `tests/test_migrate.py` asserts the same thing about the same class of -# message and held its own hand-written copy — `assertIn(f"… --root {root}")` -# — which is the copy that went stale. One definition, two callers. -_HB = load("perry_handed_back", PERRY_HOME / "tests" / "handed_back.py") -commands_named = _HB.commands_named -assert_every_command_carries = _HB.assert_every_command_carries - -BOARD = """# Board — T - -## P0 (must finish this period) - -| ID | Title | Owner | Status | Next action | Evidence | -|---|---|---|---|---|---| - -## P1 - -| ID | Title | Owner | Status | Next action | Evidence | -|---|---|---|---|---|---| - -## P2 - -| ID | Title | Owner | Status | Next action | Evidence | -|---|---|---|---|---|---| - -## Cadence (recurring; doesn't consume P0 slots) - -| ID | Recurring task | Owner | Frequency | Next due | Last evidence | -|---|---|---|---|---|---| - -## User Input Queue - -| USER-id | Needed from user | Blocks | Idle | Status | -|---|---|---|---|---| - -## Top risks - -- none -""" - -#: The same board with the ID column renamed. `perry-lint` calls this a -#: `table-columns` error, which is precisely a shape violation: every reader -#: keys on that header. -BOARD_WRONG_SHAPE = BOARD.replace( - "| ID | Title | Owner | Status | Next action | Evidence |", - "| Ticket | Title | Owner | Status | Next action | Evidence |", 1) - -ADD = ("add", "--title", "a row", "--priority", "P1", - "--deliverable", "a thing that exists afterwards", - "--verification", "the suite is green") - - -#: The hostile directory name every fixture project is built under; see -#: `tests/handed_back.py` for the character-by-character reasoning. -HOSTILE_ROOT_NAME = _HB.HOSTILE_ROOT_NAME - - -class Project: - """A throwaway project, unmarked by default — like every project alive.""" - - def __init__(self, board: str | None = BOARD, config_extra: str = "", - dirname: str = HOSTILE_ROOT_NAME): - self.dir = tempfile.TemporaryDirectory() - self.root = Path(self.dir.name) / dirname - self.root.mkdir() - (self.root / ".perry").mkdir() - (self.root / ".perry" / "config.md").write_text( - "# Perry configuration\n\n- Document language: English\n" - "- Repo layout: single\n- State root: .\n" + config_extra) - if board is not None: - (self.root / "BOARD.md").write_text(board) - # Armed, so the fixture carries no lint finding of its own and a test - # that measures "did being undeclared add a finding" is measuring that - # and not the hook warning every bare project starts with. - # - # **The fragments are backticked, and were not.** This line read - # `- anything that spends money` — a bullet, so `hook-high-stakes-armed` - # stayed quiet, and zero fragments, so the gate it silenced was empty. - # TASK-202's check found it here, in this repository's own fixtures, - # the first time it ran: written by someone satisfying "armed" who - # believed a bullet was a rule. That is the whole defect, and the - # fixture now models a hook that actually arms something. - (self.root / ".perry" / "hook.md").write_text( - "# Hook\n\n## High-stakes operations\n\n" - "- anything that spends money — `invoice`, `billing`\n") - - def run(self, tool: Path, *argv, enforce: bool | None = None, - json_out: bool = True) -> tuple[int, dict | str, str]: - env = dict(os.environ) - env.pop("PERRY_CONFORMANCE", None) - if enforce is not None: - env["PERRY_CONFORMANCE"] = "enforce" if enforce else "advisory" - argv = [*argv, "--root", str(self.root)] - if json_out: - argv.append("--json") - r = subprocess.run(["python3", str(tool), *argv], - capture_output=True, text=True, env=env) - try: - return r.returncode, json.loads(r.stdout or "{}"), r.stderr - except json.JSONDecodeError: - return r.returncode, r.stdout, r.stderr - - def marker(self) -> Path: - """The record — `.perry/conformance.jsonl` since TASK-234.""" - return self.root / C.P.CONFORMANCE_FILE - - def legacy_marker(self) -> Path: - """`.perry/conformance.md`, the record every project written before - TASK-234 carries. Not a register any more: a conversion source.""" - return self.root / C.P.CONFORMANCE_LEGACY_FILE - - def line(self, path: str = "BOARD.md", version: int | None = None, - declared: str = "2026-08-28", route: str = "declare", - **extra) -> str: - """One canonical store line, as `perry-conform declare` writes it.""" - rec = {"kind": "declaration", "path": path, - "shape_version": C.shape_version(SCHEMA) if version is None - else version, - "declared": declared, "route": route, - "writer": "", "recorded_at": "", "run": ""} - rec.update(extra) - return json.dumps(rec, ensure_ascii=False) + "\n" - - def verdict(self, key: str = "BOARD.md"): - return C.verdict(self.root, self.root, key, SCHEMA) - - def __del__(self): - self.dir.cleanup() - - -# ── 1 · the marker records a decision; lint verifies a shape ────────────── - - -class TestTwoFactsNotOne(unittest.TestCase): - - def test_a_file_that_conforms_but_was_never_declared_is_not_conformant(self): - """The default ADR-004 chose. `BOARD.md` here is byte-identical to one - Perry would have written and lints clean — and it is still not - conformant, because nobody said so.""" - p = Project() - rc, out, _ = p.run(CONFORM, "check", "BOARD.md") - self.assertEqual(out["errors"], 0, "the fixture board must lint clean") - self.assertEqual(out["state"], C.UNDECLARED) - self.assertEqual(rc, 1) - - def test_the_declaration_alone_is_not_trusted_when_the_file_no_longer_matches(self): - """A user can edit a file after declaring it. That is a finding.""" - p = Project() - rc, _, _ = p.run(CONFORM, "declare", "BOARD.md") - self.assertEqual(rc, 0) - self.assertEqual(p.verdict().state, C.CONFORMANT) - (p.root / "BOARD.md").write_text(BOARD_WRONG_SHAPE) - v = p.verdict() - self.assertEqual(v.state, C.DRIFTED) - self.assertTrue(v.errors, "drift with no lint error is not drift") - - def test_a_drifted_declaration_is_reported_and_not_revoked(self): - """Not silently trusted, and not silently corrected either — the row - the user wrote stays in the record.""" - p = Project() - p.run(CONFORM, "declare", "BOARD.md") - (p.root / "BOARD.md").write_text(BOARD_WRONG_SHAPE) - p.run(CONFORM, "check", "BOARD.md") - p.run(TASK, *ADD, enforce=False) # a full advisory write cycle - self.assertEqual( - [d.path for d in C.P.read_conformance(p.root).declarations.values()], - ["BOARD.md"], "the declaration was revoked behind the user's back") - - def test_no_tool_stamps_the_marker_on_its_own_initiative(self): - """ADR-004 § 4. A whole advisory write cycle — the mode that is allowed - to proceed — must leave the record untouched.""" - p = Project() - rc, out, _ = p.run(TASK, *ADD, enforce=False) - self.assertEqual(rc, 0, out) - self.assertFalse(p.marker().exists(), - "perry-task wrote a conformance declaration nobody made") - rc, _, _ = p.run(DECIDE, "bootstrap", enforce=False) - self.assertEqual(rc, 0) - self.assertFalse(p.marker().exists(), - "perry-decide wrote a conformance declaration nobody made") - - def test_declare_refuses_to_record_a_declaration_that_would_be_false(self): - p = Project(board=BOARD_WRONG_SHAPE) - rc, out, _ = p.run(CONFORM, "declare", "BOARD.md") - self.assertEqual(rc, 1) - self.assertEqual(out["declared"], []) - self.assertEqual(out["refused"][0]["path"], "BOARD.md") - self.assertGreater(out["refused"][0]["errors"], 0) - self.assertFalse(p.marker().exists()) - - def test_declaring_is_never_implicit(self): - """`declare` with no file and no --all refuses rather than guessing.""" - p = Project() - rc, out, _ = p.run(CONFORM, "declare") - self.assertEqual(rc, 1) - self.assertIn("--all", out["refused"]) - self.assertFalse(p.marker().exists()) - - -# ── 2 · per file, not per project ───────────────────────────────────────── - - -class TestPerFileNotPerProject(unittest.TestCase): - - def test_declaring_the_board_does_not_declare_the_okr(self): - """**This was written against `perry-decide` and `DECISIONS.md`.** - TASK-235 deleted that file, and with it this lane's gate — the ADR - bodies `perry-decide` still writes have no `files[]` shape to conform - to, so gating it on anything would be a gate that cannot fire. The - property under test is ADR-004 § 5's, not that lane's: two writers, - two files, one declaration, and the second writer still refuses.""" - p = Project() - (p.root / "OKR.md").write_text(PRE_SPLIT_OKR) - p.run(CONFORM, "declare", "BOARD.md") - rc, out, _ = p.run(TASK, *ADD, enforce=True) - self.assertEqual(rc, 0, f"the board was declared: {out}") - rc, out, _ = p.run(GOALS, "commit", "--track", "ops", "--promise", "a", - "--to", "x", "--due", "3d", enforce=True) - self.assertEqual(rc, 1, "perry-goals wrote an undeclared OKR.md") - self.assertIn("OKR.md", out["refused"]) - self.assertNotIn("BOARD.md", out["refused"]) - - def test_a_project_may_declare_one_file_and_not_another(self): - """ADR-004 § 5 — partial migration is a state, not a failure. The rows - that can be written are written; the exit code still says the request - was not fully satisfied.""" - p = Project(board=BOARD_WRONG_SHAPE) - rc, out, _ = p.run(CONFORM, "declare", "--all") - declared = {d["path"] for d in out["declared"]} - refused = {r["path"] for r in out["refused"]} - self.assertIn(".perry/config.md", declared) - self.assertIn("BOARD.md", refused) - self.assertEqual(rc, 1) - stored = C.P.read_conformance(p.root).declarations - self.assertIn(".perry/config.md", stored) - self.assertNotIn("BOARD.md", stored) - - -# ── 3 · versioned from the start ────────────────────────────────────────── - - -class TestVersionedFromTheStart(unittest.TestCase): - - def test_the_shape_version_is_the_schema_version_and_not_a_second_number(self): - """One rule, one number. A `conformance_version` beside - `schema_version` would be two counters for one fact, and the first - schema change that bumped only one of them would make every marker - a lie.""" - on_disk = json.loads( - (PERRY_HOME / "schema" / "state-schema.json").read_text())["schema_version"] - self.assertEqual(C.shape_version(SCHEMA), on_disk) - p = Project() - p.run(CONFORM, "declare", "BOARD.md") - self.assertEqual( - C.P.read_conformance(p.root).declarations["BOARD.md"].shape_version, - on_disk) - - def test_a_declaration_at_an_older_shape_version_is_never_silently_accepted(self): - p = Project() - p.run(CONFORM, "declare", "BOARD.md") - p.marker().write_text(p.line(version=1)) - v = p.verdict() - self.assertEqual(v.state, C.STALE) - rc, out, _ = p.run(TASK, *ADD, enforce=True) - self.assertEqual(rc, 1) - self.assertIn("shape version 1", out["refused"]) - self.assertIn("perry-conform declare", out["refused"]) - - def test_the_declared_version_is_readable_without_re_deriving_it(self): - """The whole point of storing it: a v1 project is distinguishable from - a v2 project by reading the record, not by inspecting the files.""" - p = Project() - p.run(CONFORM, "declare", "BOARD.md") - p.marker().write_text(p.line(version=1)) - rc, out, _ = p.run(CONFORM, "status") - row = next(f for f in out["files"] if f["path"] == "BOARD.md") - self.assertEqual(row["declared_version"], 1) - self.assertEqual(row["shape_version"], C.shape_version(SCHEMA)) - self.assertNotEqual(row["declared_version"], row["shape_version"], - "a fixture where both agree cannot show the difference") - - -# ── 4 · a refusal names the way forward ─────────────────────────────────── - - -class TestARefusalNamesTheWayForward(unittest.TestCase): - """Four distinct non-ok states exist, and none of them is a wall.""" - - def _states(self) -> dict: - out = {} - - clean = Project() - out[C.UNDECLARED] = clean.verdict() - - dirty = Project(board=BOARD_WRONG_SHAPE) - out["undeclared_dirty"] = dirty.verdict() - - stale = Project() - stale.run(CONFORM, "declare", "BOARD.md") - stale.marker().write_text(stale.line(version=1)) - out[C.STALE] = stale.verdict() - - drift = Project() - drift.run(CONFORM, "declare", "BOARD.md") - (drift.root / "BOARD.md").write_text(BOARD_WRONG_SHAPE) - out[C.DRIFTED] = drift.verdict() - self._keep = (clean, dirty, stale, drift) - return out - - def test_every_non_conformant_state_names_a_command_that_exists(self): - states = self._states() - self.assertEqual( - {v.state for v in states.values()}, - {C.UNDECLARED, C.STALE, C.DRIFTED}, - "the fixtures did not actually produce distinct verdicts") - self.assertEqual(states["undeclared_dirty"].errors and True, True) - for name, v in states.items(): - msg = C.message_for(v, "perry-task", None) - self.assertTrue(msg, f"{name} refuses with no message at all") - named = [w.strip() for line in msg.split("\n") - for w in [line] if line.strip().startswith("perry-")] - self.assertTrue(named, f"{name} names no command to run: {msg}") - for line in named: - tool = line.split()[0] - self.assertTrue((PERRY_HOME / "bin" / tool).exists(), - f"{name} names {tool!r}, which does not exist") - - def test_the_refusal_distinguishes_conformant_but_undeclared_from_malformed(self): - """The two need different next steps: one is a declaration, the other - is a migration. A gate that said "not conformant" to both would send - half its users to the wrong place.""" - states = self._states() - clean = C.message_for(states[C.UNDECLARED], "perry-task", None) - dirty = C.message_for(states["undeclared_dirty"], "perry-task", None) - self.assertIn("already matches Perry's shape", clean) - self.assertNotIn("perry-lint", clean) - self.assertIn("perry-lint", dirty) - self.assertIn("read-only", dirty) - - def test_the_refusal_says_nothing_was_written(self): - p = Project(board=BOARD_WRONG_SHAPE) - before = (p.root / "BOARD.md").read_text() - rc, out, _ = p.run(TASK, *ADD, enforce=True) - self.assertEqual(rc, 1) - self.assertEqual((p.root / "BOARD.md").read_text(), before) - self.assertFalse((p.root / "journal").exists()) - self.assertFalse((p.root / ".perry" / "events.jsonl").exists()) - - -# ── 5 · reading is not gated ────────────────────────────────────────────── - - -class TestReadingIsNotGated(unittest.TestCase): - """The half of ADR-004 that is easy to break by accident. Every check here - runs with the gate ENFORCING on a project that has declared nothing.""" - - #: Frozen on 2026-08-17, before this task touched anything. A reader that - #: gains or loses a top-level key breaks a consumer that does not read - #: Perry's changelog. - CONTRACTS = { - # 1.5 was 1.4's key set exactly — that minor moved for two corrected - # VALUES (`evidence_paths` and `conformance.evidence_not_found` on - # closed rows, TASK-057), which is what this table is here to let - # through while a gained or lost key is not. - # - # 1.6 DOES gain keys, so this line was edited deliberately: `risks`, - # `asks` and `drift` (TASK-058 — three blocks a Work surface shows that - # were readable only through the unversioned `perry-state --json`). The - # freeze still freezes: every other key in the set is unchanged, the - # version string had to move in the same edit, and a fourth key added - # without touching this line still fails here. - "perry-task/list/1.18": ( - TASK, ("list", "--all"), - {"project_root", "state_root", "contract", "semantics", "tasks", - "open", "closed", "events", "untitled", "conformance", "intake", - "risks", "asks", "drift"}), - # - # Both lines below were edited deliberately by TASK-205, on the same - # terms: each payload gains `semantics` and states so in its version - # string in the same edit. `perry-decide/list` carries it EMPTY, which - # is the shipped fact and not a placeholder — a consumer checks before - # it looks, so the key is asserted here by presence, not by content. - "perry-decide/list/2.0": ( - DECIDE, ("list",), - {"project_root", "state_root", "contract", "semantics", - "decisions", "active", "total", "expired_sunsets", - "conformance"}), - "perry-goals/list/2.3": ( - GOALS, ("list",), - {"project_root", "state_root", "contract", "semantics", "okr", - "phase", "krs", "linkage", "counts", "conformance", - "unlinked_task_ids", "answered_by"}), - } - - def test_every_read_command_answers_on_an_undeclared_project(self): - p = Project() - for tool, argv in ((TASK, ("list",)), (DECIDE, ("list",)), - (GOALS, ("list",))): - rc, out, err = p.run(tool, *argv, enforce=True) - self.assertEqual(rc, 0, f"{tool.name} {argv} was gated: {out} {err}") - - def test_perry_state_answers_on_an_undeclared_project(self): - p = Project() - rc, out, err = p.run(STATE, enforce=True) - self.assertEqual(rc, 0, err) - self.assertIn("board", out) - - def test_the_three_contracts_do_not_change_shape(self): - p = Project() - for version, (tool, argv, keys) in self.CONTRACTS.items(): - with self.subTest(contract=version): - rc, out, err = p.run(tool, *argv, enforce=True) - self.assertEqual(rc, 0, err) - self.assertEqual(out.get("contract"), version, - "the contract version moved") - self.assertEqual(set(out), keys, - "a published contract gained or lost a key") - - def test_the_gate_adds_nothing_to_the_task_list_payload(self): - """`list`'s `conformance` block already means something else — the rows - this reader could not parse. A second, differently-meaning key under - the same name is how a front-end learns the wrong thing.""" - p = Project() - p.run(TASK, *ADD, enforce=False) - rc, out, _ = p.run(TASK, "list", "--all", enforce=True) - self.assertNotIn("state", out["conformance"], - "the shape verdict leaked into the read contract") - self.assertNotIn("gate", out["conformance"]) - - -# ── 6 · one definition of the shape ─────────────────────────────────────── - - -#: The corpus for § 6, and it is inside the repository on every machine. -#: -#: It used to be `~/proj/gimegime-pmo`, behind `PERRY_TEST_CORPUS` and an -#: `if (REAL / "BOARD.md").exists()` in `setUpClass`. On the author's machine -#: the four tests below ran; on every other checkout they skipped, so the "one -#: definition of the shape" claim — the thing this section exists to hold — -#: had no coverage anywhere it mattered. TASK-111's sweep named this file and -#: left it its own row; TASK-124 is that row. -#: -#: `tests/fixtures/witness-project` (TASK-132) replaces it, read through the -#: **same `--root` seam** the real project was read through. It is not a -#: snapshot of anybody's project: every finding in it is constructed, one per -#: rule, and its own top-risk line says so. And nothing below asserts what -#: those findings ARE — the assertions are that `perry-conform` and -#: `perry-lint` report the *same* per-file error counts, whatever the fixture -#: happens to carry. A golden file recording what one real project looked like -#: on capture day is the failure TASK-145 spent a row escaping; this is the -#: other shape, where correctness is a property of the two checkers and the -#: fixture only has to be non-trivial. -#: -#: `PERRY_TEST_CORPUS` is gone. It was a way to point these tests at a -#: different directory, and the only thing it ever pointed them at was the one -#: directory that is now unnecessary; keeping it would leave a second, untested -#: way for this corpus to become something else. -WITNESS = PERRY_HOME / "tests" / "fixtures" / "witness-project" - - -def copy_of(src: Path, into: tempfile.TemporaryDirectory) -> Path: - """A COPY, always — `declare` and `perry-tasks write` both write, and the - fixture is read by other modules in the same run.""" - root = Path(into.name) / "project" - shutil.copytree(src, root, ignore=shutil.ignore_patterns(".git"), - symlinks=True) - return root - - -def lint_errors_by_file(root: Path) -> dict[str, int]: - lint = json.loads(subprocess.run( - ["python3", str(LINT), "--root", str(root), "--json"], - capture_output=True, text=True).stdout) - by_file: dict[str, int] = {} - for f in lint["findings"]: - if f["severity"] == "error": - by_file[f["file"]] = by_file.get(f["file"], 0) + 1 - return by_file - - -class TestOneDefinitionOfTheShape(unittest.TestCase): - """`perry-conform` must not contain a second answer to "is this Perry's - shape". It contains none at all — it calls `perry-lint.check_file`.""" - - @classmethod - def setUpClass(cls): - cls.tmp = tempfile.TemporaryDirectory() - cls.root = copy_of(WITNESS, cls.tmp) - - @classmethod - def tearDownClass(cls): - cls.tmp.cleanup() - - def test_the_corpus_can_still_tell_the_two_checkers_apart(self): - """The anti-vacuity guard, stated before the comparisons that need it. - - A corpus with no errors makes both tests below pass against a - `perry-conform` that returns 0 for everything, and a corpus whose - errors all sit in one file, or all come out the same count, makes them - pass against one that reports the project total per file. This is the - one place that asserts something about the fixture, and it asserts the - least that the measurement requires: two files, two counts.""" - by_file = lint_errors_by_file(self.root) - self.assertGreaterEqual( - len(by_file), 2, - f"{WITNESS.name} carries lint errors in fewer than two files " - f"({by_file}) — the per-file comparison below cannot fail") - self.assertGreaterEqual( - len(set(by_file.values())), 2, - f"every file with errors in {WITNESS.name} carries the same " - f"number of them ({by_file}) — a perry-conform that reported the " - f"project total for every file would pass") - - def test_per_file_error_counts_match_perry_lints_own_findings(self): - by_file = lint_errors_by_file(self.root) - conform = json.loads(subprocess.run( - ["python3", str(CONFORM), "status", "--root", str(self.root), "--json"], - capture_output=True, text=True).stdout) - - for row in conform["files"]: - with self.subTest(path=row["path"]): - self.assertEqual(row["errors"], by_file.get(row["path"], 0)) - self.assertEqual(sum(r["errors"] for r in conform["files"]), - sum(by_file.values()), - "the two disagree about how many errors this project has") - self.assertEqual( - {r["path"] for r in conform["files"] if r["errors"]}, set(by_file), - "the two disagree about WHICH files this project's errors are in") - - def test_declare_all_splits_the_project_exactly_where_status_does(self): - """The concrete ADR-004 § 5 case, as a property rather than a census: - `declare --all` declares every file `perry-conform status` reports at - zero errors and refuses every file it reports with errors — so a - partial declaration is partial along the line the checker draws, not - along a list of filenames someone wrote down. Both sides are asserted - non-empty, because a project where everything is refused and one where - everything is declared each pass half of this by accident.""" - conform = json.loads(subprocess.run( - ["python3", str(CONFORM), "status", "--root", str(self.root), "--json"], - capture_output=True, text=True).stdout) - clean = {r["path"] for r in conform["files"] if r["errors"] == 0} - dirty = {r["path"] for r in conform["files"] if r["errors"] > 0} - self.assertTrue(clean, "nothing in the corpus conforms") - self.assertTrue(dirty, "everything in the corpus conforms") - - r = subprocess.run( - ["python3", str(CONFORM), "declare", "--all", - "--root", str(self.root), "--json"], - capture_output=True, text=True) - out = json.loads(r.stdout) - self.assertEqual(r.returncode, 1, "a partial declaration exits 1") - self.assertEqual({x["path"] for x in out["declared"]}, clean) - self.assertEqual({x["path"] for x in out["refused"]}, dirty) - - -#: A board with exactly two errors, of two kinds, chosen so that -#: `perry-migrate` must fix one and must refuse the other: -#: -#: - `## Cadence` is missing. That is a shape error `fix_sections` repairs by -#: adding the empty section, and nothing is invented in doing it. -#: - `T-001`'s status reads `half-solved`, which is not in the enum and is not -#: an alias of anything in it. It is a distinction its author drew in their -#: own words, and coercing it to `in_progress` would be asserting a fact -#: nobody stated. -#: -#: Written here rather than committed as a project because what is under test -#: is the migrator's behaviour on a board of this shape, not any project's -#: history. `T-002` is present so the board is not one unmigratable row. -BOARD_WITH_A_ROW_MIGRATION_CANNOT_COERCE = """# Board — T - -## P0 (must finish this period) - -| ID | Title | Owner | Status | Next action | Evidence | -|---|---|---|---|---|---| -| T-001 | the row whose state its author named themselves | Coding Agent | half-solved | say what half-solved means | — | -| T-002 | an ordinary row | Coding Agent | in_progress | keep going | — | - -## P1 - -| ID | Title | Owner | Status | Next action | Evidence | -|---|---|---|---|---|---| - -## P2 - -| ID | Title | Owner | Status | Next action | Evidence | -|---|---|---|---|---|---| - -## User Input Queue - -| USER-id | Needed from user | Blocks | Idle | Status | -|---|---|---|---|---| - -## Top risks - -- none -""" - - -class TestMigrationDoesNotReachTheWholeBoard(unittest.TestCase): - """TASK-047, cost 1 — the residue the `enforce` flip ships with. - - ADR-004 says flip the gate to `enforce` once the migration exists, and - TASK-047 flipped it. What this pins is what that flip therefore costs: a - board can carry a shape error `perry-migrate` **must not** fix, because - fixing it would mean choosing a meaning its author did not choose. The - file then stays refused until a human edits it and declares it. That is a - door needing a hand rather than a wall — the refusal names `perry-lint` - and `perry-conform declare` as well as `perry-migrate` — but it is a real - cost and it is stated, not discovered. - - It used to be measured on `~/proj/gimegime-pmo`, whose board carried a row - reading `Status: 半解`, and it therefore measured nothing on any other - machine. The row is now written by the test, which is the stronger claim - in any case: the old assertions were "*that* project still has a residue", - and these are "a board of this shape gets partly migrated and is not - written" — true of the project too, and checkable everywhere. - - Read-only about the fixture: a dry run, on a board this class wrote, and - it asserts nothing about which residual finding remains — only that one - does, and that the migration got strictly closer without arriving. This - goes RED the day `perry-migrate` learns to coerce a status nobody defined, - which is the day the row in `bin/README.md` needs re-reading rather than - deleting.""" - - def project(self) -> Project: - return Project(board=BOARD_WITH_A_ROW_MIGRATION_CANNOT_COERCE) - - def plan_for_the_board(self, p: Project) -> dict: - r = subprocess.run( - ["python3", str(MIGRATE), "--root", str(p.root), - "--only", "BOARD.md", "--json"], - capture_output=True, text=True, timeout=600) - plan = json.loads(r.stdout) - self.assertIn("files", plan, plan) - return next(f for f in plan["files"] if f["path"] == "BOARD.md") - - def test_the_migration_plan_for_the_board_does_not_reach_zero(self): - board = self.plan_for_the_board(self.project()) - self.assertGreater(board["before_errors"], 0, - "the board this test wrote is already conformant — " - "it no longer measures anything") - self.assertGreater( - board["after_errors"], 0, - "perry-migrate now takes this board to zero errors, which means it " - "coerced a status its author invented. Cost 1 in bin/README.md " - "§ The switch-over checklist is either gone or wrong — the flip " - "itself already happened (TASK-047), so nothing about DEFAULT_MODE " - "needs revisiting, but the residue this pins does.") - self.assertLess( - board["after_errors"], board["before_errors"], - "the plan fixed nothing at all, so 'does not reach zero' is true " - "for the wrong reason — migration is not partial here, it is inert") - self.assertFalse(board["writable"], - "a plan with residual errors must not be applied — " - "ADR-004 guarantee 5, partial migration is per file") - - def test_the_residue_is_the_cell_no_one_may_choose_a_meaning_for(self): - """Names which finding survives, so the cost above can be checked - rather than trusted. Deliberately not asserted by the test above: that - one is about the arithmetic of a partial migration, and would still be - making its point if the residue were some other rule.""" - board = self.plan_for_the_board(self.project()) - rules = {f["rule"] for f in board["residual"]} - self.assertEqual(rules, {"bad-enum"}, board["residual"]) - self.assertIn("half-solved", - " ".join(f["message"] for f in board["residual"])) - self.assertEqual( - [c["kind"] for c in board["changes"]], ["section-added"], - "the half of the board migration CAN fix stopped being fixed") - - def test_that_the_store_is_read_while_the_board_is_unwritable(self): - """Conformance gates projection writes, not reads of canonical tasks. - - The count is taken from the board this test wrote — every row of it - comes back, not "more than twenty", which was a census of the author's - project and would have gone red the week it was triaged.""" - p = self.project() - expected = sum(1 for line in BOARD_WITH_A_ROW_MIGRATION_CANNOT_COERCE - .split("\n") if line.startswith("| T-")) - self.assertEqual(expected, 2, "the board constant lost a row") - - rc, out, _ = p.run(CONFORM, "status", enforce=True) - board = next(f for f in out["files"] if f["path"] == "BOARD.md") - self.assertGreater(board["errors"], 0, - "the board conforms, so nothing here is gated and " - "the read below proves nothing") - - seeded = subprocess.run( - ["python3", str(PERRY_HOME / "bin" / "perry-tasks"), "write", - "--from-board", "--root", str(p.root)], - capture_output=True, text=True, timeout=300) - self.assertEqual(seeded.returncode, 0, seeded.stdout + seeded.stderr) - r = subprocess.run( - ["python3", str(TASK), "list", "--all", - "--root", str(p.root), "--json"], - capture_output=True, text=True, - env=dict(os.environ, PERRY_CONFORMANCE="enforce"), timeout=300) - self.assertEqual(r.returncode, 0, r.stderr) - self.assertEqual(len(json.loads(r.stdout)["tasks"]), expected, - "the unwritable board stopped being readable") - - -class TestConformanceIsErrorsNotWarnings(unittest.TestCase): - """Open question in the spec, answered: **errors only**. - - Warnings in this schema are quality signals, and at least one of them — - `stale-run` — becomes true with the passage of time and nothing else. A - declaration that revoked itself on a calendar boundary would not be a - statement about shape.""" - - def _stale_dossier(self) -> Project: - p = Project() - (p.root / ".perry" / "adoption").mkdir() - (p.root / ".perry" / "adoption" / "run.md").write_text( - "---\nadoption: 1\nproject: t\nstage: confirm\nstep: state_root\n" - "updated: '2020-01-01T00:00:00Z'\n---\n\n# run\n") - return p - - def test_a_file_carrying_only_warnings_can_be_declared(self): - p = self._stale_dossier() - lint = json.loads(subprocess.run( - ["python3", str(LINT), "--root", str(p.root), "--json"], - capture_output=True, text=True).stdout) - warns = [f for f in lint["findings"] - if f["file"] == ".perry/adoption/run.md" and f["severity"] == "warn"] - errs = [f for f in lint["findings"] - if f["file"] == ".perry/adoption/run.md" and f["severity"] == "error"] - self.assertTrue(warns, "the fixture produced no warning — nothing is proven") - self.assertEqual(errs, [], "the fixture produced an error, not a warning") - - rc, out, _ = p.run(CONFORM, "declare", ".perry/adoption/run.md") - self.assertEqual(rc, 0, out) - self.assertEqual(p.verdict(".perry/adoption/run.md").state, C.CONFORMANT) - - def test_the_warning_the_fixture_relies_on_is_time_dependent(self): - """Names the actual reason, so a future reader can check the argument - rather than trust it.""" - p = self._stale_dossier() - lint = json.loads(subprocess.run( - ["python3", str(LINT), "--root", str(p.root), "--json"], - capture_output=True, text=True).stdout) - rules = {f["rule"] for f in lint["findings"] - if f["file"] == ".perry/adoption/run.md"} - self.assertIn("stale-run", rules) - - -class TestTheGateSpeaksEveryDocumentLanguage(unittest.TestCase): - """`perry-lint`'s glossary is module-level state that `main()` used to arm - inline. Calling `check_file` without arming it reports a Chinese board's own - column headers as the wrong columns — so a localized project would be told - it is not Perry's shape when it is, and could never declare itself.""" - - ZH = PERRY_HOME / "tests" / "fixtures" / "sample-project-zh" - - def setUp(self): - self.tmp = tempfile.TemporaryDirectory() - self.root = Path(self.tmp.name) / "zh" - shutil.copytree(self.ZH, self.root) - - def tearDown(self): - self.tmp.cleanup() - - def test_a_localized_board_is_conformant_and_can_be_declared(self): - head = (self.root / "BOARD.md").read_text().split("\n") - self.assertTrue(any("负责人" in l or "任务" in l for l in head), - "the fixture is not actually localized") - r = subprocess.run( - ["python3", str(CONFORM), "declare", "BOARD.md", - "--root", str(self.root), "--json"], capture_output=True, text=True) - out = json.loads(r.stdout) - self.assertEqual(out["refused"], [], - "a localized board was called malformed") - self.assertEqual(r.returncode, 0) - - -# ── 7 · enforcing, and what enforcing costs ─────────────────────────────── - -#: A commitments register with the pre-TASK-091 single clock column — out of -#: Perry's shape by exactly the defect `perry-goals commit --migrate` exists to -#: repair, which is what makes it the fixture for the exemption. Kept here -#: rather than imported from `test_goals_writer` so that a change to that -#: suite's fixture cannot silently stop this one from testing the gate. -PRE_SPLIT_OKR = """# OKR — fixture - -## Mission - -Ship it. - -## Commitments - -| Id | Track | Promise | To whom | By when | Status | -|-------|-------|---------------------|---------|----------------------|--------| -| rel/1 | rel | Release 2.0 | Users | 2027-01-01 | active | -| ops/7 | ops | Invoices reconciled | Finance | within the track SLA | active | - -## Anti-Goals - -- not this -""" - - -class TestTheGateEnforces(unittest.TestCase): - """TASK-047. `advisory` shipped for one release on an argument that named - its own expiry condition — *enforcement flips when TASK-044 gives the - non-conformant half of the population a road* — and TASK-044 landed - 2026-08-19. These assert the flip, both escape hatches, and both - exemptions.""" - - def test_the_shipped_default_is_enforce(self): - p = Project() - self.assertEqual(C.DEFAULT_MODE, C.ENFORCE) - self.assertEqual(C.gate_mode(p.root), C.ENFORCE) - - def test_an_undeclared_project_is_refused_and_nothing_is_written(self): - """V4.1. A refusal must mean the file was not touched — the gate is - taken before the lock and before the command runs for this reason.""" - p = Project() - before = (p.root / "BOARD.md").read_text() - rc, out, _ = p.run(TASK, *ADD, enforce=None) - self.assertEqual(rc, 1, out) - self.assertIn("BOARD.md", out["refused"]) - self.assertEqual(before, (p.root / "BOARD.md").read_text(), - "a refused write left a mark on the file") - self.assertFalse(p.marker().exists(), - "the refusal declared the file on the user's behalf") - - def test_the_refusal_names_the_file_the_version_and_a_declare_command(self): - """V4.1, clause by clause. Three facts, because a refusal missing any - one of them cannot be acted on without a second command.""" - p = Project() - rc, out, _ = p.run(TASK, *ADD, enforce=None) - msg = out["refused"] - self.assertIn("BOARD.md", msg) - self.assertIn(f"version {C.shape_version(SCHEMA)}", msg) - self.assertIn("perry-conform declare BOARD.md", msg) - - def test_the_declare_command_the_refusal_names_is_runnable_verbatim(self): - """The difference between naming a command and naming a road. The - exact string is lifted out of the refusal and executed — if the - message ever names a command that does not parse, this goes red.""" - p = Project() - _, out, _ = p.run(TASK, *ADD, enforce=None) - line = next(l.strip() for l in out["refused"].split("\n") - if l.strip().startswith("perry-conform declare")) - # **`shlex.split`, and the root the MESSAGE names.** This read - # `line.split()[1:] + ["--root", str(p.root)]`: whitespace splitting, - # which turns a quoted path into as many arguments as it has spaces, - # and then it threw away whatever root the message had named and - # supplied its own. A test called "runnable verbatim" that appends the - # answer is not running it verbatim. Both halves fixed here; the - # fixture root now has a space in it, so the first half is measured - # rather than asserted. - argv = shlex.split(line)[1:] - self.assertIn("--root", argv, - f"the refusal named {line!r}, with no root at all") - r = subprocess.run(["python3", str(CONFORM), *argv], - capture_output=True, text=True) - self.assertEqual(r.returncode, 0, r.stdout + r.stderr) - self.assertEqual(p.verdict("BOARD.md").state, C.CONFORMANT) - rc, out, _ = p.run(TASK, *ADD, enforce=None) - self.assertEqual(rc, 0, out) - - def test_advisory_lets_the_write_through_and_says_so(self): - """V4.2. The escape hatch, and the reason `advisory` is not `off`: the - gate computed the same verdict and printed the same message.""" - p = Project() - rc, out, _ = p.run(TASK, *ADD, enforce=False) - self.assertEqual(rc, 0, out) - self.assertIn(out["id"], (p.root / "BOARD.md").read_text()) - self.assertEqual(out["conformance"]["state"], C.UNDECLARED) - self.assertEqual(out["conformance"]["gate"], C.ADVISORY) - self.assertTrue(out["conformance"]["allowed"]) - rc, _, err = p.run(TASK, *ADD, enforce=False, json_out=False) - self.assertEqual(rc, 0, err) - self.assertIn("conformance (advisory)", err) - self.assertIn("perry-conform declare BOARD.md", err) - - def test_a_project_can_opt_out_of_enforcement_without_the_environment(self): - """The other escape hatch. Going back is per project, not per release, - and not a flag only the test suite can set.""" - p = Project(config_extra="- Conformance gate: advisory\n") - self.assertEqual(C.gate_mode(p.root), C.ADVISORY) - rc, out, _ = p.run(TASK, *ADD, enforce=None) - self.assertEqual(rc, 0, out) - - def test_the_environment_overrides_the_project_setting(self): - """Precedence, in the direction the flip makes load-bearing: a project - that opted out can still be checked by a single enforcing run.""" - p = Project(config_extra="- Conformance gate: advisory\n") - rc, out, _ = p.run(TASK, *ADD, enforce=True) - self.assertEqual(rc, 1) - self.assertIn("perry-conform declare", out["refused"]) - - def test_declaring_the_file_turns_the_refusal_off(self): - p = Project() - p.run(CONFORM, "declare", "BOARD.md") - rc, out, err = p.run(TASK, *ADD, enforce=None, json_out=False) - self.assertEqual(rc, 0, err) - self.assertNotIn("conformance", err) - - def test_the_refusal_on_a_malformed_file_names_perry_migrate(self): - """Deliverable 4, and the whole reason the flip is defensible. The - advisory release existed because this branch could only name - `perry-lint`, which reports the problem and fixes nothing.""" - p = Project(board=BOARD_WRONG_SHAPE) - rc, out, _ = p.run(TASK, *ADD, enforce=None) - self.assertEqual(rc, 1, out) - self.assertIn("perry-migrate", out["refused"]) - self.assertIn("perry-migrate apply", out["refused"]) - self.assertIn(f"shape version {C.shape_version(SCHEMA)}", - out["refused"]) - - # ── the two documented exemptions ───────────────────────────────────── - # - # A gate that refuses the migration is a wall with no door: the migration - # is how an undeclared project becomes declarable. Both exemptions are - # asserted here, under the SHIPPED default rather than a forced `enforce`, - # because after TASK-047 the shipped default is the mode users meet. - - def test_goals_commit_migrate_writes_an_undeclared_file_without_refusal(self): - """V4.3. `perry-goals commit --migrate` splits the register's clock - column — the file it repairs is out of shape by exactly that defect, - so gating it would make the file permanently unmigratable.""" - p = Project() - (p.root / "OKR.md").write_text(PRE_SPLIT_OKR) - self.assertEqual(p.verdict("OKR.md").state, C.UNDECLARED) - - blocked, out, _ = p.run(GOALS, "commit", "--track", "ops", - "--promise", "a", "--to", "x", "--due", "3d", - enforce=None) - self.assertEqual(blocked, 1, - "the fixture is not gated, so the exemption proves " - "nothing") - - rc, out, err = p.run(GOALS, "commit", "--migrate", enforce=None) - self.assertEqual(rc, 0, f"{out} {err}") - self.assertIn("Due", (p.root / "OKR.md").read_text()) - self.assertIn("By when note", (p.root / "OKR.md").read_text()) - self.assertFalse(p.marker().exists(), - "the exempt write declared the file on the user's " - "behalf") - - def test_the_exempt_goals_run_announces_the_exemption_exactly_once(self): - """The exemption is loud, and it is not also advisory. Three - independent `if`s printed both lines under `enforce`; unreachable - while the default was advisory, wrong the day it flipped.""" - p = Project() - (p.root / "OKR.md").write_text(PRE_SPLIT_OKR) - rc, _, err = p.run(GOALS, "commit", "--migrate", enforce=None, - json_out=False) - self.assertEqual(rc, 0, err) - self.assertEqual(1, err.count("that is what a migration is")) - self.assertNotIn("conformance (advisory)", err) - self.assertNotIn("conformance (enforce)", err) - - def test_perry_migrate_runs_to_completion_against_an_undeclared_project(self): - """V4.4. `perry-migrate` is exempt from its own gate — it is how an - undeclared project becomes declarable, and it is the command the - refusal names. Run to completion means `apply`, not just a plan.""" - p = Project(board=BOARD_WRONG_SHAPE) - self.assertEqual(p.verdict("BOARD.md").state, C.UNDECLARED) - rc, out, err = p.run(MIGRATE, "apply", enforce=None) - self.assertEqual(rc, 0, f"{out} {err}") - board = next(f for f in out["files"] if f["path"] == "BOARD.md") - self.assertEqual(board["after_errors"], 0, board) - self.assertEqual(p.verdict("BOARD.md").state, C.CONFORMANT, - "apply did not record the user's declaration") - rc, out, _ = p.run(TASK, *ADD, enforce=None) - self.assertEqual(rc, 0, - "the road the refusal names does not lead anywhere") - - # ── TASK-047 · what the flip costs ──────────────────────────────────── - # - # Two costs came out of the measurement that preceded the flip. Neither is - # a missing road — both are places a user meets the gate on day one, and - # they are pinned here so that the day either stops being true a test says - # so instead of `bin/README.md` quietly going stale. Each is written to go - # RED when the cost is removed. - - def test_a_project_with_a_perfect_shape_is_still_refused_before_declaring(self): - """Cost 2. Conformance is two facts and Perry can only produce one of - them: a project Perry itself just wrote carries zero shape errors and - is still `undeclared`, because `SKILL.md § Conformance gate` forbids an - agent from declaring on the user's behalf (`perry/OKR.md` — *adoption - proposes; the user declares*). So the first `perry-task add` on a - spotless project asks the user for one command. - - Goes red when setup/adopt ends in the user's own declaration.""" - p = Project() - lint = json.loads(subprocess.run( - ["python3", str(LINT), "--root", str(p.root), "--json"], - capture_output=True, text=True).stdout) - board_errors = [f for f in lint["findings"] - if f["file"] == "BOARD.md" and f["severity"] == "error"] - self.assertEqual(board_errors, [], - "the fixture is no longer a perfectly shaped board, " - "so this test would prove nothing") - rc, out, _ = p.run(TASK, *ADD, enforce=None) - self.assertEqual( - rc, 1, - "a zero-error project is now writable under the shipped default — " - "cost 2 in bin/README.md § The switch-over checklist is gone; " - "delete this test and the row it pins") - self.assertIn("no one has declared it", out["refused"]) - - def test_reading_is_not_gated_for_the_commands_a_refusal_names(self): - """The guarantee ADR-004 calls non-negotiable, applied to the two - readers the refusal message itself points at. A gated `perry-migrate` - or `perry-lint` would close the loop: refused, and told to run a - command that is refused for the same reason.""" - p = Project(board=BOARD_WRONG_SHAPE) - env = dict(os.environ, PERRY_CONFORMANCE="enforce") - for tool in (LINT, PERRY_HOME / "bin" / "perry-migrate"): - with self.subTest(tool=tool.name): - r = subprocess.run( - ["python3", str(tool), "--root", str(p.root), "--json"], - capture_output=True, text=True, env=env, timeout=300) - self.assertIn(r.returncode, (0, 1), - f"{tool.name} crashed under enforce: {r.stderr}") - self.assertNotIn("refused", r.stderr, - f"{tool.name} is gated — the refusal names it") - self.assertTrue(r.stdout.strip(), - f"{tool.name} produced no output under enforce") - - def test_the_switch_over_checklist_names_both_costs_and_the_way_back(self): - """The checklist is the deliverable a reader acts on. It must name - both costs the flip carries AND the way back — a document that - announces an enforcing default without naming `advisory` is the wall - this whole gate is built not to be.""" - doc = (PERRY_HOME / "bin" / "README.md").read_text() - self.assertIn("switch-over checklist", doc.lower()) - body = doc.split("switch-over checklist", 1)[1].split("\n### ", 1)[0] - for claim in ("perry-migrate", "declare", "BOARD.md", "undeclared", - "advisory", "PERRY_CONFORMANCE"): - self.assertIn(claim, body, - f"the checklist no longer names {claim}") - self.assertNotIn("not the default yet", doc, - "the checklist still describes a state that passed") - - -# ── 8 · a file that does not exist yet is not a stranger's file ─────────── - - -class TestAbsentIsNotNonConformant(unittest.TestCase): - """**Asserted on the gate, and it used to be asserted through a tool.** - - The pair below ran `perry-decide bootstrap`, which created `DECISIONS.md` - — the one shipped case of a tool creating the very file it gated on. - TASK-235 deleted that file, so no shipped tool has that shape any more and - the CLI half of these two has no stand-in: `perry-task` refuses on a - missing board, `perry-goals link` refuses on a missing register, and - `perry-goals commit` refuses on a missing `OKR.md`. Substituting one of - those would test a refusal, which is the opposite property. - - So the gate is called directly. What is under test is `verdict` and - `gate` — a file that is not there yet is `absent`, `absent` is allowed, and - the file appearing does not declare it — and that is what the tools were - only ever a delivery mechanism for. - """ - - def test_an_absent_file_is_allowed_rather_than_refused(self): - """There is no shape to conform to before a file exists, and refusing - here would make every lane unreachable on a new project.""" - p = Project(board=None) - self.assertFalse((p.root / "BOARD.md").exists()) - self.assertEqual(p.verdict("BOARD.md").state, C.ABSENT) - gate = C.gate(p.root, p.root, "BOARD.md", tool="perry-task", - root_arg=None) - self.assertTrue(gate.ok, gate.message) - - def test_the_file_appearing_does_not_declare_it(self): - """…and Perry did not declare it on the user's behalf. The next write - asks, and the refusal is the one-command kind.""" - p = Project(board=None) - (p.root / "BOARD.md").write_text(BOARD) - self.assertFalse(p.marker().exists()) - self.assertEqual(p.verdict("BOARD.md").state, C.UNDECLARED) - rc, out, _ = p.run(TASK, *ADD, enforce=True) - self.assertEqual(rc, 1) - self.assertIn("perry-conform declare BOARD.md", out["refused"]) - - -# ── 9 · the record itself ───────────────────────────────────────────────── - - -class TestTheRecordIsReadHonestly(unittest.TestCase): - - def test_a_row_that_cannot_be_read_is_reported_not_treated_as_absent(self): - p = Project() - p.run(CONFORM, "declare", "BOARD.md") - p.marker().write_text(p.line().replace('"shape_version": 2', - '"shape_version": "v-two"')) - rc, out, _ = p.run(CONFORM, "status") - self.assertEqual(len(out["unreadable_rows"]), 1) - row = next(f for f in out["files"] if f["path"] == "BOARD.md") - self.assertEqual(row["state"], C.UNDECLARED) - self.assertEqual(row["record_unreadable_rows"], 1) - - def test_the_refusal_mentions_the_unreadable_rows(self): - """Otherwise "you never declared it" is a confident lie about a file - the user did declare, in a table they mistyped.""" - p = Project() - p.run(CONFORM, "declare", "BOARD.md") - p.marker().write_text(p.line().replace('"shape_version": 2', - '"shape_version": "v-two"')) - rc, out, _ = p.run(TASK, *ADD, enforce=True) - self.assertEqual(rc, 1) - self.assertIn("could not be read", out["refused"]) - - def test_the_record_is_not_reported_as_someone_elses_file(self): - """`perry-lint --claims` asks "is anything in the territory Perry wants - already someone else's". The record lives in `.perry/`, is not a - `files[]` entry, and was reported as a foreign file the moment it was - first written — which would tell a user that adopting Perry collides - with Perry.""" - p = Project() - def collisions() -> int: - r = subprocess.run( - ["python3", str(LINT), "--claims", "--root", str(p.root), "--json"], - capture_output=True, text=True) - return json.loads(r.stdout)["collisions"] - - self.assertEqual(collisions(), 0, "the fixture already collides") - p.run(CONFORM, "declare", "BOARD.md") - self.assertTrue(p.marker().exists()) - self.assertEqual(collisions(), 0, - "declaring conformance made Perry collide with itself") - - def test_the_record_survives_a_second_declaration(self): - p = Project() - p.run(CONFORM, "declare", "BOARD.md") - p.run(CONFORM, "declare", ".perry/hook.md") - stored = C.P.read_conformance(p.root).declarations - self.assertIn("BOARD.md", stored) - self.assertIn(".perry/hook.md", stored) - - def test_dry_run_declares_nothing(self): - p = Project() - rc, out, _ = p.run(CONFORM, "declare", "BOARD.md", "--dry-run") - self.assertEqual(rc, 0) - self.assertEqual([d["path"] for d in out["declared"]], ["BOARD.md"]) - self.assertFalse(p.marker().exists()) - - -# ── 10 · lint says how to get there ─────────────────────────────────────── - - -class TestLintPointsAtTheDeclaration(unittest.TestCase): - - def _lint(self, root: Path) -> str: - return subprocess.run(["python3", str(LINT), "--root", str(root)], - capture_output=True, text=True).stdout - - def test_lint_reports_the_declaration_count_and_names_the_tool(self): - p = Project() - before = self._lint(p.root) - self.assertIn("0 file(s) declared conformant", before) - self.assertIn("perry-conform status", before) - p.run(CONFORM, "declare", "BOARD.md") - after = self._lint(p.root) - self.assertIn("1 file(s) declared conformant", after) - - def test_being_undeclared_produces_no_lint_finding_at_all(self): - """`--strict` must not go red on every project in existence for a - reason lint cannot fix. Measured as a difference rather than as an - absolute: the findings before and after a declaration must be - identical, so a finding that appeared only because the project was - undeclared would show up here whatever else the fixture carries.""" - p = Project() - def findings(): - r = subprocess.run( - ["python3", str(LINT), "--root", str(p.root), "--strict", "--json"], - capture_output=True, text=True) - return r.returncode, json.loads(r.stdout) - - rc_before, before = findings() - p.run(CONFORM, "declare", "BOARD.md") - rc_after, after = findings() - self.assertEqual(before["findings"], after["findings"]) - self.assertEqual(rc_before, rc_after) - self.assertEqual(rc_before, 0, before["findings"]) - self.assertEqual(before["conformance"]["declared"], 0) - self.assertEqual(after["conformance"]["declared"], 1) - - -# ── 10b · a decorated row is not a declaration, and never becomes one ────── -# -# **TASK-241's suite, moved to the door it now guards, not deleted.** -# -# Every test below was written against `read_conformance` while the record was -# `.perry/conformance.md`. TASK-234 made the record a store, and its subject -# MOVED rather than disappeared: the markdown is still on disk in every project -# written before the conversion, it is still read exactly once — by -# `perry-conform migrate`, through `read_legacy_conformance`, which is TASK-241's -# reader unchanged — and a row that fools that reader is now laundered into a -# JSON declaration nothing downstream can tell from a real one. That is TASK-241's -# harm at a ONE-WAY DOOR instead of at a re-runnable read. -# -# So each test keeps its shape and gains a second assertion. Both layers matter -# and each can go red alone: -# -# 1. the READER still refuses the row — the round trip and the fence rule, -# unchanged, now measured on `read_legacy_conformance`; -# 2. the CONVERSION refuses the FILE — the whole-file fixed point, which is -# what catches the shapes the per-row round trip is blind to BY -# CONSTRUCTION (a fenced row, and TASK-248's `
` / HTML comment /
-#      `
` row, are byte-for-byte genuine rows). -# -# Layer 2 is not a substitute for layer 1: a fixed-point check with the reader -# reverted would convert a decorated row that round-trips to itself, which is -# exactly `test_an_asterisked_path_reads_exactly_as_it_did_before` below. - - -class TestADecoratedRowIsNotADeclaration(unittest.TestCase): - """`read_legacy_conformance` stripped each cell with ``strip("` ")``, so a - row whose path cell was in BACKTICKS parsed to the same plain key as a row a - person had declared on purpose. Same for an INDENTED row (`_CONFORMANCE_ROW` - is `^\\s*\\|`) and for a row inside a ``` FENCE (this reader tracked none). - - Found by the `TASK-226` V4 reviewer, who measured the harm: one hand-written - backticked row flips a real file from `undeclared` to **conformant**, and - because the writer rewrote the whole record from the parsed declarations, - the next legitimate declare **laundered** it into a canonical row nothing - downstream can tell from a real one. The record's own header invited hand - editing — *"Delete a row to withdraw a declaration"* — so this is reachable - by design, not contrivance. - - **THREE SHAPES, THREE TESTS.** One test covering all three would still pass - with two of the three regressed, and the three are stopped by two different - mechanisms — the row round trip catches decoration written INSIDE the row, - and only fence tracking catches the fenced row, which is byte-for-byte - identical to a genuine one. - - **Each test carries its own control.** It first plants the UNDECORATED row - and asserts that it really does read as a declaration and convert cleanly — - so the trap is proved live in the same test that proves it closed, and none - of these can pass because the reader stopped reading, because the fixture - stopped being lint-clean, or because the row was malformed for some fourth - reason. - - **Shape 3 has more than one spelling, and the first fix only closed one.** - That fix was a boolean toggle flipped by any fence-looking line, so a fence - NESTED in another — which is how every markdown document that shows a fenced - block writes it — turned tracking off and gave the row back. Six further - spellings were measured live on that fix (§ "the fence has to be markdown's - fence" below) and each has its own test, because a single test over all of - them would pass with five regressed. - """ - - VER = C.shape_version(SCHEMA) - - def plant(self, body: str) -> tuple: - """A project whose MARKDOWN record is exactly the real header plus - `body`, and what the legacy reader makes of it. - - Returns `(project, keys honoured, number of unreadable rows)` — the two - halves of what the conversion would be allowed to carry across.""" - p = Project() - p.legacy_marker().write_text( - "\n".join(C.LEGACY_HEADER) + "\n" + body) - rec = C.P.read_legacy_conformance(p.root) - self._keep = p - return p, sorted(rec.declarations), len(rec.unreadable) - - def canonical(self) -> str: - return f"| BOARD.md | {self.VER} | 2026-08-28 | declare |\n" - - def assert_conversion_refuses(self, p, why: str, names: str | None = None): - """`perry-conform migrate` refuses, NOTHING was written, **and the - refusal names the offending line and a command that fixes it.** - - Exit code and both files, not just the message: a crash and a refusal - both print no declaration, and a conversion that wrote the store and - then reported a problem would have already gone through the door. - - **The last two assertions were missing and that is why the FAIL - happened.** This helper checked only `"refused" in out`, so a refusal - that named `perry-conform status` — a command that computes no diff and - reports nothing about the markdown's contents — shipped past the whole - suite. `bin/perry-conform § message_for` states the standard in the - same file the violation was in: *"a gate that says 'not conformant' and - stops is a wall — every branch here ends in a command the reader can - run."* Applying it here is what the helper is for. - - `names` is the exact text the reader has to find in their file, when - the caller knows it. A refusal that prints a diff of the WRONG lines is - a refusal that passes every assertion above. - """ - rc, out, err = p.run(CONFORM, "migrate") - self.assertEqual(rc, 1, f"{why}: the conversion did not refuse ({out})") - self.assertIsInstance(out, dict, f"migrate printed no JSON: {out} {err}") - self.assertIn("refused", out, why) - self.assertFalse(p.marker().exists(), - f"{why}: the store was written anyway") - self.assertTrue(p.legacy_marker().exists(), - f"{why}: the markdown record was deleted anyway") - self.assertEqual(p.verdict("BOARD.md").state, C.UNDECLARED) - - message = out["refused"] - self.assertIn("perry-conform migrate", message, - f"{why}: the refusal names no command to run — a wall") - # **Named is not enough: it has to be the command THIS reader can - # run.** Every invocation of this helper runs with `--root `, - # and for three rounds every one of them asserted only that some - # `perry-conform migrate` appeared in the message — which was true, and - # was not about the reader's situation in the test. The shipped - # refusal named the command with the root DROPPED, so a reader who - # copied it got `rc=0` and "nothing to convert" about a different - # project. Checked generically rather than for `migrate` alone: a - # refusal that grows a NEW command tomorrow is caught by the same - # assertion. - assert_every_command_carries(self, message, p.root, why) - self.assertNotIn( - "perry-conform status", message, - f"{why}: the refusal points at `status`, which computes no diff " - f"and reports nothing about the markdown's contents") - # **The refusal must LOCATE the problem**, by one of the two shapes - # this tool has: a numbered line (the unreadable-rows branch, which - # always did) or a unified diff (the fixed-point branch, which did not - # and is the FAIL this helper failed to catch). - self.assertRegex( - message, - r"(--- " + re.escape(C.P.CONFORMANCE_LEGACY_FILE) + r"|line \d+:)", - f"{why}: the refusal locates nothing — neither a line number nor a " - f"diff — so the way forward is reading the file by eye while " - f"`declare`, `perry-migrate apply` and every gate call site refuse") - if names is not None: - self.assertIn(names, message, - f"{why}: the refusal does not quote the offending " - f"line {names!r} — locating the WRONG line passes " - f"every other assertion here") - - # ── the control, shared by all three ────────────────────────────────── - - def assert_trap_would_have_worked(self): - """The undecorated row. If this stops reading as a declaration and - converting cleanly, every test below is vacuous — so every test below - runs it first.""" - p, keys, unreadable = self.plant(self.canonical()) - self.assertEqual( - (keys, unreadable), (["BOARD.md"], 0), - "the control row no longer reads as a declaration — the tests " - "below would pass for the wrong reason") - rc, out, err = p.run(CONFORM, "migrate") - self.assertEqual(rc, 0, f"the control conversion refused: {out} {err}") - self.assertEqual(p.verdict("BOARD.md").state, C.CONFORMANT, - "the control row did not survive the conversion — " - "the tests below would pass for the wrong reason") - - # ── shape 1 ─────────────────────────────────────────────────────────── - - def test_a_backticked_path_cell_is_not_a_declaration(self): - self.assert_trap_would_have_worked() - p, keys, unreadable = self.plant( - f"| `BOARD.md` | {self.VER} | 2026-08-28 | declare |\n") - self.assertEqual(keys, [], "a backticked path cell still declares a file") - self.assertEqual(unreadable, 1, - "the row was dropped silently instead of reported") - self.assert_conversion_refuses( - p, "a backticked path cell", names="| `BOARD.md` |") - - # ── shape 2 ─────────────────────────────────────────────────────────── - - def test_an_indented_row_is_not_a_declaration(self): - self.assert_trap_would_have_worked() - p, keys, unreadable = self.plant(" " + self.canonical()) - self.assertEqual(keys, [], "an indented row still declares a file") - self.assertEqual(unreadable, 1, - "the row was dropped silently instead of reported") - self.assert_conversion_refuses( - p, "an indented row", names=self.canonical().strip()) - - # ── shape 3 ─────────────────────────────────────────────────────────── - - def test_a_row_inside_a_code_fence_is_not_a_declaration(self): - self.assert_trap_would_have_worked() - p, keys, unreadable = self.plant("```\n" + self.canonical() + "```\n") - self.assertEqual(keys, [], - "a row inside a code fence still declares a file") - self.assertEqual(unreadable, 1, - "the row was dropped silently instead of reported") - self.assert_conversion_refuses( - p, "a fenced row", names=self.canonical().strip()) - - # ── the fence has to be markdown's fence ────────────────────────────── - # - # Every test in this block was measured GREEN-side-up on the first version - # of the guard — that is, the row declared `BOARD.md` and `unreadable` was - # 0, exactly as if no guard existed — because the toggle closed on a line - # that markdown does not close on. They are six different ways to write the - # same lie, and they get six tests. - - def test_a_backtick_fence_nested_in_a_tilde_fence_is_still_a_fence(self): - """`~~~` opens; the ``` ``` ``` under it is CONTENT, not a close — a - different delimiter character cannot close. This is the plainest way a - document shows a fenced block: wrap it in the other fence character.""" - self.assert_trap_would_have_worked() - p, keys, unreadable = self.plant( - "~~~\n```\n" + self.canonical() + "```\n~~~\n") - self.assertEqual(keys, [], - "a backtick fence inside a tilde fence closed it") - self.assertEqual(unreadable, 1) - self.assert_conversion_refuses( - p, "a backtick fence in a tilde fence", - names=self.canonical().strip()) - - def test_a_three_backtick_line_inside_a_four_backtick_fence_is_still_a_fence(self): - """The other plain way: open with a LONGER run. A close must be at - least as long as the open, so ``` inside ```` is content.""" - self.assert_trap_would_have_worked() - p, keys, unreadable = self.plant( - "````\n```\n" + self.canonical() + "````\n") - self.assertEqual(keys, [], "a short fence run closed a longer fence") - self.assertEqual(unreadable, 1) - self.assert_conversion_refuses( - p, "a short run inside a longer fence", - names=self.canonical().strip()) - - def test_a_tilde_fence_nested_in_a_backtick_fence_is_still_a_fence(self): - """The mirror of the first, and it is not the same test: the toggle was - symmetric but the rule is not, so a fix that keyed on the character - could close one direction and leave the other open.""" - self.assert_trap_would_have_worked() - p, keys, unreadable = self.plant( - "```\n~~~\n" + self.canonical() + "~~~\n```\n") - self.assertEqual(keys, [], - "a tilde fence inside a backtick fence closed it") - self.assertEqual(unreadable, 1) - self.assert_conversion_refuses( - p, "a tilde fence in a backtick fence", - names=self.canonical().strip()) - - def test_a_fence_line_with_trailing_text_does_not_close_the_fence(self): - """An info string is allowed on the OPENING fence only. ```` ```x ```` - inside an open fence is a content line — and it is exactly what an - example showing an opening fence looks like.""" - self.assert_trap_would_have_worked() - p, keys, unreadable = self.plant( - "```\n```x\n" + self.canonical() + "```\n") - self.assertEqual(keys, [], - "a fence line with an info string closed a fence") - self.assertEqual(unreadable, 1) - self.assert_conversion_refuses( - p, "a fence line with an info string", - names=self.canonical().strip()) - - def test_a_four_space_indented_fence_line_does_not_close_the_fence(self): - """A closing fence may be indented at most three spaces. At four it is - content — which is how a fenced block nested in a list item or a - blockquote-free indent appears.""" - self.assert_trap_would_have_worked() - p, keys, unreadable = self.plant( - "```\n ```\n" + self.canonical() + "```\n") - self.assertEqual(keys, [], - "a four-space-indented fence line closed a fence") - self.assertEqual(unreadable, 1) - self.assert_conversion_refuses( - p, "a four-space-indented fence line", - names=self.canonical().strip()) - - def test_a_whole_table_inside_a_nested_fence_declares_nothing(self): - """The shape that decided the mechanism. - - A document does not show one bare row; it shows the table — header, - delimiter, row. This is why the reader tracks FENCES and not "rows in - the contiguous run under the `| File |` header": measured, that rule - closes every bare-row shape above and then reads THIS one as a - declaration, because the fenced example brings its own header and so - starts its own run. Both rows must be refused, and reported.""" - self.assert_trap_would_have_worked() - p, keys, unreadable = self.plant( - "~~~\n```\n" - "| File | Shape version | Declared | Route |\n" - "|---|---|---|---|\n" + self.canonical() - + "```\n~~~\n") - self.assertEqual(keys, [], - "an example table in a nested fence declared a file") - self.assertEqual(unreadable, 2, - "the fenced rows were dropped silently, not reported") - self.assert_conversion_refuses( - p, "an example table in a nested fence", - names=self.canonical().strip()) - - # ── and the two the corner sweep says must stay shut ────────────────── - # - # CommonMark says neither of these OPENS a fence. This reader opens on both - # anyway, deliberately: an unsure line costs a loud `unreadable` if we treat - # it as a fence and a false `conformant` if we do not, and this is the file - # that gates every write. Named, because "be liberal about opening" is the - # half of the rule that a later tidy-up toward strict CommonMark would - # delete without noticing it had reopened anything. - - def test_a_four_space_indented_fence_still_opens_one(self): - self.assert_trap_would_have_worked() - p, keys, unreadable = self.plant( - " ```\n" + self.canonical() + " ```\n") - self.assertEqual(keys, [], - "a four-space-indented fence stopped opening one") - self.assertEqual(unreadable, 1) - self.assert_conversion_refuses( - p, "a four-space-indented opener", - names=self.canonical().strip()) - - def test_a_backtick_fence_with_a_backtick_in_its_info_string_still_opens_one(self): - self.assert_trap_would_have_worked() - p, keys, unreadable = self.plant( - "```a`b\n" + self.canonical() + "```\n") - self.assertEqual(keys, [], - "a backtick in the info string stopped opening a fence") - self.assertEqual(unreadable, 1) - self.assert_conversion_refuses( - p, "a backtick in the info string", - names=self.canonical().strip()) - - # ── the shape the round trip is blind to BY CONSTRUCTION (TASK-248) ──── - - def test_a_canonical_row_inside_an_html_block_is_not_carried_across(self): - """**TASK-248's shape, and the reason the conversion is a FILE-level - fixed point rather than the per-row round trip.** - - A bare canonical row inside `
`, an HTML comment or `
` is - byte-for-byte a genuine row, so the round trip honours it and always - did: measured `conformant` with 0 unreadable at the fork point, at - TASK-241 round 1 and at round 2. It is not a regression and no - predicate over the row can see it — what makes it not a declaration is - what surrounds it, exactly as for a fenced row. - - Three spellings, one test each would be better and one test here is - honest about what it measures: they are one mechanism away, and the - mechanism is that the lines around the row are not in `render_legacy`'s - output. Each spelling is asserted separately below so a fix that closed - one would still go red on the other two.""" - for name, wrap in ( - ("
", "
\n%s
\n"), - ("an HTML comment", "\n"), - ("
", "
\n%s
\n")): - with self.subTest(html=name): - p, keys, unreadable = self.plant(wrap % self.canonical()) - # The reader HONOURS it — stated, not hidden, because it is - # what makes the file-level check load-bearing rather than - # belt-and-braces. - self.assertEqual( - keys, ["BOARD.md"], - f"a row inside {name} stopped reading as a row — then this " - f"test no longer measures the shape it exists for") - self.assertEqual(unreadable, 0) - self.assert_conversion_refuses( - p, f"a row inside {name}", names="-" + wrap.split("%s")[0].strip()) - - # ── a cell that cannot be written back at all ───────────────────────── - - def test_a_path_cell_that_cannot_be_written_back_is_reported_not_crashed(self): - """`read_legacy_conformance` splits the record on `"\\n"`; `render_row` - refuses through `line_break_at`, which uses `str.splitlines()` — - **eleven** boundaries, not one. So a path cell holding `U+2028` sits - inside a single line for the reader and makes the canonical form - unwritable. - - Without the `except UnrenderableCell` the round trip raises straight out - of the reader and `perry-conform` dies with a traceback on a - hand-edited record — on the tool the enforce gate calls. This test - exists because the RESULT for round 1 claimed nothing it added could be - deleted with the suite unchanged, and a reviewer deleted this guard with - the suite unchanged. Asserts the exit code of BOTH surfaces that read - the file: a crash and a refusal both produce no declaration.""" - p, keys, unreadable = self.plant( - f"| BOARD
.md | {self.VER} | 2026-08-28 | declare |\n") - self.assertEqual(keys, []) - self.assertEqual(unreadable, 1, - "the unwritable row was dropped instead of reported") - rc, out, err = p.run(CONFORM, "status") - self.assertEqual(rc, 0, f"status crashed on the record: {err}") - self.assertIsInstance(out, dict, f"status printed no JSON: {out} {err}") - self.assert_conversion_refuses(p, "a cell that cannot be written back") - - # ── the harm the shapes lead to ─────────────────────────────────────── - - def test_a_nested_fence_row_is_not_laundered_by_the_next_declare(self): - """The laundering came back with the nesting, so it is measured again - against the shape that reopened it — and against the writer that can - still do it, which is the conversion `declare` runs before it writes. - - The declare here is of a DIFFERENT file — a legitimate one — because - that is the whole point: the user does something entirely ordinary and - the record quietly canonicalises a claim nobody made.""" - p, _, _ = self.plant( - "~~~\n```\n" + self.canonical() + "```\n~~~\n") - rc, out, err = p.run(CONFORM, "declare", ".perry/hook.md") - self.assertEqual(rc, 1, f"the declare went through: {out} {err}") - self.assertIn("refused", out) - self.assertFalse(p.marker().exists(), - "the fenced row was laundered into a store line") - self.assertEqual(p.verdict("BOARD.md").state, C.UNDECLARED) - self.assertEqual(p.verdict(".perry/hook.md").state, C.UNDECLARED, - "a record it refuses to convert must not be half " - "converted with the new declaration on top") - - def test_a_planted_row_is_not_laundered_by_the_next_declare(self): - """The second half of the measured defect, and the worse half: after - the rewrite the row is indistinguishable from one a person wrote.""" - p, _, _ = self.plant( - f"| `BOARD.md` | {self.VER} | 2026-08-28 | declare |\n") - rc, out, err = p.run(CONFORM, "declare", ".perry/hook.md") - self.assertEqual(rc, 1, f"the declare went through: {out} {err}") - self.assertFalse(p.marker().exists(), - "the decorated row was laundered into a store line") - self.assertEqual(p.verdict("BOARD.md").state, C.UNDECLARED) - - # ── and the case that must NOT change ───────────────────────────────── - - def test_an_asterisked_path_reads_exactly_as_it_did_before(self): - """``strip("` ")`` never removed asterisks, so `| **BOARD.md** |` has - always parsed to the decorated key `**BOARD.md**` — inert, because no - key `state_files()` produces carries asterisks. TASK-226 filed it as an - observation and it stays one. The guard is about rows that reach a - REAL key; widening it to reject asterisks too would be a different - change, and the round trip deliberately lets this row through because - it is already exactly what the writer would write for that key. - - **This is also what proves the file-level fixed point is not a - substitute for the row round trip.** This row survives the file check — - the file IS what `render_legacy` would write — so the only thing - standing between a decorated row and a real key is the round trip.""" - p, keys, unreadable = self.plant( - f"| **BOARD.md** | {self.VER} | 2026-08-28 | declare |\n") - self.assertEqual(keys, ["**BOARD.md**"]) - self.assertEqual(unreadable, 0) - rc, out, err = p.run(CONFORM, "migrate") - self.assertEqual(rc, 0, f"the conversion refused a fixed point: {out}") - self.assertEqual(list(C.P.read_conformance(p.root).declarations), - ["**BOARD.md**"], - "the inert key stopped travelling across unchanged") - self.assertEqual(p.verdict("BOARD.md").state, C.UNDECLARED, - "the asterisked row started flipping a real verdict") - - def test_a_bolded_header_row_is_still_not_a_row(self): - """`squash` answers this and answered it before TASK-241 (TASK-050). - Here so that a guard added ABOVE the header check — where it would - report the header as an unreadable row — cannot land green.""" - p = Project() - p.legacy_marker().write_text( - "# Perry conformance\n\n" - "| **File** | **Shape version** | **Declared** | **Route** |\n" - "|---|---|---|---|\n" + self.canonical()) - rec = C.P.read_legacy_conformance(p.root) - self.assertEqual(list(rec.declarations), ["BOARD.md"]) - self.assertEqual(rec.unreadable, []) - # And the conversion still refuses it, because a hand-edited header is - # not what the writer wrote — two independent reasons this file is not - # carried across, and neither one hides the other. - self.assert_conversion_refuses(p, "a hand-edited header") - - def test_perrys_own_record_is_read_without_a_single_refusal(self): - """The guard is strict, and a strict guard that refuses the real file - would take the enforce gate down for this repository. Every line of the - shipped record must still read — and the markdown it was converted from - must be gone, because two registers for this fact is the defect - TASK-234 exists to remove.""" - rec = C.P.read_conformance(PERRY_HOME) - self.assertTrue(rec.exists, "Perry's own record was never converted") - self.assertEqual(rec.unreadable, [], - "the reader refuses lines in Perry's own record") - self.assertGreater(len(rec.declarations), 0) - self.assertIsNone(rec.stray_legacy, - "the markdown record is still on disk beside the " - "store — two registers for the gating fact") - - -# ── 11 · is_adopted still answers its own question ──────────────────────── - - -class TestIsAdoptedIsNotReplaced(unittest.TestCase): - """TASK-045 deletes tolerance branches; this task deletes nothing. The old - predicate keeps its old meaning and its old callers.""" - - def test_is_adopted_still_answers_does_this_folder_hold_perry_state(self): - L = load("perry_lint_under_test", LINT) - p = Project() - self.assertTrue(L.is_adopted(p.root, p.root), - "is_adopted stopped answering its own question") - self.assertNotEqual(p.verdict().state, C.CONFORMANT, - "the two predicates collapsed into one") - - - -# ── 12 · the record is a store (TASK-234) ───────────────────────────────── - - -class TestTheRecordIsAStore(unittest.TestCase): - """DESIGN-013 § 5.1 on the cheapest file it applies to. - - The record was 23 rows of four regular columns under a header that was - already a constant in the writer, with no per-row prose at all — so the - rule's document side has nothing to weigh. What the table cost was a - parser, and the parser is where TASK-241 and TASK-248 lived. - """ - - def stored(self, p) -> list[dict]: - return [json.loads(l) for l in p.marker().read_text().split("\n") - if l.strip()] - - def test_one_json_object_per_line_with_the_declared_fields(self): - p = Project() - p.run(CONFORM, "declare", "BOARD.md") - rows = self.stored(p) - self.assertEqual(len(rows), 1) - self.assertEqual(list(rows[0]), list(C.P.CONFORMANCE_FIELDS), - "the store's field order is not the declared one") - self.assertEqual(rows[0]["kind"], C.P.CONFORMANCE_KIND) - self.assertEqual(rows[0]["path"], "BOARD.md") - self.assertIsInstance(rows[0]["shape_version"], int, - "the version is a JSON number, not a string — " - "storing it as text would put back the ambiguity " - "the table's `\\d+` had to police") - - def test_a_declaration_records_who_wrote_it_and_when(self): - """**The point of the conversion, not a bonus for having done it.** - - `TASK-226` — where did this row come from — was an investigation - because four regular columns could not answer it. A record that can is - the reason DESIGN-013 § 5.1 was worth applying to this file.""" - p = Project() - p.run(CONFORM, "declare", "BOARD.md") - row = self.stored(p)[0] - self.assertEqual(row["writer"], "perry-conform declare") - self.assertTrue(row["recorded_at"], "the moment was not recorded") - self.assertIsNotNone( - __import__("datetime").datetime.fromisoformat(row["recorded_at"]), - "the moment is not an ISO timestamp") - self.assertNotEqual( - row["recorded_at"], row["declared"], - "`recorded_at` is the MOMENT and `declared` is the day — a store " - "that made them the same string would have carried nothing new") - - def test_a_malformed_line_does_not_void_its_neighbours(self): - """**Per line, not all-or-nothing**, and this is TASK-241 round 2's - measurement carried across the format change: under a whole-file rule - one stray line voids all 23 of Perry's real declarations and takes the - enforce gate down with them. Here it voids one and says which.""" - p = Project() - p.marker().parent.mkdir(exist_ok=True) - p.marker().write_text( - "{ not json at all\n" - + p.line("BOARD.md") - + p.line(".perry/hook.md")) - rec = C.P.read_conformance(p.root) - self.assertEqual(sorted(rec.declarations), [".perry/hook.md", "BOARD.md"], - "a malformed line voided the lines around it") - self.assertEqual([n for n, _ in rec.unreadable], [1], - "the malformed line was dropped silently") - self.assertEqual(p.verdict("BOARD.md").state, C.CONFORMANT) - - def test_a_line_that_is_not_a_declaration_is_reported_not_skipped(self): - """Four shapes, one property: refused, and said out loud. A line that - is neither `declared` nor `absent` must not read as either.""" - for name, line in ( - ("not JSON", "{ not json at all\n"), - ("not an object", '["BOARD.md", 2]\n'), - ("a foreign kind", '{"kind": "setting", "path": "BOARD.md", ' - '"shape_version": 2, "declared": "2026-08-28", ' - '"route": "declare"}\n'), - ("a string version", '{"kind": "declaration", "path": "BOARD.md", ' - '"shape_version": "2", "declared": ' - '"2026-08-28", "route": "declare"}\n')): - with self.subTest(shape=name): - p = Project() - p.marker().parent.mkdir(exist_ok=True) - p.marker().write_text(line) - rec = C.P.read_conformance(p.root) - self.assertEqual(rec.declarations, {}, name) - self.assertEqual(len(rec.unreadable), 1, - f"{name} was dropped silently") - self.assertEqual(p.verdict("BOARD.md").state, C.UNDECLARED) - - def test_two_lines_for_one_path_are_unreadable_rather_than_last_one_wins(self): - """They disagree about when the file was declared. A reader that - silently picked one would make the record's answer depend on line - order, which is the shape of a defect nobody can reproduce.""" - p = Project() - p.marker().parent.mkdir(exist_ok=True) - p.marker().write_text(p.line(declared="2026-08-01") - + p.line(declared="2026-08-28")) - rec = C.P.read_conformance(p.root) - self.assertEqual(len(rec.unreadable), 1) - self.assertEqual(rec.declarations["BOARD.md"].declared, "2026-08-01", - "the SECOND line won, so the record's answer depends " - "on the order somebody typed two lines in") - - def test_a_blank_line_is_layout_and_not_a_finding(self): - """A trailing newline is how every jsonl this project writes ends, and - a store that reported its own last byte as unreadable would report a - finding against every correct file.""" - p = Project() - p.marker().parent.mkdir(exist_ok=True) - p.marker().write_text("\n" + p.line() + "\n\n") - rec = C.P.read_conformance(p.root) - self.assertEqual(rec.unreadable, []) - self.assertEqual(list(rec.declarations), ["BOARD.md"]) - - -class TestTheRecordIsNotDeclarableAboutItself(unittest.TestCase): - """`schema/state-schema.json` says the record is deliberately NOT a - `files[]` entry: *"it is a record of the user's decisions ABOUT state, not - state, and listing it here would make it declarable-conformant about - itself."* TASK-234 had to carry that across a format change EXPLICITLY - rather than let it lapse, and it is what makes the conversion possible at - all — the gate has no opinion about a file that is not a `files[]` entry, - so the write that migrates the record needs no exemption. - """ - - def test_the_record_is_not_a_files_entry(self): - paths = {spec["path"] for spec in SCHEMA["files"]} - for name in (C.P.CONFORMANCE_FILE, C.P.CONFORMANCE_LEGACY_FILE): - with self.subTest(record=name): - self.assertNotIn( - name, paths, - f"{name} became a files[] entry, so it is now declarable " - f"conformant about itself and the write that migrates it " - f"is gated on its own verdict") - - def test_no_writer_gates_on_the_record(self): - """The bootstrap property, measured rather than asserted: `state_files` - never yields the record, so no `gate()` call can ever be about it.""" - p = Project() - p.run(CONFORM, "declare", "BOARD.md") - keys = [k for k, _, _ in - C.state_files(p.root, p.root, SCHEMA)] - self.assertIn("BOARD.md", keys, "the fixture yields no files at all") - for name in (C.P.CONFORMANCE_FILE, C.P.CONFORMANCE_LEGACY_FILE): - self.assertNotIn(name, keys) - self.assertEqual(C.verdict(p.root, p.root, C.P.CONFORMANCE_FILE, - SCHEMA).state, C.ABSENT, - "the record has a verdict of its own") - - def test_the_record_is_not_a_claim_of_its_own_and_does_not_need_one(self): - """The SEPARATE question, with its own answer (TASK-234). - - `claims[]` asks what territory Perry occupies in someone else's - project, and `.perry/` is already claimed as a dir — so the store is - covered exactly as the markdown was, which - `TestTheRecordIsReadHonestly § test_the_record_is_not_reported_as_ - someone_elses_file` measures. An entry of its own would add nothing the - collision check can see and would add a seventh store to the six that - `perry/phase/003-linkage.md`'s KR1, KR2 and KR3 are each phrased - *"of 6"* over. Moving that denominator is the goals lane's decision, - not a side effect of a format change.""" - claimed = {c["path"] for c in SCHEMA["claims"]} - self.assertNotIn(C.P.CONFORMANCE_FILE, claimed) - self.assertIn(".perry/", claimed, - "the territory that covers the record is unclaimed") - stores = sorted(c["path"] for c in SCHEMA["claims"] - if c["path"].endswith(".jsonl") - and c["path"] != ".perry/events.jsonl") - self.assertEqual( - len(stores), 6, - f"the number of claimed stores moved to {len(stores)} ({stores}); " - f"perry/phase/003-linkage.md's KR1, KR2 and KR3 are each phrased " - f"'of 6' and are now wrong") - - -class TestTheMarkdownRecordIsConvertedOnce(unittest.TestCase): - """Bootstrap order, which the row had to settle before any code (§ 5.1). - - A project written before TASK-234 keeps its declarations in - `.perry/conformance.md`. The store reader does not read it — a fallback - would be a second live register for the fact that gates every write, and - would carry TASK-248's hole for as long as any project left the markdown in - place. So the project is `undeclared` until it converts, and the refusal - names `perry-conform migrate` rather than `perry-conform declare`, because - `declare` would mint a declaration dated today over one the user made weeks - ago. - """ - - #: **Sorted by path, because that is what the writer wrote.** The - #: conversion's fixed point is byte-for-byte, so a record whose rows a hand - #: has reordered is one this tool cannot say it is copying — measured here - #: the first time this fixture was written the other way round. - LEGACY = ("| .perry/hook.md | 2 | 2026-08-21 | declare |\n" - "| BOARD.md | 2 | 2026-08-20 | migrate |\n") - - def legacy_project(self) -> Project: - p = Project() - p.legacy_marker().write_text("\n".join(C.LEGACY_HEADER) + "\n" + self.LEGACY) - return p - - def test_the_markdown_alone_declares_nothing(self): - p = self.legacy_project() - self.assertEqual(C.P.read_conformance(p.root).declarations, {}, - "the markdown is still being read as a register") - self.assertEqual(p.verdict("BOARD.md").state, C.UNDECLARED) - - def test_the_refusal_names_migrate_and_not_declare(self): - p = self.legacy_project() - rc, out, _ = p.run(TASK, *ADD, enforce=True) - self.assertEqual(rc, 1) - self.assertIn("perry-conform migrate", out["refused"]) - self.assertNotIn("perry-conform declare", out["refused"], - "the refusal names the command that would mint a new " - "declaration over the user's own") - self.assertIn(".perry/conformance.md", out["refused"], - "the refusal does not say which file it is talking about") - - def test_the_conversion_carries_every_date_and_route_unchanged(self): - p = self.legacy_project() - rc, out, err = p.run(CONFORM, "migrate") - self.assertEqual(rc, 0, f"{out} {err}") - stored = C.P.read_conformance(p.root).declarations - self.assertEqual( - {k: (d.shape_version, d.declared, d.route) for k, d in stored.items()}, - {"BOARD.md": (2, "2026-08-20", "migrate"), - ".perry/hook.md": (2, "2026-08-21", "declare")}) - self.assertFalse(p.legacy_marker().exists(), - "two registers for the gating fact") - self.assertEqual(p.verdict("BOARD.md").state, C.CONFORMANT) - - def test_the_conversion_invents_no_provenance(self): - """The three new fields stay EMPTY on a converted row. The markdown - never held them, and a value stamped at conversion time would put a - fact in the record that nobody recorded — a writer of - `perry-conform migrate` and a moment weeks after the user decided.""" - p = self.legacy_project() - p.run(CONFORM, "migrate") - for row in [json.loads(l) for l in - p.marker().read_text().split("\n") if l.strip()]: - self.assertEqual((row["writer"], row["recorded_at"], row["run"]), - ("", "", ""), row["path"]) - - def test_the_conversion_declares_nothing_the_record_did_not_hold(self): - """`SKILL.md § Conformance gate` reserves the declaration to the user. - `migrate` is runnable by an agent precisely because it cannot mint one: - it writes the parsed record and nothing else, so a file the markdown - did not declare is undeclared afterwards.""" - p = self.legacy_project() - p.run(CONFORM, "migrate") - self.assertEqual(sorted(C.P.read_conformance(p.root).declarations), - [".perry/hook.md", "BOARD.md"]) - self.assertEqual(p.verdict(".perry/config.md").state, C.UNDECLARED) - - def test_declaring_converts_first_and_says_so(self): - """The one writer of the record is the one place the conversion can - live without becoming a second one.""" - p = self.legacy_project() - rc, out, err = p.run(CONFORM, "declare", ".perry/config.md") - self.assertEqual(rc, 0, f"{out} {err}") - self.assertEqual(out["converted"]["declarations"], 2, - "the conversion was silent") - stored = C.P.read_conformance(p.root).declarations - self.assertEqual(sorted(stored), - [".perry/config.md", ".perry/hook.md", "BOARD.md"]) - self.assertEqual(stored["BOARD.md"].declared, "2026-08-20", - "the user's own date was overwritten") - self.assertEqual(stored[".perry/config.md"].writer, - "perry-conform declare") - - def test_a_dry_run_converts_nothing(self): - p = self.legacy_project() - rc, out, _ = p.run(CONFORM, "declare", ".perry/config.md", "--dry-run") - self.assertEqual(rc, 0) - self.assertFalse(p.marker().exists()) - self.assertTrue(p.legacy_marker().exists()) - - def test_converting_twice_is_a_no_op_and_deletes_nothing(self): - p = self.legacy_project() - p.run(CONFORM, "migrate") - before = p.marker().read_text() - rc, out, _ = p.run(CONFORM, "migrate") - self.assertEqual(rc, 0) - self.assertIsNone(out["converted"]) - self.assertEqual(p.marker().read_text(), before) - - def test_a_markdown_beside_a_store_is_reported_and_not_read(self): - """Two registers for the fact that gates every write. The store is the - record; the markdown is named because a user editing it would be - editing nothing and would have no way to find that out.""" - p = self.legacy_project() - p.run(CONFORM, "migrate") - p.legacy_marker().write_text( - "\n".join(C.LEGACY_HEADER) + "\n" - + "| .perry/config.md | 2 | 2026-08-20 | declare |\n") - rec = C.P.read_conformance(p.root) - self.assertEqual(rec.stray_legacy, p.legacy_marker()) - self.assertNotIn(".perry/config.md", rec.declarations, - "the markdown beside the store is being read") - rc, out, _ = p.run(CONFORM, "status") - self.assertEqual(Path(out["stray_legacy_record"]).resolve(), - p.legacy_marker().resolve()) - - def test_a_stale_markdown_never_overwrites_a_store(self): - """The conversion runs on a project that has NO store. A project that - has both — a markdown restored from an old backup, a bad merge — must - keep the store: the markdown is by definition the older record, and - converting it again would silently roll every declaration back to - whatever it said then. Found by mutation: nothing stopped it. - """ - p = self.legacy_project() - p.run(CONFORM, "migrate") - p.run(CONFORM, "declare", ".perry/config.md") - store = p.marker().read_text() - self.assertIn(".perry/config.md", store) - p.legacy_marker().write_text( - "\n".join(C.LEGACY_HEADER) + "\n" + self.LEGACY) - - rc, out, err = p.run(CONFORM, "migrate") - - self.assertEqual(rc, 0, f"{out} {err}") - self.assertIsNone(out["converted"], "the store was converted over") - self.assertEqual(p.marker().read_text(), store, - "a stale markdown overwrote the store") - self.assertTrue(p.legacy_marker().exists(), - "the markdown was deleted by a conversion that did " - "not happen") - self.assertEqual(p.verdict(".perry/config.md").state, C.CONFORMANT, - "a declaration the store held was rolled back") - - def test_an_unreadable_row_is_refused_rather_than_deleted_at_the_door(self): - """The conversion will not carry a record it cannot say it is copying, - and refusing is the only honest answer at a one-way door: the - alternative is a rewrite that deletes a line the user typed.""" - p = Project() - p.legacy_marker().write_text( - "\n".join(C.LEGACY_HEADER) + "\n" + self.LEGACY - + "| OKR.md | v-two | 2026-08-20 | declare |\n") - rc, out, _ = p.run(CONFORM, "migrate") - self.assertEqual(rc, 1) - self.assertIn("will not honour", out["refused"]) - self.assertFalse(p.marker().exists()) - self.assertTrue(p.legacy_marker().exists(), - "the record was deleted anyway") - - -class TestTheRefusalNamesTheLine(unittest.TestCase): - """**The V4 FAIL, and the standard it broke.** - - The first version of this row shipped a refusal that told the reader to run - `perry-conform status`. Measured by the reviewer: `status` computes no - diff, reports nothing about the markdown's contents, and names - `perry-conform migrate` — the command that had just refused. No shipped - surface named the offending line, in text or `--json`; `status`, `check`, - `migrate`, `declare` and `perry-lint` were all checked. Meanwhile every - write path on such a project is closed: `declare` calls `migrate_record` - first and raises, `perry-migrate apply` refuses and rolls back, and all - three gate call sites refuse for want of a store. - - So the claim *"the cost of refusing is look at your file"* was false. The - measured cost was: read 37 lines by eye, with no tool help, while nothing - can write. And `bin/perry-conform § message_for` states the standard in the - same file: *"a gate that says 'not conformant' and stops is a wall — every - branch here ends in a command the reader can run."* - - It is reachable by ordinary editing: 7 of 9 plausible hand edits refuse, - and the edit the record's own header invites — *"delete a row to withdraw a - declaration"* — is one of the two that survive. - """ - - HEADER = None # set in setUp - - def record(self, body: str) -> Project: - p = Project() - p.legacy_marker().write_text("\n".join(C.LEGACY_HEADER) + "\n" + body) - return p - - CANON = ("| .perry/hook.md | 2 | 2026-08-21 | declare |\n" - "| BOARD.md | 2 | 2026-08-20 | declare |\n") - - def refusal(self, body: str) -> str: - p = self.record(body) - rc, out, err = p.run(CONFORM, "migrate") - self.assertEqual(rc, 1, f"the conversion did not refuse: {out} {err}") - return out["refused"] - - def test_the_canonical_record_still_converts(self): - """The control. Every test below asserts a REFUSAL, and a refusal is - free if the conversion refuses everything.""" - p = self.record(self.CANON) - rc, out, err = p.run(CONFORM, "migrate") - self.assertEqual(rc, 0, f"the control record refused: {out} {err}") - - def test_deleting_a_row_still_withdraws_a_declaration(self): - """The second control, and it is the edit the file's own header - invites. If this ever refuses, the header is lying to the user.""" - p = self.record("| BOARD.md | 2 | 2026-08-20 | declare |\n") - rc, out, err = p.run(CONFORM, "migrate") - self.assertEqual(rc, 0, f"deleting a row refused: {out} {err}") - self.assertEqual(list(C.P.read_conformance(p.root).declarations), - ["BOARD.md"]) - - def test_the_refusal_carries_a_diff_and_not_a_command_that_computes_none(self): - message = self.refusal(self.CANON + "\nre-declare OKR.md later\n") - self.assertIn("--- .perry/conformance.md", message, - "the refusal carries no diff") - self.assertIn("+++ what Perry reads out of it", message) - self.assertIn("-re-declare OKR.md later", message, - "the diff does not name the line the reader must delete") - self.assertIn("perry-conform migrate", message, - "the refusal names no command to run") - self.assertNotIn("perry-conform status", message, - "the refusal still points at a command that computes " - "no diff and says nothing about this file") - - def test_the_diff_says_which_direction_is_which(self): - """A hunk with no legend is a hunk the reader has to guess at.""" - message = self.refusal(self.CANON + "stray\n") - self.assertIn("`-` is your file", message) - self.assertIn("`+` is what Perry reads out of it", message) - - def test_each_plausible_hand_edit_is_located_by_the_diff(self): - """One subTest per edit, so a fix that located one would still go red - on the others.""" - for name, body, must_name in ( - ("a trailing blank line", self.CANON + "\n", "@@"), - ("a note under the table", - self.CANON + "\nreminder: check OKR.md\n", - "-reminder: check OKR.md"), - ("rows re-ordered by hand", - "| BOARD.md | 2 | 2026-08-20 | declare |\n" - "| .perry/hook.md | 2 | 2026-08-21 | declare |\n", - "-| .perry/hook.md | 2 | 2026-08-21 | declare |"), - ("a row hidden in an HTML comment", - self.CANON + "\n", - "- +2026-08-31 — **`P003-O3-KR1` withdrawn; Objective 3 keeps `P003-O3-KR2` only.** +*What*: the backfill KR — open `main`-track rows in neither `tasks[]` nor +`unlinked[]` — is removed from `phase/003-linkage.md`; the `add`-time linkage +gate stays. Phase KR count 8 → 7. *Why*: the gate is the mechanism that makes +attribution happen as ordinary project work; the backfill is a one-off cleanup +that the gate makes cheap afterwards. Doing the cleanup first spends the phase +on 45 answers and leaves the next 45 rows arriving the same way. The population +moved 45 → 7 during phase 003 **without the backfill being worked at all**, and +those 7 (TASK-253…TASK-259) are exactly the rows the gate would have caught at +`add`. *Who*: the user, asked directly and answering "去掉 KR1, 留下 KR2". The +cut is the one this phase's own KR-progress trigger already describes, taken at +day 4 instead of day 10. *Consequences*: DoD Must-Have 5 restated above; +PROJ-003-LINK's verification restated; the KR-progress trigger marked spent. +Pre-pivot state preserved at +`phase/snapshots/2026-08-31-003-storage-code.md`. + ## Mid-phase check ## Retro — phase scored diff --git a/perry/phase/snapshots/2026-08-31-003-storage-code.md b/perry/phase/snapshots/2026-08-31-003-storage-code.md new file mode 100644 index 00000000..0087dbbc --- /dev/null +++ b/perry/phase/snapshots/2026-08-31-003-storage-code.md @@ -0,0 +1,237 @@ +> Snapshot taken: 2026-08-31 10:52 · phase day 4 · KR progress 0/8 commit asserted (4/8 measured 2026-08-31, unasserted in the register) +# Phase #003 — storage-code + +> **Owner**: `goals` lane (only writer). `work` reads this every standup. +> **Started**: 2026-08-28 +> **Status**: active +> **Source**: `OKR.md` v2 (Objective 2 — every piece of state is queryable and writable by deterministic code) +> **Predecessor**: `phase/002-fields-are-typed.md` (scored 2026-08-28, mean 0.89) +> **Tier 1 hard cap**: ≤ 300 lines. + +## Phase Focus + +**Phase 002 made the stores exist. Phase 003 makes the code live on them.** + +Six projection stores are declared in `schema/state-schema.json § claims[]`. +Four exist on disk — `tasks.jsonl`, `okr.jsonl`, `risks.jsonl`, +`.perry/config.jsonl`. Two were built and never imported: `intake.jsonl` +(TASK-196) and `asks.jsonl` (TASK-197). And `perry-lint`, the one command that +is supposed to make ADR-007's guarantee checkable, gives a drift verdict for +**two of the six** — tasks and risks. `okr.jsonl` and `.perry/config.jsonl` +each have a working `diff` tool that the census does not call. + +Meanwhile the read side still treats rendered markdown as truth where a store +already exists: `parse_tracks` is called at four sites in `bin/` that use +`.perry/config.md` as the authority on a project's tracks, while +`.perry/config.jsonl` sits beside it holding the same nine records. + +The phase is scored when a store's existence, its drift verdict, and the code +path that reads it are all one answer instead of three. + +This phase does **not** migrate anyone else's project — `TASK-097` carries +forward untouched — and it does **not** stop Python parsing the document class +(`design/`, `DECISIONS.md`, `phase/`, `.perry/roles/`). Phase 002's Not Doing +deferred that to "a later phase"; this is not that phase either, and folding it +in would put a ten-section prose file in the same migration as a 35-column +table for the second time. + +## Operating Rules + +- **Count call sites, never names.** Phase 002's most expensive recurring + defect was locating an implementation by grepping its name — it recurred + roughly ten times, once over-counting and once missing a whole second + reporter. Every KR below names the expression or the call and, where the + baseline is small, the file and line. +- **A gate is not green until it has been shown able to go red.** Reverting the + line that implements a check must break its test. A pass that was never + falsifiable is not evidence (phase 002, lesson 4). +- **An absent store reports `unchecked`, never `clean`.** This is the property + under test in `P003-O1-KR3`, and it is also a rule for anything written this + phase. +- **Agent autonomy**: agents may delete, fence and re-point code paths inside + this repository, and may import Perry's own stores. Agents may **not** touch + any project other than Perry's own. +- **User authorization required for**: anything in `.perry/hook.md § + High-stakes operations` — publishing, history rewrites, host skill + installation, and writing into a project Perry does not own. +- **Evidence requirement**: every KR movement cites a command's output at + `evidence/2026-08/` or later, never a claim. + +## Cost Ceiling (phase #003) + +- Spend cap: **$0** in new paid APIs, models or infra. Perry is stdlib Python + and stays that way; a dependency is a decision, not an implementation detail. +- Wiring status: **doc-only**. Nothing in code refuses an import. +- ⚠ **Open risk, surfaced at every snapshot** per the goals-lane style rule: + the ceiling is a convention, not a guard. The one thing that makes it cheap + to hold is that the test suite already reads identically under + `PYTHONNOUSERSITE=1 /usr/bin/python3`, so a new dependency shows up as a red + suite rather than as silent drift. + +## User Commitments + +- **Decide the read-side promise for `.perry/config.md`.** `USER-903` settled + the *write* side — the file became a projection. `P003-O2-KR1` changes who + reads it: four call sites move from the markdown to `.perry/config.jsonl`. + `SKILL.md` promises the user owns that file directly, and this is the half of + that promise not yet decided. +- **V5 sign-off on the adoption-reader fence** (`P003-O2-KR2`). It changes + which code path a foreign project goes through, and phase 002's carried DoD + item exists because that path has never been tested on a real project. +- **Attribution answers for the 40 rows still never asked** (`P003-O3-KR1`). Perry + never guesses a KR; these can only be declared. +- **`git push`.** `feat/work-modes` is several hundred commits ahead of + `origin/main`; the remote and CI have seen none of this phase's predecessor. +- Phase scope-reduction trigger review, and phase-scoring participation. + +## User-Unavailable Degradation + +If user input is missing for >5 calendar days, work continues in this order: +**TASK-209 → TASK-095 → TASK-099 → TASK-050 → TASK-199**. Objectives 1 and 2 +are fully reachable without the user. + +**Objective 3 stalls by design, and that is not a defect.** `P003-O3-KR1` is +resolved by declaring an attribution, and `reference/okr-linkage.md` forbids +guessing one. An agent that filled `unlinked[]` to clear the number would be +recording a decision nobody made. New rows opened in the interim still take +`P003-O3-KR2`'s gate, because declaring a row unlinked at `add` time is the +author's own statement about their own row. + +## Phase Scope Reduction Rule + +- **KR-progress trigger**: if at phase day 10 the commit KRs of Objectives 1 + and 2 are <50% achieved, Objective 3 collapses to its Must-Have — the + `add`-time linkage gate (`P003-O3-KR2`) — and the backfill of the existing + never-asked rows defers to phase 004. +- **Phase-day trigger**: if by phase day 14 the read-side decision on + `.perry/config.md` is still open, `P003-O2-KR1` collapses to the two call + sites that are unambiguously internal (`bin/perry-state:139`, + `bin/perry-goals:2102`) and the two user-facing readers defer. + +--- + +## Objective 1 — Every declared store exists, and one command checks all of them + +ADR-007's guarantee is only as wide as the census that checks it. Today the +guarantee is checked for tasks and risks, unchecked for four, and the +difference is invisible unless you read the tail of a lint run. + +### Key Results + +> Declared in `phase/003-linkage.md`; `bin/perry-goals krs` prints them. Not written +> here — TASK-157 / DESIGN-013 § 5.1, a fact with a schema lives in one store. + +### Projects (seed for PMO TASK-IDs) + +- **TASK-209 — the drift census covers one store of five** + - Owner: Coding Agent · User role: none + - Deliverable: `check_store_drift` reports every declared projection store + - Verification: remove each store in turn; lint prints `unchecked` six times + +## Objective 2 — The code reads a store, not a rendered file + +### Key Results + +> Declared in `phase/003-linkage.md`; `bin/perry-goals krs` prints them. Not written +> here — TASK-157 / DESIGN-013 § 5.1, a fact with a schema lives in one store. + +**Two exclusions in `P003-O2-KR1`, both deliberate.** `bin/perry-tasks:1473` +parses `BOARD.md` to *compare* it against the store — a drift check must read +the rendered file by definition. `bin/perry-migrate:1177` / `:1188` parse a +foreign project's board and OKR, which is what adoption is. Phase 002's +`P002-O2-KR3` set a target of 0 parser lines and scored 0.68 against a number +that could not have been reached, because `TASK-094` had already proved the +adoption reader must stay. The exclusions are what make this target real. + +### Projects (seed for PMO TASK-IDs) + +- **TASK-095 — remove the parser for the three stores; keep what adoption needs** + - Owner: Coding Agent · User role: the read-side decision on `.perry/config.md` + - Deliverable: four `parse_tracks` call sites read `.perry/config.jsonl` + - Verification: the four named lines no longer call a markdown parser +- **TASK-099 — sweep `bin/`, `viewer/` and `tests/` for handling ADR-007 made dead** + - Owner: Coding Agent · Deliverable: the fence and its guard + - Verification: restoring one removed call site turns the guard red +- **TASK-050 — one normalization for a header cell, not two** + - Owner: Coding Agent · Deliverable: re-scoped to the adoption reader + - Verification: a mutation harness, not another regex +- **TASK-199 — `BOARD.md` carries two truth models and nothing marks the boundary** + - Owner: Coding Agent · Verification: the boundary is readable from the file + +## Objective 3 — The phase's KRs cover the work that actually runs + +Phase 002 declared 13 tasks and the board ran 47. At scoring, **43 open rows +resolved to no KR against 3 that did** — the phase's largest single signal, and +the retro's own conclusion was that phase 003 must either declare KRs the live +work serves or make the linkage step part of `add`. This Objective does both. +Re-measured at phase start the set is **45**, not 43 — the board opened 14 rows +between 002's scoring and this file. 43 is 002's number and is not carried. + +The number to drive to zero is not "unlinked rows". Work that serves no KR is a +legitimate, declarable state. The number is rows in **neither** state — never +linked, never declared — because that is the set nobody has ever been asked +about. + +### Key Results + +> Declared in `phase/003-linkage.md`; `bin/perry-goals krs` prints them. Not written +> here — TASK-157 / DESIGN-013 § 5.1, a fact with a schema lives in one store. + +### Projects (seed for PMO TASK-IDs) + +- **PROJ-003-LINK — attribution becomes part of opening a row** + - Owner: Coding Agent + User + - User role: declaring which KR a row serves — never guessed + - Deliverable: `add` resolves to exactly one KR or writes a declared `unlinked` + - Verification: `perry-state --section attribution` reports 0 never-asked rows + +## Definition of Done + +**Must-Have** (failure = phase missed): + +1. `perry-lint --root .` prints a drift verdict for six stores, and prints + `unchecked` for each one whose file is removed. +2. `intake.jsonl` and `asks.jsonl` exist, imported by their own commands. +3. The four named `parse_tracks` call sites read `.perry/config.jsonl`. +4. The adoption-reader guard exists **and has been shown to go red**. +5. `perry-state --section attribution` reports 0 never-asked `main`-track rows. + +**Nice-to-Have** (failure allowed, explained in retro): + +6. `BOARD.md`'s truth-model boundary is marked in the file (TASK-199). +7. `TASK-050`'s mutation harness replaces the regex round. + +## Not Doing in this phase + +- **Migrating gimegime-pmo or PolyForge** (`TASK-097`). Carried from phase + 002's DoD item 5, not waived. The user decided on 2026-08-28 that the target + project's state is not suitable yet and the features land first. The + consequence stands and is worth restating: **phase 002's own argument — that + the abstraction survives contact with a real project — is still untested, and + phase 003 does not test it either.** +- **The document class.** `design/*.md`, `DECISIONS.md`, `phase/*.md`, + `.perry/roles/*.md` stay documents, and Python keeps parsing them for now. +- **Counting parser lines.** `P002-O2-KR3`'s target was unreachable by + construction; this phase measures call sites instead and says so in the KR. +- **Raising the `BOARD.md` 200-line cap** to make room for intake. Phase 002's + queue-mode argument holds: an overflowing intake is a finding, not a cap + problem. + +## Process Note + +Cadence work — weekly status, handoffs, journal entries — lives under +`BOARD.md § Cadence` and does not consume a phase Objective slot. + +Three intake rows are past the `intake` track's 5d SLA as of 2026-08-28: +`TASK-139` (8d, over by 3), `TASK-155` (7d, over by 2), `TASK-157` (7d, over by +2). They belong to `/perry work triage`, not to a phase Objective, and are +noted here only so the phase is not planned as though the queue were empty. + +`TASK-157` — *plan-phase still authors the KR block by hand in a file +documented as machine-written* — is about the command that wrote this file. + +## Changes / Pivots + +## Mid-phase check + +## Retro — phase scored diff --git a/perry/tasks.jsonl b/perry/tasks.jsonl index 82fc4aaf..0ed261cc 100644 --- a/perry/tasks.jsonl +++ b/perry/tasks.jsonl @@ -66,9 +66,8 @@ {"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-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": 2, "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": 3, "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": 2, "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": ""} @@ -98,7 +97,7 @@ {"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-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": 0, "summary": ""} {"id": "TASK-128", "title": "the Agent entity has no writer because the signature that would give it one was never taken", "summary": "", "owner": "User", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "evidence/2026-08/TASK-128-proposal.md", "next_action": "V5 sign-off: a human names the date and what they checked", "depends_on": [], "commitment": "", "parent": "", "group": "P0 (must finish this period)", "role": "", "created": "2026-08-20T19:57:28", "order": null} -{"id": "TASK-129", "title": "Agent is five strings that do not join, and role has never once been written", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "unblocked: work owns .perry/agents.jsonl → .perry/roles/ as of the 2026-08-20 signature; needs a spec, then dispatch", "depends_on": ["TASK-128"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T19:57:28", "order": 4} +{"id": "TASK-129", "title": "Agent is five strings that do not join, and role has never once been written", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "unblocked: work owns .perry/agents.jsonl → .perry/roles/ as of the 2026-08-20 signature; needs a spec, then dispatch", "depends_on": ["TASK-128"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T19:57:28", "order": 3} {"id": "TASK-134", "title": "probe row for the TASK-133 track experiment", "summary": "", "owner": "PMO Agent", "status": "dropped", "priority": "P2", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-20", "verification": "V2", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-20T20:29:48", "order": null} {"id": "TASK-137", "title": "a new queue row is born in the second stage, not the first", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V2", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-20T20:31:13", "order": 3} {"id": "TASK-138", "title": "stat -f succeeds on GNU with a different meaning, so the mtime fallback is never reached", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P0", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-20", "verification": "V3", "evidence": "evidence/2026-08/TASK-133-track-experiment.md", "next_action": "fixing in the main checkout; it blocks every PR", "depends_on": [], "commitment": "", "parent": "", "group": "P0 (must finish this period)", "role": "", "created": "2026-08-20T20:41:42", "order": null} @@ -161,19 +160,19 @@ {"id": "TASK-147", "title": "nothing outside describe_cell proves the table and bullet paths stay separated", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P2", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-21", "verification": "V3", "evidence": "evidence/2026-08/TASK-147-result.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-21T00:03:52", "order": null} {"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": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-123-result.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T18:30:45", "order": null} {"id": "TASK-158", "title": "the citation families are hardcoded in the tool, so a project with its own id family gets noise on every legitimate citation", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P2", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-21", "verification": "V3", "evidence": "evidence/2026-08/TASK-158-result.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-21T02:13:42", "order": null} -{"id": "TASK-181", "title": "D009 step 1 — objective rows exist in okr.jsonl, with no id yet", "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-28T11:05:25+08:00", "order": 9} -{"id": "TASK-183", "title": "D009 step 3 — the O-1 mint and the write-back to the store", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-182"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:04+08:00", "order": 11} -{"id": "TASK-184", "title": "D009 step 4 — okr.objectives[].id is filled from the store and the contract moves to 2.2", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-183"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:04+08:00", "order": 12} -{"id": "TASK-185", "title": "D009 step 5 — an Objective id survives a rename and a reorder, proved", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-184"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:04+08:00", "order": 13} -{"id": "TASK-186", "title": "D010 step 2 — a spec declares its author, and the escalation scan reports 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-28T11:06:04+08:00", "order": 14} -{"id": "TASK-187", "title": "D010 step 3 — a machine-authored spec is fail-closed at the escalation gate", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-186"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:05+08:00", "order": 15} -{"id": "TASK-188", "title": "D010 step 4 — the scout, run by hand on ten real rows and scored against what the PMO actually decided", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-187"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:05+08:00", "order": 16} -{"id": "TASK-189", "title": "D010 step 5 — autopilot becomes the two-stage scout-then-build loop", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-188"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:05+08:00", "order": 17} -{"id": "TASK-190", "title": "D011 step 1 — a question bank for the first-ever-OKR route", "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-28T11:06:40+08:00", "order": 18} -{"id": "TASK-191", "title": "D011 step 2 — a real transcript, scored by the rubric that stays unchanged", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-190"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:40+08:00", "order": 19} -{"id": "TASK-192", "title": "D011 step 3 — routing and smart-skip, by track spine", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-191"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:41+08:00", "order": 20} -{"id": "TASK-193", "title": "D011 step 4 — the escape hatch and the premise challenge", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-191"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:41+08:00", "order": 21} -{"id": "TASK-194", "title": "D011 step 5 — plan-phase uses the same question bank", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-191"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:41+08:00", "order": 22} +{"id": "TASK-181", "title": "D009 step 1 — objective rows exist in okr.jsonl, with no id yet", "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-28T11:05:25+08:00", "order": 8} +{"id": "TASK-183", "title": "D009 step 3 — the O-1 mint and the write-back to the store", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-182"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:04+08:00", "order": 10} +{"id": "TASK-184", "title": "D009 step 4 — okr.objectives[].id is filled from the store and the contract moves to 2.2", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-183"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:04+08:00", "order": 11} +{"id": "TASK-185", "title": "D009 step 5 — an Objective id survives a rename and a reorder, proved", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-184"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:04+08:00", "order": 12} +{"id": "TASK-186", "title": "D010 step 2 — a spec declares its author, and the escalation scan reports 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-28T11:06:04+08:00", "order": 13} +{"id": "TASK-187", "title": "D010 step 3 — a machine-authored spec is fail-closed at the escalation gate", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-186"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:05+08:00", "order": 14} +{"id": "TASK-188", "title": "D010 step 4 — the scout, run by hand on ten real rows and scored against what the PMO actually decided", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-187"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:05+08:00", "order": 15} +{"id": "TASK-189", "title": "D010 step 5 — autopilot becomes the two-stage scout-then-build loop", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-188"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:05+08:00", "order": 16} +{"id": "TASK-190", "title": "D011 step 1 — a question bank for the first-ever-OKR route", "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-28T11:06:40+08:00", "order": 17} +{"id": "TASK-191", "title": "D011 step 2 — a real transcript, scored by the rubric that stays unchanged", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-190"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:40+08:00", "order": 18} +{"id": "TASK-192", "title": "D011 step 3 — routing and smart-skip, by track spine", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-191"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:41+08:00", "order": 19} +{"id": "TASK-193", "title": "D011 step 4 — the escape hatch and the premise challenge", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-191"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:41+08:00", "order": 20} +{"id": "TASK-194", "title": "D011 step 5 — plan-phase uses the same question bank", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-191"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:06:41+08:00", "order": 21} {"id": "TASK-094", "title": "Delete the header rule and the row splitter for the three stores", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-094-result.md", "next_action": "PR #20 merged but the row does NOT close on it: verification item 1 asked for 0 call sites and BOARD.md keeps 13 splits / 87 resolutions on four storeless registers — needs a scope decision, not a close", "depends_on": ["TASK-090", "TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:51", "order": null, "summary": ""} {"id": "TASK-198", "title": "## Cadence becomes a store", "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-28T11:13:21+08:00", "order": 5} {"id": "TASK-200", "title": "draft a finance-shaped role card from gimegime-pmo, and name every field that does not survive the shape change", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-200-finance-role-card.md", "next_action": "CORRECTED 2026-08-28 mid-flight by the user: gimegime-pmo is a HYBRID, not a non-software project. Repo layout: split — it manages the software development of ~/proj/gimegime AND real investment work on one board, physically partitioned by declared id prefix: IPS-/ALLOC-/DUE- = 投资线, ARCH-V2/RW-/PAPER-/RES-/DATA-/INFRA-/TECH- etc = 工程线. So the question is not 'does a finance role card work' but 'can one board carry two roles whose escalation boundaries differ in KIND' — 工程线 escalates on paths, 投资线 escalates on 系统永不下单, an action no file list matches. The 工程线 half already has its role (rows read Owner: Coding Agent). 'They cannot coexist under this model' is a winning answer.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:17:57+08:00", "order": null} @@ -181,31 +180,30 @@ {"id": "TASK-195", "title": "## Top risks becomes a store — risks.jsonl is declared in claims[] and has never existed", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-195-result.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:13:20+08:00", "order": null} {"id": "TASK-201", "title": "the escalation gate is half-internationalised and drops short fragments silently", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-201-result.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P0 (must finish this period)", "role": "", "created": "2026-08-28T13:58:47+08:00", "order": null} {"id": "TASK-196", "title": "## Intake becomes a store — 46 rows live only in BOARD.md", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-196-result.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:13:20+08:00", "order": null} -{"id": "TASK-204", "title": "Perry has no writer for a migration event, so TASK-180 hand-wrote JSON into an append-only log", "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-28T15:12:20+08:00", "order": 24} -{"id": "TASK-206", "title": "a write returns no seq, so a poll cannot tell a stale read from a fresh one", "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-28T15:12:20+08:00", "order": 25} -{"id": "TASK-207", "title": "no compare-and-set on a write, and the board demonstrably moves between a read and a write", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-206"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T15:12:20+08:00", "order": 26} +{"id": "TASK-204", "title": "Perry has no writer for a migration event, so TASK-180 hand-wrote JSON into an append-only log", "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-28T15:12:20+08:00", "order": 23} +{"id": "TASK-206", "title": "a write returns no seq, so a poll cannot tell a stale read from a fresh one", "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-28T15:12:20+08:00", "order": 24} +{"id": "TASK-207", "title": "no compare-and-set on a write, and the board demonstrably moves between a read and a write", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-206"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T15:12:20+08:00", "order": 25} {"id": "TASK-197", "title": "## User Input Queue becomes a store — the queue has no store and the board section IS the record", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-197-result.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:13:21+08:00", "order": null} -{"id": "TASK-208", "title": "perry-diagnose asks 'is this ask answered' with a word search over free prose, and disagrees with the store in both directions", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-179"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T15:19:14+08:00", "order": 27} +{"id": "TASK-208", "title": "perry-diagnose asks 'is this ask answered' with a word search over free prose, and disagrees with the store in both directions", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-179"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T15:19:14+08:00", "order": 26} {"id": "TASK-202", "title": "the hook side of the escalation union has no not-extractable check at all — only role cards get one", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-202-result.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T14:23:46+08:00", "order": null} -{"id": "TASK-212", "title": "a locked decision that gets no task row does not ship, and nothing links a design's plan step to the work that discharges 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-28T15:32:54+08:00", "order": 28} -{"id": "TASK-173", "title": "an Objective is not a record, so it has no durable address", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "DESIGN-009 drafted 2026-08-21: design/DESIGN-009-the-objective-is-a-record.md. Four User Decisions open — id shape, write-back location, how the five existing objectives get minted, and whether krs[].objective keeps the title. The key finding: the PHASE level already solved this. phase/002-linkage.md states id: O1 and the payload carries it, while okr.objectives[].id is '' for all five — and the contract already says a STATED id is legitimate where a DERIVED one is not", "depends_on": ["TASK-181", "TASK-182", "TASK-183", "TASK-184", "TASK-185"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-21T15:35:32", "order": 6} -{"id": "TASK-177", "title": "OKR setting is a ten-field checklist where it should be an elicitation", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "DESIGN-011 drafted 2026-08-21: design/DESIGN-011-the-okr-is-elicited-not-collected.md. Four User Decisions open. Step 2 is the gate — run the question bank against a project with no OKR.md and run the rubric on the output; goal 3 (the rubric surfaces ZERO issues) is measured there or it is not measured", "depends_on": ["TASK-190", "TASK-191", "TASK-192", "TASK-193", "TASK-194"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-21T18:10:21", "order": 7} +{"id": "TASK-212", "title": "a locked decision that gets no task row does not ship, and nothing links a design's plan step to the work that discharges 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-28T15:32:54+08:00", "order": 27} +{"id": "TASK-173", "title": "an Objective is not a record, so it has no durable address", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "DESIGN-009 drafted 2026-08-21: design/DESIGN-009-the-objective-is-a-record.md. Four User Decisions open — id shape, write-back location, how the five existing objectives get minted, and whether krs[].objective keeps the title. The key finding: the PHASE level already solved this. phase/002-linkage.md states id: O1 and the payload carries it, while okr.objectives[].id is '' for all five — and the contract already says a STATED id is legitimate where a DERIVED one is not", "depends_on": ["TASK-181", "TASK-182", "TASK-183", "TASK-184", "TASK-185"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-21T15:35:32", "order": 5} +{"id": "TASK-177", "title": "OKR setting is a ten-field checklist where it should be an elicitation", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "DESIGN-011 drafted 2026-08-21: design/DESIGN-011-the-okr-is-elicited-not-collected.md. Four User Decisions open. Step 2 is the gate — run the question bank against a project with no OKR.md and run the rubric on the output; goal 3 (the rubric surfaces ZERO issues) is measured there or it is not measured", "depends_on": ["TASK-190", "TASK-191", "TASK-192", "TASK-193", "TASK-194"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-21T18:10:21", "order": 6} {"id": "TASK-210", "title": "the id scanner excludes fenced blocks but not inline code spans, so a regex in backticks becomes a dangling id", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-210-spec.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T15:32:53+08:00", "order": null} {"id": "TASK-205", "title": "semantics ships on 2 of 5 payloads, so CONTRACT_TESTED.goals can never go red", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-205-spec.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T15:12:20+08:00", "order": null} -{"id": "TASK-217", "title": "four pages disagree on whether the retro is written before or after score-phase", "summary": "Perry ships two opposite orderings of the phase-close pipeline. Nothing picks one, so whoever runs it picks by which page they read.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-217-spec.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T18:22:19+08:00", "order": 29} -{"id": "TASK-218", "title": "thread the closing phase id through every close stage, so no stage re-reads phase/CURRENT", "summary": "DESIGN-012 I1. Today each of the four phase-close stages re-reads phase/CURRENT, so the moment one stage advances it every later stage aims at the wrong phase. That is the 2026-08-28 failure, and it is a data-flow bug rather than a documentation one.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-218-spec.md", "next_action": "—", "depends_on": ["TASK-217"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T19:03:20+08:00", "order": 30} -{"id": "TASK-220", "title": "the close-phase router subcommand, over the four unchanged lane subcommands", "summary": "DESIGN-012 § 5.1. One invocation closes a phase. The router sequences; every write stays inside an existing lane subcommand, so the sequence spans two writers without becoming a third.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-220-spec.md", "next_action": "—", "depends_on": ["TASK-217", "TASK-218"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T19:03:36+08:00", "order": 31} -{"id": "TASK-221", "title": "a phase close that stopped halfway is visible at the next snapshot, resolved from state", "summary": "DESIGN-012 decision 3. Phase 002 was left scored with no retro and a rollover that never ran, and nothing anywhere recorded that. The progress is already legible from what the stages leave behind, so this needs no new file.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-221-spec.md", "next_action": "—", "depends_on": ["TASK-217"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T19:03:36+08:00", "order": 32} +{"id": "TASK-217", "title": "four pages disagree on whether the retro is written before or after score-phase", "summary": "Perry ships two opposite orderings of the phase-close pipeline. Nothing picks one, so whoever runs it picks by which page they read.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-217-spec.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T18:22:19+08:00", "order": 28} +{"id": "TASK-218", "title": "thread the closing phase id through every close stage, so no stage re-reads phase/CURRENT", "summary": "DESIGN-012 I1. Today each of the four phase-close stages re-reads phase/CURRENT, so the moment one stage advances it every later stage aims at the wrong phase. That is the 2026-08-28 failure, and it is a data-flow bug rather than a documentation one.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-218-spec.md", "next_action": "—", "depends_on": ["TASK-217"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T19:03:20+08:00", "order": 29} +{"id": "TASK-220", "title": "the close-phase router subcommand, over the four unchanged lane subcommands", "summary": "DESIGN-012 § 5.1. One invocation closes a phase. The router sequences; every write stays inside an existing lane subcommand, so the sequence spans two writers without becoming a third.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-220-spec.md", "next_action": "—", "depends_on": ["TASK-217", "TASK-218"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T19:03:36+08:00", "order": 30} +{"id": "TASK-221", "title": "a phase close that stopped halfway is visible at the next snapshot, resolved from state", "summary": "DESIGN-012 decision 3. Phase 002 was left scored with no retro and a rollover that never ran, and nothing anywhere recorded that. The progress is already legible from what the stages leave behind, so this needs no new file.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-221-spec.md", "next_action": "—", "depends_on": ["TASK-217"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T19:03:36+08:00", "order": 31} {"id": "TASK-222", "title": "score-phase's own snapshots trip NS-01, because the names it writes do not match the declared pattern", "summary": "Running the documented scoring procedure adds a lint warning about the files that procedure just wrote. The same shape has been sitting under evidence/, handoff/ and knowledge/ all along.", "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-28T19:11:27+08:00", "order": 6} -{"id": "TASK-223", "title": "the conformance gate cannot tell a file Perry generated from one it found, so authored files need a hand declare", "summary": "7 authored files sat undeclared for 8 days and it blocked perry-goals link --project on 2026-08-28. perry-migrate already records route: migrate; there is no route: authored.", "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-28T19:11:27+08:00", "order": 7} -{"id": "TASK-224", "title": "linkage-kr-exists fires only on an absent id, so a KR nested under the wrong objective lints clean", "summary": "002-linkage carried three O2 KRs under O1 for eight days at 0 errors and 0 warnings. The prose retro had the grouping right and the graph had it wrong; a human found it, not the linter.", "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-28T19:11:27+08:00", "order": 8} -{"id": "TASK-225", "title": "decide/SKILL.md:220 specifies a design index that nothing renders", "summary": "init is told to write design/README.md as 'convention + index', but perry-decide is ADR-only, decide/state/ ships no README template, and perry-state --section design already computes the same list.", "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-28T19:11:27+08:00", "order": 9} +{"id": "TASK-224", "title": "linkage-kr-exists fires only on an absent id, so a KR nested under the wrong objective lints clean", "summary": "002-linkage carried three O2 KRs under O1 for eight days at 0 errors and 0 warnings. The prose retro had the grouping right and the graph had it wrong; a human found it, not the linter.", "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-28T19:11:27+08:00", "order": 7} +{"id": "TASK-225", "title": "decide/SKILL.md:220 specifies a design index that nothing renders", "summary": "init is told to write design/README.md as 'convention + index', but perry-decide is ADR-only, decide/state/ ships no README template, and perry-state --section design already computes the same list.", "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-28T19:11:27+08:00", "order": 8} {"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": "Startable — all four dependencies are done. Write the first non-software role card (the real blocker; every shipped card is software-shaped). Decision and the two record corrections: evidence/2026-08/TASK-077-notes.md. The run and the V5 signature stay with the user.", "depends_on": ["TASK-073", "TASK-075", "TASK-076", "TASK-200"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-18T07:31:46", "order": 0, "summary": ""} -{"id": "TASK-179", "title": "writing about an id costs a dangling entry, and three records tonight paid it", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-179-notes.md", "next_action": "Startable — TASK-210 is done. Decide the rule for the five dangling ids, then make the reconcile test assert that decision: widen the report mark beyond the paragraph, exempt evidence records wholesale, or accept the cost in writing. The list and its shape: evidence/2026-08/TASK-179-notes.md.", "depends_on": ["TASK-210"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T00:40:50", "order": 8} -{"id": "TASK-139", "title": "a design back-reference lives in a cell the close path clears, so a finished design reports as never handed off", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-20", "verification": "V3", "evidence": "—", "next_action": "Promoted to P1 on 2026-08-28 for breaching the intake track's 5d SLA. The row arrived from intake carrying a title and nothing else — no summary, deliverable or verification — so the first step is to investigate the defect and write evidence/2026-08/TASK-139-spec.md, which subcommands.md:708 requires of every P0/P1 row.", "depends_on": ["TASK-102"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T21:04:19", "order": 33} -{"id": "TASK-155", "title": "the register updated field carries two facts, so appending an edge silently re-dates every asserted number in the file", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-21", "verification": "V3", "evidence": "—", "next_action": "Promoted to P1 on 2026-08-28 for breaching the intake track's 5d SLA. The row arrived from intake carrying a title and nothing else — no summary, deliverable or verification — so the first step is to investigate the defect and write evidence/2026-08/TASK-155-spec.md, which subcommands.md:708 requires of every P0/P1 row.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-21T01:37:37", "order": 5} -{"id": "TASK-219", "title": "retro-cites-phase-scores — a cross_file check that the retro cites the scores rather than re-deriving them", "summary": "DESIGN-012 decision 4. With the retro running after scoring, 'cites rather than re-derives' becomes a comparison between two files that both exist, so it is checkable instead of being a convention.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-219-spec.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T19:03:20+08:00", "order": 34} -{"id": "TASK-231", "title": "a measured KR number has no way into the register that does not break one of its two rules", "summary": "The linkage file says machine-written, never by hand. Its only writer refuses to write target and current on purpose, so that an invented number cannot get in. Together those two leave a genuinely MEASURED number with no legitimate path, and the register reports asserted 0 while the phase has moved.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-231-spec.md", "next_action": "—", "depends_on": ["TASK-155"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T23:40:22+08:00", "order": 35} +{"id": "TASK-179", "title": "writing about an id costs a dangling entry, and three records tonight paid it", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-179-notes.md", "next_action": "Startable — TASK-210 is done. Decide the rule for the five dangling ids, then make the reconcile test assert that decision: widen the report mark beyond the paragraph, exempt evidence records wholesale, or accept the cost in writing. The list and its shape: evidence/2026-08/TASK-179-notes.md.", "depends_on": ["TASK-210"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T00:40:50", "order": 7} +{"id": "TASK-139", "title": "a design back-reference lives in a cell the close path clears, so a finished design reports as never handed off", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-20", "verification": "V3", "evidence": "—", "next_action": "Promoted to P1 on 2026-08-28 for breaching the intake track's 5d SLA. The row arrived from intake carrying a title and nothing else — no summary, deliverable or verification — so the first step is to investigate the defect and write evidence/2026-08/TASK-139-spec.md, which subcommands.md:708 requires of every P0/P1 row.", "depends_on": ["TASK-102"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T21:04:19", "order": 32} +{"id": "TASK-155", "title": "the register updated field carries two facts, so appending an edge silently re-dates every asserted number in the file", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-21", "verification": "V3", "evidence": "—", "next_action": "Promoted to P1 on 2026-08-28 for breaching the intake track's 5d SLA. The row arrived from intake carrying a title and nothing else — no summary, deliverable or verification — so the first step is to investigate the defect and write evidence/2026-08/TASK-155-spec.md, which subcommands.md:708 requires of every P0/P1 row.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-21T01:37:37", "order": 4} +{"id": "TASK-219", "title": "retro-cites-phase-scores — a cross_file check that the retro cites the scores rather than re-deriving them", "summary": "DESIGN-012 decision 4. With the retro running after scoring, 'cites rather than re-derives' becomes a comparison between two files that both exist, so it is checkable instead of being a convention.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-219-spec.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T19:03:20+08:00", "order": 33} +{"id": "TASK-231", "title": "a measured KR number has no way into the register that does not break one of its two rules", "summary": "The linkage file says machine-written, never by hand. Its only writer refuses to write target and current on purpose, so that an invented number cannot get in. Together those two leave a genuinely MEASURED number with no legitimate path, and the register reports asserted 0 while the phase has moved.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-231-spec.md", "next_action": "—", "depends_on": ["TASK-155"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T23:40:22+08:00", "order": 34} {"id": "TASK-209", "title": "perry-lint's store-drift census covers tasks.jsonl only, so ADR-007's guarantee holds for one store of five", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-209-result.md", "next_action": "dispatched to claude-subagent 2026-08-28; awaiting RESULT", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T15:32:53+08:00", "order": null} {"id": "TASK-229", "title": "no store and clean are different answers, and that has been measured for two of six stores", "summary": "P003-O1-KR3's own metric says 'measured by removing each one', and four of the six have never been removed. The identically-numbered KR one phase ago scored 0.33 for exactly this: a metric that said 'reported' without saying by what, and nine days in which nobody checked.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-229-result.md", "next_action": "measured 2026-08-29 on a scratch copy; six removals, six unchecked verdicts", "depends_on": ["TASK-209"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T19:46:39+08:00", "order": null} {"id": "TASK-228", "title": "attribution reports a declared-unlinked row in the unresolved bucket too, so the standup number counts it twice", "summary": "okr-linkage.md describes unlinked as 'couldn't resolve' and declared_unlinked as 'the graph says outright this serves no KR' — two different states. The payload puts a declared row in both.", "owner": "Coding Agent", "status": "done", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-228-result.md", "next_action": "building", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-28T19:23:35+08:00", "order": null} @@ -214,42 +212,44 @@ {"id": "TASK-215", "title": "BOARD.md's Last updated header is twelve days stale while the file is re-rendered dozens of times a day", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-215-result.md", "next_action": "building", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-28T15:32:54+08:00", "order": null} {"id": "TASK-213", "title": "bin/perry-task's ABSENT is a fourth copy of the blank-cell list, so 'Depends on: 待定' parses as a real dependency id", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-213-result.md", "next_action": "building", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-28T15:32:54+08:00", "order": null} {"id": "TASK-216", "title": "the foreign-write guard scans reference pages only, and misses the third-person verb a summary table uses", "summary": "The test that stops one lane from being told to write another lane's files never reads the lane SKILL.md files, and only matches 'write', not 'writes'. Both holes let a real violation sit in goals/SKILL.md for a release.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-216-result.md", "next_action": "building", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T18:22:19+08:00", "order": null} -{"id": "TASK-232", "title": "viewer/ is named for a web console deleted under TASK-178, and a reader just called it dead code", "summary": "The directory holds the entire read side of the project — viewer/parsers.py is the ONLY markdown parser (perry-state, perry-goals, perry-decide, perry-lint, perry-diagnose, perry_store.py and perry_md_store.py all import it) and viewer/tables.py owns render_row, split_row and squash. The web console it is named for was deleted under TASK-178; bin/README.md:357 already records the name as wrong and says the directory is kept because renaming touches the imports. The cost is not cosmetic: on 2026-08-29 a reader looked at the tree and asked whether viewer/ could be deleted, and bin/lib/__init__.py:446 already records one wrong judgement made from the same name. Recommended target parse/; the alternative considered is read/. bin/lib/ already exists and is a different thing, so lib/ at the root would be worse than the name it replaces.", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "Blocked until TASK-050 lands. TASK-050 option C converts the header resolution in the same 18 readers and touches viewer/tables.py's squash directly, so doing this first guarantees a conflict and doing it second is conflict-free. First step when unblocked: pick the name — parse/ is the recommendation, read/ the alternative, lib/ is ruled out because bin/lib/ already exists.", "depends_on": ["TASK-050"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-29T13:27:17+08:00", "order": 10} -{"id": "TASK-237", "title": "BOARD.md stops existing; the board is what a command prints", "summary": "DESIGN-013 step 3, User Decision 4, authorised by ADR-010 which supersedes ADR-007 section 6 decision 2 — that one sentence only. BOARD.md is 43,289 bytes of which 42,099 (97%) are inside table rows, 101 rows, longest single cell 2,825 bytes. The natural language an agent reads is INSIDE the cells and is already in tasks.jsonl, so there is no markdown-only content to preserve; the 1,190 bytes outside the tables are a title, nine header lines and eight section headings. What this removes is the board-table read-back path, which is why TASK-050 (seven failed V4 rounds), TASK-067, TASK-199 and TASK-234 exist in the form they do. What it costs is the file anyone can open.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked on TASK-235 and TASK-236, and on a GATE that is not a dependency edge: TASK-236 must report IN WRITING that a CLI render is a good enough reading surface. DESIGN-013's risk table is explicit — if that report is negative this row STOPS and returns to the design, rather than proceeding because the decision was already made. Also note for the goals lane, not this row's write: P003-O2-KR3 is 'BOARD.md's two truth models are marked in the file' and TASK-199 is its only row. Deleting the file moots both. That KR needs an answer from the goals lane before this row closes, or the phase records a KR that cannot be met.", "depends_on": ["TASK-235", "TASK-236"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-29T13:59:02+08:00", "order": 37} -{"id": "TASK-236", "title": "OKR.md drops its KR tables; perry-goals renders them — and reports whether a CLI render is a good enough read surface", "summary": "DESIGN-013 step 2, User Decision 2. OKR.md is 12,275 bytes split 51% table / 48% prose, longest cell 192 bytes — the one file where prose and record genuinely separate. okr.jsonl already holds 34 KR records and 2 version records. The prose that stays is Mission, Operating Principles, Anti-Goals, the per-objective narrative and the Versioning log: 5,935 bytes that belong to no store and include Perry's oldest rule, 'never compute a number by reading files and eyeballing it'. This step carries a second deliverable that is not about OKR.md at all: it is the cheapest place to find out whether a CLI render can replace a markdown table as something a human READS, and TASK-237 is gated on that answer.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked on TASK-235 (same pattern, smaller file first) AND on TASK-181/TASK-182, which are a PRECONDITION and not a coincidence. DESIGN-009 section 6 step 2 states its own purpose: 'perry-okr render reproduces OKR.md byte-for-byte with objective rows in the store. THIS IS THE GATE: if the renderer cannot rebuild the five headings from records, the records are wrong.' That gate proves okr.jsonl holds everything OKR.md's tables hold — which is exactly the thing this row must know before it deletes them. Run in the other order and the gate evaporates: with the tables already gone there is nothing left to rebuild, so TASK-182 would pass vacuously and nobody would learn whether the store was complete. That is the same class of defect this project has caught six times — a check that cannot fail on the thing it names. The read-surface report is not a courtesy: DESIGN-013's risk table says if it comes back negative, TASK-237 STOPS and returns to the design rather than proceeding because the decision was already made. Write it as a finding, not as a justification for having done the work.", "depends_on": ["TASK-235", "TASK-181", "TASK-182"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-29T13:58:42+08:00", "order": 36} -{"id": "TASK-182", "title": "D009 step 2 — perry-okr render rebuilds OKR.md byte-for-byte from objective records", "summary": "DESIGN-009 step 2, and as of 2026-08-29 also the precondition of TASK-236. Its purpose is not to ship a renderer but to PROVE the store is complete: if perry-okr render cannot rebuild OKR.md byte-for-byte from objective records, the records are wrong. DESIGN-013 decided OKR.md stops carrying its KR tables (User Decision 2), which makes this gate load-bearing rather than incidental — the tables must not be deleted until something has proved the store holds them. Ordering matters in one direction only: if TASK-236 ran first this row would pass with nothing to rebuild.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "Startable once TASK-181 lands, and it is now a PRECONDITION of TASK-236 rather than a step in one chain. Its byte-for-byte target is the CURRENT OKR.md, tables included — that is what makes it a proof that okr.jsonl is complete. Run it before TASK-236 deletes those tables; afterwards there is nothing to rebuild and the gate passes vacuously. DESIGN-009 section 6 step 2 states the bar: 'if the renderer cannot rebuild the five headings from records, the records are wrong. Same bar as risks-diff.'", "depends_on": ["TASK-181"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:05:42+08:00", "order": 10} -{"id": "TASK-199", "title": "BOARD.md carries two truth models in one file and nothing marks the boundary", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "UNBLOCKED by USER-907 (option a). RE-SCOPED, not dropped: the deliverable is no longer 'mark the boundary in BOARD.md' — ADR-010 deletes that file — but 'the render distinguishes what is projected from a store from what is still canonical markdown', which is the reader-facing property the KR was actually buying. Depends on TASK-237 delivering the render. The KR's own wording is a goals-lane edit and is handed off in handoff/2026-08-29-goals-lane-after-design-013.md; this row does not wait on that edit to be startable, but the two must agree before it closes. Note the original finding still holds and is what the render must not repeat: today nothing tells a reader which sections of BOARD.md are projected and which are canonical.", "depends_on": ["TASK-237"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:13:21+08:00", "order": 23} -{"id": "TASK-238", "title": "no commit on main may fail to build standalone, and nothing checks it", "summary": "USER-908, part (c), authorised 2026-08-29. Commit 0d68034 (TASK-213) also carries the bin/perry-task half of TASK-095 round 4, so at that commit every perry-task write on a project holding .perry/config.jsonl dies with AttributeError: module 'perry_state' has no attribute 'defaulted_over_a_declaring_table' — bin/perry-task:6773 calls a function that arrives one commit later. Its own commit message's suite claim is false at that commit. The branch tip was whole and main is whole; only that one commit does not build, and it is now in main's history via the 777d021 merge, so a git bisect across the 20 commits after it gets a false 'broken' verdict there. Nothing caught it: it was found by a V4 reviewer reading a commit message, and the same class can recur on any branch whose commits are split by hand.", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "Startable. The live test case is on main right now: git worktree add --detach 0d68034 then run perry-task on a project carrying .perry/config.jsonl — it dies at :6773. Use that as the fixture rather than constructing one, and note it will STOP being reproducible once USER-908 part (b) runs, so the test must not depend on that sha surviving.", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-29T14:14:00+08:00", "order": 11} +{"id": "TASK-232", "title": "viewer/ is named for a web console deleted under TASK-178, and a reader just called it dead code", "summary": "The directory holds the entire read side of the project — viewer/parsers.py is the ONLY markdown parser (perry-state, perry-goals, perry-decide, perry-lint, perry-diagnose, perry_store.py and perry_md_store.py all import it) and viewer/tables.py owns render_row, split_row and squash. The web console it is named for was deleted under TASK-178; bin/README.md:357 already records the name as wrong and says the directory is kept because renaming touches the imports. The cost is not cosmetic: on 2026-08-29 a reader looked at the tree and asked whether viewer/ could be deleted, and bin/lib/__init__.py:446 already records one wrong judgement made from the same name. Recommended target parse/; the alternative considered is read/. bin/lib/ already exists and is a different thing, so lib/ at the root would be worse than the name it replaces.", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "Blocked until TASK-050 lands. TASK-050 option C converts the header resolution in the same 18 readers and touches viewer/tables.py's squash directly, so doing this first guarantees a conflict and doing it second is conflict-free. First step when unblocked: pick the name — parse/ is the recommendation, read/ the alternative, lib/ is ruled out because bin/lib/ already exists.", "depends_on": ["TASK-050"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-29T13:27:17+08:00", "order": 9} +{"id": "TASK-237", "title": "BOARD.md stops existing; the board is what a command prints", "summary": "DESIGN-013 step 3, User Decision 4, authorised by ADR-010 which supersedes ADR-007 section 6 decision 2 — that one sentence only. BOARD.md is 43,289 bytes of which 42,099 (97%) are inside table rows, 101 rows, longest single cell 2,825 bytes. The natural language an agent reads is INSIDE the cells and is already in tasks.jsonl, so there is no markdown-only content to preserve; the 1,190 bytes outside the tables are a title, nine header lines and eight section headings. What this removes is the board-table read-back path, which is why TASK-050 (seven failed V4 rounds), TASK-067, TASK-199 and TASK-234 exist in the form they do. What it costs is the file anyone can open.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked on TASK-235 and TASK-236, and on a GATE that is not a dependency edge: TASK-236 must report IN WRITING that a CLI render is a good enough reading surface. DESIGN-013's risk table is explicit — if that report is negative this row STOPS and returns to the design, rather than proceeding because the decision was already made. Also note for the goals lane, not this row's write: P003-O2-KR3 is 'BOARD.md's two truth models are marked in the file' and TASK-199 is its only row. Deleting the file moots both. That KR needs an answer from the goals lane before this row closes, or the phase records a KR that cannot be met.", "depends_on": ["TASK-235", "TASK-236"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-29T13:59:02+08:00", "order": 36} +{"id": "TASK-236", "title": "OKR.md drops its KR tables; perry-goals renders them — and reports whether a CLI render is a good enough read surface", "summary": "DESIGN-013 step 2, User Decision 2. OKR.md is 12,275 bytes split 51% table / 48% prose, longest cell 192 bytes — the one file where prose and record genuinely separate. okr.jsonl already holds 34 KR records and 2 version records. The prose that stays is Mission, Operating Principles, Anti-Goals, the per-objective narrative and the Versioning log: 5,935 bytes that belong to no store and include Perry's oldest rule, 'never compute a number by reading files and eyeballing it'. This step carries a second deliverable that is not about OKR.md at all: it is the cheapest place to find out whether a CLI render can replace a markdown table as something a human READS, and TASK-237 is gated on that answer.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked on TASK-235 (same pattern, smaller file first) AND on TASK-181/TASK-182, which are a PRECONDITION and not a coincidence. DESIGN-009 section 6 step 2 states its own purpose: 'perry-okr render reproduces OKR.md byte-for-byte with objective rows in the store. THIS IS THE GATE: if the renderer cannot rebuild the five headings from records, the records are wrong.' That gate proves okr.jsonl holds everything OKR.md's tables hold — which is exactly the thing this row must know before it deletes them. Run in the other order and the gate evaporates: with the tables already gone there is nothing left to rebuild, so TASK-182 would pass vacuously and nobody would learn whether the store was complete. That is the same class of defect this project has caught six times — a check that cannot fail on the thing it names. The read-surface report is not a courtesy: DESIGN-013's risk table says if it comes back negative, TASK-237 STOPS and returns to the design rather than proceeding because the decision was already made. Write it as a finding, not as a justification for having done the work.", "depends_on": ["TASK-235", "TASK-181", "TASK-182"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-29T13:58:42+08:00", "order": 35} +{"id": "TASK-182", "title": "D009 step 2 — perry-okr render rebuilds OKR.md byte-for-byte from objective records", "summary": "DESIGN-009 step 2, and as of 2026-08-29 also the precondition of TASK-236. Its purpose is not to ship a renderer but to PROVE the store is complete: if perry-okr render cannot rebuild OKR.md byte-for-byte from objective records, the records are wrong. DESIGN-013 decided OKR.md stops carrying its KR tables (User Decision 2), which makes this gate load-bearing rather than incidental — the tables must not be deleted until something has proved the store holds them. Ordering matters in one direction only: if TASK-236 ran first this row would pass with nothing to rebuild.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "Startable once TASK-181 lands, and it is now a PRECONDITION of TASK-236 rather than a step in one chain. Its byte-for-byte target is the CURRENT OKR.md, tables included — that is what makes it a proof that okr.jsonl is complete. Run it before TASK-236 deletes those tables; afterwards there is nothing to rebuild and the gate passes vacuously. DESIGN-009 section 6 step 2 states the bar: 'if the renderer cannot rebuild the five headings from records, the records are wrong. Same bar as risks-diff.'", "depends_on": ["TASK-181"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:05:42+08:00", "order": 9} +{"id": "TASK-199", "title": "BOARD.md carries two truth models in one file and nothing marks the boundary", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "UNBLOCKED by USER-907 (option a). RE-SCOPED, not dropped: the deliverable is no longer 'mark the boundary in BOARD.md' — ADR-010 deletes that file — but 'the render distinguishes what is projected from a store from what is still canonical markdown', which is the reader-facing property the KR was actually buying. Depends on TASK-237 delivering the render. The KR's own wording is a goals-lane edit and is handed off in handoff/2026-08-29-goals-lane-after-design-013.md; this row does not wait on that edit to be startable, but the two must agree before it closes. Note the original finding still holds and is what the render must not repeat: today nothing tells a reader which sections of BOARD.md are projected and which are canonical.", "depends_on": ["TASK-237"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T11:13:21+08:00", "order": 22} +{"id": "TASK-238", "title": "no commit on main may fail to build standalone, and nothing checks it", "summary": "USER-908, part (c), authorised 2026-08-29. Commit 0d68034 (TASK-213) also carries the bin/perry-task half of TASK-095 round 4, so at that commit every perry-task write on a project holding .perry/config.jsonl dies with AttributeError: module 'perry_state' has no attribute 'defaulted_over_a_declaring_table' — bin/perry-task:6773 calls a function that arrives one commit later. Its own commit message's suite claim is false at that commit. The branch tip was whole and main is whole; only that one commit does not build, and it is now in main's history via the 777d021 merge, so a git bisect across the 20 commits after it gets a false 'broken' verdict there. Nothing caught it: it was found by a V4 reviewer reading a commit message, and the same class can recur on any branch whose commits are split by hand.", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "Startable. The live test case is on main right now: git worktree add --detach 0d68034 then run perry-task on a project carrying .perry/config.jsonl — it dies at :6773. Use that as the fixture rather than constructing one, and note it will STOP being reproducible once USER-908 part (b) runs, so the test must not depend on that sha surviving.", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-29T14:14:00+08:00", "order": 10} {"id": "TASK-095", "title": "Remove the parser for the three stores; keep what adoption needs", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "V4 ROUND 6: PASS 2026-08-29 — first PASS after five FAILs. evidence/2026-08/TASK-095-round6-v4-review.md (583 lines). The reviewer attacked the load-bearing claim first and ruled the M11 equivalence argument CORRECT by reading the control flow: both production callers derive tracks/source from the same declared_tracks_detail call and stored_tracks reaches TRACKS_STORE_DEFAULT on exactly one return, so after the gate 'have' provably equals the round 4 literal — and that is what USER-905 Decision 2 ASKED for, because it sits on the REFUSAL, not the drift rule. The drift rule is now perry_md_store.plan, the same call perry-lint makes. The other three greens are genuine equivalents; M22, the one that was a crash path, is now 1 ERROR. Verified with the reviewer's OWN fixtures, not the author's helpers: two stores one verdict (opposite responses at base, all five tools agreeing at head); parse_tracks( 2 lines at head vs 3 at base; W1/W2/W3 exit=1 to exit=0 with W3's remedy still failing on both trees; the remedy pin proved REAL by simulating the fix and watching exactly that test go red; M5 kills the tautology; state 7 contradicted=['intake','main'] with MODE-02; the zh path identical at every state; all 27 anchors matching, 17 mutations re-run with every count and RED name matching. The reviewer also ran the runner the author declined to: unittest discover 2882/6 vs 2902/6, identical sets. The stderr drift warning was ruled IN SCOPE and correct, measured — three clean workflows byte-identical, no exit code going 0 to 1. ONE NON-BLOCKING FINDING, sent back: bin/perry-state:1022's startswith('track/') filter survives its own deletion with 56 tests green, and without it perry-task says 'the track register disagrees' about a hand-edited SETTING cell. Shipped code is correct; it needs one named test on the message. Merge after that lands.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:28:03", "order": 1, "summary": ""} -{"id": "TASK-240", "title": "an ADR id can be reissued, because perry-decide writes no events and has nothing to retire one with", "summary": "Measured by the TASK-235 agent and confirmed by its V4 reviewer, 2026-08-30. Delete an ADR file and the next mint hands out the same number. perry-task purge retires an id through the append-only event log so it is never reissued; perry-decide writes NO events at all, so it has nothing to retire an id with — two tools, one contract, opposite answers. Before TASK-235 it was worse and NON-DETERMINISTIC: the reviewer reproduced the same starting state giving opposite mint outcomes depending on a single unrelated status flip, because that flip re-rendered the index and dropped the row holding the number. The reviewer also measured that the only detector on main had a one-command half-life — indexed_without_file: ['ADR-003'] went empty after one unrelated write. TASK-235 declared the disagreement and pinned it with a named test rather than resolving it silently, which is why that row could close with this open.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked on USER-909, which is the decision and not this row. Recommendation recorded there is (b) then (a) — stop the deletion that creates the problem, then give perry-decide the event surface so the two tools agree on principle rather than by accident. Read evidence/2026-08/TASK-235-v4-review.md first; it carries the reproduction and the measurement that the on-main detector had a one-command half-life.", "depends_on": ["USER-909"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-30T00:34:18+08:00", "order": 39} +{"id": "TASK-240", "title": "an ADR id can be reissued, because perry-decide writes no events and has nothing to retire one with", "summary": "Measured by the TASK-235 agent and confirmed by its V4 reviewer, 2026-08-30. Delete an ADR file and the next mint hands out the same number. perry-task purge retires an id through the append-only event log so it is never reissued; perry-decide writes NO events at all, so it has nothing to retire an id with — two tools, one contract, opposite answers. Before TASK-235 it was worse and NON-DETERMINISTIC: the reviewer reproduced the same starting state giving opposite mint outcomes depending on a single unrelated status flip, because that flip re-rendered the index and dropped the row holding the number. The reviewer also measured that the only detector on main had a one-command half-life — indexed_without_file: ['ADR-003'] went empty after one unrelated write. TASK-235 declared the disagreement and pinned it with a named test rather than resolving it silently, which is why that row could close with this open.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked on USER-909, which is the decision and not this row. Recommendation recorded there is (b) then (a) — stop the deletion that creates the problem, then give perry-decide the event surface so the two tools agree on principle rather than by accident. Read evidence/2026-08/TASK-235-v4-review.md first; it carries the reproduction and the measurement that the on-main detector had a one-command half-life.", "depends_on": ["USER-909"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-30T00:34:18+08:00", "order": 38} {"id": "TASK-235", "title": "DECISIONS.md stops existing; perry-decide list is the surface", "summary": "DESIGN-013 step 1, User Decision 3. The file is 1,834 bytes, 76% table, 12 rows, longest cell 68 bytes, and its own third line already declares it generated: 'Rendered by bin/perry-decide from decisions/ADR-*.md. Those files are the record; this file is a view of them ... do not hand-edit rows here, they are overwritten.' perry-decide list already prints the same content. Smallest surface of the three steps: one reader, one writer.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-235-v4-review.md", "next_action": "V4 PASS 2026-08-30 with the required correction applied at 5de1ed5; evidence/2026-08/TASK-235-v4-review.md. Merging once the suite on the merge probe reports. THE CORRECTION: perry-decide's justification for dropping the ADR-004 gate closed with 'only the index write was ever gated', which is false — on a real main snapshot at ee0b36a with enforce and nothing declared, perry-decide new returns rc=1, refuses, and writes NO ADR body, while the branch returns rc=0 and writes ADR-001. The gate ran before the command and refused it entire. The clause now says so and tells the follow-up row to size itself as RESTORING a gate rather than closing a gap that was mostly open. Scope held: two files, and bin/perry-decide's diff is 2 comment lines out and 13 in with ZERO non-comment lines changed. WORTH KEEPING FROM THIS ROUND: the agent's first check appeared to REFUTE the reviewer, and that check was the broken one — it ran main's perry-decide with the BRANCH's PERRY_HOME, so it loaded the branch schema, found no files[id=decisions] and returned 'absent'. Recorded in the RESULT section 7 B as a silent way to measure the wrong thing, and filed separately. The declared gap is closed twice over: the reviewer's 458.4s / 2892 / 3 on a quiet machine, and the agent's own tests/parallel -j 4 on the same tree independently agreeing at 98 modules / 2892 tests / 613.6s / the same 3. The gap paragraph was KEPT rather than deleted — a gap declared and then closed is a different record from one that was never there.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-29T13:58:25+08:00", "order": null} {"id": "TASK-214", "title": "perry-decide's mint_id reads max(files ∪ index) but render_index rebuilds the index from the files, so the departed half erases itself", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-235-v4-review.md", "next_action": "Blocked on TASK-235, which may close it outright. The defect is that mint_id reads max(files ∪ index) while render_index rebuilds the index from the files, so the departed half erases itself. TASK-235 DELETES the index — if mint_id then reads the files alone, there is no departed half and this row is closed by that change rather than by its own. Do not start it separately; re-read it after TASK-235 lands and either close it with TASK-235's evidence or restate what survives.", "depends_on": ["TASK-235"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-28T15:32:54+08:00", "order": null} {"id": "TASK-226", "title": "a row entered .perry/conformance.md with neither of its two documented writers running", "summary": "The file that gates every write under the enforce gate gained a declaration nobody can account for. Observed between two perry-conform status runs about twenty minutes apart on 2026-08-28; the cause was not determined.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-226-v4-review.md", "next_action": "V4 PASS 2026-08-30 with four RESULT corrections in flight; evidence/2026-08/TASK-226-v4-review.md. The conclusion was ruled POSITIVELY ESTABLISHED, not merely fitted: the reviewer checked the epoch arithmetic two ways with no timezone slip, confirmed both readings from the transcript itself (10:24:19.530Z and 10:30:42.309Z, next user prompt 10:25:13.205Z — 2.2s after the shell command), enumerated every agent event in the window with nothing left over, and replicated the byte-for-byte reproduction from scratch to md5 ff66fbf343266a0f339fc48df8b0cd44. THE REVIEW'S OWN FINDING, now TASK-241: the RESULT files a misparse class as 'inert, never affects a verdict' and three of it are NOT — a backticked, indented or fenced path cell parses to a plain key because read_conformance strips backticks, which flips a real file undeclared to conformant and lets the next legitimate declare LAUNDER the decorated row into a canonical one, on the file that gates every write under enforce. It did not cause this row: the elimination rests on the render fixed-point check, which the reviewer reproduced and calls a complete detector for the whole class. So the conclusion is safe and the argument offered for it was not. CORRECTIONS SENT BACK: 'eliminated by experiment rather than by grep' is overstated (row 11 is a grep); 'no other file names CONFORMANCE_FILE' is false (bin/perry-lint:3512 does, read-only, conclusion survives); 'no other input produces it' is false; the lesson has no procedure attached and outranks the RESULT's own better root cause — no actor column, date-only timestamps, and perry-conform never writes events.jsonl, all three verified; and the reproduction recipe no longer replays against the current install because TASK-235 merged at 00:42 and removed DECISIONS.md's schema spec, so it must be run against the skill at 0179c02^. Close at V4 once those land.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T19:11:41+08:00", "order": null} -{"id": "TASK-242", "title": "linkage-kr-exists proves SOME phase has resolvable overall edges, not that THIS phase does", "summary": "Found by the TASK-157 V4 reviewer 2026-08-30 while confirming that row's own audit. TASK-157's new guard requires every non-empty linked: value to RESOLVE against perry-goals list --level overall — resolve rather than shape-match, because KR-O9.9 has the right shape — and refuses to pass on zero values, via checked >= 8. That threshold is satisfied by phases 001 and 003 between them, both already correct. So a phase 004 authored with every linked: empty passes the guard untouched. It is the same shape as the defect TASK-157 exists to fix, displaced into the future: TASK-157 caught eight KR-to-OKR edges silently replaced by prose, and this lets the next phase drop all of its edges silently instead. TASK-157's own round is deliberately NOT widening the guard — changing what it requires is a scope decision rather than a template fix — so it is filed here.", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked until TASK-157 lands, since it is that row's guard being strengthened. Start from evidence/2026-08/TASK-157-v4-review.md, which carries the measurement: at f15d234 all eight linked: values in 001-linkage.md were verbatim the retro Measured column, and 16 of 24 cells survived; after the fix, 24 of 24. The threshold that now protects that is checked >= 8, and 001 and 003 supply all eight between them.", "depends_on": ["TASK-157"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-30T01:47:44+08:00", "order": 12} +{"id": "TASK-242", "title": "linkage-kr-exists proves SOME phase has resolvable overall edges, not that THIS phase does", "summary": "Found by the TASK-157 V4 reviewer 2026-08-30 while confirming that row's own audit. TASK-157's new guard requires every non-empty linked: value to RESOLVE against perry-goals list --level overall — resolve rather than shape-match, because KR-O9.9 has the right shape — and refuses to pass on zero values, via checked >= 8. That threshold is satisfied by phases 001 and 003 between them, both already correct. So a phase 004 authored with every linked: empty passes the guard untouched. It is the same shape as the defect TASK-157 exists to fix, displaced into the future: TASK-157 caught eight KR-to-OKR edges silently replaced by prose, and this lets the next phase drop all of its edges silently instead. TASK-157's own round is deliberately NOT widening the guard — changing what it requires is a scope decision rather than a template fix — so it is filed here.", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked until TASK-157 lands, since it is that row's guard being strengthened. Start from evidence/2026-08/TASK-157-v4-review.md, which carries the measurement: at f15d234 all eight linked: values in 001-linkage.md were verbatim the retro Measured column, and 16 of 24 cells survived; after the fix, 24 of 24. The threshold that now protects that is checked >= 8, and 001 and 003 supply all eight between them.", "depends_on": ["TASK-157"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-30T01:47:44+08:00", "order": 11} {"id": "TASK-157", "title": "the same KR is written twice — a phase table and a linkage YAML — with no check between them, and plan-phase hand-authors one of them", "summary": "Investigated 2026-08-29 at 30cc467; spec at evidence/2026-08/TASK-157-spec.md. A phase declares each KR in TWO files under perry/phase/ — the markdown table in 00N-.md and the YAML frontmatter in 00N-linkage.md — with id, title, metric and target duplicated in full. perry-lint reports drift for four stores (tasks, risks, OKR, config) and NOTHING for this pair; there is no reconcile for it anywhere. The markdown copy is the one that goes stale and already has: P003-O2-KR1 reads target 0 on the live phase while the literal count is >=7, filed by two reviewers. The linkage YAML has the only writer (perry-goals link), the only reader (viewer/parsers.py:3192) and a spec version; nothing writes the markdown table at all — plan-phase authors it by hand in a file its own header documents as machine-written, which is this row's original title and one half of the defect.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-21", "verification": "V4", "evidence": "evidence/2026-08/TASK-157-v4-review.md", "next_action": "V4 PASS with the template fix applied at aab4186; merging once the merge-probe suite reports. THE TEMPLATE IS CLOSED: goals/state/linkage_TEMPLATE.md — the file an author writes the register FROM — now carries a linked: slot on all three KR stubs, with a comment saying it is an id and not prose and naming the phase-001 mistake that made the point; the metric: placeholder reads 'metric, as prose — always safe to display' instead of pointing at the phase file; linked is in the template's own field table with the rule that it must resolve to an overall KR OKR.md declares. ONE MORE STALENESS FOUND OUTSIDE THE SCOPE LINE AND FLAGGED AS SUCH: goals/reference/linkage.md:128 described perry-lint as checking 'every KR id present in the phase file', which has been false since f15d234 replaced that scan with the two id questions. Corrected, and the agent said explicitly it was one line past the scope it was given — a documented check that no longer existed. M13 and M14 each redden the same named test alone, green-first and md5-restored; fourteen mutations on this row now. A WRINKLE WORTH KEEPING: the first version of that test asserted 'as written in the phase file' not in text over the WHOLE FILE, and went red on the agent's own comment quoting the removed placeholder to explain why it is gone — a substring test over a file reads the explanation as the defect it explains. Now it reads the metric: value lines only. Same family as the test_board_render prose defect filed tonight. RECORDED NOT FIXED: the guard proves some phase has overall edges rather than that the newest one does (TASK-242); and aiMark and every external consumer are unrun by both this round and the reviewer — perry-state --json keeps its key shape and kr_total, but metric now carries the register's wording, longer than the document's cell on 22 of 24 KRs, so a pinned consumer sees no structural break and an unmeasured rendering change. bash tests/run at head: 99 modules / 2914 tests / the same five pre-existing.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-21T01:41:07", "order": null} {"id": "TASK-203", "title": "an ordinary write does not update its store, for either the risks or the intake register — one row, both registers", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-203-round5-v4-review.md", "next_action": "V4 PASS with both corrections applied at 65f93ef; merging once the merge-probe suite reports. bin/perry-task's md5 is UNCHANGED from the reviewed tip — neither correction touched the bound. CORRECTION 1, the control now controls: drifted() asserted three things and compared none of them to each other, so drifted([1,2,3,4]) sailed through; one assertLess(rows, records) in the shared control now fires with a message saying the board must hold FEWER rows than the store or no shrink is possible and the test cannot tell whether the bound fired — which is exactly how round 4's clean-board test passed with the allowance and without it. The RESULT says the sentence WAS overstated and that the assertion is what made it true, rather than quietly reading as if it always was, framed as 'a control that cannot fail is the same mistake as a test that cannot fail, one level up'. MB1c re-ran the bound-removal mutation after the change and the SAME SEVEN named tests go red with the md5 unchanged — the tightened control changed the mutation evidence not at all, which is what a control that is not part of the subject should do. CORRECTION 2: the tree-layout attribution is struck, replaced by the reviewer's measurement that discover gives the author's figures on BOTH a worktree copy and a git archive extraction, so layout is not the cause and the cause is UNKNOWN — recorded as unexplained. ALSO RECORDED: the zh bounded refusal is logged as verified BY THE REVIEW, explicitly weaker provenance than verified here and explicitly not the same as untested; and a new section 8 names the count-preserving substitution as the door this bound does NOT close, with the reviewer's numbers, so nobody reads the before/after tables as 'the intake store can no longer be corrupted at rc 0'. The bound closes the shrink, not the swap. Final baseline at 0ef1576: 99 modules / 2929 tests / 3 failures, same red set; the added assertion introduced no test.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T14:39:08+08:00", "order": null} -{"id": "TASK-245", "title": "tests/parallel main() has never had test coverage, and the guard TASK-230 added there survives its own deletion", "summary": "Found by the TASK-230 V4 reviewer 2026-08-30 and ruled non-blocking for that row. TASK-230 added a refusal to main(): --ids will not write a file whose count disagrees with unittest's own Ran N. The refusal demonstrably fires end-to-end at rc=1 with no file written, and unaccounted() — the function behind it — is unit-tested. But its USE is not: changing 'if short:' to 'if False:' in main() leaves the entire suite green. TASK-230's RESULT says a unit test 'cannot give' that coverage; the reviewer measured otherwise and says it is a run_module monkeypatch away. The pre-existing zero-test guard in the same function survives deletion identically, so this is not a regression TASK-230 introduced — it is the shape of main() itself, which has never been covered. The reviewer's extended sweep of thirteen mutations across every production surface the new tests touch found all 25 tests dying under at least one, with this guard the ONLY survivor.", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "Blocked until TASK-230 lands. Start from evidence/2026-08/TASK-230-v4-review.md, which carries both the deletion measurement and the reviewer's statement that a run_module monkeypatch is enough — TASK-230's own RESULT is being corrected to say so rather than that it cannot be done.", "depends_on": ["TASK-230"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-30T03:11:53+08:00", "order": 14} -{"id": "TASK-244", "title": "the suite's floor is one module: test_task_writer.py runs alone for 105-149s and no worker count moves it", "summary": "Measured by TASK-230 across twelve full runs, 2026-08-30. Longest-first scheduling saves 33-37% of wall-clock and matches the perfect-knowledge schedule in 3 of 4 load-controlled runs — but the run cannot finish before its longest single module does, and test_task_writer.py takes 105-149s by itself. The spec's 'under two minutes' is therefore not reachable by scheduling, and TASK-230 says so rather than claiming it. Getting below that floor means sharding BELOW the file — splitting one module across workers — which TASK-230's spec explicitly scopes out and which its author names as the next row. This is that row.", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked until TASK-230 lands. PREMISE CORRECTED 2026-08-30 by TASK-230's own post-review measurement — read this before starting, because the row was filed on a number that has since moved. On a QUIET machine bash tests/run on the corrected tree is 108.9s, UNDER the two-minute target, red on exactly the same five tests, with CPU identical to a 266.7s run (341.1s user + 157.0s sys against 338.6s + 153.9s) — identical work, less than half the wall. A follow-up --times run took 126.3s while test_task_writer.py took 126.28s: the suite finished 0.02 SECONDS after its longest module, which independently reproduces the reviewer's 246.74-of-246.8 observation. So the floor is real and the schedule is optimal — 'optimal is one module long' — but the target is NOT structurally unreachable the way this row was filed. It is reachable on a quiet machine and unreachable on a loaded one, and the module's own 105-149s is the whole margin. That changes what this row is for: not 'get under two minutes', which the machine already does when it is idle, but 'stop one module owning the floor', so the number holds under the load this project actually runs at. TASK-230's spec-contradiction finding still stands — the spec asks for a number only sharding can guarantee while forbidding sharding below the file — and correcting that wording is part of this row.", "depends_on": ["TASK-230"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-30T02:51:51+08:00", "order": 13} +{"id": "TASK-245", "title": "tests/parallel main() has never had test coverage, and the guard TASK-230 added there survives its own deletion", "summary": "Found by the TASK-230 V4 reviewer 2026-08-30 and ruled non-blocking for that row. TASK-230 added a refusal to main(): --ids will not write a file whose count disagrees with unittest's own Ran N. The refusal demonstrably fires end-to-end at rc=1 with no file written, and unaccounted() — the function behind it — is unit-tested. But its USE is not: changing 'if short:' to 'if False:' in main() leaves the entire suite green. TASK-230's RESULT says a unit test 'cannot give' that coverage; the reviewer measured otherwise and says it is a run_module monkeypatch away. The pre-existing zero-test guard in the same function survives deletion identically, so this is not a regression TASK-230 introduced — it is the shape of main() itself, which has never been covered. The reviewer's extended sweep of thirteen mutations across every production surface the new tests touch found all 25 tests dying under at least one, with this guard the ONLY survivor.", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "Blocked until TASK-230 lands. Start from evidence/2026-08/TASK-230-v4-review.md, which carries both the deletion measurement and the reviewer's statement that a run_module monkeypatch is enough — TASK-230's own RESULT is being corrected to say so rather than that it cannot be done.", "depends_on": ["TASK-230"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-30T03:11:53+08:00", "order": 13} +{"id": "TASK-244", "title": "the suite's floor is one module: test_task_writer.py runs alone for 105-149s and no worker count moves it", "summary": "Measured by TASK-230 across twelve full runs, 2026-08-30. Longest-first scheduling saves 33-37% of wall-clock and matches the perfect-knowledge schedule in 3 of 4 load-controlled runs — but the run cannot finish before its longest single module does, and test_task_writer.py takes 105-149s by itself. The spec's 'under two minutes' is therefore not reachable by scheduling, and TASK-230 says so rather than claiming it. Getting below that floor means sharding BELOW the file — splitting one module across workers — which TASK-230's spec explicitly scopes out and which its author names as the next row. This is that row.", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked until TASK-230 lands. PREMISE CORRECTED 2026-08-30 by TASK-230's own post-review measurement — read this before starting, because the row was filed on a number that has since moved. On a QUIET machine bash tests/run on the corrected tree is 108.9s, UNDER the two-minute target, red on exactly the same five tests, with CPU identical to a 266.7s run (341.1s user + 157.0s sys against 338.6s + 153.9s) — identical work, less than half the wall. A follow-up --times run took 126.3s while test_task_writer.py took 126.28s: the suite finished 0.02 SECONDS after its longest module, which independently reproduces the reviewer's 246.74-of-246.8 observation. So the floor is real and the schedule is optimal — 'optimal is one module long' — but the target is NOT structurally unreachable the way this row was filed. It is reachable on a quiet machine and unreachable on a loaded one, and the module's own 105-149s is the whole margin. That changes what this row is for: not 'get under two minutes', which the machine already does when it is idle, but 'stop one module owning the floor', so the number holds under the load this project actually runs at. TASK-230's spec-contradiction finding still stands — the spec asks for a number only sharding can guarantee while forbidding sharding below the file — and correcting that wording is part of this row.", "depends_on": ["TASK-230"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-30T02:51:51+08:00", "order": 12} {"id": "TASK-230", "title": "the full suite takes eleven minutes, and that cost has started changing behaviour", "summary": "2793 tests across 91 files, 76 of which spawn subprocesses (276 call sites). Two dispatches died on it on 2026-08-28: both subagents kicked the suite off and were killed by a 600s no-progress watchdog before they could commit.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-230-v4-review.md", "next_action": "V4 PASS with all four corrections applied at eb75dbe; merging once the merge-probe suite reports. THE GUARD IS FIXED, NOT EXPLAINED AWAY: main() is now driven directly with run_module stubbed, and the refusal gets THREE mutations rather than one because it is two independently deletable lines plus the direction where a guard gets 'fixed' into refusing everything — M6 'if short:' to 'if False:' (the reviewer's own), M7 'if args.ids and short:' to 'if False:', M8 'if short:' to 'if True:', all three red, tree md5-restored. The original error is KEPT in the RESULT rather than deleted, because the untestability claim was load-bearing for shipping an uncovered guard. 'FOUR TIMES OF FOUR' IS NOW TWO, with the reasoning: the two longest-first runs are arithmetic identities where the longest module starts at t=0 and the simulated makespan restates that module back at itself; only the two ALPHABETICAL runs predict anything, and they landed 179.7 against 179.8 and 241.1 against 241.1. The md5 is replaced by an end-to-end proof re-run against the committed state, and the published range becomes 133-285s median 149.7s — the author's note is that the reviewer's 246.8s run was inside the honest range and outside the published one, which was 'the same optimism I had just finished criticising in the inherited docstring'. TWO THINGS AFTER THE REVIEW. The machine went quiet: 108.9s, UNDER the two-minute target, same five reds, CPU identical to a 266.7s run — and a --times run finished 0.02s after test_task_writer.py's own 126.28s, reproducing the reviewer's 246.74-of-246.8. TASK-244's premise is corrected accordingly. AND THE FLAKE FIRED AGAIN WITH A WORSE COUNT: test_host_support is 2 of 10 longest-first against 0 of 5 alphabetical, not the 1-of-7 published. The author still declines to claim an effect but records that publishing the stale ratio because it read better is the failure this row exists to catch — and notes the second occurrence fired at load 5.76, which cuts AGAINST the contention mechanism it had volunteered. Recorded as evidence against its own story.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-28T23:00:46+08:00", "order": null} -{"id": "TASK-246", "title": "an unreadable row in .perry/conformance.md is now DELETED by the next declare, not laundered", "summary": "Reported by TASK-241's author against its own change, 2026-08-30, and not filed by it because the PMO owns the board. bin/perry-conform:423 render rewrites the whole file from the parsed declarations. Before TASK-241 a decorated row parsed to a plain key, so the next declare LAUNDERED it into a canonical row — that was the defect TASK-241 closes. After TASK-241 the row is unreadable instead, so the next declare simply does not carry it forward and it is GONE from the file. The author calls that fail-closed and better than laundering, and says the change ENLARGES a pre-existing case: the same already happened for an unreadable version cell. It is better than the alternative and it is still a write that destroys a line the user typed, with no report at the moment of destruction.", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked until TASK-241 lands. Start from evidence/2026-08/TASK-241-result.md, where the author states this against its own change rather than leaving it to a reviewer — that is the reason to trust the framing. Note the pre-existing half: an unreadable VERSION cell already behaved this way before TASK-241, so this is not a regression the row introduced, only one it made reachable more often.", "depends_on": ["TASK-241"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-30T03:27:22+08:00", "order": 15} -{"id": "TASK-247", "title": "three sites still ask 'is there a .perry/config.md' as their test for 'is this configured' — two in perry-diagnose, one in perry-migrate", "summary": "Declared as a COUNT by TASK-233 round 2 on 2026-08-30 rather than folded into a sweep claim — which is the distinction round 1 was failed for — and confirmed independently by its V4 reviewer, which also named a third site the author's grep shape did not surface. The re-run grep over bin/ and viewer/ for EXISTENCE checks returns five hits: viewer/parsers.py:401 is the predicate itself, bin/perry-goals:2177 already asks the wide form, bin/perry-lint:637 is TASK-095's class, and bin/perry-diagnose:1373 and :2501 still ask the narrow way. The reviewer added bin/perry-migrate:228, where board_language() returns 'en' the moment .perry/config.md is absent — so a project configured by the store alone migrates into English regardless of what its store says. perry-diagnose was not converted because it does not import parsers, making it an import change plus two guards. THE REVIEWER MADE THIS ROW A CONDITION OF ITS PASS: 'the two bin/perry-diagnose sites and bin/perry-migrate:228 exist only in this report. If they are not filed as a row, the count becomes evidence nobody is counting.'", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked until TASK-233 lands. THREE sites, not two — the third came from TASK-233 round 2's reviewer: bin/perry-migrate:228 board_language() returns 'en' as soon as .perry/config.md is absent, so a store-only project migrates into English no matter what its store declares. That one differs from the other two: it is not an 'installed' question but a LANGUAGE default, and getting it wrong writes a board in the wrong language rather than reporting a project as unconfigured. Start from evidence/2026-08/TASK-233-round2-v4-review.md and evidence/2026-08/TASK-233-result.md, which carry the grep and the classification of every hit. TASK-233 round 2 proved by cross-check that its own two sites fail under DIFFERENT conditions and that one test covering both would have been a false guard — assume the same here until measured otherwise, and give each of the three its own named test and its own mutation. This row is a CONDITION of TASK-233's V4 PASS: the reviewer wrote that if these are not filed, the declared count becomes evidence nobody is counting.", "depends_on": ["TASK-233"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-30T04:58:07+08:00", "order": 16} +{"id": "TASK-247", "title": "three sites still ask 'is there a .perry/config.md' as their test for 'is this configured' — two in perry-diagnose, one in perry-migrate", "summary": "Declared as a COUNT by TASK-233 round 2 on 2026-08-30 rather than folded into a sweep claim — which is the distinction round 1 was failed for — and confirmed independently by its V4 reviewer, which also named a third site the author's grep shape did not surface. The re-run grep over bin/ and viewer/ for EXISTENCE checks returns five hits: viewer/parsers.py:401 is the predicate itself, bin/perry-goals:2177 already asks the wide form, bin/perry-lint:637 is TASK-095's class, and bin/perry-diagnose:1373 and :2501 still ask the narrow way. The reviewer added bin/perry-migrate:228, where board_language() returns 'en' the moment .perry/config.md is absent — so a project configured by the store alone migrates into English regardless of what its store says. perry-diagnose was not converted because it does not import parsers, making it an import change plus two guards. THE REVIEWER MADE THIS ROW A CONDITION OF ITS PASS: 'the two bin/perry-diagnose sites and bin/perry-migrate:228 exist only in this report. If they are not filed as a row, the count becomes evidence nobody is counting.'", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked until TASK-233 lands. THREE sites, not two — the third came from TASK-233 round 2's reviewer: bin/perry-migrate:228 board_language() returns 'en' as soon as .perry/config.md is absent, so a store-only project migrates into English no matter what its store declares. That one differs from the other two: it is not an 'installed' question but a LANGUAGE default, and getting it wrong writes a board in the wrong language rather than reporting a project as unconfigured. Start from evidence/2026-08/TASK-233-round2-v4-review.md and evidence/2026-08/TASK-233-result.md, which carry the grep and the classification of every hit. TASK-233 round 2 proved by cross-check that its own two sites fail under DIFFERENT conditions and that one test covering both would have been a false guard — assume the same here until measured otherwise, and give each of the three its own named test and its own mutation. This row is a CONDITION of TASK-233's V4 PASS: the reviewer wrote that if these are not filed, the declared count becomes evidence nobody is counting.", "depends_on": ["TASK-233"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-30T04:58:07+08:00", "order": 14} {"id": "TASK-233", "title": ".perry/config.md is load-bearing because six settings and the conformance gate still read it, not because the store lacks them", "summary": "Measured 2026-08-29 at 7df879d. .perry/config.jsonl carries all 9 records — 7 settings and 2 tracks — and nothing structured in the markdown is missing from it. But only ## Tracks was converted to read the store. perry-state:115 parse_config regex-scans the markdown for six settings and early-returns an empty config when the file is absent; perry-conform:304 reads the Conformance gate the same way. So deleting the file today silently blanks document language, chat language, repo layout, state root and both repo paths, and drops the gate to the shipped default. Two more things stand in the way: perry-config render cannot rebuild the file from the store (with it deleted it prints 'no .perry/config.md' and exits 0 — filed separately), so it is an in-place cell updater rather than the projection BOARD.md has; and 27 of the file's 45 lines are prose the store has no field for — what intake carries versus main, why Default rung is V3 rather than queue mode's V2, and why the state root is not '.' (the DESIGN-002 collision). SKILL.md names the file in 6 places including the first-run path at :89, and :195 records the reason the field names stay English in every language: this file declares the language and must be readable before it is known.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-233-round2-v4-review.md", "next_action": "V4 ROUND 1: FAIL 2026-08-30, evidence/2026-08/TASK-233-v4-review.md. Round 2 dispatched; the fix is two lines and the correction to the RESULT is the larger part. THE FAIL: bin/perry-state still asks 'is there a .perry/config.md' as its test for 'is this configured' in TWO places — :2022, the installed gate, and :2607, its own project-root walk, which is a BYTE-FOR-BYTE DUPLICATE of the walk that WAS converted in bin/perry-lint main and viewer/parsers.py _resolve_project_root. Reproduced on the branch tip with the markdown deleted and the store untouched, same tree, same cwd, same PERRY_HOME: perry-lint walks up and finds the project, and perry-state --json does not — it reports 'No Perry state found — run /perry for first-time setup', WHICH IS THE EXACT STRING THE AUTHOR QUOTES as the defect that justified converting resolve_state_root. The row's own fix justification still reproduces one file over. Second site with --root given so the walk is out of play: a project configured by the STORE alone reads installed:false while the same project configured by the MARKDOWN alone reads true. WHY IT BLOCKS, and it is not the reach — the reach is narrow, needing cwd != project root: the row serves a KR that COUNTS CALL SITES IN bin/, the missed sites are in the file the spec names first, and the RESULT ASSERTS COMPLETENESS rather than declaring a gap. The author's own declared grep finds both in one command. Declared gap 4 was honest and does not block; the FAIL is a DIFFERENT sentence in section 4, which says the four converted sites 'were the rest'. WHAT THE REVIEWER CONFIRMED INDEPENDENTLY: the third-reader call was CORRECT and not scope creep — reproduced on main at 658e8c9, so the spec's own V4 step 1 was unsatisfiable as written; the placement and the _validated_config_records delegate are behaviour-preserving; the md5 reproduced on both sides of the rebuild; the 29 prose lines verbatim; the harness's '>=1 test selected' assertion real; all 38 tests in the new module reddened, with the mutations 28-of-28 red on the reviewer's own driver against a clean clone. All five declared gaps ruled non-blocking. TWO NUMBER CORRECTIONS: the mutation table has 28 rows, not the 27 the text claims; and the reviewer measured 100/2992/3 to 101/3031/the same 3, getting a third data-dependent test_diagnose failure the author did not, red on both sides. ONE MINOR: mutation X4, dropping the store-default versus store distinction in config_store_settings, leaves all 38 green — a documented reason value with no guard.", "depends_on": ["TASK-095"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-29T13:38:02+08:00", "order": null} -{"id": "TASK-248", "title": "a canonical row inside
, an HTML comment, or 
still declares a file conformant, and is still laundered", "summary": "Found by the TASK-241 round 2 V4 reviewer, 2026-08-30, and ruled non-blocking for that row. A bare canonical row placed inside an HTML block —
, or an HTML comment — reads as a real declaration: conformant with 0 unreadable, identically at the fork point, at round 1 and at round 2. TASK-241 closes the three markdown decoration traps the spec named (backticked, indented, fenced, including four nestings) and this is outside all of them: it is invisible to the round-trip property BY CONSTRUCTION, because the row inside the HTML is byte-for-byte a genuine row, exactly as a fenced row is. It is not a regression — nothing TASK-241 did made it reachable — and TASK-234's conversion of the record to .perry/conformance.jsonl dissolves it entirely. It is filed because the file gates every write under ADR-004's enforce gate and because TASK-241's section 9 mentions HTML blocks only in the fence-line direction, which reads as coverage; that wording is being corrected in the same round.", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked until TASK-241 lands. Start from evidence/2026-08/TASK-241-round2-v4-review.md, which carries the measurement at all three trees. Read TASK-246 beside this one — same file, same class of question about what the reader should do with a row it will not honour — and consider whether the two want one answer rather than two. Note the reviewer's framing: this is invisible to the round-trip property BY CONSTRUCTION, for the same reason a fenced row is, so the answer is structural rather than another predicate.", "depends_on": ["TASK-241"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-30T05:19:33+08:00", "order": 17}
 {"id": "TASK-241", "title": "a backticked path in .perry/conformance.md parses to a plain key, so a decorated row can declare a file conformant and the next legitimate declare launders it into a real one", "summary": "Found by the TASK-226 V4 reviewer 2026-08-30, who ran seven misparse traps where the author had run five. Three are NOT inert. read_conformance strips with strip('`  '), so a row whose path cell is in BACKTICKS — or indented, or inside a fenced block — parses to the same plain key as an undecorated row. Measured on a copy: it flips a real file from 'undeclared' to 'conformant', and the next legitimate perry-conform declare rewrites the whole file from the parsed declarations, LAUNDERING the decorated row into a plain canonical row indistinguishable from one a person wrote on purpose. This is the file that gates every write under ADR-004's enforce gate. TASK-226's RESULT files this class as 'inert ... never affects a verdict' — true of asterisks, false of the class. It did NOT cause TASK-226's phantom row: that row's elimination rests on a render fixed-point check the reviewer reproduced independently (23/24 declarations, 0 unreadable, render(parse(f)) == f on both actual files), which is a complete detector for the whole class. So the elimination stands; the argument offered for it does not.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-241-round2-v4-review.md", "next_action": "V4 ROUND 2: PASS 2026-08-30; evidence/2026-08/TASK-241-round2-v4-review.md. Two RESULT corrections in flight, then merge. THE RULING THE ROUND ASKED FOR: the contiguous-run rejection STANDS, and the reviewer established it the hard way — it BUILT the framing itself from round 1's own sentence, into its own copy, never having seen the author's prototype, and probed it against its own 22-shape catalogue. The author's measurement reproduces exactly. It added two things the RESULT does not say, and both strengthen the rejection: it would have been a REGRESSION rather than merely a non-fix, because shape 15, the whole table in a plain unnested fence, is already closed by round 1's broken toggle and the contiguous run HANDS IT BACK; and the obvious patch, 'only the first header run counts', is the same all-or-nothing failure section 1 rejects the whole-file fixed point for, reached by document order instead of by a stray line. Round 1's framing had a hole neither that reviewer nor the PMO saw. VERIFIED INDEPENDENTLY: the 21-shape catalogue cell-for-cell across three trees, six flips, nothing regressed, four legitimate rows still declaring, 09 and 12 confirmed fail-open on round 1; all 15 mutations on the reviewer's OWN harness with its own replacement lines, M9 through M13 each reddening exactly one named test, M1 disjoint from M2/M3, M14 reddening at the EXIT-CODE assertion; and the all-or-nothing replacement measured genuinely stronger rather than differently worded — one stray blank line voids all 23 of Perry's real declarations under a whole-file rule and 0 under the per-row rule. Suite on the merged tree 3c5f186: 101 modules / 3036 tests / 3 failures, exactly the PMO's figure, WITH NO FOURTH FAILURE — an eighth non-reproduction of the flake, which does not block. It also ran two checks nobody had: seven 'must still declare after a properly closed fence' shapes with no false refusals, and the human non-JSON status rendering. And it could not run declare, because the brief forbade it, so it COMPUTED what declare writes via render(parse(record)) and said so as a method note rather than claiming the command was exercised. TWO CORRECTIONS SENT BACK: section 9 mentions HTML blocks only in the fence-line direction, which reads as coverage — a bare canonical row inside 
 or an HTML comment still declares and is still laundered, identically at all three trees, filed as TASK-248; and section 4's 'no clause of the new mechanism can be deleted with the suite unchanged' should say FENCE RULE, since M16/M17 show two other clauses survive weakening with the suite green. That second one is the same shape TASK-233 was failed for tonight, one row over.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-30T01:00:58+08:00", "order": null}
 {"id": "TASK-050", "title": "One normalization for a header cell, not two", "owner": "Coding Agent", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-050-round11-v4-review.md", "next_action": "V4 ROUND 11 PASS 2026-08-30; corrections at 642091f now in DELTA CONFIRMATION with the same reviewer, because they went beyond what was charged and the PASS predates them. WHAT THE CORRECTIONS DID. Correction 3 was charged as two branches surviving their own deletion; the author fixed both — ast.Set DELETED as unreachable, since a row is a list and a list is not hashable so a row cannot be an element of a set literal, and yield from TESTED by D43 with R11-24 reddening D43 alone — and then decided the real gap was its own CANDIDATE LIST. It rebuilt the sweep to take candidates from git diff rather than from itself: 128 candidates over the 337 new or changed lines, each mutated on the AST and re-emitted with ast.unparse so a multi-line condition cannot break syntax, with a control run that unparses without mutating and stays green, and every corpus-clean candidate re-probed against the runtime half so that green means the corpus is caught AND the remainder test is unmoved. Result: 60 red in the corpus, 26 red in the watch, 22 unneutralisable, 20 green. IT FOUND FOUR MORE UNPINNED DETECTION BRANCHES beyond the review's two — D44 _rpaths_of by attribute name, D45 _bind_element on a comprehension generator, D46 the cell() half of the tuple unpack, D47 the subscript half of the carried write — each now single-entry. The twenty remaining greens are claimed to contain NO detection branch and all twenty are named in section 1.5 with why a planting corpus cannot pin each, because that is a claim; two were re-verified by hand on the reasoning that a sweep disagreeing with a hand check is a broken sweep, and both agreed. The sweep's own bounds are stated: whole if tests rather than conjuncts, no constants or operators, tests/header_rule.py only. DRIFT 42 to 47 all caught, CLEAN 14 none flagged, 27 mutations all red, eight single-entry. Correction 1: reproduced the synthetic-file proof itself before changing anything, then fixed the why in three places including UNCOVERED's own comment, where the next round will actually read it — five of eight cross-module, three _paths lacking a comprehension branch, next target 5 not 0. Correction 2: the table re-measured in one run, R11-5 reddening D38 D42 D43 D44 D45, and EVERY ROW NOW CARRIES ITS ANCHOR, which is the fix for the reviewer being unable to verify 9 of 23 from a table that named mutations and not lines. The author records that this is the SECOND time on this row a mutation table was published against a corpus older than itself, and that both times someone else found it. Baselines unchanged: 102 modules / 3036 tests / the same 3; offenders_by_symbol empty; 76 sites / 27 static-blind / remainder 8.", "depends_on": [], "commitment": "", "parent": "", "group": "P0 (must finish this period)", "role": "", "created": "2026-08-17T19:24:52", "order": null, "summary": ""}
-{"id": "TASK-239", "title": "the decide lane is fully ungated under ADR-004 after TASK-235, and the comment that records it says the opposite", "summary": "PMO verification of two of its claims, 2026-08-30 10:50: the THIRD gate( site is real — grep over bin/ returns exactly three, perry-task, perry-goals:3251 and perry_md_store.py:1157, so the agent's count is right and the review's two was wrong. One drift: perry-task's site is at 7375 now, not the 7194 the agent recorded, so that file moved under it between measurement and merge. Its 'perry-tasks --dry-run writes anyway' finding had no row of its own and lived only in this row's prose; filed as TASK-253.", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-239-spec.md", "next_action": "Blocked until TASK-235 lands. Start from the reviewer's measurement rather than re-deriving it: evidence/2026-08/TASK-235-v4-review.md carries both exit codes on both trees. The first question is not how to gate it but WHETHER ADR-004 was ever meant to cover a lane whose artefacts are prose documents — ADR-007 rule 3 says the Python layer never parses a document at all, and a conformance gate is a shape check on a document.", "depends_on": ["TASK-235"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-30T00:31:41+08:00", "order": 38}
-{"id": "TASK-250", "title": "ADR-004's 'every writer gates on it' is false of at least three writers, and nothing sweeps for the rest", "summary": "Measured across TASK-239's round and its V4 review, 2026-08-30. There are exactly THREE gate() call sites in all of bin/, and at least three writers reach a files[]-shaped path without passing one: perry-knowledge promote writes a files[]-shaped path with no gate (found by the TASK-239 author); bin/perry-tasks render --write rewrites BOARD.md ITSELF on an undeclared project under enforce, rc=0 and no warning, md5 confirmed before and after (found by its reviewer); and the decide lane, which TASK-239 settled as exempt by design. The first two were already true BEFORE TASK-235 deleted the decide lane's only gateable file, so ADR-004's sentence has not been literally true for some time and nobody noticed. That matters for how the decide-lane exemption reads: an exemption argued as 'this lane is special' is weaker when two other lanes were already ungated by accident. TASK-239 ships an UNGATED_BY_DESIGN surface whose stated job is 'the count line makes you think the rest is covered — it is not', and it currently lists ONE lane out of at least three, which is the same shape as the surface it exists to correct.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked until TASK-239 lands. Start from evidence/2026-08/TASK-239-v4-review.md, which carries both measurements and the count of gate() call sites. Note the framing the reviewer gave and do not lose it: the UNGATED_BY_DESIGN surface currently reads as exhaustive while naming one lane of at least three, which is the same failure as the count line it was built to correct.", "depends_on": ["TASK-239"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-30T10:07:55+08:00", "order": 40}
-{"id": "TASK-251", "title": "tests/run's output offers THREE numbers that all look like a failure count, and two of the three are wrong", "summary": "MECHANISM FOUND 2026-08-30 by the TASK-249 agent while retracting a number this trap had produced: tests/parallel:283 prints a red module's stderr TRUNCATED TO ITS LAST 25 LINES, and nothing is visibly elided. test_diagnose fails twice; the second failure's FAIL: header survives that window and the FIRST ONE'S DOES NOT, so the first arrives as a bare traceback with no prefix. That is why the output offers three numbers that all look like a failure count — grepping ^FAIL: gives 3, summing the per-module FAILED (failures=N) gives 4, and the summary line 'N module(s) red' gives 3 — and only the sum is right. It has now caught three agents in twelve hours, one of whom had DOCUMENTED the trap in its own result before walking into it, and one of whom used it to accuse a correct measurement of being wrong. The truncation is the root cause: the missing prefix is not a formatting choice, it is a line that fell off the top of a 25-line window.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Startable. The live instance is test_diagnose's queue-reconcile test, which prints without a FAIL: prefix on this repository today. Start from evidence/2026-08/TASK-239-v4-review.md, which carries the mechanism and the reviewer's account of walking into it itself. Note the shape of the fix: two numbers of independent origin agreeing is what TASK-230 shipped for --ids after the same class of accounting error, and it is the pattern that works here too.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-30T10:10:57+08:00", "order": 41}
-{"id": "TASK-252", "title": "a register write honours board rows it was never asked about, and the durable 'somebody has seen this' surface does not exist", "summary": "The two paragraphs TASK-243 wrote as its section 7 and which its V4 reviewer said should be rows rather than prose. FIRST, the fourth ending TASK-243 considered and rejected: 'a register write must not honour rows it did not address'. It was rejected because intake is position-keyed so the blast radius is real, and the reviewer accepted that rejection — but rejecting an ending inside one row is not the same as deciding the question, and today an ordinary write still carries every board row forward including ones the command never looked at. SECOND, the literal property TASK-243's own spec named and could not deliver: 'the drift report must not decrease while canonical records are being destroyed'. Its close was ruled acceptable, on the reasoning that the literal wording would require lint to report a disagreement that no longer exists and that the defect filed was the SILENCE, which is closed. What is still missing is a durable 'somebody has seen this' surface with a clearing condition — a place where a destruction stays visible after the write that announced it has scrolled away.", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked until TASK-243 lands. Start from evidence/2026-08/TASK-243-result.md sections 7.1 and 7.2 and from its V4 review, which accepted both the rejection and the close and then said each deserves a row. Note the reviewer's framing of the second: the literal property would require lint to report a disagreement that no longer exists, so the question is not how to keep drift high but where a loss lives after the write that named it.", "depends_on": ["TASK-243"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-30T10:22:29+08:00", "order": 18}
+{"id": "TASK-239", "title": "the decide lane is fully ungated under ADR-004 after TASK-235, and the comment that records it says the opposite", "summary": "PMO verification of two of its claims, 2026-08-30 10:50: the THIRD gate( site is real — grep over bin/ returns exactly three, perry-task, perry-goals:3251 and perry_md_store.py:1157, so the agent's count is right and the review's two was wrong. One drift: perry-task's site is at 7375 now, not the 7194 the agent recorded, so that file moved under it between measurement and merge. Its 'perry-tasks --dry-run writes anyway' finding had no row of its own and lived only in this row's prose; filed as TASK-253.", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-239-spec.md", "next_action": "Blocked until TASK-235 lands. Start from the reviewer's measurement rather than re-deriving it: evidence/2026-08/TASK-235-v4-review.md carries both exit codes on both trees. The first question is not how to gate it but WHETHER ADR-004 was ever meant to cover a lane whose artefacts are prose documents — ADR-007 rule 3 says the Python layer never parses a document at all, and a conformance gate is a shape check on a document.", "depends_on": ["TASK-235"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-30T00:31:41+08:00", "order": 37}
+{"id": "TASK-250", "title": "ADR-004's 'every writer gates on it' is false of at least three writers, and nothing sweeps for the rest", "summary": "Measured across TASK-239's round and its V4 review, 2026-08-30. There are exactly THREE gate() call sites in all of bin/, and at least three writers reach a files[]-shaped path without passing one: perry-knowledge promote writes a files[]-shaped path with no gate (found by the TASK-239 author); bin/perry-tasks render --write rewrites BOARD.md ITSELF on an undeclared project under enforce, rc=0 and no warning, md5 confirmed before and after (found by its reviewer); and the decide lane, which TASK-239 settled as exempt by design. The first two were already true BEFORE TASK-235 deleted the decide lane's only gateable file, so ADR-004's sentence has not been literally true for some time and nobody noticed. That matters for how the decide-lane exemption reads: an exemption argued as 'this lane is special' is weaker when two other lanes were already ungated by accident. TASK-239 ships an UNGATED_BY_DESIGN surface whose stated job is 'the count line makes you think the rest is covered — it is not', and it currently lists ONE lane out of at least three, which is the same shape as the surface it exists to correct.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked until TASK-239 lands. Start from evidence/2026-08/TASK-239-v4-review.md, which carries both measurements and the count of gate() call sites. Note the framing the reviewer gave and do not lose it: the UNGATED_BY_DESIGN surface currently reads as exhaustive while naming one lane of at least three, which is the same failure as the count line it was built to correct.", "depends_on": ["TASK-239"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-30T10:07:55+08:00", "order": 39}
+{"id": "TASK-251", "title": "tests/run's output offers THREE numbers that all look like a failure count, and two of the three are wrong", "summary": "MECHANISM FOUND 2026-08-30 by the TASK-249 agent while retracting a number this trap had produced: tests/parallel:283 prints a red module's stderr TRUNCATED TO ITS LAST 25 LINES, and nothing is visibly elided. test_diagnose fails twice; the second failure's FAIL: header survives that window and the FIRST ONE'S DOES NOT, so the first arrives as a bare traceback with no prefix. That is why the output offers three numbers that all look like a failure count — grepping ^FAIL: gives 3, summing the per-module FAILED (failures=N) gives 4, and the summary line 'N module(s) red' gives 3 — and only the sum is right. It has now caught three agents in twelve hours, one of whom had DOCUMENTED the trap in its own result before walking into it, and one of whom used it to accuse a correct measurement of being wrong. The truncation is the root cause: the missing prefix is not a formatting choice, it is a line that fell off the top of a 25-line window.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Startable. The live instance is test_diagnose's queue-reconcile test, which prints without a FAIL: prefix on this repository today. Start from evidence/2026-08/TASK-239-v4-review.md, which carries the mechanism and the reviewer's account of walking into it itself. Note the shape of the fix: two numbers of independent origin agreeing is what TASK-230 shipped for --ids after the same class of accounting error, and it is the pattern that works here too.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-30T10:10:57+08:00", "order": 40}
+{"id": "TASK-252", "title": "a register write honours board rows it was never asked about, and the durable 'somebody has seen this' surface does not exist", "summary": "The two paragraphs TASK-243 wrote as its section 7 and which its V4 reviewer said should be rows rather than prose. FIRST, the fourth ending TASK-243 considered and rejected: 'a register write must not honour rows it did not address'. It was rejected because intake is position-keyed so the blast radius is real, and the reviewer accepted that rejection — but rejecting an ending inside one row is not the same as deciding the question, and today an ordinary write still carries every board row forward including ones the command never looked at. SECOND, the literal property TASK-243's own spec named and could not deliver: 'the drift report must not decrease while canonical records are being destroyed'. Its close was ruled acceptable, on the reasoning that the literal wording would require lint to report a disagreement that no longer exists and that the defect filed was the SILENCE, which is closed. What is still missing is a durable 'somebody has seen this' surface with a clearing condition — a place where a destruction stays visible after the write that announced it has scrolled away.", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked until TASK-243 lands. Start from evidence/2026-08/TASK-243-result.md sections 7.1 and 7.2 and from its V4 review, which accepted both the rejection and the close and then said each deserves a row. Note the reviewer's framing of the second: the literal property would require lint to report a disagreement that no longer exists, so the question is not how to keep drift high but where a loss lives after the write that named it.", "depends_on": ["TASK-243"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-30T10:22:29+08:00", "order": 15}
 {"id": "TASK-243", "title": "a count-preserving substitution destroys canonical records silently, and the drift report goes DOWN as it happens", "summary": "Found by the TASK-203 round 5 V4 reviewer 2026-08-30, ruled non-blocking for that row and filed here. Swap N ## Intake rows on the board by hand — same count, different rows — and any register-touching command persists the swap, INCLUDING resolve-intake, which declares 0 removals and is therefore inside its bound. Measured: 10 canonical records lost, 10 gained, rc 0, and perry-lint going from '16 row(s) drifted' to '0 row(s) drifted' as the records are destroyed. Also reproduced on asks.jsonl on the zh fixture. TASK-203's invariant does not catch it and is not supposed to: USER-906 chose a COUNT rule, and 32 to 32 is not fewer. The reviewer's reason for filing rather than blocking is the part worth keeping — closing it needs a per-record IDENTITY predicate, which is round 2's door and the fifth predicate the amendment explicitly forbids, and no tool path reaches it today because every tool-produced case is a shrink and is already refused.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "perry/evidence/2026-08/TASK-243-result.md", "next_action": "V4 PASS with both corrections applied at f2c4925; merging once the merge-probe suite reports (probe merges with ZERO conflicts). bin/perry-task is BYTE-IDENTICAL to the reviewed 980c830 — the only changes since are a test and the result document. THE MISSING REGRESSION TEST: a new WIDE_INTAKE fixture, a nine-row ## Intake with SEVEN records destroyed in one write, because every other board in the module stages at most three, which is exactly why the len(lost) > 5 branch was unreachable. The test asserts four things behind a control that RUNS FIRST — the register really is nine records and assertGreater(staged.n, SUBSTITUTION_RECORDS_SHOWN), so on a narrower board the test dies on the control rather than passing. The four: the count is 7 and not 5, which kills the shortened-number mutation; exactly SUBSTITUTION_RECORDS_SHOWN identities are named, so the cap stays on the output; ', and 2 more' is present, which kills the tail deletion; and 'did not survive' rather than 'would not survive', the past-tense verb on a real write, which nothing had asserted. AND IT RE-MEASURED THE SURVIVALS RATHER THAN TAKING THEM: MS10, MS11 and MS12 each applied to 980c830 give 'Ran 25 tests OK' on all three, and on b96ab35 all three are RED with one named failure, against a 265-test control. THIRTEEN OF THIRTEEN MUTATIONS NOW DIE. THE SECTION 7 CAVEAT: a new 7.0 placed AHEAD of the literal-property gap, because it is the cost of the ending rather than a limitation of it — under REPORT, fixing lgoin to login in a Request cell prints a data-loss warning, and the section says plainly that this is correct, why Perry cannot know better (on ## Intake the identity IS the text), that the alternative is the refusal that hard-blocks the typo fix, and that the noise is BOUNDED TO intake because editing a USER- or RX- row's text leaves its identity alone. It names narrowing it via a minted intake key as a follow-up row, not a patch. Section 7.1 now carries the reviewer's reasoning verbatim.", "depends_on": ["TASK-203"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-30T02:26:23+08:00", "order": null}
-{"id": "TASK-253", "title": "bin/perry-tasks accepts --dry-run and writes anyway", "summary": "SHARPENED 2026-08-30 by the TASK-234 round-4 review: 'perry-tasks render --write' is not merely a writer that ignores the gate, it is a command Perry HANDS BACK TO READERS. bin/perry-migrate section _plan_task_store prints it in a refusal, from a function that has plan.project_root two lines above and does not pass it — so a reader who copies it runs 'render --write' against whatever project their cwd happens to be in, and it rewrites that project's BOARD.md. The review calls this worse harm than the rc=0 no-op that failed TASK-234 round 3, because that one changed nothing and this one changes a file. That makes this row a hazard, not a tidy-up.", "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-30T10:48:57+08:00", "order": 42}
-{"id": "TASK-254", "title": "bin/perry-lint hands back 22 commands and every one of them drops the root", "summary": "Filed 2026-08-30 by the PMO from the TASK-234 round-3 sweep. Agents cannot mint ids or write the board, so the finding needed a row from this side. Sibling of TASK-234; the wall standard it violates is bin/perry-conform:360.", "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-30T13:35:26+08:00", "order": 43}
-{"id": "TASK-255", "title": "Perry never shell-quotes a path into a command it hands a reader — shlex appears nowhere in bin/ or viewer/", "summary": "POPULATION CORRECTED 2026-08-30 by the TASK-234 round-5 reviewer, which re-derived the census with its own instrument: the row's headline was over FOURTEEN tools, not twelve. Over the other twelve it is 42 handed-back commands / 208 mentions, not 63 / 232 — the difference is exactly perry-conform and perry-migrate's own 21/24, which TASK-234 has now fixed. So THIS ROW'S POPULATION IS A THIRD SMALLER THAN FIRST FILED. The counts that actually matter reproduce exactly: 25 rootless and 19 raw, with both splits confirmed. Two further corrections: of the '3 genuine' raw interpolations outside these tools, TWO INTERPOLATE THE VERB, NOT AN ARGUMENT, so the genuine count is 1; and the backtick residual TASK-234 sized at two is EIGHT, of which only the message_for pair is pinned — two of the unpinned six are 'perry-tasks render --write' and 'write --from-board', the ones that WRITE. Whoever takes this row should re-derive the census a third time rather than inherit any of these numbers: it has now been measured three times and been wrong twice.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-234"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-30T14:20:46+08:00", "order": 44}
-{"id": "TASK-256", "title": "The mutation harness's md5 restore-check is circular: it compares the file to bytes it just wrote", "summary": "Filed 2026-08-30 from the TASK-249 round-4 result. The agent found it in its own instrument, reported it rather than quietly re-running, rebuilt the copy and re-ran the mutations. That is the right handling, and it is also why the defect is worth a row: the harness pattern is prescribed by the PMO in every brief, so this is a defect in the project's verification discipline and not in one agent's script.", "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-30T14:48:15+08:00", "order": 45}
-{"id": "TASK-257", "title": "The ignored-name bullet pin asserts a substring, not a bullet, and one satisfying string blinds the guard to BOARD.md", "summary": "Filed 2026-08-30 from the TASK-249 round-4 confirmation, which PASSED the row and merged it — these three are the non-blocking remainder. The '.md' case is the reason this is P1 rather than a tidy-up: a suffix that satisfies the pin makes the guard blind to the project's most-written file. The row can also strike its own 'the skip path is reasoned, not exercised' caveat: the reviewer exercised it on a case-sensitive APFS image, where the case-flipped spelling is correctly refused.", "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-30T15:17:06+08:00", "order": 46}
-{"id": "TASK-258", "title": "tests/test_tree_guard.py copies the LIVE repository, so any concurrent write reddens it", "summary": "Filed 2026-08-30. This is not a defect the TASK-249 rounds could have caught: all four rounds ran in private worktrees where nothing else was writing, which is exactly why a test that depends on the tree being still passed four reviews. It surfaced the first time the module ran in the live repository. The PMO caused the write; the fragility is the test's.", "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-30T15:55:00+08:00", "order": 47}
+{"id": "TASK-253", "title": "bin/perry-tasks accepts --dry-run and writes anyway", "summary": "SHARPENED 2026-08-30 by the TASK-234 round-4 review: 'perry-tasks render --write' is not merely a writer that ignores the gate, it is a command Perry HANDS BACK TO READERS. bin/perry-migrate section _plan_task_store prints it in a refusal, from a function that has plan.project_root two lines above and does not pass it — so a reader who copies it runs 'render --write' against whatever project their cwd happens to be in, and it rewrites that project's BOARD.md. The review calls this worse harm than the rc=0 no-op that failed TASK-234 round 3, because that one changed nothing and this one changes a file. That makes this row a hazard, not a tidy-up.", "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-30T10:48:57+08:00", "order": 41}
+{"id": "TASK-254", "title": "bin/perry-lint hands back 22 commands and every one of them drops the root", "summary": "Filed 2026-08-30 by the PMO from the TASK-234 round-3 sweep. Agents cannot mint ids or write the board, so the finding needed a row from this side. Sibling of TASK-234; the wall standard it violates is bin/perry-conform:360.", "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-30T13:35:26+08:00", "order": 42}
+{"id": "TASK-255", "title": "Perry never shell-quotes a path into a command it hands a reader — shlex appears nowhere in bin/ or viewer/", "summary": "POPULATION CORRECTED 2026-08-30 by the TASK-234 round-5 reviewer, which re-derived the census with its own instrument: the row's headline was over FOURTEEN tools, not twelve. Over the other twelve it is 42 handed-back commands / 208 mentions, not 63 / 232 — the difference is exactly perry-conform and perry-migrate's own 21/24, which TASK-234 has now fixed. So THIS ROW'S POPULATION IS A THIRD SMALLER THAN FIRST FILED. The counts that actually matter reproduce exactly: 25 rootless and 19 raw, with both splits confirmed. Two further corrections: of the '3 genuine' raw interpolations outside these tools, TWO INTERPOLATE THE VERB, NOT AN ARGUMENT, so the genuine count is 1; and the backtick residual TASK-234 sized at two is EIGHT, of which only the message_for pair is pinned — two of the unpinned six are 'perry-tasks render --write' and 'write --from-board', the ones that WRITE. Whoever takes this row should re-derive the census a third time rather than inherit any of these numbers: it has now been measured three times and been wrong twice.", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-234"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-30T14:20:46+08:00", "order": 43}
+{"id": "TASK-256", "title": "The mutation harness's md5 restore-check is circular: it compares the file to bytes it just wrote", "summary": "Filed 2026-08-30 from the TASK-249 round-4 result. The agent found it in its own instrument, reported it rather than quietly re-running, rebuilt the copy and re-ran the mutations. That is the right handling, and it is also why the defect is worth a row: the harness pattern is prescribed by the PMO in every brief, so this is a defect in the project's verification discipline and not in one agent's script.", "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-30T14:48:15+08:00", "order": 44}
+{"id": "TASK-257", "title": "The ignored-name bullet pin asserts a substring, not a bullet, and one satisfying string blinds the guard to BOARD.md", "summary": "Filed 2026-08-30 from the TASK-249 round-4 confirmation, which PASSED the row and merged it — these three are the non-blocking remainder. The '.md' case is the reason this is P1 rather than a tidy-up: a suffix that satisfies the pin makes the guard blind to the project's most-written file. The row can also strike its own 'the skip path is reasoned, not exercised' caveat: the reviewer exercised it on a case-sensitive APFS image, where the case-flipped spelling is correctly refused.", "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-30T15:17:06+08:00", "order": 45}
+{"id": "TASK-258", "title": "tests/test_tree_guard.py copies the LIVE repository, so any concurrent write reddens it", "summary": "Filed 2026-08-30. This is not a defect the TASK-249 rounds could have caught: all four rounds ran in private worktrees where nothing else was writing, which is exactly why a test that depends on the tree being still passed four reviews. It surfaced the first time the module ran in the live repository. The PMO caused the write; the fragility is the test's.", "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-30T15:55:00+08:00", "order": 46}
 {"id": "TASK-249", "title": "bash tests/run WRITES PERRY STATE INTO THE REPOSITORY IT RUNS IN — four files, via an intake-sweep discharging a real board row", "summary": "ROUND 4 FIXES IN at f8bc100, five commits. BLOCKER CLOSED and pinned: the bullet is in the 'What it does NOT catch' list carrying scope re-derived with controls — a directory appearing mid-run is invisible INCLUDING ITS OWN CREATION; with .claude/ already present at snapshot it is not in the manifest at all, so rewriting a file in it and creating another gives compare() == []; and the match is on the NAME AT ANY DEPTH, so perry/evidence/.claude/ and perry/.gstack/ are invisible while the same writes into .claudex/ are reported. The pin is the good part: test_every_ignored_name_is_a_bullet_in_the_list_of_what_is_missed is red when the bullet is deleted AND red when a fifth ignored directory is added WITH THE EQUALITY PIN MOVED WITH IT — which the equality pin alone would not catch. THE FIVE ITEMS: (1) it reproduced all three green mutations plus both bullet rewrites ON THE UNFIXED TIP FIRST, confirming the pin was green in all five, then took BOTH halves — the claim narrowed (class renamed to say it checks the bullet's VOCABULARY against the token spelled in tests/run, docstring states the measured gap) and the pin widened to catch both export spellings, with the refuse token anchored to a non-comment line. The dead-refusal-under-'if false' case stays uncatchable by string search and is recorded as such rather than papered over. (2) IndexError replaced by two FAILs with sentences, verified with both banners reworded. (3) case-differing spellings now accepted, comparison is 'test -ef' on device+inode. (4) relative paths REFUSED with their own banner and reason — decided, not incidental; the 17-spelling sweep re-run shows four changed, all in the intended direction, nothing became accept-everything. (5) 24 and 18 removed, grep returns nothing. TWENTY mutations, four green, all reported. MC1 is the most useful: reverting -ef to round 2's string comparison kills exactly one test and it is the new one — round 3's blindness finding, one layer out. THREE OF ITS OWN FIXES WERE GREEN UNDER THEIR FIRST MUTATION and were tightened: the relative-refusal assertion matched the word 'relative' in an explanatory paragraph rather than in the banner, setUp's terminator, and the doc pin would have passed on three empty sets. Five suites measured, 4/3 on every one including both board states of a moving main; the final tip differs from the probed merge only by the result document, and re-merging is clean.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "perry/evidence/2026-08/TASK-249-round4-v4-review.md", "next_action": "Startable. Start from TASK-050 round 11's result, which carries the controlled experiment, and from the TASK-241 merge, where the stray event surfaced. The idempotence is the reason this survived: the first run in a fresh clone moves four files, and every run after it looks clean, so the natural way to check — run it twice and diff — reports nothing. Restore the four files first, then run once.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-30T05:52:25+08:00", "order": null}
 {"id": "TASK-234", "title": ".perry/conformance.md is a pure ledger with a hand-rolled table parser, and its phantom row has nowhere to record provenance", "summary": "ROUND 5 REVIEW: PASS, merged. The reviewer went looking for the THIRD layer — rounds 3 and 4 having failed on the same sentence one register deeper each time — using eight distinct refusal surfaces driven on planted hostile-root projects, every command extracted by the SHIPPED extractor and pasted into a real /bin/sh. It is not there: all parse, all carry the exact typed root, including the full round trip, a state file named 'My Notes & draft.md', a RELATIVE --root, and perry-migrate's actual restore putting two files back. Newline, which the row called unmeasured, is milder than claimed — _q quotes it correctly and the two-line block pastes and runs rc 0. TWO MUTATIONS TURNED THE ROW'S ARGUMENT INTO A MEASUREMENT: R5-16, a friendly fixture root PLUS round 4's defect put back, drops 24 red methods to 2, and both survivors are the source rule and the backtick test — so 'a choke point is a convention' is now an experiment rather than an argument. R5-15, _q double-quoting with escapes, leaves shlex.split reading the right root so all 16 helper invocations and the source guard stay GREEN while /bin/sh expands  and the end-to-end proof goes red — that is exactly the shell-layer-only mutation the RESULT said it could not construct, which makes the /bin/sh paste load-bearing rather than decorative. 57/57 of the row's own harness reproduced independently plus 16 of the reviewer's, restored from git show and never from its own bytes. ONE SURVIVOR: R5-11, the sweep's phrase boundary (TAIL excluding the backtick) has no positive control. Suite main 105/3148/4, tip 103/3150/4, probe 105/3200/4, ZERO errors throughout, test_host_support absent from all three.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "perry/evidence/2026-08/TASK-234-round5-v4-review.md", "next_action": "Blocked until TASK-050 lands: converting this reader removes one of the markdown tables TASK-050's header_index() has to cover, so doing it first means TASK-050 converts a site that is about to be deleted. TWO THINGS TO SETTLE BEFORE WRITING CODE, both real. (1) BOOTSTRAP ORDER: this file gates every write under ADR-004's enforce gate, including the write that migrates it — the migration path must not require the gate to be passable mid-migration. (2) SELF-REFERENCE: schema/state-schema.json:2053 already states, deliberately, that .perry/conformance.md is NOT a files[] entry because 'it is a record of the user's decisions ABOUT state, not state, and listing it here would make it declarable-conformant about itself'. That reasoning carries over to the jsonl unchanged and must be moved across EXPLICITLY, not dropped in the format change. (3) NOTE FOR THE GOALS LANE, not this row's to write: P003-O1-KR1, KR2 and KR3 are all phrased 'of 6' over the six stores in claims[]. A seventh claimed store moves that denominator. Whether conformance.jsonl joins claims[] at all is the same question as (2).", "depends_on": ["TASK-050"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-29T13:43:22+08:00", "order": null}
-{"id": "TASK-259", "title": "Nothing asserts the TASK-234 fixture root is shell-hostile, and 8 of 19 bypass spellings get past the source rule", "summary": "Filed 2026-08-30 from the TASK-234 round-5 review. Item (b) is the interesting one: the row's defence is a choke point PLUS a source rule, and the source rule is the half that makes the choke point more than a convention — so its recall is the property the whole shape rests on. It is 11 of 19 today.", "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-30T16:44:44+08:00", "order": 48}
+{"id": "TASK-259", "title": "Nothing asserts the TASK-234 fixture root is shell-hostile, and 8 of 19 bypass spellings get past the source rule", "summary": "Filed 2026-08-30 from the TASK-234 round-5 review. Item (b) is the interesting one: the row's defence is a choke point PLUS a source rule, and the source rule is the half that makes the choke point more than a convention — so its recall is the property the whole shape rests on. It is 11 of 19 today.", "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-30T16:44:44+08:00", "order": 47}
 {"id": "TASK-260", "title": "V4 criteria must be bounded, and the round stops auditing its own exhibit", "summary": "TASK-050 ran 11 rounds against a universal negative and PASSed on the round the criterion became decidable. Measured: 22 of 49 finding headlines audit the round's own artifact, not the product.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "a4eb411; evidence/2026-08/2026-08-31-representation-layer-delete-list.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-31T20:27:36+08:00", "order": null}
-{"id": "TASK-261", "title": "Tier A — the ADR-004 declaration gate comes out; perry-conform keeps only its shared helpers", "summary": "23 records, all route: declare, all Perry's own files, zero migrations and zero disagreements. The gate's value needs a foreign project that drifts, and Perry has never been run on one. The delete list said 'delete bin/perry-conform, 974 lines'; that was wrong — 598 lines are the dead ledger and ~280 are helpers four tools depend on, so the file is gutted, not removed.", "owner": "Coding Agent", "status": "blocked", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "blocked on migration fork: perry-migrate's output is the deleted ledger", "depends_on": ["USER-910"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-31T20:27:48+08:00", "order": 49}
+{"id": "TASK-261", "title": "Tier A — the ADR-004 declaration gate comes out; perry-conform keeps only its shared helpers", "summary": "23 records, all route: declare, all Perry's own files, zero migrations and zero disagreements. The gate's value needs a foreign project that drifts, and Perry has never been run on one. The delete list said 'delete bin/perry-conform, 974 lines'; that was wrong — 598 lines are the dead ledger and ~280 are helpers four tools depend on, so the file is gutted, not removed.", "owner": "Coding Agent", "status": "blocked", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "blocked on migration fork: perry-migrate's output is the deleted ledger", "depends_on": ["USER-910"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-31T20:27:48+08:00", "order": 48}
+{"id": "TASK-097", "title": "Migrate the two real projects to the store, at V5", "owner": "Coding Agent", "status": "dropped", "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": null, "summary": ""}
+{"id": "TASK-223", "title": "the conformance gate cannot tell a file Perry generated from one it found, so authored files need a hand declare", "summary": "7 authored files sat undeclared for 8 days and it blocked perry-goals link --project on 2026-08-28. perry-migrate already records route: migrate; there is no route: authored.", "owner": "Coding Agent", "status": "dropped", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-28T19:11:27+08:00", "order": null}
+{"id": "TASK-246", "title": "an unreadable row in .perry/conformance.md is now DELETED by the next declare, not laundered", "summary": "Reported by TASK-241's author against its own change, 2026-08-30, and not filed by it because the PMO owns the board. bin/perry-conform:423 render rewrites the whole file from the parsed declarations. Before TASK-241 a decorated row parsed to a plain key, so the next declare LAUNDERED it into a canonical row — that was the defect TASK-241 closes. After TASK-241 the row is unreadable instead, so the next declare simply does not carry it forward and it is GONE from the file. The author calls that fail-closed and better than laundering, and says the change ENLARGES a pre-existing case: the same already happened for an unreadable version cell. It is better than the alternative and it is still a write that destroys a line the user typed, with no report at the moment of destruction.", "owner": "Coding Agent", "status": "dropped", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked until TASK-241 lands. Start from evidence/2026-08/TASK-241-result.md, where the author states this against its own change rather than leaving it to a reviewer — that is the reason to trust the framing. Note the pre-existing half: an unreadable VERSION cell already behaved this way before TASK-241, so this is not a regression the row introduced, only one it made reachable more often.", "depends_on": ["TASK-241"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-30T03:27:22+08:00", "order": null}
+{"id": "TASK-248", "title": "a canonical row inside 
, an HTML comment, or 
still declares a file conformant, and is still laundered", "summary": "Found by the TASK-241 round 2 V4 reviewer, 2026-08-30, and ruled non-blocking for that row. A bare canonical row placed inside an HTML block —
, or an HTML comment — reads as a real declaration: conformant with 0 unreadable, identically at the fork point, at round 1 and at round 2. TASK-241 closes the three markdown decoration traps the spec named (backticked, indented, fenced, including four nestings) and this is outside all of them: it is invisible to the round-trip property BY CONSTRUCTION, because the row inside the HTML is byte-for-byte a genuine row, exactly as a fenced row is. It is not a regression — nothing TASK-241 did made it reachable — and TASK-234's conversion of the record to .perry/conformance.jsonl dissolves it entirely. It is filed because the file gates every write under ADR-004's enforce gate and because TASK-241's section 9 mentions HTML blocks only in the fence-line direction, which reads as coverage; that wording is being corrected in the same round.", "owner": "Coding Agent", "status": "dropped", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked until TASK-241 lands. Start from evidence/2026-08/TASK-241-round2-v4-review.md, which carries the measurement at all three trees. Read TASK-246 beside this one — same file, same class of question about what the reader should do with a row it will not honour — and consider whether the two want one answer rather than two. Note the reviewer's framing: this is invisible to the round-trip property BY CONSTRUCTION, for the same reason a fenced row is, so the answer is structural rather than another predicate.", "depends_on": ["TASK-241"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-30T05:19:33+08:00", "order": null}
diff --git a/reference/glossary.md b/reference/glossary.md
index 7c4b7399..6acb9ec7 100644
--- a/reference/glossary.md
+++ b/reference/glossary.md
@@ -145,11 +145,6 @@ A named stream of work inside one project, declared in `.perry/config.md §
 Tracks`. A blank `Track` cell means the implicit `main` track.
 Implemented: schema/state-schema.json
 
-### restore point
-The copy `perry-migrate` takes before it writes, so a migration can be undone
-as a unit.
-Implemented: bin/perry-migrate
-
 ### knowledge card
 A reusable claim about how to do something correctly, with mandatory
 provenance. Distinguished from a digest by its `Kind:`.
diff --git a/tests/test_header_index_is_the_only_fold.py b/tests/test_header_index_is_the_only_fold.py
index bb58375e..763796f9 100644
--- a/tests/test_header_index_is_the_only_fold.py
+++ b/tests/test_header_index_is_the_only_fold.py
@@ -101,7 +101,6 @@
     "replace_row",             # bin/perry-task
     "canonical_of",            # bin/perry-goals
     "markdown_tables",         # bin/perry_store.py
-    "fix_tables",              # bin/perry-migrate
     "cmd_intake_write",        # bin/perry-tasks
 ]
 
@@ -448,8 +447,6 @@ def parse_everything(self):
         perry_store.markdown_tables(OKR_TABLE, 0, len(OKR_TABLE), lambda s: s)
         import perry_md_store                    # noqa: E402
         perry_md_store.scan_okr(OKR)
-        load("perry-migrate").fix_tables(
-            MIGRATE_LINES, MIGRATE_SPEC, {}, [], [], root_arg=None)
         self.drive_intake_write()
         self.drive_the_carried_row_readers()
 
diff --git a/tests/test_migrate.py b/tests/test_migrate.py
deleted file mode 100644
index 2d217d8a..00000000
--- a/tests/test_migrate.py
+++ /dev/null
@@ -1,2899 +0,0 @@
-"""TASK-044: migration is dry-runnable, lossless, recoverable, and declared.
-
-The claim under test: **a project can be brought to Perry's shape by a program
-whose preview cannot diverge from what it does, which refuses rather than lose
-a character of somebody's writing, which can be undone, and which never runs
-unasked.**
-
-Three of ADR-004's five guarantees are assertions, and an assertion an agent
-performs by reading is not one. So the shape of this suite is deliberate:
-
-- the losslessness tests run against a board that Perry did NOT write. A board
-  Perry generated is already Perry-shaped and proves nothing; `LEGACY_BOARD`
-  below is modelled on `~/proj/gimegime-pmo` — work filed under headings its
-  author chose, a four-column priority table, a status word Perry has no value
-  for, and prose in a cell the schema has nowhere to put.
-- `TestDryRunIsTheRealRun` compares bytes, not intentions.
-- `TestRecoverable` exercises the recovery path rather than describing it.
-
-Run: python3 -m unittest discover -s tests   (or ./tests/run)
-"""
-
-from __future__ import annotations
-
-import hashlib
-import contextlib
-import importlib.machinery
-import inspect
-import importlib.util
-import json
-import os
-import re
-import subprocess
-import sys
-import tempfile
-import unittest
-from pathlib import Path
-
-PERRY_HOME = Path(__file__).resolve().parent.parent
-MIGRATE = PERRY_HOME / "bin" / "perry-migrate"
-CONFORM = PERRY_HOME / "bin" / "perry-conform"
-LINT = PERRY_HOME / "bin" / "perry-lint"
-TASK = PERRY_HOME / "bin" / "perry-task"
-TASKS = PERRY_HOME / "bin" / "perry-tasks"
-
-SCHEMA = json.loads((PERRY_HOME / "schema" / "state-schema.json").read_text())
-
-
-def load(name: str, path: Path):
-    spec = importlib.util.spec_from_loader(
-        name, importlib.machinery.SourceFileLoader(name, str(path)))
-    mod = importlib.util.module_from_spec(spec)
-    sys.modules[name] = mod
-    spec.loader.exec_module(mod)
-    return mod
-
-
-M = load("perry_migrate_under_test", MIGRATE)
-
-
-# ── fixtures ──────────────────────────────────────────────────────────────
-
-#: Not a board Perry wrote. Modelled on the year-old real one this task was
-#: built against: 41 tasks under `## Open — `, one hand-kept `## P2`
-#: with four columns, a status word its author invented, and free prose in
-#: cells the schema does not model.
-LEGACY_BOARD = """# Board — Legacy
-
-> Last updated: 2026-01-04
-
-## ID prefixes (canonical)
-
-`INV-*` investments · `ENG-*` engineering.
-
-## Open — investment line (policy · allocation)
-
-| ID | Title | Owner | Status | Next action |
-|---|---|---|---|---|
-| INV-DRAFT-1 | policy draft, **blocked on the RM reply** (see `policy/INDEX.md`) | User | not_started | chase the RM |
-| INV-ALLOC-2 | rebalance band ~200bp, defensive not tactical | User | in_progress | wait for Q3 |
-
-## Open — engineering line · phase #004
-
-| ID | Title | Owner | Status | Next action |
-|---|---|---|---|---|
-| ENG-7 | fd leak in the scheduler; 17 jobs re-registered | Coding Agent | done | — |
-
-## P2 (low priority carry)
-
-| ID | Title | Owner | Status |
-|---|---|---|---|
-| ENG-9 | conftest has no DB isolation | Coding Agent | not_started |
-
-## Cadence
-
-| ID | Recurring task | Owner | Frequency | Next due |
-|---|---|---|---|---|
-| CAD-1 | weekly reconcile | User | weekly | 2026-01-11 |
-
-## User Input Queue
-
-| USER-id | Needed from user | Blocks | Status |
-|---|---|---|---|
-| USER-3 | pick a broker | INV-DRAFT-1 | open |
-
-## Top risks
-
-- the RM has not replied since November
-"""
-
-#: The same board with a status word Perry has no value for. This is the real
-#: one: `半解` ("half-solved") on gimegime-pmo's `## P2`.
-UNRESOLVABLE_BOARD = LEGACY_BOARD.replace(
-    "| ENG-9 | conftest has no DB isolation | Coding Agent | not_started |",
-    "| ENG-9 | conftest has no DB isolation | Coding Agent | half-done |")
-
-LEGACY_DESIGN = """# DESIGN-001: the thing
-
-> **Status**: v1.1 LOCKED 2026-05-19 PM BJT** (Amendments A+B applied; v1.0 LOCKED 2026-05-18)
-> **Owner**: User
-> **Date**: 2026-05-18
-
-## 1. Problem
-
-It is broken.
-
-## 2. Goals
-
-Fix it.
-
-## 3. Non-Goals
-
-Everything else.
-
-## 4. User Decisions
-
-D-1: go.
-
-## 5. Architecture
-
-A box.
-
-## 6. Implementation plan
-
-Do the thing.
-
-## 7. Risks & mitigations
-
-None.
-"""
-
-CONFIG_EN = ("# Perry configuration\n\n- Document language: English\n"
-             "- Repo layout: single\n- State root: .\n")
-CONFIG_ZH = ("# Perry configuration\n\n- Document language: 中文\n"
-             "- Repo layout: single\n- State root: .\n")
-
-HOOK = "# Hook\n\n## High-stakes operations\n\n- anything that spends money\n"
-
-
-#: **The extractor, the assertion and the hostile fixture root all come
-#: from `tests/handed_back.py`**, which `tests/test_conformance.py` also
-#: uses. This module held its own hand-written spelling of the rule —
-#: `assertIn(f"perry-migrate restore {run_id} --root {p.root}")` — and a
-#: substring assertion cannot tell a runnable command from an unrunnable
-#: one, which is what the round-4 V4 FAIL was.
-_HB = load("perry_handed_back", PERRY_HOME / "tests" / "handed_back.py")
-commands_named = _HB.commands_named
-assert_every_command_carries = _HB.assert_every_command_carries
-HOSTILE_ROOT_NAME = _HB.HOSTILE_ROOT_NAME
-
-
-class Project:
-    """A throwaway project holding whatever files a test needs."""
-
-    def __init__(self, files: dict[str, str] | None = None,
-                 config: str = CONFIG_EN):
-        self.dir = tempfile.TemporaryDirectory()
-        self.root = Path(self.dir.name) / HOSTILE_ROOT_NAME
-        self.root.mkdir()
-        (self.root / ".perry").mkdir()
-        (self.root / ".perry" / "config.md").write_text(config)
-        (self.root / ".perry" / "hook.md").write_text(HOOK)
-        for rel, text in (files or {"BOARD.md": LEGACY_BOARD}).items():
-            p = self.root / rel
-            p.parent.mkdir(parents=True, exist_ok=True)
-            p.write_text(text)
-
-    def run(self, *argv, tool: Path = MIGRATE, json_out: bool = True):
-        argv = [*argv, "--root", str(self.root)]
-        if json_out:
-            argv.append("--json")
-        r = subprocess.run(["python3", str(tool), *argv],
-                           capture_output=True, text=True, env=dict(os.environ))
-        try:
-            return r.returncode, json.loads(r.stdout or "{}"), r.stderr
-        except json.JSONDecodeError:
-            return r.returncode, r.stdout, r.stderr
-
-    def text(self, rel: str) -> str:
-        return (self.root / rel).read_text()
-
-    def tree(self) -> dict[str, str]:
-        return {str(f.relative_to(self.root)):
-                hashlib.sha256(f.read_bytes()).hexdigest()
-                for f in sorted(self.root.rglob("*")) if f.is_file()}
-
-    def lint_errors(self) -> int:
-        r = subprocess.run(["python3", str(LINT), "--root", str(self.root),
-                            "--json"], capture_output=True, text=True)
-        return json.loads(r.stdout)["errors"]
-
-    def plan(self):
-        # `root_arg` is the root the CLI would have been given — the same
-        # string `Project.run` puts on the command line. An in-process plan
-        # built with `root_arg=None` is a plan no reader ever has, and every
-        # assertion about a handed-back command made from inside one is an
-        # assertion about a situation that does not occur (TASK-234 r5).
-        return M.plan_project(self.root, self.root, SCHEMA,
-                              root_arg=str(self.root))
-
-    def __del__(self):
-        self.dir.cleanup()
-
-
-def edit_for(plan, key: str):
-    return next((e for e in plan.edits if e.key == key), None)
-
-
-def spec_for(path: str) -> dict:
-    """The schema's entry for a state file, by its declared path."""
-    return next(f for f in SCHEMA["files"] if f["path"] == path)
-
-
-BOARD_SPEC = spec_for("BOARD.md")
-DESIGN_SPEC = spec_for("design/*.md")
-
-
-# ── 1 · dry run first, always ─────────────────────────────────────────────
-
-
-class TestDryRunIsTheRealRun(unittest.TestCase):
-
-    def test_the_default_subcommand_writes_nothing(self):
-        """Asserted on bytes, not by reading the code — every file in the
-        project, hashed before and after."""
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        before = p.tree()
-        rc, out, _ = p.run()
-        self.assertEqual(p.tree(), before)
-        self.assertEqual(out["mode"], "dry-run")
-
-    def test_the_dry_run_prints_the_complete_diff_not_a_summary(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        rc, out, _ = p.run(json_out=False)
-        self.assertIn("--- a/BOARD.md", out)
-        self.assertIn("+++ b/BOARD.md", out)
-        self.assertIn("## P0", out)
-        self.assertIn("+| ID | Title | Owner | Status | Next action | Evidence |",
-                      out.replace(" ", " "))
-
-    def test_the_bytes_the_dry_run_showed_are_the_bytes_apply_writes(self):
-        """The guarantee: a preview that can diverge is worse than none. It
-        cannot diverge here because the plan carries the post-image and `apply`
-        writes exactly that — this asserts the property, not the mechanism."""
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        _, dry, _ = p.run()
-        _, real, _ = p.run("apply")
-        for f in dry["files"]:
-            if not f["writable"]:
-                continue
-            on_disk = hashlib.sha256(p.text(f["path"]).encode()).hexdigest()
-            self.assertEqual(f["after_sha256"], on_disk,
-                             f"{f['path']} on disk is not what the dry run showed")
-
-    def test_the_dry_run_and_the_real_run_report_the_same_files_and_changes(self):
-        p = Project({"BOARD.md": LEGACY_BOARD, "design/DESIGN-001-x.md": LEGACY_DESIGN})
-        _, dry, _ = p.run()
-        _, real, _ = p.run("apply")
-        strip = lambda d: [(f["path"], f["before_sha256"], f["after_sha256"],
-                            [c["kind"] for c in f["changes"]]) for f in d["files"]]
-        self.assertEqual(strip(dry), strip(real))
-
-    def test_planning_twice_produces_the_same_plan(self):
-        """A plan that is not a pure function of the project's bytes cannot be
-        previewed. Nothing here may depend on the clock: a dry run read on
-        Monday and applied on Tuesday has to be the same edit."""
-        p = Project({"BOARD.md": LEGACY_BOARD,
-                     "knowledge/a/note.md": "# note\n\n> Source: a paste\n"})
-        a = [e.after for e in p.plan().edits]
-        b = [e.after for e in p.plan().edits]
-        self.assertEqual(a, b)
-        inserted = "\n".join(l for e in p.plan().edits
-                             for l in e.after.split("\n")
-                             if l not in e.before.split("\n"))
-        self.assertNotRegex(inserted, r"\d{4}-\d{2}-\d{2}",
-                            "migration writes no date: a plan read on Monday "
-                            "and applied on Tuesday must be the same edit")
-
-    def test_the_cross_file_consequence_is_reported_in_the_dry_run_too(self):
-        """Normalizing a design doc's Status makes `locked-design-has-plan`
-        readable for the first time. The dry run says so; a preview that hides
-        a consequence only `apply` would reveal is the divergence § 1 forbids."""
-        doc = LEGACY_DESIGN.replace("## 6. Implementation plan\n\nDo the thing.\n",
-                                    "## 6. Implementation plan\n\n")
-        doc = doc.replace("> **Status**: v1.1 LOCKED 2026-05-19 PM BJT**",
-                          "> **Status**: Design locked(2026-06-03;D1**")
-        p = Project({"BOARD.md": LEGACY_BOARD, "design/DESIGN-002-y.md": doc})
-        _, dry, _ = p.run()
-        rules = [f["rule"] for f in dry["newly_visible"]]
-        self.assertIn("locked-design-has-plan", rules)
-
-
-# ── 2 · nothing is lost ───────────────────────────────────────────────────
-
-
-class TestNothingIsLost(unittest.TestCase):
-
-    def test_every_id_present_before_is_present_after(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        before = M.ids(p.text("BOARD.md"))
-        p.run("apply")
-        after = M.ids(p.text("BOARD.md"))
-        self.assertTrue(before, "the fixture must actually carry ids")
-        self.assertEqual(before - after, set())
-
-    def test_no_character_the_author_wrote_is_dropped(self):
-        """Character granularity, not word: `> **状态**:进行中` is a single
-        whitespace-delimited token, so a word-level check both misses damage
-        inside it and reports the whole line as lost when its value is
-        normalized."""
-        p = Project({"BOARD.md": LEGACY_BOARD, "design/DESIGN-001-x.md": LEGACY_DESIGN})
-        before = {k: p.text(k) for k in ("BOARD.md", "design/DESIGN-001-x.md")}
-        p.run("apply")
-        for k, was in before.items():
-            missing = M.characters(was) - M.characters(p.text(k))
-            self.assertEqual(missing, {}.__class__() if False else missing.__class__(),
-                             f"{k} lost characters: {missing}")
-
-    def test_free_prose_in_a_cell_the_schema_does_not_model_is_carried(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        p.run("apply")
-        after = p.text("BOARD.md")
-        self.assertIn("policy draft, **blocked on the RM reply** "
-                      "(see `policy/INDEX.md`)", after)
-        self.assertIn("rebalance band ~200bp, defensive not tactical", after)
-
-    def test_the_value_an_enum_field_had_is_kept_verbatim_beside_the_canonical_one(self):
-        p = Project({"BOARD.md": LEGACY_BOARD, "design/DESIGN-001-x.md": LEGACY_DESIGN})
-        p.run("apply")
-        line = next(l for l in p.text("design/DESIGN-001-x.md").split("\n")
-                    if "Status" in l)
-        self.assertTrue(line.split("|")[0].strip().endswith("locked"), line)
-        self.assertIn("v1.1 LOCKED 2026-05-19 PM BJT", line)
-
-    def test_row_counts_per_section_are_preserved(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        before = M.rows_by_section(p.text("BOARD.md").split("\n"))
-        p.run("apply")
-        after = M.rows_by_section(p.text("BOARD.md").split("\n"))
-        for section, n in before.items():
-            self.assertEqual(after.get(section), n, section)
-
-    def test_a_transform_that_loses_something_is_refused_rather_than_written(self):
-        """The assertion is the tool's, not the reader's. Injecting a lossy
-        transform must stop the write, not produce a report nobody checks."""
-        text = LEGACY_BOARD
-        before = text
-        after = text.replace("| CAD-1 | weekly reconcile | User | weekly | 2026-01-11 |",
-                             "| CAD-1 | weekly reconcile | User | weekly |  |")
-        bad = M.losslessness(before, after, [])
-        self.assertTrue(bad, "dropping a cell must be caught")
-        self.assertTrue(any("character" in b or "cell" in b for b in bad), bad)
-
-    def test_a_dropped_row_is_caught_even_when_every_character_survives(self):
-        """Row counts are a separate check because a row moved out of its
-        section keeps every character it had."""
-        before = LEGACY_BOARD
-        after = before.replace(
-            "| ENG-9 | conftest has no DB isolation | Coding Agent | not_started |\n", "")
-        after += "\n| ENG-9 | conftest has no DB isolation | Coding Agent | not_started |\n"
-        bad = M.losslessness(before, after, [])
-        self.assertTrue(any("row(s)" in b for b in bad), bad)
-
-    def test_a_character_can_be_lost_with_no_cell_or_row_going_missing(self):
-        """Why the character check is separate. Prose outside a table is
-        invisible to the cell check, and `~200bp` losing its tilde is a change
-        to what the sentence says."""
-        before = "a note about ~200bp, defensive"
-        after = "a note about 200bp, defensive"
-        bad = M.losslessness(before, after, [before])
-        self.assertEqual([b for b in bad if "character" not in b], [],
-                         "no other check may fire on this input")
-        self.assertTrue(any("character" in b for b in bad), bad)
-
-    def test_an_id_can_be_lost_while_every_character_survives(self):
-        """Why the id set is checked on its own. Characters are whitespace-
-        blind, so a handle can be broken without a single one going missing —
-        and an id is what attribution and every citation resolve on."""
-        bad = M.losslessness("ADR-005 x", "ADR-005x", ["ADR-005 x"])
-        self.assertEqual([b for b in bad if "id(s)" not in b], [],
-                         "no other check may fire on this input")
-        self.assertTrue(any("id(s)" in b for b in bad), bad)
-
-    def test_a_write_that_does_not_match_the_plan_rolls_the_whole_run_back(self):
-        """The tool checks its own writing. If the bytes on disk are not the
-        bytes the preview showed, the run is undone rather than left half
-        applied."""
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        before = p.tree()
-        plan = p.plan()
-        real = M.write_atomic
-        calls = []
-
-        def corrupt_the_first_write(path, text):
-            calls.append(path)
-            suffix = b"\n" if isinstance(text, bytes) else "\n"
-            return real(path, text + suffix if len(calls) == 1 else text)
-
-        M.write_atomic = corrupt_the_first_write
-        try:
-            with self.assertRaises(M.Refused):
-                M.apply_plan(plan, SCHEMA)
-        finally:
-            M.write_atomic = real
-        for path, digest in before.items():
-            self.assertEqual(p.tree().get(path), digest, path)
-
-    def test_automatic_rollback_does_not_overwrite_a_later_concurrent_edit(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        plan = p.plan()
-        board = p.root / "BOARD.md"
-        real_write = M.write_atomic
-        real_undo = M.undo
-        writes = []
-
-        def corrupt_the_first_write(path, text):
-            writes.append(path)
-            suffix = b"\n" if isinstance(text, bytes) else "\n"
-            real_write(path, text + suffix if len(writes) == 1 else text)
-
-        def edit_between_detection_and_rollback(point, **kwargs):
-            board.write_bytes(board.read_bytes() + b"CONCURRENT EDIT\n")
-            return real_undo(point, **kwargs)
-
-        M.write_atomic = corrupt_the_first_write
-        M.undo = edit_between_detection_and_rollback
-        try:
-            with self.assertRaises(M.Refused) as caught:
-                M.apply_plan(plan, SCHEMA)
-        finally:
-            M.write_atomic = real_write
-            M.undo = real_undo
-
-        self.assertIn("automatic rollback also failed", str(caught.exception))
-        self.assertTrue(board.read_bytes().endswith(b"CONCURRENT EDIT\n"))
-
-    def test_post_write_concurrent_edit_is_not_treated_as_the_tools_image(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        plan = p.plan()
-        board = p.root / "BOARD.md"
-        real = M.write_atomic
-        writes = {"n": 0}
-
-        def edit_before_verification(path, text):
-            published = real(path, text)
-            writes["n"] += 1
-            if writes["n"] == 1:
-                path.write_bytes(path.read_bytes() + b"CONCURRENT EDIT\n")
-            return published
-
-        M.write_atomic = edit_before_verification
-        try:
-            with self.assertRaises(M.Refused) as caught:
-                M.apply_plan(plan, SCHEMA)
-        finally:
-            M.write_atomic = real
-
-        self.assertIn("automatic rollback also failed", str(caught.exception))
-        self.assertTrue(board.read_bytes().endswith(b"CONCURRENT EDIT\n"))
-
-    def test_a_line_edited_without_being_recorded_is_caught(self):
-        """`rewritten` is the tool's own claim about what it changed. A
-        transform that edits a line it did not declare fails here — the same
-        discipline the mutation table is scored on."""
-        before = "> **Owner**: User\n\n## P0\n"
-        after = "> **Owner**: Someone Else User\n\n## P0\n"
-        self.assertTrue(any("not recorded as rewritten" in b
-                            for b in M.losslessness(before, after, [])))
-        self.assertEqual(M.losslessness(before, after, ["> **Owner**: User"]), [])
-
-
-# ── 3 · recoverable ───────────────────────────────────────────────────────
-
-
-class TestRecoverable(unittest.TestCase):
-
-    def test_a_run_writes_a_restore_point_and_names_it(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        rc, out, _ = p.run("apply", json_out=False)
-        self.assertIn(".perry/migrate/", out)
-        self.assertIn("perry-migrate restore", out)
-        points = list((p.root / ".perry" / "migrate").glob("*.json"))
-        self.assertEqual(len(points), 1)
-
-    def test_every_way_back_this_tool_names_carries_the_root(self):
-        """**A command handed back names the root the reader used, or it
-        addresses a different project** (TASK-234 round 4).
-
-        The V4 round-3 reviewer measured this on `perry-conform migrate` and
-        listed `perry-migrate restore` as the thing it had NOT checked. It had
-        the same omission on both surfaces that name it: the line under a
-        successful `apply`, and the restore-point listing. Neither errors
-        without the flag — `restore` run from another Perry project looks for a
-        run id under THAT project's `.perry/migrate/` — so the failure is a
-        refusal about the wrong tree, or a rollback of it.
-        """
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        rc, applied, _ = p.run("apply", json_out=False)
-        run_id = list((p.root / ".perry" / "migrate").glob("*.json"))[0].stem
-        # Both surfaces are asserted by PARSING what was printed. The two
-        # `assertIn(f"… --root {p.root}")` calls that stood here could not
-        # distinguish a runnable command from `--root /home/ada/My Project`.
-        under_run = next(l for l in applied.split("\n") if "undo with:" in l)
-        assert_every_command_carries(
-            self, under_run, p.root, "the line under a finished run")
-        self.assertIn(
-            run_id, under_run,
-            f"the line under a finished run does not name THIS run: "
-            f"{under_run!r}")
-
-        rc, listing, _ = p.run("restore", "--list", json_out=False)
-        self.assertEqual(rc, 0, listing)
-        assert_every_command_carries(
-            self, listing, p.root, "the restore-point listing")
-
-    def test_restore_puts_every_byte_back(self):
-        """Exercised, not described."""
-        p = Project({"BOARD.md": LEGACY_BOARD, "design/DESIGN-001-x.md": LEGACY_DESIGN})
-        before = p.tree()
-        p.run("apply")
-        self.assertNotEqual(p.tree(), before)
-        rc, _, err = p.run("restore")
-        self.assertEqual(rc, 0, err)
-        after = p.tree()
-        for path, digest in before.items():
-            self.assertEqual(after.get(path), digest, path)
-
-    def test_restore_also_withdraws_the_declarations_the_run_wrote(self):
-        """A restore that put the files back and left the record standing would
-        claim conformance for files that no longer have it."""
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        p.run("apply")
-        self.assertTrue((p.root / ".perry" / "conformance.jsonl").exists())
-        p.run("restore")
-        self.assertFalse((p.root / ".perry" / "conformance.jsonl").exists(),
-                         "the record was created by the run and must go back "
-                          "to not existing")
-
-    def test_apply_and_restore_keep_board_and_store_in_the_same_restore_set(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        p.run("apply")
-        store = p.root / "tasks.jsonl"
-        self.assertTrue(store.exists())
-        diff = subprocess.run(
-            [sys.executable, str(TASKS), "diff", "--root", str(p.root)],
-            capture_output=True, text=True)
-        self.assertEqual(diff.returncode, 0, diff.stdout + diff.stderr)
-        self.assertTrue(json.loads(diff.stdout)["identical"])
-        p.run("restore")
-        self.assertFalse(store.exists(),
-                         "restore left a store for the pre-migration board")
-
-    def test_apply_recreates_a_missing_store_when_the_board_needs_no_edit(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        self.assertEqual(p.run("apply")[0], 0)
-        store = p.root / "tasks.jsonl"
-        self.assertTrue(store.exists())
-        store.unlink()
-        rc, out, err = p.run("apply")
-        self.assertEqual(rc, 0, (out, err))
-        self.assertTrue(store.exists(),
-                        "a structurally current board suppressed store creation")
-        diff = subprocess.run(
-            [sys.executable, str(TASKS), "diff", "--root", str(p.root)],
-            capture_output=True, text=True)
-        self.assertEqual(diff.returncode, 0, diff.stdout + diff.stderr)
-        self.assertTrue(json.loads(diff.stdout)["identical"])
-
-    def test_apply_refuses_a_malformed_or_drifted_store_before_board_changes(self):
-        for malformed in (True, False):
-            with self.subTest(malformed=malformed):
-                p = Project({"BOARD.md": LEGACY_BOARD})
-                store = p.root / "tasks.jsonl"
-                if malformed:
-                    store.write_text('{"id":"INV-DRAFT-1","order":true}\n')
-                else:
-                    made = subprocess.run(
-                        [sys.executable, str(TASKS), "write", "--from-board",
-                         "--root", str(p.root)], capture_output=True, text=True)
-                    self.assertEqual(made.returncode, 0, made.stderr)
-                    records = [json.loads(line) for line in store.read_text().splitlines()]
-                    records[0]["owner"] = "store-only edit"
-                    store.write_text("".join(json.dumps(r) + "\n" for r in records))
-                before = p.text("BOARD.md")
-                rc, out, err = p.run("apply")
-                self.assertEqual(rc, 1, (out, err))
-                self.assertEqual(p.text("BOARD.md"), before)
-                self.assertIn("tasks.jsonl", out.get("refused", ""))
-
-
-    def test_apply_plans_and_writes_while_the_project_lock_is_held(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        active = {"value": False}
-        real_lock, real_plan, real_apply = M.lib.project_lock, M.plan_project, M.apply_plan
-
-        @contextlib.contextmanager
-        def lock(*_args, **_kwargs):
-            active["value"] = True
-            try:
-                yield
-            finally:
-                active["value"] = False
-
-        def checked_plan(*args, **kwargs):
-            self.assertTrue(active["value"], "migration planning escaped the lock")
-            return real_plan(*args, **kwargs)
-
-        def checked_apply(*args, **kwargs):
-            self.assertTrue(active["value"], "migration writes escaped the lock")
-            return real_apply(*args, **kwargs)
-
-        M.lib.project_lock, M.plan_project, M.apply_plan = lock, checked_plan, checked_apply
-        try:
-            rc = M.main(["apply", "--root", str(p.root), "--json"])
-        finally:
-            M.lib.project_lock, M.plan_project, M.apply_plan = \
-                real_lock, real_plan, real_apply
-        self.assertEqual(rc, 0)
-
-    def test_a_dirty_git_tree_is_reported_and_not_refused(self):
-        """Refusing on a dirty tree answers the question only for projects
-        under git, and the project this was built against is a local-only repo
-        whose state files are routinely uncommitted."""
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        subprocess.run(["git", "init", "-q", str(p.root)], capture_output=True)
-        (p.root / "untracked.txt").write_text("x")
-        rc, out, _ = p.run(json_out=False)
-        self.assertIn("uncommitted", out)
-        rc, _, _ = p.run("apply")
-        self.assertIn("BOARD.md", [f for f in p.tree()])
-        self.assertTrue((p.root / ".perry" / "migrate").is_dir(),
-                        "a dirty tree must not stop the run — the restore "
-                        "point is what makes it recoverable")
-
-    def test_the_restore_point_is_invisible_to_the_namespace_check(self):
-        """`perry-lint --claims` globs `*.md`; a restore point that landed as
-        markdown under `.perry/` would report Perry colliding with Perry, which
-        is the defect TASK-043 shipped and fixed."""
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        p.run("apply")
-        r = subprocess.run(["python3", str(LINT), "--claims", "--root",
-                            str(p.root), "--json"], capture_output=True, text=True)
-        self.assertEqual(json.loads(r.stdout)["collisions"], 0)
-
-
-class TestFileImageFidelity(unittest.TestCase):
-    def test_crlf_apply_and_restore_preserve_the_exact_file_image(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        board = p.root / "BOARD.md"
-        before = LEGACY_BOARD.replace("\n", "\r\n").encode("utf-8")
-        board.write_bytes(before)
-
-        dry_before = board.read_bytes()
-        dry_rc, _, dry_err = p.run()
-        self.assertEqual(dry_rc, 0, dry_err)
-        self.assertEqual(board.read_bytes(), dry_before)
-
-        apply_rc, _, apply_err = p.run("apply")
-        self.assertEqual(apply_rc, 0, apply_err)
-        applied = board.read_bytes()
-        self.assertNotEqual(applied, before)
-        self.assertEqual(applied.count(b"\r\n"), applied.count(b"\n"))
-
-        restore_rc, _, restore_err = p.run("restore")
-        self.assertEqual(restore_rc, 0, restore_err)
-        self.assertEqual(board.read_bytes(), before)
-
-    def test_a_symlinked_state_file_is_refused_before_any_write(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        board = p.root / "BOARD.md"
-        target = p.root / "outside-board.md"
-        target.write_bytes(board.read_bytes())
-        before = target.read_bytes()
-        board.unlink()
-        board.symlink_to(target)
-
-        rc, out, err = p.run("apply")
-
-        self.assertEqual(rc, 1, (out, err))
-        self.assertIn("symlink", out["refused"].lower())
-        self.assertTrue(board.is_symlink())
-        self.assertEqual(target.read_bytes(), before)
-        self.assertFalse((p.root / "tasks.jsonl").exists())
-
-    def test_a_non_regular_state_path_is_a_refusal_not_a_traceback(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        board = p.root / "BOARD.md"
-        board.unlink()
-        board.mkdir()
-
-        rc, out, err = p.run("apply")
-
-        self.assertEqual(rc, 1, (out, err))
-        self.assertIn("not a regular file", out["refused"])
-        self.assertNotIn("Traceback", err)
-        self.assertTrue(board.is_dir())
-
-    def test_a_symlinked_derived_task_store_is_refused_before_any_write(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        board = p.root / "BOARD.md"
-        board_before = board.read_bytes()
-        target = p.root / "outside-tasks.jsonl"
-        target.write_text("", encoding="utf-8")
-        store = p.root / "tasks.jsonl"
-        store.symlink_to(target)
-
-        rc, out, err = p.run("apply")
-
-        self.assertEqual(rc, 1, (out, err))
-        self.assertIn("tasks.jsonl is a symlink", out["refused"])
-        self.assertEqual(board.read_bytes(), board_before)
-        self.assertTrue(store.is_symlink())
-        self.assertEqual(target.read_bytes(), b"")
-
-    def test_a_state_file_below_a_symlinked_parent_is_refused(self):
-        p = Project({"BOARD.md": LEGACY_BOARD,
-                     "design/DESIGN-001-x.md": LEGACY_DESIGN})
-        design = p.root / "design"
-        outside = p.root / "outside-design"
-        design.rename(outside)
-        design.symlink_to(outside, target_is_directory=True)
-        before = (outside / "DESIGN-001-x.md").read_bytes()
-
-        rc, out, err = p.run("apply")
-
-        self.assertEqual(rc, 1, (out, err))
-        self.assertIn("crosses symlink", out["refused"])
-        self.assertEqual((outside / "DESIGN-001-x.md").read_bytes(), before)
-        self.assertFalse((p.root / "tasks.jsonl").exists())
-
-    def test_a_symlinked_declaration_record_is_refused_before_state_writes(self):
-        """The store — what a declaration is written INTO."""
-        self._symlinked_record_is_refused("conformance.jsonl")
-
-    def test_a_symlinked_markdown_record_is_refused_before_state_writes(self):
-        """The markdown a pre-TASK-234 project still has — what the run
-        converts and then UNLINKS. Its own test, not a second assertion in the
-        one above, because the two are preflighted by two calls and one of them
-        can be deleted with the other green."""
-        self._symlinked_record_is_refused("conformance.md")
-
-    def _symlinked_record_is_refused(self, name: str):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        plan = p.plan()
-        board = p.root / "BOARD.md"
-        before = board.read_bytes()
-        # **Both records, because `apply` writes one and deletes the other**
-        # (TASK-234): the store is what a declaration is written into, and the
-        # markdown is what a pre-conversion project has and the run UNLINKS.
-        # Unlinking a symlink Perry did not put there is the same refusal for
-        # the same reason, so the preflight has to cover both names.
-        record = p.root / ".perry" / name
-        record.parent.mkdir(exist_ok=True)
-        target = p.root / f"outside-{name}"
-        target.write_text("outside\n", encoding="utf-8")
-        record.symlink_to(target)
-
-        with self.assertRaises(M.Refused) as caught:
-            M.apply_plan(plan, SCHEMA)
-
-        self.assertIn(f"{name} is a symlink", str(caught.exception))
-        self.assertEqual(board.read_bytes(), before)
-        self.assertTrue(record.is_symlink())
-        self.assertEqual(target.read_text(encoding="utf-8"), "outside\n")
-        self.assertFalse((p.root / ".perry" / "migrate").exists())
-
-
-class TestRestoreTransactionProtocol(unittest.TestCase):
-    def test_restore_preflights_every_path_before_overwriting_a_new_edit(self):
-        p = Project({"BOARD.md": LEGACY_BOARD,
-                     "design/DESIGN-001-x.md": LEGACY_DESIGN})
-        apply_rc, _, apply_err = p.run("apply")
-        self.assertEqual(apply_rc, 0, apply_err)
-        board = p.root / "BOARD.md"
-        design = p.root / "design" / "DESIGN-001-x.md"
-        board.write_bytes(board.read_bytes() + b"\nPOST MIGRATION USER EDIT\n")
-        current = {"BOARD.md": board.read_bytes(), "design": design.read_bytes()}
-
-        rc, out, err = p.run("restore")
-
-        self.assertEqual(rc, 1, (out, err))
-        self.assertIn("changed since migration", out["refused"])
-        self.assertEqual(board.read_bytes(), current["BOARD.md"])
-        self.assertEqual(design.read_bytes(), current["design"],
-                         "restore wrote one file before discovering the conflict")
-
-    def test_restore_can_retry_after_a_partial_restore_failure(self):
-        p = Project({"BOARD.md": LEGACY_BOARD,
-                     "design/DESIGN-001-x.md": LEGACY_DESIGN})
-        apply_rc, _, apply_err = p.run("apply")
-        self.assertEqual(apply_rc, 0, apply_err)
-        point = next((p.root / ".perry" / "migrate").glob("*.json"))
-
-        real = M.write_atomic
-        calls = {"n": 0}
-
-        def fail_second_restore_write(path, data):
-            calls["n"] += 1
-            if calls["n"] == 2:
-                raise PermissionError(13, "simulated restore failure", str(path))
-            return real(path, data)
-
-        M.write_atomic = fail_second_restore_write
-        try:
-            with self.assertRaises(PermissionError):
-                M.undo(point, expected_root=p.root)
-        finally:
-            M.write_atomic = real
-
-        rc, out, err = p.run("restore", point.stem)
-
-        self.assertEqual(rc, 0, (out, err))
-        self.assertEqual(p.text("BOARD.md"), LEGACY_BOARD)
-        self.assertEqual(p.text("design/DESIGN-001-x.md"), LEGACY_DESIGN)
-        self.assertFalse((p.root / "tasks.jsonl").exists())
-
-    def test_two_runs_in_one_second_never_share_a_restore_point(self):
-        p = Project({"BOARD.md": LEGACY_BOARD,
-                     "design/DESIGN-001-x.md": LEGACY_DESIGN})
-        real_datetime = M.datetime
-
-        class Frozen:
-            @classmethod
-            def now(cls):
-                return real_datetime(2026, 1, 2, 3, 4, 5)
-
-        M.datetime = Frozen
-        try:
-            board_run = M.apply_plan(
-                M.plan_project(p.root, p.root, SCHEMA, ["BOARD.md"],
-                               root_arg=str(p.root)),
-                SCHEMA, declare=False)
-            design_run = M.apply_plan(
-                M.plan_project(p.root, p.root, SCHEMA,
-                               ["design/DESIGN-001-x.md"],
-                               root_arg=str(p.root)),
-                SCHEMA, declare=False)
-        finally:
-            M.datetime = real_datetime
-
-        self.assertNotEqual(board_run["run"], design_run["run"])
-        points = sorted((p.root / ".perry" / "migrate").glob("*.json"))
-        self.assertEqual(len(points), 2)
-
-    def test_restore_payload_path_type_and_hash_are_validated_before_writes(self):
-        mutations = ("path", "type", "hash", "unhashed")
-        for mutation in mutations:
-            with self.subTest(mutation=mutation):
-                p = Project({"BOARD.md": LEGACY_BOARD})
-                self.assertEqual(p.run("apply")[0], 0)
-                point = next((p.root / ".perry" / "migrate").glob("*.json"))
-                payload = json.loads(point.read_text())
-                board = p.root / "BOARD.md"
-                before = board.read_bytes()
-                escape = p.root.parent / f"{p.root.name}-escape"
-
-                if mutation == "path":
-                    entry = payload["files"].pop("BOARD.md")
-                    expected = payload["expected_after"].pop("BOARD.md")
-                    rel = f"../{escape.name}"
-                    payload["files"][rel] = entry
-                    payload["expected_after"][rel] = expected
-                elif mutation == "type":
-                    payload["files"]["BOARD.md"]["type"] = "symlink"
-                elif mutation == "unhashed":
-                    payload["files"]["BOARD.md"] = "unverified replacement\n"
-                else:
-                    payload["files"]["BOARD.md"]["sha256"] = "0" * 64
-                point.write_text(json.dumps(payload))
-
-                try:
-                    rc, out, err = p.run("restore", point.stem)
-
-                    self.assertEqual(rc, 1, (out, err))
-                    self.assertIn("restore payload", out["refused"])
-                    self.assertEqual(board.read_bytes(), before)
-                    self.assertFalse(escape.exists())
-                finally:
-                    if escape.exists():
-                        escape.unlink()
-
-
-# ── 4 · the user declares ─────────────────────────────────────────────────
-
-
-class TestTheUserDeclares(unittest.TestCase):
-
-    def test_migration_never_runs_as_a_side_effect_of_another_command(self):
-        """Nothing in `bin/` may invoke this. A migration that fires from a
-        `perry-task add` is TASK-040 B-2 with a bigger blast radius."""
-        callers = []
-        for tool in sorted((PERRY_HOME / "bin").glob("perry-*")):
-            if tool.name == "perry-migrate":
-                continue
-            body = tool.read_text()
-            for line in body.split("\n"):
-                if "perry-migrate" in line and not line.lstrip().startswith(("#", "*")):
-                    if re.search(r"(subprocess|import|_load|exec)", line):
-                        callers.append(f"{tool.name}: {line.strip()}")
-        self.assertEqual(callers, [])
-
-    def test_an_unconvertible_markdown_record_refuses_and_names_the_way_back(self):
-        """`declare` converts a pre-TASK-234 record before it writes, and that
-        step can REFUSE — with `bin/perry-conform`'s `Refused`, which is a
-        different class from this module's. It walked straight past the handler
-        around the declaration, which is Site 3's own failure mode verbatim:
-        fully migrated, restore point on disk and never named, raw traceback.
-
-        Asserts the shape of the refusal, not just that one happened: a
-        traceback is also a non-zero exit."""
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        (p.root / ".perry").mkdir(exist_ok=True)
-        # A record with a row inside an HTML comment — a genuine row by every
-        # per-row property, and not what `perry-conform declare` would write.
-        (p.root / ".perry" / "conformance.md").write_text(
-            "# Perry conformance\n\n"
-            "| File | Shape version | Declared | Route |\n"
-            "|---|---|---|---|\n"
-            "\n",
-            encoding="utf-8")
-
-        rc, out, err = p.run("apply")
-
-        self.assertEqual(rc, 1, f"the run did not refuse: {out} {err}")
-        self.assertIsInstance(out, dict, f"a traceback, not a refusal: {err}")
-        self.assertIn("refused", out, out)
-        self.assertIn("perry-migrate restore", out["refused"],
-                      "the refusal does not name the way back")
-        self.assertNotIn("Traceback", err)
-
-        # **Both commands in this message have to be runnable from where the
-        # reader is standing** (TASK-234 round 4). This message carries two:
-        # `perry-migrate restore `, this tool's own way back, and — quoted
-        # inside it — `bin/perry-conform`'s refusal, which names
-        # `perry-conform migrate`. The reader typed `--root` on `perry-migrate
-        # apply`; a command handed back without it acts on whatever project
-        # they happen to be in, and in `perry-conform migrate`'s case exits 0
-        # saying "nothing to convert" about that other project.
-        #
-        # The route through `apply_plan` was the ONE member of the class no
-        # test held: mutating `root_arg=root_arg` to `root_arg=None` there was
-        # GREEN across `tests.test_migrate` and `tests.test_conformance` both.
-        #
-        # **Asserted by parsing, not by a regex over the text** (round 5). The
-        # regex here was `re.escape(cmd) + r"[^\n`]*--root " +
-        # re.escape(str(p.root))`, which is a substring test wearing a regex:
-        # it is satisfied by `--root /home/ada/My Project`, the exact line the
-        # round-4 FAIL handed back, which parses as five arguments and exits 1
-        # about a file the reader never named.
-        named = commands_named(out["refused"])
-        for cmd in ("perry-migrate restore", "perry-conform migrate"):
-            self.assertTrue(
-                any(c.startswith(cmd + " ") for c in named),
-                f"the refusal does not hand back `{cmd}` at all; it names "
-                f"{named!r}")
-        assert_every_command_carries(
-            self, out["refused"], p.root,
-            "the refusal `apply_plan` raises when the record will not convert")
-
-    def test_a_run_that_converted_a_legacy_record_can_be_restored(self):
-        """**The round trip the `update_expected_after` call exists for, and
-        which nothing exercised** (round 5).
-
-        `apply_plan` records a post-run signature for BOTH records — the store
-        it wrote and the markdown `declare` converted and unlinked. The V4
-        round-4 reviewer's R-N13 deleted the second of those two calls, which
-        this branch added, and the whole of `tests.test_migrate` stayed
-        **GREEN**: `tests/test_migrate.py` names `conformance.md` in exactly
-        two places, the symlink preflight and the unconvertible-record
-        refusal, and **no test applied a migration to a project holding a
-        legacy record and then restored it**.
-
-        Without the call the restore point still holds the PRE-declaration
-        digest for `.perry/conformance.md`, while the run that converted it
-        deleted the file — so `undo` compares the tree against a signature the
-        run itself invalidated, and the recovery path refuses on a project it
-        is supposed to be able to recover.
-        """
-        CF = M.conform()
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        legacy = p.root / ".perry" / "conformance.md"
-        legacy.write_text(
-            "\n".join(CF.LEGACY_HEADER) + "\n"
-            "| OKR.md | 2 | 2026-08-20 | declare |\n", encoding="utf-8")
-        # Canonical by construction: the conversion refuses anything that is
-        # not line-for-line what `render_legacy` writes, and this test is
-        # about the RESTORE, not about the fixed point.
-        legacy.write_text(
-            CF.render_legacy(CF.P.read_legacy_conformance(p.root).declarations),
-            encoding="utf-8")
-        before = p.tree()
-        self.assertIn(".perry/conformance.md", before)
-
-        rc, out, err = p.run("apply")
-        self.assertEqual(rc, 0, f"{out} {err}")
-        self.assertFalse(legacy.exists(),
-                         "the conversion did not consume the markdown record")
-        self.assertTrue((p.root / ".perry" / "conformance.jsonl").exists())
-
-        rc, out, err = p.run("restore")
-        self.assertEqual(
-            rc, 0,
-            f"the run converted a legacy record and then could not be undone: "
-            f"{out} {err}")
-
-        after = {k: v for k, v in p.tree().items()
-                 if not k.startswith(".perry/migrate/")}
-        self.assertEqual(
-            after, before,
-            "restoring a run that converted a legacy record did not put the "
-            "project back byte for byte")
-
-    def test_the_declaration_goes_through_perry_conform_and_is_the_only_record(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        p.run("apply")
-        record = p.root / ".perry" / "conformance.jsonl"
-        self.assertTrue(record.exists())
-        stored = [json.loads(l) for l in
-                  record.read_text().split("\n") if l.strip()]
-        board = next(r for r in stored if r["path"] == "BOARD.md")
-        self.assertEqual(board["shape_version"], 2)
-        self.assertEqual(board["route"], "migrate",
-                         "the route field exists so a declaration says how it "
-                         "was made; a migration's is not a hand `declare`")
-        # **Provenance the four markdown columns could not carry** (TASK-234).
-        # `run` is this run's id, which is also the name of its restore point:
-        # a declaration can now say which migration made it, which is what made
-        # TASK-226 an investigation rather than a query.
-        self.assertEqual(board["writer"], "perry-migrate apply")
-        self.assertTrue(board["run"], "the declaration does not name its run")
-        self.assertTrue(
-            (p.root / ".perry" / "migrate" / f"{board['run']}.json").exists(),
-            "the run a declaration names is not a restore point on disk")
-        self.assertTrue(board["recorded_at"])
-        others = [f for f in (p.root / ".perry").rglob("*")
-                  if f.is_file() and "conform" in f.name and f != record]
-        self.assertEqual(others, [], "there must be exactly one record")
-
-    def test_a_dry_run_declares_nothing(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        p.run()
-        self.assertFalse((p.root / ".perry" / "conformance.jsonl").exists())
-
-    def test_no_declare_migrates_without_declaring(self):
-        """The two acts are separable: a user may want the shape fixed and
-        want to read it before saying it is theirs."""
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        rc, out, _ = p.run("apply", "--no-declare")
-        self.assertFalse((p.root / ".perry" / "conformance.jsonl").exists())
-        self.assertEqual(p.lint_errors(), 0)
-
-    def test_the_gate_refusal_names_the_migration_and_the_dry_run(self):
-        """`risk-add`'s shape: the count, the command, and the preview. Before
-        this task the only command it could name reported the problem and
-        fixed nothing."""
-        C = load("perry_conform_for_message", CONFORM)
-        v = C.Verdict(path="BOARD.md", state=C.UNDECLARED, shape_version=2,
-                      errors=[object(), object(), object()])
-        msg = C.message_for(v, "perry-task", None)
-        commands = [l.strip() for l in msg.split("\n")]
-        self.assertIn("3 error(s)", msg)
-        self.assertIn("perry-migrate", commands, "the dry run, on its own line")
-        self.assertIn("perry-migrate apply", commands)
-        for line in msg.split("\n"):
-            if line.strip().startswith("perry-"):
-                self.assertTrue((PERRY_HOME / "bin" / line.split()[0]).exists(),
-                                f"names a tool that does not exist: {line}")
-
-    def test_a_migrated_file_can_be_written_to_under_an_enforcing_gate(self):
-        """The point of the whole exercise: after migration the writers work."""
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        env = dict(os.environ, PERRY_CONFORMANCE="enforce")
-        add = ["add", "--title", "a row", "--group", "P2",
-               "--deliverable", "a thing that exists afterwards",
-               "--verification", "the suite is green", "--root", str(p.root)]
-        r = subprocess.run(["python3", str(TASK), *add], capture_output=True,
-                           text=True, env=env)
-        self.assertEqual(r.returncode, 1, "unmigrated must refuse")
-        p.run("apply")
-        r = subprocess.run(["python3", str(TASK), *add], capture_output=True,
-                           text=True, env=env)
-        self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
-
-
-# ── 5 · partial migration is a state, not a failure ───────────────────────
-
-
-class TestPartialIsAState(unittest.TestCase):
-
-    def test_a_file_that_cannot_reach_conformance_is_left_byte_identical(self):
-        """"Valid" for an incomplete migration means: every file is either
-        exactly as its author left it, or conformant. Never in between — a file
-        that changed and is still read-only is the worst of both."""
-        p = Project({"BOARD.md": UNRESOLVABLE_BOARD})
-        before = p.text("BOARD.md")
-        rc, out, _ = p.run("apply")
-        self.assertEqual(p.text("BOARD.md"), before)
-        self.assertEqual(rc, 1)
-        blocked = edit_for(p.plan(), "BOARD.md")
-        self.assertTrue(blocked.residual)
-        self.assertFalse(blocked.writable)
-
-    def test_one_file_migrates_while_another_does_not_and_both_halves_work(self):
-        p = Project({"BOARD.md": UNRESOLVABLE_BOARD,
-                     "design/DESIGN-001-x.md": LEGACY_DESIGN})
-        board_before = p.text("BOARD.md")
-        p.run("apply")
-        self.assertEqual(p.text("BOARD.md"), board_before)
-        rc, out, _ = p.run("check", "design/DESIGN-001-x.md", tool=CONFORM)
-        self.assertEqual(out["state"], "conformant")
-        rc, out, _ = p.run("check", "BOARD.md", tool=CONFORM)
-        self.assertEqual(out["state"], "undeclared")
-
-    def test_only_migrates_the_named_file_and_nothing_else(self):
-        p = Project({"BOARD.md": LEGACY_BOARD,
-                     "design/DESIGN-001-x.md": LEGACY_DESIGN})
-        design_before = p.text("design/DESIGN-001-x.md")
-        p.run("apply", "--only", "BOARD.md")
-        self.assertEqual(p.text("design/DESIGN-001-x.md"), design_before)
-        self.assertNotEqual(p.text("BOARD.md"), LEGACY_BOARD)
-
-    def test_only_design_does_not_derive_a_store_from_the_unselected_board(self):
-        p = Project({"BOARD.md": LEGACY_BOARD,
-                     "design/DESIGN-001-x.md": LEGACY_DESIGN})
-        board_before = (p.root / "BOARD.md").read_bytes()
-
-        rc, out, err = p.run("apply", "--only", "design/DESIGN-001-x.md")
-
-        self.assertEqual(rc, 0, (out, err))
-        self.assertEqual(out["applied"]["applied"],
-                         ["design/DESIGN-001-x.md"])
-        self.assertEqual((p.root / "BOARD.md").read_bytes(), board_before)
-        self.assertFalse((p.root / "tasks.jsonl").exists(),
-                         "--only design crossed its boundary through the task store")
-
-    def test_the_refusal_names_the_finding_that_blocked_the_file(self):
-        p = Project({"BOARD.md": UNRESOLVABLE_BOARD})
-        rc, out, _ = p.run(json_out=False)
-        self.assertIn("left byte-identical", out)
-        self.assertIn("half-done", out)
-
-
-# ── 6 · what must change, and what is merely different ────────────────────
-
-
-class TestTheVocabularyIsNotRewritten(unittest.TestCase):
-
-    def test_a_heading_the_project_chose_is_never_renamed_or_moved(self):
-        """`## Open — investment line` is 41 rows on the real project.
-        `bin/perry-task` already reads such a section as a `group`; renaming it
-        would be Perry deciding what someone's workstream is called."""
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        p.run("apply")
-        after = p.text("BOARD.md")
-        self.assertIn("## Open — investment line (policy · allocation)", after)
-        self.assertIn("## Open — engineering line · phase #004", after)
-        self.assertIn("## P2 (low priority carry)", after,
-                      "the parenthetical is the author's; only the P2 prefix "
-                      "is Perry's")
-
-    def test_rows_are_never_moved_into_the_priority_sections_perry_creates(self):
-        """The sections Perry adds are empty. Filing someone's task as P0 or P1
-        is a decision about their work, and nothing in the file states it."""
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        p.run("apply")
-        lines = p.text("BOARD.md").split("\n")
-        i = lines.index("## P0")
-        block = lines[i:i + 6]
-        self.assertFalse([l for l in block if l.startswith("|") and "---" not in l
-                          and "ID" not in l],
-                         f"## P0 must be created empty, got {block}")
-        own = p.text("BOARD.md").split("## Open — investment line")[1].split("\n##")[0]
-        self.assertIn("| INV-DRAFT-1 |", own,
-                      "the rows must still be under the heading their author "
-                      "filed them under")
-
-    def test_a_table_sharing_no_column_with_the_schemas_is_not_widened(self):
-        """A `| seed | Owner | Deliverable |` under `## Objective 1` is not
-        Perry's phase-KR table. Bolting four empty columns onto it would be
-        writing into a table Perry does not recognise."""
-        phase = ("# Phase #001 — x\n\n> **Started**: 2026-01-01\n"
-                 "> **Status**: active\n\n"
-                 "## Phase Focus\n\nf\n\n## Operating Rules\n\nr\n\n"
-                 "## Cost Ceiling\n\nc\n\n## User Commitments\n\nu\n\n"
-                 "## User-Unavailable Degradation\n\nd\n\n"
-                 "## Phase Scope Reduction Rule\n\ns\n\n"
-                 "## Objective 1 — a\n\n| seed | Owner | Deliverable |\n|---|---|---|\n"
-                 "| s1 | User | a thing |\n\n"
-                 "## Definition of Done\n\nd\n\n## Not Doing in this phase\n\nn\n\n"
-                 "## Process Note\n\np\n")
-        p = Project({"BOARD.md": LEGACY_BOARD, "phase/001-x.md": phase})
-        p.run("apply")
-        self.assertEqual(p.text("phase/001-x.md"), phase,
-                         "the file must be left exactly as found")
-        e = edit_for(p.plan(), "phase/001-x.md")
-        self.assertTrue(any(f.rule == "table-columns" for f in e.residual))
-
-    def test_a_table_perry_recognises_is_widened_and_every_row_padded(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        p.run("apply")
-        after = p.text("BOARD.md")
-        block = after.split("## P2 (low priority carry)")[1].split("\n##")[0]
-        self.assertIn("| ID | Title | Owner | Status | Next action | Evidence |", block)
-        row = next(l for l in block.split("\n") if "ENG-9" in l)
-        self.assertEqual(len(row.strip().strip("|").split("|")), 6, row)
-        self.assertIn("conftest has no DB isolation", row)
-
-    def test_an_ambiguous_enum_value_is_never_guessed(self):
-        """Two candidates means the migration does not know. A tool that picked
-        one would be inventing a fact about somebody's design doc."""
-        doc = LEGACY_DESIGN.replace(
-            "> **Status**: v1.1 LOCKED 2026-05-19 PM BJT** (Amendments A+B applied; v1.0 LOCKED 2026-05-18)",
-            "> **Status**: superseded by the locked v2")
-        p = Project({"BOARD.md": LEGACY_BOARD, "design/DESIGN-001-x.md": doc})
-        p.run("apply")
-        self.assertEqual(p.text("design/DESIGN-001-x.md"), doc)
-
-    def test_an_enum_spelling_resolves_only_through_the_declared_glossary(self):
-        allowed = SCHEMA["enums"]["phase_status"]
-        aliases = SCHEMA["migration"]["enum_aliases"]["phase_status"]
-        self.assertEqual(M.enum_candidates("进行中", allowed, aliases), ["active"])
-        self.assertEqual(M.enum_candidates("已评分(2026-06-10 score-phase;承诺 10/11)",
-                                           allowed, aliases), ["scored"])
-        self.assertEqual(M.enum_candidates("洽谈中", allowed, aliases), [],
-                         "an unlisted spelling is not guessed at")
-
-    def test_a_localized_project_gets_localized_headings_and_columns(self):
-        """`bin/perry-task.ensure_section` localizes the sections it creates.
-        A migration that hardcoded English would leave a board in two
-        languages."""
-        board = LEGACY_BOARD.replace("## Cadence\n", "## 例行节奏 (was Cadence)\n")
-        board = board.replace("## Top risks\n\n- the RM has not replied since November\n", "")
-        p = Project({"BOARD.md": board}, config=CONFIG_ZH)
-        p.run("apply")
-        after = p.text("BOARD.md")
-        self.assertIn("| 编号 | 标题 | 负责人 | 状态 | 下一步 | 证据 |", after)
-        self.assertIn("## 主要风险", after,
-                      "a section Perry creates is named in the project's own "
-                      "language, like the ones `perry-task` creates")
-        self.assertEqual(p.lint_errors(), 0,
-                         "a localized board Perry created must lint clean")
-
-    def test_a_column_added_to_a_localized_table_matches_the_table_it_joins(self):
-        """The spelling comes off the table's own header, not off the config:
-        a new column has to match the row it is joining, and the header is the
-        only thing that states that without a second file being right."""
-        board = LEGACY_BOARD.replace(
-            "| ID | Title | Owner | Status |\n|---|---|---|---|\n"
-            "| ENG-9 | conftest has no DB isolation | Coding Agent | not_started |",
-            "| 编号 | 标题 | 负责人 | 状态 |\n|---|---|---|---|\n"
-            "| ENG-9 | conftest has no DB isolation | Coding Agent | not_started |")
-        p = Project({"BOARD.md": board}, config=CONFIG_ZH)
-        p.run("apply")
-        block = p.text("BOARD.md").split("## P2 (low priority carry)")[1].split("\n##")[0]
-        self.assertIn("| 编号 | 标题 | 负责人 | 状态 | 下一步 | 证据 |", block)
-
-    def test_a_field_that_only_appears_in_a_table_row_is_never_rewritten(self):
-        """`perry-lint` searches the whole file, so the field it validates is
-        whichever comes first. Rewriting one that lives in a table row would
-        put a `|` inside the row and turn one cell into two."""
-        lines = ["| Status: draft | x |", "> **Status**: locked"]
-        idx, _ = M.field_line(lines, "Status", DESIGN_SPEC, SCHEMA)
-        self.assertIsNone(idx)
-
-    def test_a_minted_source_id_never_collides_with_one_already_in_the_tree(self):
-        """An id is an address, not a claim — but an address that is already
-        taken re-points somebody's citation."""
-        old = ("# old\n\n> Id: SRC-9\n> Source: x\n> Received: 2026-01-01\n"
-               "> Status: active\n")
-        p = Project({"BOARD.md": LEGACY_BOARD, "knowledge/a/old.md": old,
-                     "knowledge/a/new.md": "# new\n\n> Source: y\n"})
-        p.run("apply")
-        self.assertIn("SRC-10", p.text("knowledge/a/new.md"))
-        self.assertEqual(p.text("knowledge/a/old.md"), old)
-
-    def test_warnings_are_never_acted_on(self):
-        """Migration changes shape, not quality. Some of this schema's warnings
-        are time-dependent, and a migration that chased them would rewrite a
-        file because a calendar boundary passed."""
-        # A design doc in `draft` with no `## Implementation plan` (a warning
-        # today, an error the moment its Status says `locked`) AND no `Date`
-        # (an error now). The migration must fix the second and leave the
-        # first — a file with a real error is exactly where a transform driven
-        # off the spec rather than off the findings starts overreaching.
-        doc = LEGACY_DESIGN.replace(
-            "> **Status**: v1.1 LOCKED 2026-05-19 PM BJT** (Amendments A+B applied; v1.0 LOCKED 2026-05-18)",
-            "> **Status**: draft").replace(
-            "## 6. Implementation plan\n\nDo the thing.\n", "").replace(
-            "> **Date**: 2026-05-18\n", "")
-        p = Project({"BOARD.md": LEGACY_BOARD, "design/DESIGN-001-x.md": doc})
-        p.run("apply")
-        after = p.text("design/DESIGN-001-x.md")
-        self.assertIn("Date", after, "the error must be fixed")
-        self.assertNotIn("## Implementation plan", after,
-                         "a section that is only a warning must not be inserted")
-        r = subprocess.run(["python3", str(LINT), "--root", str(p.root), "--json"],
-                           capture_output=True, text=True)
-        out = json.loads(r.stdout)
-        self.assertEqual(out["errors"], 0)
-        self.assertTrue([f for f in out["findings"] if f["severity"] == "warn"],
-                        "the fixture must still carry a warning afterwards")
-
-    def test_perrys_own_machine_written_files_are_never_edited(self):
-        """A shape error in a diagnosis or an adoption dossier is a defect in
-        the tool that wrote it. Rewriting a finding's status to satisfy an enum
-        would be editing a diagnostic record."""
-        dossier = ("---\ndiagnosis: 1\nstage: read\nfindings:\n"
-                   "  - id: LOAD-02\n    severity: error\n    source: read\n"
-                   "    status: false_positive\n---\n\n# Diagnosis\n")
-        p = Project({"BOARD.md": LEGACY_BOARD,
-                     ".perry/diagnose/2026-08-17-diagnosis.md": dossier})
-        rc, out, _ = p.run("apply")
-        self.assertEqual(p.text(".perry/diagnose/2026-08-17-diagnosis.md"), dossier)
-        self.assertTrue(any(s["path"].endswith("diagnosis.md")
-                            for s in out["skipped"]), out["skipped"])
-
-
-# ── 7 · the near-empty project ────────────────────────────────────────────
-
-
-class TestTheNearEmptyProject(unittest.TestCase):
-
-    def test_a_project_with_no_perry_state_is_refused_in_one_sentence(self):
-        """The other real case. The failure mode here is a tool that finds
-        nothing to do and says so at length, or half-builds a structure."""
-        p = Project({"README.md": "# hello\n"}, config="")
-        (p.root / ".perry" / "config.md").unlink()
-        rc, out, err = p.run(json_out=False)
-        self.assertEqual(rc, 1)
-        said = (out if isinstance(out, str) else "") + err
-        self.assertIn("perry adopt", said)
-        self.assertLess(len(said.strip().split("\n")), 8)
-        self.assertEqual(p.text("README.md"), "# hello\n")
-
-    def test_a_conformant_project_is_told_there_is_nothing_to_do(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        p.run("apply")
-        rc, out, _ = p.run(json_out=False)
-        self.assertEqual(rc, 0)
-        self.assertIn("nothing to migrate", out)
-
-
-# ── 8 · one definition of the shape ───────────────────────────────────────
-
-
-class TestOneDefinitionOfTheShape(unittest.TestCase):
-
-    def test_the_migration_holds_no_opinion_about_what_perrys_shape_is(self):
-        """It proposes edits; `perry-lint.check_file` judges them. If this file
-        grew its own copy of the rules, the two would drift — which is the
-        defect ADR-004 exists to end."""
-        body = MIGRATE.read_text()
-        self.assertNotIn("def check_file", body)
-        self.assertIn("lint().check_file", body)
-
-    def test_every_file_it_writes_lints_clean_by_perry_lints_own_reckoning(self):
-        p = Project({"BOARD.md": LEGACY_BOARD,
-                     "design/DESIGN-001-x.md": LEGACY_DESIGN})
-        rc, out, _ = p.run("apply")
-        self.assertEqual(p.lint_errors(), 0)
-        for f in out["files"]:
-            if f["writable"]:
-                self.assertEqual(f["after_errors"], 0, f["path"])
-
-    def test_the_shape_version_declared_is_the_schemas_own(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        _, out, _ = p.run()
-        self.assertEqual(out["shape_version"], SCHEMA["schema_version"])
-
-    def test_a_section_label_that_is_a_pattern_is_never_written_as_a_heading(self):
-        """`## Objective ` and `## §1 … §8 (eight fixed sections)`
-        describe a family. Writing one verbatim would insert punctuation as a
-        section title."""
-        for spec in SCHEMA["files"]:
-            for req in spec.get("headings", []):
-                label = M.literal_label(req)
-                if label is None:
-                    continue
-                self.assertNotRegex(label, r"[<>…()]", f"{spec['path']}: {label}")
-
-
-class TestAHeaderBlockIsNotAnyQuotedText(unittest.TestCase):
-    """A news article sitting in `knowledge/` opens with a third-party-AI
-    disclaimer in a blockquote. Migration appended its four header fields to
-    that blockquote, so Perry's metadata rendered as the last sentences of
-    somebody else's disclaimer — no character lost, the meaning changed.
-
-    Found by the user reading the migrated file. Thirty mutations had passed
-    over it, because every one of them asked whether the bytes survived and
-    none asked what the file now said.
-    """
-
-    #: The real shape, reduced: an H1, prose, a rule, then a quoted disclaimer
-    #: whose colon sits *inside* the bold — field-shaped to any pattern.
-    ARTICLE = """# AI 船票全球飞涨
-
-**来源:** 财新周刊
-
----
-
-> **注意:** 本文由第三方AI提炼总结而成,可能与原文真实意图存在偏差。
-
-正文第一段。
-"""
-
-    #: A digest Perry wrote: the header block is directly under the H1 and
-    #: names fields the schema declares.
-    DIGEST = """# Digest — 月度点评
-
-> Source: 用户直接粘贴全文
-> Received: 2026-08-12 by chat paste
-> Status: active
-
-## 摘要
-
-内容。
-"""
-
-    def files(self, text, name="knowledge/market-context/a.md"):
-        return {"BOARD.md": LEGACY_BOARD, name: text}
-
-    def block(self, p, rel="knowledge/market-context/a.md"):
-        """The contiguous run Perry wrote, not every quoted line in the file —
-        the disclaimer is also `> `-prefixed, and counting it was this test's
-        own first bug."""
-        lines = p.text(rel).split("\n")
-        start = next(i for i, l in enumerate(lines) if l.startswith("> Id:"))
-        out = []
-        while start < len(lines) and lines[start].startswith("> "):
-            out.append(lines[start]); start += 1
-        return out
-
-    def test_quoted_prose_is_not_joined(self):
-        p = Project(files=self.files(self.ARTICLE))
-        p.run("apply", "--only", "knowledge/market-context/a.md")
-        text = p.text("knowledge/market-context/a.md")
-        disclaimer = next(i for i, l in enumerate(text.split("\n"))
-                          if "第三方AI" in l)
-        after = text.split("\n")[disclaimer + 1]
-        self.assertNotIn("Id", after,
-                         "the header fields joined a disclaimer that is not a "
-                         "header block")
-
-    def test_a_new_block_sits_directly_under_the_h1(self):
-        p = Project(files=self.files(self.ARTICLE))
-        p.run("apply", "--only", "knowledge/market-context/a.md")
-        lines = p.text("knowledge/market-context/a.md").split("\n")
-        self.assertTrue(lines[0].startswith("# "))
-        self.assertEqual("", lines[1])
-        self.assertTrue(lines[2].startswith("> Id:"), lines[:5])
-
-    def test_a_new_block_is_one_language_and_one_colon(self):
-        """`i18n.fields` maps `Status` and not `Id`/`Source`/`Received`, so
-        translating through it produced three English names and one Chinese in
-        a single block. `reference/i18n.md` requires one language per file."""
-        p = Project(files=self.files(self.ARTICLE), config=CONFIG_ZH)
-        p.run("apply", "--only", "knowledge/market-context/a.md")
-        block = self.block(p)
-        self.assertEqual(4, len(block), block)
-        self.assertTrue(all(": " in l for l in block),
-                        f"mixed colon forms in one block: {block}")
-        self.assertFalse([l for l in block if ":" in l], block)
-
-    def test_a_new_block_is_never_bolded(self):
-        """`perry-lint --provenance` matches `^>\\s*Id\\s*[::]`; a bolded
-        `> **Id**:` does not satisfy it, so bolding a block Perry starts
-        breaks the provenance chain the id exists for."""
-        p = Project(files=self.files(self.ARTICLE))
-        p.run("apply", "--only", "knowledge/market-context/a.md")
-        self.assertRegex(p.text("knowledge/market-context/a.md"),
-                         r"(?m)^>\s*Id\s*:")
-
-    def test_every_field_lands_in_the_same_block(self):
-        """`joining` was recomputed per field, so the first insertion turned a
-        fresh block into an existing one and the rest followed the project's
-        spelling instead of the schema's."""
-        p = Project(files=self.files(self.ARTICLE), config=CONFIG_ZH)
-        p.run("apply", "--only", "knowledge/market-context/a.md")
-        lines = p.text("knowledge/market-context/a.md").split("\n")
-        run = [i for i, l in enumerate(lines) if l.startswith("> Id:")]
-        start = run[0]
-        contiguous = 0
-        while start + contiguous < len(lines) and \
-                lines[start + contiguous].startswith("> "):
-            contiguous += 1
-        self.assertEqual(4, contiguous,
-                         "the four fields were split across two blocks")
-
-    def test_a_real_header_block_is_still_joined(self):
-        """The discrimination must not cost the behaviour it protects: a
-        digest missing only `Id` gets it appended to the block it has."""
-        p = Project(files=self.files(self.DIGEST, "knowledge/x/d.md"))
-        p.run("apply", "--only", "knowledge/x/d.md")
-        lines = p.text("knowledge/x/d.md").split("\n")
-        quoted = [i for i, l in enumerate(lines) if l.startswith("> ")]
-        self.assertEqual(list(range(quoted[0], quoted[0] + len(quoted))), quoted,
-                         "the digest's header block was split")
-        self.assertEqual(4, len(quoted))
-
-
-# ── 9 · a table, a value and a sentence are recognised by vocabulary ───────
-
-
-class TestRecognitionIsByVocabularyNotByShape(unittest.TestCase):
-    """TASK-051. Three transforms recognised their target by shape, and the
-    three commonest words in any markdown table — `ID`, `Status`, `Owner` — are
-    enough shape to be mistaken for Perry's.
-
-    Every case below preserves every character, every cell, every id, every
-    per-section row count, and declares every line it rewrites. That is TASK-052
-    and it is asserted in § 10.
-    """
-
-    #: A legend under a heading `^P[012]\b` matches. Two rows, two columns, one
-    #: of which is `ID` — and ADR-004's own Context table cites this exact shape
-    #: as the reason ADR-004 exists.
-    LEGEND_BOARD = """# Board — Legacy
-
-> Last updated: 2026-01-04
-
-## P0 holding
-
-A legend, not a task table:
-
-| ID | Meaning |
-|---|---|
-| INV-* | investments |
-| ENG-* | engineering |
-
-## Cadence
-
-| ID | Recurring task | Owner | Frequency | Next due |
-|---|---|---|---|---|
-| CAD-1 | weekly reconcile | User | weekly | 2026-01-11 |
-"""
-
-    def test_a_legend_that_shares_one_column_name_is_not_widened(self):
-        """One shared word is a coincidence, not a vocabulary. Before this,
-        migration appended five columns to the legend, every lint error went to
-        zero, the conformance marker declared the board conformant, and
-        `perry-task list` returned two tasks with the ids `INV-` and `ENG-` and
-        no titles — through a frozen contract, on a board a reader had
-        correctly refused."""
-        p = Project({"BOARD.md": self.LEGEND_BOARD})
-        before = p.text("BOARD.md")
-        p.run("apply")
-        self.assertEqual(p.text("BOARD.md"), before,
-                         "the legend was widened into a task table")
-        self.assertNotIn("| ID | Meaning | Title |", p.text("BOARD.md"))
-        self.assertFalse((p.root / ".perry" / "conformance.jsonl").exists(),
-                         "a board that was correctly refused must not be "
-                         "declared conformant")
-
-    def test_the_reader_still_refuses_the_legend_after_the_run(self):
-        """The harm was never the columns; it was `perry-task list` returning
-        rows built from the legend's own cells. Asserted through the reader, not
-        through the file."""
-        p = Project({"BOARD.md": self.LEGEND_BOARD})
-        p.run("apply")
-        r = subprocess.run(["python3", str(TASK), "list", "--all", "--json",
-                            "--root", str(p.root)], capture_output=True, text=True)
-        out = json.loads(r.stdout)
-        self.assertEqual(out["tasks"], [], "the legend's rows became tasks")
-        self.assertEqual(out["conformance"]["sections_skipped"], [])
-
-    def test_a_table_missing_a_minority_of_the_schemas_names_is_still_widened(self):
-        """The discrimination must not cost the behaviour it protects. The
-        four-column `## P2` this suite was built around — the real one on
-        `~/proj/gimegime-pmo` — is 4 of the board's 6 and must still widen.
-        Covered by `test_a_table_perry_recognises_is_widened_and_every_row_padded`
-        end to end; stated here at the boundary."""
-        cols = ["ID", "Title", "Owner", "Status", "Next action", "Evidence"]
-        L = M.lint()
-        sat = lambda c, got: any(a in got for a in L.accepted(c))
-        got = lambda names: [L.norm(n) for n in names]
-        self.assertTrue(M.is_the_schemas_table(
-            {"columns": cols}, got(["ID", "Title", "Owner", "Status"]), sat),
-            "4 of 6 is the table this transform exists for")
-        self.assertTrue(M.is_the_schemas_table(
-            {"columns": cols},
-            got(["ID", "Title", "Owner", "Status", "備考", "负责小组"]), sat),
-            "columns the author added are not counted against the table")
-
-    def test_a_minority_of_the_schemas_names_is_not_a_vocabulary(self):
-        """The boundary itself. `| ID | Meaning |` is one of six and
-        `| ID | Title |` is two of six: both are refused, and refusing the
-        second is the stated cost of the rule — that file is reported and left
-        byte-identical rather than turned into a table by Perry."""
-        cols = ["ID", "Title", "Owner", "Status", "Next action", "Evidence"]
-        L = M.lint()
-        sat = lambda c, got: any(a in got for a in L.accepted(c))
-        got = lambda names: [L.norm(n) for n in names]
-        self.assertFalse(M.is_the_schemas_table(
-            {"columns": cols}, got(["ID", "Meaning"]), sat))
-        self.assertFalse(M.is_the_schemas_table(
-            {"columns": cols}, got(["ID", "Title"]), sat))
-        self.assertFalse(M.is_the_schemas_table(
-            {"columns": cols}, got(["ID", "Title", "Owner"]), sat),
-            "three of six is a tie, and a tie is not a majority")
-
-    def test_a_status_that_says_it_is_not_locked_is_not_read_as_locked(self):
-        """`test_an_ambiguous_enum_value_is_never_guessed` covers two candidates
-        and none. It cannot cover *one wrong* candidate, and one hit was treated
-        as certainty: `> Status: not yet locked — do not build from this` was
-        written as `locked | not yet locked — do not build from this`. Every
-        character survives and the claim is reversed."""
-        doc = LEGACY_DESIGN.replace(
-            "> **Status**: v1.1 LOCKED 2026-05-19 PM BJT** "
-            "(Amendments A+B applied; v1.0 LOCKED 2026-05-18)",
-            "> **Status**: not yet locked — do not build from this")
-        p = Project({"BOARD.md": LEGACY_BOARD, "design/DESIGN-001-x.md": doc})
-        p.run("apply")
-        self.assertEqual(p.text("design/DESIGN-001-x.md"), doc,
-                         "a value that says only what it is NOT was resolved")
-        allowed = SCHEMA["enums"]["design_status"]
-        neg = SCHEMA["migration"]["negations"]
-        self.assertEqual(
-            M.enum_candidates("not yet locked — do not build from this",
-                              allowed, {}, neg), [])
-
-    def test_a_negator_past_a_clause_boundary_does_not_deny_the_value(self):
-        """The window is the clause the token sits in, not the whole value. A
-        negator that governs a different clause — `not a draft; locked
-        2026-05-19` — must leave `locked` standing, or every value carrying the
-        word `not` anywhere becomes unresolvable."""
-        allowed = SCHEMA["enums"]["design_status"]
-        neg = SCHEMA["migration"]["negations"]
-        self.assertEqual(
-            M.enum_candidates("not a draft; locked 2026-05-19", allowed, {}, neg),
-            ["locked"])
-        self.assertEqual(
-            M.enum_candidates("已评分,不再改动",
-                              SCHEMA["enums"]["phase_status"],
-                              SCHEMA["migration"]["enum_aliases"]["phase_status"],
-                              neg),
-            ["scored"], "a denial after the token denies the clause after it")
-
-    def test_body_prose_that_mentions_a_field_is_never_rewritten(self):
-        """`field_line` guarded only `is_row`/`is_separator`, and sentences are
-        field-shaped. Perry's own repo carries the trigger at
-        `perry/design/DESIGN-001-resumable-pipelines.md:126`."""
-        doc = LEGACY_DESIGN.replace(
-            "# DESIGN-001: the thing\n",
-            "# DESIGN-001: the thing\n\nBackground: the vendor contract "
-            "Status: superseded by the 2025 MSA, so we rebuilt.\n")
-        p = Project({"BOARD.md": LEGACY_BOARD, "design/DESIGN-001-x.md": doc})
-        p.run("apply")
-        after = p.text("design/DESIGN-001-x.md")
-        self.assertIn("Background: the vendor contract Status: superseded by "
-                      "the 2025 MSA, so we rebuilt.", after)
-        self.assertNotIn("superseded | superseded", after)
-
-    def test_a_field_shaped_sentence_in_the_body_is_not_the_header_field(self):
-        """At the unit, because two mechanisms now produce the same file: the
-        transform declines the line and `meaning()` would refuse the write. This
-        is the first of them, and the second assertion is the contract that
-        keeps them from disagreeing — *presence* has to stay what `perry-lint`
-        means by it, or `fix_missing_fields` adds a second `Status` while the
-        linter goes on reading the first."""
-        lines = ["# D", "",
-                 "Background: the vendor contract Status: superseded by the "
-                 "2025 MSA, so we rebuilt.", "", "> **Status**: draft"]
-        idx, m = M.field_line(lines, "Status", DESIGN_SPEC, SCHEMA)
-        self.assertIsNone(idx, "a sentence is not Perry's to write into")
-        self.assertIn("superseded", m.group(1),
-                      "the line reported must be the one the linter validates")
-
-    def test_the_field_the_header_block_holds_is_still_rewritten(self):
-        """The counterpart: refusing prose must not cost the transform. The
-        same document with no prose sentence still gets its status
-        normalized."""
-        p = Project({"BOARD.md": LEGACY_BOARD,
-                     "design/DESIGN-001-x.md": LEGACY_DESIGN})
-        p.run("apply")
-        line = next(l for l in p.text("design/DESIGN-001-x.md").split("\n")
-                    if "Status" in l)
-        self.assertTrue(line.split("|")[0].strip().endswith("locked"), line)
-
-
-# ── 10 · what the file now says ───────────────────────────────────────────
-
-
-class TestAColumnSplitIsNotAColumnAdd(unittest.TestCase):
-    """TASK-091. `OKR.md § Commitments` traded one `By when` column for a typed
-    `Due` plus a prose `By when note` (ADR-007, decision 3).
-
-    T2's whole job is appending a column the schema declares and the file
-    lacks, and doing that here is worse than doing nothing: the table ends up
-    with an empty `Due`, every promise's clock still in the retired column, and
-    `perry-lint` reporting zero errors. A file that looks migrated and is not
-    is the failure ADR-004 exists to prevent, so this transform stands aside
-    and names the command that owns the values."""
-
-    PRE_SPLIT = """# OKR — legacy
-
-## Mission
-
-Ship it.
-
-## Operating Principles
-
-- one
-
-## Commitments
-
-| Id | Track | Promise | To whom | By when | Status |
-|---|---|---|---|---|---|
-| ops/1 | ops | Invoices | Finance | within the track SLA | active |
-| rel/1 | rel | Release | Users | 2027-01-01 | active |
-
-## Anti-Goals
-
-- not this
-
-## v1: 2026-01-01
-
-### Objective 1 — ship
-
-## Versioning log
-
-- v1: 2026-01-01 — initial.
-"""
-
-    PRE_SPLIT_CN = PRE_SPLIT.replace(
-        "| Id | Track | Promise | To whom | By when | Status |",
-        "| 编号 | 轨道 | 承诺内容 | 承诺对象 | 截止 | 状态 |").replace(
-        "| ops/1 | ops | Invoices | Finance | within the track SLA | active |",
-        "| ops/1 | ops | 对账 | 财务 | 下周期 | active |")
-
-    TRACKS = (CONFIG_EN +
-              "\n## Tracks\n\n"
-              "| Track | Mode | Spine | Stages | WIP | SLA | Cycle | Default rung |\n"
-              "|---|---|---|---|---|---|---|---|\n"
-              "| ops | queue | commitments | intake -> doing | — | 5d | weekly | V2 |\n"
-              "| rel | pipeline | commitments | draft -> shipped | 3 | 10d | weekly | V3 |\n")
-
-    def project(self):
-        return Project({"OKR.md": self.PRE_SPLIT})
-
-    def test_the_table_is_left_byte_identical(self):
-        p = self.project()
-        before = p.text("OKR.md")
-        p.run("apply")
-        self.assertEqual(before, p.text("OKR.md"),
-                         "an empty `Due` was bolted onto a pre-split register")
-
-    def test_the_finding_names_the_command_that_owns_the_values(self):
-        p = self.project()
-        _, out, _ = p.run()
-        kinds = [c["kind"] for e in out["files"] for c in e["changes"]]
-        self.assertIn("split-needed", kinds, out)
-        detail = next(c["detail"] for e in out["files"] for c in e["changes"]
-                      if c["kind"] == "split-needed")
-        self.assertIn("perry-goals commit --migrate", detail)
-
-    def test_it_is_reported_once_and_not_once_per_pass(self):
-        """`migrate_text` runs the transforms until the linter stops finding
-        fixable things, and this one is never fixable — so it is seen on every
-        pass. Printed twice it reads as two tables."""
-        p = self.project()
-        _, out, _ = p.run()
-        kinds = [c["kind"] for e in out["files"] for c in e["changes"]]
-        self.assertEqual(1, kinds.count("split-needed"), kinds)
-
-    def test_a_register_already_split_is_not_touched_either(self):
-        p = Project({"OKR.md": self.PRE_SPLIT.replace(
-            "| To whom | By when | Status |",
-            "| To whom | Due | Status |")})
-        before = p.text("OKR.md")
-        p.run("apply")
-        self.assertEqual(before, p.text("OKR.md"))
-        _, out, _ = p.run()
-        kinds = [c["kind"] for e in out["files"] for c in e["changes"]]
-        self.assertNotIn("split-needed", kinds,
-                         "a migrated register was reported as needing it")
-
-    def test_chinese_pre_split_is_an_error_not_nothing_to_migrate(self):
-        p = Project({"OKR.md": self.PRE_SPLIT_CN}, config=CONFIG_ZH)
-        before = p.text("OKR.md")
-
-        dry_rc, dry, _ = p.run()
-        apply_rc, applied, _ = p.run("apply")
-
-        self.assertEqual((dry_rc, apply_rc), (1, 1))
-        self.assertEqual(before, p.text("OKR.md"))
-        dry_file = next(f for f in dry["files"] if f["path"] == "OKR.md")
-        apply_file = next(f for f in applied["files"] if f["path"] == "OKR.md")
-        self.assertEqual(dry_file["residual"], apply_file["residual"],
-                         "dry-run and apply classified the same cell differently")
-        self.assertEqual([f["rule"] for f in dry_file["residual"]],
-                         ["bad-typed-cell"])
-        self.assertFalse(dry_file["writable"])
-
-        rc, out, _ = p.run(json_out=False)
-        self.assertEqual(rc, 1)
-        self.assertNotIn("nothing to migrate", out)
-        self.assertIn("bad-typed-cell", out)
-
-    def test_migration_lint_uses_the_project_track_context(self):
-        split = self.PRE_SPLIT.replace("| By when |", "| Due |")
-        pipeline_bad = split.replace("within the track SLA", "2027-02-01") \
-                            .replace("2027-01-01", "3d")
-        queue_bad = split.replace("within the track SLA", "2027-02-01")
-        no_clock = self.TRACKS.replace("| ops | queue | commitments | intake -> doing | — | 5d |",
-                                       "| ops | queue | commitments | intake -> doing | — |  |")
-
-        for text, config, phrase in (
-                (pipeline_bad, self.TRACKS, "pipeline track requires"),
-                (queue_bad, no_clock, "queue track has no declared clock")):
-            with self.subTest(phrase=phrase):
-                p = Project({"OKR.md": text}, config=config)
-                rc, out, _ = p.run()
-                self.assertEqual(rc, 1)
-                residual = next(f for f in out["files"]
-                                if f["path"] == "OKR.md")["residual"]
-                self.assertEqual([f["rule"] for f in residual], ["bad-typed-cell"])
-                self.assertIn(phrase, residual[0]["message"])
-
-    def test_migration_lint_uses_localized_track_headers(self):
-        split = self.PRE_SPLIT.replace("| By when |", "| Due |")
-        pipeline_bad = split.replace("within the track SLA", "2027-02-01") \
-                            .replace("2027-01-01", "3d")
-        queue_bad = split.replace("within the track SLA", "2027-02-01")
-        tracks = (CONFIG_ZH +
-                  "\n## 轨道\n\n"
-                  "| 轨道 | 模式 | 时限 |\n"
-                  "|---|---|---|\n"
-                  "| ops | queue | 5d |\n"
-                  "| rel | pipeline | 10d |\n")
-        no_clock = tracks.replace("| ops | queue | 5d |",
-                                  "| ops | queue | |")
-
-        for text, config, phrase in (
-                (pipeline_bad, tracks, "pipeline track requires"),
-                (queue_bad, no_clock, "queue track has no declared clock")):
-            with self.subTest(phrase=phrase):
-                p = Project({"OKR.md": text}, config=config)
-                rc, out, _ = p.run()
-                self.assertEqual(rc, 1)
-                residual = next(f for f in out["files"]
-                                if f["path"] == "OKR.md")["residual"]
-                self.assertEqual([f["rule"] for f in residual], ["bad-typed-cell"])
-                self.assertIn(phrase, residual[0]["message"])
-
-
-class TestTheAssertionsAskWhatTheFileSays(unittest.TestCase):
-    """TASK-052. Every assertion in `losslessness()` answers *is it all still
-    there*. None answers *does it still mean that*, which is why thirty
-    mutations found none of § 9.
-
-    There is no single check that sees all three; `meaning()` is three readings,
-    each stating what it cannot see. These tests assert the split explicitly —
-    that the old assertions are silent on inputs the new ones refuse — because a
-    claim that a check is needed is only worth as much as the demonstration that
-    the existing ones do not make it.
-    """
-
-    BEFORE = ("# Board\n\n## P0 holding\n\n| ID | Meaning |\n|---|---|\n"
-              "| INV-* | investments |\n")
-    AFTER = ("# Board\n\n## P0 holding\n\n"
-             "| ID | Meaning | Title | Owner | Status | Next action | Evidence |\n"
-             "|---|---|---|---|---|---|---|\n"
-             "| INV-* | investments |  |  |  |  |  |\n")
-
-    def test_inventing_a_record_survives_every_losslessness_assertion(self):
-        """The premise of the whole task, asserted rather than argued."""
-        rewritten = [l for l in self.BEFORE.split("\n") if l.startswith("|")]
-        self.assertEqual(M.losslessness(self.BEFORE, self.AFTER, rewritten), [],
-                         "if this ever fails, `meaning()` has a cheaper twin")
-
-    def test_a_record_perry_could_not_read_before_and_reads_now_is_refused(self):
-        """`viewer/parsers` is the reader, not a second opinion written here."""
-        bad = M.meaning(self.BEFORE, self.AFTER, "BOARD.md", BOARD_SPEC, SCHEMA)
-        self.assertTrue(any("task(s) Perry did not read before" in b
-                            for b in bad), bad)
-
-    def test_widening_a_table_perry_recognises_changes_no_reading(self):
-        """The negative control. A check that refused every widening would pass
-        the test above and destroy the transform."""
-        before = ("# Board\n\n## P2\n\n| ID | Title | Owner | Status |\n"
-                  "|---|---|---|---|\n| ENG-9 | no DB isolation | User | done |\n")
-        after = ("# Board\n\n## P2\n\n"
-                 "| ID | Title | Owner | Status | Next action | Evidence |\n"
-                 "|---|---|---|---|---|---|\n"
-                 "| ENG-9 | no DB isolation | User | done |  |  |\n")
-        self.assertEqual(M.meaning(before, after, "BOARD.md", BOARD_SPEC, SCHEMA),
-                         [])
-
-    def test_a_sentence_that_gained_a_word_is_refused(self):
-        """Prose is not Perry's to write into, and this is the check that says
-        so without knowing which transform did it."""
-        before = ("# D\n\nBackground: the vendor contract Status: superseded "
-                  "by the 2025 MSA.\n\n> **Status**: draft\n")
-        after = before.replace("Status: superseded by",
-                               "Status: superseded | superseded by")
-        self.assertEqual(M.losslessness(
-            before, after, [l for l in before.split("\n") if l.strip()]), [],
-            "the byte-level assertions are silent on this")
-        bad = M.meaning(before, after, "design/DESIGN-001-x.md", DESIGN_SPEC,
-                        SCHEMA)
-        self.assertTrue(any("neither a table row nor part of the header block"
-                            in b for b in bad), bad)
-
-    def test_a_canonical_value_the_authors_own_words_deny_is_refused(self):
-        """The migration keeps the author's value beside the one it wrote, so
-        the file carries its own evidence and this can re-read it."""
-        before = "# D\n\n> **Status**: not yet locked — do not build\n"
-        after = "# D\n\n> **Status**: locked | not yet locked — do not build\n"
-        self.assertEqual(M.losslessness(
-            before, after, ["> **Status**: not yet locked — do not build"]), [],
-            "the byte-level assertions are silent on this")
-        bad = M.meaning(before, after, "design/DESIGN-001-x.md", DESIGN_SPEC,
-                        SCHEMA)
-        self.assertTrue(any("does not say" in b for b in bad), bad)
-
-    def test_a_cell_can_be_lost_while_every_character_survives(self):
-        """Why the cell count is checked on its own — and the test it did not
-        have. Replacing `cells(before) - cells(after)` with an empty `Counter()`
-        left all 823 tests green, so the assertion was unguarded: every input
-        that lost a cell also lost a character, a row or an id.
-
-        Two cells re-cut across the same boundary keep every character, every
-        `|`, the row, the section and the id set, and are not the same two
-        cells."""
-        before = "| ab | c |"
-        after = "| a | bc |"
-        bad = M.losslessness(before, after, [before])
-        self.assertEqual([b for b in bad if "cell(s)" not in b], [],
-                         "no other check may fire on this input")
-        self.assertTrue(any("cell(s) lost" in b for b in bad), bad)
-
-    def test_the_assertion_catches_what_the_transform_lets_through(self):
-        """Why it is an assertion and not a code review, and the only test that
-        can see the wiring: with the vocabulary test disabled — the defect
-        exactly as it shipped — the legend is widened and the file is refused
-        anyway, by the reading, before a byte is written.
-
-        Both lines of defence are now live, which is why every other test here
-        calls `meaning()` directly: the transforms no longer produce an input
-        for it. This one puts the defect back."""
-        p = Project({"BOARD.md": TestRecognitionIsByVocabularyNotByShape
-                     .LEGEND_BOARD})
-        real = M.is_the_schemas_table
-        M.is_the_schemas_table = lambda *a, **kw: True
-        try:
-            plan = p.plan()
-        finally:
-            M.is_the_schemas_table = real
-        e = edit_for(plan, "BOARD.md")
-        self.assertTrue(e.touched, "the transform must have widened the legend")
-        self.assertEqual(e.residual, [], "and taken it to zero shape errors")
-        self.assertTrue(any("did not read before" in v for v in e.violations),
-                        e.violations)
-        self.assertFalse(e.writable, "an assertion nobody acts on is a comment")
-
-    def test_a_refused_file_is_left_byte_identical_and_the_reason_is_printed(self):
-        p = Project({"BOARD.md": TestRecognitionIsByVocabularyNotByShape
-                     .LEGEND_BOARD})
-        rc, out, _ = p.run(json_out=False)
-        self.assertIn("left byte-identical", out)
-
-
-class TestAMigratedIdIsReadableByItsOwnReader(unittest.TestCase):
-    """Migration wrote an id `perry-lint --provenance` could not see.
-
-    `fix_missing_fields` matched the surrounding block's bold style, so a digest
-    whose neighbours are bolded got `> **Id**:SRC-n`, and the provenance check
-    anchors `^>\\s*Id\\s*[::]` literally. Measured on a migrated copy of a real
-    project: 3 of 15 provenance findings were files migration had **just given
-    an id to**, every one then declared conformant. Migration minted an id
-    nothing could cite, which is the one thing the id is for.
-
-    `header_block_end`'s docstring already named the hazard and named it one
-    case too narrow — "a digest whose neighbours are *plain* must get a plain
-    line" — when the dangerous case is neighbours who are bold.
-
-    The fixture is deliberately **not** Perry-generated: a block Perry starts is
-    never bolded, so a Perry-shaped fixture cannot reach this branch at all.
-    That is `TASK-044-spec.md`'s governing sentence, and it is why 30 mutations
-    walked past this.
-    """
-
-    DIGEST = ("# A digest someone else wrote\n"
-              "\n"
-              "> **Origin**: https://example.invalid/paper\n"
-              "> **Fetched**: 2026-01-02\n"
-              "\n"
-              "Body prose that is not Perry's.\n")
-
-    def test_a_bolded_neighbour_block_still_gets_a_readable_id(self):
-        p = Project({"knowledge/research/digest.md": self.DIGEST})
-        rc, _, err = p.run("apply")
-        self.assertEqual(rc, 0, err)
-        text = p.text("knowledge/research/digest.md")
-        self.assertNotIn("**Id**", text, f"the id was bolded:\n{text}")
-        self.assertRegex(text, r"(?m)^>\s*Id\s*[::]\s*SRC-\d+",
-                         f"not in the form its reader anchors on:\n{text}")
-
-    def test_the_authors_own_bold_style_is_left_alone(self):
-        """Perry stops bolding its OWN field. It does not un-bold theirs."""
-        p = Project({"knowledge/research/digest.md": self.DIGEST})
-        p.run("apply")
-        text = p.text("knowledge/research/digest.md")
-        self.assertIn("**Origin**", text)
-        self.assertIn("**Fetched**", text)
-
-
-class TestTheRootIsRequiredNotDefaulted(unittest.TestCase):
-    """**`bin/perry-migrate`'s half of the shape** — see the class of the same
-    name in `tests/test_conformance.py`.
-
-    Round 4 gave `perry-conform`'s two entry points a keyword-only parameter
-    with no default and argued for the shape, and then gave `bin/perry-migrate`
-    three parameters that all kept a silent default:
-
-        apply_plan       (plan, schema, declare=True, root_arg=None)
-        rollback_message (point, key, why, allow_changed=None, root_arg=None)
-        do_restore       (project_root, positional, do_list, as_json, root_arg=None)
-
-    The V4 round-4 reviewer's R-N3 and R-N4 are what that cost: two of
-    `apply_plan`'s three rollback sites could drop the root with the whole of
-    both modules green, because every test called `apply_plan(plan, SCHEMA)`
-    and `None` was `None` on both sides.
-
-    **`apply_plan` and `render` are asserted to have NO such parameter.** A
-    required parameter can still be filled with a value that disagrees with
-    the plan; reading it off `plan.root_arg` means there is no second place to
-    say it. That is the difference between "you must answer" and "there is
-    only one answer".
-    """
-
-    def assert_required_keyword(self, fn, name="root_arg"):
-        sig = inspect.signature(fn)
-        self.assertIn(name, sig.parameters, f"{fn.__name__}{sig}: no `{name}`")
-        param = sig.parameters[name]
-        self.assertIs(param.kind, inspect.Parameter.KEYWORD_ONLY,
-                      f"{fn.__name__}{sig}: `{name}` is not keyword-only")
-        self.assertIs(
-            param.default, inspect.Parameter.empty,
-            f"{fn.__name__}{sig}: `{name}` has a default, so a caller that "
-            f"has a root can decline to pass it and nothing says so")
-
-    def test_every_function_that_hands_back_a_command_requires_the_root(self):
-        for fn in (M.plan_project, M.rollback_message, M.do_restore,
-                   M.fix_tables, M.migrate_text):
-            with self.subTest(fn=fn.__name__):
-                self.assert_required_keyword(fn)
-
-    def test_the_plan_carries_the_root_and_cannot_be_built_without_one(self):
-        field = inspect.signature(M.Plan).parameters.get("root_arg")
-        self.assertIsNotNone(
-            field, "`Plan` does not carry the root the caller typed, so every "
-                   "refusal raised while planning is back to having no root "
-                   "in scope — which is what `§ 10.9` excused two members of "
-                   "the class on")
-        self.assertIs(
-            field.default, inspect.Parameter.empty,
-            "`Plan.root_arg` has a default, so a plan can exist without an "
-            "answer and `plan_project`'s required parameter guards nothing")
-
-    def test_apply_plan_and_render_have_no_root_of_their_own(self):
-        """There is one root per plan and no second place to disagree."""
-        for fn in (M.apply_plan, M.render):
-            with self.subTest(fn=fn.__name__):
-                self.assertNotIn(
-                    "root_arg", inspect.signature(fn).parameters,
-                    f"{fn.__name__} takes a root separately from the plan it "
-                    f"is given. A caller can then pass one that disagrees "
-                    f"with `plan.root_arg`, or — as round 4 shipped — pass "
-                    f"nothing and get `None`")
-
-
-class TestAFailedWriteIsRecoverableAndSaysSo(unittest.TestCase):
-    """Guarantee 3 of `TASK-044-spec.md`: the recovery path is **named in the
-    output**, and shown working rather than described.
-
-    Only a write that *landed wrong* was handled. A write that **fails** — a
-    read-only file, a full disk, a permission revoked mid-run — propagated as an
-    unhandled traceback: a stranger's project left N-of-M migrated, the restore
-    point on disk and **never named**, the declaration never run, and empty
-    stdout. A traceback names nothing.
-    """
-
-    def project(self) -> Project:
-        return Project(files={
-            "BOARD.md": LEGACY_BOARD,
-            "knowledge/research/a.md": "# A digest\n\nBody.\n",
-            "knowledge/research/b.md": "# Another digest\n\nBody.\n",
-        })
-
-    def test_a_failing_write_rolls_back_and_names_the_restore_command(self):
-        p = self.project()
-        before = p.tree()
-        target = None
-        for e in M.plan_project(p.root, p.root, SCHEMA,
-                                root_arg=str(p.root)).writable:
-            target = e.path
-            break
-        self.assertIsNotNone(target, "nothing writable in the fixture")
-
-        real = M.write_atomic
-        calls = {"n": 0}
-
-        def flaky(path, text):
-            calls["n"] += 1
-            if calls["n"] == 2:          # fail PART WAY, not on the first file
-                raise PermissionError(13, "Permission denied", str(path))
-            return real(path, text)
-
-        M.write_atomic = flaky
-        try:
-            with self.assertRaises(M.Refused) as caught:
-                M.apply_plan(M.plan_project(p.root, p.root, SCHEMA,
-                                            root_arg=str(p.root)), SCHEMA)
-        finally:
-            M.write_atomic = real
-
-        msg = str(caught.exception)
-        self.assertIn("perry-migrate restore", msg,
-                      f"the recovery command is not named:\n{msg}")
-        self.assertIn("Restore point:", msg,
-                      f"the restore point path is not named:\n{msg}")
-        # **Named is not enough; it has to be the reader's own project.** This
-        # is the V4 round-4 reviewer's R-N3: `root_arg=None` here was GREEN
-        # across both modules, because the test built its plan without a root.
-        assert_every_command_carries(
-            self, msg, p.root,
-            "the refusal raised when a write fails part way")
-        # Compare the files that existed before. The restore point itself is
-        # NEW and must survive the rollback — it is the thing the refusal just
-        # told the user to run, and deleting it would make the message a lie.
-        after = {k: v for k, v in p.tree().items()
-                 if not k.startswith(".perry/migrate/")}
-        self.assertEqual(after, before,
-                         "the project was left half-migrated")
-        self.assertTrue(
-            [k for k in p.tree() if k.startswith(".perry/migrate/")],
-            "the restore point the refusal names was not kept")
-
-    def test_a_write_that_lands_wrong_names_the_way_back_with_the_root(self):
-        """**The digest-mismatch path, which no test reached** (round 5).
-
-        `apply_plan` calls `rollback_message` from three places. Round 4
-        threaded the caller's root into all three and the V4 round-4 reviewer
-        then removed it from two of them — this one and the write-failed path
-        above — with the whole of `tests.test_migrate` and
-        `tests.test_conformance` **GREEN** (its R-N3 and R-N4).
-
-        Green for the reason the round-3 FAIL was invisible: every test that
-        reached these paths built its plan with no root at all, so `None` was
-        `None` on both sides of the mutation and the assertion about the
-        handed-back command was being made from inside a run no reader ever
-        has. The fixtures pass the root now, and this path had no test at all,
-        so it gets one.
-
-        The mismatch is made the way it happens in the world: the bytes on
-        disk after the write are not the bytes the plan printed."""
-        p = self.project()
-        real = M.write_atomic
-        calls = {"n": 0}
-
-        def tamper(path, text):
-            calls["n"] += 1
-            if calls["n"] == 1:
-                # Published bytes that are not the plan's image, and the
-                # digest of what was really published — so `published == got`
-                # and the run takes the `allow_changed` branch.
-                tail = "\n<not what the plan said>\n"
-                return real(path, text + (tail.encode()
-                                          if isinstance(text, bytes) else tail))
-            return real(path, text)
-
-        M.write_atomic = tamper
-        try:
-            with self.assertRaises(M.Refused) as caught:
-                M.apply_plan(M.plan_project(p.root, p.root, SCHEMA,
-                                            root_arg=str(p.root)), SCHEMA)
-        finally:
-            M.write_atomic = real
-
-        msg = str(caught.exception)
-        self.assertIn("does not match the plan", msg,
-                      f"this is not the digest-mismatch path: {msg}")
-        assert_every_command_carries(
-            self, msg, p.root,
-            "the refusal raised when a write lands with the wrong digest")
-
-    def test_the_restore_point_is_named_even_when_the_rollback_also_fails(self):
-        """The worst case must not be the one that says nothing. `undo` writes,
-        so the failure that broke the run can break the repair — and then the
-        user needs the path most."""
-        point = Path(tempfile.mkdtemp()) / "2026-01-01-000000.json"
-        point.write_text("{}")
-        real = M.undo
-        M.undo = lambda _p, **_kwargs: (_ for _ in ()).throw(
-            PermissionError(13, "Permission denied"))
-        try:
-            msg = M.rollback_message(point, "BOARD.md", "boom",
-                                     root_arg=str(point.parent))
-        finally:
-            M.undo = real
-        self.assertIn("rollback also failed", msg)
-        self.assertIn("perry-migrate restore 2026-01-01-000000", msg)
-        self.assertIn("still migrated", msg)
-
-
-class TestIOFailuresAreStructuredRefusals(unittest.TestCase):
-    def test_scratch_copy_failure_is_a_refusal_during_planning(self):
-        p = Project({"BOARD.md": LEGACY_BOARD,
-                     "design/DESIGN-001-x.md": LEGACY_DESIGN})
-        before = p.tree()
-        real = M.shutil.copy2
-
-        def denied(*_args, **_kwargs):
-            raise PermissionError(13, "scratch denied")
-
-        M.shutil.copy2 = denied
-        try:
-            with self.assertRaises(M.Refused) as caught:
-                M.plan_project(p.root, p.root, SCHEMA,
-                               root_arg=str(p.root))
-        finally:
-            M.shutil.copy2 = real
-
-        self.assertIn("scratch", str(caught.exception).lower())
-        self.assertEqual(p.tree(), before)
-
-    def test_invalid_utf8_is_refused_without_changing_the_file(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        board = p.root / "BOARD.md"
-        board.write_bytes(b"# Board\n\xff\xfe\n")
-        before = board.read_bytes()
-
-        rc, out, err = p.run("apply")
-
-        self.assertEqual(rc, 1, (out, err))
-        self.assertIn("not valid UTF-8", out["refused"])
-        self.assertNotIn("Traceback", err)
-        self.assertEqual(board.read_bytes(), before)
-
-    def test_malformed_restore_json_is_refused_for_restore_and_list(self):
-        for args in (("restore", "broken"), ("restore", "--list")):
-            with self.subTest(args=args):
-                p = Project({"BOARD.md": LEGACY_BOARD})
-                point = p.root / ".perry" / "migrate" / "broken.json"
-                point.parent.mkdir(parents=True)
-                point.write_text("{broken")
-
-                rc, out, err = p.run(*args)
-
-                self.assertEqual(rc, 1, (out, err))
-                self.assertIn("restore payload", out["refused"])
-                self.assertNotIn("Traceback", err)
-
-
-class TestNothingWritableIsNotACrash(unittest.TestCase):
-    def test_apply_on_a_project_with_no_writable_edit_returns_a_run_key(self):
-        """It raised `KeyError: 'run'` in the renderer — a traceback where
-        "there was nothing to do" belongs."""
-        p = Project(files={"BOARD.md": M.render_board_from_template()
-                           if hasattr(M, "render_board_from_template")
-                           else LEGACY_BOARD})
-        plan = M.plan_project(p.root, p.root, SCHEMA,
-                              root_arg=str(p.root))
-        plan.edits = [e for e in plan.edits if False]
-        out = M.apply_plan(plan, SCHEMA)
-        self.assertIn("run", out)
-        self.assertEqual(out["applied"], [])
-
-
-class TestPositionIsNotEvidence(unittest.TestCase):
-    """The fourth instance of one defect, and the branch that produced it.
-
-    `is_header_block` had two ways to qualify, and the second was **position**:
-    *"it opens immediately under the H1, which is where the template puts it."*
-    Its own first paragraph already said position cannot decide this —
-    *"sentences are field-shaped; only the vocabulary tells them apart"* — and
-    the fallback said it anyway.
-
-    So a real digest whose author opens with a seed thesis in a blockquote
-    directly under the H1 got Perry's four fields appended to the end of that
-    paragraph. No character lost; the meaning changed. `ADR-004`'s named
-    failure mode: *"a board that still parses and no longer reads like
-    theirs."*
-
-    Every fixture here is somebody else's writing. A Perry-generated one cannot
-    reach this branch, which is `TASK-044-spec.md`'s governing sentence and the
-    reason thirty mutations walked past it.
-    """
-
-    THESIS = ("# 某人写的综述\n"
-              "\n"
-              "> **种子观点**:一段很长的引用,作者自己的论点,"
-              "不是任何字段,也不属于 Perry。\n"
-              ">\n"
-              "> 第二段,同一个引用块里。\n"
-              "\n"
-              "## 正文\n"
-              "\n"
-              "内容。\n")
-
-    def test_a_thesis_under_the_h1_is_not_a_header_block(self):
-        p = Project({"knowledge/research/survey.md": self.THESIS})
-        rc, _, err = p.run("apply")
-        self.assertEqual(rc, 0, err)
-        text = p.text("knowledge/research/survey.md")
-        thesis_line = next(l for l in text.split("\n") if "种子观点" in l)
-        after = text.split(thesis_line, 1)[1].split("\n")
-        # The line after the author's opening line must still be their own.
-        self.assertNotRegex(
-            after[1] if len(after) > 1 else "",
-            r"^>\s*\**\s*(Id|Source|Received|Status)",
-            f"Perry wrote into the author's paragraph:\n{text}")
-
-    def test_perrys_fields_land_in_a_block_of_their_own(self):
-        p = Project({"knowledge/research/survey.md": self.THESIS})
-        p.run("apply")
-        lines = p.text("knowledge/research/survey.md").split("\n")
-        i = next(i for i, l in enumerate(lines) if l.startswith("> Id:"))
-        # its block must not contain the thesis
-        j = i
-        while j > 0 and lines[j - 1].strip().startswith(">"):
-            j -= 1
-        k = i
-        while k + 1 < len(lines) and lines[k + 1].strip().startswith(">"):
-            k += 1
-        block = "\n".join(lines[j:k + 1])
-        self.assertNotIn("种子观点", block)
-
-    def test_all_four_fields_stay_together(self):
-        """The half that only appeared once Perry started its own block: a
-        blank line did not end a `>` run, so the span reached across it into
-        the author's quote and the SECOND field onwards landed in their
-        paragraph — the same defect, one field later."""
-        p = Project({"knowledge/research/survey.md": self.THESIS})
-        p.run("apply")
-        lines = p.text("knowledge/research/survey.md").split("\n")
-        idx = [i for i, l in enumerate(lines)
-               if re.match(r"^>\s*(Id|Source|Received|Status)\s*:", l)]
-        self.assertEqual(len(idx), 4, lines)
-        self.assertEqual(idx, list(range(idx[0], idx[0] + 4)),
-                         "the fields were split across blocks")
-
-    def test_a_real_header_block_is_still_joined(self):
-        """The vocabulary branch is the one that survived, so a block that
-        names a declared field is still written into rather than duplicated."""
-        p = Project({"knowledge/research/d.md":
-                     "# A digest\n\n> Source: https://example.invalid\n\nBody.\n"})
-        p.run("apply")
-        text = p.text("knowledge/research/d.md")
-        self.assertEqual(text.count("> Source:"), 1)
-        self.assertIn("> Id:", text)
-        lines = text.split("\n")
-        i = lines.index("> Source: https://example.invalid")
-        j = i
-        while j + 1 < len(lines) and lines[j + 1].strip().startswith(">"):
-            j += 1
-        while i > 0 and lines[i - 1].strip().startswith(">"):
-            i -= 1
-        self.assertTrue(
-            any(l.startswith("> Id:") for l in lines[i:j + 1]),
-            f"a second block was started beside a real header block:\n{text}")
-
-    def test_the_new_block_is_written_in_one_language(self):
-        """A joined block inherits its neighbours' spelling; a new one uses the
-        schema's. The mixed `Id:` / `状态:` block that appeared on the real
-        file was a symptom of joining, and starting a block removes it."""
-        p = Project({"knowledge/research/survey.md": self.THESIS})
-        p.run("apply")
-        text = p.text("knowledge/research/survey.md")
-        self.assertNotIn("状态:", text)
-        self.assertIn("> Status:", text)
-
-
-class TestAReadOnlyFileDoesNotCrashPlanning(unittest.TestCase):
-    """The crash was one stage upstream of where the first fix guarded.
-
-    `apply_plan` was given a `try/except OSError` that rolls back and names the
-    restore point. A V4 reviewer then found the real crash site: `cross_file_delta`
-    builds a scratch **mirror** of the state tree during PLANNING, with
-    `shutil.copy2`/`copytree`, which preserve mode bits — so a file the project
-    has marked read-only produced a read-only copy in a directory Perry owns,
-    and the next line wrote to it.
-
-    That is worse than the one that was fixed first: at plan time **there is no
-    restore point yet**, so the traceback was the whole of what the user got,
-    and it killed `--dry-run` as well as `apply` — the command whose entire
-    promise is that it writes nothing.
-
-    Preserving the mode of a throwaway mirror buys nothing.
-    """
-
-    FILES = {"BOARD.md": LEGACY_BOARD,
-             "knowledge/research/a.md": "# A digest\n\nBody.\n"}
-
-    def read_only_project(self) -> "Project":
-        # Kept on `self` so the `Project`'s TemporaryDirectory outlives the
-        # test body; and no chmod-back cleanup, because a cleanup that runs
-        # after the temp dir is collected is a FileNotFoundError pretending to
-        # be a test failure — which is what the first version of this did.
-        self._p = Project(files=dict(self.FILES))
-        (self._p.root / "BOARD.md").chmod(0o444)
-        return self._p
-
-    def test_a_dry_run_survives_a_read_only_file(self):
-        p = self.read_only_project()
-        rc, out, err = p.run("", json_out=False)
-        self.assertEqual(rc, 0, f"planning crashed:\n{err}")
-        self.assertNotIn("Traceback", err)
-
-    def test_apply_survives_it_too(self):
-        p = self.read_only_project()
-        rc, _, err = p.run("apply", json_out=False)
-        self.assertEqual(rc, 0, f"apply crashed:\n{err}")
-        self.assertNotIn("Traceback", err)
-
-    def test_a_read_only_file_IS_migrated_and_that_is_recorded_not_asserted(self):
-        """**This asserts what happens, not what I assumed happened.**
-
-        My first version asserted the file is left alone, on the reasoning that
-        `plan.writable` would exclude it. It does not: `write_atomic` writes a
-        `.tmp` and calls `Path.replace`, and a **rename needs write permission
-        on the directory, not on the target** — so a file the user marked
-        read-only is migrated like any other.
-
-        Whether that is right is a policy question about somebody else's files
-        and it is not decided here. It is on the board as its own row. What is
-        pinned is the behaviour, so the day it changes, it changes on purpose.
-        """
-        p = self.read_only_project()
-        before = (p.root / "BOARD.md").read_bytes()
-        p.run("apply", json_out=False)
-        self.assertNotEqual((p.root / "BOARD.md").read_bytes(), before,
-                            "behaviour changed — see the row on the read-only "
-                            "policy before updating this test")
-
-    def test_the_restore_point_carries_the_read_only_file_too(self):
-        """Whatever the policy turns out to be, the recovery path must cover a
-        file Perry wrote — that is guarantee 3, and it is what makes the
-        current behaviour survivable rather than merely undetected."""
-        p = self.read_only_project()
-        before = (p.root / "BOARD.md").read_bytes()
-        p.run("apply", json_out=False)
-        points = sorted((p.root / ".perry" / "migrate").glob("*.json"))
-        self.assertTrue(points, "no restore point was written")
-        payload = json.loads(points[-1].read_text())
-        self.assertIn("BOARD.md", payload["files"])
-        self.assertEqual(M.image_bytes(payload["files"]["BOARD.md"], "BOARD.md"),
-                         before)
-
-    def test_the_rest_of_the_project_still_migrates(self):
-        """One unwritable file must not stop the run — guarantee 5, partial
-        migration is a state rather than a failure."""
-        p = self.read_only_project()
-        p.run("apply", json_out=False)
-        self.assertIn("> Id:", p.text("knowledge/research/a.md"))
-
-
-class TestTheOverriddenReadOnlyBitIsReported(unittest.TestCase):
-    """TASK-079 · V4 — the plan names a permission the run crossed.
-
-    The class above pins the *behaviour*: a file whose owner-write bit is
-    cleared is migrated like any other, because `write_atomic` renames over it
-    and a rename needs write permission on the directory. What was missing is
-    that the plan said nothing about it, while `TASK-044-spec` requires the run
-    to be "not silent — every file it touched, listed, with what changed in
-    each".
-
-    So these tests are about the *report* and deliberately not about the
-    policy. Whether migration should refuse such a file is `USER-004` and is
-    open; every assertion below would still hold under either answer except the
-    ones that pin today's behaviour, and those are here so that the day it
-    changes, it changes on purpose.
-    """
-
-    #: One file with the bit cleared and one without — both needing migration,
-    #: so that "names the first and not the second" is a statement about the
-    #: mode and not about which files were touched.
-    FILES = {"BOARD.md": LEGACY_BOARD,
-             "knowledge/research/a.md": "# A digest\n\nBody.\n"}
-
-    #: The words the observation may not use, in ONE place, because the plan
-    #: has two surfaces that say the same thing — the rendered note and the
-    #: `--json` `read_only_override` — and a second copy of this list is a
-    #: second thing to forget. That is the drift `READ_ONLY_MECHANISM` already
-    #: exists to prevent for the sentence itself; the guard over the sentence
-    #: has to be single-sourced for the same reason.
-    POLICY_WORDS = ("should", "must", "refus", "chmod", "instead", "unsafe",
-                    "warning", "error")
-
-    def project(self, mode: int = 0o444) -> "Project":
-        # Kept on `self` for the same reason as the class above: the
-        # TemporaryDirectory must outlive the test body, and chmod-ing back
-        # after it is collected raises a FileNotFoundError that looks like a
-        # test failure.
-        self._p = Project(files=dict(self.FILES))
-        (self._p.root / "BOARD.md").chmod(mode)
-        return self._p
-
-    def legacy_store_project(self) -> "Project":
-        """A project where the file with the bit cleared is `tasks.jsonl`.
-
-        The store has to have a rewrite waiting for it, or there is no override
-        to report: a file the plan leaves byte-identical crosses no permission,
-        which is what `test_a_file_left_byte_identical_is_not_reported_as_...`
-        pins. So the store is written by the shipped writer and then aged back
-        to how a pre-TASK-106 Perry left it — no `summary` key. That record is
-        still valid, projects identically to the board, and acquires the
-        explicit empty summary at the validation boundary, so migration plans
-        the canonical rewrite and nothing is refused.
-
-        BOARD.md keeps its ordinary mode here. The store is then the only file
-        in the run whose bit is cleared, so "names it" is a statement about the
-        store and not about whichever file happened to be first.
-        """
-        self._p = Project(files=dict(self.FILES))
-        made = subprocess.run(
-            [sys.executable, str(TASKS), "write", "--from-board",
-             "--root", str(self._p.root)], capture_output=True, text=True)
-        self.assertEqual(made.returncode, 0, made.stderr)
-        store = self._p.root / "tasks.jsonl"
-        records = [json.loads(line) for line in
-                   store.read_text(encoding="utf-8").splitlines() if line.strip()]
-        self.assertTrue(records, "the fixture wrote an empty store")
-        store.write_text(
-            "".join(json.dumps({k: v for k, v in record.items() if k != "summary"},
-                               ensure_ascii=False) + "\n" for record in records),
-            encoding="utf-8")
-        # No chmod-back, for the reason `project` gives above.
-        store.chmod(0o444)
-        return self._p
-
-    def assert_takes_no_position(self, text: str, where: str) -> None:
-        """`text` reports an observation and does not answer USER-004."""
-        lowered = text.lower()
-        for word in self.POLICY_WORDS:
-            self.assertNotIn(word, lowered, f"{where} takes a position: {text}")
-
-    @staticmethod
-    def notes(out: str) -> list[str]:
-        return [l.strip() for l in out.split("\n") if l.strip().startswith("! ")]
-
-    @staticmethod
-    def entry(out: str, key: str) -> list[str]:
-        """The lines the per-file list prints under one file, up to the next."""
-        lines = out.split("\n")
-        at = next(i for i, l in enumerate(lines) if l.strip().endswith(")")
-                  and f" {key}  (" in l)
-        end = next((i for i in range(at + 1, len(lines))
-                    if lines[i].startswith("   ") and not lines[i].startswith("    ")),
-                   len(lines))
-        return lines[at:end]
-
-    def test_the_dry_run_names_the_read_only_file_and_not_the_ordinary_one(self):
-        p = self.project()
-        rc, out, err = p.run(json_out=False)
-        self.assertEqual(rc, 0, err)
-        board = self.entry(out, "BOARD.md")
-        self.assertTrue(any("read-only for its owner (mode 0444)" in l
-                            for l in board),
-                        f"the dry run did not name the mode:\n{out}")
-        self.assertEqual(self.notes(out), self.notes("\n".join(board)),
-                         "the ordinary file was reported too")
-        self.assertEqual(
-            self.notes("\n".join(self.entry(out, "knowledge/research/a.md"))), [])
-
-    def test_the_note_is_the_first_thing_under_that_file_in_the_list(self):
-        """"In the same list" is the point: this is not a separate warning
-        block at the end, it is the per-file entry TASK-044 already asks for."""
-        p = self.project()
-        _, out, _ = p.run(json_out=False)
-        self.assertTrue(self.entry(out, "BOARD.md")[1].strip().startswith("! "),
-                        f"the note is not in the file's own entry:\n{out}")
-
-    def test_the_applied_run_names_it_in_the_same_place(self):
-        p = self.project()
-        rc, out, err = p.run("apply", json_out=False)
-        self.assertEqual(rc, 0, err)
-        entry = self.entry(out, "BOARD.md")
-        self.assertTrue(entry[1].strip().startswith("! "), out)
-        self.assertIn("read-only for its owner (mode 0444)", entry[1])
-        self.assertIn("replaced anyway", entry[1])
-
-    def test_the_file_it_names_was_in_fact_migrated(self):
-        """The report is a report. Behaviour is unchanged — asserted on the
-        bytes, because a note about an override that did not happen would be a
-        worse defect than the silence it replaced."""
-        p = self.project()
-        before = (p.root / "BOARD.md").read_bytes()
-        _, out, _ = p.run("apply", json_out=False)
-        after = (p.root / "BOARD.md").read_bytes()
-        self.assertNotEqual(after, before)
-        self.assertIn("## P0", after.decode())
-        self.assertIn("read-only for its owner", out)
-        self.assertEqual(os.stat(p.root / "BOARD.md").st_mode & 0o777, 0o444,
-                         "the note says the mode is left as found")
-
-    def test_the_restore_point_still_carries_its_original_bytes(self):
-        """Guarantee 3 for exactly the file this task made visible: making the
-        override reportable must not move it out of the recovery path."""
-        p = self.project()
-        before = (p.root / "BOARD.md").read_bytes()
-        p.run("apply", json_out=False)
-        points = sorted((p.root / ".perry" / "migrate").glob("*.json"))
-        self.assertTrue(points, "no restore point was written")
-        payload = json.loads(points[-1].read_text())
-        self.assertEqual(
-            M.image_bytes(payload["files"]["BOARD.md"], "BOARD.md"), before)
-
-    def test_a_project_with_no_such_file_reads_exactly_as_it_did_before(self):
-        """No new noise on the ordinary path — asserted by difference, not by
-        eyeballing. The same project is planned twice, once with the bit and
-        once without, and the only thing the first says more than the second is
-        the note itself. Byte-identical everywhere else."""
-        p = self.project(mode=0o444)
-        _, marked, _ = p.run(json_out=False)
-        (p.root / "BOARD.md").chmod(0o644)
-        _, ordinary, _ = p.run(json_out=False)
-        self.assertNotIn("read-only", ordinary)
-        without = "\n".join(l for l in marked.split("\n")
-                            if not l.strip().startswith("! "))
-        self.assertEqual(without, ordinary)
-
-    def test_the_json_plan_carries_it_and_adds_nothing_when_there_is_none(self):
-        p = self.project(mode=0o444)
-        _, marked, _ = p.run()
-        (p.root / "BOARD.md").chmod(0o644)
-        _, ordinary, _ = p.run()
-        board = next(f for f in marked["files"] if f["path"] == "BOARD.md")
-        self.assertEqual(board["read_only_override"]["mode"], "0444")
-        self.assertIn("read-only for its owner",
-                      board["read_only_override"]["observed"])
-        self.assertNotIn("read_only_override", json.dumps(ordinary),
-                         "the ordinary plan gained a key")
-        strip = lambda d: [{k: v for k, v in f.items()
-                            if k != "read_only_override"} for f in d["files"]]
-        self.assertEqual(strip(marked), strip(ordinary))
-
-    def test_a_file_left_byte_identical_is_not_reported_as_overridden(self):
-        """A read-only file the plan cannot migrate crosses no permission.
-        Naming it would report an override that never happened — the same
-        defect as the silence, pointing the other way."""
-        p = Project(files={"BOARD.md": UNRESOLVABLE_BOARD})
-        self._p = p
-        before = (p.root / "BOARD.md").read_bytes()
-        (p.root / "BOARD.md").chmod(0o444)
-        rc, out, _ = p.run("apply", json_out=False)
-        self.assertEqual(rc, 1)
-        self.assertEqual((p.root / "BOARD.md").read_bytes(), before)
-        self.assertNotIn("read-only for its owner", out)
-
-    def test_the_dry_run_and_the_applied_run_report_the_same_observation(self):
-        """§ 1: one computation, not two. The mode is read once, at plan time,
-        so the preview cannot name a mode the run then contradicts."""
-        p = self.project()
-        _, dry, _ = p.run()
-        _, real, _ = p.run("apply")
-        pick = lambda d: [(f["path"], f.get("read_only_override"))
-                          for f in d["files"]]
-        self.assertEqual(pick(dry), pick(real))
-
-    def test_the_wording_states_what_was_observed_not_what_should_happen(self):
-        """The policy is USER-004 and this task does not settle it, so the
-        sentence may not lean: no "should", no "refuse", no advice to chmod."""
-        p = self.project()
-        _, out, _ = p.run(json_out=False)
-        self.assert_takes_no_position(self.notes(out)[0], "the note")
-
-    def test_the_json_wording_states_what_was_observed_too(self):
-        """TASK-115 — the same guard over the OTHER surface.
-
-        The test above reads the rendered note only. A V4 reviewer mutated the
-        `--json` plan's `read_only_override.observed` to "…you should chmod
-        it…" and all ten tests here stayed green: a policy word could enter
-        through the machine-readable plan and nothing said so, which is the
-        deliverable this task's wording guard most depends on.
-
-        Every override in the plan is checked, not just BOARD.md's — the words
-        are banned from the observation, wherever it is made — and against the
-        one `POLICY_WORDS` above rather than a copy, so the two surfaces cannot
-        end up guarded against different lists.
-        """
-        p = self.project()
-        _, plan, _ = p.run()
-        overrides = [(f["path"], f["read_only_override"]) for f in plan["files"]
-                     if "read_only_override" in f]
-        self.assertTrue(overrides, f"the plan reported no override: {plan}")
-        for path, override in overrides:
-            self.assert_takes_no_position(
-                json.dumps(override, ensure_ascii=False),
-                f"the JSON override for {path}")
-
-    def test_the_read_only_task_store_is_reported_in_both_surfaces(self):
-        """TASK-115 — the store is a reported file and was the untested one.
-
-        `tasks.jsonl` is written by migration like any other file in the
-        per-file list, so deliverable 1 covers it. It was the only file in that
-        list with nothing behind it: deleting `read_only_mode=owner_read_only(
-        store_path)` from `_plan_task_store` left all ten tests green, and a
-        read-only store would have lost its report in silence.
-        """
-        p = self.legacy_store_project()
-        rc, out, err = p.run(json_out=False)
-        self.assertEqual(rc, 0, err)
-        entry = self.entry(out, "tasks.jsonl")
-        self.assertTrue(entry[1].strip().startswith("! "),
-                        f"the store's entry carries no note:\n{out}")
-        self.assertIn("read-only for its owner (mode 0444)", entry[1])
-        self.assertEqual(self.notes(out), self.notes("\n".join(entry)),
-                         "a file whose bit was not cleared was reported too")
-        _, plan, _ = p.run()
-        store = next(f for f in plan["files"] if f["path"] == "tasks.jsonl")
-        self.assertEqual(store["read_only_override"]["mode"], "0444")
-        self.assertIn("read-only for its owner",
-                      store["read_only_override"]["observed"])
-
-    def test_the_task_store_it_names_was_in_fact_rewritten(self):
-        """The store's counterpart to `test_the_file_it_names_was_in_fact_
-        migrated`: asserted on the bytes, because a note about an override that
-        did not happen would be a worse defect than the silence it replaced."""
-        p = self.legacy_store_project()
-        store = p.root / "tasks.jsonl"
-        before = store.read_bytes()
-        rc, out, err = p.run("apply", json_out=False)
-        self.assertEqual(rc, 0, err)
-        self.assertNotEqual(store.read_bytes(), before)
-        self.assertIn('"summary"', store.read_text(encoding="utf-8"))
-        self.assertIn("read-only for its owner", out)
-        self.assertEqual(os.stat(store).st_mode & 0o777, 0o444,
-                         "the note says the mode is left as found")
-
-
-class TestEveryWriteSiteIsGuarded(unittest.TestCase):
-    """**Three rounds each guarded the site it had seen, not the class.**
-
-    Round 1 caught the edit loop. Round 2 found the crash was one stage
-    upstream, in planning's scratch mirror. Round 3 stopped guessing and
-    **enumerated all five places migration writes to a project** — and found
-    three of them unguarded, including the recovery path itself.
-
-    That enumeration is the fix, not the three patches. This test is it, made
-    standing: every call that writes must sit inside an `OSError` handler, so a
-    sixth write site cannot be added unguarded without failing here.
-
-    The axis all three rounds missed until the enumeration: a read-only
-    **directory**, not a read-only file. `write_atomic` renames, and a rename
-    needs write permission on the *directory* — which is why every existing
-    test, all driving read-only through a file, passed throughout.
-    """
-
-    #: Attribute/function names whose call puts bytes into the user's project.
-    WRITES = {"write_atomic", "declare", "restore_point", "undo", "unlink",
-              "write_text", "chmod", "replace", "mkdir"}
-
-    #: Functions that ARE the guarded body — the `try` is around their callers,
-    #: so calls inside them are covered by that.
-    INSIDE_GUARDED = {"write_atomic", "undo", "restore_point", "render",
-                      "decode_image", "encode_image", "update_expected_after",
-                      "main"}
-
-    @staticmethod
-    def _name(node):
-        f = node.func
-        return getattr(f, "attr", None) or getattr(f, "id", None)
-
-    def _scan(self):
-        """AST, not a text scan.
-
-        The first version grepped lines and flagged the module docstring, which
-        quotes `path.write_text(...)` while explaining the design. A guard that
-        reports prose is one people switch off.
-        """
-        import ast
-        src = (PERRY_HOME / "bin" / "perry-migrate").read_text(encoding="utf-8")
-        tree = ast.parse(src)
-        out = []
-        for fn in ast.walk(tree):
-            if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
-                continue
-            if fn.name in self.INSIDE_GUARDED:
-                continue
-            guarded = set()
-            for t in ast.walk(fn):
-                if isinstance(t, ast.Try) and any(
-                        h.type is not None for h in t.handlers):
-                    for body_node in t.body:
-                        for inner in ast.walk(body_node):
-                            guarded.add(id(inner))
-            for call in ast.walk(fn):
-                if isinstance(call, ast.Call) and self._name(call) in self.WRITES:
-                    if id(call) not in guarded:
-                        out.append(f"perry-migrate:{call.lineno} in "
-                                   f"{fn.name}(): {self._name(call)}()")
-        return out
-
-    def test_every_project_write_sits_inside_an_oserror_handler(self):
-        unguarded = self._scan()
-        self.assertEqual(
-            unguarded, [],
-            "these write to the user's project outside a `try`, so a "
-            "permission or a full disk becomes a traceback instead of a "
-            "refusal naming the restore point:\n  " + "\n  ".join(unguarded))
-
-    def test_the_scan_finds_the_writes_that_are_there(self):
-        """Anti-vacuity. If the scan matched nothing, the test above would pass
-        by finding no writes at all — which is how a guard becomes ceremony."""
-        import ast
-        src = (PERRY_HOME / "bin" / "perry-migrate").read_text(encoding="utf-8")
-        found = {self._name(c) for c in ast.walk(ast.parse(src))
-                 if isinstance(c, ast.Call) and self._name(c) in self.WRITES}
-        self.assertGreaterEqual(len(found), 4,
-                                f"the scan sees almost no writes: {found}")
-        self.assertIn("write_atomic", found)
-
-
-class TestTaskSummaryMigration(unittest.TestCase):
-    """TASK-106: migration neither invents nor erases non-projected summaries."""
-
-    def test_legacy_board_records_get_an_explicit_empty_summary(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        rc, out, err = p.run("apply")
-        self.assertEqual(rc, 0, (out, err))
-        records = [json.loads(line) for line in
-                   (p.root / "tasks.jsonl").read_text(encoding="utf-8").splitlines()]
-        self.assertTrue(records)
-        self.assertTrue(all(record.get("summary") == "" for record in records))
-
-    def test_existing_store_summary_survives_board_migration(self):
-        p = Project({"BOARD.md": LEGACY_BOARD})
-        made = subprocess.run(
-            [sys.executable, str(TASKS), "write", "--from-board",
-             "--root", str(p.root)], capture_output=True, text=True)
-        self.assertEqual(made.returncode, 0, made.stderr)
-        path = p.root / "tasks.jsonl"
-        records = [json.loads(line) for line in path.read_text().splitlines()]
-        task_id = records[0]["id"]
-        # This is SETUP, not the thing under test: it puts a summary in the
-        # store so the assertion below can prove migration preserved it. The
-        # board is deliberately legacy, so after TASK-047 the shipped
-        # `enforce` default refuses this write — correctly, and that refusal is
-        # asserted in `tests/test_conformance.py`, not here. Scoped to this one
-        # call rather than the fixture, because the rest of this module is
-        # about `perry-migrate`, which is exempt from the gate anyway.
-        summarized = subprocess.run(
-            [sys.executable, str(TASK), "summary", task_id, "--summary",
-             "SUMMARY-SURVIVES-MIGRATION", "--root", str(p.root), "--json"],
-            capture_output=True, text=True,
-            env=dict(os.environ, PERRY_CONFORMANCE="advisory"))
-        self.assertEqual(summarized.returncode, 0, summarized.stderr)
-
-        rc, out, err = p.run("apply")
-
-        self.assertEqual(rc, 0, (out, err))
-        migrated = [json.loads(line) for line in path.read_text().splitlines()]
-        record = next(item for item in migrated if item["id"] == task_id)
-        self.assertEqual(record["summary"], "SUMMARY-SURVIVES-MIGRATION")
-
-    def test_non_projected_summary_is_the_only_allowed_store_difference(self):
-        for field in ("owner", "title"):
-            with self.subTest(field=field):
-                p = Project({"BOARD.md": LEGACY_BOARD})
-                made = subprocess.run(
-                    [sys.executable, str(TASKS), "write", "--from-board",
-                     "--root", str(p.root)], capture_output=True, text=True)
-                self.assertEqual(made.returncode, 0, made.stderr)
-                path = p.root / "tasks.jsonl"
-                records = [json.loads(line) for line in path.read_text().splitlines()]
-                records[0][field] = f"STORE-ONLY-{field.upper()}"
-                path.write_text("".join(json.dumps(record) + "\n"
-                                        for record in records))
-
-                rc, out, err = p.run("apply")
-
-                self.assertEqual(rc, 1, (out, err))
-                self.assertIn("differs from the current BOARD.md-derived baseline",
-                              out.get("refused", ""))
-
-
-if __name__ == "__main__":
-    unittest.main()
diff --git a/tests/test_one_primitive.py b/tests/test_one_primitive.py
index f2b69454..e60e07d7 100644
--- a/tests/test_one_primitive.py
+++ b/tests/test_one_primitive.py
@@ -160,7 +160,7 @@ class TestEveryWriterReachesTheSharedLock(unittest.TestCase):
     is supposed to make impossible."""
 
     #: Tools that write a project's state files and so must serialize.
-    WRITERS = ("perry-task", "perry-tasks", "perry-migrate", "perry-goals",
+    WRITERS = ("perry-task", "perry-tasks", "perry-goals",
                "perry-decide", "perry-knowledge")
 
     def test_every_writer_takes_the_project_lock(self):

From 37e9af581f3f9cac9a4e175dd22c317ded744f6c Mon Sep 17 00:00:00 2001
From: Ran Jiao <ranjiao@gmail.com>
Date: Mon, 31 Aug 2026 22:02:38 +0800
Subject: [PATCH 4/4] The prose stops promising a gate and a migrator that no
 longer exist
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Code deletion without this is a lie with a passing test suite. Nine documents
described the ADR-004 conformance gate, `perry-conform` or `perry-migrate` as
live, and three of them were user-facing.

WHAT WAS FALSE, not merely stale:

- **Both READMEs** said "a project that will not migrate stays readable rather
  than drivable". That was the gate's refusal, and with the gate deleted
  nothing is read-only for want of a declaration — writes just work. Rewritten
  around what `/perry adopt` actually does: read what is there as evidence,
  write Perry's own state, never rewrite your files in place.
- **`SKILL.md`** told the agent "never run `perry-conform declare` for the
  user" — an instruction naming a command that does not exist.
- **`reference/config.md`** documented a `Conformance gate` setting as
  enforcing. Now marked deleted, with the sentence a user needs: a
  `- Conformance gate:` line left in an existing config is INERT. Which is why
  the schema keeps tolerating the key rather than rejecting it — removing the
  definition would have made that sentence false in the other direction.
- **`work/reference/review-constraints.md`** told every V4 reviewer not to run
  two tools that are gone.
- **`reference/glossary.md § conformance`** defined the word as two things.
  It is one thing now.

`reference/adoption.md § Migration` (57 lines) and `bin/README.md`'s gate
section (152 lines) are replaced rather than trimmed: both described a
mechanism end to end, and a shortened description of a deleted mechanism is
worse than a paragraph saying it is deleted and why. `/perry adopt` itself is
untouched — stages 0–5 never called `perry-migrate`; only the "project that
already has Perry-shaped state" case did, and that case is gone.

`schema § migration` is deleted — `enum_aliases` and `negations`, 1,766 bytes
whose own descriptions read "Read only by bin/perry-migrate". Zero readers.

ONE TEST CAUGHT ME. `test_router_budget` went red: my `SKILL.md` edit pushed
the file 17 bytes past its 20,480 cap, and the failure message says what to do
about it — "do not raise the cap without deciding that the file should be
bigger". Shortened to 20,452. Worth recording that I nearly missed it: two
full-suite runs overlapped with my own edits to the tree they were reading, so
their results were not evidence about anything. The run reported below is a
clean one.

tests/run: 3 modules red — test_diagnose, test_heading_title,
test_kr_progress_provenance — the same three, failing the same way, on a clean
`git archive HEAD` export. Every module this branch touched is green.

TASK-261 closes at V3: the tests and the mutation work are mine, and V4 needs a
reviewer that did not write it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
 .perry/events.jsonl                  |   6 +
 README.md                            |   4 +-
 README_cn.md                         |   4 +-
 SKILL.md                             |   2 +-
 bin/README.md                        | 166 ++++-----------------------
 bin/lib/__init__.py                  |   6 +-
 modes/queue.md                       |   6 +-
 perry/BOARD.md                       |   1 -
 perry/journal/2026-08/2026-08-31.md  |   1 +
 perry/tasks.jsonl                    |   2 +-
 reference/adoption.md                |  84 +++++---------
 reference/config.md                  |  48 +++-----
 reference/glossary.md                |   8 +-
 schema/state-schema.json             |  32 ------
 work/reference/review-constraints.md |   7 +-
 15 files changed, 92 insertions(+), 285 deletions(-)

diff --git a/.perry/events.jsonl b/.perry/events.jsonl
index 36fc68de..618601aa 100644
--- a/.perry/events.jsonl
+++ b/.perry/events.jsonl
@@ -1402,3 +1402,9 @@
 {"ts": "2026-08-31T20:27:58+08:00", "event": "start", "id": "TASK-261", "title": "Tier A — the ADR-004 declaration gate comes out; perry-conform keeps only its shared helpers", "track": "main", "actor": "agent", "from": "not_started", "to": "in_progress"}
 {"ts": "2026-08-31T21:01:37+08:00", "event": "ask", "id": "USER-910", "title": "perry-migrate cannot survive Tier A — its output IS the deleted ledger (C.declare, 14 sites). A: delete migration too (0 records ever carried route:migrate; TASK-097 never started) — recommended. B: restore ~200 ledger lines for migrate alone, keep the write-path gate deleted, make TASK-097 the next phase. Full form: evidence/2026-08/2026-08-31-TASK-261-migration-fork.md", "asked": "2026-08-31", "blocks": "TASK-261", "actor": "agent", "from": null, "to": "pending"}
 {"ts": "2026-08-31T21:01:44+08:00", "event": "status", "id": "TASK-261", "title": "Tier A — the ADR-004 declaration gate comes out; perry-conform keeps only its shared helpers", "track": "main", "actor": "agent", "depends_on": ["USER-910"], "from": "in_progress", "to": "blocked", "reason": "migration fork: perry-migrate's output is the deleted ledger"}
+{"ts": "2026-08-31T21:13:25+08:00", "event": "answer", "id": "USER-910", "title": "perry-migrate cannot survive Tier A — its output IS the deleted ledger (C.declare, 14 sites). A: delete migration too (0 records ever carried route:migrate; TASK-097 never started) — recommended. B: restore ~200 ledger lines for migrate alone, keep the write-path gate deleted, make TASK-097 the next phase. Full form: evidence/2026-08/2026-08-31-TASK-261-migration-fork.md", "answer": "A — delete migration too. perry-migrate, perry_schema.py and test_migrate.py are out; TASK-097 drops with them.", "actor": "agent", "from": "pending", "to": "answered"}
+{"ts": "2026-08-31T21:13:25+08:00", "event": "drop", "id": "TASK-097", "title": "Migrate the two real projects to the store, at V5", "track": "main", "owner": "Coding Agent", "role": "", "stage": "", "actor": "agent", "from": "not_started", "to": "dropped", "reason": "the ADR-004 gate and perry-migrate are deleted (TASK-261, USER-910 answered A); this row is about a subsystem that no longer exists"}
+{"ts": "2026-08-31T21:13:26+08:00", "event": "drop", "id": "TASK-223", "title": "the conformance gate cannot tell a file Perry generated from one it found, so authored files need a hand declare", "track": "main", "owner": "Coding Agent", "role": "", "stage": "", "actor": "agent", "from": "not_started", "to": "dropped", "reason": "the ADR-004 gate and perry-migrate are deleted (TASK-261, USER-910 answered A); this row is about a subsystem that no longer exists"}
+{"ts": "2026-08-31T21:13:26+08:00", "event": "drop", "id": "TASK-246", "title": "an unreadable row in .perry/conformance.md is now DELETED by the next declare, not laundered", "track": "main", "owner": "Coding Agent", "role": "", "stage": "", "actor": "agent", "from": "not_started", "to": "dropped", "reason": "the ADR-004 gate and perry-migrate are deleted (TASK-261, USER-910 answered A); this row is about a subsystem that no longer exists"}
+{"ts": "2026-08-31T21:13:26+08:00", "event": "drop", "id": "TASK-248", "title": "a canonical row inside <pre>, an HTML comment, or <details> still declares a file conformant, and is still laundered", "track": "main", "owner": "Coding Agent", "role": "", "stage": "", "actor": "agent", "from": "not_started", "to": "dropped", "reason": "the ADR-004 gate and perry-migrate are deleted (TASK-261, USER-910 answered A); this row is about a subsystem that no longer exists"}
+{"ts": "2026-08-31T22:02:17+08:00", "event": "done", "id": "TASK-261", "title": "Tier A — the ADR-004 declaration gate comes out; perry-conform keeps only its shared helpers", "track": "main", "owner": "Coding Agent", "role": "", "actor": "agent", "from": "blocked", "to": "done", "evidence": "6ce1f5b, 436d0fb; evidence/2026-08/2026-08-31-TASK-261-migration-fork.md", "rung": "V3"}
diff --git a/README.md b/README.md
index 776523b6..3d850c5d 100644
--- a/README.md
+++ b/README.md
@@ -87,7 +87,7 @@ Don't start from a blank page — Perry can read what's already there:
 
 It reads your README, roadmap, git history, existing design notes, TODOs and issues, then **proposes** goals, tasks and decisions. Nothing is written until you say yes.
 
-**Adoption is a migration, and it is meant to happen once.** Perry used to adapt at runtime to whatever shape your files were already in, and that tolerance is where its bugs lived — two branches guessing differently about the same table and quietly losing a row between them. So it was traded away on purpose ([ADR-004](perry/decisions/ADR-004-mandatory-migration.md)). The rule now: **a project migrates to Perry's structure in order to use Perry's write features, and a project that will not migrate stays readable rather than drivable.**
+**Adoption writes Perry's own state, once.** Perry used to adapt at runtime to whatever shape your files were already in, and that tolerance is where its bugs lived — two branches guessing differently about the same table and quietly losing a row between them. So it was traded away on purpose ([ADR-004](perry/decisions/ADR-004-mandatory-migration.md)). The rule now: **`/perry adopt` reads what you already have as evidence and writes Perry's structure from it; it never rewrites your files in place.** ADR-004 also made every writer refuse a file nobody had declared conformant. That gate is deleted (`TASK-261`) — in 23 declarations it never once disagreed with the live check, so nothing now refuses a write for want of a declaration.
 
 Readable is not the consolation prize. `/perry diagnose` runs on any folder at all, and reading an unmigrated project is exactly how you decide whether to migrate it. And the migration owes you four things: the complete diff before anything is written, no lost rows or IDs, a restore point, and no step you did not ask for.
 
@@ -316,7 +316,7 @@ Any language works for prose. Details, and how to switch later: [reference/i18n.
 
 **Can I use it for non-code projects?** Yes — research, writing, ops, business planning. Those are not a bolt-on: they are the [four modes](#four-kinds-of-work), each with its own horizon, throttle and triage. `/perry diagnose` recognises them from what's on your board.
 
-**Can Perry drive the board I already have?** Only after `/perry adopt` migrates it. Perry stopped bending at runtime to arbitrary file shapes ([ADR-004](perry/decisions/ADR-004-mandatory-migration.md)) — that flexibility was where its data-losing bugs came from. An unmigrated project stays readable and diagnosable; it is just not driven.
+**Can Perry drive the board I already have?** Not in place. `/perry adopt` reads it as evidence and writes Perry's own state alongside; Perry then drives that. Perry stopped bending at runtime to arbitrary file shapes ([ADR-004](perry/decisions/ADR-004-mandatory-migration.md)) — that flexibility was where its data-losing bugs came from. A project Perry has not adopted stays readable and diagnosable; it is just not driven.
 
 **What if my project already has a `design/` folder?** Nothing collides — Perry's own files live under `perry/` by default, so your `design/` stays yours. Setup checks for the collision before it writes anything, and only asks if you tell it to use the project root instead.
 
diff --git a/README_cn.md b/README_cn.md
index f0890499..db248c88 100644
--- a/README_cn.md
+++ b/README_cn.md
@@ -89,7 +89,7 @@ git clone https://github.com/ranjiao/Perry.git ~/perry && ~/perry/setup
 
 它会读你的 README、路线图、git 历史、已有的设计笔记、TODO 和 issue,然后**提议**目标、任务和决策。你不点头,它什么都不写。
 
-**adopt 是一次迁移,只做一次。** 以前 Perry 会在运行时迁就你原有的文件格式。它的 bug 大多出在这里:两处代码对同一张表的读法不一致,中间悄悄丢掉一行。所以这份灵活性被有意放弃了,理由记在 [ADR-004](perry/decisions/ADR-004-mandatory-migration.md)。现在的规矩是:**要用 Perry 的写入能力,项目就得先迁到 Perry 的结构;不迁的项目仍然可读,但 Perry 不会去驱动它。**
+**adopt 写出 Perry 自己的状态,只做一次。** 以前 Perry 会在运行时迁就你原有的文件格式。它的 bug 大多出在这里:两处代码对同一张表的读法不一致,中间悄悄丢掉一行。所以这份灵活性被有意放弃了,理由记在 [ADR-004](perry/decisions/ADR-004-mandatory-migration.md)。现在的规矩是:**`/perry adopt` 把你已有的东西当证据读,据此写出 Perry 的结构;它不会就地改写你的文件。** ADR-004 当初还让每个写工具拒绝未被声明合规的文件,那道门禁已经删除(`TASK-261`)—— 23 次声明里它一次都没和实时检查产生过分歧,现在没有任何写入会因为缺一份声明被拒。
 
 「可读」不是安慰奖。`/perry diagnose` 在任何目录上都能跑,而读一个没迁移的项目,恰恰是你判断该不该迁的依据。迁移本身欠你四件事:动手之前先给出完整 diff、不丢任何一行和任何一个 ID、留一个可回退的还原点、以及绝不做你没让它做的事。
 
@@ -318,7 +318,7 @@ Perry 本身是英文写的,你的项目不必是。首次配置时它会记
 
 **能用在非代码项目上吗?** 能 —— 研究、写作、运维、业务规划都行。这不是外挂上去的:它们就是[四种 mode](#四种工作形态),各有各的收尾条件、节奏控制和 triage 问法。`/perry diagnose` 会从你板子上的样子把它们认出来。
 
-**Perry 能直接驱动我现有的板子吗?** 得先用 `/perry adopt` 迁一次。Perry 已经不再在运行时迁就任意文件格式了([ADR-004](perry/decisions/ADR-004-mandatory-migration.md))—— 那份灵活性正是它丢数据的那类 bug 的来源。没迁的项目仍然可读、可以 diagnose,只是不被驱动。
+**Perry 能直接驱动我现有的板子吗?** 不是就地驱动。`/perry adopt` 把它当证据读,在旁边写出 Perry 自己的状态,之后 Perry 驱动的是后者。Perry 已经不再在运行时迁就任意文件格式了([ADR-004](perry/decisions/ADR-004-mandatory-migration.md))—— 那份灵活性正是它丢数据的那类 bug 的来源。没被 adopt 过的项目仍然可读、可以 diagnose,只是不被驱动。
 
 **我的项目已经有** **`design/`** **目录了怎么办?** 不会撞上 —— Perry 自己的文件默认就在 `perry/` 下面,你的 `design/` 还是你的。setup 在写任何东西之前先查一遍冲突,只有你坚持要用项目根目录时它才会问。
 
diff --git a/SKILL.md b/SKILL.md
index 7d7a3759..daec7229 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -194,7 +194,7 @@ Moves every path Perry claims under a new state root and rewrites `State root:`
 
 `.perry/config.md` projects `.perry/config.jsonl`; prose belongs in `.perry/hook.md`. First-time setup creates both. Field **names** stay English in every language, because this file declares the language and must be readable before it is known. An optional `## Tracks` table turns on `pipeline` / `queue` / `inquiry` mode; absent means one implicit `main` track, mode `project`.
 
-The field list and the four subjects with consequences worth reading before you change them are `reference/config.md`: **repo layout** (single, or the two-repo PMO ↔ code split), **state root** (`perry` is what setup writes; the *code* fallback is still the project root and must stay that way), **tracks**, and the **conformance gate** (enforces — never run `perry-conform declare` for the user; adoption proposes, the user declares).
+The field list and the three subjects with consequences worth reading before you change them are `reference/config.md`: **repo layout** (single, or the two-repo PMO ↔ code split), **state root** (`perry` is what setup writes; the *code* fallback is still the project root and must stay that way), and **tracks**. The ADR-004 **conformance gate** was a fourth; it is deleted (`TASK-261`) — nothing refuses a write now.
 
 ## Style rules
 
diff --git a/bin/README.md b/bin/README.md
index 9878ad26..27f8d75b 100644
--- a/bin/README.md
+++ b/bin/README.md
@@ -24,7 +24,6 @@ Python 3 or POSIX-ish bash, with no install step and no dependencies at all.
 | [`perry-decide`](perry-decide) | **write** + read | The `decide` lane's writer: bootstrap `decisions/`, mint ADRs, supersede, set status, list. |
 | [`perry-knowledge`](perry-knowledge) | **write** + read | The knowledge-card write path (DESIGN-006 phase B). `propose` is read-only and answers whether a capture point should fire; `promote` writes `knowledge/<topic>/<slug>.md` and **refuses a card that cannot say where its claim came from**. |
 | [`perry-lint`](perry-lint) | read | Validates state files against `schema/state-schema.json`. Run it after every write to a tier‑1 file. |
-| [`perry-conform`](perry-conform) | read + writes `.perry/conformance.jsonl` | The conformance marker (ADR-004): *this file matches Perry's shape, at shape version N, and the user declared it.* The gate every writer calls, and the one command that records a declaration. The record is a **store**, one JSON object per line, since TASK-234 — `perry-conform status` is the human surface and there is deliberately no rendered markdown. |
 | [`perry-diagnose`](perry-diagnose) | read | How a project is *structured* for agent work — context load, document graph, tracking spine. Works on any folder, Perry or not. |
 | [`perry-state-cost`](perry-state-cost) | read | What a project's Perry state costs it: bytes, file count, share of tracked bytes and the growth trend, per claimed path, at a named commit. The paths come from `schema/state-schema.json § claims`, so a directory cannot fall out of the report by being forgotten. Reads `evidence/` and `journal/` to size them and writes nothing anywhere. |
 | [`perry-context-budget`](perry-context-budget) | read | What the SESSION costs per turn, from the host's own transcript accounting — not what the state costs on disk, which is `perry-state-cost`. Measured over 25 sessions and 18,941 turns: 99.1% of this project's 8.43B tokens was `cache_read`, the accumulated context re-read every turn, so the bill is `Σ over turns (context at that turn)`. Exit 1 at the ceiling in `schema § thresholds.session_context_ceiling`, which is how `autopilot` knows to hand off; `--composition` says what the context is made of. Abstains loudly on a host with no transcript rather than reporting a clean bill it never measured. |
@@ -175,157 +174,32 @@ web used its rows as links into `decisions/` and now lands in the directory
 listing instead — and says the implementing row must not re-add an index under
 another name.
 
-### `perry-task` and `perry-goals` gate on the conformance marker
+### Nothing gates on a conformance marker any more
 
-Under [ADR-004](../perry/decisions/ADR-004-mandatory-migration.md) a project
-migrates to Perry's shape once, and after that both the reader and the writer
-may assume that shape. The fact that makes it safe to assume is **declared and
-checkable**, and it is not `perry-lint`'s `is_adopted()` — that answers "does
-this folder hold any Perry file at all", which is a different and still-correct
-question.
+`perry-task`, `perry-goals` and `perry_md_store § render --write` used to call
+an ADR-004 gate before every write: it read a **declaration** out of
+`.perry/conformance.jsonl` — *this file matches Perry's shape, at shape version
+N, and the user said so* — and refused when the file's live shape no longer
+matched what had been declared. Keeping the stored decision and the live check
+apart was the design, because the two disagreeing was supposed to be a finding.
 
-```bash
-"$PERRY_HOME/bin/perry-conform" status                  # every file, every verdict
-"$PERRY_HOME/bin/perry-conform" check BOARD.md          # one file; exit 1 if not conformant
-"$PERRY_HOME/bin/perry-conform" declare BOARD.md        # the user's declaration
-"$PERRY_HOME/bin/perry-conform" declare --all
-"$PERRY_HOME/bin/perry-conform" migrate                 # a pre-TASK-234 record -> the store
-```
+They never disagreed. The ledger held 23 records, all `route: declare`, all
+files in this repository. The disagreement needs a foreign project that drifts,
+and Perry has never been pointed at one. `bin/perry-conform`, the ledger, the
+three gate call sites and `bin/perry-migrate` are all deleted (`TASK-261`,
+`USER-910`) — about 10,600 lines with their tests.
 
-`migrate` carries a project's `.perry/conformance.md` into
-`.perry/conformance.jsonl` with its dates and routes unchanged and deletes the
-markdown. It **declares nothing** — it writes only rows already in the record —
-so it is not the act `SKILL.md § Conformance gate` reserves to the user, and an
-agent may run it. It refuses rather than convert a file that is not line-for-line
-what `perry-conform declare` would have written for what it parses to: a row
-inside a code fence, an HTML comment, `<pre>` or `<details>` is byte-for-byte a
-genuine row, and the only thing that can tell it apart is what surrounds it.
-(Line-for-line, not byte-for-byte: the comparison applies Python's
-universal-newline translation, so a record saved with CRLF converts.)
-
-**The refusal prints the diff.** `-` is the file, `+` is what Perry reads out of
-it, so a `-` line alone is a line to delete and a `+` line is one to restore —
-because on such a project nothing can write until the record is fixed, and a
-refusal that named no line would be the wall `perry-conform § message_for`
-forbids.
-
-Two facts, kept apart on purpose:
-
-| | Where it lives | Who produces it |
-|---|---|---|
-| **the declaration** — the user said this file is Perry's, at shape version N | `.perry/conformance.jsonl` | only `perry-conform declare`, or a migration the user asked for. **No tool stamps it on its own initiative.** Each line also records **who** wrote it, **when** to the second, and **which migration run** — three facts the four markdown columns could not carry, which is why `TASK-226` was an investigation rather than a query. |
-| **the shape** — does it still match `schema/state-schema.json` | nowhere; recomputed every call | `perry-lint`'s own `check_file`, imported rather than reimplemented |
-
-A stored verdict would be a cache that goes wrong, and a content hash would
-revoke itself on every legitimate `perry-task add`. A stored *decision* plus a
-live *check* can disagree — and that disagreement (`drifted`) is a finding, not
-a crash and not a silent correction.
-
-Five verdicts: `conformant`, `undeclared`, `stale` (declared at an older shape
-version), `drifted` (declared, no longer matches), `absent` (nothing there yet,
-so nothing is gated). Conformance means **zero lint errors** for that one file —
-warnings are quality signals and one of them, `stale-run`, becomes true with the
-passage of time alone.
-
-Conformance is **per file**: a project may migrate its board and not its risks,
-so `perry-task` gates on `BOARD.md` and `perry-goals` on `OKR.md` (on the phase's
-linkage register for `link`, which is the file that command writes), and neither
-looks at the other.
-
-**`perry-decide` gates on nothing, and that is a hole rather than an
-exemption.** `DECISIONS.md` was the only file it wrote that
-`schema/state-schema.json § files[]` gives a shape, and TASK-235 deleted it. The
-ADR bodies it writes have no declared shape and never had one, so there is
-nothing to gate on and a gate would be one that cannot fire. Restoring it means
-giving `decisions/ADR-*.md` a `files[]` entry — new claim surface, and its own
-row.
-
-**Reading is never gated.** `perry-state`, `perry-task list`, `perry-goals list`,
-and `perry-decide list` answer on an unmarked project, whatever the gate is set
-to.
-
-The gate ships **enforce**: a writer refuses a state file that is not declared
-conformant, naming the file, the shape version it was checked against, and the
-command that fixes it. Set `- Conformance gate: advisory` in `.perry/config.md`,
-or `PERRY_CONFORMANCE=advisory` in the environment, to make it proceed and say
-what it found instead — on stderr and in the `conformance` block of its
-(non-contract) `--json` result.
-
-#### The switch-over checklist — what the flip to `enforce` costs
-
-ADR-004's decision was to flip once the migration existed. `bin/perry-migrate`
-landed with TASK-044 on 2026-08-19, so TASK-047 flipped `DEFAULT_MODE`. Every
-refusal now names a road: `perry-conform declare` for a file that already
-matches Perry's shape, `perry-migrate` for one that does not.
-
-The flip was **measured on a copy of a real project** rather than argued, and
-two costs came out of that measurement. Neither is a missing road; both are
-places a user meets the gate on day one, so both are stated here rather than
-discovered in the field.
-
-| | What it costs | What removes the cost |
-|---|---|---|
-| **1 · migration does not always reach zero on a real board** | On a `~/proj/gimegime-pmo` copy, `perry-migrate` takes `BOARD.md` from 3 errors to **1**, and the residue is a row reading `Status: 半解`. That file stays refused until a human edits it and runs `perry-conform declare BOARD.md`. The refusal names both commands, so it is a door that needs a hand — not a wall. | A path for the residue that is not a hand edit. The three classes seen were: a `Status` cell in the user's own words, a tier-1 file over its size cap, and a KR table whose columns are the project's. **Not** widening the enums — `半解` is a real distinction the user drew, and coercing it to `in_progress` is the confidently-wrong-value class. |
-| **2 · every new file is born undeclared, in a new project and an old one alike** | A file with **zero** lint errors is still `undeclared`, and undeclared is refused. `SKILL.md § Conformance gate` forbids an agent from running `perry-conform declare` on the user's behalf (`perry/OKR.md` — *adoption proposes; the user declares*), so the first `perry-task add` on a project Perry itself just wrote asks the user for one command. **This is not confined to first runs** — see the measurement below. | Setup or adopt ending in the user's own declaration — one prompt, at the point where the files are created. That is a better first run than a refusal, but it is a convenience, not a road: the road already exists and the refusal names it. |
+**What replaced it: nothing, deliberately.** A writer that can render a file
+writes it. `perry-lint` still answers whether a file matches the schema, which
+was always a different question from whether anyone had declared it.
 
-Both are checked by `tests/test_conformance.py § TestTheGateEnforces`, so the day
-either becomes false a test says so rather than the paragraph going stale.
+Two things kept the word and are unrelated to any of the above:
 
-**Cost 2 was first written at the wrong scope, and the correction is the part
-worth keeping.** It read *a brand-new project asks for one declaration*, which
-is true and too narrow: the same thing happens to **every file Perry creates
-after the last declaration, in a project that has been declared for weeks**.
-Measured 2026-08-20 on a declared scratch project with the gate enforcing:
-
-```
-perry-decide bootstrap        →  wrote ['decisions/', 'DECISIONS.md']
-perry-conform status          →  · DECISIONS.md   undeclared
-perry-decide new <slug> …     →  refused — DECISIONS.md already matches Perry's
-                                 shape at version 2, but no one has declared it
-```
+- `perry-task list --json`'s `conformance.*` block — `evidence_not_found`,
+  `depends_on_unknown`, `blocked_by_closed_rows` — is read-time integrity
+  reporting and a published contract (`schema/task-list-contract.md`).
+- `perry-lint`'s schema pass, untouched.
 
-> **This transcript is history, and it is kept verbatim for that reason.** It
-> was measured on 2026-08-20, and TASK-235 has since deleted `DECISIONS.md` and
-> with it `perry-decide`'s gate, so the exact commands above can no longer be
-> re-run. Restating them against a lane that still gates would be quoting a
-> measurement nobody took. What was measured is a property of the GATE, not of
-> that lane, and still holds wherever one is armed: creation is not gated, the
-> next write is, and the refusal names the one command that fixes it.
-
-Two facts hold that together, and only both make it survivable:
-
-- **Creation is not gated.** The file is written. A gate that refused creation
-  would leave a project unable to open a phase, a decision or a knowledge card
-  at all, which is not a door needing a hand — it is the wall this checklist
-  exists to avoid.
-- **The next write to it is.** The refusal names `perry-conform declare` with
-  the exact path, so the road is one command, exactly as in row 1.
-
-Concretely, in Perry's own repository on the day of the flip: `phase/002`,
-`DESIGN-007` and one knowledge card were undeclared, because the last
-declaration ran 2026-08-17 and all three were created on the 18th and 19th. None
-of them was malformed. They were simply younger than the last time a human said
-*yes, this is Perry's shape*.
-
-This is a **consequence of the design, not a gap in it.** A writer that declared
-its own output would be certifying its own work, which is the thing ADR-004's
-*adoption proposes; the user declares* exists to prevent. Naming the real scope
-does not argue for changing it — it argues that "one declaration at setup" is
-the wrong mental model, and "a declaration each time the shape of your state
-grows" is the right one.
-
-**Going back is per project, not per release.** A project that wants the old
-behaviour sets `- Conformance gate: advisory` in `.perry/config.md`; a single
-command gets `PERRY_CONFORMANCE=advisory`. Both branches stay live and both stay
-exercised by the suite — a guard that cannot be made to fire is not a guard, and
-neither is one that cannot be turned off.
-
-What is **not** affected: reading. `perry-state`, `perry-task list`, `perry-goals
-list` and `perry-decide list` were re-run at every step of that migration with
-the gate enforcing, on an undeclared project, on a half-migrated one and on a
-declared one, and answered `rc=0` with all 41 rows every time. `perry-lint` and
-`perry-migrate` are ungated for the same reason — they are the commands a
-refusal names, and a gated one would close the loop.
 
 ### Lint after every tier‑1 write
 
diff --git a/bin/lib/__init__.py b/bin/lib/__init__.py
index bfeaee91..975c55ad 100644
--- a/bin/lib/__init__.py
+++ b/bin/lib/__init__.py
@@ -22,7 +22,7 @@
 **Each tool keeps its own `Refused`.** These functions take the exception class
 to raise rather than defining one here, because a shared `Refused` would make
 `perry-task`'s `except Refused` start catching refusals raised inside
-`perry-conform` when one tool loads another — a real change in control flow,
+another tool it loads — a real change in control flow,
 and this extraction is supposed to change none.
 """
 
@@ -460,8 +460,8 @@ def resolve_state_root(project_root: Path) -> Path:
 
     **The implementation is `viewer/parsers.py`'s and stays there**, because
     that is where every other reader already gets it — `perry-lint`,
-    `perry-state`, `perry-task`, `perry-goals`, `perry-decide`, `perry-conform`,
-    `perry-knowledge` and `perry-migrate` all call `P.resolve_state_root`. This
+    `perry-state`, `perry-task`, `perry-goals`, `perry-decide` and
+    `perry-knowledge` all call `P.resolve_state_root`. This
     is a re-export so that `bin/` has one import site rather than one function
     with two bodies; it is not a second implementation and must never become
     one.
diff --git a/modes/queue.md b/modes/queue.md
index b75fb84d..6085f966 100644
--- a/modes/queue.md
+++ b/modes/queue.md
@@ -57,9 +57,9 @@ asking, and it is honest, so nothing is measured against a number.
 **After creation it is a warning, not a refusal.** `perry-lint` reports
 `no-default` on a queue or pipeline row whose `SLA` or `Cycle` is undeclared,
 and stops there: a project that already has such a track predates this rule,
-and under ADR-004 an error would make the whole of `.perry/config.md`
-undeclarable and therefore unwritable — one blank cell taking the entire track
-register read-only. The hard stops live where the missing value is actually
+and an error would report the whole track register red over one blank cell.
+(It used to do worse: under ADR-004 it made `.perry/config.md` undeclarable and
+therefore unwritable. That gate is deleted — `TASK-261`.) The hard stops live where the missing value is actually
 used instead: `goals/reference/phases.md` refuses to write a queue commitment
 while the track has no `SLA` cell, and triage below reports the gap rather than
 skipping the breach step.
diff --git a/perry/BOARD.md b/perry/BOARD.md
index ff319972..7a84188c 100644
--- a/perry/BOARD.md
+++ b/perry/BOARD.md
@@ -122,7 +122,6 @@
 | TASK-257 | The ignored-name bullet pin asserts a substring, not a bullet, and one satisfying string blinds the guard to BOARD.md | Coding Agent | not_started | — | — | V4 |  | main |  |  |  |  |  |  |
 | TASK-258 | tests/test_tree_guard.py copies the LIVE repository, so any concurrent write reddens it | Coding Agent | not_started | — | — | V4 |  | main |  |  |  |  |  |  |
 | TASK-259 | Nothing asserts the TASK-234 fixture root is shell-hostile, and 8 of 19 bypass spellings get past the source rule | Coding Agent | not_started | — | — | V4 |  | main |  |  |  |  |  |  |
-| TASK-261 | Tier A — the ADR-004 declaration gate comes out; perry-conform keeps only its shared helpers | Coding Agent | blocked | blocked on migration fork: perry-migrate's output is the deleted ledger | — | V4 | USER-910 | main |  |  |  |  |  |  |
 
 ## P2
 
diff --git a/perry/journal/2026-08/2026-08-31.md b/perry/journal/2026-08/2026-08-31.md
index e4e81d11..4dd694c7 100644
--- a/perry/journal/2026-08/2026-08-31.md
+++ b/perry/journal/2026-08/2026-08-31.md
@@ -13,6 +13,7 @@
 - [TASK-223] not_started → dropped · reason: the ADR-004 gate and perry-migrate are deleted (TASK-261, USER-910 answered A); this row is about a subsystem that no longer exists
 - [TASK-246] not_started → dropped · reason: the ADR-004 gate and perry-migrate are deleted (TASK-261, USER-910 answered A); this row is about a subsystem that no longer exists
 - [TASK-248] not_started → dropped · reason: the ADR-004 gate and perry-migrate are deleted (TASK-261, USER-910 answered A); this row is about a subsystem that no longer exists
+- [TASK-261] blocked → done · closed · evidence: `6ce1f5b, 436d0fb; evidence/2026-08/2026-08-31-TASK-261-migration-fork.md` · verification: V3
 
 ## New tasks added
 
diff --git a/perry/tasks.jsonl b/perry/tasks.jsonl
index 0ed261cc..99f2e893 100644
--- a/perry/tasks.jsonl
+++ b/perry/tasks.jsonl
@@ -248,8 +248,8 @@
 {"id": "TASK-234", "title": ".perry/conformance.md is a pure ledger with a hand-rolled table parser, and its phantom row has nowhere to record provenance", "summary": "ROUND 5 REVIEW: PASS, merged. The reviewer went looking for the THIRD layer — rounds 3 and 4 having failed on the same sentence one register deeper each time — using eight distinct refusal surfaces driven on planted hostile-root projects, every command extracted by the SHIPPED extractor and pasted into a real /bin/sh. It is not there: all parse, all carry the exact typed root, including the full round trip, a state file named 'My Notes & draft.md', a RELATIVE --root, and perry-migrate's actual restore putting two files back. Newline, which the row called unmeasured, is milder than claimed — _q quotes it correctly and the two-line block pastes and runs rc 0. TWO MUTATIONS TURNED THE ROW'S ARGUMENT INTO A MEASUREMENT: R5-16, a friendly fixture root PLUS round 4's defect put back, drops 24 red methods to 2, and both survivors are the source rule and the backtick test — so 'a choke point is a convention' is now an experiment rather than an argument. R5-15, _q double-quoting with escapes, leaves shlex.split reading the right root so all 16 helper invocations and the source guard stay GREEN while /bin/sh expands  and the end-to-end proof goes red — that is exactly the shell-layer-only mutation the RESULT said it could not construct, which makes the /bin/sh paste load-bearing rather than decorative. 57/57 of the row's own harness reproduced independently plus 16 of the reviewer's, restored from git show and never from its own bytes. ONE SURVIVOR: R5-11, the sweep's phrase boundary (TAIL excluding the backtick) has no positive control. Suite main 105/3148/4, tip 103/3150/4, probe 105/3200/4, ZERO errors throughout, test_host_support absent from all three.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "perry/evidence/2026-08/TASK-234-round5-v4-review.md", "next_action": "Blocked until TASK-050 lands: converting this reader removes one of the markdown tables TASK-050's header_index() has to cover, so doing it first means TASK-050 converts a site that is about to be deleted. TWO THINGS TO SETTLE BEFORE WRITING CODE, both real. (1) BOOTSTRAP ORDER: this file gates every write under ADR-004's enforce gate, including the write that migrates it — the migration path must not require the gate to be passable mid-migration. (2) SELF-REFERENCE: schema/state-schema.json:2053 already states, deliberately, that .perry/conformance.md is NOT a files[] entry because 'it is a record of the user's decisions ABOUT state, not state, and listing it here would make it declarable-conformant about itself'. That reasoning carries over to the jsonl unchanged and must be moved across EXPLICITLY, not dropped in the format change. (3) NOTE FOR THE GOALS LANE, not this row's to write: P003-O1-KR1, KR2 and KR3 are all phrased 'of 6' over the six stores in claims[]. A seventh claimed store moves that denominator. Whether conformance.jsonl joins claims[] at all is the same question as (2).", "depends_on": ["TASK-050"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-29T13:43:22+08:00", "order": null}
 {"id": "TASK-259", "title": "Nothing asserts the TASK-234 fixture root is shell-hostile, and 8 of 19 bypass spellings get past the source rule", "summary": "Filed 2026-08-30 from the TASK-234 round-5 review. Item (b) is the interesting one: the row's defence is a choke point PLUS a source rule, and the source rule is the half that makes the choke point more than a convention — so its recall is the property the whole shape rests on. It is 11 of 19 today.", "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-30T16:44:44+08:00", "order": 47}
 {"id": "TASK-260", "title": "V4 criteria must be bounded, and the round stops auditing its own exhibit", "summary": "TASK-050 ran 11 rounds against a universal negative and PASSed on the round the criterion became decidable. Measured: 22 of 49 finding headlines audit the round's own artifact, not the product.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "a4eb411; evidence/2026-08/2026-08-31-representation-layer-delete-list.md", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-31T20:27:36+08:00", "order": null}
-{"id": "TASK-261", "title": "Tier A — the ADR-004 declaration gate comes out; perry-conform keeps only its shared helpers", "summary": "23 records, all route: declare, all Perry's own files, zero migrations and zero disagreements. The gate's value needs a foreign project that drifts, and Perry has never been run on one. The delete list said 'delete bin/perry-conform, 974 lines'; that was wrong — 598 lines are the dead ledger and ~280 are helpers four tools depend on, so the file is gutted, not removed.", "owner": "Coding Agent", "status": "blocked", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "blocked on migration fork: perry-migrate's output is the deleted ledger", "depends_on": ["USER-910"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-31T20:27:48+08:00", "order": 48}
 {"id": "TASK-097", "title": "Migrate the two real projects to the store, at V5", "owner": "Coding Agent", "status": "dropped", "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": null, "summary": ""}
 {"id": "TASK-223", "title": "the conformance gate cannot tell a file Perry generated from one it found, so authored files need a hand declare", "summary": "7 authored files sat undeclared for 8 days and it blocked perry-goals link --project on 2026-08-28. perry-migrate already records route: migrate; there is no route: authored.", "owner": "Coding Agent", "status": "dropped", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-28T19:11:27+08:00", "order": null}
 {"id": "TASK-246", "title": "an unreadable row in .perry/conformance.md is now DELETED by the next declare, not laundered", "summary": "Reported by TASK-241's author against its own change, 2026-08-30, and not filed by it because the PMO owns the board. bin/perry-conform:423 render rewrites the whole file from the parsed declarations. Before TASK-241 a decorated row parsed to a plain key, so the next declare LAUNDERED it into a canonical row — that was the defect TASK-241 closes. After TASK-241 the row is unreadable instead, so the next declare simply does not carry it forward and it is GONE from the file. The author calls that fail-closed and better than laundering, and says the change ENLARGES a pre-existing case: the same already happened for an unreadable version cell. It is better than the alternative and it is still a write that destroys a line the user typed, with no report at the moment of destruction.", "owner": "Coding Agent", "status": "dropped", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked until TASK-241 lands. Start from evidence/2026-08/TASK-241-result.md, where the author states this against its own change rather than leaving it to a reviewer — that is the reason to trust the framing. Note the pre-existing half: an unreadable VERSION cell already behaved this way before TASK-241, so this is not a regression the row introduced, only one it made reachable more often.", "depends_on": ["TASK-241"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-30T03:27:22+08:00", "order": null}
 {"id": "TASK-248", "title": "a canonical row inside <pre>, an HTML comment, or <details> still declares a file conformant, and is still laundered", "summary": "Found by the TASK-241 round 2 V4 reviewer, 2026-08-30, and ruled non-blocking for that row. A bare canonical row placed inside an HTML block — <pre>, or an HTML comment — reads as a real declaration: conformant with 0 unreadable, identically at the fork point, at round 1 and at round 2. TASK-241 closes the three markdown decoration traps the spec named (backticked, indented, fenced, including four nestings) and this is outside all of them: it is invisible to the round-trip property BY CONSTRUCTION, because the row inside the HTML is byte-for-byte a genuine row, exactly as a fenced row is. It is not a regression — nothing TASK-241 did made it reachable — and TASK-234's conversion of the record to .perry/conformance.jsonl dissolves it entirely. It is filed because the file gates every write under ADR-004's enforce gate and because TASK-241's section 9 mentions HTML blocks only in the fence-line direction, which reads as coverage; that wording is being corrected in the same round.", "owner": "Coding Agent", "status": "dropped", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Blocked until TASK-241 lands. Start from evidence/2026-08/TASK-241-round2-v4-review.md, which carries the measurement at all three trees. Read TASK-246 beside this one — same file, same class of question about what the reader should do with a row it will not honour — and consider whether the two want one answer rather than two. Note the reviewer's framing: this is invisible to the round-trip property BY CONSTRUCTION, for the same reason a fenced row is, so the answer is structural rather than another predicate.", "depends_on": ["TASK-241"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-30T05:19:33+08:00", "order": null}
+{"id": "TASK-261", "title": "Tier A — the ADR-004 declaration gate comes out; perry-conform keeps only its shared helpers", "summary": "23 records, all route: declare, all Perry's own files, zero migrations and zero disagreements. The gate's value needs a foreign project that drifts, and Perry has never been run on one. The delete list said 'delete bin/perry-conform, 974 lines'; that was wrong — 598 lines are the dead ledger and ~280 are helpers four tools depend on, so the file is gutted, not removed.", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "6ce1f5b, 436d0fb; evidence/2026-08/2026-08-31-TASK-261-migration-fork.md", "next_action": "blocked on migration fork: perry-migrate's output is the deleted ledger", "depends_on": ["USER-910"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-31T20:27:48+08:00", "order": null}
diff --git a/reference/adoption.md b/reference/adoption.md
index dfa1216c..09bf7a1e 100644
--- a/reference/adoption.md
+++ b/reference/adoption.md
@@ -429,62 +429,31 @@ If Perry state already exists (a partially adopted project, or one adopted at
   the worse direction: silently dropping real work.
 - Existing state is never overwritten. Adoption only adds.
 
-## Migration — the project that already has state
+## Migration — deleted, and what replaced it
 
-Adoption converts a project that has *no* Perry state. A different case turns up
-just as often: a project whose `BOARD.md`, `OKR.md` and `design/` already exist,
-written by hand or by an older Perry, in a shape today's readers cannot parse.
-ADR-004 says those files are **read-only until they migrate**, and this is where
-that happens.
+A section here used to cover *the project that already has state*: a
+`BOARD.md`, `OKR.md` and `design/` written by hand or by an older Perry, in a
+shape today's readers cannot parse. ADR-004 made those files **read-only until
+they migrated**, and `bin/perry-migrate` was the road out.
 
-**Do not do it by hand, and do not describe it here.** Run it:
+**All three of those things are gone** (`TASK-261`, `USER-910`). The ADR-004
+write gate is deleted, so nothing is read-only for want of a declaration any
+more — a writer that can render a file writes it. `perry-migrate` is deleted
+with it, because its output *was* the declaration: 2,393 lines that, across the
+whole life of this project, moved zero foreign projects. `.perry/conformance.jsonl`
+held 23 records and not one carried `route: migrate`.
 
-```
-python3 "$PERRY_HOME/bin/perry-migrate" --root .          # the complete diff, writes nothing
-python3 "$PERRY_HOME/bin/perry-migrate" apply --root .    # writes, declares, names a restore point
-```
+If a project's files are in a shape Perry cannot read, the answer now is
+`/perry adopt` — which reads what is there as evidence and writes Perry's own
+state — or `perry-lint`, which names what does not parse. Neither pretends to
+convert somebody's file in place, and nothing refuses a write because a
+declaration is missing.
+
+**If foreign projects become a real goal**, the cheap shape is an *importer you
+re-run* — read a foreign board, write Perry state, overwrite on conflict — not
+a lossless recoverable migrator with a declaration format of its own. That is
+recorded in `evidence/2026-08/2026-08-31-TASK-261-migration-fork.md`.
 
-Prose cannot assert that the id set before equals the id set after. The tool
-does — for every file, before it writes it — and refuses the file if it cannot.
-It also holds the rule this file's § "The one rule" states, in the one place it
-is hardest to hold: **the sections it creates are empty**. A project that files
-work under `## Open — 工程线` keeps that heading and every row under it; nothing
-is moved into `## P0`, because nothing in the file says which work is P0 and
-inferring it is exactly what this pipeline forbids.
-
-It asserts a second thing, which took longer to learn: **that the file still
-says what it said.** Every id, cell, character and row count can survive an edit
-that reverses the claim — a two-column legend under `## P0` widened into a task
-table, a `Status: not yet locked` normalized to `locked`, a token spliced into a
-sentence about a vendor contract. So the tool also re-reads its own output with
-`viewer/parsers` and refuses a file whose *records* changed, a line it rewrote
-that is neither a table row nor part of the header block, and a canonical value
-the author's own words — kept beside it — do not say. Each check names what it
-cannot see; `bin/perry-migrate § meaning()` is where that is written down.
-
-What the agent does around it:
-
-1. **Show the dry run.** All of it. It is the artifact the user is agreeing to.
-2. **Read back the files it will not touch**, and why. A file it refuses is
-   left byte-identical — an unresolvable status word, a table Perry does not
-   recognise, a file over its size cap. Each is one hand edit, and after it
-   `perry-migrate apply` finishes the job. "Does not recognise" is a
-   vocabulary test, not a shape one: a table is Perry's when more of the
-   schema's column names are already in its header than are missing from it.
-   `ID`, `Status` and `Owner` are the commonest words in any markdown table,
-   and sharing one of them is a coincidence.
-3. **Read the migrated files.** Not the diff — the files, as a reader. The
-   assertions above exist because three defects in a row passed thirty
-   mutations and were found by somebody opening the file, and each check states
-   its own blind spot precisely so this step still has something to do.
-4. **Never run `apply` without being asked.** ADR-004 § 4: mandatory migration
-   means the tool may refuse without it; it never means the tool may perform it
-   unasked.
-5. **Hand the restore point to the user by name.** `perry-migrate restore
-   <run-id>` puts every byte back, including the declarations the run made.
-
-`perry-migrate` refuses outright on a project with no Perry state — that project
-wants `/perry adopt`, above, which writes Perry's shape in the first place.
 
 ## Post-adoption report
 
@@ -518,9 +487,9 @@ Then hand off to the normal standup.
   `evidence/<YYYY-MM>/<TASK-ID>-spec.md`. Two sources of truth means a board that
   rots within a month.
 - **Never fuzzy-matches** — not for attribution, not for dedupe.
-- **Never migrates existing state as a side effect.** A project whose files
-  predate Perry's shape is converted by `bin/perry-migrate`, on the user's
-  explicit instruction, after they have read the diff — see § Migration.
+- **Never rewrites existing state in place.** Adoption reads what is there as
+  evidence and writes Perry's own state; it never edits somebody's file to make
+  it parse. The tool that used to do that is deleted — § Migration.
 - **Never re-asks a question the user already answered.** A banked declaration is
   re-rendered for confirmation, never discarded and re-put.
 - **Never resumes without being asked to.** Detection is automatic; continuation
@@ -529,8 +498,9 @@ Then hand off to the normal standup.
 
 ## See also
 
-- [../bin/perry-migrate](../bin/perry-migrate) — the migration itself, and the
-  five guarantees it holds. § Migration above calls it; it does not restate it.
+- [../perry/evidence/2026-08/2026-08-31-TASK-261-migration-fork.md](../perry/evidence/2026-08/2026-08-31-TASK-261-migration-fork.md)
+  — why `perry-migrate` and the ADR-004 write gate were deleted, and what an
+  importer would look like if foreign projects become a goal. § Migration above.
 - [adoption-sources.md](adoption-sources.md) — the source catalog: detectors, trust
   tiers, what each source may emit, and the depth matrix. Non-code projects are
   handled here, not in this file.
diff --git a/reference/config.md b/reference/config.md
index 23c709a8..7d14a95d 100644
--- a/reference/config.md
+++ b/reference/config.md
@@ -1,4 +1,4 @@
-# `.perry/config.md` — repo layout, state root, tracks, conformance gate
+# `.perry/config.md` — repo layout, state root, tracks
 
 Tier 1. Loaded on demand from `SKILL.md § Configuration`, which carries the
 field list and points here for what each field means.
@@ -140,31 +140,21 @@ converge by accretion; `1` makes every FAIL a decision point. Raise
 `Session context ceiling` for a project doing genuinely wide reads, knowing the
 cost of doing so does not grow linearly.
 
-### `Conformance gate` — and the one thing the agent must not do
-
-Under [ADR-004](perry/decisions/ADR-004-mandatory-migration.md) a project
-migrates to Perry's shape once, and every writer then gates on a **declared**
-marker: *this file matches Perry's shape, at shape version N, and the user said
-so*. The declarations live in `.perry/conformance.jsonl` — a store, one JSON
-object per line, since TASK-234, with no rendered markdown beside it because
-`perry-conform status` is the human surface. A project written before that keeps
-its `.perry/conformance.md` and converts it once with `perry-conform migrate`,
-which declares nothing and carries every date and route across unchanged.
-`bin/perry-conform`
-computes the verdict and is the only thing that writes them.
-
-The gate **enforces** — `perry-task`, `perry-goals` and `perry-decide` refuse a
-file nobody has declared, and the refusal names the file, the shape version it
-was checked against, and the command that fixes it (`perry-conform declare` for
-a file that already matches Perry's shape, `perry-migrate` for one that does
-not). Set `Conformance gate: advisory` (or export `PERRY_CONFORMANCE=advisory`)
-to make them write anyway and print what they found instead. **Reading is never
-gated in either mode**, and neither is `perry-migrate` — it is how an undeclared
-project becomes declarable, so a gate that refused it would be a wall with no
-door. `perry-goals commit --migrate` is exempt for the same reason.
-
-When a write prints a conformance line, **relay it and let the user decide.** Do
-not run `perry-conform declare` on the user's behalf: `perry/OKR.md` — *"adoption
-proposes; the user declares"* — is the rule the marker exists to encode, and a
-tool or an agent stamping it unasked is the violation, not the shortcut. Say
-which file, which verdict, and which command; then wait.
+### `Conformance gate` — deleted
+
+This setting is gone (`TASK-261`). Under ADR-004 every writer gated on a
+**declared** marker — *this file matches Perry's shape, at shape version N, and
+the user said so* — and refused a file nobody had declared.
+
+It never caught anything. `.perry/conformance.jsonl` held 23 records at the
+end, all `route: declare`, all files in Perry's own repository: zero
+disagreements, because the disagreement the design exists to surface needs a
+foreign project that drifts and Perry has never been pointed at one. The gate,
+its ledger, `bin/perry-conform` and `bin/perry-migrate` are all deleted.
+
+**What this changes for you**: nothing refuses a write for want of a
+declaration. A writer that can render a file writes it. `perry-lint` still says
+whether a file matches the schema — that half was never the gate.
+
+A `- Conformance gate:` line left in an existing `.perry/config.md` is inert.
+Nothing reads it and nothing reports it.
diff --git a/reference/glossary.md b/reference/glossary.md
index 6acb9ec7..f0a2f144 100644
--- a/reference/glossary.md
+++ b/reference/glossary.md
@@ -119,10 +119,10 @@ board.
 Implemented: schema/state-schema.json
 
 ### conformance
-Two unrelated things, and the collision is deliberate only in that both are
-named in the schema: (a) the block in a read contract naming what the board did
-**not** parse cleanly; (b) a file's declared shape version under
-`perry-conform`.
+The block in a read contract naming what the board did **not** parse cleanly.
+It used to be two unrelated things under one word; the other — a file's
+declared shape version under `perry-conform` — is deleted (`TASK-261`), and the
+collision went with it.
 Implemented: schema/task-list-contract.md
 
 ### the hand-off contract
diff --git a/schema/state-schema.json b/schema/state-schema.json
index 5727cbe4..8d52da28 100644
--- a/schema/state-schema.json
+++ b/schema/state-schema.json
@@ -775,38 +775,6 @@
       ]
     }
   },
-  "migration": {
-    "description": "Read only by bin/perry-migrate. It does NOT widen what perry-lint accepts, and it is not an exception to i18n.invariant - enum values stay ASCII in every language, and making that true of a file that predates the rule is exactly what the migration is for. This table declares the localized spellings that have been SEEN standing where a canonical enum value belongs, so that resolving one is a lookup in a declared vocabulary rather than a translation performed in code. A spelling that is not listed here is not guessed at: perry-migrate reports the field and leaves the file alone. Extend it when a real project shows a new spelling; never from imagination, because an entry here decides what somebody's file is asserting.",
-    "enum_aliases": {
-      "phase_status": {
-        "进行中": "active",
-        "已评分": "scored"
-      }
-    },
-    "negations_description": "The other half of the same vocabulary. A canonical value standing in a sentence is not the same as one standing alone: `> Status: not yet locked - do not build from this` contains the word `locked`, and reading it as `locked` reverses what the author wrote while preserving every character they typed. perry-migrate drops a candidate whose clause is denied by one of these words, so a value that says only what it is NOT resolves to nothing and is reported rather than guessed. Same discipline as enum_aliases: a denial word is looked up here, never inferred in code. What it cannot see is stated in perry-migrate's enum_candidates - negation carried by grammar rather than by a word.",
-    "negations": [
-      "not",
-      "no",
-      "never",
-      "nor",
-      "cannot",
-      "isn't",
-      "aren't",
-      "wasn't",
-      "weren't",
-      "don't",
-      "doesn't",
-      "didn't",
-      "won't",
-      "hasn't",
-      "haven't",
-      "未",
-      "尚未",
-      "没",
-      "不",
-      "非"
-    ]
-  },
   "thresholds": {
     "review_fail_rounds_before_escalation": {
       "value": 2,
diff --git a/work/reference/review-constraints.md b/work/reference/review-constraints.md
index a375903f..42151453 100644
--- a/work/reference/review-constraints.md
+++ b/work/reference/review-constraints.md
@@ -40,13 +40,12 @@ purpose of.
 
 ## Do not run the write side against what you are reviewing
 
-A Perry tool that writes — `perry-task`, `perry-goals`, `perry-decide`,
-`perry-migrate apply`, `perry-conform declare` — changes board rows, journal
-lines and the event log. Running one against the project under review injects
+A Perry tool that writes — `perry-task`, `perry-goals`, `perry-decide` —
+changes board rows, journal lines and the event log. Running one against the project under review injects
 your own events into the history you are checking.
 
 Read tools are safe and are the point: `perry-task list --json`,
-`perry-state --json`, `perry-lint`, `perry-migrate --dry-run`.
+`perry-state --json`, `perry-lint`.
 
 **Never run `setup`.** Its `sweep_legacy_links` step removes symlinks under the
 host's skills directory, and on a developer machine those are real installs.