diff --git a/.perry/conformance.md b/.perry/conformance.md index 4cccd4b..5d23ebb 100644 --- a/.perry/conformance.md +++ b/.perry/conformance.md @@ -12,16 +12,19 @@ | File | Shape version | Declared | Route | |---|---|---|---| -| .perry/config.md | 2 | 2026-08-17 | declare | -| .perry/hook.md | 2 | 2026-08-17 | declare | -| BOARD.md | 2 | 2026-08-17 | declare | -| DECISIONS.md | 2 | 2026-08-17 | declare | -| OKR.md | 2 | 2026-08-17 | declare | -| design/DESIGN-001-resumable-pipelines.md | 2 | 2026-08-17 | declare | -| design/DESIGN-002-namespace-collision.md | 2 | 2026-08-17 | declare | -| design/DESIGN-003-work-modes.md | 2 | 2026-08-17 | declare | -| design/DESIGN-004-deterministic-writes.md | 2 | 2026-08-17 | declare | -| design/DESIGN-005-state-and-contracts.md | 2 | 2026-08-17 | declare | -| design/DESIGN-006-roles-and-knowledge.md | 2 | 2026-08-17 | declare | -| phase/001-linkage.md | 2 | 2026-08-17 | declare | -| phase/001-work-modes-live.md | 2 | 2026-08-17 | declare | +| .perry/config.md | 2 | 2026-08-20 | declare | +| .perry/hook.md | 2 | 2026-08-20 | declare | +| BOARD.md | 2 | 2026-08-20 | declare | +| DECISIONS.md | 2 | 2026-08-20 | declare | +| OKR.md | 2 | 2026-08-20 | declare | +| design/DESIGN-001-resumable-pipelines.md | 2 | 2026-08-20 | declare | +| design/DESIGN-002-namespace-collision.md | 2 | 2026-08-20 | declare | +| design/DESIGN-003-work-modes.md | 2 | 2026-08-20 | declare | +| design/DESIGN-004-deterministic-writes.md | 2 | 2026-08-20 | declare | +| design/DESIGN-005-state-and-contracts.md | 2 | 2026-08-20 | declare | +| design/DESIGN-006-roles-and-knowledge.md | 2 | 2026-08-20 | declare | +| design/DESIGN-007-the-entity-model.md | 2 | 2026-08-20 | declare | +| knowledge/toolchain/pycache-staleness.md | 2 | 2026-08-20 | declare | +| phase/001-linkage.md | 2 | 2026-08-20 | declare | +| phase/001-work-modes-live.md | 2 | 2026-08-20 | declare | +| phase/002-fields-are-typed.md | 2 | 2026-08-20 | declare | diff --git a/.perry/events.jsonl b/.perry/events.jsonl index dfea880..f0c6fc6 100644 --- a/.perry/events.jsonl +++ b/.perry/events.jsonl @@ -444,3 +444,9 @@ {"ts": "2026-08-20T15:08:17", "event": "evidence", "id": "TASK-107", "title": "the dispatch safety gate matches its fragments as bare substrings, so ordinary English trips it", "track": "main", "actor": "agent", "from": "—", "to": "evidence/2026-08/TASK-107-spec.md"} {"ts": "2026-08-20T15:13:45", "event": "add", "id": "TASK-108", "title": "LOAD-03 counts prose about a decision, so documenting an open question makes the count go up", "track": "main", "mode": "project", "priority": "P1", "actor": "agent", "summary": "", "depends_on": [], "from": null, "to": "not_started"} {"ts": "2026-08-20T15:16:40", "event": "start", "id": "TASK-108", "title": "LOAD-03 counts prose about a decision, so documenting an open question makes the count go up", "track": "main", "actor": "agent", "from": "not_started", "to": "in_progress"} +{"ts": "2026-08-20T15:25:23", "event": "next", "id": "TASK-047", "title": "Flip the conformance gate to enforce", "track": "main", "actor": "agent", "from": "user decides the subjective line: enforce by default while both 2026-08-18 blockers are still live, or revert to advisory (one line). Then merge PR #8", "to": "PR #8 merged and the gate enforces on this branch; awaiting the user's V5 sign-off text (name, date, what was checked)"} +{"ts": "2026-08-20T15:26:29", "event": "start", "id": "TASK-085", "title": "Decision status has no word for a proposal, and lives in three places", "track": "main", "actor": "agent", "from": "not_started", "to": "in_progress"} +{"ts": "2026-08-20T15:41:45", "event": "done", "id": "TASK-047", "title": "Flip the conformance gate to enforce", "track": "main", "owner": "Coding Agent", "role": "", "actor": "agent", "from": "review", "to": "done", "evidence": "evidence/2026-08/TASK-047-dispatch-2026-08-20-1416.md", "rung": "V5"} +{"ts": "2026-08-20T15:47:04", "event": "evidence", "id": "TASK-108", "title": "LOAD-03 counts prose about a decision, so documenting an open question makes the count go up", "track": "main", "actor": "agent", "from": "—", "to": "evidence/2026-08/TASK-108-dispatch-2026-08-20-1547.md"} +{"ts": "2026-08-20T15:47:05", "event": "status", "id": "TASK-108", "title": "LOAD-03 counts prose about a decision, so documenting an open question makes the count go up", "track": "main", "actor": "agent", "depends_on": [], "from": "in_progress", "to": "review", "reason": ""} +{"ts": "2026-08-20T15:49:41", "event": "add", "id": "TASK-109", "title": "a V5 sign-off is composed by selection from measured facts, not authored from memory", "track": "main", "mode": "project", "priority": "P1", "actor": "agent", "summary": "", "depends_on": [], "from": null, "to": "not_started"} diff --git a/bin/README.md b/bin/README.md index 9e544bc..c972ec9 100644 --- a/bin/README.md +++ b/bin/README.md @@ -236,11 +236,46 @@ 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 · a brand-new project asks for one declaration before its first write** | A project 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. | 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. | +| **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. | 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. +**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 +``` + +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 diff --git a/bin/perry-task b/bin/perry-task index ed539c3..920e741 100755 --- a/bin/perry-task +++ b/bin/perry-task @@ -95,6 +95,18 @@ Usage: id and every cell. --group "" on a board that files work under its own headings instead of P0/P1/P2. perry-task done --evidence [--rung V1..V6] + at V5, the sign-off is SELECTED rather than composed: + [--measured ""] repeatable + [--restated ""] repeatable + [--checked 1,3 | all | none] what you checked too + [--not-looked-at 2] what you did not examine + [--also ""] + [--signer ""] [--signed-on YYYY-MM-DD] + Name and date are filled in; unselected items are recorded + as `accepted on report`, never dropped. + perry-task signoff-offer --measured "…" [--restated "…"] [--json] + read-only. The one selection prompt a V5 close offers, + numbered the way `done --checked` reads it. perry-task list [--all] [--track T] [--json] perry-task events [--limit N] [--since ] [--json] the event log's TAIL, in log order, with a cursor. @@ -126,6 +138,7 @@ import json import hashlib import os import re +import subprocess import sys from datetime import date, datetime from pathlib import Path @@ -1762,7 +1775,7 @@ def replace_canonical_pair(state_root: Path, def commit(project_root: Path, state_root: Path, board: Board, journal_line: str, event: dict, dry_run: bool, - definition: str = "") -> dict: + definition: str = "", signoff_block: str = "") -> dict: """Write the store and the journal together, render the board, log the event. **The three-way write is `store + journal + event` now, not `board + @@ -1929,6 +1942,13 @@ def commit(project_root: Path, state_root: Path, board: Board, # BEFORE the tool existed and 0 times after, so every tool-created task # was one title and nothing else. jtext = append_block(jtext, "New tasks added", definition) + if signoff_block: + # The signature, in the canonical half of the write rather than beside + # it. A V5 close whose journal line says "signed off" and whose record + # of WHAT was signed lives only in the disposable event log would be + # the same defect in a new place: the rung's entire content is what was + # checked, so it belongs where the transaction guarantees reach. + jtext = append_block(jtext, "V5 sign-off", signoff_block) replace_canonical_pair( state_root, @@ -2314,6 +2334,383 @@ def cmd_start(args, ctx) -> dict: return {"id": tid, "row": line, **plan} +# ── the V5 sign-off: selected from measured facts, never authored from memory ── +# +# V5 is "human sign-off — name, date, and what they checked". Until TASK-109 the +# tool took none of that: `done --rung V5` wrote a rung and the *signature* was +# a paragraph the user composed by hand into an evidence file. Measured on this +# project: 3 of 80 closed rows carry V5, so the cost is not frequency — it is +# what the paragraph asks for when it does fire. +# +# TASK-047 is the case that named both defects. Perry ran three checks, printed +# their output and showed the user; the user then wrote, from memory, a sentence +# describing those same three checks (`perry/evidence/2026-08/ +# TASK-047-dispatch-2026-08-20-1416.md § V5 sign-off`). So: +# +# 1. the user re-derived by hand a record the tool already held, and +# 2. free text cannot distinguish *I re-ran this* from *Perry ran this and I +# read the output*. The second collapses into the first the more Perry +# does, which is the rung's failure mode wearing its own label. +# +# **The line that keeps this from becoming a rubber stamp.** If Perry drafts the +# signature and the user presses return, Perry has certified its own work — the +# exact failure V5 exists to prevent. So: +# +# Perry may draft only facts it MEASURED. It may never draft a claim about +# what the USER did. +# +# `claims[] has zero lines in the diff` is drafted — Perry ran the diff. +# `the user reviewed the diff` is refused — Perry cannot know that. +# +# `check_no_user_claim` enforces that mechanically at the option builder, which +# is the only place a drafted string is minted. The user's contribution is which +# of Perry's facts they personally re-checked, plus `--also` for anything Perry +# does not know about. + +#: The two provenance labels an offered item can carry, stored **verbatim** in +#: the record because the label is the product. `checked` against a +#: Perry-verified fact means *I checked this too*; against a restated one it +#: means *I checked a claim Perry only passed along*. Collapse the labels and +#: those two become the same sentence, which is today's defect exactly. +PROVENANCE_VERIFIED = "Perry verified" +PROVENANCE_RESTATED = "restated — Perry did not verify this" + +#: The three dispositions an offered item ends in. +#: +#: **Why three and not two.** The obvious design has `checked` and one bucket +#: for everything else. It loses the distinction the rung exists for. Perry's +#: own corpus already writes all three by hand: TASK-034's signature carries a +#: `**Not checked, and recorded because V5's whole value is saying so:**` +#: section beside what it did check, and TASK-047's carries "fixture opt-out 的 +#: 理由已读并接受" (read, then accepted) beside two costs taken on the strength of +#: Perry's printed output. A format that cannot hold what the three existing +#: signatures already say is a regression against the corpus it must stay +#: compatible with. +#: +#: The second reason is the rule above. Defaulting every unselected item to +#: `accepted on report` is already the outer edge of what Perry may assert: it +#: is a statement about the SCOPE OF THE SIGNATURE — signing the close accepts +#: these on the strength of the report — and not a claim that the user +#: performed the act of reading. `not looked at` is therefore never a default. +#: It is only ever reached by the user naming the item, which is what keeps it +#: a user statement rather than a Perry one. +SIGNED_CHECKED = "checked" +SIGNED_ON_REPORT = "accepted on report" +SIGNED_UNSEEN = "not looked at" +SIGNED_DISPOSITIONS = (SIGNED_CHECKED, SIGNED_ON_REPORT, SIGNED_UNSEEN) + +#: A subject that makes a sentence a claim about a person rather than about the +#: world. `(?![\w-])` after the match is the escape hatch that keeps +#: "the user-facing message" — a perfectly good measured fact — from tripping +#: the guard, because the hyphen is part of the following token. +#: +#: CJK terms sit OUTSIDE the boundary assertions on purpose: `\w` matches CJK, +#: so `用户已阅` has no boundary after `用户` and the guarded form silently never +#: fires. A guard that reports clean on the language half of this project's own +#: signatures would be worse than no guard. +_USER_SUBJECT = re.compile( + r"(? str: + """Refuse a drafted option that asserts what a person did. Deliverable 6. + + This is a *mechanical* guard rather than a review note on purpose: the + difference between a fact Perry measured and a claim about the user is the + single thing standing between this feature and a rubber stamp, and a rule + enforced by remembering it is a rule that holds until the first hurried + close. + + A false positive is cheap and is meant to be: the refusal names the phrase + and the fix is to restate the option as the observation it came from. + """ + hit = _USER_SUBJECT.search(text) + if hit: + raise Refused( + f"{flag} {text!r} names {hit.group(0)!r}. A sign-off option may " + f"only state a fact Perry measured — never a claim about what a " + f"person did, which Perry cannot know and must not draft on their " + f"behalf. State the observation instead: not \"the user reviewed " + f"the diff\" but \"the diff carries zero changed lines\".") + hit = _ACT_OF_JUDGEMENT.search(text) + if hit: + raise Refused( + f"{flag} {text!r} says {hit.group(0)!r}, which names an act of " + f"judgement rather than an observation. Whether it was reviewed, " + f"approved or accepted is what the signer decides by selecting " + f"this item; drafting it here would have Perry certify its own " + f"work. Restate it as what was measured.") + return text + + +def signoff_options(measured, restated) -> list[dict]: + """The one place a sign-off option is minted, numbered from 1. + + Order is `--measured` first, then `--restated`, and the numbering handed to + the user by `signoff-offer` is the numbering `done --checked` reads. They + are the same function so they cannot drift: a prompt whose option 3 is the + tool's option 4 records the wrong item under a signature, which is the one + failure this whole path exists to make impossible. + """ + options: list[dict] = [] + for raw, provenance, flag in ( + [(t, PROVENANCE_VERIFIED, "--measured") for t in (measured or [])] + + [(t, PROVENANCE_RESTATED, "--restated") for t in (restated or [])]): + text = " ".join((raw or "").split()) + if not text: + raise Refused( + f"{flag} was given an empty item. An unnamed thing cannot be " + f"checked, accepted or declined; drop the flag instead.") + check_no_user_claim(text, flag) + options.append({"n": len(options) + 1, "text": text, + "provenance": provenance}) + return options + + +def parse_item_selection(values, count: int, flag: str) -> set[int]: + """`--checked 1,3`, `--checked all`, `--checked none`, or repeated flags. + + All four spellings exist because the host that has no selection UI gets the + numbered free-text fallback (`reference/host-capabilities.md § Prompt + rendering`), and what comes back from a person typing into that is + `1,3` or `all` or `none`. Rendering differs per host; the record does not. + """ + picked: set[int] = set() + for value in values or []: + for token in str(value or "").replace(",", ",").split(","): + token = token.strip() + if not token: + continue + low = token.lower() + if low == "none": + continue + if low == "all": + picked |= set(range(1, count + 1)) + continue + try: + n = int(token) + except ValueError: + raise Refused( + f"{flag} takes the numbers shown by `perry-task " + f"signoff-offer`, `all` or `none`; got {token!r}") from None + if not 1 <= n <= count: + raise Refused( + f"{flag} {n} is not one of the {count} offered item(s). " + f"Selecting an item nobody was offered would record a " + f"signature against something the record does not hold.") + picked.add(n) + return picked + + +def resolve_signer(project_root: Path, explicit: str | None) -> str: + """The name, filled in rather than typed. Deliverable 3. + + Name and date are the two fields a human should never be retyping: they are + the parts of a signature the machine knows for certain and the human can + only get wrong. `git config user.name` is the same source the three existing + signatures in this repository were written from. + + Refused rather than defaulted to a placeholder if nothing resolves — an + anonymous signature is not a weaker signature, it is not one. + """ + if explicit and explicit.strip(): + return explicit.strip() + from_env = (os.environ.get("PERRY_SIGNER") or "").strip() + if from_env: + return from_env + try: + out = subprocess.run( + ["git", "-C", str(project_root), "config", "user.name"], + capture_output=True, text=True, timeout=5) + if out.returncode == 0 and out.stdout.strip(): + return out.stdout.strip() + except (OSError, subprocess.SubprocessError): + pass + try: + import getpass + who = (getpass.getuser() or "").strip() + if who: + return who + except Exception: # noqa: BLE001 + pass + raise Refused( + "no name to sign with. Perry fills the signer in rather than asking a " + "human to type it, and found nothing: set `git config user.name`, or " + "pass --signer \"\".") + + +def build_signoff(options, checked, unseen, also, signer, signed_on) -> dict: + """The record. Designed before the prompt, and identical on every host. + + Every offered item lands in the record with its provenance and its + disposition. **Nothing is dropped**: an item the user did not select is + written as `accepted on report`, which is strictly more than the free-text + paragraph could say, since that form could not distinguish the two at all. + """ + both = checked & unseen + if both: + raise Refused( + f"item(s) {', '.join(str(n) for n in sorted(both))} were named " + f"both `{SIGNED_CHECKED}` and `{SIGNED_UNSEEN}`. One item, one " + f"disposition — a record that says both says neither.") + items = [{ + "n": option["n"], + "text": option["text"], + "provenance": option["provenance"], + "disposition": (SIGNED_CHECKED if option["n"] in checked + else SIGNED_UNSEEN if option["n"] in unseen + else SIGNED_ON_REPORT), + } for option in options] + also = (also or "").strip() + if not any(i["disposition"] == SIGNED_CHECKED for i in items) and not also: + # Deliverable 7. An empty signature is the failure the rung exists to + # prevent, and the way it gets written is by pressing return at the + # prompt. Refusing here is what makes that keystroke cost something. + raise Refused( + "a V5 sign-off with nothing checked and no free text is not a " + "sign-off. Either name what you checked yourself (--checked), or " + "write it in --also. If you checked nothing, the rung is not V5 — " + "close at the rung the work actually reached.") + return { + "signed_by": signer, + "signed_on": signed_on, + "items": items, + "also_checked": also, + "counts": {label: sum(1 for i in items if i["disposition"] == label) + for label in SIGNED_DISPOSITIONS}, + } + + +def render_signoff(tid: str, record: dict) -> str: + """The record as the block that lands in the journal. + + Headings are the disposition labels **verbatim**, so a reader of the + markdown and a reader of the event see the same three words. The three + signatures already in `perry/evidence/2026-08/` keep their own shape and + are not touched: this adds a path, it does not rewrite history. + """ + out = [f"**{tid} — V5 sign-off. {record['signed_by']}, " + f"{record['signed_on']}.**", ""] + for label in SIGNED_DISPOSITIONS: + group = [i for i in record["items"] if i["disposition"] == label] + if not group: + continue + out += [f"**{label}**", ""] + out += [f"- {i['text']} *({i['provenance']})*" for i in group] + out += [""] + if record["also_checked"]: + out += ["**checked, and not among what Perry offered**", "", + record["also_checked"], ""] + return "\n".join(out).rstrip() + "\n" + + +def signoff_from_args(args, ctx, rung: str) -> tuple[dict | None, str]: + """Assemble the signature `done` was given, or `(None, "")` for no attempt. + + **When an empty signature is refused, stated exactly.** The refusal fires + whenever the sign-off path was ENGAGED — any of the flags below was passed + — and produced nothing. A bare `done --rung V5` with no sign-off flags at + all is left alone and writes no signature rather than a blank one: rungs + are advisory this release by DESIGN-003 § 4 decision 4, and hardening the + rung itself is out of this row's scope. What is closed here is the hole + deliverable 7 names — a signature that reads as one and says nothing. + """ + engaged = any([args.measured, args.restated, args.checked, + args.not_looked_at, args.also, args.signer, args.signed_on]) + if not engaged: + return None, "" + if rung != "V5": + raise Refused( + f"a sign-off belongs to V5 and this close is {rung}. V5 is the " + f"rung whose whole content is a name, a date and what was checked " + f"(`schema/state-schema.json § verification`); attaching one to " + f"{rung} would record a human gate that was never asked for.") + options = signoff_options(args.measured, args.restated) + checked = parse_item_selection(args.checked, len(options), "--checked") + unseen = parse_item_selection(args.not_looked_at, len(options), + "--not-looked-at") + on = (args.signed_on or "").strip() or f"{date.today():%Y-%m-%d}" + try: + date.fromisoformat(on) + except ValueError: + raise Refused(f"--signed-on takes YYYY-MM-DD; got {on!r}") from None + record = build_signoff( + options, checked, unseen, args.also, + resolve_signer(ctx["project_root"], args.signer), on) + return record, render_signoff(args.id, record) + + +def cmd_signoff_offer(args, ctx) -> dict: + """Read-only. Build the one selection prompt a V5 close offers. + + Separate from `done` because the numbering the human sees has to be the + numbering `done --checked` reads, and the only way to guarantee that is for + one function to mint both. It writes nothing: an offer is a question. + + The payload carries the host-native shape (`options`, `multi_select`) and + the rendered numbered fallback together, because Perry runs on three hosts + and only one of them has a selection UI. `SKILL.md § User-prompt + convention` picks `AskUserQuestion` on Claude Code and `question` on + OpenCode; Codex prints `prompt` and reads free text back. **The record is + identical whichever produced it** — the host chooses the rendering, never + the content. + """ + tid = args.id + if not tid: + raise Refused( + "perry-task signoff-offer --measured \"…\" " + "[--restated \"…\"] — the id is the prompt's header.") + if tid not in ctx["tasks_by_id"]: + raise Refused(f"{tid} is not a task in this project's record, so there " + f"is nothing to sign off.") + options = signoff_options(args.measured, args.restated) + if not options: + raise Refused( + "nothing to offer. Pass what Perry measured during this task as " + "--measured (the objective-verification commands it ran, the scope " + "cross-check) and anything it is only restating as --restated. An " + "offer built from nothing is a prompt that teaches the reader to " + "press return.") + header = f"{tid} — V5 sign-off" + question = "Which of these did you check yourself?" + lines = [f"[{header}]", f" {question}"] + lines += [f" {o['n']}) {o['text']} [{o['provenance']}]" for o in options] + lines += [ + f"Reply with the numbers you checked yourself (e.g. `1,3`), `all`, or " + f"`none`.", + f"Anything you leave out is recorded as `{SIGNED_ON_REPORT}`; say " + f"`unseen: ` for an item", + f"you did not look at. Then add anything you checked that is not " + f"listed here.", + ] + return { + "id": tid, + "rung": "V5", + "header": header, + "question": question, + "multi_select": True, + "options": options, + "dispositions": list(SIGNED_DISPOSITIONS), + "default_disposition": SIGNED_ON_REPORT, + "prompt": "\n".join(lines), + "free_text": { + "flag": "--also", + "invitation": "Anything you checked that Perry did not offer?", + }, + } + + def cmd_done(args, ctx) -> dict: tid = args.id if not args.evidence: @@ -2328,9 +2725,18 @@ def cmd_done(args, ctx) -> dict: track = track_of(ctx["config"], values.get("track", "main")) rung = (track.get("default_rung") or ctx["schema"]["work_modes"]["modes"][track["mode"]]["default_rung"]) + signoff, signoff_block = signoff_from_args(args, ctx, rung) line = ctx["board"].remove_row(idx) jline = (f"- [{tid}] {prev or '—'} → done · closed · " f"evidence: `{args.evidence}` · verification: {rung}") + if signoff: + counts = signoff["counts"] + jline += (f" · signed off: {signoff['signed_by']} " + f"{signoff['signed_on']} — " + + ", ".join(f"{counts[label]} {label}" + for label in SIGNED_DISPOSITIONS + if counts[label]) + + (", plus free text" if signoff["also_checked"] else "")) event = {"ts": datetime.now().isoformat(timespec="seconds"), "event": "done", "id": tid, "title": values.get("title", ""), "track": values.get("track", "main"), @@ -2349,8 +2755,10 @@ def cmd_done(args, ctx) -> dict: "role": values.get("role", ""), "actor": args.actor, "from": prev, "to": "done", "evidence": args.evidence, "rung": rung} + if signoff: + event["signoff"] = signoff plan = commit(ctx["project_root"], ctx["state_root"], ctx["board"], - jline, event, args.dry_run) + jline, event, args.dry_run, signoff_block=signoff_block) # **A row that closed without ever being started is reported, not refused.** # # `DESIGN-004 § 1.3`'s second question is "what is being worked on right @@ -2374,7 +2782,8 @@ def cmd_done(args, ctx) -> dict: f"is what makes `list` answer \"what is being worked on now\".", file=sys.stderr) return {"id": tid, "rung": rung, "removed": line, - "was_started": started, **plan} + "was_started": started, "signoff": signoff, + "signoff_block": signoff_block, **plan} def cmd_stage(args, ctx) -> dict: @@ -4452,7 +4861,7 @@ def perry_conform(): #: `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. -READ_ONLY_COMMANDS = {"list", "events"} +READ_ONLY_COMMANDS = {"list", "events", "signoff-offer"} TASK_ROW_COMMANDS = {"start", "stage", "done", "drop", "status", "depends", "next", "retitle", "summary", "rung", "evidence", "prioritize"} @@ -4496,6 +4905,11 @@ def parse(argv: list[str]) -> Args: a.frequency = a.on = None a.needed = a.blocks = a.answer = a.opened = None a.deliverable = a.verification = a.depends = a.out_of_scope = a.kr = None + # The V5 sign-off. Four of these are LISTS: one offered item is one flag, + # so an item containing a comma is still one item. `--checked` accepts both + # spellings because the host without a selection UI hands back `1,3`. + a.measured, a.restated, a.checked, a.not_looked_at = [], [], [], [] + a.also = a.signer = a.signed_on = None a.group = a.prefix = a.role = None a.limit = a.since = None a.dry_run = a.as_json = a.all = a.clear = False @@ -4514,7 +4928,11 @@ def parse(argv: list[str]) -> Args: "--opened": "opened", "--deliverable": "deliverable", "--verification": "verification", "--depends": "depends", "--out-of-scope": "out_of_scope", "--kr": "kr", - "--group": "group", "--prefix": "prefix", "--role": "role"} + "--group": "group", "--prefix": "prefix", "--role": "role", + "--also": "also", "--signer": "signer", + "--signed-on": "signed_on"} + repeated = {"--measured": "measured", "--restated": "restated", + "--checked": "checked", "--not-looked-at": "not_looked_at"} while i < len(argv): t = argv[i] if t in ("-h", "--help"): @@ -4528,6 +4946,9 @@ def parse(argv: list[str]) -> Args: a.all = True elif t == "--clear": a.clear = True + elif t in repeated: + i += 1 + getattr(a, repeated[t]).append(argv[i] if i < len(argv) else "") elif t in flags: i += 1 setattr(a, flags[t], argv[i] if i < len(argv) else None) @@ -4698,7 +5119,8 @@ COMMANDS = {"add": cmd_add, "start": cmd_start, "stage": cmd_stage, "evidence": cmd_evidence, "prioritize": cmd_prioritize, "intake-sweep": cmd_intake_sweep, "status": cmd_status, "depends": cmd_depends, - "list": cmd_list, "events": cmd_events} + "list": cmd_list, "events": cmd_events, + "signoff-offer": cmd_signoff_offer} def project_lock(state_root: Path, timeout: float = 10.0): @@ -4872,6 +5294,11 @@ def main(argv: list[str]) -> int: if untitled: print(f" ⚠ {len(untitled)} id(s) have no title in the record: " f"{', '.join(untitled)}") + elif args.cmd == "signoff-offer": + # The numbered fallback, printed as-is. A host WITH a selection UI + # reads `--json` and renders `options`; this is what Codex shows and + # what a human reads at a terminal, and both produce the same record. + print(result["prompt"]) else: verb = "would write" if args.dry_run else "wrote" # `commit()` reports a lost event honestly; this line used to say diff --git a/perry/BOARD.md b/perry/BOARD.md index 30c17ce..26ad9b2 100644 --- a/perry/BOARD.md +++ b/perry/BOARD.md @@ -23,7 +23,6 @@ | ID | Title | Owner | Status | Next action | Evidence | Verification | Depends on | |---|---|---|---|---|---|---|---| -| TASK-047 | Flip the conformance gate to enforce | Coding Agent | review | user decides the subjective line: enforce by default while both 2026-08-18 blockers are still live, or revert to advisory (one line). Then merge PR #8 | evidence/2026-08/TASK-047-dispatch-2026-08-20-1416.md | V4 | TASK-044 | | TASK-038 | tasks: the task store becomes canonical, BOARD.md becomes a projection | Coding Agent | blocked | Read evidence/2026-08/TASK-038-v5-signoff-request.md and provide the named V5 approval. | — | V5 | — | | TASK-077 | DESIGN-006 F — a finance-shaped role runs one real task end to end | Coding Agent | not_started | Run the finance-shaped role end to end on a copy of gimegime-pmo, then write the extraction report. | evidence/2026-08/TASK-077-context.md | V5 | TASK-073, TASK-075, TASK-076 | | TASK-079 | Migration writes a file the user marked read-only, via rename | Coding Agent | review | user verifies: merge PR #6, and decide USER-004 (refuse vs report) which this task deliberately left open | evidence/2026-08/TASK-079-dispatch-2026-08-20-1345.md | V4 | — | @@ -34,7 +33,8 @@ | TASK-099 | Sweep bin/, viewer/ and tests/ for document handling that ADR-007 made dead | Coding Agent | not_started | — | — | V4 | TASK-095 | | TASK-102 | Evidence becomes a typed relation: {path, kind, round}, not one prose cell | Coding Agent | not_started | — | — | V4 | TASK-090, TASK-092 | | TASK-107 | the dispatch safety gate matches its fragments as bare substrings, so ordinary English trips it | Coding Agent | review | V5 sign-off: a human names the date and what they checked | evidence/2026-08/TASK-107-spec.md | V5 | — | -| TASK-108 | LOAD-03 counts prose about a decision, so documenting an open question makes the count go up | Coding Agent | in_progress | dispatched 15:16 via claude-subagent; worktree pinned to feat/work-modes; awaiting completion | — | V4 | — | +| TASK-108 | LOAD-03 counts prose about a decision, so documenting an open question makes the count go up | Coding Agent | review | merge PR #10, then swap the literal GATE_OFF constant in tests/test_diagnose.py for the shared import | evidence/2026-08/TASK-108-dispatch-2026-08-20-1547.md | V4 | — | +| TASK-109 | a V5 sign-off is composed by selection from measured facts, not authored from memory | Coding Agent | not_started | — | — | V5 | | ## P2 @@ -45,7 +45,7 @@ | TASK-045 | Retire the runtime tolerance branches, behind the conformance marker | Coding Agent | blocked | blocked on chain 044 → 047 → 045; switching to the head of it | — | V4 | TASK-044, TASK-047 | | TASK-066 | Split perry-task by subcommand group | Coding Agent | not_started | Re-size the split after the markdown reader, row renderer and cell escaping are gone. | evidence/2026-08/TASK-066-context.md | V4 | TASK-065, TASK-038 | | TASK-070 | Perry's own state is 19.5% of the tracked repo and grows unbounded | Coding Agent | not_started | Start with journal/ and evidence/ retention, which do not depend on the store; log rotation follows it. | evidence/2026-08/TASK-070-context.md | V3 | — | -| TASK-085 | Decision status has no word for a proposal, and lives in three places | Coding Agent | not_started | — | — | V2 | | +| TASK-085 | Decision status has no word for a proposal, and lives in three places | Coding Agent | in_progress | dispatched 15:26 via claude-subagent; worktree pinned to feat/work-modes post-PR-#8; awaiting completion | — | V2 | — | | TASK-086 | DESIGN-002 decision 4 says lint warns on a collision; lint does not emit NS-01 | Coding Agent | review | user confirms the strict-flag contract change (the namespace warning is no longer promoted to a failure), then merge PR #9; PR #7 is superseded | evidence/2026-08/TASK-086-dispatch-2026-08-20-1429.md | V2 | — | | TASK-100 | tasks.jsonl is in no claims[] entry, so a namespace collision on it cannot be reported | Coding Agent | not_started | — | — | V3 | | diff --git a/perry/evidence/2026-08/TASK-047-dispatch-2026-08-20-1416.md b/perry/evidence/2026-08/TASK-047-dispatch-2026-08-20-1416.md index bb942d5..ac4852b 100644 --- a/perry/evidence/2026-08/TASK-047-dispatch-2026-08-20-1416.md +++ b/perry/evidence/2026-08/TASK-047-dispatch-2026-08-20-1416.md @@ -125,3 +125,25 @@ rule that was decided in ADR-004, fully implemented, and shipped switched off. `review`. Not `done`: the subjective verification above is unanswered and the branch is unmerged. + +## V5 sign-off + +> **Ran Jiao, 2026-08-20.** 检查了 PR #8 的 diff 确认 claims[] 零改动;实测了 +> enforce / advisory / 已声明三种情形的写入行为与 migrate 豁免;在知悉两个代价 +> (真实板迁移有残留、每个新文件出生即未声明)的前提下接受 enforce 作为默认值, +> 并已对本仓库运行 declare --all。fixture opt-out 的理由已读并接受。 + +Recorded verbatim, in the language it was written in. The rung's whole value is +saying what was actually checked, and a translation is a paraphrase. + +The three write-behaviour measurements and the migrate exemption were run by the +PMO and their output shown before the signature was given; the two costs were +stated with their measured scope, including the correction to cost 2 now carried +in `bin/README.md § The switch-over checklist`. `declare --all` was run by the +user, not by Perry — `perry-conform declare` is the user's command and an agent +running it would be certifying its own work. + +**Gates cleared at close:** architecture review skipped (`Touches architecture: +(none)`, primary's `Touched sections` empty); runbook gate skipped +(`Deployed: no`); rung V5 rather than the row's V4, because the row matched +`.perry/hook.md § High-stakes operations` and consequence beats mode. diff --git a/perry/evidence/2026-08/TASK-047-spec.md b/perry/evidence/2026-08/TASK-047-spec.md index 60c889f..ce77e25 100644 --- a/perry/evidence/2026-08/TASK-047-spec.md +++ b/perry/evidence/2026-08/TASK-047-spec.md @@ -1,6 +1,6 @@ # TASK-047 — Flip the conformance gate to enforce -> Source: `perry/decisions/ADR-004-conformance-marker.md`; unblocked by TASK-044 (migration) landing 2026-08-19 +> Source: `perry/decisions/ADR-004-mandatory-migration.md`; unblocked by TASK-044 (migration) landing 2026-08-19 > Dispatch mode: auto > Executor: claude-subagent (repository-local behaviour change across a writer gate, its schema default and its tests; needs codebase familiarity) > Estimated cycle: medium @@ -80,4 +80,12 @@ - 2026-08-20 — Dispatched via `claude-subagent` in an isolated git worktree, with `PERRY_MAX_DISPATCH_SUBAGENT` raised from 2 to 3 by the user to make the third concurrent slot available. +- 2026-08-20 — Corrected the `Source:` path. It read + `perry/decisions/ADR-004-conformance-marker.md`, which does not exist and + never did; ADR-004 is `ADR-004-mandatory-migration.md`. The id was right and + the decision was the right one — the filename was invented from the subject + matter instead of read off the filesystem. Caught by the user, not by any + check: `ADR-004` resolves as an id, so the dangling-id check passes, and + nothing validates that a `> Source:` path exists. The dispatch prompt happened + to carry the correct path, so the agent read the real file. diff --git a/perry/evidence/2026-08/TASK-085-spec.md b/perry/evidence/2026-08/TASK-085-spec.md new file mode 100644 index 0000000..95cd2ad --- /dev/null +++ b/perry/evidence/2026-08/TASK-085-spec.md @@ -0,0 +1,90 @@ +# TASK-085 — A decision's status has one binding, and a word for a proposal + +> Source: opened 2026-08-19; re-measured 2026-08-20 against the post-PR-#8 tree +> Dispatch mode: auto +> Executor: claude-subagent (repository-local; the enum has three prose copies that must move in the same edit) +> Estimated cycle: small +> Subjective verification: the name of the new value — `proposed` is the obvious candidate, but `prescription_status` already uses it for a different thing, so a reader meeting both may expect them to mean the same +> Touches architecture: (none) +> Deployed: no + +## Schema + +- **Owner**: Coding Agent +- **Priority**: P2 +- **Attribution**: unlinked + +## Deliverable + +1. `schema/state-schema.json § enums` gains a `decision_status` entry. It is the + one binding; `bin/perry-decide` reads it instead of its hardcoded + `STATUSES = ("active", "superseded", "expired", "archived")` at line 79, + which is also consulted at lines 182, 227, 419 and 420. +2. The enum carries a value meaning **drafted, awaiting the user** — a decision + that has been written but not yet adopted. Today there is no such word, so a + proposal is indistinguishable from a decision in force, which is the defect + this row names. +3. The two prose copies move in the same edit, because a value list with three + independent spellings is what made this a bug rather than a typo: + `schema/decide-list-contract.md` (lines 18 and 49) and + `decide/reference/decisions.md` (its status definitions, and the `archived` + guidance around line 227). +4. Reading stays tolerant. `decide-list-contract.md` already promises "or + whatever the file says — see `conformance.off_enum_status`", and an existing + `DECISIONS.md` carrying a value outside the enum must still be read and + reported, not refused. Writing is strict; reading is not. +5. The `perry-decide/list` contract's payload keys do not change. Adding a + possible value to a documented field is not a break; renaming or removing one + would be. + +## Verification — V2 + +1. Assert `bin/perry-decide` has no hardcoded status tuple left — the values it + accepts come from the schema, and a value added to the schema is accepted + with no code edit. +2. Assert the new value round-trips: an ADR written with it is listed, counted + and filtered by `--status ` like any other. +3. Assert an off-enum value in an existing `DECISIONS.md` is still read and + reported through `conformance.off_enum_status`, not refused. +4. Assert the three spellings agree, mechanically — a test that reads the enum + from the schema and asserts both prose files list exactly those values, so + the next divergence fails instead of drifting. +5. `python3 bin/perry-lint`, `python3 tests/parallel`, `bash tests/run`, + `git diff --check`. + +## Files in scope + +- `schema/state-schema.json` — the new `decision_status` enum only +- `bin/perry-decide` — read the enum instead of the tuple +- `schema/decide-list-contract.md`, `decide/reference/decisions.md` — the prose copies +- focused decide tests + +## Out of scope + +- **`schema/state-schema.json § claims[]`.** No path Perry claims in anyone's + project changes. The edit adds one enum and touches nothing else in that file. +- The conformance gate and its default, which PR #8 just settled. +- `DECISIONS.md` itself, and any project's existing decision records. No file is + rewritten to use the new value; it becomes available, not mandatory. +- `design_status`, `task_status` or any other enum. +- Deciding whether existing ADRs should be re-classified. +- Closing without the V2 evidence above. + +## Changes + +- 2026-08-20 — **The gate returned `pass`, and that pass does not constitute + clearance.** `perry-state --escalation-scan` matched `state-schema.json` in + both `Deliverable` and `Files in scope`, then green-lit both because the + `Out of scope` section above names the same fragment. That disclaimer was + written by the same author as the spec, so the mechanism is self-certifying: + any spec can pass this rule by asserting it does not do the thing. + + The clearance that counts came from the user in chat, having been told the row + edits `schema/state-schema.json`. Bound: **one new `decision_status` enum, no + entry in `claims[]`, no change to which paths Perry claims.** + + Recorded rather than left implicit because the same self-certifying pass + happened on `TASK-047-spec.md` and was refused as authority there too. Whether + an `Out of scope` line written by the spec's own author should green-light a + high-stakes rule is a real question about the gate, and neither override + settles it. diff --git a/perry/evidence/2026-08/TASK-108-dispatch-2026-08-20-1547.md b/perry/evidence/2026-08/TASK-108-dispatch-2026-08-20-1547.md new file mode 100644 index 0000000..63ef4e7 --- /dev/null +++ b/perry/evidence/2026-08/TASK-108-dispatch-2026-08-20-1547.md @@ -0,0 +1,80 @@ +# TASK-108 — dispatch record + +> Date: 2026-08-20 · Executor: claude-subagent · Worktree pinned to `feat/work-modes` (4b64e1d) +> Branch: `coding/task-108-open-decision-count` · PR: https://github.com/ranjiao/Perry/pull/10 +> Cycle time: ~22 min (agent-reported) + +## Objective verification — run by the PMO + +| Spec item | Result | +|---|---| +| Deliverable 3 — the number equals the queue | `open_decisions = 2`, `asks.open = 2` ✅ | +| Before/after on this repository | **7 → 2**; `LOAD-03` no longer fires ✅ | +| Information preserved, not discarded | new `decision_mentions = 5` ✅ | +| Scope | `bin/perry-diagnose`, `tests/test_diagnose.py` — 2 files, both in scope ✅ | +| Suite | 59 modules · 1718 tests · **2 red** | + +**Baseline confirmed on a clean detached worktree at 4b64e1d: 3 red.** The change +takes it to 2 — it fixed `test_diagnose` and introduced nothing. + +**The PMO's stated baseline was wrong, and the agent caught it.** The dispatch +prompt said "1 module red". That number was measured in the main checkout, where +uncommitted edits to `SKILL.md` and `tests/test_board_render.py` mask +`test_router_budget` and `test_board_render`. A worktree does not carry +uncommitted state, so the agent's tree had 3. This is the second time in one +session a baseline measured in the main checkout was handed to an agent working +in a worktree; the first was an over-broad `LOAD-03` warning in TASK-086's +re-dispatch prompt, which that agent also correctly rejected. + +## The boundary the agent decided, and its reasoning + +The spec left open whether prose raising an **unrecorded** question should count. +Its choice: **report it, never count it.** The argument is identity — a decision +is countable once only if it has one. `USER-004` has an id, so three files +discussing it are three mentions of one decision; prose has none, so nothing +distinguishes "the same question restated" from "a second question". A number +taken over prose can only count sentences, which is why five carve-outs already +existed and a sixth was queued. + +Its concrete evidence for that: two of the five prose hits on this repository are +a **code quotation** of `perry-lint`'s `BLANK_CELL` set inside a fenced review +document. Reaching 2 by adding a fence guard would have been the sixth special +case. + +The question is not discarded — it lands in `user_load.decision_mentions`, where +a reader looks for questions that ought to be recorded and are not. Recording one +as a `USER-` row is what makes it countable, which is the same move Perry asks of +a human. Pinned by a test whose docstring names the other reading and says which +test to change if a future row prefers it. + +## What counts now + +Two registers, not one: a pending `USER-` row in `BOARD.md § User Input Queue`, +and a `## User Decisions` row with an unfilled `Chosen` cell. The second was +forced by an existing fixture and is the register a design doc uses before its +questions reach the queue. Consequence stated in the code rather than left to be +found: a project with neither register reports 0 however many `TBD`s its prose +carries. + +## Safety bound + +Honoured. Read-and-count path only; no change to the diagnose execute stage, no +write outside this repository, no change to `claims` or `state-schema.json`, and +`perry/` untouched — the prose that used to trip the check is exactly as it was, +which was the explicit out-of-scope line. + +## Conformance-gate handling + +The agent's new fixtures write `.perry/config.md`, so it added the opt-out as a +literal `GATE_OFF` constant in `tests/test_diagnose.py`, commented as the one +`tests/gate.py` will own after the rebase. **Rebase action:** replace it with +`from gate import GATE_OFF`. It also notes a pre-existing helper in the same file +that writes an undeclared config without the line. + +## Architecture review + +Skipped — `Touches architecture: (none)`, primary's `Touched sections` empty. + +## Status + +`review`. The branch is unmerged and the rebase action above is outstanding. diff --git a/perry/evidence/2026-08/TASK-109-spec.md b/perry/evidence/2026-08/TASK-109-spec.md new file mode 100644 index 0000000..186ed18 --- /dev/null +++ b/perry/evidence/2026-08/TASK-109-spec.md @@ -0,0 +1,112 @@ +# TASK-109 — A V5 sign-off is selected from measured facts, not authored from memory + +> Source: raised by the user 2026-08-20, immediately after signing TASK-047 — "像是银行签约一样" +> Dispatch mode: auto +> Executor: claude-subagent (repository-local: one subcommand's flags, one lane procedure, and the tests that pin both) +> Estimated cycle: medium +> Subjective verification: whether `accepted on report` is the right second category, or whether a third — "not looked at" — is worth distinguishing from it +> Touches architecture: (none) +> Deployed: no + +## Schema + +- **Owner**: Coding Agent +- **Priority**: P1 +- **Attribution**: unlinked + +## The problem, stated precisely + +`V5 needs a signature` is right and the frequency is not the issue: **3 of 80 +closed rows carry V5, 4%**. The cost is not how often it fires; it is what it +asks for when it does. + +Today the user is asked to compose, from memory, prose describing checks that +**Perry itself ran and printed minutes earlier**. On TASK-047 those were: the +`claims[]` diff, the write behaviour under `enforce` / `advisory` / declared, and +the `perry-migrate` exemption. Perry had every one of them as a command and an +output. It then asked a human to re-derive them as a paragraph. + +There is a second, quieter defect. The resulting sentence cannot express the +distinction that matters most: TASK-047's signature reads *实测了…三种情形*, and +what actually happened is that **Perry ran them and the user read the output**. +That is not the same as the user running them, and the format has no way to say +so. A record that flattens *I checked this* into *I accepted this* is weaker than +it looks, and it gets weaker the more Perry does. + +## Deliverable + +1. Closing a row at V5 offers **one** selection prompt, built from what Perry + measured during this task: the objective-verification commands it ran, the + scope cross-check, and any subjective-verification items the spec declared. +2. **Every offered item is labelled with its provenance** — whether Perry + verified it independently, or is merely restating a claim. Selecting an item + Perry verified means *I checked this too*; it must not read as *I accept + Perry's word*, and the label is what keeps those apart. +3. **Name and date are filled automatically.** They are the two fields a human + should never be typing. +4. **Unselected items are recorded, not dropped** — as `accepted on report`. + This is strictly more information than today's free-text paragraph, which + cannot distinguish the two at all. +5. **Free text stays**, for anything the user checked that Perry does not know + about. It is additive, never a replacement for the selection. +6. **Perry never drafts a claim about what the user did.** It may offer + `claims[] has zero lines in the diff` — a fact it measured. It may not offer + `the user reviewed the diff`. This is the line that keeps the mechanism from + becoming self-certification, and it is the same line `ADR-004` draws with + *adoption proposes; the user declares*. +7. A V5 close with **nothing selected and no free text** is refused, not written + blank. An empty signature is the failure the rung exists to prevent, and it + must not be reachable by pressing return. + +## Verification — V5 + +Rung reasoning, because it is worth arguing with: this row builds the mechanism +that records every future V5 signature, in every project Perry ships to. If it +is subtly wrong — an item labelled verified that was not, a selection silently +widened — then every signature written afterwards is compromised and nothing +downstream can tell. That is a larger blast radius than the V4 default covers. +The recursion is real and intended: this row's own close will be signed through +the mechanism it replaces. + +1. Fixture close offering three Perry-measured items and one restated claim. + Select two. Assert the written record names the two as **checked**, the other + two as **accepted on report**, and that the labels survive verbatim. +2. Fixture where the user adds free text Perry did not offer. Assert it is + recorded alongside, not instead of, the selection. +3. Assert **no drafted option asserts a user action** — a mechanical test over + the option builder, not a review comment. +4. Assert a V5 close with an empty selection and empty free text is **refused**. +5. Assert the three V5 signatures already in this repository still read + correctly; the change adds a path, it does not rewrite history. +6. `python3 tests/parallel`, `bash tests/run`, `python3 bin/perry-lint`, + `git diff --check`. + +## Files in scope + +- `bin/perry-task` — the `done` subcommand's flags and the record it writes +- `work/reference/subcommands.md § close-task` — the procedure that calls it +- focused task-writer and close tests + +## Out of scope + +- The rungs themselves, what they mean, or which rung a row gets. Only how a V5 + signature is composed changes. +- V1–V4 closes. They gain nothing and must be byte-identical. +- Rewriting any signature already recorded. +- `schema/state-schema.json`, `claims`, and which paths Perry claims. +- `bin/perry-decide`, `bin/perry-diagnose`, `bin/perry-lint`, `bin/perry-migrate` + — each is carried by an open unmerged branch or a live dispatch. +- Closing without the V5 evidence above. + +## Changes + +- 2026-08-20 — Note on the escalation scan, which returned `pass` with a match. + The fragment `claims` appears in `Deliverable` because that section **quotes an + example** — `claims[] has zero lines in the diff` is offered as a specimen of a + fact Perry may draft. The row does not touch the claim surface, and + `Out of scope` says so. This is a third shape of scan result, distinct from the + two seen earlier today: TASK-079 and TASK-086 were substring artifacts inside + longer words (fixed by TASK-107); TASK-047 and TASK-085 were true matches + green-lit by the spec author's own disclaimer; this one is a true negative that + matched a quotation. Recorded because the first two shapes each cost a round + trip to the user, and a scanner that reads prose will keep producing new ones. diff --git a/perry/journal/2026-08/2026-08-20.md b/perry/journal/2026-08/2026-08-20.md index f18a582..32b46b6 100644 --- a/perry/journal/2026-08/2026-08-20.md +++ b/perry/journal/2026-08/2026-08-20.md @@ -44,6 +44,12 @@ - [TASK-107] evidence · — → evidence/2026-08/TASK-107-spec.md - [TASK-108] — → not_started · LOAD-03 counts prose about a decision, so documenting an open question makes the count go up · owner: Coding Agent · priority: P1 - [TASK-108] not_started → in_progress · started +- [TASK-047] next action · PR #8 merged and the gate enforces on this branch; awaiting the user's V5 sign-off text (name, date, what was checked) +- [TASK-085] not_started → in_progress · started +- [TASK-047] review → done · closed · evidence: `evidence/2026-08/TASK-047-dispatch-2026-08-20-1416.md` · verification: V5 +- [TASK-108] evidence · — → evidence/2026-08/TASK-108-dispatch-2026-08-20-1547.md +- [TASK-108] in_progress → review +- [TASK-109] — → not_started · a V5 sign-off is composed by selection from measured facts, not authored from memory · owner: Coding Agent · priority: P1 ## Notes @@ -162,3 +168,30 @@ - **Dependencies**: — - **Out of scope**: — - **KR linkage**: unlinked +- The user ran `perry-conform declare --all`; the repository is 16/16 declared + with the gate enforcing. Note the side effect: `--all` re-stamps every row, + so the thirteen files declared on 2026-08-17 now read 2026-08-20 and the + original acceptance dates survive only in git history. Behaviour is unchanged. +- `bin/README.md § The switch-over checklist` corrected. Its cost 2 said "a + brand-new project asks for one declaration before its first write", which is + true and too narrow: every file Perry creates after the last declaration is + born undeclared, in a project declared for weeks as much as in a new one — + which is why `phase/002`, `DESIGN-007` and one knowledge card were undeclared + here while nothing about them was malformed. Measured on a scratch project + rather than asserted: creation is NOT gated (`perry-decide bootstrap` wrote + the file), and the next write to it IS. Both halves matter — a gate that + refused creation would be the wall the checklist exists to avoid. +- That measurement is what a V5 sign-off is for. Reading the documents confirms + they are internally consistent; it does not surface a scope error in one of + them, because the document reads correctly either way. + +### TASK-109 — a V5 sign-off is composed by selection from measured facts, not authored from memory + +- **Owner**: Coding Agent +- **Priority**: P1 +- **Track / mode**: main / project +- **Deliverable**: close-task on a V5 row offers one selection prompt built from what Perry actually measured, each item labelled with whether Perry verified it independently; name and date are filled automatically; every item not selected is recorded as accepted-on-report rather than dropped; and no option Perry drafts is ever a claim about what the user did +- **Verification**: a fixture close where three measured items and one unverified statement are offered, two are selected, and the written record distinguishes checked from accepted-on-report; a fixture where the user adds free text Perry did not offer; an assertion that no drafted option asserts a user action; an assertion that a V5 close with nothing selected and no free text is refused rather than written blank; the existing V5 signatures in this repository still read correctly +- **Dependencies**: — +- **Out of scope**: — +- **KR linkage**: unlinked diff --git a/perry/tasks.jsonl b/perry/tasks.jsonl index daa4d2e..ca27fbb 100644 --- a/perry/tasks.jsonl +++ b/perry/tasks.jsonl @@ -70,15 +70,14 @@ {"id": "TASK-082", "title": "t", "owner": "", "status": "dropped", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "", "evidence": "", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-18T18:48:34", "order": null, "summary": ""} {"id": "TASK-083", "title": "t", "owner": "", "status": "dropped", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "", "evidence": "", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-18T18:48:40", "order": null, "summary": ""} {"id": "TASK-084", "title": "perry-task events — a project-level event feed the front-end can tail", "owner": "", "status": "done", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "schema/events-list-contract.md", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-18T19:52:24", "order": null, "summary": ""} -{"id": "TASK-085", "title": "Decision status has no word for a proposal, and lives in three places", "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-19T09:53:50", "order": 5, "summary": ""} {"id": "TASK-087", "title": "Contract invariance gate: the three list payloads are byte-identical before and after any store change", "owner": "", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "tests/test_contract_invariance.py", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-19T10:27:37", "order": null, "summary": ""} {"id": "TASK-088", "title": "Renderer: BOARD.md is generated from tasks.jsonl, byte-identical to today's file", "owner": "", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-088-renderer.md", "next_action": "", "depends_on": [], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-19T10:27:37", "order": null, "summary": ""} {"id": "TASK-089", "title": "perry-task writes the store, not the board", "owner": "", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-089-v4-review-r4.md", "next_action": "", "depends_on": ["TASK-088"], "commitment": "", "parent": "", "group": "", "role": "", "created": "2026-08-19T10:27:37", "order": null, "summary": ""} -{"id": "TASK-094", "title": "Delete the header rule and the row splitter for the three stores", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-090", "TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:51", "order": 5, "summary": ""} -{"id": "TASK-095", "title": "Remove the parser for the three stores; keep what adoption needs", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-094"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:28:03", "order": 6, "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": 7, "summary": ""} +{"id": "TASK-094", "title": "Delete the header rule and the row splitter for the three stores", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": ["TASK-090", "TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:51", "order": 4, "summary": ""} +{"id": "TASK-095", "title": "Remove the parser for the three stores; keep what adoption needs", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-094"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:28:03", "order": 5, "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": 6, "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": 8, "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": 7, "summary": ""} {"id": "TASK-100", "title": "tasks.jsonl is in no claims[] entry, so a namespace collision on it cannot be reported", "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-19T11:44:02", "order": 7, "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": ""} @@ -86,20 +85,22 @@ {"id": "TASK-096", "title": "Lane procedures call the tool before writing prose", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-096-v4-review-r3.md", "next_action": "Implement the bounded lane-only guard contract in TASK-096-spec.md; keep root/reference/packs and incidents.md deferred to TASK-101, then dispatch fresh mutation-sensitive V4.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:28:03", "order": null, "summary": ""} {"id": "TASK-091", "title": "By when splits into due + by_when_note, and CLOCK_RE is deleted", "owner": "Coding Agent", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-091-v4-review-r3.md", "next_action": "Implement the five bounded defect classes in TASK-091-spec.md, keep migration safety protocol changes in TASK-044, then dispatch fresh mutation-sensitive V4.", "depends_on": [], "commitment": "", "parent": "", "group": "P0 (must finish this period)", "role": "", "created": "2026-08-19T10:27:50", "order": null, "summary": ""} {"id": "TASK-042", "title": "OKR.md § Commitments — the half TASK-021 did not do", "owner": "Coding Agent", "status": "dropped", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Drop this task when TASK-091 passes V4; retain it only if TASK-091 is abandoned", "depends_on": ["TASK-091"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-17T13:12:01", "order": null, "summary": ""} -{"id": "TASK-038", "title": "tasks: the task store becomes canonical, BOARD.md becomes a projection", "owner": "Coding Agent", "status": "blocked", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "—", "next_action": "Read evidence/2026-08/TASK-038-v5-signoff-request.md and provide the named V5 approval.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-17T00:55:19", "order": 1, "summary": ""} +{"id": "TASK-038", "title": "tasks: the task store becomes canonical, BOARD.md becomes a projection", "owner": "Coding Agent", "status": "blocked", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "—", "next_action": "Read evidence/2026-08/TASK-038-v5-signoff-request.md and provide the named V5 approval.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-17T00:55:19", "order": 0, "summary": ""} {"id": "TASK-101", "title": "The procedure guard walks the whole tree, not just the three lanes", "owner": "Coding Agent", "status": "done", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-101-procedure-guard-scope.md", "next_action": "Verify the expanded procedure guard and record V3 evidence.", "depends_on": ["TASK-096"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-19T12:19:20", "order": null, "summary": ""} {"id": "TASK-044", "title": "Migration must be dry-runnable, lossless, recoverable and user-declared", "owner": "Coding Agent", "status": "done", "priority": "P0", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-044-final-v4-review.md", "next_action": "V4 r4 FAIL exposed four bounded repair families. After TASK-091, implement C selection boundary -> A file-image fidelity -> B restore transaction protocol -> D I/O failure boundary, each with mutation-sensitive checkpoint tests, then dispatch fresh V4.", "depends_on": [], "commitment": "", "parent": "", "group": "P0 (must finish this period)", "role": "", "created": "2026-08-17T15:59:34", "order": null, "summary": ""} {"id": "TASK-105", "title": "perry-explain resolves TASK ids from the typed Task store", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-105-v3-verification.md", "next_action": "Implement typed Task lookup and focused behavioral tests from TASK-105-spec.md.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T22:57:50", "order": null, "summary": ""} {"id": "TASK-093", "title": "A hand edit to a rendered file is reported rather than honoured", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-093-final-v4-review.md", "next_action": "Implement the bounded M2-M7 store-drift contract in TASK-093-spec.md; keep typed due, migration safety, claims and parser deletion out of scope, then dispatch fresh mutation-sensitive V4.", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:50", "order": null, "summary": ""} {"id": "TASK-106", "title": "Task summary is optional, explicit and preserved end to end", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-106-final-v4-review.md", "next_action": "Implement the optional explicit summary field end to end from TASK-106-spec.md, including store validation, writer/list contract, migration preservation and perry-explain output; infer nothing for legacy tasks.", "depends_on": ["TASK-105", "TASK-044"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T22:58:11", "order": null, "summary": ""} -{"id": "TASK-102", "title": "Evidence becomes a typed relation: {path, kind, round}, not one prose cell", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-090", "TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T13:57:15", "order": 9, "summary": ""} -{"id": "TASK-092", "title": "OKR.md and .perry/config.md become stores with renderers", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Mint the OKR and config stores plus their renderers, reusing the store-and-projection pattern already in bin/perry_store.py.", "depends_on": ["TASK-090"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:50", "order": 4, "summary": ""} +{"id": "TASK-102", "title": "Evidence becomes a typed relation: {path, kind, round}, not one prose cell", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": ["TASK-090", "TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T13:57:15", "order": 8, "summary": ""} +{"id": "TASK-092", "title": "OKR.md and .perry/config.md become stores with renderers", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Mint the OKR and config stores plus their renderers, reusing the store-and-projection pattern already in bin/perry_store.py.", "depends_on": ["TASK-090"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:50", "order": 3, "summary": ""} {"id": "TASK-040", "title": "perry-task: Top risks becomes a table with id / opened / cleared", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "Risks still read from a markdown table with empty opened/cleared; make them records in the store.", "depends_on": ["TASK-089"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-17T12:14:37", "order": 1, "summary": ""} -{"id": "TASK-077", "title": "DESIGN-006 F — a finance-shaped role runs one real task end to end", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "evidence/2026-08/TASK-077-context.md", "next_action": "Run the finance-shaped role end to end on a copy of gimegime-pmo, then write the extraction report.", "depends_on": ["TASK-073", "TASK-075", "TASK-076"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-18T07:31:46", "order": 2, "summary": ""} +{"id": "TASK-077", "title": "DESIGN-006 F — a finance-shaped role runs one real task end to end", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "evidence/2026-08/TASK-077-context.md", "next_action": "Run the finance-shaped role end to end on a copy of gimegime-pmo, then write the extraction report.", "depends_on": ["TASK-073", "TASK-075", "TASK-076"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-18T07:31:46", "order": 1, "summary": ""} {"id": "TASK-070", "title": "Perry's own state is 19.5% of the tracked repo and grows unbounded", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-070-context.md", "next_action": "Start with journal/ and evidence/ retention, which do not depend on the store; log rotation follows it.", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-18T00:13:21", "order": 4, "summary": ""} {"id": "TASK-066", "title": "Split perry-task by subcommand group", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-066-context.md", "next_action": "Re-size the split after the markdown reader, row renderer and cell escaping are gone.", "depends_on": ["TASK-065", "TASK-038"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-17T23:23:02", "order": 3, "summary": ""} -{"id": "TASK-079", "title": "Migration writes a file the user marked read-only, via rename", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-079-dispatch-2026-08-20-1345.md", "next_action": "user verifies: merge PR #6, and decide USER-004 (refuse vs report) which this task deliberately left open", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-18T16:13:35", "order": 3, "summary": ""} -{"id": "TASK-047", "title": "Flip the conformance gate to enforce", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-047-dispatch-2026-08-20-1416.md", "next_action": "user decides the subjective line: enforce by default while both 2026-08-18 blockers are still live, or revert to advisory (one line). Then merge PR #8", "depends_on": ["TASK-044"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-17T17:46:26", "order": 0, "summary": ""} +{"id": "TASK-079", "title": "Migration writes a file the user marked read-only, via rename", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-079-dispatch-2026-08-20-1345.md", "next_action": "user verifies: merge PR #6, and decide USER-004 (refuse vs report) which this task deliberately left open", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-18T16:13:35", "order": 2, "summary": ""} {"id": "TASK-086", "title": "DESIGN-002 decision 4 says lint warns on a collision; lint does not emit NS-01", "owner": "Coding Agent", "status": "review", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V2", "evidence": "evidence/2026-08/TASK-086-dispatch-2026-08-20-1429.md", "next_action": "user confirms the strict-flag contract change (the namespace warning is no longer promoted to a failure), then merge PR #9; PR #7 is superseded", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-19T10:09:57", "order": 6, "summary": ""} -{"id": "TASK-107", "title": "the dispatch safety gate matches its fragments as bare substrings, so ordinary English trips it", "summary": "", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "evidence/2026-08/TASK-107-spec.md", "next_action": "V5 sign-off: a human names the date and what they checked", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T15:08:07", "order": 10} -{"id": "TASK-108", "title": "LOAD-03 counts prose about a decision, so documenting an open question makes the count go up", "summary": "", "owner": "Coding Agent", "status": "in_progress", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "dispatched 15:16 via claude-subagent; worktree pinned to feat/work-modes; awaiting completion", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T15:13:45", "order": 11} +{"id": "TASK-107", "title": "the dispatch safety gate matches its fragments as bare substrings, so ordinary English trips it", "summary": "", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "evidence/2026-08/TASK-107-spec.md", "next_action": "V5 sign-off: a human names the date and what they checked", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T15:08:07", "order": 9} +{"id": "TASK-085", "title": "Decision status has no word for a proposal, and lives in three places", "owner": "Coding Agent", "status": "in_progress", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V2", "evidence": "—", "next_action": "dispatched 15:26 via claude-subagent; worktree pinned to feat/work-modes post-PR-#8; awaiting completion", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-19T09:53:50", "order": 5, "summary": ""} +{"id": "TASK-047", "title": "Flip the conformance gate to enforce", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "evidence/2026-08/TASK-047-dispatch-2026-08-20-1416.md", "next_action": "PR #8 merged and the gate enforces on this branch; awaiting the user's V5 sign-off text (name, date, what was checked)", "depends_on": ["TASK-044"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-17T17:46:26", "order": null, "summary": ""} +{"id": "TASK-108", "title": "LOAD-03 counts prose about a decision, so documenting an open question makes the count go up", "summary": "", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-108-dispatch-2026-08-20-1547.md", "next_action": "merge PR #10, then swap the literal GATE_OFF constant in tests/test_diagnose.py for the shared import", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T15:13:45", "order": 10} +{"id": "TASK-109", "title": "a V5 sign-off is composed by selection from measured facts, not authored from memory", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V5", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T15:49:41", "order": 11} diff --git a/tests/test_v5_signoff.py b/tests/test_v5_signoff.py new file mode 100644 index 0000000..1077e30 --- /dev/null +++ b/tests/test_v5_signoff.py @@ -0,0 +1,581 @@ +"""TASK-109 — a V5 sign-off is SELECTED from measured facts, not authored. + +V5 is the one rung whose content is a human's: "name, date, and what they +checked". Until this row the tool took none of it. `done --rung V5` wrote a +rung, and the signature was a paragraph the user composed by hand. + +TASK-047 is the case that named the defect. Perry ran three checks, printed +their output, and showed the user; the user then wrote, from memory, a sentence +describing those same three checks. Two things are wrong with that, and the +second is the one that matters: + + 1. the user re-derives by hand a record the system already holds, and + 2. free text cannot distinguish *I re-ran this* from *Perry ran this and I + read the output*. The gap widens the more Perry does. + +What keeps the fix from being a rubber stamp is one rule, and it is enforced +here mechanically rather than by review: + + Perry may draft only facts it MEASURED. It may never draft a claim about + what the USER did. + +`TestNoDraftedOptionAssertsAUserAction` is that rule as a test over the option +builder — the only place a drafted string is minted. + +Run: python3 -m unittest discover -s tests (or ./tests/run) +""" + +from __future__ import annotations + +import importlib.machinery +import importlib.util +import json +import re +import subprocess +import tempfile +import unittest +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" +TASKS = PERRY_HOME / "bin" / "perry-tasks" + + +def load_tool(): + spec = importlib.util.spec_from_loader( + "perry_task", importlib.machinery.SourceFileLoader("perry_task", str(TOOL))) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +PT = load_tool() + +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 | +|---|---|---|---|---|---| +""" + +#: The fixture V5 verification 1 asks for: three facts Perry measured during +#: the task, and one claim it is only passing along. Worded the way a real +#: dispatch would word them — an observation, never an act. +MEASURED = [ + "claims[] carries zero changed lines in the diff on schema/state-schema.json", + "write behaviour under enforce / advisory / declared: 3 runs, exit codes recorded", + "the perry-migrate exemption still runs on an undeclared project", +] +RESTATED = ["the branch carrying this change is unmerged"] + + +class Project: + """A throwaway Perry project the tool can close a row in.""" + + def __init__(self): + self.dir = tempfile.TemporaryDirectory() + self.root = Path(self.dir.name) + (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) + (self.root / "BOARD.md").write_text(BOARD) + r = subprocess.run( + ["python3", str(TASKS), "write", "--from-board", "--root", + str(self.root)], capture_output=True, text=True) + if r.returncode: + raise AssertionError(r.stdout + r.stderr) + + def run(self, *argv) -> tuple[int, dict | str]: + r = subprocess.run( + ["python3", str(TOOL), *argv, "--root", str(self.root), "--json"], + capture_output=True, text=True) + try: + return r.returncode, json.loads(r.stdout or "{}") + except json.JSONDecodeError: + return r.returncode, r.stdout + r.stderr + + def a_task(self) -> str: + _, a = self.run("add", "--title", "Flip the conformance default", + "--deliverable", "the gate enforces", + "--verification", "the suite is green") + return a["id"] + + def journal(self) -> str: + for p in (self.root / "journal").rglob("*.md"): + return p.read_text() + return "" + + def events(self) -> list[dict]: + p = self.root / ".perry" / "events.jsonl" + return ([json.loads(l) for l in p.read_text().split("\n") if l.strip()] + if p.exists() else []) + + def close_v5(self, tid, *extra): + return self.run("done", tid, "--evidence", "evidence/x.md", + "--rung", "V5", + *[f for t in MEASURED for f in ("--measured", t)], + *[f for t in RESTATED for f in ("--restated", t)], + *extra) + + def __del__(self): + self.dir.cleanup() + + +class TestTheRecordIsASelection(unittest.TestCase): + """V5 verification 1 — three measured items, one restated, two selected.""" + + def setUp(self): + self.p = Project() + self.tid = self.p.a_task() + self.code, self.out = self.p.close_v5(self.tid, "--checked", "1,3") + self.assertEqual(self.code, 0, self.out) + self.sig = self.out["signoff"] + + def test_the_two_selected_are_recorded_as_checked(self): + checked = [i["text"] for i in self.sig["items"] + if i["disposition"] == "checked"] + self.assertEqual(checked, [MEASURED[0], MEASURED[2]]) + + def test_the_two_unselected_are_recorded_as_accepted_on_report(self): + """Deliverable 4. Not dropped — that is the whole gain over free text, + which could not distinguish the two at all.""" + rest = [i["text"] for i in self.sig["items"] + if i["disposition"] == "accepted on report"] + self.assertEqual(rest, [MEASURED[1], RESTATED[0]]) + self.assertEqual(len(self.sig["items"]), 4, + "an offered item left the record entirely") + + def test_the_labels_survive_verbatim_into_the_written_record(self): + """Both labels, in the journal, spelled exactly as the record spells + them. A record whose markdown says "accepted" and whose JSON says + `accepted on report` has two answers to the question the rung asks.""" + journal = self.p.journal() + self.assertIn("## V5 sign-off", journal) + for label in ("checked", "accepted on report"): + self.assertIn(f"**{label}**", journal, + f"the disposition label {label!r} did not survive") + for item in self.sig["items"]: + self.assertIn(item["text"], journal) + self.assertIn("*(Perry verified)*", journal) + self.assertIn("*(restated — Perry did not verify this)*", journal) + + def test_every_item_is_labelled_with_its_provenance(self): + """Deliverable 2. Selecting a Perry-verified item means *I checked this + too*; selecting a restated one means *I checked a claim Perry passed + along*. The label is what keeps those from reading the same.""" + by_text = {i["text"]: i["provenance"] for i in self.sig["items"]} + for text in MEASURED: + self.assertEqual(by_text[text], "Perry verified") + self.assertEqual(by_text[RESTATED[0]], + "restated — Perry did not verify this") + + def test_name_and_date_are_filled_in_not_typed(self): + """Deliverable 3 — the two fields a human should never be retyping.""" + self.assertTrue(self.sig["signed_by"].strip(), + "an anonymous signature is not a weaker signature") + self.assertEqual(self.sig["signed_on"], f"{date.today():%Y-%m-%d}") + self.assertIn(self.sig["signed_by"], self.p.journal()) + + def test_the_signature_rides_in_the_event_too(self): + done = [e for e in self.p.events() if e.get("event") == "done"] + self.assertEqual(len(done), 1) + self.assertEqual(done[0]["signoff"]["counts"], + {"checked": 2, "accepted on report": 2, + "not looked at": 0}) + + def test_the_row_still_closes_at_v5(self): + """The sign-off is added to the close; it does not replace it.""" + self.assertEqual(self.out["rung"], "V5") + self.assertNotIn(self.tid, (self.p.root / "BOARD.md").read_text()) + + +class TestFreeTextIsAdditive(unittest.TestCase): + """V5 verification 2 — recorded ALONGSIDE the selection, never instead.""" + + ALSO = "I re-ran perry-conform declare --all against my own checkout." + + def test_free_text_lands_beside_the_selection(self): + p = Project() + tid = p.a_task() + code, out = p.close_v5(tid, "--checked", "2", "--also", self.ALSO) + self.assertEqual(code, 0, out) + sig = out["signoff"] + self.assertEqual(sig["also_checked"], self.ALSO) + self.assertEqual(len(sig["items"]), 4, + "free text replaced the selection instead of joining it") + self.assertEqual(sig["counts"]["checked"], 1) + journal = p.journal() + self.assertIn(self.ALSO, journal) + self.assertIn(MEASURED[1], journal) + self.assertIn("**accepted on report**", journal) + + def test_free_text_alone_is_a_signature(self): + """Deliverable 5 read the other way: the user may have checked only + something Perry never offered. That is a signature, not an empty one.""" + p = Project() + tid = p.a_task() + code, out = p.close_v5(tid, "--checked", "none", "--also", self.ALSO) + self.assertEqual(code, 0, out) + self.assertEqual(out["signoff"]["counts"]["checked"], 0) + self.assertEqual(out["signoff"]["also_checked"], self.ALSO) + + def test_the_free_text_is_not_run_through_the_drafting_guard(self): + """`--also` is the USER's sentence. The guard exists to stop PERRY + drafting a claim about a person; applying it to the user's own words + would refuse them for describing what they did, which is the one thing + only they may say.""" + p = Project() + tid = p.a_task() + code, out = p.close_v5( + tid, "--checked", "none", + "--also", "I reviewed the diff myself and approved it.") + self.assertEqual(code, 0, out) + self.assertIn("I reviewed the diff myself", p.journal()) + + +class TestNoDraftedOptionAssertsAUserAction(unittest.TestCase): + """V5 verification 3 and deliverable 6, as a machine check. + + This is the load-bearing test in the file. If Perry may draft *the user + reviewed the diff*, then a V5 close is Perry certifying its own work with a + human's name on it, and every other guarantee here is decoration. + + A review comment cannot enforce this: it holds until the first hurried + close. `PT.signoff_options` is the only place a drafted string is minted, + so the rule is checked there and nowhere else needs to remember it. + """ + + #: Each of these is a claim about a person. None is Perry's to write. + USER_CLAIMS = [ + "the user reviewed the diff", + "the user accepted the two costs", + "you confirmed the migrate exemption", + "your checkout was declared", + "the human read the fixture opt-out reasoning", + "the reviewer signed off on the rung", + "the signer approved the enforce default", + "reviewed the claims[] diff", + "approved the migration plan", + "accepted on the strength of the printed output", + "acknowledged the residue on a real board", + "用户已阅并接受两项代价", + "同意把 enforce 设为默认值", + ] + + #: The complement, and the anti-vacuity guard: a rule that refused + #: everything would pass the list above and be useless. Every one of these + #: is a fact Perry can measure, and several are near-misses on purpose — + #: `user-facing` contains `user`, `acceptance` contains `accept`. + MEASURABLE = [ + "claims[] carries zero changed lines in the diff", + "the perry-migrate exemption still runs on an undeclared project", + "the user-facing message names the mode rather than the literal string", + "3 of 80 closed rows carry V5", + "the acceptance-criteria file resolves to an existing path", + "tests/parallel: 59 modules, 1717 tests, 3 red", + "SKILL.md is 21030 bytes against a 20480 cap", + "owner is present on 21 of 21 open rows and 0 of 60 closed ones", + ] + + def test_a_drafted_claim_about_a_person_is_refused(self): + for claim in self.USER_CLAIMS: + for flag, kwargs in (("--measured", {"measured": [claim]}), + ("--restated", {"restated": [claim]})): + with self.subTest(claim=claim, flag=flag): + with self.assertRaises(PT.Refused) as caught: + PT.signoff_options(kwargs.get("measured", []), + kwargs.get("restated", [])) + self.assertIn(claim, str(caught.exception), + "the refusal must quote what it refused") + + def test_a_measured_fact_is_not_refused(self): + options = PT.signoff_options(self.MEASURABLE, []) + self.assertEqual(len(options), len(self.MEASURABLE)) + self.assertEqual([o["n"] for o in options], + list(range(1, len(self.MEASURABLE) + 1))) + + def test_the_refusal_reaches_the_cli_not_just_the_function(self): + p = Project() + tid = p.a_task() + code, out = p.run("done", tid, "--evidence", "e.md", "--rung", "V5", + "--measured", "the user reviewed the diff", + "--checked", "1") + self.assertEqual(code, 1) + self.assertIn("refused", out) + self.assertEqual(p.journal().count("V5 sign-off"), 0, + "a refused sign-off wrote something anyway") + + def test_no_option_the_builder_emits_carries_a_user_claim(self): + """The rule stated over the OUTPUT rather than the input, so a future + builder that rewrites or decorates an option cannot smuggle one past + the entry check.""" + for option in PT.signoff_options(self.MEASURABLE, ["a restated claim"]): + PT.check_no_user_claim(option["text"], "--measured") + self.assertIn(option["provenance"], + ("Perry verified", + "restated — Perry did not verify this")) + + +class TestAnEmptySignatureIsRefused(unittest.TestCase): + """V5 verification 4 and deliverable 7.""" + + def test_nothing_selected_and_no_free_text_is_refused(self): + p = Project() + tid = p.a_task() + code, out = p.close_v5(tid, "--checked", "none") + self.assertEqual(code, 1, out) + self.assertIn("not a sign-off", json.dumps(out, ensure_ascii=False)) + + def test_the_refused_close_wrote_nothing(self): + """A refusal that half-closed the row would be worse than the blank + signature it prevented.""" + p = Project() + tid = p.a_task() + p.close_v5(tid, "--checked", "none") + self.assertIn(tid, (p.root / "BOARD.md").read_text()) + self.assertEqual([e["event"] for e in p.events()], ["add"]) + + def test_pressing_return_at_the_prompt_is_what_this_costs(self): + """`--checked` absent entirely is the same keystroke as `none`, and + must not be the cheap path to a blank signature.""" + p = Project() + tid = p.a_task() + code, _ = p.close_v5(tid) + self.assertEqual(code, 1) + + def test_an_empty_offered_item_is_refused(self): + with self.assertRaises(PT.Refused): + PT.signoff_options([""], []) + + def test_a_bare_close_that_never_engaged_the_path_is_unchanged(self): + """The refusal fires when the sign-off path was ENGAGED and produced + nothing. A close that passes no sign-off flag at all writes no + signature rather than a blank one — rungs are advisory this release + (DESIGN-003 § 4 decision 4) and hardening the rung itself is out of + this row's scope.""" + p = Project() + tid = p.a_task() + code, out = p.run("done", tid, "--evidence", "e.md", "--rung", "V5") + self.assertEqual(code, 0, out) + self.assertIsNone(out["signoff"]) + + +class TestThreeDispositionsNotTwo(unittest.TestCase): + """The subjective question this row was dispatched with, pinned. + + **The alternative that was rejected: two categories** — `checked` and + `accepted on report` — with nothing between "I read Perry's output and took + its word" and "I never looked at this at all". + + It was rejected on the corpus. All three V5 signatures already in this + repository write the third category by hand. TASK-034's carries a section + headed *"Not checked, and recorded because V5's whole value is saying so"* + beside what it did check. TASK-047's distinguishes *fixture opt-out 的理由已读 + 并接受* — read, then accepted — from two costs taken on the strength of + Perry's printed output. A format that cannot hold what the existing + signatures already say is a regression against the corpus it must stay + compatible with. + + The second reason is the drafting rule. Defaulting an unselected item to + `accepted on report` is already the outer edge of what Perry may assert: + it describes the SCOPE of the signature, not an act the user performed. So + `not looked at` is never a default — it is reachable only by the user + naming the item, which is what keeps it a user statement. + """ + + def test_not_looked_at_is_a_disposition_of_its_own(self): + p = Project() + tid = p.a_task() + code, out = p.close_v5(tid, "--checked", "1", "--not-looked-at", "4") + self.assertEqual(code, 0, out) + by_text = {i["text"]: i["disposition"] for i in out["signoff"]["items"]} + self.assertEqual(by_text[MEASURED[0]], "checked") + self.assertEqual(by_text[MEASURED[1]], "accepted on report") + self.assertEqual(by_text[RESTATED[0]], "not looked at") + self.assertIn("**not looked at**", p.journal()) + + def test_not_looked_at_is_never_a_default(self): + p = Project() + tid = p.a_task() + _, out = p.close_v5(tid, "--checked", "1") + self.assertEqual(out["signoff"]["counts"]["not looked at"], 0) + + def test_one_item_cannot_carry_two_dispositions(self): + p = Project() + tid = p.a_task() + code, _ = p.close_v5(tid, "--checked", "1", "--not-looked-at", "1") + self.assertEqual(code, 1) + + +class TestTheOfferIsBuiltNotComposed(unittest.TestCase): + """Deliverable 1, and the numbering contract between offer and close.""" + + def test_the_offer_numbers_items_the_way_done_reads_them(self): + p = Project() + tid = p.a_task() + code, offer = p.run( + "signoff-offer", tid, + *[f for t in MEASURED for f in ("--measured", t)], + *[f for t in RESTATED for f in ("--restated", t)]) + self.assertEqual(code, 0, offer) + self.assertEqual([o["text"] for o in offer["options"]], + MEASURED + RESTATED) + _, out = p.close_v5(tid, "--checked", "3") + picked = [i["text"] for i in out["signoff"]["items"] + if i["disposition"] == "checked"] + self.assertEqual(picked, [offer["options"][2]["text"]], + "option 3 in the prompt is not option 3 in the record") + + def test_the_offer_writes_nothing(self): + p = Project() + tid = p.a_task() + before = (p.root / "BOARD.md").read_text() + p.run("signoff-offer", tid, "--measured", MEASURED[0]) + self.assertEqual((p.root / "BOARD.md").read_text(), before) + self.assertEqual([e["event"] for e in p.events()], ["add"]) + + def test_it_degrades_to_a_numbered_free_text_prompt(self): + """`reference/host-capabilities.md § Prompt rendering`: Codex has no + selection UI and gets numbered options plus free text. The RENDERING + changes per host; the record does not.""" + p = Project() + tid = p.a_task() + _, offer = p.run("signoff-offer", tid, + *[f for t in MEASURED for f in ("--measured", t)]) + self.assertTrue(offer["multi_select"]) + prompt = offer["prompt"] + for n in (1, 2, 3): + self.assertIn(f" {n}) ", prompt) + self.assertIn("all", prompt) + self.assertIn("none", prompt) + self.assertIn("accepted on report", prompt) + self.assertIn(tid, prompt) + + def test_the_same_selection_records_the_same_thing_from_either_spelling(self): + """`--checked 1,3` is what the free-text host hands back; `--checked 1 + --checked 3` is what a structured host produces. One record.""" + a, b = Project(), Project() + _, one = a.close_v5(a.a_task(), "--checked", "1,3") + _, two = b.close_v5(b.a_task(), "--checked", "1", "--checked", "3") + strip = lambda s: [(i["n"], i["disposition"]) for i in s["signoff"]["items"]] + self.assertEqual(strip(one), strip(two)) + + def test_an_offer_with_nothing_measured_is_refused(self): + p = Project() + tid = p.a_task() + code, _ = p.run("signoff-offer", tid) + self.assertEqual(code, 1) + + def test_a_signoff_on_a_rung_that_is_not_v5_is_refused(self): + """V5 is "human sign-off"; V4 is a rubric and V6 is the world. Hanging + a signature on either records a human gate nobody asked for.""" + for rung in ("V3", "V4", "V6"): + with self.subTest(rung=rung): + p = Project() + code, _ = p.run("done", p.a_task(), "--evidence", "e.md", + "--rung", rung, "--measured", MEASURED[0], + "--checked", "1") + self.assertEqual(code, 1) + + +class TestHistoryIsNotRewritten(unittest.TestCase): + """V5 verification 5 — this adds a path; it does not touch what is signed. + + The three V5 closes in Perry's own log predate the selection format. They + must keep reading exactly as they did: an evidence file carrying a name and + a date, and an event with no `signoff` key, because there was none. + """ + + SIGNED = { + "TASK-034": "evidence/2026-08/TASK-034-lifecycle.md", + "TASK-103": "evidence/2026-08/TASK-103-design-007-lock.md", + "TASK-047": "evidence/2026-08/TASK-047-dispatch-2026-08-20-1416.md", + } + + def v5_events(self): + log = PERRY_HOME / ".perry" / "events.jsonl" + out = [] + for line in log.read_text().split("\n"): + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("rung") == "V5" and event.get("event") == "done": + out.append(event) + return out + + def test_the_three_existing_v5_closes_still_read(self): + events = {e["id"]: e for e in self.v5_events()} + self.assertEqual(set(events), set(self.SIGNED), + "the set of V5 closes in the log moved") + for tid, rel in self.SIGNED.items(): + with self.subTest(tid=tid): + self.assertEqual(events[tid]["evidence"], rel) + self.assertNotIn( + "signoff", events[tid], + "a signature was back-filled onto a close that predates " + "the format — the change adds a path, it does not rewrite " + "history") + + def test_each_signature_document_still_carries_a_name_and_a_date(self): + """What V5 asks for, checked against the files rather than asserted. + These are read here and written nowhere: `perry/` is the PMO's state.""" + state_root = PERRY_HOME / "perry" + for tid, rel in self.SIGNED.items(): + with self.subTest(tid=tid): + text = (state_root / rel).read_text() + self.assertRegex(text, r"\d{4}-\d{2}-\d{2}", + "the signature lost its date") + self.assertTrue( + re.search(r"[Ss]igned off|sign-off|签", text), + "the signature block is no longer findable in the file") + + def test_the_new_record_does_not_claim_to_be_the_old_one(self): + """The old signatures are prose in an evidence file; the new one is a + journal block plus an event. Both are readable, neither is rewritten, + and nothing here converts one into the other.""" + p = Project() + code, out = p.close_v5(p.a_task(), "--checked", "1") + self.assertEqual(code, 0, out) + self.assertIn("signoff", out) + self.assertNotIn("V5 sign-off", (p.root / "BOARD.md").read_text()) + + +class TestV1toV4ClosesAreUntouched(unittest.TestCase): + """Out of scope, asserted rather than assumed: they gain nothing here.""" + + def test_a_v3_close_writes_exactly_what_it_wrote_before(self): + p = Project() + tid = p.a_task() + code, out = p.run("done", tid, "--evidence", "evidence/x.md", + "--rung", "V3") + self.assertEqual(code, 0, out) + self.assertIsNone(out["signoff"]) + self.assertEqual(out["signoff_block"], "") + journal = p.journal() + self.assertNotIn("V5 sign-off", journal) + self.assertNotIn("signed off:", journal) + done = [e for e in p.events() if e["event"] == "done"][0] + self.assertNotIn("signoff", done) + + +if __name__ == "__main__": + unittest.main() diff --git a/work/reference/subcommands.md b/work/reference/subcommands.md index b4441fb..a47a58b 100644 --- a/work/reference/subcommands.md +++ b/work/reference/subcommands.md @@ -625,10 +625,47 @@ Before flipping status, capture **how** this was verified, not just that evidenc Two rules override the default, and neither is optional: - **Consequence beats mode.** If the task matches `.perry/hook.md § High-stakes operations` — outward-facing, irreversible, or carrying money, legal or safety exposure — the rung is **V5 minimum** whatever the mode default says. `perry-lint --verification` reports the mismatch as `consequence-needs-signoff`, so a close below V5 on a high-stakes row will surface at the next standup regardless. -- **V4 needs a rubric, V5 needs a signature.** A `V4` close must cite the acceptance-criteria file the reviewer scored against, and that reviewer must not have seen the reasoning that produced the artifact. A `V5` close must record **name, date, and what was checked** — "reviewed" is not what was checked. +- **V4 needs a rubric, V5 needs a signature.** A `V4` close must cite the acceptance-criteria file the reviewer scored against, and that reviewer must not have seen the reasoning that produced the artifact. A `V5` close must record **name, date, and what was checked** — "reviewed" is not what was checked. At V5 the signature is *selected* rather than composed; the procedure is the next block. **Choose** the rung here and hand it to `perry-task done --rung`. Do not write it into the row or the journal yourself — the tool writes both, and doing it here as well produces a duplicate journal line and an edit to a row the next command removes. **Advisory this release** by DESIGN-003 § 4 decision 4: a missing or unsatisfiable rung is reported, never refused, because a hard gate on day one would retroactively invalidate every `done` row written before rungs existed. The number to watch is `unrated` in `perry-state`'s `board.verification` — it is what should shrink before the gate hardens. +**Pre-close gate 3, second half — at V5 the signature is SELECTED from what Perry measured, never composed from memory** (TASK-109). Rungs V1–V4 stop at the paragraph above; only a V5 close continues here. + +The first half of a V5 close is a read-only offer. It writes nothing: + +```bash +"$PERRY_HOME/bin/perry-task" signoff-offer --json \ + --measured "" \ + --restated "" +``` + +Both flags repeat. `signoff-offer` numbers the items, labels each with its provenance, and the numbering it prints is the numbering `done --checked` reads back — one function mints both, so a prompt whose option 3 is the tool's option 4 cannot happen. + +**`--measured` is what Perry ran**: the objective-verification commands and their output, the scope cross-check, the diffs it took. **`--restated` is what Perry is only repeating** — a dispatch RESULT line, a claim from the spec, a subjective-verification item the spec declared. That distinction is the product. Selecting a `--measured` item means *I checked this too*; selecting a `--restated` one means *I checked a claim Perry only passed along*. Flatten them and the rung records acceptance where it promised verification. + +**Perry may draft only facts it measured. It may never draft a claim about what the user did.** `claims[] carries zero changed lines in the diff` is yours to draft — you ran the diff. `the user reviewed the diff` is not, and the tool refuses it by pattern rather than by review note: drafting the signature and collecting a keystroke is Perry certifying its own work, which is the failure V5 exists to prevent. + +Render the payload's `options` with **`AskUserQuestion`** (`multiSelect: true`, header = TASK-ID). On a host with no selection UI, print the payload's `prompt` — the numbered free-text fallback of `reference/host-capabilities.md § Prompt rendering`. Then ask the free-text half once: *anything you checked that Perry did not offer?* Rendering differs per host; the record does not. + +Hand the answer to the same call that closes the row — this is one tool call, not a close plus a write: + +```bash +"$PERRY_HOME/bin/perry-task" done --evidence "" --rung V5 \ + --measured "…" --restated "…" \ + --checked "1,3" \ + [--not-looked-at "4"] \ + [--also ""] +``` + +Pass the same `--measured` / `--restated` items back unchanged: the record holds every offered item, not only the selected ones. `--checked` also accepts `all`, `none`, or one flag per number, because that is what the free-text host hands back. + +- **Name and date are filled in** from `git config user.name` and today. They are the two fields a human should never be retyping, and `--signer` exists only for the case where git has no name. +- **Unselected items are recorded, not dropped**, as `accepted on report` — strictly more than the free-text paragraph could say, which could not distinguish the two at all. +- **`not looked at` is never a default.** It is reached only by the user naming the item, which is what keeps it the user's statement rather than Perry's. +- **A V5 close with nothing checked and no free text is refused, not written blank.** An empty signature is the failure the rung exists to prevent, and it must not be reachable by pressing return. + +The tool writes the signature block into today's journal under `## V5 sign-off` and the structured record into the close event, in the same transaction as the row. **Do not also write the signature into the evidence file by hand** — a second copy is a second answer to the question the rung asks, and the two rot apart. The signatures already recorded in `evidence/2026-08/` keep their own shape; this adds a path, it does not rewrite them. + **Pre-close gate 4 — inquiry mode** (`modes/inquiry.md`). On an inquiry-mode track: 1. `evidence//-answer.md` must exist — the question restated, the answer, the claims with their `[SRC-n]` citations, and what would change the answer. The mode's signature failure is re-deriving the same synthesis every session, and its one cause is the answer living in chat. 2. `perry-lint --provenance --root .` must report no `citation-dangling` for that file. This is the half of the bar `modes/inquiry.md` calls the mode's test suite; the rung is the other half, and shipping only the rung leaves the script unrun.