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..618601aa 100644 --- a/.perry/events.jsonl +++ b/.perry/events.jsonl @@ -1396,3 +1396,15 @@ {"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"} +{"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
, an HTML comment, or 
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//.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, `
` or `
` 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 … → 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/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 89c2b433..e49d58dd 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 @@ -4169,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"] @@ -4192,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( @@ -4311,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 deleted file mode 100755 index d4b001c8..00000000 --- a/bin/perry-migrate +++ /dev/null @@ -1,2393 +0,0 @@ -#!/usr/bin/env python3 -""" -perry-migrate — bring a project's existing state files to Perry's shape, once. - -ADR-004 makes migration the price of using Perry's write features, which makes -this the only thing in Perry that rewrites a stranger's files. Its five -guarantees are in `perry/evidence/2026-08/TASK-044-spec.md`; three of them are -*assertions*, and that is why this is a program and not a paragraph: - - 1. **Dry run first, always.** `perry-migrate` with no subcommand plans and - prints the complete diff. It cannot write — `apply` is a different word. - The plan carries the full post-image of every file, so `apply` is - `path.write_text(plan.after)` and nothing else. The dry run and the real - run cannot diverge because there is one computation, not two. - 2. **Nothing is lost, and nothing is changed into something else.** Two - groups of assertions run over every file before it is written, and a file - that fails any of them is not written at all. `losslessness()` asks - whether it is all still there — every non-whitespace character, every - table cell, every id, every per-section row count, and every line - accounted for. `meaning()` asks whether it still says the same thing — - because a legend widened into a task table, a `not yet locked` normalized - to `locked`, and a token spliced into a sentence about a vendor contract - all pass every check in the first group. Each part of `meaning()` states - what it cannot see; see the note above it. - 3. **Recoverable.** Every run writes a restore point naming every file it - touched, with the bytes it found there. `perry-migrate restore` puts them - back. See § "Dirty tree or restore point" below. - 4. **The user declares.** `apply` is the user's act, and it records the - declaration through `bin/perry-conform` — the one writer of - `.perry/conformance.jsonl` — with `route: migrate`. There is no second - record. The run's id travels with each declaration it records, so a row - can name the run that made it and the restore point that undoes it - (TASK-234); four markdown columns could not. - 5. **Partial migration is a state.** Per file, never per project. A file is - migrated only if the plan takes it to zero shape errors; otherwise it is - left byte-identical and its remaining findings are named. So after any - run, every file is either exactly as its author left it, or conformant. - -Usage: - perry-migrate [--root ] [--only ]... [--json] - perry-migrate apply [--root ] [--only ]... [--json] - [--no-declare] - perry-migrate restore [] [--root ] [--list] [--json] - -Exit codes: - 0 the plan is complete / everything planned was applied - 1 something could not be migrated, or a run was refused - 2 bad invocation - -No LLM, no external dependencies (stdlib only). The default subcommand is -read-only; `apply` and `restore` are the only writers. - -── What migration changes, and what it never touches ────────────────────── - -**Must change to be Perry-shaped** — the things a *reader* keys on by name: - - · the presence of a required section heading (readers select by heading) - · the columns parsers resolve by name, in a table Perry already recognises - · the token in an enum-valued header field (behaviour gates on it) - · the presence of a required header field - -**Merely different** — never touched, in any file: - - · any heading the schema does not require. `## Open — 工程线 · phase #004` - is 41 tasks under a name its author chose; `bin/perry-task` already reads - it as a `group`, and renaming it would be Perry deciding what someone's - workstream is called. - · heading text beyond the part the schema matches (`## P2 (低优先 carry)` - keeps its parenthetical) - · column order, row order, section order, extra columns, extra sections - · every cell, every line of prose, the language it is written in - · anything only a *warning* fires on. Warnings in this schema are quality - signals and some are time-dependent (`stale-run`); a migration that acted - on them would be a quality pass, which this is not. - -**Must change, but Perry may not decide** — reported, never guessed: - - · an enum value that resolves to two candidates, or to none — including a - value that says only what it is *not* - · a table under a required heading that does not speak the schema's - vocabulary — Perry does not recognise it, and widening it would be writing - columns into somebody else's table. Sharing one column name is not - vocabulary: `ID`, `Status` and `Owner` are the commonest words in any - markdown table, and a two-row legend under `## P0` has an `ID` column. - `is_the_schemas_table()` states the test and what it cannot see. - · a field-shaped sentence in the body. `Status:` in a paragraph is prose; - only the header block is Perry's to write into - · a file over its size cap: splitting it is a content decision - · a required section whose *content* the schema constrains - -── A file whose mode says read-only ─────────────────────────────────────── - -Migration replaces it, and now says so. `write_atomic` stages a temp file and -calls `os.replace`, and a rename needs write permission on the *directory*, -not on the target — so a file its author `chmod -w`'d was migrated like any -other and nothing in the plan mentioned the bit. It is named in the per-file -list in both modes, with the mode it was found in, because `TASK-044-spec` -asks that list for "every file it touched, with what changed in each" and a -permission the run crossed belongs to that answer. - -That is a report, not a decision. Whether migration should refuse such a file -instead is `USER-004` and is open; nothing here answers it, and the behaviour -is unchanged — the file is migrated, its mode is left as found, and the -restore point carries its original bytes. - -── Dirty tree or restore point ──────────────────────────────────────────── - -TASK-044 § 3 asks for one of the two, and says why the choice needs stating. - -This writes a **restore point**, and does not refuse on a dirty tree. - -Refusing on a dirty working tree answers the question only for projects under -git, and `~/proj/gimegime-pmo` — the project this was built against — is a -local-only repo whose state files are routinely uncommitted. A guarantee that -is absent exactly where the risk is highest is not a guarantee. It is also -weaker than it looks on projects that *do* use git: state files are frequently -untracked or ignored, and `git checkout` cannot restore what git never saw. - -So recovery is Perry's own, works identically in both cases, and is exercised -rather than described: `.perry/migrate/.json` holds the bytes of every -file the run touched — including `.perry/conformance.jsonl`, because the run -wrote that too and a restore that left the record behind would claim -conformance for files that no longer have it. And `.perry/conformance.md` when -the project still has one, because the run CONVERTS it (TASK-234) and a -conversion is a deletion: a restore that put the store back and left the -markdown deleted would take the user's pre-conversion record with it. - -The cost, stated: this is state Perry now owns, and it holds a copy of the -project's own writing. It lives under `.perry/`, which is already Perry's -namespace; it is JSON, so it is invisible to `perry-lint --claims`, which -globs `*.md`; and `perry-migrate restore --list` is how you find one. - -A dirty git tree is *reported* in the output, because it changes what a `git -checkout` would undo — but it is not a refusal. -""" - -from __future__ import annotations - -import atexit -import base64 -import difflib -import fnmatch -import hashlib -import importlib.machinery -import importlib.util -import json -import os -import re -import shutil -import stat -import subprocess -import sys -import tempfile -from collections import Counter -from dataclasses import dataclass, field -from datetime import datetime -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, split_row # noqa: E402 -import lib # noqa: E402 -import perry_store # noqa: E402 - -write_atomic = lib.write_atomic - -MIGRATE_DIR = ".perry/migrate" - -_MODULES: dict[str, object] = {} - - -def _load(name: str, filename: str): - """A `bin/` script with a dash in its name, as an importable module. - - Same loader `bin/perry-conform` uses on `bin/perry-lint`, and for the same - reason: this tool must contain no second definition of Perry's shape, no - second definition of the conformance record, and no second copy of the - column glossary. It proposes edits; the linter judges them.""" - if name not in _MODULES: - sys.path.insert(0, str(PERRY_HOME / "bin")) - spec = importlib.util.spec_from_loader( - name, importlib.machinery.SourceFileLoader( - name, str(PERRY_HOME / "bin" / filename))) - mod = importlib.util.module_from_spec(spec) - sys.modules.setdefault(name, mod) - spec.loader.exec_module(mod) - _MODULES[name] = mod - return _MODULES[name] - - -def conform(): - return _load("perry_conform", "perry-conform") - - -def _q(value) -> str: - """`bin/perry-conform § _q`, imported rather than re-typed. - - One argument of a handed-back command, shell-quoted. Same reason as - `_root_flag` below: one rule, one spelling. A run id looks safe and a - restore point's stem is derived from a clock, but "looks safe today" is - 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) - - -def _root_flag(root_arg: str | None) -> str: - """`bin/perry-conform § _root_flag`, imported rather than re-typed. - - This module had its own copy inline in `render`. One rule with two - spellings is how the second one goes stale, and the rule here is the one - 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) - - -def lint(): - """`bin/perry-lint`, and specifically **the instance `perry-conform` armed**. - - Not `_load("perry_lint", ...)`. `perry-conform.load_schema` calls - `load_glossary`, which populates module-level tables that decide whether - `编号` satisfies the `ID` column. Loading a second copy of the linter gives - 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() - - -def task(): - """`bin/perry-task`, for `display_name` / `header_language` only. - - A column this tool adds has to be spelled the way the rest of that table is - spelled, and `perry-task` is where that glossary lookup lives. Copying it - here would be the two-implementations defect ADR-004 is about.""" - return _load("perry_task", "perry-task") - - -class Refused(Exception): - """A refusal is an outcome, not a crash. Nothing was written.""" - - -# ── the language a file is written in ───────────────────────────────────── - - -def doc_language(project_root: Path) -> str: - """`zh` or `en`, from `.perry/config.md § Document language`. - - A section this tool creates in a Chinese project reads `## 流程说明`, not - `## Process Note` — `bin/perry-task.ensure_section` already localizes the - sections *it* creates, and a migration that hardcoded English would leave a - board written in two languages.""" - cfg = project_root / ".perry" / "config.md" - if not cfg.exists(): - return "en" - m = re.search(r"Document language\s*[::]\s*([^\n]+)", - cfg.read_text(errors="replace"), re.I) - value = (m.group(1) if m else "").lower() - if "中文" in value or "zh" in value or "chinese" in value: - return "zh" - return "en" - - -def heading_spelling(schema: dict, label: str, lang: str) -> str: - """The heading text to write, in the project's own language.""" - per_lang = ((schema.get("i18n") or {}).get("headings") or {}).get(label) or {} - names = per_lang.get(lang) or [] - return names[0] if names else label - - -def field_spelling(schema: dict, name: str, lang: str) -> str: - per_lang = ((schema.get("i18n") or {}).get("fields") or {}).get(name) or {} - names = per_lang.get(lang) or [] - return names[0] if names else name - - -# ── the change record ───────────────────────────────────────────────────── - -#: Why a cleared owner-write bit does not stop a migration. One sentence, one -#: definition, used by both the rendered plan and the `--json` one, so the two -#: cannot say different things about the same observation. -READ_ONLY_MECHANISM = ( - "the write is a rename over this path, and a rename needs write permission " - "on the directory, not on the file. The mode itself is left as found.") - - -def owner_read_only(path: Path) -> int | None: - """The file's permission bits when they deny its **owner** write, else None. - - Deliberately the owner bit and not `os.access(path, os.W_OK)`: what this - reports is the signal a `chmod -w` leaves, which is a property of the file - the project's author set. Effective writability is a different question — - it varies by uid and by mount, and root passes it on a 0444 file — and - answering it here would report something other than what was observed.""" - try: - mode = stat.S_IMODE(path.stat().st_mode) - except OSError: - # Planning never fails on account of the report. A file whose mode - # cannot be read is one this run has nothing to say about. - return None - return None if mode & stat.S_IWUSR else mode - - -@dataclass -class Change: - kind: str - detail: str - line: int | None = None - - def as_dict(self) -> dict: - return {"kind": self.kind, "detail": self.detail, "line": self.line} - - -@dataclass -class Edit: - """One file's planned post-image, and everything asserted about it.""" - key: str - path: Path - before: str - after: str - before_bytes: bytes | None = None - after_bytes: bytes | None = None - #: the same file relative to the *project* root. `key` is relative to the - #: state root (or to the project root for the `.perry/` entries), which is - #: what the schema declares and what a declaration is filed under; the - #: restore point needs a path it can resolve without knowing which. - key_rel: str = "" - changes: list[Change] = field(default_factory=list) - rewritten: list[str] = field(default_factory=list) # original line texts - residual: list = field(default_factory=list) # perry-lint Findings - before_errors: int = 0 - violations: list[str] = field(default_factory=list) - minted: list[str] = field(default_factory=list) - existed: bool = True - #: The file's permission bits as planning found them, when those bits deny - #: its **owner** write — `None` otherwise. Observed, never acted on: - #: `write_atomic` stages a temp file and renames over the target, and a - #: rename needs write permission on the *directory*, not on the file, so - #: the bit never stopped a migration and nothing in the plan said it was - #: there. Whether it *should* stop one is USER-004 and is not decided by - #: recording it here. - read_only_mode: int | None = None - - @property - def touched(self) -> bool: - return self.image_after != self.image_before - - @property - def image_before(self) -> bytes: - return self.before_bytes if self.before_bytes is not None else self.before.encode("utf-8") - - @property - def image_after(self) -> bytes: - return self.after_bytes if self.after_bytes is not None else self.after.encode("utf-8") - - @property - def writable(self) -> bool: - """Written only if it lands *conformant*. See § 5 in the module docstring. - - A file migrated halfway is the worst of both outcomes: the author's file - changed and they gained nothing, because a writer still refuses it. So - "valid" for an incomplete migration means every file is either exactly - as its author left it or conformant — never in between.""" - return self.touched and not self.residual and not self.violations - - @property - def overrode_read_only(self) -> bool: - """The file denies its owner write, and this plan replaces it anyway. - - The two halves have to be asked together. A read-only file the plan - leaves byte-identical overrides nothing, and reporting it would name a - permission no write ever crossed.""" - return self.read_only_mode is not None and self.writable - - def read_only_note(self, applied: bool) -> str: - """What was observed about the mode. Not what to do about it. - - The refuse-versus-report question is USER-004, and this sentence has to - read the same whichever way that lands — so it says what the run did - and why the bit did not stop it, and stops there.""" - return (f"read-only for its owner (mode {self.read_only_mode:04o}) — " - f"{'replaced' if applied else 'would be replaced'} anyway: " - f"{READ_ONLY_MECHANISM}") - - def as_dict(self) -> dict: - record = { - "path": self.key, - "before_sha256": sha(self.image_before), - "after_sha256": sha(self.image_after), - "before_errors": self.before_errors, - "after_errors": len(self.residual), - "changes": [c.as_dict() for c in self.changes], - "residual": [f.as_dict() for f in self.residual], - "assertion_failures": self.violations, - "minted_ids": self.minted, - "writable": self.writable, - } - # Added only when there is something to say. A `"read_only": false` on - # every ordinary file would be new noise on the path where nothing was - # observed, and this task's whole content is what was observed. - if self.overrode_read_only: - record["read_only_override"] = { - "mode": f"{self.read_only_mode:04o}", - "observed": f"read-only for its owner; {READ_ONLY_MECHANISM}", - } - return record - - -def sha(value: str | bytes) -> str: - data = value if isinstance(value, bytes) else value.encode("utf-8") - return hashlib.sha256(data).hexdigest() - - -def decode_image(data: bytes, key: str) -> tuple[str, str]: - """Decode one UTF-8 file and return logical LF text plus its line ending.""" - try: - text = data.decode("utf-8") - except UnicodeDecodeError as exc: - raise Refused( - f"{key} is not valid UTF-8 ({exc}). Migration only rewrites UTF-8 " - f"text files and left this path byte-identical") from None - crlf = data.count(b"\r\n") - lf = data.count(b"\n") - if crlf and crlf == lf: - return text.replace("\r\n", "\n"), "\r\n" - return text, "\n" - - -def encode_image(text: str, newline: str) -> bytes: - if newline != "\n": - text = text.replace("\n", newline) - return text.encode("utf-8") - - -# ── locating things in a file ───────────────────────────────────────────── - - -SEP_RE = re.compile(r"^\|\s*:?-{2,}") - - -def is_separator(line: str) -> bool: - return bool(SEP_RE.match(line.strip())) - - -def is_row(line: str) -> bool: - s = line.strip() - return s.startswith("|") and not is_separator(s) - - -def heads(lines: list[str]) -> list[tuple[int, str, int]]: - """`(level, text, 0-based index)` for every ATX heading. - - `bin/perry-lint.headings` is the definition — including its fenced-code - skipping — reused rather than re-derived, and shifted to 0-based here - because this file edits a list of lines rather than reporting positions.""" - return [(lvl, text, no - 1) for lvl, text, no in lint().headings("\n".join(lines))] - - -def section_bounds(lines: list[str], level: int, matcher: re.Pattern) -> list[tuple[int, int]]: - """`(start, end)` line indices of every section whose heading matches.""" - hs = heads(lines) - out = [] - for i, (lvl, text, idx) in enumerate(hs): - if lvl != level or not matcher.search(text): - continue - end = len(lines) - for lvl2, _, idx2 in hs[i + 1:]: - if lvl2 <= level: - end = idx2 - break - out.append((idx + 1, end)) - return out - - -def tables_in(lines: list[str], start: int, end: int) -> list[tuple[int, int, list[int]]]: - """`(header index, separator index, row indices)` for each table in a range.""" - out = [] - i = start - while i < end: - if is_separator(lines[i]) and i > start and is_row(lines[i - 1]): - rows = [] - j = i + 1 - while j < end and is_row(lines[j]): - rows.append(j) - j += 1 - out.append((i - 1, i, rows)) - i = j - continue - i += 1 - return out - - -# ── T1 · a required section that is not there ───────────────────────────── - - -def literal_label(req: dict) -> str | None: - """The heading text to write for this requirement, or None if it is a shape. - - `## P0` is a name. `## Objective ` and - `## §1 … §8 (eight fixed sections)` are descriptions of a family, and - writing one verbatim into someone's file would be Perry inserting - punctuation as a section title. The test is both directions: the label must - carry no placeholder syntax AND must satisfy its own `match` regex, so a - label that only *looks* literal cannot slip through.""" - text = req["label"].lstrip("#").strip() - if re.search(r"[<>…()]", text): - return None - if not re.compile(req["match"]).search(text): - return None - return text - - -def table_for_heading(spec: dict, req: dict) -> dict | None: - for tspec in spec.get("tables", []): - if tspec.get("under_level", 2) != req["level"]: - continue - if re.compile(tspec["under"]).search(req["label"].lstrip("#").strip()): - return tspec - return None - - -def present(lines: list[str], req: dict) -> int | None: - m = re.compile(req["match"]) - for lvl, text, idx in heads(lines): - if lvl == req["level"] and m.search(text): - return idx - return None - - -def fix_sections(lines: list[str], spec: dict, schema: dict, lang: str, - errors: list, changes: list[Change]) -> list[str]: - """Insert missing required sections, in the schema's own order. - - Position is chosen so the file still reads in the order Perry's templates - put things: after the last required section that precedes it and is - present, or before the first that follows it, or at the end. Nothing - existing moves, and no section body is ever split. - - Driven off the linter's **errors**, not off the spec. `missing-section` is - the one finding in this schema whose severity varies: a design doc in - `draft` may be incomplete, and only `locked` makes its sections mandatory. - Reading the spec directly inserted `## Implementation plan` into a draft — - a migration acting on a warning, which is a quality pass, which this is - not. The gate that decides is `check_file`'s, and it stays there. - """ - reqs = spec.get("headings", []) - wanted = {f.message for f in errors if f.rule == "missing-section"} - for i, req in enumerate(reqs): - if f"required section not found: {req['label']}" not in wanted: - continue - if present(lines, req) is not None: - continue - label = literal_label(req) - if label is None: - continue - shown = heading_spelling(schema, label, lang) - at = insertion_point(lines, reqs, i) - lead = [] if at == 0 or not lines[at - 1].strip() else [""] - block = lead + ["#" * req["level"] + f" {shown}", ""] - tspec = table_for_heading(spec, req) - if tspec: - cols = [task().display_name(c, lang) for c in tspec["columns"]] - block += [render_row(cols), - "|" + "|".join(["---"] * len(cols)) + "|", ""] - lines[at:at] = block - changes.append(Change("section-added", - f"`{'#' * req['level']} {shown}`" - + (" with its column header" if tspec else "") - + " — empty; nothing was moved into it", - at + 2)) - return lines - - -def insertion_point(lines: list[str], reqs: list[dict], i: int) -> int: - for j in range(i - 1, -1, -1): - at = present(lines, reqs[j]) - if at is None: - continue - level = reqs[j]["level"] - for lvl, _, idx in heads(lines): - if idx > at and lvl <= level: - return idx - return len(lines) - for j in range(i + 1, len(reqs)): - at = present(lines, reqs[j]) - if at is not None: - return at - return len(lines) - - -# ── T2 · a table missing the columns parsers key on ─────────────────────── - - -def is_the_schemas_table(tspec: dict, got: list[str], satisfied) -> bool: - """Is this the schema's table, or somebody else's that shares a word? - - **It is the schema's when more of the schema's names are already in its - header than are missing from it.** - - The rule used to be "it is Perry's unless it shares *zero* column names", - which is the weakest test the sentence in the module docstring can be read - as, and `ID` / `Status` / `Owner` / `Title` / `Date` are the commonest words - in any markdown table ever written. A two-row legend under `## P0 holding` - - | ID | Meaning | - | INV-* | investments | - - shares `ID`, so migration bolted five columns onto it, `perry-lint` went 6 - errors → 0, the conformance marker declared the board conformant, and - `perry-task list` then returned two tasks with the ids `INV-` and `ENG-` and - no titles — through a frozen contract. ADR-004's own Context table cites - this exact shape (a legend under a section heading getting columns bolted - on) as the reason ADR-004 exists. - - Recognition is therefore by **vocabulary**, the way `is_header_block` tells a - header block from a quoted disclaimer: one shared word is a coincidence, a - majority is a table speaking the schema's language. `| ID | Title | Owner | - Status |` under `## P2` is 4 of the board's 6 — that table, missing two - columns, which is precisely what widening is for. Extra columns the author - added cost nothing: the test counts the schema's names, not the table's. - - What it cannot see, stated: - - - **a genuine table written with only two of six names is refused.** - `| ID | Title |` under `## P0` is a board table by intent and a minority - by this test, so it is reported and left byte-identical — the same - outcome as any other table Perry does not recognise, and one hand edit - away from migrating. That is the safe direction of the two. - - **it counts names, not meaning.** A legend whose columns happened to be a - majority of the schema's would pass. `meaning()`'s reading assertion is - the second line of defence there, and it is a different question asked by - a different reader. - """ - cols = tspec["columns"] - present = [c for c in cols if satisfied(c, got)] - return len(present) > len(cols) - len(present) - - -def fix_tables(lines: list[str], spec: dict, schema: dict, - changes: list[Change], rewritten: list[str], *, - root_arg: str | None) -> list[str]: - """`root_arg` is keyword-only with no default for the reason every other - one on this path is: the `split-needed` change below hands the reader - `perry-goals commit --migrate`, which WRITES `OKR.md`, and a caller that - can say nothing is a caller that will.""" - L = lint() - for tspec in spec.get("tables", []): - matcher = re.compile(tspec["under"]) - level = tspec.get("under_level", 2) - prefix = tspec.get("column_match") == "prefix" - - def satisfied(col: str, got: list[str]) -> bool: - return any(any(g.startswith(a) for g in got) if prefix else a in got - for a in L.accepted(col)) - - for start, end in section_bounds(lines, level, matcher): - for hdr_i, sep_i, row_is in tables_in(lines, start, end): - header = split_row(lines[hdr_i]) - got = L.header_index(header) - if not got: - continue - missing = [c for c in tspec["columns"] if not satisfied(c, got)] - # **A column SPLIT is not a column ADD, and doing the add is - # worse than doing nothing.** `OKR.md § Commitments` used to - # carry one `By when` cell holding either a date or prose; - # TASK-091 split it into a typed `Due` and a prose `By when - # note` (ADR-007, decision 3). Appending an empty `Due` here - # leaves the table with both columns, every promise's clock - # still in the retired one, and `perry-lint` calling it clean — - # a file that looks migrated and is not. - # - # The transform that moves the VALUES belongs to the register's - # own writer, which is also the one tool that may write this - # file: `perry-goals commit --migrate`. So this reports and - # leaves the table byte-identical, which is what every other - # table this tool does not recognise already gets. - if "Due" in missing and L.norm("By when") in got: - # Reported once, not once per pass: `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. A finding printed twice reads as two tables. - if not any(c.kind == "split-needed" for c in changes): - changes.append(Change( - "split-needed", - f"`Commitments` still carries the pre-split " - f"`By when` column. That is a column SPLIT, not a " - f"column add — run `perry-goals commit " - f"--migrate{_root_flag(root_arg)}`, which moves " - f"each cell into `Due` or `By when note` by its " - f"value and drops nothing. Left byte-identical " - f"here.", - hdr_i + 1)) - continue - if not missing: - continue - if not is_the_schemas_table(tspec, got, satisfied): - # Not the schema's table. A `| 种子 | Owner | Deliverable |` - # under `## 目标 1` shares nothing with Perry's phase-KR - # table, and a `| ID | Meaning |` legend under `## P0` - # shares one word out of six — bolting empty columns onto - # either would be Perry writing into a table it does not - # recognise. Left alone; `perry-lint` still reports it, and - # the report says why it was not touched. - continue - lang = task().header_language(header) - added = [task().display_name(c, lang) for c in missing] - rewritten.append(lines[hdr_i]) - rewritten.append(lines[sep_i]) - lines[hdr_i] = render_row(header + added) - lines[sep_i] = "|" + "|".join(["---"] * (len(header) + len(added))) + "|" - for r in row_is: - rewritten.append(lines[r]) - lines[r] = render_row(split_row(lines[r]) + [""] * len(missing)) - changes.append(Change( - "columns-added", - f"{added} appended to the table under " - f"`{lines[start - 1].lstrip('#').strip()}`; " - f"{len(row_is)} row(s) padded with empty cells", - hdr_i + 1)) - return lines - - -# ── T3 · an enum-valued header field carrying prose ─────────────────────── - - -#: Where the clause before a token starts. A negator only denies the token it -#: governs: in `locked — do not build from this` the `not` is on the far side of -#: the dash and denies `build`, not `locked`. `-` is deliberately absent — a -#: hyphen joins words far more often than it separates clauses. -CLAUSE_START = re.compile(r"[.;:,!?()\[\]|/—–。;:,、()]") - - -def negated(low: str, at: int, negators: list[str]) -> bool: - """Is the token at offset `at` denied by the clause it sits in? - - Only what stands *before* it, and only back to the nearest clause boundary. - `已评分,不再改动` says scored and then says something else; `not yet locked` - says it is not locked.""" - cut = 0 - for m in CLAUSE_START.finditer(low, 0, at): - cut = m.end() - window = low[cut:at] - for n in negators or []: - n = n.lower() - if n.isascii(): - if re.search(rf"(?<![A-Za-z0-9]){re.escape(n)}(?![A-Za-z0-9])", - window): - return True - elif n in window: - return True - return False - - -def enum_candidates(value: str, allowed: list[str], aliases: dict, - negators: list[str] | None = None) -> list[str]: - """Every canonical value this text could be saying. Never fewer, never more. - - Resolution is by *declared* vocabulary only — the enum's own tokens, the - localized spellings `schema/state-schema.json § migration.enum_aliases` - records, and the negators `§ migration.negations` records. There is no - keyword scoring and no default: two candidates or none means the migration - does not know, and a migration that guessed the status of somebody's design - doc would be inventing a fact about their project. - - **A word standing in a sentence is not the same as a word standing alone.** - Plain substring search read `> Status: not yet locked — do not build from - this` as `locked` and wrote it into the file, keeping the author's sentence - verbatim beside it — every character preserved, the claim reversed. One hit - was treated as certainty because nothing here could see the word in front of - it. A negated candidate is dropped rather than counted, so a value that says - only what it is *not* resolves to nothing and is reported, which is what the - module docstring promises for a value Perry cannot read. - - What it cannot see, stated: negation carried by grammar rather than by a - word — `we un-locked it on Tuesday`, `locked? no longer` — and a negator - separated from its token by a clause boundary. It reads a declared list of - denial words in the clause before the token, and nothing more.""" - text = value.strip().strip("*`> ") - low = text.lower() - if low in [a.lower() for a in allowed]: - return [a for a in allowed if a.lower() == low] - hits: list[str] = [] - for canon in allowed: - found = [m for m in re.finditer( - rf"(?<![A-Za-z0-9]){re.escape(canon)}(?![A-Za-z0-9])", low)] - if found and not all(negated(low, m.start(), negators) for m in found): - hits.append(canon) - for spelling, canon in (aliases or {}).items(): - if canon not in allowed or canon in hits: - continue - found = [m for m in re.finditer(re.escape(spelling.lower()), low)] - if found and not all(negated(low, m.start(), negators) for m in found): - hits.append(canon) - return hits - - -def field_line(lines: list[str], name_re: str, spec: dict, - schema: dict) -> tuple[int, re.Match] | None: - """The first line carrying this header field, and whether it may be rewritten. - - `bin/perry-lint` searches the whole file text, so the field it validates is - whichever comes first. That is the line this returns, because *presence* has - to mean what the linter means by it — a caller asking "does this file have a - `Status`" must get the same answer the linter got, or migration adds a - second one and the linter goes on reading the first. - - The index is separate, and is `None` when the line is one Perry may not - write into. Two ways that happens: - - - **a table row or separator.** Rewriting one would put a `|` inside a row - and turn one cell into two. - - **a line that is not in this file's header block.** This guarded only the - table case, and body prose is field-shaped: `Background: the vendor - contract Status: superseded by the 2025 MSA, so we rebuilt.` became - `… Status: superseded | superseded by the 2025 MSA, so we rebuilt.` — a - token spliced into the middle of somebody's sentence, while the real - header field two lines below was left alone. Perry's own repo carries the - shape at `perry/design/DESIGN-001-resumable-pipelines.md:126`: *"ALL rows - must be resolved before this doc can move to `Status: locked`."* - - `is_header_block` was written for exactly this — *"Sentences are field- - shaped; only the vocabulary tells them apart"* — and was applied to - `header_block_end` and not here. It is the same span both use now.""" - pat = re.compile(rf"{name_re}\s*\**\s*[::]\s*\**\s*([^\n]+)") - span = header_block_span(lines, spec, schema) - for i, line in enumerate(lines): - m = pat.search(line) - if m: - inside = span is not None and span[0] <= i <= span[1] - return (i if inside and not is_row(line) and not is_separator(line) - else None), m - return None - - -def fix_enum_fields(lines: list[str], spec: dict, schema: dict, - changes: list[Change], rewritten: list[str]) -> list[str]: - L = lint() - aliases = ((schema.get("migration") or {}).get("enum_aliases") or {}) - negators = ((schema.get("migration") or {}).get("negations") or []) - for fspec in spec.get("header_fields", []): - if not fspec.get("enum"): - continue - allowed = schema["enums"][fspec["enum"]] - found = field_line(lines, L.field_re(fspec["name"]), spec, schema) - if not found: - continue - idx, m = found - if idx is None: - continue - raw = m.group(1) - current = re.split(r"[|/,]", raw.strip().strip("*`> "))[0].strip() - if current in allowed: - continue - hits = enum_candidates(raw, allowed, aliases.get(fspec["enum"]) or {}, - negators) - if len(hits) != 1: - continue - canon = hits[0] - start = m.start(1) - rewritten.append(lines[idx]) - # The author's value is kept, verbatim, on its own line after a `|`. - # `viewer/parsers.parse_phase` already reads a Status cell as - # `split("|")[0]` and `perry-lint` splits the same way, so this is the - # separator Perry already treats as "canonical value, then whatever - # else this project wanted to say". - lines[idx] = lines[idx][:start] + f"{canon} | " + raw - changes.append(Change( - "enum-normalized", - f"`{fspec['name']}` reads `{canon}`; the value as written " - f"is kept on the same line after `|`", - idx + 1)) - return lines - - -# ── T4 · a required header field that is not there ──────────────────────── - - -SRC_RE = re.compile(r"\bSRC-(\d+)\b") - - -def is_header_block(lines, first, last, spec, schema) -> bool: - """Is this leading `>` block Perry's header block, or just quoted prose? - - Judged from the **schema**, not from shape. The first attempt matched - `> Name:` and `> **Name**:` and still joined - `> **注意:** 本文由第三方AI…` — a disclaimer whose colon sits inside the - bold, which is field-shaped by any reasonable pattern. Sentences are - field-shaped; only the vocabulary tells them apart. - - **One way to qualify: it names a field this file's spec declares**, in any - declared spelling. - - There used to be a second — *"it opens immediately under the H1, which is - where the template puts it"* — and that is **position used as evidence**, - which is the thing this docstring's own first paragraph rules out. It let - the fourth instance of this defect through: - `knowledge/auto-research/…全景综述_2026.md` opens with the author's seed - thesis in a blockquote directly under the H1, so Perry appended `Id`, - `Source`, `Received` and `Status` to the end of somebody's paragraph. No - character lost; the meaning changed. `ADR-004`'s failure mode exactly: *"a - board that still parses and no longer reads like theirs."* - - **What deleting it costs, stated:** a genuine header block written entirely - in field names the schema does not know gets a second block beside it - rather than being joined. That is cosmetically worse and **never - destructive** — Perry's metadata lands in its own block above the author's - prose, and the prose is untouched. Between "sometimes two blocks" and - "sometimes inside their sentence", the guarantee `TASK-044-spec.md` asks - for picks the first without hesitating. - """ - names = set() - for f in spec.get("header_fields", []): - names.add(f["name"].lower()) - for spellings in ((schema.get("i18n") or {}).get("fields") or {}).get( - f["name"], {}).values(): - names.update(s.lower() for s in spellings) - for i in range(first, last + 1): - m = re.match(r"^\s*>\s*\*{0,2}\s*([^::*]+?)\s*\*{0,2}\s*[::]", lines[i]) - if m and m.group(1).strip().lower() in names: - return True - return False - - -def header_block_span(lines: list[str], spec: dict, - schema: dict) -> tuple[int, int] | None: - """`(first, last)` line indices of this file's header block, or None. - - The one definition of "where Perry's metadata lives in this file", shared by - `header_block_end` (which writes into it) and `field_line` (which refuses to - write anywhere else).""" - # The FIRST CONTIGUOUS run of `>` lines, not every quoted line above the - # first `## `. Treating them as one block put `Id` under the H1 and then - # `Source` back inside a disclaimer four lines further down, because the - # newly-written `Id` made the whole span qualify. A header block is one - # block. - first_quote = None - last_quote = None - for i, line in enumerate(lines): - s = line.strip() - if s.startswith("## "): - break - if s.startswith(">"): - if first_quote is None: - first_quote = i - last_quote = i - elif first_quote is not None: - # **A blank line ends the block.** This said "the FIRST CONTIGUOUS - # run of `>` lines" and then let a blank line through, so two - # quote blocks separated by one became a single span. The - # consequence only appeared once Perry started its own block: `Id` - # landed correctly above the author's prose, and then the NEXT - # field re-read the span, found it reaching across the blank into - # the author's blockquote, and appended `Source` and `Received` to - # the end of their paragraph — the very defect the new block was - # written to avoid, one field later. - break - # A leading `>` block is only a header block if it holds header fields. - # `knowledge/market-context/caixin-…md` opens with a third-party-AI - # disclaimer in a blockquote, and joining it made Perry's metadata render - # as the last sentence of that disclaimer — no character lost, the meaning - # changed. Found by the user reading the migrated file; thirty mutations - # had passed over it. - if last_quote is None or not is_header_block( - lines, first_quote, last_quote, spec, schema): - return None - return (first_quote, last_quote) - - -def header_block_end(lines: list[str], spec: dict, schema: dict) -> tuple[int, bool, bool]: - r"""Where a header field goes, and whether this file bolds its field names. - - After the last line of the leading `>` block **when that block holds header - fields**, so a new field joins the ones already there instead of landing in - the prose — and after the H1 otherwise, as a block of its own. The third - return value says which happened, because a joined field follows its - neighbours' spelling and a new block uses the schema's. - - **The `bold` flag it returns is now advisory and no caller acts on it.** - This docstring used to say the bold question "is not cosmetic: - `perry-lint --provenance` matches `^>\s*Id\s*[::]`, which a bolded - `> **Id**:` does not satisfy — so a digest whose neighbours are plain must - get a plain line". Right about the hazard and **one case too narrow about - the remedy**: the dangerous case is neighbours who ARE bold, because that - is when the flag is true and the id is written `> **Id**:SRC-n`, which its - own reader cannot see. 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 of them declared conformant. - - `fix_missing_fields` now writes a plain label unconditionally. The flag is - kept because it still describes the file truthfully; ceasing to act on a - fact is not the same as deleting it.""" - span = header_block_span(lines, spec, schema) - if span is not None: - first_quote, last_quote = span - block = [lines[i] for i in range(first_quote, last_quote + 1)] - bold = sum(1 for b in block if re.search(r">\s*\*\*[^*]+\*\*\s*[::]", b)) - return (last_quote + 1, - bold * 2 >= len([b for b in block if ":" in b or ":" in b]), - True) - # A block Perry starts is never bolded: `perry-lint --provenance` matches - # `^>\s*Id\s*[::]` literally, and a bolded `> **Id**:` does not satisfy - # it. This comment used to end "— the same rule this function already - # applies when joining", which was false: when joining it did the - # opposite. It is true now, and true because the code changed rather - # than because the sentence was softened. - for i, line in enumerate(lines): - if line.startswith("# "): - return i + 1, False, False - return 0, False, False - - -def fix_missing_fields(lines: list[str], spec: dict, schema: dict, lang: str, - changes: list[Change], mint, minted: list[str]) -> list[str]: - L = lint() - # Decided ONCE, from the file as its author left it. Recomputing per field - # made the first inserted field turn a fresh block into an existing one, so - # `Id` was written in the schema's spelling and `Source` / `Status` in the - # project's — the mixed-language block this was meant to fix, one iteration - # later. - _, bold, joining = header_block_end(lines, spec, schema) - for fspec in spec.get("header_fields", []): - if fspec.get("required") is False: - continue - if field_line(lines, L.field_re(fspec["name"]), spec, schema): - continue - at, _, _ = header_block_end(lines, spec, schema) - # Joining an existing block follows that block. Starting a new one uses - # the schema's own spelling, in English: `i18n.fields` maps `Status` - # and not `Id`/`Source`/`Received`, so translating through it produced - # `Id:… / 状态:—` — three English names and one Chinese, in one - # block, against `reference/i18n.md`'s one-language-per-file rule. And - # `perry-lint --provenance` matches `^>\s*Id\s*[::]` literally, so a - # localized `编号` would break the provenance chain this id exists for. - name = field_spelling(schema, fspec["name"], lang) if joining else fspec["name"] - pattern = fspec.get("pattern") or "" - if pattern == r"SRC-\d+": - # An id is an address, not a claim: nothing about the project is - # asserted by giving a digest a handle it never had. Minted from - # the highest id already in the tree, in sorted key order, so the - # plan is a pure function of the project's bytes. - value = mint() - minted.append(value) - else: - # Everything else gets `—`. A date Perry does not know is not a - # date Perry may write down; `—` is the schema's own way of saying - # so (`design.Date` accepts it outright) and it never reads as fact. - value = "—" - # **Never bolded, even when the block being joined is.** Same argument - # the paragraph above already makes about language, missed one line - # later: `perry-lint --provenance` anchors `^>\s*Id\s*[::]` literally, - # so `> **Id**:SRC-n` is an id its own reader cannot see. Measured on a - # migrated copy of a real project — 3 of the 15 provenance findings were - # files migration had *just given an id to*, and all three were declared - # conformant. Migration wrote an id nothing could cite, which is the one - # thing the id exists for. - # - # Matching a block's bold style is politeness; being readable by Perry's - # own tools is correctness. The tolerant half — a reader that accepts - # decoration — belongs in `perry-lint` and is not this file's to write. - label = name - sep = ":" if (joining and lang == "zh") else ": " - blank = "" if at == 0 or not lines[at - 1].strip().startswith(">") else None - line = f"> {label}{sep}{value}" - lines[at:at] = [line] if blank is None else ["", line] - changes.append(Change("field-added", f"`{name}: {value}`", at + 1)) - return lines - - -# ── the four assertions ─────────────────────────────────────────────────── - - -ID_RE = re.compile(r"\b[A-Z][A-Z0-9]*(?:-[A-Z0-9][A-Za-z0-9.]*)+\b") - - -def characters(text: str) -> Counter: - """Every non-whitespace character, with multiplicity, ignoring table rules. - - Deliberately at character granularity and not at word granularity. Words - are a Latin-script idea: `> **状态**:进行中` is a *single* whitespace- - delimited token, so a word-level check would report the whole line as lost - the moment the value inside it was normalized, and — worse — would pass - anything that mangled the inside of a token. Characters are the same - question in every language. - - Separator rows (`|---|---|`) are excluded because they carry no content and - widening a table necessarily rewrites them.""" - c: Counter = Counter() - for line in text.split("\n"): - if is_separator(line): - continue - c.update(ch for ch in line if not ch.isspace()) - return c - - -def cells(text: str) -> Counter: - out: Counter = Counter() - for line in text.split("\n"): - if is_row(line): - out.update(c for c in split_row(line) if c.strip()) - return out - - -def ids(text: str) -> set[str]: - return set(ID_RE.findall(text)) - - -def rows_by_section(lines: list[str]) -> dict[str, int]: - """Data rows under each `## ` heading — the count § 2 asks to preserve.""" - out: dict[str, int] = {} - current = "(preamble)" - for line in lines: - s = line.strip() - if s.startswith("## "): - current = s[3:].strip() - out.setdefault(current, 0) - elif is_row(s): - out[current] = out.get(current, 0) + 1 - return out - - -def losslessness(before: str, after: str, rewritten: list[str]) -> list[str]: - """Why this edit would lose something, or `[]`. - - Asserted by the tool and refused if it fails — TASK-044 § 2 is explicit - that this may not be left for the reader's eye. Four independent checks, - because each is fooled by something the others catch.""" - bad: list[str] = [] - - missing = characters(before) - characters(after) - if missing: - sample = "".join(sorted(missing))[:40] - bad.append(f"{sum(missing.values())} character(s) present before and " - f"not after (e.g. {sample!r})") - - lost_cells = cells(before) - cells(after) - if lost_cells: - bad.append(f"{sum(lost_cells.values())} table cell(s) lost, e.g. " - f"{list(lost_cells)[0]!r}") - - gone = ids(before) - ids(after) - if gone: - bad.append(f"{len(gone)} id(s) present before and not after: " - f"{sorted(gone)[:6]}") - - b_rows = rows_by_section(before.split("\n")) - a_rows = rows_by_section(after.split("\n")) - for section, n in b_rows.items(): - if a_rows.get(section, 0) != n: - bad.append(f"section {section!r} had {n} row(s) and now has " - f"{a_rows.get(section, 0)}") - - # Every line the author wrote is either still there or was declared - # rewritten. This is the check that makes "revert exactly what you claim" - # true of the tool itself: a transform that quietly edits a line it did not - # record fails here even when every character survives elsewhere. - b_lines = Counter(l for l in before.split("\n") if l.strip()) - a_lines = Counter(l for l in after.split("\n") if l.strip()) - declared = Counter(l for l in rewritten if l.strip()) - for line, n in b_lines.items(): - if a_lines.get(line, 0) + declared.get(line, 0) < n: - bad.append(f"line {line.strip()[:60]!r} disappeared and was not " - f"recorded as rewritten") - break - return bad - - -# ── the fifth assertion: what the file now says ─────────────────────────── -# -# The four above all answer *is it all still there*. None answers *does it -# still mean that*, which is why thirty mutations passed over a migration that -# appended Perry's metadata to somebody else's disclaimer, and why three more of -# the same class shipped after it: a legend widened into a task table, a status -# field that said `not yet locked` rewritten to `locked`, and a token spliced -# into the middle of a sentence about a vendor contract. Every one of them -# preserves every character, every cell, every id, every row count, and declares -# every line it rewrote. -# -# There is no single check that sees all three, and pretending otherwise would -# be worse than saying so. What they have in common is only that a *reader* can -# tell — so the checks below are three readings, each stated with what it cannot -# see: -# -# · `records` — Perry's own parsers must extract the same records after as -# before. Migration exists to make a *field* readable; it -# does not exist to turn a *row* into a record. -# · `prose` — a line Perry rewrote must be a line the schema gives it a -# place to write: a table row, or the header block. -# · `enum_claims` — a canonical value written into a field must still be what -# the author's own words, kept beside it, are saying. - - -def records(text: str, key: str) -> dict[str, list]: - """What Perry's own readers turn this file's *rows* into. - - `viewer/parsers` and nothing else — the reader `bin/perry-task` and - `bin/perry-state` both go through. A second reading written - here would be the two-implementations defect ADR-004 is about, and would - also miss the point: the question is what the tools that consume this file - will see, not what a checker thinks they should. - - Header-derived values are deliberately absent (`Phase.status`, - `Phase.started`). Normalizing a header field *is* the migration, and a check - that forbade it would forbid the tool.""" - name = Path(key).name - parts = Path(key).parts - try: - if name == "BOARD.md": - b = P.parse_board(text) - return { - "task": [(t.id, t.title, t.owner, t.status, t.priority) - for t in b.all_tasks], - "user input": [(u.id, u.needed_from_user, u.blocks, u.status) - for u in b.user_input_queue], - "cadence": [(c.id, c.title, c.frequency, c.next_due) - for c in b.cadence_items], - "risk": [(r.text, r.resolved) for r in b.risks], - } - if name == "OKR.md": - o = P.parse_okr(text) - return {"objective": [ob.title for ob in o.objectives], - "KR": [(k.id, k.text, k.metric, k.stretch) - for ob in o.objectives for k in ob.krs]} - if parts and parts[0] == "phase": - ph = P.parse_phase(Path(key).stem, text) - return {"objective": [ob.title for ob in ph.objectives], - "KR": [(k.id, k.text, k.metric, k.linked) for k in ph.krs]} - except Exception as exc: # noqa: BLE001 - # A reader that raises is itself a reading. Before/after are both run - # through this, so a file that only *becomes* unreadable is caught. - return {"unreadable": [repr(exc)[:80]]} - return {} - - -def reading_changed(before: str, after: str, key: str) -> list[str]: - """The records Perry reads must be the ones it read before. - - Widening `| ID | Title | Owner | Status |` under `## P2` to the board's six - columns does not change what `perry-task list` returns: three rows in, three - rows out, the same ids and titles, with two more columns now resolvable by - name. That is what widening is for, and the module docstring says so — the - columns parsers key on, **in a table Perry already recognises**. - - Widening `| ID | Meaning |` under `## P0 holding` takes a section - `perry-task` reported as skipped ("table has no ID and Title columns") and - turns two legend rows into two tasks with the ids `INV-` and `ENG-` and no - titles. Nothing was lost; two records were invented. - - What it cannot see, stated: - - - **design docs and knowledge digests have no records.** Their tables are - not in the schema and `viewer/parsers` has no row reader for them, so this - check is silent on every file whose only migration is a header field. - - **it compares records, not truth.** A cell rewritten from `done` to - `blocked` in place would change a record and be caught; a cell rewritten - to another value that reads the same would not. - - **it cannot see a change no reader keys on.** A column the schema does not - name is invisible to it, exactly as it is to `perry-task`.""" - bad = [] - b, a = records(before, key), records(after, key) - for kind in sorted(set(b) | set(a)): - was, now = b.get(kind, []), a.get(kind, []) - if was == now: - continue - gained = [r for r in now if r not in was] - lost = [r for r in was if r not in now] - if gained: - bad.append(f"{len(gained)} {kind}(s) Perry did not read before and " - f"reads now: {gained[:3]}") - if lost: - bad.append(f"{len(lost)} {kind}(s) Perry read before and does not " - f"read now: {lost[:3]}") - return bad - - -def prose_rewritten(before: str, after: str, spec: dict, - schema: dict) -> list[str]: - """A line Perry rewrote must be one the schema gives it a place to write. - - Migration has exactly two places it may put a token into an existing line: a - table row (widening appends cells) and the header block (an enum field gains - its canonical value). Everything else in the file is a sentence somebody - wrote, and a sentence that gained a word is a sentence that says something - else — `Background: the vendor contract Status: superseded by the 2025 MSA` - became `… Status: superseded | superseded by the 2025 MSA` and every - character survived. - - What it cannot see, stated: this is about lines that **changed**. A line - Perry *inserted* next to somebody's paragraph is not a rewrite, and the - disclaimer defect — four header fields appended to a quoted disclaimer — is - invisible here. `is_header_block` is what holds that, and this check is why - `field_line` now uses the same span.""" - b, a = before.split("\n"), after.split("\n") - span = header_block_span(b, spec, schema) - bad = [] - for tag, i1, i2, _, _ in difflib.SequenceMatcher( - a=b, b=a, autojunk=False).get_opcodes(): - if tag == "equal": - continue - for i in range(i1, i2): - line = b[i] - if not line.strip() or is_row(line) or is_separator(line): - continue - if span is not None and span[0] <= i <= span[1]: - continue - bad.append(f"line {i + 1} is neither a table row nor part of the " - f"header block and was rewritten: {line.strip()[:60]!r}") - return bad - - -def enum_claims(before: str, after: str, spec: dict, schema: dict) -> list[str]: - """A canonical value must still be what the words kept beside it are saying. - - `fix_enum_fields` writes `<canon> | <the author's value, verbatim>`, so the - file carries its own evidence and this can re-read it: the retained text - must still resolve to the value Perry wrote. `not yet locked — do not build - from this` resolves to nothing once negation is in the vocabulary, so - `locked | not yet locked …` fails here. - - What it cannot see, stated: this is the **same vocabulary** `enum_candidates` - resolves with, applied to the post-image instead of to the candidate. It is - defence in depth, not an independent opinion — a spelling the vocabulary - does not contain is invisible to both. What it adds is that any *future* - path that writes a canonical value is held to the same standard without - knowing this rule exists.""" - L = lint() - aliases = ((schema.get("migration") or {}).get("enum_aliases") or {}) - negators = ((schema.get("migration") or {}).get("negations") or []) - b, a = before.split("\n"), after.split("\n") - bad = [] - for fspec in spec.get("header_fields", []): - if not fspec.get("enum"): - continue - allowed = schema["enums"][fspec["enum"]] - was = field_line(b, L.field_re(fspec["name"]), spec, schema) - now = field_line(a, L.field_re(fspec["name"]), spec, schema) - if not now or (was and was[1].group(1) == now[1].group(1)): - continue - head, sep, rest = now[1].group(1).partition("|") - canon = head.strip().strip("*`> ") - if not sep or canon not in allowed: - continue - if canon not in enum_candidates(rest, allowed, - aliases.get(fspec["enum"]) or {}, - negators): - bad.append(f"`{fspec['name']}` was written as {canon!r}, which the " - f"author's own value kept beside it does not say: " - f"{rest.strip()[:60]!r}") - return bad - - -def meaning(before: str, after: str, key: str, spec: dict, - schema: dict) -> list[str]: - """Why this edit would change what the file says, or `[]`. - - Asserted and refused on the same terms as `losslessness()`, and for the same - reason TASK-044 § 2 gives: an assertion an agent performs by reading is not - one. See the note above this block for what each part can and cannot see.""" - return (reading_changed(before, after, key) - + prose_rewritten(before, after, spec, schema) - + enum_claims(before, after, spec, schema)) - - -# ── planning ────────────────────────────────────────────────────────────── - - -MAX_PASSES = 4 - - -@dataclass -class Plan: - project_root: Path - state_root: Path - shape_version: int - #: **The root the caller TYPED**, not `project_root`. Every refusal raised - #: while planning hands the reader a command, and a command handed back - #: without the reader's own `--root` acts on whatever project they are - #: standing in. `project_root` is resolved and absolute and would work as - #: a value, but it is not what the reader typed, and § 10.9 of - #: `TASK-234-result.md` excused two members of that class on the ground - #: that "no root is in scope" — which was true of the function and false - #: of the plan it was handed. It is in scope now. - root_arg: str | None - edits: list[Edit] = field(default_factory=list) - skipped: list[dict] = field(default_factory=list) - dirty_git: bool = False - newly_visible: list = field(default_factory=list) - - @property - def writable(self) -> list[Edit]: - return [e for e in self.edits if e.writable] - - @property - def blocked(self) -> list[Edit]: - return [e for e in self.edits if e.residual or e.violations] - - -class Linter: - """`perry-lint.check_file` against a candidate text, in a scratch file. - - The migration proposes; the linter judges. Nothing here decides whether a - file is Perry's shape — that answer comes from the same function - `bin/perry-conform` calls and `perry-lint` prints, so a transform that only - *looks* correct is caught by the shape definition itself rather than by a - second opinion this tool would otherwise have to hold.""" - - #: One scratch directory per process, removed at exit. Per-Linter - #: directories left a `ResourceWarning` in every in-process test that built - #: a plan, which is noise a suite has to learn to ignore. - _scratch: tempfile.TemporaryDirectory | None = None - - def __init__(self, schema: dict, project_root: Path): - self.schema = schema - if Linter._scratch is None: - Linter._scratch = tempfile.TemporaryDirectory(prefix="perry-migrate-") - atexit.register(Linter._scratch.cleanup) - scratch = Path(Linter._scratch.name) - scratch_cfg = scratch / ".perry" / "config.md" - project_cfg = project_root / ".perry" / "config.md" - try: - scratch_cfg.parent.mkdir(parents=True, exist_ok=True) - scratch_cfg.write_text( - project_cfg.read_text(encoding="utf-8", errors="replace") - if project_cfg.is_file() else "", - encoding="utf-8") - except OSError as exc: - raise Refused( - f"could not stage .perry/config.md for migration linting " - f"({exc}). Nothing in the project was touched") from None - # The lint module is process-global in the in-process migration tests. - # Reusing its cached row from a previous project's scratch config would - # make dry-run depend on test order rather than this project's tracks. - getattr(lint(), "_TRACK_CONTEXTS", {}).pop(str(scratch_cfg.resolve()), None) - - def errors(self, text: str, key: str, spec: dict) -> list: - path = Path(Linter._scratch.name) / Path(key).name - # Perry's own scratch, not the user's project — and guarded anyway - # rather than exempted. An exemption list is where a guard like this - # rots: the next write added to this function would inherit the - # exemption silently. And the last scratch write that was left - # unguarded — the planning mirror — killed `--dry-run` with a traceback - # naming nothing. - try: - path.write_text(text) - except OSError as exc: - raise Refused( - f"could not stage {key} for linting ({exc}). Nothing in the " - f"project was touched") from None - return [f for f in lint().check_file(path, key, spec, - self.schema["enums"], is_template=False) - if f.severity == "error"] - - -def migrate_text(text: str, key: str, spec: dict, schema: dict, lang: str, - linter: Linter, mint, *, root_arg: str | None) -> Edit: - """One file's whole plan: the post-image, what changed, and what is left. - - Runs the transforms until the linter stops finding anything they can fix, - at most `MAX_PASSES` times. The loop is there because the fixes interact: - normalizing a design doc's `Status` to `locked` flips its - `required_at_status` gate, and sections that were warnings a moment ago - become errors. Driving off the schema and re-asking the linter after each - pass is how that stays correct without this file knowing the rule.""" - edit = Edit(key=key, path=Path(key), before=text, after=text) - edit.before_errors = len(linter.errors(text, key, spec)) - lines = text.split("\n") - for _ in range(MAX_PASSES): - before_pass = list(lines) - errors = linter.errors("\n".join(lines), key, spec) - if not errors: - break - lines = fix_enum_fields(lines, spec, schema, edit.changes, edit.rewritten) - lines = fix_missing_fields(lines, spec, schema, lang, edit.changes, - mint, edit.minted) - lines = fix_sections(lines, spec, schema, lang, errors, edit.changes) - lines = fix_tables(lines, spec, schema, edit.changes, - edit.rewritten, root_arg=root_arg) - if lines == before_pass: - break - edit.after = "\n".join(lines) - edit.residual = linter.errors(edit.after, key, spec) - edit.violations = (losslessness(edit.before, edit.after, edit.rewritten) - + meaning(edit.before, edit.after, key, spec, schema)) - return edit - - -#: What `perry-lint.check_cross_file` reads. Mirrored rather than copying the -#: whole state root, which on a real project is mostly `knowledge/` and can be -#: tens of megabytes for a question about six files. -CROSS_FILE_INPUTS = ("phase", "design", "BOARD.md") - - -def cross_file_delta(plan: Plan, schema: dict) -> list: - """Cross-file findings the migration would make appear. Never hidden. - - A migration that normalizes `Status: Design locked(2026-06-03;D1` to - `locked` does not create a defect — it lets an existing one be seen. Six of - gimegime-pmo's design docs are locked with no implementation plan, and - `check_cross_file` could not say so while it could not read the status. - - That is a true finding and a useful one, but it arrives as new red text - right after a tool rewrote your files, so the run says it up front. It is - computed the same way in both modes — the dry run mirrors the plan into a - scratch tree rather than reporting something only `apply` could know, since - a preview that omits a consequence is the divergence § 1 forbids.""" - L = lint() - before = {(f.file, f.rule, f.message) - for f in L.check_cross_file(plan.state_root, schema["enums"], - plan.project_root)} - if not plan.writable: - return [] - try: - with tempfile.TemporaryDirectory(prefix="perry-migrate-xf-") as tmp: - mirror = Path(tmp) / "state" - mirror.mkdir(parents=True) - (mirror.parent / "project").mkdir(exist_ok=True) - for name in CROSS_FILE_INPUTS: - src = plan.state_root / name - if src.is_dir(): - shutil.copytree(src, mirror / name) - elif src.is_file(): - shutil.copy2(src, mirror / name) - hook = plan.project_root / ".perry" / "hook.md" - if hook.exists(): - (mirror.parent / "project" / ".perry").mkdir( - parents=True, exist_ok=True) - shutil.copy2( - hook, mirror.parent / "project" / ".perry" / "hook.md") - # Preserve content, not read-only modes, in the throwaway mirror. - for f in mirror.parent.rglob("*"): - if f.is_file(): - f.chmod(f.stat().st_mode | stat.S_IWUSR) - for e in plan.writable: - rel = Path(e.key) - if (rel.parts[0] not in CROSS_FILE_INPUTS - and rel.name not in CROSS_FILE_INPUTS): - continue - target = mirror / rel - if target.parent.exists(): - target.write_text(e.after) - after = L.check_cross_file( - mirror, schema["enums"], mirror.parent / "project") - except OSError as exc: - raise Refused( - f"cross-file scratch planning failed ({exc}). Nothing in the " - f"project was touched") from None - return [f for f in after if (f.file, f.rule, f.message) not in before] - - -def id_minter(state_root: Path): - """`SRC-<n>` allocator, seeded from the highest id already in the tree.""" - highest = 0 - for md in sorted(state_root.rglob("*.md")): - try: - for n in SRC_RE.findall(md.read_text(errors="replace")): - highest = max(highest, int(n)) - except OSError: - continue - counter = {"n": highest} - - def mint() -> str: - counter["n"] += 1 - return f"SRC-{counter['n']}" - return mint - - -def git_dirty(project_root: Path) -> bool: - try: - out = subprocess.run(["git", "-C", str(project_root), "status", - "--porcelain"], capture_output=True, text=True, - timeout=20) - except (OSError, subprocess.SubprocessError): - return False - return out.returncode == 0 and bool(out.stdout.strip()) - - -def preflight_file_object(project_root: Path, path: Path, key: str) -> None: - """Refuse one write target whose filesystem topology cannot be restored.""" - try: - rel = path.relative_to(project_root) - except ValueError: - raise Refused(f"{key} is outside the project root") from None - cursor = project_root - for part in rel.parts[:-1]: - cursor /= part - if cursor.is_symlink(): - raise Refused( - f"{key} crosses symlink {cursor}. Migration refuses before " - f"writing because it cannot restore link topology") - if path.is_symlink(): - raise Refused( - f"{key} is a symlink. Migration refuses before writing because " - f"replacing it would destroy link topology") - if path.exists() and not path.is_file(): - raise Refused( - f"{key} is not a regular file. Migration refuses before writing " - f"because it cannot restore this filesystem object") - - -def preflight_file_objects(project_root: Path, state_root: Path, schema: dict, - only: list[str] | None) -> None: - """Refuse filesystem objects migration cannot reproduce byte-for-byte.""" - for spec in schema["files"]: - base = project_root if spec.get("anchor") == "project" else state_root - pattern = spec["path"] - candidates = (sorted(base.glob(pattern)) - if any(ch in pattern for ch in "*?[") else [base / pattern]) - for path in candidates: - key = path.relative_to(base).as_posix() - if only and key not in only: - continue - exclude = spec.get("exclude") - if exclude and fnmatch.fnmatch(key, exclude): - continue - preflight_file_object(project_root, path, key) - - -def plan_project(project_root: Path, state_root: Path, schema: dict, - only: list[str] | None = None, *, - root_arg: str | None) -> Plan: - """**`root_arg` is keyword-only with no default, like `declare`'s.** - Planning refuses in two places that hand the reader a command, and both - 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() - 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) - plan.dirty_git = git_dirty(project_root) - linter = Linter(schema, project_root) - lang = doc_language(project_root) - mint = id_minter(state_root) - for key, path, spec in C.state_files(project_root, state_root, schema): - if only and key not in only: - continue - if spec.get("format") == "yaml-frontmatter": - # Perry's own machine-written records: the linkage graph, an - # adoption dossier, a diagnosis. A shape error in one of those is a - # defect in the tool that wrote it, and rewriting a diagnosis's - # findings to satisfy an enum would be editing a diagnostic record - # rather than migrating a project's writing. - errs = linter.errors(path.read_text(errors="replace"), key, spec) - if errs: - plan.skipped.append({ - "path": key, "errors": len(errs), - "reason": "Perry wrote this file itself (YAML frontmatter). " - "Its findings belong to the tool that produced it, " - "not to this project's shape."}) - continue - image = path.read_bytes() - text, newline = decode_image(image, key) - edit = migrate_text(text, key, spec, schema, lang, linter, mint, - root_arg=plan.root_arg) - edit.path = path - edit.key_rel = path.relative_to(project_root).as_posix() - edit.before_bytes = image - # Observed here rather than at the write path: the dry run and `apply` - # then report the same mode from the same read, which is § 1 — one - # computation, not two. - edit.read_only_mode = owner_read_only(path) - edit.after_bytes = encode_image(edit.after, newline) - if edit.touched or edit.residual: - plan.edits.append(edit) - plan.newly_visible = cross_file_delta(plan, schema) - if not only or "BOARD.md" in only: - _plan_task_store(plan) - return plan - - -def _task_records(project_root: Path, state_root: Path, - board_text: str) -> list[dict]: - T = task() - board = T.Board(state_root / "BOARD.md") - board.lines = board_text.split("\n") - try: - records, _ = T.store_records(project_root, state_root, board, - T.read_events(project_root)) - except T.Refused as exc: - raise Refused(str(exc)) from None - return records - - -def _board_projected_task_records(records: list[dict]) -> list[dict]: - """Return the fields that BOARD.md can represent for drift comparison.""" - return [{key: value for key, value in record.items() if key != "summary"} - for record in records] - - -def _plan_task_store(plan: Plan) -> None: - """Keep a migrated board and its canonical store in one restore set.""" - board_path = plan.state_root / "BOARD.md" - if not board_path.exists(): - return - store_path = perry_store.store_path(plan.state_root) - preflight_file_object(plan.project_root, store_path, "tasks.jsonl") - baseline = _task_records(plan.project_root, plan.state_root, - board_path.read_text(errors="replace")) - summaries: dict[str, str] = {} - if store_path.exists(): - try: - current = perry_store.load_store(plan.state_root) - except (OSError, ValueError) as exc: - raise Refused(f"{store_path} is malformed ({type(exc).__name__}: " - f"{exc}); migration refuses before changing BOARD.md") from None - valid, findings = perry_store.validate_records(current) - if findings: - raise Refused(f"{store_path} is malformed ({findings[0]['message']}); " - f"migration refuses before changing BOARD.md") - if (_board_projected_task_records(valid) != - _board_projected_task_records(baseline)): - # **With the reader's own root** (TASK-234 round 5). - # `TASK-234-result.md § 10.9` excused this site as a function - # "with no root in scope"; `plan.project_root` was two lines up, - # and `plan.root_arg` — the root the reader TYPED — is on the plan - # now. It matters more here than anywhere else the row fixed: - # `perry-tasks render --write` without `--root` WRITES - # `BOARD.md` under the reader's current directory, so the copied - # command does not no-op about the wrong project, it rewrites it. - r = _root_flag(plan.root_arg) - raise Refused( - f"{store_path} differs from the current BOARD.md-derived " - f"baseline. Migration will not choose a winner: run " - f"`perry-tasks render --write{r}` if the store is " - f"authoritative, or explicitly import the board with " - f"`perry-tasks write --from-board{r}`, then retry.") - summaries = {record["id"]: record["summary"] for record in valid} - - board_edit = next((e for e in plan.writable if e.path == board_path), None) - board_after = (board_edit.after if board_edit is not None - else board_path.read_text(errors="replace")) - after_records = _task_records(plan.project_root, plan.state_root, board_after) - for record in after_records: - if record["id"] in summaries: - record["summary"] = summaries[record["id"]] - after = perry_store.store_text(after_records) - before = store_path.read_text(encoding="utf-8") if store_path.exists() else "" - if before == after: - return - plan.edits.append(Edit( - key="tasks.jsonl", path=store_path, before=before, after=after, - key_rel=store_path.relative_to(plan.project_root).as_posix(), - changes=[Change("synchronize task store", - "derive tasks.jsonl from the migrated BOARD.md")], - existed=store_path.exists(), - read_only_mode=owner_read_only(store_path))) - - -# ── applying ────────────────────────────────────────────────────────────── -# -# `write_atomic` is `lib.write_atomic`, imported at the top. The copy that used -# to live here staged onto a FIXED name — `BOARD.md.tmp` — and left it behind -# on failure, in the user's project, during the one operation that rewrites -# every state file they have. - - -def file_image(data: bytes) -> dict: - return { - "type": "file", - "sha256": sha(data), - "bytes_b64": base64.b64encode(data).decode("ascii"), - } - - -def absent_image() -> dict: - return {"type": "absent"} - - -def image_signature(data: bytes | None) -> dict: - return ({"type": "absent"} if data is None else - {"type": "file", "sha256": sha(data)}) - - -def image_bytes(entry, rel: str, legacy: bool = False) -> bytes | None: - """Decode a restore image, optionally accepting the pre-version format.""" - if entry is None: - if legacy: - return None - raise Refused(f"restore payload for {rel!r} has an invalid image type") - if isinstance(entry, str): - if legacy: - return entry.encode("utf-8") - raise Refused(f"restore payload for {rel!r} has an invalid image type") - if not isinstance(entry, dict) or entry.get("type") not in {"file", "absent"}: - raise Refused(f"restore payload for {rel!r} has an invalid image type") - if entry["type"] == "absent": - return None - try: - data = base64.b64decode(entry["bytes_b64"], validate=True) - except (KeyError, ValueError) as exc: - raise Refused(f"restore payload for {rel!r} has invalid base64 ({exc})") from None - if entry.get("sha256") != sha(data): - raise Refused(f"restore payload for {rel!r} fails its stored sha256") - return data - - -def current_signature(path: Path, rel: str) -> dict: - if path.is_symlink(): - return {"type": "symlink"} - if not path.exists(): - return {"type": "absent"} - if not path.is_file(): - return {"type": "special"} - try: - return image_signature(path.read_bytes()) - except OSError as exc: - raise Refused(f"restore preflight could not read {rel!r} ({exc})") from None - - -def restore_point(plan: Plan, run_id: str, edits: list[Edit]) -> Path: - """The bytes of every file this run is about to touch, before it touches it. - - Includes `.perry/conformance.jsonl`: the run writes that too, through - `perry-conform`, and a restore that put the state files back while leaving - the declarations standing would leave the record claiming conformance for - files that no longer have it. And `.perry/conformance.md` when the project - still has one, because the run CONVERTS it (TASK-234) and a conversion is a - deletion.""" - record = plan.project_root / P.CONFORMANCE_FILE - legacy = plan.project_root / P.CONFORMANCE_LEGACY_FILE - files = {e.key_rel: (file_image(e.image_before) - if e.existed else absent_image()) for e in edits} - files[P.CONFORMANCE_FILE] = (file_image(record.read_bytes()) - if record.exists() else absent_image()) - # **Both records, because `apply` may convert one into the other.** A - # project written before TASK-234 keeps its declarations in - # `.perry/conformance.md`; `perry-conform declare` carries them into the - # store and DELETES the markdown, which is a write this restore point has - # to be able to undo like any other. - files[P.CONFORMANCE_LEGACY_FILE] = (file_image(legacy.read_bytes()) - if legacy.exists() else absent_image()) - payload = { - "version": 1, - "run": run_id, - "created": datetime.now().isoformat(timespec="seconds"), - "project_root": str(plan.project_root), - "tool": "perry-migrate", - "files": files, - "expected_after": { - **{e.key_rel: image_signature(e.image_after) for e in edits}, - P.CONFORMANCE_FILE: current_signature(record, P.CONFORMANCE_FILE), - P.CONFORMANCE_LEGACY_FILE: current_signature( - legacy, P.CONFORMANCE_LEGACY_FILE), - }, - } - out = plan.project_root / MIGRATE_DIR / f"{run_id}.json" - out.parent.mkdir(parents=True, exist_ok=True) - if out.exists(): - raise Refused(f"restore point {out} already exists; refusing to overwrite it") - tmp = out.with_suffix(".json.tmp") - tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=1)) - tmp.replace(out) - return out - - -def next_run_id(project_root: Path) -> str: - base = datetime.now().strftime("%Y-%m-%d-%H%M%S") - candidate = base - n = 0 - while (project_root / MIGRATE_DIR / f"{candidate}.json").exists(): - n += 1 - candidate = f"{base}-{n:02d}" - return candidate - - -def update_expected_after(point: Path, rel: str, path: Path) -> None: - payload = json.loads(point.read_text(encoding="utf-8")) - payload["expected_after"][rel] = current_signature(path, rel) - write_atomic(point, json.dumps(payload, ensure_ascii=False, indent=1)) - - -def apply_plan(plan: Plan, schema: dict, declare: bool = True) -> dict: - """Write the plan's post-images, then declare what landed. In that order. - - **The root comes off the plan and there is no parameter for it.** Round 4 - gave this function `root_arg: str | None = None` and threaded it into all - three `rollback_message` calls; the V4 round-4 reviewer then dropped it - from two of the three with the whole of `test_conformance` and - `test_migrate` green, because every test that reaches those two paths - calls `apply_plan(plan, SCHEMA)` positionally and `None` is `None` on both - sides of the mutation. That is the round-3 defect one file over: a - parameter a caller can decline to fill is a parameter that will be - unfilled. `plan.root_arg` is set in `plan_project`, which has no default - for it, so a plan cannot exist without an answer and this function cannot - hold a different one. - - Every write is `write_text(edit.after)` — the exact bytes the dry run - printed. Nothing is recomputed here, which is the whole reason the plan - carries post-images instead of instructions.""" - root_arg = plan.root_arg - edits = plan.writable - if not edits: - # `run` is present here too. It was not, and `perry-migrate apply` on a - # project where nothing is writable died with `KeyError: 'run'` in the - # renderer — a traceback where "there was nothing to do" belongs. - return {"applied": [], "restore_point": None, "run": None, - "declared": [], "refused": []} - for e in edits: - preflight_file_object(plan.project_root, e.path, e.key) - if declare: - preflight_file_object( - plan.project_root, - plan.project_root / P.CONFORMANCE_FILE, - P.CONFORMANCE_FILE, - ) - # The markdown record too, when the project still has one: `declare` - # converts it and then UNLINKS it, and unlinking a symlink Perry did - # not put there is the same refusal for the same reason (TASK-234). - preflight_file_object( - plan.project_root, - plan.project_root / P.CONFORMANCE_LEGACY_FILE, - P.CONFORMANCE_LEGACY_FILE, - ) - run_id = next_run_id(plan.project_root) - try: - # **Site 2 of 5.** Pre-write, so a failure here leaves the project - # valid — but a traceback where a refusal belongs, and the refusal has - # to say nothing was touched, which no traceback does. - point = restore_point(plan, run_id, edits) - except OSError as exc: - raise Refused( - f"the restore point could not be written ({exc}). **Nothing was " - f"migrated** — the run stops before touching a file rather than " - f"proceeding without a way back") from None - applied = [] - for e in edits: - try: - published = write_atomic(e.path, e.image_after) - got = sha(e.path.read_bytes()) - except OSError as exc: - # **A write that FAILS, as opposed to one that lands wrong.** Only - # the second was handled: a read-only file, a full disk or a - # revoked permission mid-run propagated as an unhandled traceback, - # so a stranger's project was left N-of-M migrated, the restore - # point existed and was **never named**, and the declaration never - # ran. `TASK-044-spec.md` guarantee 3 requires the recovery path be - # named in the output; a traceback names nothing. - raise Refused(rollback_message(point, e.key, exc, - root_arg=root_arg)) from None - if got != sha(e.image_after): - # Roll back this image only when it is provably the one our atomic - # writer published. A different digest may be a non-cooperating - # user edit that landed before the verification read. - allow_changed = ({e.key_rel: {"type": "file", "sha256": got}} - if published == got else None) - raise Refused(rollback_message( - point, e.key, - f"written but does not match the plan " - f"({got[:12]} vs {sha(e.image_after)[:12]})", - allow_changed=allow_changed, root_arg=root_arg)) - applied.append(e) - result = {"applied": [e.key for e in applied], "restore_point": str(point), - "run": run_id, "declared": [], "refused": []} - if declare: - C = conform() - # 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. - # **Site 3 of 5.** Round 1 guarded the edit loop and this sits after - # it, so with `.perry/` read-only and `.perry/migrate/` writable — any - # project migrated once and then locked down — the project was FULLY - # migrated, the restore point was on disk and never named, and the user - # got a raw traceback. Round 1's failure mode verbatim, one stage - # downstream. - try: - out = C.declare(plan.project_root, plan.state_root, - [e.key for e in applied if e.key != "tasks.jsonl"], - schema, route="migrate", - # **The root travels into the refusal too** - # (TASK-234 round 4). `declare` converts the - # markdown record first, and that step can refuse - # with a command for the reader to run. Reached - # from here the reader typed `perry-migrate apply - # --root X`, so the command they are handed has to - # say `--root X` or it acts on whatever project - # they happen to be standing in. - root_arg=root_arg, - # **The run travels with the declaration** - # (TASK-234). `route: migrate` says a migration - # made it; `run` says WHICH ONE, and the id is also - # the name of the restore point that undoes it, so - # a row can be traced to the bytes it replaced. - writer="perry-migrate apply", run=run_id) - update_expected_after(point, P.CONFORMANCE_FILE, - plan.project_root / P.CONFORMANCE_FILE) - update_expected_after( - point, P.CONFORMANCE_LEGACY_FILE, - plan.project_root / P.CONFORMANCE_LEGACY_FILE) - # **`C.Refused`, not just this module's** (TASK-234). `Refused` here - # is `bin/perry-migrate`'s own class, so a refusal raised INSIDE - # `perry-conform` is a different type and walked straight past this - # handler. That became reachable the moment `declare` gained a step - # that can refuse — the record conversion — and it is Site 3's own - # failure mode verbatim: fully migrated, restore point on disk and - # never named, raw traceback. - except (OSError, Refused, C.Refused, ValueError) as exc: - record = plan.project_root / P.CONFORMANCE_FILE - raise Refused(rollback_message( - point, P.CONFORMANCE_FILE, - f"the files were migrated but the declaration could not be " - f"written ({exc})", - allow_changed={ - P.CONFORMANCE_FILE: current_signature( - record, P.CONFORMANCE_FILE), - }, root_arg=root_arg)) from None - result["declared"] = [d["path"] for d in out["declared"]] - result["refused"] = out["refused"] - result["record"] = out["record"] - return result - - -def rollback_message(point: Path, key: str, why, - allow_changed: dict[str, dict] | None = None, *, - root_arg: str | None) -> str: - """Roll the run back and say so — **and name the restore point either way.** - - `undo` writes, so it can fail for the same reason the run did. If the - refusal only named the restore command on a *successful* rollback, the - worst case — a project half-migrated by a failure that also blocks the - repair — would be the one case that told the user nothing. So the path is - named first, unconditionally, and whether the automatic rollback worked is - reported as a separate fact. - """ - # **With the root the caller used** (TASK-234 round 4). A refusal that - # names a command names it for a reader standing where they were when - # they ran it; without the flag, `perry-migrate restore <id>` copied out - # of this message looks for a restore point under whatever project the - # reader happens to be in. - cmd = f"perry-migrate restore {_q(point.stem)}{_root_flag(root_arg)}" - try: - back = undo(point, allow_partial=True, allow_changed=allow_changed) - rolled = (f"The run was rolled back — {len(back)} file(s) restored. " - f"Nothing on disk changed.") - except (OSError, Refused, ValueError) as exc2: - rolled = (f"**The automatic rollback also failed** ({exc2}). Files " - f"written before the failure are still migrated. Put them " - f"back with:\n {cmd}") - return (f"{key}: {why}\n{rolled}\n" - f"Restore point: {point}\n" - f"Recover at any time with:\n {cmd}") - - -def restore_target(root: Path, rel: str) -> Path: - if not isinstance(rel, str) or not rel or Path(rel).is_absolute(): - raise Refused(f"restore payload path {rel!r} is not project-relative") - parts = Path(rel).parts - if any(part in {"", ".", ".."} for part in parts): - raise Refused(f"restore payload path {rel!r} escapes the project root") - target = root.joinpath(*parts) - cursor = root - for part in parts[:-1]: - cursor = cursor / part - if cursor.is_symlink(): - raise Refused( - f"restore payload path {rel!r} crosses symlink {cursor}") - return target - - -def expected_signature(value, rel: str) -> dict: - if isinstance(value, str): - return {"type": "file", "sha256": value} - if value is None: - return {"type": "absent"} - if not isinstance(value, dict) or value.get("type") not in {"file", "absent"}: - raise Refused(f"restore payload expected-after type for {rel!r} is invalid") - if value["type"] == "file" and not re.fullmatch( - r"[0-9a-f]{64}", str(value.get("sha256") or "")): - raise Refused(f"restore payload expected-after hash for {rel!r} is invalid") - return value - - -def load_restore_payload(point: Path, expected_root: Path | None = None) -> tuple: - try: - payload = json.loads(point.read_text(encoding="utf-8")) - except (OSError, UnicodeError, json.JSONDecodeError) as exc: - raise Refused(f"restore payload {point} is unreadable ({exc})") from None - if not isinstance(payload, dict): - raise Refused(f"restore payload {point} is not a JSON object") - version = payload.get("version") - if version not in {None, 1}: - raise Refused(f"restore payload {point} has unsupported version {version!r}") - try: - root = Path(payload["project_root"]) - files = payload["files"] - expected = payload["expected_after"] - except (KeyError, TypeError) as exc: - raise Refused(f"restore payload {point} is missing {exc}") from None - if not root.is_absolute(): - raise Refused("restore payload project_root is not absolute") - if expected_root is not None and root.resolve() != expected_root.resolve(): - raise Refused( - f"restore payload belongs to {root}, not requested project {expected_root}") - if not isinstance(files, dict) or not isinstance(expected, dict): - raise Refused("restore payload files/expected_after must be objects") - if version == 1 and set(files) != set(expected): - raise Refused("restore payload files and expected_after name different paths") - - decoded = {} - signatures = {} - targets = {} - for rel, entry in files.items(): - targets[rel] = restore_target(root, rel) - decoded[rel] = image_bytes(entry, rel, legacy=version is None) - if rel not in expected: - raise Refused(f"restore payload has no expected-after state for {rel!r}") - signatures[rel] = expected_signature(expected[rel], rel) - return payload, root, targets, decoded, signatures - - -def undo(point: Path, expected_root: Path | None = None, - allow_partial: bool = False, - allow_changed: dict[str, dict] | None = None) -> list[str]: - _, _, targets, decoded, expected = load_restore_payload(point, expected_root) - conflicts = [] - for rel, target in targets.items(): - current = current_signature(target, rel) - allowed = [expected[rel]] - if allow_partial: - allowed.append(image_signature(decoded[rel])) - if rel in (allow_changed or {}): - allowed.append(allow_changed[rel]) - if current not in allowed: - conflicts.append( - f"{rel} changed since migration ({current} != {expected[rel]})") - if conflicts: - raise Refused( - "restore preflight refused before writing any path: " - + "; ".join(conflicts)) - - back = [] - for rel, target in targets.items(): - data = decoded[rel] - if data is None: - if target.exists() or target.is_symlink(): - target.unlink() - back.append(f"{rel} (removed — it did not exist before the run)") - continue - write_atomic(target, data) - back.append(rel) - return back - - -# ── rendering ───────────────────────────────────────────────────────────── - - -def diff(edit: Edit) -> str: - return "".join(difflib.unified_diff( - edit.before.splitlines(keepends=True), - edit.after.splitlines(keepends=True), - fromfile=f"a/{edit.key}", tofile=f"b/{edit.key}", n=2)) - - -def render(plan: Plan, applied: dict | None) -> None: - """The complete diff. Not a summary and not a count — TASK-044 § 1.""" - verb = "migrated" if applied else "would migrate" - r = _root_flag(plan.root_arg) - print(f"\n🔧 Migration · {plan.project_root.name} · shape version " - f"{plan.shape_version} · {'apply' if applied else 'dry run'}\n") - if not plan.edits and not plan.skipped: - print(" ✓ nothing to migrate — every file this schema claims already " - "matches Perry's shape\n") - return - for e in plan.edits: - if not e.touched: - continue - mark = "✓" if e.writable else "·" - print(f" {mark} {e.key} ({e.before_errors} error(s) → " - f"{len(e.residual)})") - if e.overrode_read_only: - # In the per-file list, because TASK-044-spec asks that list for - # "every file it touched, with what changed in each", and a - # permission this run crossed belongs to that answer. Marked `!` - # rather than `+`: nothing here was edited into the file. - print(f" ! {e.read_only_note(applied=bool(applied))}") - for c in e.changes: - print(f" + {c.kind}: {c.detail}") - body = diff(e) - if body: - print("".join(f" {l}\n" for l in body.rstrip("\n").split("\n"))) - for e in plan.blocked: - print(f" ✗ {e.key} — left byte-identical. The edits above are not " - f"applied to it until this is resolved by hand:") - for f in e.residual: - print(f" · [{f.rule}] {f.message}") - for v in e.violations: - print(f" · assertion: {v}") - for s in plan.skipped: - print(f" · {s['path']} — {s['errors']} error(s), not migrated: " - f"{s['reason']}") - n_ok, n_bad = len(plan.writable), len(plan.blocked) - print(f"\n {n_ok} file(s) {verb}, {n_bad} left as found.") - if plan.newly_visible: - print(f" · {len(plan.newly_visible)} cross-file finding(s) become " - f"visible once these files are readable. Migration did not " - f"create them; it stopped hiding them:") - for f in plan.newly_visible: - print(f" · {f.file} [{f.rule}] {f.message}") - if plan.dirty_git: - print(" · this project's git tree has uncommitted changes — a `git " - "checkout` will not undo only this run. The restore point below " - "will.") - if applied: - print(f" · restore point: {applied['restore_point']}") - print(f" undo with: perry-migrate restore {_q(applied['run'])}{r}") - if applied["declared"]: - print(f" · declared conformant ({len(applied['declared'])}): " - f"{', '.join(applied['declared'])}") - for ref in applied["refused"]: - print(f" ✗ not declared: {ref['path']} — {ref['reason']}") - elif plan.writable: - print(f" Nothing was written. Apply it with:\n" - f" perry-migrate apply{r}") - print() - - -# ── 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 = "plan" - root_arg = None - only: list[str] = [] - positional: list[str] = [] - as_json = do_list = no_declare = 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 == "--only": - i += 1 - if i < len(argv): - only.append(argv[i]) - elif a == "--json": - as_json = True - elif a == "--list": - do_list = True - elif a == "--no-declare": - no_declare = True - elif a.startswith("-"): - print(f"perry-migrate: unknown argument {a!r} (try --help)", - file=sys.stderr) - return 2 - elif a in ("plan", "apply", "restore") and not positional and cmd == "plan": - cmd = a - else: - positional.append(a) - i += 1 - - try: - schema = conform().load_schema() - project_root, state_root = _roots(root_arg) - - if cmd == "restore": - with lib.project_lock(state_root, refused=Refused): - return do_restore(project_root, positional, do_list, - as_json, root_arg=root_arg) - - if not lint().is_adopted(project_root, state_root): - # One sentence, not a wall. The near-empty project is the other - # real case in TASK-044's measurement, and the failure mode there - # is a tool that finds nothing to do and says so at length. - tail = "" - n = perry_written_findings(project_root, state_root, schema) - if n: - tail = (f" ({n} lint finding(s) here are in files Perry wrote " - f"itself under .perry/; migration never edits those — " - f"they belong to the tool that produced them.)") - raise Refused( - f"{project_root.name} has no Perry state to migrate — there is " - f"no BOARD.md, no OKR.md, no phase/ and no .perry/config.md. " - f"Migration converts state a project already has; a project " - f"that has none starts with `/perry adopt`, which writes " - f"Perry's shape in the first place.{tail}") - - if cmd == "apply": - # The lock begins before planning: every post-image and drift check - # must describe the same source bytes the replacements consume. - with lib.project_lock(state_root, refused=Refused): - plan = plan_project(project_root, state_root, schema, - only or None, root_arg=root_arg) - applied = apply_plan(plan, schema, - declare=not no_declare) - else: - plan = plan_project(project_root, state_root, schema, - only or None, root_arg=root_arg) - applied = None - - if as_json: - print(json.dumps({ - "project_root": str(project_root), - "state_root": state_root.relative_to(project_root).as_posix() or ".", - "shape_version": plan.shape_version, - "mode": "apply" if applied else "dry-run", - "dirty_git": plan.dirty_git, - "files": [e.as_dict() for e in plan.edits], - "skipped": plan.skipped, - "newly_visible": [f.as_dict() for f in plan.newly_visible], - "applied": applied, - }, ensure_ascii=False, indent=2)) - else: - render(plan, applied) - return 0 if not plan.blocked and not plan.skipped else 1 - except Refused as exc: - if as_json: - print(json.dumps({"refused": str(exc)}, ensure_ascii=False, indent=2)) - else: - print(f"perry-migrate: refused — {exc}", file=sys.stderr) - return 1 - - -def perry_written_findings(project_root: Path, state_root: Path, - schema: dict) -> int: - """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): - if spec.get("format") != "yaml-frontmatter": - continue - n += len(linter.errors(path.read_text(errors="replace"), key, spec)) - return n - - -def do_restore(project_root: Path, positional: list[str], do_list: bool, - as_json: bool, *, root_arg: str | None) -> int: - base = project_root / MIGRATE_DIR - points = sorted(base.glob("*.json")) if base.is_dir() else [] - if do_list or not positional and len(points) != 1: - if not points: - raise Refused(f"no restore points under {MIGRATE_DIR}/ — nothing " - f"to undo") - summaries = [] - for p in points: - payload, _, _, _, _ = load_restore_payload( - p, expected_root=project_root) - summaries.append((p, payload)) - if as_json: - print(json.dumps({"restore_points": [p.stem for p in points]}, - ensure_ascii=False, indent=2)) - else: - print(f"\n🔧 Restore points · {project_root.name}\n") - for p, payload in summaries: - n = len([k for k in payload["files"]]) - print(f" {p.stem} {n} file(s) {payload['created']}") - print(f"\n perry-migrate restore <run-id>{_root_flag(root_arg)}\n") - return 0 if do_list else 1 - point = base / f"{positional[0]}.json" if positional else points[0] - if not point.exists(): - raise Refused(f"no restore point {point.stem!r} under {MIGRATE_DIR}/") - # **Site 5 of 5 — the recovery path itself.** Unguarded, it restored - # `BOARD.md` and then died unlinking `conformance.md`: half restored, with - # the record still claiming conformance for a rolled-back file, which is - # the exact thing `restore_point`'s own docstring exists to prevent. - try: - # A previous restore may have stopped after putting only some paths - # back. Accept those exact before-images so the documented retry is - # idempotent, while still refusing any third state as a later edit. - back = undo(point, expected_root=project_root, allow_partial=True) - except Refused: - raise - except (OSError, ValueError) as exc: - raise Refused( - f"the restore is incomplete ({exc}). Some files were put back and " - f"some were not, and `{P.CONFORMANCE_FILE}` may still claim " - f"conformance for a file that was rolled back. The restore point " - f"is still at {point} and re-running is safe once the permission " - f"is fixed") from None - if as_json: - print(json.dumps({"restored": back, "from": str(point)}, - ensure_ascii=False, indent=2)) - else: - print(f"\n🔧 Restored {len(back)} file(s) from {point.name}\n") - for b in back: - print(f" ← {b}") - print(f"\n {point} is kept — it is the record that the run happened.\n") - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) 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/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 5973b6d3..7a84188c 100644 --- a/perry/BOARD.md +++ b/perry/BOARD.md @@ -5,7 +5,7 @@ > Per-task spec / deliverable / audit: `evidence/2026-08/<TASK-ID>-*.md` (P0/P1 always have a `<TASK-ID>-spec.md`) > Auto-dispatch a task: `/pmo dispatch <TASK-ID>` (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 @@ -76,7 +76,6 @@ |---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| | TASK-077 | DESIGN-006 F — a finance-shaped role runs one real task end to end | Coding Agent | not_started | 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. | evidence/2026-08/TASK-077-context.md | V5 | TASK-073, TASK-075, TASK-076, TASK-200 | main | | | | | | | | TASK-095 | Remove the parser for the three stores; keep what adoption needs | Coding Agent | review | 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. | — | V4 | — | main | | | | | | | -| TASK-097 | Migrate the two real projects to the store, at V5 | Coding Agent | not_started | — | — | V5 | TASK-092 | main | | | | | | | | TASK-099 | Sweep bin/, viewer/ and tests/ for document handling that ADR-007 made dead | Coding Agent | not_started | — | — | V4 | TASK-095 | main | | | | | | | | TASK-129 | Agent is five strings that do not join, and role has never once been written | Coding Agent | not_started | unblocked: work owns .perry/agents.jsonl → .perry/roles/ as of the 2026-08-20 signature; needs a spec, then dispatch | — | V3 | TASK-128 | main | | | | | | | | TASK-155 | the register updated field carries two facts, so appending an edge silently re-dates every asserted number in the file | Coding Agent | not_started | 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. | — | V3 | — | intake | triaged | | 2026-08-21 | | | | @@ -135,7 +134,6 @@ | TASK-172 | four of six document collections are unreachable through any contract | Coding Agent | not_started | DEFERRED 2026-08-21 by the user: aiMark reads the directories directly for now. THE COST, stated so it is on the record: aiMark then owns a reader of Perry's LAYOUT, and perry relocate moves every claimed path — a consumer holding perry/design/ breaks silently the first time a project moves its state root. aiMark's own document says it did not want this ('a second reader of your layout is the thing this whole integration exists to avoid'); the decision overrides that knowingly | — | V4 | — | main | | | | TASK-198 | ## Cadence becomes a store | Coding Agent | not_started | — | — | V3 | | main | | | | TASK-222 | score-phase's own snapshots trip NS-01, because the names it writes do not match the declared pattern | Coding Agent | not_started | — | — | V3 | | main | | | -| TASK-223 | the conformance gate cannot tell a file Perry generated from one it found, so authored files need a hand declare | Coding Agent | not_started | — | — | V3 | | main | | | | TASK-224 | linkage-kr-exists fires only on an absent id, so a KR nested under the wrong objective lints clean | Coding Agent | not_started | — | — | V3 | | main | | | | TASK-225 | decide/SKILL.md:220 specifies a design index that nothing renders | Coding Agent | not_started | — | — | V3 | | main | | | | TASK-232 | viewer/ is named for a web console deleted under TASK-178, and a reader just called it dead code | Coding Agent | not_started | 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. | — | V3 | TASK-050 | main | | | @@ -143,9 +141,7 @@ | TASK-242 | linkage-kr-exists proves SOME phase has resolvable overall edges, not that THIS phase does | Coding Agent | not_started | 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. | — | V4 | TASK-157 | main | | | | TASK-244 | the suite's floor is one module: test_task_writer.py runs alone for 105-149s and no worker count moves it | Coding Agent | not_started | 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. | — | V4 | TASK-230 | main | | | | TASK-245 | tests/parallel main() has never had test coverage, and the guard TASK-230 added there survives its own deletion | Coding Agent | not_started | 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. | — | V3 | TASK-230 | main | | | -| TASK-246 | an unreadable row in .perry/conformance.md is now DELETED by the next declare, not laundered | Coding Agent | not_started | 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. | — | V4 | TASK-241 | main | | | | TASK-247 | 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 | Coding Agent | not_started | 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. | — | V4 | TASK-233 | main | | | -| TASK-248 | a canonical row inside <pre>, an HTML comment, or <details> still declares a file conformant, and is still laundered | Coding Agent | not_started | 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. | — | V4 | TASK-241 | main | | | | TASK-252 | a register write honours board rows it was never asked about, and the durable 'somebody has seen this' surface does not exist | Coding Agent | not_started | 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. | — | V4 | TASK-243 | main | | | ## Cadence (recurring; doesn't consume P0 slots) @@ -171,6 +167,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 | | answered 2026-08-31: A — delete migration too. perry-migrate, perry_schema.py and test_migrate.py are out; TASK-097 drops with them. | 2026-08-31 | ## Done this period (leaves the board at next triage) diff --git a/perry/asks.jsonl b/perry/asks.jsonl index afe7a007..e0d86cd4 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": "answered 2026-08-31: A — delete migration too. perry-migrate, perry_schema.py and test_migrate.py are out; TASK-097 drops with them.", "answered": true, "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/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/perry/journal/2026-08/2026-08-31.md b/perry/journal/2026-08/2026-08-31.md new file mode 100644 index 00000000..4dd694c7 --- /dev/null +++ b/perry/journal/2026-08/2026-08-31.md @@ -0,0 +1,40 @@ +# 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 +- [USER-910] pending → answered · A — delete migration too. perry-migrate, perry_schema.py and test_migrate.py are out; TASK-097 drops with them. +- [TASK-097] 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-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 + +### 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/phase/003-linkage.md b/perry/phase/003-linkage.md index 886df6f9..701fcc2c 100644 --- a/perry/phase/003-linkage.md +++ b/perry/phase/003-linkage.md @@ -1,7 +1,7 @@ --- linkage: 1 phase: "003-storage-code" -updated: "2026-08-30T02:22:30Z" +updated: "2026-08-31T02:53:21Z" objectives: - id: O1 title: "Every declared store exists, and one command checks all of them" @@ -52,13 +52,6 @@ objectives: - id: O3 title: "The phase's KRs cover the work that actually runs" krs: - - id: P003-O3-KR1 - title: "Open `main`-track rows in neither `objectives[].krs[].tasks[]` nor a declared `unlinked[]` — the never-asked state" - metric: "0 (baseline 45 of 45 at phase start, measured by `perry-state --section attribution` on 2026-08-28)" - target: 0 - stretch: false - linked: "KR-O2.3" - tasks: [] - id: P003-O3-KR2 title: "Rows opened during phase 003 that take a KR edge or an `unlinked` declaration in the same action as `add`" metric: "100% of rows added this phase (baseline 0 — the edge is a separate step nobody takes)" diff --git a/perry/phase/003-storage-code.md b/perry/phase/003-storage-code.md index 3bc2cd28..a98b6438 100644 --- a/perry/phase/003-storage-code.md +++ b/perry/phase/003-storage-code.md @@ -77,8 +77,9 @@ table for the second time. - **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. +- ~~**Attribution answers for the 40 rows still never asked** (`P003-O3-KR1`). Perry + never guesses a KR; these can only be declared.~~ **Withdrawn 2026-08-31** — + the backfill is phase 004's; see `## Changes / Pivots`. - **`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. @@ -89,19 +90,24 @@ 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 +~~**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. +recording a decision nobody made.~~ **Rewritten 2026-08-31, and the conclusion +inverts.** With `P003-O3-KR1` withdrawn, Objective 3 is the `add`-time gate +alone and it does **not** stall on an absent user: declaring a row unlinked at +`add` time is the author's own statement about their own row, so the gate is +reachable by an agent working through the degradation order above. ## Phase Scope Reduction Rule -- **KR-progress trigger**: if at phase day 10 the commit KRs of Objectives 1 +- **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. + never-asked rows defers to phase 004.~~ **Spent 2026-08-31, phase day 4.** + The user took this exact collapse deliberately rather than waiting for the + condition; it cannot fire again. Recorded so a day-10 reader does not evaluate + a trigger whose cut has already been applied. - **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`, @@ -182,7 +188,10 @@ about. - 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 + - Verification: ~~`perry-state --section attribution` reports 0 never-asked + rows~~ **restated 2026-08-31** — every `main`-track row opened after the gate + lands carries a `link-edge` or `link-unlinked` event in `.perry/events.jsonl` + written by the same action as its `add` ## Definition of Done @@ -193,7 +202,10 @@ about. 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. +5. ~~`perry-state --section attribution` reports 0 never-asked `main`-track + rows.~~ **Restated 2026-08-31**: every `main`-track row opened after the gate + lands carries a KR edge or an `unlinked` declaration written by its own `add`. + The rows that were never asked before the gate are phase 004's. **Nice-to-Have** (failure allowed, explained in retro): @@ -231,6 +243,22 @@ documented as machine-written* — is about the command that wrote this file. ## Changes / Pivots <!-- append-only --> +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 <!-- filled by `okr dashboard` or `pmo mid-phase-review` --> ## Retro — phase scored <!-- filled by `okr score-phase` when the phase closes --> 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 <!-- append-only --> + +## Mid-phase check <!-- filled by `okr dashboard` or `pmo mid-phase-review` --> + +## Retro — phase scored <!-- filled by `okr score-phase` when the phase closes --> diff --git a/perry/tasks.jsonl b/perry/tasks.jsonl index a2a0b507..99f2e893 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,40 +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 <path> 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 <path> 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-<slug>.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 <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": "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 <pre> 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-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 7c4b7399..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 @@ -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/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/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 <run-id>' - '{_root_flag(root_arg)}\\n")', - ' print(f"\\n perry-migrate restore <run-id>\\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 `<pre>` / HTML comment / -# `<details>` 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 <tmpdir>`, - # 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 `<pre>`, an HTML comment or `<details>` 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 ( - ("<pre>", "<pre>\n%s</pre>\n"), - ("an HTML comment", "<!--\n%s-->\n"), - ("<details>", "<details>\n%s</details>\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| OKR.md | 2 | 2026-08-20 | declare |\n-->\n", - "-<!--")): - with self.subTest(edit=name): - message = self.refusal(body) - self.assertIn("--- .perry/conformance.md", message, name) - self.assertIn(must_name, message, - f"{name}: the diff does not locate it") - - def test_a_wholly_rewritten_record_is_capped_and_says_how_much_it_dropped(self): - """A refusal is read in a terminal. A whole record replaced by hand - would print two lines per row and bury its own last sentence — the - command to run — so the hunk is capped, and the cap says how many lines - it dropped rather than trailing off.""" - rows = [f"| phase/{i:03d}-x.md | 2 | 2026-08-20 | declare |\n" - for i in range(60)] - # Reversed, so the file differs from the record almost everywhere — a - # stray line at the end of a long file makes a two-line hunk, which is - # the point of the tight context and not a case the cap has to handle. - body = "".join(reversed(rows)) - p = self.record(body) - rc, out, err = p.run(CONFORM, "migrate") - self.assertEqual(rc, 1, f"the conversion did not refuse: {out} {err}") - message = out["refused"] - self.assertIn("more diff line(s)", message, "the hunk was not capped") - - # **The NUMBER, not just the notice.** Asserting the sentence exists - # let a mutation replace the count with a constant and stay green — the - # same shape as the FAIL itself: an assertion sitting beside the thing - # that matters. Recomputed from the file on disk and the shipped - # reader, the way a reader checking the message would. - import difflib - authored = p.legacy_marker().read_text() - canonical = C.render_legacy( - C.P.read_legacy_conformance(p.root).declarations) - total = len(list(difflib.unified_diff( - authored.splitlines(), canonical.splitlines(), - fromfile=C.P.CONFORMANCE_LEGACY_FILE, - tofile="what Perry reads out of it", lineterm="", n=1))) - expected = total - C.DIFF_CAP - self.assertGreater(expected, 0, "the fixture does not reach the cap") - self.assertIn(f"and {expected} more diff line(s)", message, - f"the refusal miscounts what it dropped (expected " - f"{expected} of {total})") - # The diff BLOCK, not every indented line in the message — the - # `perry-conform migrate` the last sentence names is indented too, and - # counting it made this assertion off by one in the direction that - # hides a cap one line too loose. - block = message[message.index(" --- "):message.index("\n\nFix those")] - self.assertLessEqual(len(block.split("\n")), C.DIFF_CAP + 1, - "the cap did not hold") - self.assertTrue(message.rstrip().endswith("**Nothing was written.**"), - "the diff buried the message's last sentence") - - def test_a_crlf_record_converts_and_the_wording_does_not_say_byte(self): - """**"Byte-for-byte" overclaimed and the phrase is gone.** The - comparison is against `Path.read_text()`, which applies universal - newline translation, so a record saved with CRLF converts. That is the - behaviour we want — a CRLF record is still Perry's record — but the - docstring said "byte-for-byte", which it is not. Pinned so the sentence - and the code cannot drift apart again.""" - p = Project() - p.legacy_marker().write_text( - ("\n".join(C.LEGACY_HEADER) + "\n" + self.CANON).replace("\n", "\r\n"), - newline="") - self.assertIn(b"\r\n", p.legacy_marker().read_bytes(), - "the fixture is not CRLF, so this measures nothing") - rc, out, err = p.run(CONFORM, "migrate") - self.assertEqual(rc, 0, f"a CRLF record refused: {out} {err}") - self.assertEqual(sorted(C.P.read_conformance(p.root).declarations), - [".perry/hook.md", "BOARD.md"]) - # **The guard pinned one literal in one file, and the V4 round-3 - # reviewer said so: `"byte-for-byte what"` in `bin/perry-conform` - # only.** A reworded overclaim — "byte for byte", "byte-for-byte - # identical to what" — walked past it, and `bin/README.md`, which - # documents the same conversion for the same reader, was not covered at - # all. Decided in round 4: widen it rather than leave it, because the - # sentence it protects lives in both files. - # - # It is a REGEX and not a ban on the phrase, deliberately. Both files - # use "byte-for-byte" correctly about other things — a row inside an - # HTML comment IS byte-for-byte a genuine row, `perry-tasks risks-diff` - # DOES byte-compare — and a guard that made those red would be deleted - # by the next person who hit it. What is banned is the phrase - # describing what the file is compared AGAINST. - # - # **Round 5 widened it, having measured how narrow it was.** The V4 - # round-4 reviewer put nine plausible overclaims to the round-4 regex: - # it caught **3** and evaded **5** — "identical to the file … wrote", - # "compared byte-for-byte against what", "byte-for-byte with what", - # the same phrase with a U+2011 non-breaking hyphen, and "bytewise". - # The regex below catches **9 of 9**, measured, and fires on nothing - # in either file today. - # - # The widening that was NOT taken is worth recording, because it was - # tried: allowing any short run of characters between "byte-for-byte" - # and its object also catches 9 of 9 and fires on **two correct - # sentences** — `bin/README.md`'s true claim that `perry-config` - # reproduces prose "byte for byte **while the file is on disk**", and - # `bin/perry-conform`'s own CORRECTING comment, which quotes the phrase - # in order to disown it. A guard that reddens the correction is a guard - # that gets deleted. So the object has to follow the phrase directly, - # through a connector from a closed list. - # - # **What it still cannot catch**, stated rather than left: a genuine - # byte comparison in either file described in exactly this shape — "the - # store is compared byte-for-byte with the record it derived" — would - # be a false positive. There is none today. If one arrives, the fix is - # to name the object rather than to delete the guard. - overclaim = re.compile( - r"byte[\s\-\u2010-\u2015]*(?:for[\s\-\u2010-\u2015]*byte|wise)" - r"(?:\s+(?:identical|equal|the\s+same))?" - r"(?:\s+(?:to|with|against|as))?" - r"\s+(?:what|the\s+file|the\s+record)\b", re.IGNORECASE) - for rel in ("bin/perry-conform", "bin/README.md"): - text = (PERRY_HOME / rel).read_text() - found = overclaim.search(text) - self.assertIsNone( - found, - f"{rel} claims a byte comparison it does not make " - f"({found.group(0) if found else ''!r}) — `read_text` " - f"translates newlines, which is why a CRLF record converts") - # And the correction itself is pinned, in both places: deleting the - # sentence that states the difference passes a NotIn assertion. - self.assertIn( - "ine-for-line, not byte-for-byte", text, - f"{rel} no longer states the difference between what the " - f"comparison does and what the word would have claimed") - - -class TestTheCommandTheRefusalNamesIsTheOneTheReaderCanRun(unittest.TestCase): - """**The round-3 V4 FAIL: the refusal named the command with the root - dropped, and the dropped-root command succeeds against a DIFFERENT - project.** - - `bin/perry-conform § message_for` propagates the invocation's `--root` - into every branch through `_root_flag()`. `migrate_record`'s two refusals - did not — including the one round 3 rewrote under the wall standard's own - banner. A reader routed there by `perry-conform migrate --root $PROJ`, who - copied the command they were handed, got: - - $ perry-conform migrate - perry-conform: nothing to convert — .perry/conformance.jsonl is - already this project's record (or it has none). - rc=0 - - **Exit 0 and a success-shaped sentence**, about a project they never asked - about, while their own record sat unconverted and still gating every - write. A named command that errors is worse than none; this is the worse - still variant, because nothing tells the reader anything went wrong. - - Sixteen invocations of `assert_conversion_refuses` asserted this message - while themselves running with `--root <tmpdir>`, and every one of them was - green: `assertIn("perry-conform migrate", message)` is true of the broken - string. **The assertion checked that A command was named, never that it was - the command the caller could actually run.** So this test does not - construct what it expects. It: - - 1. plants two REAL projects — the reader's, and a different one the - reader is standing in, which has already converted; - 2. measures the harm the dropped root causes, so the test states what it - is preventing rather than asserting a substring; - 3. takes the command OUT OF THE REFUSAL TEXT and runs it, unedited; - 4. asserts it converted the reader's project and left the other one - byte-identical. - - A test that built the expected string by hand would pass on the broken - implementation. This one cannot: step 3 runs whatever the message says. - """ - - def snapshot(self, root: Path) -> dict: - return {f.relative_to(root).as_posix(): f.read_bytes() - for f in sorted(root.rglob("*")) if f.is_file()} - - def test_the_named_command_converts_the_readers_project_from_elsewhere(self): - # ── the reader's project: a record with one real declaration and a - # stray line under it, which is the edit the record's own header - # invites and one of the two that survive the row round trip. - theirs = Project() - canonical = f"| BOARD.md | {C.shape_version(SCHEMA)} | 2026-08-20 | declare |\n" - header = "\n".join(C.LEGACY_HEADER) + "\n" - theirs.legacy_marker().write_text( - header + canonical + "\nreminder: check OKR.md\n") - - # ── a DIFFERENT project, and the one the reader is standing in. It has - # already converted, so `perry-conform migrate` run here is a no-op - # that exits 0 — which is exactly what makes the dropped root silent - # rather than loud. - elsewhere = Project() - rc, _, _ = elsewhere.run(CONFORM, "declare", "BOARD.md") - self.assertEqual(rc, 0, "the second project would not declare") - self.assertTrue(elsewhere.marker().exists()) - before = self.snapshot(elsewhere.root) - - # ── 1 · the refusal, reached the way the reader reaches it - rc, out, err = theirs.run(CONFORM, "migrate") - self.assertEqual(rc, 1, f"the conversion did not refuse: {out} {err}") - message = out["refused"] - - # ── 2 · the harm, measured on this tree rather than asserted. The - # command the BROKEN refusal named, run from where the reader stands. - harm = subprocess.run( - ["python3", str(CONFORM), "migrate"], - cwd=elsewhere.root, capture_output=True, text=True) - self.assertEqual(harm.returncode, 0, - "the dropped-root command is expected to SUCCEED — " - "that is what makes it dangerous; if it now errors " - "this test is measuring something else") - self.assertIn("nothing to convert", harm.stdout) - self.assertTrue( - theirs.legacy_marker().exists(), - "the dropped-root command converted the reader's project after " - "all — then there is no defect and this test is vacuous") - self.assertFalse(theirs.marker().exists()) - - # ── 3 · the command, taken out of the message - named = commands_named(message) - self.assertEqual( - len(named), 1, - f"expected exactly one command in the refusal, got {named!r}") - cmd = named[0] - argv = shlex.split(cmd) - self.assertEqual(argv[0], "perry-conform", - f"the refusal names something other than this tool: {cmd!r}") - self.assertNotEqual( - argv[1:], ["migrate"], - "the refusal hands back the bare command measured in step 2, " - "which exits 0 about a different project") - - # The reader does what the refusal told them to: fix those lines. - theirs.legacy_marker().write_text(header + canonical) - - # ── 4 · run it verbatim, from where the reader is standing — - # **through a shell**, because that is what "the reader copies it" - # means. `shlex.split` above proves the line PARSES; it does not - # perform globbing, `$` expansion or command substitution, and a - # fixture root ending in `*` would sail past it and be expanded by - # `/bin/sh` into whatever happens to be in the directory. Only the - # tool's own name is substituted, and the rest of the line is passed - # byte-for-byte, so the quoting under test is the quoting that runs. - self.assertTrue(cmd.startswith("perry-conform "), cmd) - shell_line = (f"python3 {shlex.quote(str(CONFORM))}" - + cmd[len("perry-conform"):]) - ran = subprocess.run( - ["/bin/sh", "-c", shell_line], - cwd=elsewhere.root, capture_output=True, text=True) - self.assertEqual( - ran.returncode, 0, - f"the command the refusal named failed: {ran.stdout} {ran.stderr}") - self.assertIn("carried 1 declaration(s)", ran.stdout, - f"it did not convert anything: {ran.stdout}") - - # ── the RIGHT project, and only it - self.assertTrue(theirs.marker().exists(), - "the reader's record was not converted") - self.assertFalse(theirs.legacy_marker().exists(), - "the markdown record was left behind") - decls = C.P.read_conformance(theirs.root).declarations - self.assertEqual(sorted(decls), ["BOARD.md"]) - self.assertEqual(decls["BOARD.md"].declared, "2026-08-20", - "the date was not carried across unchanged") - self.assertEqual(theirs.verdict("BOARD.md").state, C.CONFORMANT) - self.assertEqual( - self.snapshot(elsewhere.root), before, - "the command changed the project the reader was standing in") - - def test_a_backtick_in_the_root_is_quoted_and_what_that_costs(self): - """**The one residual of the class, measured rather than described.** - - `_q` quotes a backtick correctly — the indented commands in this - message are runnable verbatim on a project at `/tmp/a ``b`` c`. But - this codebase also hands commands back INLINE, delimited by single - backticks, and a backtick inside the argument closes the span early. - Two branches of `message_for` do that. So on such a root the same - message carries two runnable commands and two truncated ones, and the - truncated pair does not even parse. - - **Why it is not fixed here.** The break is in the message's markdown, - not in the quoting: closing it means either moving those two commands - onto indented lines of their own — which rewrites two sentences to - serve a directory name almost nobody has — or emitting a double-backtick - span when the argument contains a backtick. Both are real fixes and - neither is this row's FAIL. It is written down with its harm instead of - being left for the next reviewer to find, and pinned here so it cannot - get worse quietly. - - **This test goes red when the residual is closed**, like the TASK-246 - pin: if the inline spelling starts surviving, delete the second half - and say so in `TASK-234-result.md`. - """ - # The branch that hands back four commands — two indented, two inline - # — which is what makes the two spellings comparable in one message. - root = "/tmp/a `b` c" - message = C.message_for( - C.Verdict(path="BOARD.md", state=C.UNDECLARED, shape_version=2, - errors=["a shape error"]), - "perry-task", root) - - indented = [l.strip() for l in message.split("\n") - if l.startswith(" ") and l.strip().startswith("perry-")] - self.assertTrue(indented, f"no indented command at all:\n{message}") - for cmd in indented: - argv = shlex.split(cmd) - self.assertEqual( - argv[argv.index("--root") + 1], root, - f"the indented command {cmd!r} does not carry the root a " - f"backtick and all — that IS a defect in `_q`, not a " - f"limitation of the message's markdown") - - # The residual, stated as a measurement. - inline = re.findall(r"`(perry-[^`]*--root[^`]*)`", message) - broken = [] - for cmd in inline: - try: - shlex.split(cmd) - except ValueError: - broken.append(cmd) - self.assertTrue( - broken, - "an inline backticked command with a backtick in its root now " - "parses — the residual named in `TASK-234-result.md § 10.12` is " - "closed. Delete this half of the test and say so there.") - - def test_the_unreadable_rows_refusal_names_it_too(self): - """The other branch, and the one reached from `declare` — where the - old wording also said "again", which the reader had not done.""" - theirs = Project() - theirs.legacy_marker().write_text( - "\n".join(C.LEGACY_HEADER) + "\n" - + "| OKR.md | v-two | 2026-08-20 | declare |\n") - rc, out, _ = theirs.run(CONFORM, "migrate") - self.assertEqual(rc, 1) - message = out["refused"] - self.assertIn("will not honour", message) - assert_every_command_carries( - self, message, theirs.root, "the unreadable-rows branch") - self.assertNotIn( - "migrate` again", message, - "reached from `declare` or `perry-migrate apply` the reader ran " - "neither `migrate` nor it twice, so `again` is a false sentence") - - def test_the_declare_route_into_the_conversion_carries_the_root_too(self): - """`declare` converts the record first, so the same refusal is reached - from a command that is not `migrate`. It has to name the root the - reader typed on THAT command.""" - theirs = Project() - theirs.legacy_marker().write_text( - "\n".join(C.LEGACY_HEADER) + "\n" - + f"| BOARD.md | {C.shape_version(SCHEMA)} | 2026-08-20 | declare |\n" - + "\nreminder: check OKR.md\n") - rc, out, _ = theirs.run(CONFORM, "declare", "BOARD.md") - self.assertEqual(rc, 1, f"declare did not refuse: {out}") - assert_every_command_carries( - self, out["refused"], theirs.root, "the `declare` route") - - def test_no_refusal_in_perry_conform_names_a_command_without_the_root(self): - """**The class, guarded at the source.** Fixing the two sentences is an - instance; this is what stops the next one. - - Every `perry-*` command a runtime message HANDS BACK — on an indented - line of its own, backticked after `run` / `with` / `is`, or built into a - variable called `cmd` — must carry the invocation's root, spelled - `{r}` or `{_root_flag(...)}`. Prose that merely names a tool is not an - instruction and is not caught: *"is not what `perry-conform declare` - would have written"* names a command the reader is being told NOT to - run. - - **The rule is imported, not retyped.** It lives in - `tests/sweep_handed_back_commands.py`, which is also what sweeps the - wider tree where the remaining members are recorded rather than fixed - (`TASK-234-result.md § 10.9`). A second copy here would be a second - definition of the rule, and the first one to go stale would be the one - nobody ran. - """ - sweep = load("sweep_handed_back_commands", - PERRY_HOME / "tests" / "sweep_handed_back_commands.py") - handed, bad = [], [] - for tool in ("perry-conform", "perry-migrate"): - for _, lineno, phrase, problems in sweep.sites( - str(PERRY_HOME / "bin" / tool)): - if problems is None: - continue - handed.append((tool, lineno, phrase)) - if problems: - bad.append((tool, lineno, phrase, problems)) - self.assertEqual( - bad, [], - f"these messages hand back a command a reader cannot copy — the " - f"caller's root dropped (run from where the reader is standing it " - f"acts on a different project), or an argument interpolated raw " - f"(on a path with a space in it the line is not a command at " - f"all): {bad}") - # Non-vacuous: the sweep has to be FINDING the commands, not returning - # an empty set because the shapes it looks for stopped existing. - self.assertGreaterEqual( - len(handed), 20, - f"the sweep found only {len(handed)} handed-back command(s) in " - f"bin/perry-conform and bin/perry-migrate, so its empty finding " - f"list means nothing") - - -class TestTheRootIsRequiredNotDefaulted(unittest.TestCase): - """**The shape, asserted rather than merely written.** - - `root_arg` is keyword-only with no default on every function that hands a - reader a command, so a caller that has a root must pass it and a new caller - cannot inherit the omission by saying nothing. That argument is in - `TASK-234-result.md § 1.2` and nothing held it: the V4 round-4 reviewer's - R-N11 and R-N12 gave both parameters a default back and the whole suite - stayed green, because no caller in the tree omits them today. A shape that - protects a FUTURE caller cannot be pinned by a test that exercises present - ones — but it can be asserted directly, which is what this does. - - It is not pedantry. The reviewer's R-N3 and R-N4 are exactly what a silent - default costs: `apply_plan` had `root_arg: str | None = None`, every test - called it positionally, and two of three call sites could drop the root - with both modules green. - """ - - def assert_required_keyword(self, fn, name="root_arg"): - sig = inspect.signature(fn) - self.assertIn( - name, sig.parameters, - f"{fn.__name__}{sig} has no `{name}` at all") - param = sig.parameters[name] - self.assertIs( - param.kind, inspect.Parameter.KEYWORD_ONLY, - f"{fn.__name__}{sig}: `{name}` is not keyword-only, so a caller " - f"can supply it positionally and the next parameter added in " - f"front of it silently changes what every caller passes") - 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. That is " - f"the round-3 defect's shape — and, measured, the reason two of " - f"`apply_plan`'s three rollback sites could drop the root with " - f"the suite green") - - def test_perry_conforms_two_entry_points_require_the_root(self): - for fn in (C.declare, C.migrate_record): - with self.subTest(fn=fn.__name__): - self.assert_required_keyword(fn) - - -class TestTheSweepIsMeasuredNotTrusted(unittest.TestCase): - """**A positive control for `tests/sweep_handed_back_commands.py`, and the - number its census is a lower bound of.** - - The V4 round-4 reviewer's mutation R-N8: neuter the sweep's `ROOT` regex to - `re.compile(r"")` so it matches everything, and the whole of - `tests.test_conformance` stays **GREEN**. The test above asserts the - finding list is empty and that at least N commands were found — which - guards against the sweep finding *nothing*, and not at all against it - calling *everything* ok. The half of the decision that matters had no - control. - - It has one now, and the same fixture answers the other question the RESULT - was asserting rather than measuring: **how much of the class the sweep can - see.** `tests/fixtures/handed_back_spellings.py` plants one defect per - plausible spelling, each in a function whose name carries the measured - verdict, so recall is recomputed here rather than quoted from a review - nobody can re-derive. - """ - - FIXTURE = PERRY_HOME / "tests" / "fixtures" / "handed_back_spellings.py" - - def regions(self): - """Each planted spelling and the lines it owns. - - The regions are contiguous — a spelling owns everything from the end of - the previous one — so a module-level constant placed just above its - function (`MIGRATE_HINT`, `FIXES`) belongs to that spelling. Those two - are exactly the shapes the round-4 sweep could not see, so attributing - them by proximity rather than by nesting is the point, not a shortcut. - """ - import ast - tree = ast.parse(self.FIXTURE.read_text()) - out, prev = [], 0 - for node in tree.body: - if (isinstance(node, ast.FunctionDef) - and node.name.startswith("spelling_")): - out.append((node.name, prev + 1, node.end_lineno, - node.name.rsplit("_", 1)[-1])) - prev = node.end_lineno - return out - - def findings(self, sweep): - return [(lineno, phrase, problems) for _, lineno, phrase, problems - in sweep.sites(str(self.FIXTURE)) if problems] - - def test_the_sweep_reports_every_planted_defect_it_claims_to_see(self): - """**The control R-N8 asked for.** Every `_found` spelling has to - produce a finding and every `_clean` one has to produce none, so a - sweep that called everything ok fails here even though the real tools - are at zero and its census is still non-empty.""" - sweep = load("sweep_handed_back_commands", - PERRY_HOME / "tests" / "sweep_handed_back_commands.py") - found = self.findings(sweep) - self.assertTrue(found, "the sweep reported nothing about a file of " - "nothing but planted defects") - for name, lo, hi, verdict in self.regions(): - with self.subTest(spelling=name): - hit = [f for f in found if lo <= f[0] <= hi] - if verdict == "found": - self.assertTrue( - hit, - f"{name} plants a command the reader cannot copy and " - f"the sweep reported nothing about it — its census " - f"over the real tools is worth that much less") - else: - self.assertEqual( - hit, [], - f"{name} is a {verdict} ruling: the sweep is expected " - f"to stay silent and reported {hit!r}. A sweep that " - f"reports everything has perfect recall and no value") - - def test_the_recall_the_result_quotes_is_the_recall_measured_here(self): - """**The census is a lower bound and this is the bound.** - - `TASK-234-result.md § 1.2` prints `7 members / 3 left` and round 5 - prints `0 left`. Those are counts under ONE rule, not a census of the - class: what the rule cannot see it does not count. The rate is - measured, on the fixture, and asserted here so the RESULT's number and - the code cannot drift apart silently. - - 18 of the 19 planted defects, and 14 of the 15 the V4 round-4 reviewer - planted (the round-4 sweep found 10 of those 15). The one residual is - `spelling_13`, whose reasoning is in the fixture. - """ - sweep = load("sweep_handed_back_commands", - PERRY_HOME / "tests" / "sweep_handed_back_commands.py") - found = self.findings(sweep) - seen = missed = 0 - for _name, lo, hi, verdict in self.regions(): - if verdict not in ("found", "missed"): - continue - hit = any(lo <= f[0] <= hi for f in found) - seen += hit - missed += not hit - self.assertEqual( - (seen, missed), (18, 1), - f"the sweep's recall on the planted spellings is {seen}/" - f"{seen + missed}; `TASK-234-result.md § 1.2` says 18/19. One of " - f"the two is now wrong, and a recall number in a document nobody " - f"recomputes is the kind of claim this row exists to stop") - - -class TestTheDefensiveBranchesAreLoadBearing(unittest.TestCase): - """**Branches that survived their own deletion, now pinned.** - - The V4 reviewer swept the new code for defensive branches that could be - removed with the suite green and found five; a wider sweep on the same - method found seven. The reviewer's ruling was that none could produce a - false verdict or destroy data, and asked for them to be tested or named as - unpinned with that reasoning rather than left under a general claim. - - Six are tested here. One of the six turned out not to be defensive at all — - see `test_a_short_diff_does_not_claim_it_dropped_a_negative_number`. - - **The one NOT tested, named with its reasoning**, per the ruling: - - - `bin/perry-conform § verdict`'s `legacy_record=record.legacy is not None` - versus `bool(record.legacy)`. These are the same predicate: `record.legacy` - is `None` or a `Path` that came from `/`-joining two non-empty strings, and - every such `Path` is truthy. It is an EQUIVALENT MUTANT, not an untested - branch — there is no input that distinguishes them, so a test asserting - the difference cannot be written. Recorded so a later sweep does not - re-find it and file it again. - """ - - def store(self, line: str) -> Project: - p = Project() - p.marker().parent.mkdir(exist_ok=True) - p.marker().write_text(line) - return p - - def test_a_non_string_path_is_refused_rather_than_used_as_a_key(self): - """`{"path": 123}` would otherwise become a dict key of the wrong type, - which no `state_files()` key can ever equal — an unreachable - declaration that reports as present.""" - for value in ("123", '""', "null", "[]"): - with self.subTest(path=value): - p = self.store('{"kind": "declaration", "path": ' + value - + ', "shape_version": 2, "declared": ' - '"2026-08-28", "route": "declare"}\n') - rec = C.P.read_conformance(p.root) - self.assertEqual(rec.declarations, {}, value) - self.assertEqual(len(rec.unreadable), 1, - f"path {value} was dropped silently") - - def test_a_non_string_declared_or_route_is_refused(self): - """Both cells reach a human — `declared` is printed in every STALE and - DRIFTED refusal — and a non-string there formats as itself and reads - as a date nobody wrote.""" - for field, value in (("declared", "20260828"), ("declared", "null"), - ("route", "2"), ("route", "null")): - with self.subTest(**{field: value}): - rec = {"kind": '"declaration"', "path": '"BOARD.md"', - "shape_version": "2", "declared": '"2026-08-28"', - "route": '"declare"'} - rec[field] = value - p = self.store("{" + ", ".join( - f'"{k}": {v}' for k, v in rec.items()) + "}\n") - got = C.P.read_conformance(p.root) - self.assertEqual(got.declarations, {}, f"{field}={value}") - self.assertEqual(len(got.unreadable), 1) - - def test_an_empty_route_reads_as_declare_rather_than_as_blank(self): - """`route` answers *how was this declared*, and the two values are - `declare` and `migrate`. A blank is neither, and it is what a row - written before `route` existed parses to.""" - p = self.store('{"kind": "declaration", "path": "BOARD.md", ' - '"shape_version": 2, "declared": "2026-08-28", ' - '"route": ""}\n') - self.assertEqual( - C.P.read_conformance(p.root).declarations["BOARD.md"].route, - "declare") - rc, out, _ = p.run(CONFORM, "status") - row = next(f for f in out["files"] if f["path"] == "BOARD.md") - self.assertEqual(row["route"], "declare") - - def test_non_string_provenance_reads_as_empty_rather_than_as_itself(self): - """The three provenance fields are free text a reader is shown. A - number or an object there would travel into `status --json` and into - the next rewrite of the record exactly as typed.""" - p = self.store('{"kind": "declaration", "path": "BOARD.md", ' - '"shape_version": 2, "declared": "2026-08-28", ' - '"route": "declare", "writer": 7, ' - '"recorded_at": {"x": 1}, "run": []}\n') - decl = C.P.read_conformance(p.root).declarations["BOARD.md"] - self.assertEqual((decl.writer, decl.recorded_at, decl.run), ("", "", "")) - - def test_a_record_that_exists_but_cannot_be_read_is_not_a_crash(self): - """`exists()` is true and `read_text` raises — a directory where the - record should be, a revoked permission, a device. `perry-conform - status` is what the enforce gate calls, so a traceback here is a - traceback on every write. - - A directory, because it raises `IsADirectoryError` (an `OSError`) on - every platform and needs no permission games that a root-running CI - would skip past.""" - p = Project() - p.marker().mkdir(parents=True) - self.assertTrue(p.marker().exists()) - rec = C.P.read_conformance(p.root) - self.assertEqual(rec.declarations, {}) - rc, out, err = p.run(CONFORM, "status") - self.assertEqual(rc, 0, f"status crashed on an unreadable record: {err}") - self.assertIsInstance(out, dict, f"status printed no JSON: {out} {err}") - - def test_a_short_diff_does_not_claim_it_dropped_a_negative_number(self): - """**Not a defensive branch — a live one.** `max(0, len(lines) - CAP)` - looks like belt-and-braces and is not: without it, `dropped` is - NEGATIVE for every diff shorter than the cap, `if dropped:` is true for - a negative number, and every ordinary refusal ends *"… and -37 more - diff line(s)"*. That is a false statement to the reader, printed on the - one message the V4 FAIL was about. Found by sweeping for survivors.""" - p = Project() - p.legacy_marker().write_text( - "\n".join(C.LEGACY_HEADER) + "\n" - + "| BOARD.md | 2 | 2026-08-20 | declare |\n" + "stray\n") - rc, out, _ = p.run(CONFORM, "migrate") - self.assertEqual(rc, 1) - self.assertNotIn("more diff line(s)", out["refused"], - "a short diff claims it dropped lines") - self.assertNotIn("-1", out["refused"].split("@@")[0], - "a negative count reached the message") - - -class TestWhatTheConversionDoesNotDissolve(unittest.TestCase): - """**TASK-246 survives the format change, and this is where that is said.** - - TASK-246: *an unreadable row is DELETED by the next declare, not reported.* - The writer rebuilds the whole record from the parsed declarations, exactly - as the markdown writer did, so a line it could not read is not carried - forward. Converting the record shrinks the POPULATION of such lines — a - backticked, indented or fenced row is ordinary markdown and a person could - plausibly type one, where a broken JSON line is rarer — and it does not - touch the mechanism. - - Asserted as it IS rather than as it should be, so that the day TASK-246 is - fixed this test goes red and is rewritten deliberately, instead of the - project believing a row died when it did not. - """ - - def test_an_unreadable_line_is_still_dropped_by_the_next_declare(self): - p = Project() - p.marker().parent.mkdir(exist_ok=True) - p.marker().write_text(p.line("BOARD.md") + "{ not json at all\n") - self.assertEqual(len(C.P.read_conformance(p.root).unreadable), 1) - rc, out, err = p.run(CONFORM, "declare", ".perry/hook.md") - self.assertEqual(rc, 0, f"{out} {err}") - self.assertNotIn("not json at all", p.marker().read_text(), - "TASK-246 is dissolved — rewrite this test and close " - "the row rather than leaving it open") - self.assertEqual(C.P.read_conformance(p.root).unreadable, []) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_decide_status_enum.py b/tests/test_decide_status_enum.py index 8a388e33..e8b5d06c 100644 --- a/tests/test_decide_status_enum.py +++ b/tests/test_decide_status_enum.py @@ -36,7 +36,6 @@ import unittest from pathlib import Path -from gate import GATE_OFF # tests/gate.py — why this fixture opts out PERRY_HOME = Path(os.environ.get("PERRY_HOME") or Path(__file__).resolve().parent.parent) TOOL = PERRY_HOME / "bin" / "perry-decide" @@ -59,7 +58,7 @@ def __init__(self, home: Path | None = None): (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") def run(self, *argv): env = dict(os.environ, PERRY_HOME=str(self.home)) diff --git a/tests/test_decide_writer.py b/tests/test_decide_writer.py index f5d70bba..a7b4b089 100644 --- a/tests/test_decide_writer.py +++ b/tests/test_decide_writer.py @@ -33,7 +33,6 @@ import unittest from pathlib import Path -from gate import GATE_OFF # tests/gate.py — why this fixture opts out PERRY_HOME = Path(os.environ.get("PERRY_HOME") or Path(__file__).resolve().parent.parent) TOOL = PERRY_HOME / "bin" / "perry-decide" @@ -46,7 +45,7 @@ def __init__(self): (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") def run(self, *argv): r = subprocess.run( diff --git a/tests/test_diagnose.py b/tests/test_diagnose.py index 6a0f29b2..c775c239 100644 --- a/tests/test_diagnose.py +++ b/tests/test_diagnose.py @@ -981,7 +981,6 @@ def test_the_plan_placeholders_are_not(self): #: `tests/gate.py` — which will own this string — lands on `feat/work-modes` #: after this branch was cut; the line is inert on a tree whose gate is still #: advisory by default, so it is correct both before and after that merge. -GATE_OFF = "- Conformance gate: advisory\n" def board_with_queue(rows: str) -> str: @@ -1019,7 +1018,7 @@ class DecisionsAreCountedPerRecordNotPerMention(unittest.TestCase): def project(self, root: Path, board: str | None = None) -> Path: (root / ".perry").mkdir(parents=True, exist_ok=True) (root / ".perry" / "config.md").write_text( - "# Perry configuration\n\n- State root: .\n" + GATE_OFF) + "# Perry configuration\n\n- State root: .\n") if board is not None: (root / "BOARD.md").write_text(board) return root @@ -1206,7 +1205,7 @@ class AFixtureIsNotTheProjectsState(unittest.TestCase): def project(self, root: Path) -> Path: (root / ".perry").mkdir(parents=True, exist_ok=True) (root / ".perry" / "config.md").write_text( - "# Perry configuration\n\n- State root: .\n" + GATE_OFF) + "# Perry configuration\n\n- State root: .\n") return root # ── the half the objection is about ────────────────────────────────── @@ -1329,7 +1328,7 @@ class AQuotedIdIsNotAQueueRow(unittest.TestCase): def project(self, root: Path) -> Path: (root / ".perry").mkdir(parents=True, exist_ok=True) (root / ".perry" / "config.md").write_text( - "# Perry configuration\n\n- State root: .\n" + GATE_OFF) + "# Perry configuration\n\n- State root: .\n") return root # ── the one that stops a fix which just suppresses everything ──────── diff --git a/tests/test_goals_writer.py b/tests/test_goals_writer.py index 6d283df9..dd9c4a8e 100644 --- a/tests/test_goals_writer.py +++ b/tests/test_goals_writer.py @@ -1345,15 +1345,6 @@ def test_a_write_takes_the_same_project_lock_the_other_writers_take(self): self.assertIn("another Perry write is holding", r.stderr) self.assertNotIn("Commitments", p.text()) - def test_the_conformance_gate_refuses_a_write_on_an_undeclared_file(self): - """ADR-004. `perry-task` and `perry-decide` already gate; this one - gates on `OKR.md`, its own file and no other.""" - p = self.project() - before = p.text() - r = p.run("commit", "--track", "ops", "--promise", "a", "--to", "x", - "--due", "3d", expect=1, PERRY_CONFORMANCE="enforce") - self.assertIn("ADR-004", r.stderr) - self.assertEqual(before, p.text()) def test_reading_is_never_gated(self): p = self.project() @@ -1763,23 +1754,6 @@ def test_two_clock_columns_at_once_is_reported_not_guessed(self): self.assertEqual(before, p.text()) self.assertEqual([], p.events()) - def test_the_conformance_gate_does_not_lock_the_split_out(self): - """**The deadlock this exemption exists to break.** Under `enforce`, - ADR-004's gate makes a file that is not Perry's shape read-only — and a - pre-split register is out of shape by exactly the defect this command - fixes. Gated, the file could never be written to and never be repaired, - with no third command. `perry-migrate` is exempt from its own gate for - the same reason, and this is the transform it hands over.""" - p = self.project(PRE_SPLIT) - blocked = p.commit("--track", "ops", "--promise", "a", "--to", "x", - "--due", "3d", expect=1, - PERRY_CONFORMANCE="enforce") - self.assertIn("read-only", blocked.stderr) - - r = p.commit("--migrate", PERRY_CONFORMANCE="enforce") - self.assertIn("split", r.stdout) - self.assertIn("Due", p.text()) - self.assertIn("By when note", p.text()) def test_it_takes_no_row_flags(self): p = self.project(PRE_SPLIT) 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_knowledge_promotion.py b/tests/test_knowledge_promotion.py index eceb1e80..c5e6cbed 100644 --- a/tests/test_knowledge_promotion.py +++ b/tests/test_knowledge_promotion.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-knowledge" @@ -61,7 +60,7 @@ def project(self, extra: dict[str, str] | None = None, root = Path(tmp.name) (root / ".perry").mkdir() (root / ".perry" / "config.md").write_text( - "# Perry configuration\n\n- State root: .\n" + GATE_OFF, + "# Perry configuration\n\n- State root: .\n", encoding="utf-8") (root / "evidence" / "2026-08").mkdir(parents=True) (root / "evidence" / "2026-08" / "TASK-001-export-fix.md").write_text( diff --git a/tests/test_md_store.py b/tests/test_md_store.py index 4381bc48..e360120d 100644 --- a/tests/test_md_store.py +++ b/tests/test_md_store.py @@ -49,7 +49,6 @@ import perry_store as S # noqa: E402 import tables as T # noqa: E402 -from gate import GATE_OFF, gate_off # noqa: E402 FIXTURES = ROOT / "tests" / "fixtures" SECOND_PROJECT = pathlib.Path("~/proj/gimegime-pmo").expanduser() @@ -690,7 +689,7 @@ def setUp(self): self.path.write_text( SEPARATED_CONFIG.format(bullet=SEPARATED, cell=SEPARATED.replace("|", "\\|"), - gate=GATE_OFF), + gate=""), encoding="utf-8") proc = self.config("write", "--from-file") self.assertEqual(proc.returncode, 0, proc.stderr) @@ -876,18 +875,12 @@ def __init__(self, case: unittest.TestCase): (self.root / "perry").mkdir() (self.root / ".perry").mkdir() shutil.copy2(ROOT / "perry" / "OKR.md", self.root / "perry" / "OKR.md") - # The conformance gate reads `.perry/config.md` to decide its own mode, - # and `.perry/config.md` is one of the two files under test — so a - # fixture here is writing the very file the gate consults about - # itself. `GATE_OFF` is the documented way out (tests/gate.py); the - # gate's own branches are `tests/test_conformance.py`'s subject. - # `gate_off`, not `+ GATE_OFF`: Perry's own config carries `## Tracks` - # and prose, and an appended bullet lands outside the preamble - # `perry_md_store § scan_config` reads — so it would mint no record, - # and `gate_mode` reads the store first since TASK-233. + # Perry's own `.perry/config.md`, verbatim. The ADR-004 gate used to + # read this file to decide its own mode, which made a fixture writing + # it the file the gate consulted about itself; the gate is gone + # (TASK-261) and the copy is now just a copy. (self.root / ".perry" / "config.md").write_text( - gate_off((ROOT / ".perry" / "config.md").read_text()), - encoding="utf-8") + (ROOT / ".perry" / "config.md").read_text(), encoding="utf-8") def okr(self, *args): return run("perry-okr", *args, root=self.root) @@ -1170,7 +1163,7 @@ def project(self) -> pathlib.Path: self.addCleanup(shutil.rmtree, d, ignore_errors=True) shutil.copytree(FIXTURES / "second-project", d, dirs_exist_ok=True) cfg = d / ".perry" / "config.md" - cfg.write_text(gate_off(cfg.read_text()), encoding="utf-8") + cfg.write_text(cfg.read_text(), encoding="utf-8") return d def test_commit_writes_okr_and_the_store_together(self): 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 — <workstream>`, 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| OKR.md | 2 | 2026-08-20 | declare |\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 <id>`, 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 <N> — <title>` 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_okr_store_is_the_source.py b/tests/test_okr_store_is_the_source.py index c09594a6..4d14f2e2 100644 --- a/tests/test_okr_store_is_the_source.py +++ b/tests/test_okr_store_is_the_source.py @@ -48,7 +48,6 @@ sys.path.insert(0, str(ROOT / "viewer")) import perry_md_store as M # noqa: E402 -from gate import GATE_OFF # noqa: E402 GOALS = ROOT / "bin" / "perry-goals" OKR_TOOL = ROOT / "bin" / "perry-okr" @@ -57,7 +56,7 @@ - Document language: English - Repo layout: single -""" + GATE_OFF + """ +""" + """ ## Tracks | Track | Mode | Spine | Stages | WIP | SLA | Cycle | Default rung | diff --git a/tests/test_one_heading_predicate.py b/tests/test_one_heading_predicate.py index db1826fa..22dda144 100644 --- a/tests/test_one_heading_predicate.py +++ b/tests/test_one_heading_predicate.py @@ -30,7 +30,6 @@ import unittest from pathlib import Path -from gate import GATE_OFF # tests/gate.py — why this fixture opts out PERRY_HOME = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PERRY_HOME / "viewer")) @@ -186,7 +185,7 @@ def project(self, heading: str) -> Path: (root / ".perry").mkdir() (root / "perry").mkdir() (root / ".perry" / "config.md").write_text( - "# Perry configuration\n\n- State root: perry\n" + GATE_OFF, + "# Perry configuration\n\n- State root: perry\n", encoding="utf-8") (root / "perry" / "BOARD.md").write_text( BOARD.format(heading=heading), encoding="utf-8") diff --git a/tests/test_one_line_break_rule.py b/tests/test_one_line_break_rule.py index 080b3884..61da2666 100644 --- a/tests/test_one_line_break_rule.py +++ b/tests/test_one_line_break_rule.py @@ -27,7 +27,6 @@ import tempfile import unittest -from gate import GATE_OFF # tests/gate.py — why this fixture opts out ROOT = pathlib.Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "viewer")) @@ -142,7 +141,7 @@ def setUp(self): (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) seed = subprocess.run( [sys.executable, str(ROOT / "bin" / "perry-tasks"), "write", @@ -225,7 +224,8 @@ def test_the_fixture_row_is_reachable_before_the_refusal_is_asserted(self): """ #: Schema-shaped, because the fixture is DECLARED rather than gate-exempt and -#: `perry-conform declare` refuses a file that does not match Perry's shape. +#: The fixture used to `perry-conform declare` this file so the ADR-004 gate +#: would let the write through. That gate is gone (TASK-261). #: The `## Commitments` table starts empty and with `Discharged by` already #: present, so no test here is also exercising a widening. GOALS_OKR = """# OKR — fixture @@ -276,10 +276,6 @@ def declare(root: pathlib.Path) -> None: (root / ".perry").mkdir() (root / ".perry" / "config.md").write_text(GOALS_CONFIG) (root / "OKR.md").write_text(GOALS_OKR) - d = subprocess.run( - [sys.executable, str(ROOT / "bin" / "perry-conform"), "declare", - "OKR.md", "--root", str(root)], capture_output=True, text=True) - assert d.returncode == 0, d.stdout + d.stderr class TestPerryGoalsRefusalNamesTheFlagToo(unittest.TestCase): 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): diff --git a/tests/test_prioritize.py b/tests/test_prioritize.py index 9a366813..8431d8a1 100644 --- a/tests/test_prioritize.py +++ b/tests/test_prioritize.py @@ -31,7 +31,6 @@ import unittest from pathlib import Path -from gate import GATE_OFF # tests/gate.py — why this fixture opts out PERRY_HOME = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PERRY_HOME / "viewer")) @@ -74,7 +73,7 @@ def setUp(self): (self.root / "perry").mkdir() (self.root / ".perry").mkdir() (self.root / ".perry" / "config.md").write_text( - "# Config\n\nState root: perry/\n" + GATE_OFF, encoding="utf-8") + "# Config\n\nState root: perry/\n", encoding="utf-8") self.addCleanup(self.tmp.cleanup) def write(self, text): diff --git a/tests/test_procedures_call_the_tool.py b/tests/test_procedures_call_the_tool.py index 93841169..c5833f3b 100644 --- a/tests/test_procedures_call_the_tool.py +++ b/tests/test_procedures_call_the_tool.py @@ -5,7 +5,7 @@ evidence documents." — perry/decisions/ADR-007-fields-are-typed-prose-is-not.md -A procedure that says *"append a declaration to `.perry/conformance.jsonl`"* or +A procedure that says *"append a declaration to `BOARD.md`"* or *"append the full definition to the journal"* is that rule inverted back. The field write lands wherever the agent's markdown happened to land, the tool's event is never appended, and the row shows up at the next standup as drift — which is the @@ -79,21 +79,21 @@ promise on the write side — *adoption proposes, the user declares*. So a step under a `Migration` / `Adoption` heading may write an **authored document** (an ADR file) by hand. It may **not** write a **projection** - (`BOARD.md`, `OKR.md § Commitments`, `.perry/conformance.jsonl`): a projection + (`BOARD.md`, `OKR.md § Commitments`, `BOARD.md`): a projection is rendered from the documents, so transcribing one is drift the moment the next tool call re-renders it. 6. **Bootstrap from a shipped template, for a file the tool cannot create.** `perry-task` refuses on a missing board — `no BOARD.md at <path>` — so `work/reference/bootstrap.md` copying `state/BOARD_TEMPLATE.md` is how the board comes to exist, not a hand edit of a field. This is conditioned on - `creates_file`, not on the word "template": `perry-conform declare` DOES - create `.perry/conformance.jsonl`, so the identical template phrasing about the + `creates_file`, not on the word "template": `perry-task add` DOES + create `BOARD.md`, so the identical template phrasing about the record stays reportable. **The example this paragraph used to give was `DECISIONS.md`**, whose `perry-decide bootstrap` created it — three of the nineteen were exactly that phrasing. TASK-235 deleted the file, so the asymmetry needed a target - that still has a creating writer, and `.perry/conformance.jsonl` is one. + that still has a creating writer, and `BOARD.md` is one. Run: python3 tests/parallel test_procedures_call_the_tool """ @@ -208,15 +208,6 @@ def procedure_pages(root: Path = PERRY_HOME) -> list[Path]: pattern=r"(?:##\s*Cards by topic[^.]{0,80}knowledge/INDEX\.md" r"|knowledge/INDEX\.md[^.]{0,80}##\s*Cards by topic)", tool="perry-knowledge", kind="projection"), - ".perry/conformance.jsonl": dict( - # **Both spellings** (TASK-234). The record is the jsonl store; the - # markdown is what every pre-conversion project still has on disk, and - # a procedure telling a user to hand-edit EITHER is the step this - # module exists to catch. Dropping the old spelling when the record - # moved would have retired the guard for the file that is still out - # there, which is the half that can still be hand-edited by mistake. - pattern=r"\.perry/conformance\.(?:jsonl|md)", - tool="perry-conform", kind="projection"), } def owner_pattern(tool: str) -> str: @@ -264,7 +255,7 @@ def owner_pattern(tool: str) -> str: r"\s*[`'\"*(\[]*$", re.I) #: How close a write verb has to sit to the target to be a write TO it. Wide -#: enough for "Update `.perry/conformance.jsonl` (move the row to the new shape +#: enough for "Update `BOARD.md` (move the row to the new shape #: version)", narrow enough that a read at the head of a step and a write to #: some other file two sentences later are not read as one instruction. BEFORE, AFTER = 60, 90 @@ -358,8 +349,8 @@ def target_is_subject(sentence: str, pattern: str) -> bool: #: not a field write, and it is exempt only where the owning tool cannot create #: that file (`creates_file=False`). `perry-task` refuses on a missing #: `BOARD.md`, so `work/reference/bootstrap.md` copying `BOARD_TEMPLATE.md` is -#: how the board comes to exist at all. `perry-conform declare` DOES create -#: `.perry/conformance.jsonl`, so the same phrasing about that record stays +#: how the board comes to exist at all. `perry-task add` DOES create +#: `BOARD.md`, so the same phrasing about that record stays #: reportable — the asymmetry that caught three of the nineteen, restated on a #: target that still exists after TASK-235. def from_target_template(flat: str, spec: dict) -> bool: @@ -586,7 +577,7 @@ def test_root_router_reference_and_pack_shapes_are_each_load_bearing(self): root / "SKILL.md": "1. Add a row to `BOARD.md` by hand.\n", root / "reference" / "deep" / "page.md": - "1. Update `.perry/conformance.jsonl` by hand.\n", + "1. Update `BOARD.md` by hand.\n", root / "packs" / "ops" / "incidents.md": "1. Append the `## Status changes` line by hand.\n", } @@ -617,7 +608,7 @@ def test_a_planted_lane_and_a_planted_page_are_both_caught(self): (lane / "state").mkdir() (lane / "SKILL.md").write_text( "# reckon\n\n## Procedure\n\n" - "1. Update `.perry/conformance.jsonl`: add a row for the file.\n") + "1. Update `BOARD.md`: add a row for the file.\n") (lane / "reference" / "deep" / "buried.md").write_text( "# buried\n\n## Procedure\n\n" "1. Append the row to `BOARD.md` and write the " @@ -632,7 +623,7 @@ def test_a_planted_lane_and_a_planted_page_are_both_caught(self): "# bootstrap\n\n" "1. Write `BOARD.md` from `state/BOARD_TEMPLATE.md`, empty " "tables.\n" - "2. Write `.perry/conformance.jsonl` from " + "2. Write `BOARD.md` from " "`state/conformance_TEMPLATE.md`, empty record.\n") (lane / "state" / "SHIPPED.md").write_text( "1. Update `BOARD.md`: add a row by hand.\n") @@ -661,9 +652,9 @@ def test_a_planted_lane_and_a_planted_page_are_both_caught(self): "reporting it is how a guard gets switched off") # Exemption 6 cuts one way and not the other, on one page: nothing - # creates `BOARD.md`, `perry-conform declare` creates the record. + # creates `BOARD.md`, `perry-task add` creates the record. boot = reported["bootstrap.md"] - self.assertEqual([f[1] for f in boot], [".perry/conformance.jsonl"], + self.assertEqual([f[1] for f in boot], ["BOARD.md row"], "the template exemption is conditioned on whether " "the owning tool can create the file, not on the " f"word 'template'; got {boot}") @@ -762,13 +753,13 @@ def test_adoption_exempts_a_document_and_never_a_projection(self): """ step = ("1. Edit the target ADR yourself: flip its `Status:` header " "to `active`.\n" - "2. Add the matching row to `.perry/conformance.jsonl` by hand.\n") + "2. Add the matching row to `BOARD.md` by hand.\n") with tempfile.TemporaryDirectory() as tmp: page = Path(tmp) / "migrate.md" page.write_text("# m\n\n## Migration from a legacy board\n\n" + step) under = scan(page) - self.assertEqual([f[1] for f in under], [".perry/conformance.jsonl"], + self.assertEqual([f[1] for f in under], ["BOARD.md row"], "under an adoption heading the ADR file is the " "authored document adoption exists to transcribe, " "and the record is the projection it may never " @@ -778,7 +769,7 @@ def test_adoption_exempts_a_document_and_never_a_projection(self): outside = scan(page) self.assertEqual( sorted(f[1] for f in outside), - [".perry/conformance.jsonl", "an ADR's typed header"], + ["BOARD.md row", "an ADR's typed header"], "outside an adoption heading both are reportable — if the " "document half is silent here, the exemption is not scoped to " f"the heading at all; got {outside}") @@ -850,9 +841,6 @@ def test_every_declared_target_has_positive_and_negative_behavior(self): "`phase/<NNN>-linkage.md`.\n", "1. `perry-goals link` appends the task id to its KR's " "`tasks[]`.\n"), - ".perry/conformance.jsonl": ( - "1. Append a declaration to `.perry/conformance.jsonl`.\n", - "1. `perry-conform declare` writes `.perry/conformance.jsonl`.\n"), } self.assertEqual(set(TARGETS), set(cases), "a declared rule without both fixtures is unreviewed") @@ -890,9 +878,9 @@ def test_r2_cell_and_multiple_targets_are_independent(self): def test_paragraph_steps_lists_and_leading_prose_are_all_scanned(self): paragraph, _ = self.scan_text( "# page\n\n## Procedure\n\n" - "Update `.perry/conformance.jsonl` by hand.\n") + "Update `BOARD.md` by hand.\n") self.assertEqual([(f[1], f[2]) for f in paragraph], - [(".perry/conformance.jsonl", "R1")]) + [("BOARD.md row", "R1")]) split_from_tool, _ = self.scan_text( "# page\n\n## Procedure\n\n" @@ -910,24 +898,11 @@ def test_paragraph_steps_lists_and_leading_prose_are_all_scanned(self): leading, _ = self.scan_text( "# page\n\n## Procedure\n\n" - "Update `.perry/conformance.jsonl` by hand.\n" - "1. Run `perry-conform status` afterward.\n") + "Update `BOARD.md` by hand.\n" + "1. Run `perry-task list` afterward.\n") self.assertEqual([(f[1], f[2]) for f in leading], - [(".perry/conformance.jsonl", "R1")]) - - def test_the_markdown_record_is_still_a_target_under_its_old_name(self): - """TASK-234 moved the record to `.perry/conformance.jsonl` and the - markdown is still on disk in every project written before it — the - half a user can still be told to hand-edit by mistake. Its own test, - because the old spelling is exactly what a rename would quietly drop: - the suite above would stay green with the guard covering only the file - that no longer exists in the wild. - """ - legacy, _ = self.scan_text( - "# page\n\n## Procedure\n\n" - "Update `.perry/conformance.md` by hand.\n") - self.assertEqual([(f[1], f[2]) for f in legacy], - [(".perry/conformance.jsonl", "R1")]) + [("BOARD.md row", "R1")]) + def test_bulleted_steps_keep_exemptions_inside_their_item(self): """Both Markdown bullet forms segment steps just like numbered items.""" @@ -993,7 +968,7 @@ def test_expanded_corpus_false_positive_boundaries_are_precise(self): """Four TASK-101 exemptions suppress descriptions, not instructions.""" allowed = [ ("1. `pmo` still writes `BOARD.md`.\n", True), - ("1. Detect `OKR.md` / code / `.perry/conformance.jsonl` to pre-fill " + ("1. Detect `OKR.md` / code / `BOARD.md` to pre-fill " "a draft.\n", False), ("1. The BOARD row flips to `review` after verification.\n", True), ("1. `BOARD.md` + `journal/` move to `work`.\n", True), @@ -1012,8 +987,8 @@ def test_expanded_corpus_false_positive_boundaries_are_precise(self): "semantic exemptions must be observable") refused = [ - "1. Detect the problem, then update `.perry/conformance.jsonl`.\n", - "1. Detect `OKR.md` / code. Then update `.perry/conformance.jsonl`.\n", + "1. Detect the problem, then update `BOARD.md`.\n", + "1. Detect `OKR.md` / code. Then update `BOARD.md`.\n", "1. For the BOARD row, after checking its id, update Status.\n", ] for text in refused: @@ -1028,7 +1003,7 @@ def test_prohibition_description_and_markdown_exemptions_are_observable(self): "prohibition"), ("1. It updates the `BOARD.md` row.\n", "descriptive"), ("1. It already updates the `BOARD.md` row.\n", "descriptive"), - ("1. Writes the accompanying `.perry/conformance.jsonl` row itself.\n", + ("1. Writes the accompanying `BOARD.md` row itself.\n", "descriptive"), ("1. Creating a queue row also creates `BOARD.md § Intake`.\n", "descriptive"), @@ -1046,12 +1021,12 @@ def test_prohibition_description_and_markdown_exemptions_are_observable(self): findings, suppressed = self.scan_text( "# page\n\n## Inventory\n\n" "| Action | Update `BOARD.md`: add a row. |\n" - "> Update `.perry/conformance.jsonl`: add a declaration row.\n") + "> Update `BOARD.md`: add a declaration row.\n") self.assertEqual(findings, []) self.assertEqual( [(s.exemption, s.target) for s in suppressed], [("quoted-or-table", "BOARD.md row"), - ("quoted-or-table", ".perry/conformance.jsonl")]) + ("quoted-or-table", "BOARD.md row")]) def test_write_participles_and_read_anchors_do_not_go_silent(self): passive, _ = self.scan_text( diff --git a/tests/test_queue_sla.py b/tests/test_queue_sla.py index daba7ac5..a19f2baf 100644 --- a/tests/test_queue_sla.py +++ b/tests/test_queue_sla.py @@ -38,7 +38,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 sys.path.insert(0, str(PERRY_HOME / "bin")) @@ -47,7 +46,7 @@ STATE = PERRY_HOME / "bin" / "perry-state" TASK = PERRY_HOME / "bin" / "perry-task" -CONFIG = ("# Perry configuration\n\n- State root: perry\n" + GATE_OFF +CONFIG = ("# Perry configuration\n\n- State root: perry\n" + "\n## Tracks\n\n" "| Track | Mode | Spine | Stages | WIP | SLA | Cycle | Default rung |\n" "|---|---|---|---|---|---|---|---|\n{rows}") @@ -517,7 +516,7 @@ def test_a_project_with_no_track_register_carries_the_same_keys(self): (root / ".perry").mkdir() (root / "perry").mkdir() (root / ".perry" / "config.md").write_text( - "# Perry configuration\n\n- State root: perry\n" + GATE_OFF, + "# Perry configuration\n\n- State root: perry\n", encoding="utf-8") (root / "perry" / "BOARD.md").write_text( "# Board\n\n## P1\n\n" + HEAD, encoding="utf-8") diff --git a/tests/test_register_store_invariant.py b/tests/test_register_store_invariant.py index 92286465..422128d8 100644 --- a/tests/test_register_store_invariant.py +++ b/tests/test_register_store_invariant.py @@ -41,7 +41,6 @@ import unittest from pathlib import Path -from gate import GATE_OFF # tests/gate.py — why these fixtures opt out from test_asks_store import REGISTER as ASK_TABLE from test_intake_store import REGISTER as INTAKE_TABLE from test_risks_store import REGISTER as RISK_TABLE @@ -183,7 +182,7 @@ def __init__(self, board: str, tracks: str = "", mint=("intake", "asks", (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 + tracks, + "- Repo layout: single\n- State root: .\n" + tracks, encoding="utf-8") (self.root / "BOARD.md").write_text(board, encoding="utf-8") self._tasks("write", "--from-board") diff --git a/tests/test_retired_tolerance.py b/tests/test_retired_tolerance.py index 4128ef81..6ed6e525 100644 --- a/tests/test_retired_tolerance.py +++ b/tests/test_retired_tolerance.py @@ -56,7 +56,6 @@ import parsers as P # noqa: E402 sys.path.insert(0, str(Path(__file__).resolve().parent)) -from gate import GATE_OFF # noqa: E402 #: A project that never migrated, and never will unless someone asks it to. @@ -163,7 +162,7 @@ class Project: """A throwaway project. Advisory by default, because § 1 and § 2 are not about the gate — they are about what the tools do once past it.""" - def __init__(self, board: str = CONFORMANT, gate: str = GATE_OFF, + def __init__(self, board: str = CONFORMANT, gate: str = "", store: bool = False): self.dir = tempfile.TemporaryDirectory() self.root = Path(self.dir.name) @@ -252,16 +251,6 @@ def test_the_wrong_row_is_not_cleared_by_guessing_column_zero(self): "--reason", "it stopped") self.assertEqual(p.board(), board) - def test_a_declared_board_never_reaches_the_refusal(self): - """The branch is unreachable for a conformant file, which is why it can - go. Same three sections, Perry's shape, all three commands succeed.""" - p = Project(board=CONFORMANT) - for argv in (("risk-clear", "RX-001", "--reason", "it stopped"), - ("answer", "USER-001", "--answer", "the second one"), - ("cadence-done", "CAD-001", - "--evidence", "evidence/2026-08/run.md")): - rc, out, err = p.run(TASK, *argv) - self.assertEqual(rc, 0, f"{argv[0]}: {out!r} {err}") def test_the_localized_spelling_still_resolves(self): """`编号` is the declared Chinese spelling of `ID`, so it must resolve @@ -361,25 +350,6 @@ def test_nothing_here_wrote_to_the_project(self): # ── 3 · what the enforce flip was supposed to buy ───────────────────────── -class TestADeclaredFileThatViolatesItsShapeIsRefused(unittest.TestCase): - - def test_a_declared_board_that_stops_matching_is_refused_not_tolerated(self): - p = Project(board=CONFORMANT, gate="") - rc, _, err = p.run(CONFORM, "declare", "BOARD.md") - self.assertEqual(rc, 0, err) - # The user edits it afterwards — a legitimate thing to do, and the - # declaration is reported rather than revoked. - (p.root / "BOARD.md").write_text( - without_id_column(CONFORMANT, "Top risks")) - before = p.board() - rc, out, err = p.run(TASK, "risk-add", "--title", "a new risk") - self.assertEqual(rc, 1, f"the write should have been refused: {out!r}") - self.assertEqual(p.board(), before) - # The road, named: a drifted declaration is re-checked and - # re-declared, not migrated — the file was Perry's shape once. - msg = out.get("refused", "") if isinstance(out, dict) else err - self.assertIn("no longer matches", msg) - self.assertIn("perry-conform declare", msg) if __name__ == "__main__": 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/<TASK>-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/tests/test_role_on_rows.py b/tests/test_role_on_rows.py index f198f972..a389a266 100644 --- a/tests/test_role_on_rows.py +++ b/tests/test_role_on_rows.py @@ -19,7 +19,6 @@ import unittest from pathlib import Path -from gate import GATE_OFF # tests/gate.py — why this fixture opts out PERRY_HOME = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PERRY_HOME / "viewer")) @@ -59,7 +58,7 @@ def project(self, roles: dict[str, str] | None = None) -> Path: (root / ".perry").mkdir() (root / "perry").mkdir() (root / ".perry" / "config.md").write_text( - "# Perry configuration\n\nState root: perry/\n" + GATE_OFF, + "# Perry configuration\n\nState root: perry/\n", encoding="utf-8") (root / "perry" / "BOARD.md").write_text("\n".join([ "# Board", "", diff --git a/tests/test_row_integrity.py b/tests/test_row_integrity.py index c92d4b5b..5310d47c 100644 --- a/tests/test_row_integrity.py +++ b/tests/test_row_integrity.py @@ -64,7 +64,6 @@ import unittest from pathlib import Path -from gate import GATE_OFF # tests/gate.py — why this fixture opts out PERRY_HOME = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PERRY_HOME / "viewer")) @@ -195,7 +194,7 @@ def setUp(self): encoding="utf-8") (self.root / ".perry").mkdir() (self.root / ".perry" / "config.md").write_text( - "# Config\n\nState root: perry/\n" + GATE_OFF, encoding="utf-8") + "# Config\n\nState root: perry/\n", encoding="utf-8") self.addCleanup(self.tmp.cleanup) def run_add(self, next_action: str): diff --git a/tests/test_store_is_the_write_target.py b/tests/test_store_is_the_write_target.py index 22fb1308..431cf6be 100644 --- a/tests/test_store_is_the_write_target.py +++ b/tests/test_store_is_the_write_target.py @@ -34,7 +34,6 @@ import tempfile import unittest -from gate import GATE_OFF # tests/gate.py — why this fixture opts out ROOT = pathlib.Path(__file__).resolve().parent.parent TASK = ROOT / "bin" / "perry-task" @@ -81,7 +80,7 @@ def __init__(self, case, board: str = BOARD, seed_store: bool = True): (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", encoding="utf-8") (self.root / "BOARD.md").write_text(board, encoding="utf-8") if seed_store: diff --git a/tests/test_task_store_read_cutover.py b/tests/test_task_store_read_cutover.py index 3c3e9392..6ab12229 100644 --- a/tests/test_task_store_read_cutover.py +++ b/tests/test_task_store_read_cutover.py @@ -14,7 +14,6 @@ from pathlib import Path -from gate import GATE_OFF # tests/gate.py — why this fixture opts out ROOT = Path(__file__).resolve().parent.parent TASK = ROOT / "bin" / "perry-task" @@ -58,7 +57,7 @@ def __init__(self, case: unittest.TestCase): case.addCleanup(shutil.rmtree, self.root, ignore_errors=True) (self.root / ".perry").mkdir() (self.root / ".perry" / "config.md").write_text( - "# Perry configuration\n\n- State root: .\n" + GATE_OFF, + "# Perry configuration\n\n- State root: .\n", encoding="utf-8") (self.root / "BOARD.md").write_text(BOARD, encoding="utf-8") self.write_store([ diff --git a/tests/test_task_summary.py b/tests/test_task_summary.py index 1b445433..a0e2caa1 100644 --- a/tests/test_task_summary.py +++ b/tests/test_task_summary.py @@ -8,7 +8,6 @@ import unittest from pathlib import Path -from tests.gate import GATE_OFF # tests/gate.py — why this fixture opts out from tests.test_store_is_the_write_target import Project, task_module @@ -182,11 +181,11 @@ def test_pipeline_stage_mutation_preserves_the_sentinel(self): "|---|---|---|---|---|---|---|---|", ).replace("| — | — |", "| — | — | ops | brief |") project = Project(self, board=board) - # Overwrites the config `Project` wrote, so it carries `GATE_OFF` + # Overwrites the config `Project` wrote, so it carries `""` # forward itself — see tests/gate.py. (project.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" + "\n## Tracks\n\n" "| Track | Mode | Spine | Stages | WIP | SLA | Cycle | Default rung |\n" "|---|---|---|---|---|---|---|---|\n" diff --git a/tests/test_task_writer.py b/tests/test_task_writer.py index f5d76721..827d6007 100644 --- a/tests/test_task_writer.py +++ b/tests/test_task_writer.py @@ -31,7 +31,6 @@ import unittest 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" @@ -149,7 +148,7 @@ def __init__(self, tracks: str = "", 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 + tracks) + "- Repo layout: single\n- State root: .\n" + tracks) (self.root / "BOARD.md").write_text(board) self.import_board() @@ -440,11 +439,11 @@ class TestALocalizedBoard(unittest.TestCase): def zh(self) -> "Project": p = Project(board=ZH_BOARD) - # Overwrites the config `Project` wrote, so it has to carry `GATE_OFF` + # Overwrites the config `Project` wrote, so it has to carry `""` # forward itself — `ZH_BOARD` is deliberately not Perry's shape. (p.root / ".perry" / "config.md").write_text( "# Perry configuration\n\n- Document language: 中文\n" - "- Repo layout: single\n- State root: .\n" + GATE_OFF) + "- Repo layout: single\n- State root: .\n") return p def row(self, p: "Project") -> list[str]: diff --git a/tests/test_track_move.py b/tests/test_track_move.py index 531809c0..d964a850 100644 --- a/tests/test_track_move.py +++ b/tests/test_track_move.py @@ -37,7 +37,6 @@ from datetime import date from pathlib import Path -from gate import GATE_OFF # tests/gate.py — why this fixture opts out PERRY_HOME = Path(__file__).resolve().parent.parent TASK = PERRY_HOME / "bin" / "perry-task" @@ -57,7 +56,7 @@ "| ops | queue | standing | new→triaged→resolved | 3 | 3d | weekly | V2 |\n" ) -CONFIG = ("# Perry configuration\n\n- State root: perry\n" + GATE_OFF +CONFIG = ("# Perry configuration\n\n- State root: perry\n" + "\n## Tracks\n\n" "| Track | Mode | Spine | Stages | WIP | SLA | Cycle | Default rung |\n" "|---|---|---|---|---|---|---|---|\n" + TRACKS) diff --git a/tests/test_track_register_source.py b/tests/test_track_register_source.py index 40576d47..d8c50c9f 100644 --- a/tests/test_track_register_source.py +++ b/tests/test_track_register_source.py @@ -51,7 +51,6 @@ import tempfile import unittest -from gate import GATE_OFF, gate_off_record # tests/gate.py — the opt-out ROOT = pathlib.Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "bin")) @@ -76,7 +75,7 @@ def _state_module(): PS = _state_module() -#: `GATE_OFF` is appended rather than spelled out: `tests/gate.py` exists so +#: `""` is appended rather than spelled out: `tests/gate.py` exists so #: that renaming the `Conformance gate` matcher reddens every fixture using it #: at once, and a fixture that inlines the line opts itself out of that. CONFIG_MD = ("""# Perry configuration @@ -84,7 +83,7 @@ def _state_module(): - Document language: English - Repo layout: single - State root: . -""" + GATE_OFF + """ +""" + """ ## Tracks | Track | Mode | Spine | Stages | WIP | SLA | Cycle | Default rung | @@ -128,7 +127,7 @@ def track_record(name: str, mode: str, order: int) -> str: #: an ADR-004 reason that has nothing to do with the track register, the exact #: trap this module was written after. GOOD_STORE = track_record("main", "project", 0) + "\n" \ - + track_record("intake", "queue", 1) + "\n" + gate_off_record() + + track_record("intake", "queue", 1) + "\n" + "" class Fixture(unittest.TestCase): @@ -304,7 +303,7 @@ def test_every_unusable_source_has_a_sentence_for_a_human(self): #: measuring nothing. SETTING_ONLY = json.dumps({"kind": "setting", "key": "language", "value": "English", "order": 0}) + "\n" \ - + gate_off_record() + + "" #: A `## Tracks` row whose every cell is FILLED, so that a store record which #: merely EXISTS under the same name still contradicts it. Round 5's FAIL @@ -316,7 +315,7 @@ def test_every_unusable_source_has_a_sentence_for_a_human(self): - Document language: English - Repo layout: single - State root: . -""" + GATE_OFF + """ +""" + """ ## Tracks | Track | Mode | Spine | Stages | WIP | SLA | Cycle | Default rung | @@ -954,7 +953,7 @@ class TestWhatTheProjectionDeclares(Fixture): - Document language: English - Repo layout: single - State root: . -""" + GATE_OFF + """ +""" + """ ## Tracks | Track | Mode | Spine | Stages | WIP | SLA | Cycle | Default rung | diff --git a/tests/test_unlinked_declaration.py b/tests/test_unlinked_declaration.py index fd2d8048..4a5cc3f2 100644 --- a/tests/test_unlinked_declaration.py +++ b/tests/test_unlinked_declaration.py @@ -46,7 +46,6 @@ import tempfile import unittest -from gate import gate_off # tests/gate.py — why this fixture opts out ROOT = pathlib.Path(__file__).resolve().parent.parent GOALS = ROOT / "bin" / "perry-goals" @@ -86,7 +85,7 @@ def project(self, *, store: list[str] | None = None) -> pathlib.Path: # the one test that expects a SUCCESS. The refusal tests assert on the # message for the same reason. cfg = dest / ".perry" / "config.md" - cfg.write_text(gate_off(cfg.read_text())) + cfg.write_text(cfg.read_text()) rows = STORE_ROWS if store is None else store if rows is not None: (dest / "tasks.jsonl").write_text( diff --git a/tests/test_v5_signoff.py b/tests/test_v5_signoff.py index f9a54491..c927fcc5 100644 --- a/tests/test_v5_signoff.py +++ b/tests/test_v5_signoff.py @@ -37,7 +37,6 @@ from datetime import date 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" @@ -92,7 +91,7 @@ def __init__(self): (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) r = subprocess.run( ["python3", str(TASKS), "write", "--from-board", "--root", diff --git a/tests/test_wip_and_stages.py b/tests/test_wip_and_stages.py index 3219fe73..e22c1d97 100644 --- a/tests/test_wip_and_stages.py +++ b/tests/test_wip_and_stages.py @@ -27,7 +27,6 @@ import unittest from pathlib import Path -from gate import GATE_OFF # tests/gate.py — why this fixture opts out PERRY_HOME = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PERRY_HOME / "viewer")) @@ -37,7 +36,7 @@ TASK = PERRY_HOME / "bin" / "perry-task" SCHEMA = json.loads((PERRY_HOME / "schema" / "state-schema.json").read_text()) -CONFIG = ("# Perry configuration\n\n- State root: perry\n" + GATE_OFF +CONFIG = ("# Perry configuration\n\n- State root: perry\n" + "\n## Tracks\n\n" "| Track | Mode | Spine | Stages | WIP | SLA | Cycle | Default rung |\n" "|---|---|---|---|---|---|---|---|\n{rows}") diff --git a/tests/test_work_modes.py b/tests/test_work_modes.py index 579f1852..55f24ee2 100644 --- a/tests/test_work_modes.py +++ b/tests/test_work_modes.py @@ -37,7 +37,6 @@ import unittest from pathlib import Path -from gate import GATE_OFF, gate_off, gate_off_record # noqa: E402 — tests/gate.py: why these fixtures opt out PERRY_HOME = Path(__file__).resolve().parent.parent SCHEMA = json.loads((PERRY_HOME / "schema" / "state-schema.json").read_text()) @@ -206,185 +205,6 @@ def test_missing_mode_column_is_rejected(self): self.assertIn("table-columns", out) -class TestAColumnWithNoHonestDefaultMustBeDeclared(unittest.TestCase): - """TASK-046. `work_modes.defaults_note` already says it: a column listed in - a mode's `no_default` is a promise the project makes to somebody, so Perry - may not invent one, and a track that never declared it cannot run the - triage step that reads it. The note stated the rule and left it to be - obeyed by hand — so a queue track with no `SLA` linted clean, and - `modes/queue.md`'s breach step, age sort and triage question then all - measured against a clock that did not exist. - - Everything here is driven off `work_modes.modes.<mode>.no_default`. A - linter carrying its own list of which modes need an `SLA` is two answers to - one question, and the tests below would not notice the day they diverged. - """ - - FULL = ("\n## Tracks\n\n" - "| Track | Mode | Spine | Stages | WIP | SLA | Cycle | Default rung |\n" - "|---|---|---|---|---|---|---|---|\n") - - def _project(self, tracks_block: str, extra: str = "") -> Path: - """A temp project that OUTLIVES the call — `perry-conform` runs on it - too, and a declaration is a second command against the same tree.""" - td = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, td, True) - root = Path(td) - (root / ".perry").mkdir() - (root / ".perry" / "config.md").write_text( - "# Perry configuration\n\n" - "- Document language: English\n" - "- Repo layout: single\n" - "- State root: .\n" - f"{extra}{tracks_block}" - ) - return root - - def findings(self, tracks_block: str) -> list[dict]: - root = self._project(tracks_block) - out = json.loads(subprocess.run( - ["python3", str(LINT), "--root", str(root), "--json"], - capture_output=True, text=True).stdout) - return [f for f in out["findings"] if f["rule"] == "no-default"] - - # ── the fact itself ─────────────────────────────────────────────────── - - def test_a_queue_track_with_no_sla_is_reported(self): - got = self.findings( - self.FULL + "| ops | queue | standing | | | | monthly | V2 |\n") - self.assertEqual(len(got), 1, got) - self.assertIn("SLA", got[0]["message"]) - self.assertIn("ops", got[0]["message"], - "the finding must name WHICH track — a register with " - "four rows is otherwise a scavenger hunt") - self.assertIn(".perry/config.md", got[0]["message"], - "a finding that does not name the cell to fill is a wall") - - def test_declaring_the_sla_clears_it(self): - self.assertEqual( - self.findings( - self.FULL - + "| ops | queue | standing | | | 5d | monthly | V2 |\n"), - []) - - def test_an_answer_that_declines_a_clock_is_still_a_declaration(self): - """`modes/queue.md`: a user who genuinely has no SLA writes that down. - The check is 'has anybody said', not 'is there a number' — Perry cannot - grade the sincerity of `no SLA — best effort` and must not try.""" - self.assertEqual( - self.findings( - self.FULL - + "| ops | queue | s | | | no SLA — best effort | monthly | V2 |\n"), - []) - - def test_the_mode_that_has_no_such_promise_is_untouched(self): - """The distinction `defaults_note` is about. `project` declares - `no_default: []` — an empty `SLA` there means the mode has no such - control, not that nobody has said yet.""" - self.assertEqual( - self.findings( - self.FULL + "| core | project | phase/ | — | — | — | — | V3 |\n"), - []) - - # ── the category, not the one spelling that bit ─────────────────────── - - def test_an_em_dash_is_undeclared_and_not_an_answer(self): - """`SKILL.md`'s own example track row writes empty cells as `—`. A - check that only tested for the empty string would pass over every - register Perry itself taught people to write.""" - for blank in ("—", "-", "n/a", "TBD", ""): - with self.subTest(cell=blank): - got = self.findings( - self.FULL - + f"| ops | queue | s | — | — | {blank} | monthly | V2 |\n") - self.assertEqual(len(got), 1, f"{blank!r} read as a declaration") - - def test_every_no_default_column_is_covered_not_just_sla(self): - """`SLA` is the one that bit; `Cycle` is on the same list for the same - reason. A guard narrowed to the instance would pass this file and still - let a queue track ship with no review period.""" - got = self.findings( - self.FULL + "| ops | queue | standing | | | 5d | | V2 |\n") - self.assertEqual([f["rule"] for f in got], ["no-default"]) - self.assertIn("Cycle", got[0]["message"]) - - def test_every_mode_that_declares_a_no_default_column_is_checked(self): - """Driven from the schema in both directions: the modes come from - `work_modes`, and each one that declares a `no_default` column must - actually produce a finding. `pipeline` is here because the schema puts - it here (V4 finding S7 — dwell time is a promise too), not because a - hand-written list remembered it.""" - modes = {n: m.get("no_default") or [] - for n, m in SCHEMA["work_modes"]["modes"].items()} - self.assertTrue(any(v for v in modes.values()), - "no mode declares a no_default column — this test " - "would pass over a schema with the list deleted") - for mode, cols in sorted(modes.items()): - with self.subTest(mode=mode): - got = self.findings( - self.FULL + f"| t | {mode} | s | | | | | V3 |\n") - self.assertEqual( - len(got), len(cols), - f"{mode} declares no_default {cols} and lint reported " - f"{[f['message'] for f in got]} — a mode with an empty " - f"list must produce nothing at all") - self.assertEqual( - sorted(c for f in got for c in cols if f"`{c}`" in f["message"]), - sorted(cols), - f"{mode}: lint reported {[f['message'] for f in got]}") - - def test_the_linter_does_not_carry_its_own_copy_of_the_list(self): - """The mechanism, asserted directly. Two lists of which modes need an - `SLA` is the two-implementations-of-one-rule defect ADR-004 is about, - and it fails silently: the schema gains a column and the linter keeps - checking yesterday's.""" - src = (PERRY_HOME / "bin" / "perry-lint").read_text() - code = "\n".join(ln for ln in src.split("\n") - if not ln.lstrip().startswith(("#", "#:"))) - self.assertIn("no_default", code, - "the linter no longer reads the schema's list") - for col in ("SLA", "Cycle"): - self.assertNotIn( - col, code, - f"bin/perry-lint names the column {col!r} in code — the list " - f"of which modes need it lives in schema/state-schema.json " - f"§ work_modes, and a second copy here is what drifts") - - # ── the severity, and why it is not an error ────────────────────────── - - def test_it_is_a_warning_so_an_existing_register_is_not_bricked(self): - got = self.findings( - self.FULL + "| ops | queue | standing | | | | | V2 |\n") - self.assertTrue(got) - self.assertEqual({f["severity"] for f in got}, {"warn"}) - - def test_a_track_register_missing_an_sla_can_still_be_declared_conformant(self): - """The reason the severity matters, stated as the consequence rather - than as a preference. Under ADR-004 a file carrying ERRORS cannot be - declared conformant, and an undeclared `.perry/config.md` is one the - writers refuse under `enforce`. As an error, one blank cell would take - the whole track register read-only.""" - root = self._project( - self.FULL + "| ops | queue | standing | | | | | V2 |\n") - r = subprocess.run( - ["python3", str(PERRY_HOME / "bin" / "perry-conform"), - "declare", ".perry/config.md", "--root", str(root), "--json"], - capture_output=True, text=True) - self.assertEqual(r.returncode, 0, - f"the missing SLA blocked the declaration: " - f"{r.stdout}{r.stderr}") - - def test_the_creation_time_rule_is_written_where_a_track_is_created(self): - """The lint finding is the late notice; the question is forced at - creation. `modes/queue.md` is what an agent proposing a track register - reads, and the rule has to be legible there or it does not exist.""" - text = (PERRY_HOME / "modes" / "queue.md").read_text() - self.assertIn("Declaring a queue track", text) - head = text.split("Declaring a queue track", 1)[1].split("\n## ", 1)[0] - self.assertIn("AskUserQuestion", head, - "the rule must name the mechanism that asks") - for claim in ("never default", "no_default"): - self.assertIn(claim.split()[0], head) class TestTrackParsing(unittest.TestCase): @@ -513,14 +333,14 @@ def setUp(self): shutil.copytree(PERRY_HOME / "tests" / "fixtures" / "sample-project", self.root) cfg = self.root / ".perry" / "config.md" - cfg.write_text(gate_off(cfg.read_text() + _TABLE_TRACKS)) + cfg.write_text(cfg.read_text() + _TABLE_TRACKS) # The store is hand-built, so the opt-out has to be said in it too: # `gate_mode` reads `.perry/config.jsonl` first (TASK-233) and a store # that carries no `conformance_gate` record is a project declaring no # gate. `gate_off` above puts the same line in the markdown, which is # what a derived store would have carried. (self.root / ".perry" / "config.jsonl").write_text( - _STORE_TRACKS + gate_off_record()) + _STORE_TRACKS + "") def declared(self, tracks) -> list[tuple[str, str]]: return [(t["track"], t["mode"]) for t in tracks] @@ -1324,7 +1144,7 @@ def project(self, rung: str, title: str, hook: str = ""): (root / ".perry").mkdir() (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") if hook: (root / ".perry" / "hook.md").write_text( f"# hook\n\n## High-stakes operations\n\n- {hook}\n") 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. 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