diff --git a/.perry/events.jsonl b/.perry/events.jsonl index 9c388f9..d47b849 100644 --- a/.perry/events.jsonl +++ b/.perry/events.jsonl @@ -579,3 +579,21 @@ {"ts": "2026-08-20T23:35:10", "event": "evidence", "id": "TASK-122", "title": "the repair path the tools advertise leaves the file needing a whitespace fix", "track": "main", "actor": "agent", "from": "—", "to": "evidence/2026-08/TASK-122-spec.md"} {"ts": "2026-08-20T23:37:40", "event": "status", "id": "TASK-140", "title": "every mode contract slot is assigned to an axis, and the spine-to-unit map is written down", "track": "main", "actor": "agent", "depends_on": [], "from": "in_progress", "to": "review", "reason": ""} {"ts": "2026-08-20T23:37:40", "event": "evidence", "id": "TASK-140", "title": "every mode contract slot is assigned to an axis, and the spine-to-unit map is written down", "track": "main", "actor": "agent", "from": "evidence/2026-08/TASK-140-dispatch-2026-08-21.md", "to": "evidence/2026-08/TASK-140-dispatch-2026-08-21-result.md"} +{"ts": "2026-08-20T23:38:54", "event": "start", "id": "TASK-141", "title": "a row stays blocked after its blockers close, because the stored status masks the computed one", "track": "intake", "actor": "agent", "from": "not_started", "to": "in_progress"} +{"ts": "2026-08-20T23:38:54", "event": "evidence", "id": "TASK-141", "title": "a row stays blocked after its blockers close, because the stored status masks the computed one", "track": "intake", "actor": "agent", "from": "—", "to": "evidence/2026-08/TASK-141-spec.md"} +{"ts": "2026-08-20T23:52:06", "event": "status", "id": "TASK-120", "title": "the linkage edges are read but never folded into KR progress", "track": "main", "actor": "agent", "depends_on": [], "from": "in_progress", "to": "review", "reason": ""} +{"ts": "2026-08-20T23:52:06", "event": "evidence", "id": "TASK-120", "title": "the linkage edges are read but never folded into KR progress", "track": "main", "actor": "agent", "from": "evidence/2026-08/TASK-120-spec.md", "to": "evidence/2026-08/TASK-120-dispatch-2026-08-21-result.md"} +{"ts": "2026-08-20T23:52:24", "event": "add", "id": "TASK-144", "title": "the event log timestamp has no zone and the register has one, so ordering them is a guess", "track": "intake", "mode": "queue", "priority": "P1", "actor": "agent", "summary": "", "depends_on": [], "from": null, "to": "not_started"} +{"ts": "2026-08-20T23:52:24", "event": "add", "id": "TASK-145", "title": "the contract shape baseline is stale against its own recorder", "track": "intake", "mode": "queue", "priority": "P2", "actor": "agent", "summary": "", "depends_on": [], "from": null, "to": "not_started"} +{"ts": "2026-08-20T23:52:24", "event": "add", "id": "TASK-146", "title": "the viewer renders a KR current with no provenance because it does not go through the shared derivation", "track": "intake", "mode": "queue", "priority": "P2", "actor": "agent", "summary": "", "depends_on": [], "from": null, "to": "not_started"} +{"ts": "2026-08-20T23:53:36", "event": "start", "id": "TASK-143", "title": "two PRs each green on their own base merged into a red tree, and nothing checked the pair", "track": "intake", "actor": "agent", "from": "not_started", "to": "in_progress"} +{"ts": "2026-08-20T23:53:36", "event": "evidence", "id": "TASK-143", "title": "two PRs each green on their own base merged into a red tree, and nothing checked the pair", "track": "intake", "actor": "agent", "from": "—", "to": "evidence/2026-08/TASK-143-spec.md"} +{"ts": "2026-08-21T00:03:14", "event": "status", "id": "TASK-122", "title": "the repair path the tools advertise leaves the file needing a whitespace fix", "track": "main", "actor": "agent", "depends_on": [], "from": "in_progress", "to": "review", "reason": ""} +{"ts": "2026-08-21T00:03:14", "event": "evidence", "id": "TASK-122", "title": "the repair path the tools advertise leaves the file needing a whitespace fix", "track": "main", "actor": "agent", "from": "evidence/2026-08/TASK-122-spec.md", "to": "evidence/2026-08/TASK-122-dispatch-2026-08-21-result.md"} +{"ts": "2026-08-21T00:03:52", "event": "add", "id": "TASK-147", "title": "nothing outside describe_cell proves the table and bullet paths stay separated", "track": "intake", "mode": "queue", "priority": "P2", "actor": "agent", "summary": "", "depends_on": [], "from": null, "to": "not_started"} +{"ts": "2026-08-21T00:05:02", "event": "start", "id": "TASK-121", "title": "the sweep that found four more live-state assertions runs once and then is thrown away", "track": "main", "actor": "agent", "from": "not_started", "to": "in_progress"} +{"ts": "2026-08-21T00:05:02", "event": "evidence", "id": "TASK-121", "title": "the sweep that found four more live-state assertions runs once and then is thrown away", "track": "main", "actor": "agent", "from": "—", "to": "evidence/2026-08/TASK-121-spec.md"} +{"ts": "2026-08-21T00:12:03", "event": "status", "id": "TASK-141", "title": "a row stays blocked after its blockers close, because the stored status masks the computed one", "track": "intake", "actor": "agent", "depends_on": [], "from": "in_progress", "to": "review", "reason": ""} +{"ts": "2026-08-21T00:12:03", "event": "evidence", "id": "TASK-141", "title": "a row stays blocked after its blockers close, because the stored status masks the computed one", "track": "intake", "actor": "agent", "from": "evidence/2026-08/TASK-141-spec.md", "to": "evidence/2026-08/TASK-141-dispatch-2026-08-21-result.md"} +{"ts": "2026-08-21T00:12:22", "event": "add", "id": "TASK-148", "title": "two byte-identical copies of the startable rule live 200 lines apart in one file", "track": "intake", "mode": "queue", "priority": "P1", "actor": "agent", "summary": "", "depends_on": [], "from": null, "to": "not_started"} +{"ts": "2026-08-21T00:12:22", "event": "next", "id": "TASK-142", "title": "triage has no check for a row stranded by a process bug, and the one signal that fired was read as prose hygiene", "track": "intake", "actor": "agent", "from": "design question answered 2026-08-20: it belongs in conformance, which triage already reads at step 0.5 — not as a new triage feature", "to": "read TASK-141 tasks[].blocked_stale rather than recomputing the predicate — a third statement of one rule is the defect TASK-148 is open for"} diff --git a/bin/perry-task b/bin/perry-task index 4fe7e17..f92e749 100755 --- a/bin/perry-task +++ b/bin/perry-task @@ -64,6 +64,13 @@ Usage: [--arrived YYYY-MM-DD] perry-task start [--next "…"] perry-task stage --stage + perry-task track --track T [--stage S] [--arrived YYYY-MM-DD] + [--reason "…"] + move an EXISTING row onto another declared track; keeps its + id, its section and every other cell. A queue destination + stamps `Arrived` and the track's first post-intake `Stage`, + exactly as `route` does. A destination that reads neither + CLEARS them and records what they were. perry-task intake --title "…" [--arrived YYYY-MM-DD] perry-task ask --needed "…" [--blocks ] [--arrived YYYY-MM-DD] perry-task answer --answer "…" @@ -1840,6 +1847,13 @@ def commit(project_root: Path, state_root: Path, board: Board, changed = { "start": ("status", "next_action"), "stage": ("stage", "stage_since"), + # Four fields, because a track move re-stamps the destination's + # clock in the same write. Listing only `track` would land the + # new track in the store and leave yesterday's `Stage` and + # `Arrived` beside it — the store and the board would then + # disagree, and the board is rendered FROM the store, so the + # move would appear to half-revert on the next render. + "track": ("track", "stage", "stage_since", "arrived"), "status": ("status", "next_action", "depends_on"), "depends": ("depends_on",), "next": ("next_action",), @@ -2103,13 +2117,29 @@ def check_stage(track: dict, stages: list[str], stage: str) -> None: def track_of(config: dict, name: str) -> dict: + """The declared track by that name, or the implicit `main`. + + **The refusal is `check_role`'s shape, and for the same reason.** A track + is a declaration the project makes about itself — one row of + `.perry/config.md § Tracks` — so naming one that has no row is the same + mistake as naming a role with no card, and the answer a user needs is the + same: which names DO exist, and where to add the missing one. It used to + print a Python list repr (`declared: ['main', 'intake']`) and stop there, + which named the set and not the way forward. Nothing is created here: a + tool that invented the track would turn a typo into a second track that + every count then reports as real. + """ for t in config.get("tracks", []): if t["track"] == name: return t if name and name != "main": + declared = [t["track"] for t in config.get("tracks", [])] + shown = (", ".join(declared) + or "(none — this project has only the implicit `main`)") raise Refused( - f"track {name!r} is not declared in `.perry/config.md § Tracks`; " - f"declared: {[t['track'] for t in config.get('tracks', [])]}") + f"track {name!r} is not declared in `.perry/config.md § Tracks`. " + f"Declared: {shown}. Add a row to that table naming its mode, or " + f"name one of those. Nothing was written") return {"track": "main", "mode": "project", "default_rung": ""} @@ -2827,6 +2857,142 @@ def cmd_stage(args, ctx) -> dict: return {"id": tid, "stage": stage, "row": line, **plan} +def widen_row_section(board: "Board", section: str, columns: list[str]) -> None: + """Add columns to whichever section already holds a row. + + `Board.find` returns `P0`/`P1`/`P2` **or a project's own heading**, and the + two widenings live on different methods. `cmd_stage` widens only in the + first case, so on a board that files work under its own headings — the + shape `--group` exists for, and the only real adopted project available — + a `Stage since` stamp is dropped silently, because `replace_row` maps + values onto headers and a missing header discards the value at exit 0. + That is the same defect that lost `--commitment` and then `Stage since`. + A track move writes FOUR such cells, so it asks the question once, here, + for both section shapes. + """ + if section in ("P0", "P1", "P2"): + board.ensure_columns(section, columns) + else: + board.ensure_section_columns(section, columns) + + +def cmd_track(args, ctx) -> dict: + """Move an existing row onto another declared track. + + **The second entrance a track has, and the reason it exists.** `--track` + was accepted by `add` (at creation) and `route` (intake row → new task) and + nowhere else, so a project that declared a second track started it empty + and could not move a single existing row onto it. A declared track with no + rows is not a mode running on a live track — measured on this repository, + which declared `intake` and left six rows that genuinely arrived stranded + on `main` (`perry/evidence/2026-08/TASK-133-track-experiment.md`). + + **Onto a queue track, this stamps what `route` stamps.** Not bookkeeping: + `today − Arrived` is the number every SLA check measures, so a second + entrance that omitted it would make `Arrived` mean one thing per entrance + and silently exempt every moved row from the only clock governing it. + `entry_stage` is the same function `add` and `route` call, so the three + entrances cannot disagree about where a row begins on a track. + + **An existing `Arrived` is CARRIED, never restamped.** A queue → queue move + that reset the clock to today would erase an in-flight breach — the same + exemption, arriving through the other door. `--arrived` overrides, for the + row whose arrival the board never recorded. + + **Off a track, `Stage` / `Stage since` / `Arrived` are CLEARED, and the + cleared values are written into the journal line and the event.** This is + the decision deliverable 2 asks for, and it is `drop`'s precedent applied + to a cell rather than a row. The alternative — leaving them — loses nothing + visibly and costs the thing they are for: `Arrived` is not provenance, it + is *a queue's clock*, and a clock left on a row no queue governs is a + live-looking number the next SLA reader picks up. It is already load-bearing + in the negative direction too, and measured: `cmd_route` had to stop writing + `Arrived` onto pipeline rows precisely because a non-empty `arrived` hides + the row from `rows_with_no_computable_age`, which is the finding that says + "this row has no clock at all". So keeping it would suppress the one report + that could notice the move. + + Dropping the CELL is not dropping the FACT. The journal is append-only and + the event carries `stage_from` / `arrived_from`, which is exactly where + `drop` puts the stage a row died in and where every other post-removal + question is already answered from. A move back onto a queue track re-stamps + a fresh clock, and the log says what the old one read. + + `route` keeps a non-queue `Arrived` when it has one and this clears it; + they do not contradict. `route` BIRTHS a row out of an intake row whose + `Arrived` column is its only origin. This moves a row that already has a + whole history somewhere else to be read. + """ + tid = args.id + if not args.track: + raise Refused( + f"--track is required: perry-task track {tid or ''} " + f"--track . This subcommand exists to change that cell") + dest = track_of(ctx["config"], args.track) + section, idx, header, values = task_projection_row(ctx, tid) + was = (values.get("track") or "").strip() or "main" + now = dest["track"] + # A no-op that still emitted an event would put a move in the timeline that + # did not happen, and the timeline is what `list` reports as history. + if was == now: + raise Refused(f"{tid} is already on track {now!r} — nothing to move") + + mode = dest["mode"] + stages = stages_of(ctx["schema"], dest) + check_stage(dest, stages, args.stage) + stage = args.stage or entry_stage(mode, stages) + prev_stage = (values.get("stage") or "").strip() + prev_arrived = (values.get("arrived") or "").strip() + today = f"{date.today():%Y-%m-%d}" + if mode == "queue": + arrived = args.arrived or prev_arrived or today + else: + # The destination does not read it. See the docstring: cleared here, + # recorded on both surfaces that survive. + if args.arrived: + raise Refused( + f"track {now!r} is mode `{mode or 'project'}`, which measures " + f"`Stage since` rather than `Arrived` — there is no clock for " + f"--arrived to set. Drop the flag; the move records the " + f"`Arrived` this row is leaving behind") + arrived = "" + + # Only the cells this move WRITES a value into. A column that does not + # exist holds no stale clock to clear, so widening the board to add an + # empty `Arrived` would cost every row a column to record an absence. + wanted = {"track": now, "stage": stage, + "stage since": today if stage else "", "arrived": arrived} + widen_row_section(ctx["board"], section, + [canonical_column(c) for c, v in wanted.items() if v]) + # Re-locate: widening rewrites the header and every row in place, so the + # values dict has to be refilled against the header that now exists. + section, idx, header, values = task_projection_row(ctx, tid) + values.update(wanted) + line = ctx["board"].replace_row(idx, header, values) + + jline = (f"- [{tid}] track {was} → {now} · mode {mode or 'project'} · " + f"stage {prev_stage or '—'} → {stage or '—'} · " + f"arrived {prev_arrived or '—'} → {arrived or '—'}") + if args.reason: + jline += f" · reason: {args.reason}" + event = {"ts": datetime.now().isoformat(timespec="seconds"), + "event": "track", "id": tid, "title": values.get("title", ""), + # The track the row is NOW on, like every other event's `track`. + # `from`/`to` carry the move itself. + "track": now, "mode": mode, "actor": args.actor, + "field": "track", "from": was, "to": now, + # The four cells this write touched, each with what it replaced. + # A track change nothing recorded is TASK-139's shape; a CLEARED + # clock recorded nowhere is the same defect one field down. + "stage": stage, "stage_from": prev_stage, + "arrived": arrived, "arrived_from": prev_arrived, + "reason": args.reason or ""} + plan = commit(ctx["project_root"], ctx["state_root"], ctx["board"], + jline, event, args.dry_run) + return {"id": tid, "from": was, "to": now, "mode": mode, "stage": stage, + "arrived": arrived, "row": line, **plan} + + def cmd_status(args, ctx) -> dict: """Any status transition the named subcommands do not cover. @@ -4867,9 +5033,9 @@ def perry_conform(): #: 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", "signoff-offer"} -TASK_ROW_COMMANDS = {"start", "stage", "done", "drop", "status", "depends", - "next", "retitle", "summary", "rung", "evidence", - "prioritize"} +TASK_ROW_COMMANDS = {"start", "stage", "track", "done", "drop", "status", + "depends", "next", "retitle", "summary", "rung", + "evidence", "prioritize"} GATED_FILE = "BOARD.md" @@ -4981,9 +5147,9 @@ def parse(argv: list[str]) -> Args: # allowlist drops a future event from the payload, a denylist leaks one into it, # and either way nobody finds out until a consumer reports it. This file has # already lost that bet twice with a tuple and a dict holding "the same" list. -TASK_EVENTS = frozenset({"add", "route", "start", "stage", "status", "next", - "retitle", "summary", "rung", "evidence", "prioritize", - "depends", "done", "drop"}) +TASK_EVENTS = frozenset({"add", "route", "start", "stage", "track", "status", + "next", "retitle", "summary", "rung", "evidence", + "prioritize", "depends", "done", "drop"}) # **What a timeline entry's `from`/`to` pair actually refers to.** One row per # task event, no default — `tests/test_prioritize.py` asserts this map's keys # are exactly `TASK_EVENTS`, so an event cannot be added without declaring what @@ -5005,6 +5171,12 @@ EVENT_FIELD = { "add": "status", "route": "status", "start": "status", "status": "status", "done": "status", "drop": "status", "stage": "stage", + # `track` is a cell of its own and a key of the task payload. It is NOT + # `stage`: a move re-stamps the stage as a consequence, and a consumer told + # the pair was a stage would read `main → intake` as a stage vocabulary it + # cannot find. The stage the move produced rides on the event's own + # `stage` / `stage_from` keys. + "track": "track", "prioritize": "section", "retitle": "title", "summary": "summary", @@ -5114,6 +5286,7 @@ def cmd_events(args, ctx) -> dict: COMMANDS = {"add": cmd_add, "start": cmd_start, "stage": cmd_stage, + "track": cmd_track, "ask": cmd_ask, "answer": cmd_answer, "next": cmd_next, "cadence-add": cmd_cadence_add, "cadence-done": cmd_cadence_done, "risk-add": cmd_risk_add, "risk-clear": cmd_risk_clear, diff --git a/perry/BOARD.md b/perry/BOARD.md index 276a2f5..34aec19 100644 --- a/perry/BOARD.md +++ b/perry/BOARD.md @@ -36,18 +36,20 @@ | TASK-102 | Evidence becomes a typed relation: {path, kind, round}, not one prose cell | Coding Agent | not_started | — | — | V4 | TASK-090, TASK-092 | main | | | | | | | | TASK-114 | aiMark reads Perry through the current contracts instead of a pin nine versions old | Coding Agent | in_progress | delegated to an aiMark coding agent; awaiting paste-back | evidence/2026-08/TASK-114-delegation-prompt.md | V4 | — | main | | | | | | | | TASK-119 | the linkage graph is documented as machine-written and no tool writes it | Coding Agent | not_started | — | — | V3 | | main | | | | | | | -| TASK-120 | the linkage edges are read but never folded into KR progress | Coding Agent | in_progress | dispatched to claude-subagent; worktree pinned to 7c0bb99; state-schema.json scoped out so the gate passes without a release | evidence/2026-08/TASK-120-spec.md | V3 | — | main | | | | | | | -| TASK-121 | the sweep that found four more live-state assertions runs once and then is thrown away | Coding Agent | not_started | — | — | V3 | | main | | | | | | | -| TASK-122 | the repair path the tools advertise leaves the file needing a whitespace fix | Coding Agent | in_progress | dispatched to claude-subagent; worktree pinned to 6c01b93; spec carries a live reproduction | evidence/2026-08/TASK-122-spec.md | V3 | — | main | | | | | | | +| TASK-120 | the linkage edges are read but never folded into KR progress | Coding Agent | review | PR #24 — contract 2.1; four findings handed back, two worth their own rows | evidence/2026-08/TASK-120-dispatch-2026-08-21-result.md | V3 | — | main | | | | | | | +| TASK-121 | the sweep that found four more live-state assertions runs once and then is thrown away | Coding Agent | in_progress | dispatched to claude-subagent; worktree pinned to 5dd745f | evidence/2026-08/TASK-121-spec.md | V3 | — | main | | | | | | | +| TASK-122 | the repair path the tools advertise leaves the file needing a whitespace fix | Coding Agent | review | PR #25 — item 2 proved both ways on one run; two questions handed back | evidence/2026-08/TASK-122-dispatch-2026-08-21-result.md | V3 | — | main | | | | | | | | TASK-123 | the goals writer takes the file as truth and derives the store, which is the opposite direction from the KR | Coding Agent | not_started | — | — | V4 | | main | | | | | | | | TASK-126 | closing the dangling-id row requires writing the record that re-dangles it | Coding Agent | review | PR #22 — the suite is fully green; verify the strong anti-vacuity case survives review, then close at V3 | evidence/2026-08/TASK-126-dispatch-2026-08-21-result.md | V3 | TASK-112 | main | | | | | | | | TASK-129 | Agent is five strings that do not join, and role has never once been written | Coding Agent | not_started | unblocked: work owns .perry/agents.jsonl → .perry/roles/ as of the 2026-08-20 signature; needs a spec, then dispatch | — | V3 | TASK-128 | main | | | | | | | | TASK-135 | a track can be declared but no existing row can be moved onto it | Coding Agent | not_started | — | — | V3 | | main | | | | | | | | TASK-136 | a queue track SLA is parsed, stored and never measured against anything | Coding Agent | not_started | — | — | V3 | | main | | | | | | | | TASK-140 | every mode contract slot is assigned to an axis, and the spine-to-unit map is written down | Coding Agent | review | PR #23 — three open questions for the user, incl. whether an empty illegal-pair list discharges § 7 risk 2 | evidence/2026-08/TASK-140-dispatch-2026-08-21-result.md | V3 | — | main | | | | | | | -| TASK-141 | a row stays blocked after its blockers close, because the stored status masks the computed one | Coding Agent | not_started | — | — | V3 | | intake | triaged | | 2026-08-20 | | | | -| TASK-142 | triage has no check for a row stranded by a process bug, and the one signal that fired was read as prose hygiene | Coding Agent | not_started | design question answered 2026-08-20: it belongs in conformance, which triage already reads at step 0.5 — not as a new triage feature | — | V3 | — | intake | triaged | | 2026-08-20 | | | | -| TASK-143 | two PRs each green on their own base merged into a red tree, and nothing checked the pair | Coding Agent | not_started | — | — | V3 | | intake | triaged | | 2026-08-20 | | | | +| TASK-141 | a row stays blocked after its blockers close, because the stored status masks the computed one | Coding Agent | review | PR #26 — contract 1.12; its REL-00 diagnosis was wrong and is corrected in the record | evidence/2026-08/TASK-141-dispatch-2026-08-21-result.md | V3 | — | intake | triaged | | 2026-08-20 | | | | +| TASK-142 | triage has no check for a row stranded by a process bug, and the one signal that fired was read as prose hygiene | Coding Agent | not_started | read TASK-141 tasks[].blocked_stale rather than recomputing the predicate — a third statement of one rule is the defect TASK-148 is open for | — | V3 | — | intake | triaged | | 2026-08-20 | | | | +| TASK-143 | two PRs each green on their own base merged into a red tree, and nothing checked the pair | Coding Agent | in_progress | dispatched to claude-subagent; worktree pinned to a10f897 | evidence/2026-08/TASK-143-spec.md | V3 | — | intake | triaged | | 2026-08-20 | | | | +| TASK-144 | the event log timestamp has no zone and the register has one, so ordering them is a guess | Coding Agent | not_started | — | — | V3 | | intake | triaged | | 2026-08-20 | | | | +| TASK-148 | two byte-identical copies of the startable rule live 200 lines apart in one file | Coding Agent | not_started | — | — | V3 | | intake | triaged | | 2026-08-21 | | | | ## P2 @@ -68,6 +70,9 @@ | TASK-132 | the parity check cannot see 23 keys because Perry own state leaves four collections empty | Coding Agent | not_started | — | — | V3 | | main | | | | TASK-137 | a new queue row is born in the second stage, not the first | Coding Agent | not_started | — | — | V2 | | main | | | | TASK-139 | a design back-reference lives in a cell the close path clears, so a finished design reports as never handed off | Coding Agent | not_started | — | — | V3 | TASK-102 | intake | triaged | 2026-08-20 | +| TASK-145 | the contract shape baseline is stale against its own recorder | Coding Agent | not_started | — | — | V2 | | intake | triaged | 2026-08-20 | +| TASK-146 | the viewer renders a KR current with no provenance because it does not go through the shared derivation | Coding Agent | not_started | — | — | V3 | | intake | triaged | 2026-08-20 | +| TASK-147 | nothing outside describe_cell proves the table and bullet paths stay separated | Coding Agent | not_started | — | — | V3 | | intake | triaged | 2026-08-21 | ## Cadence (recurring; doesn't consume P0 slots) diff --git a/perry/evidence/2026-08/TASK-120-dispatch-2026-08-21-result.md b/perry/evidence/2026-08/TASK-120-dispatch-2026-08-21-result.md new file mode 100644 index 0000000..64a962a --- /dev/null +++ b/perry/evidence/2026-08/TASK-120-dispatch-2026-08-21-result.md @@ -0,0 +1,106 @@ +# TASK-120 — result + +> Date: 2026-08-21 · Executor: claude-subagent · PR: https://github.com/ranjiao/Perry/pull/24 +> Branch: `coding/task-120-kr-progress-provenance` · Cycle time: ~35 min +> `perry-goals/list` **2.0 → 2.1**, additive: four keys added, none removed or +> retyped. `schema/state-schema.json` **untouched** — verified by diff, and the +> hard stop was live, so this is a real avoidance rather than luck. + +## The shape: Perry exposes the contradiction, it does not resolve it + +One derivation in `bin/lib/__init__.py`, three sibling keys per KR, emitted by +both payloads. Verified on this repository: + +``` +P-O1.1 current 0.0 target 1.0 + provenance state=asserted measured=false source=linkage-register + completion total 4 done 4 open 0 +P-O2.2 current 0.0 target 0.0 + provenance state=asserted measured=false + completion total 2 done 0 open 2 +``` + +`P-O1.1` no longer reads as 0-of-1 progress: it reads as **an author's assertion +of 0 against four closed tasks**, and Perry resolves neither. `P-O2.2` can no +longer be read as met, because nothing claims the zero was measured. + +**No `met` / `achieved` / `progress` / `ratio` key exists** — asserted as an +absence in the tests. No percentage is emitted anywhere: counts only, in their +own unit, so the tally cannot be misread as a metric value. That is the line the +spec drew and it held. + +## What it rejected, and one of the rejections is the interesting one + +- any ratio or percentage; +- a conformance entry for *"`current` disagrees with its tasks"* — that would be + Perry inferring the metric from the edges, which is the forbidden move one + step removed; +- **any new authored field in the register.** `asserted_at` reuses the + register's existing top-level `updated`, and `asserted_scope: "register"` is + emitted so a reader cannot mistake it for a per-KR date. **That choice is what + kept `schema/state-schema.json` out of the change** — the agent found the + cheap path around a gate rather than asking for a release. + +## The tally is what flips P-O1.1, not the staleness check + +Worth recording because it is counter-intuitive: P-O1.1 is **not stale by any +timestamp test** — all four of its tasks closed *before* the register's +`updated`. The contradiction is visible only because the completion tally sits +beside the number. A design that shipped staleness alone would have left that +KR reading exactly as wrongly as before. + +## Staleness, both directions on one fixture + +``` +not stale "no linked task has changed state since 2026-08-15T12:00:00" +stale "1 linked task changed state after 2026-08-15T12:00:00: + TASK-003 (in_progress → done)" + moved_tasks: [{id, from, to, at}] · only that KR goes stale +``` + +The fixture also carries a `next` event dated after the assertion, **so a check +keyed on the event name rather than on whether `to` is a status would redden.** + +## Parity, stated separately as instructed + +`perry-goals/list` **before**: 0 documented-not-emitted, **5** emitted-not-documented. +**After**: 0 and **5** — the same five, byte-identical, TASK-131's, not hidden +inside. Documented 54 → 77, emitted 59 → 78. Repo-wide total unchanged at 17. + +## Four findings handed back + +1. **The `Z` problem, unresolved and documented in the contract.** The register + writes `updated` as ISO with a `Z`; `.perry/events.jsonl` writes `ts` as naive + local time. There is no honest conversion, so the `Z` is **stripped rather + than applied**, and a register written within a few hours of a task move can + order wrongly. Fixing it means deciding what the event log's `ts` means — a + row of its own, touching every consumer. +2. **`tests/fixtures/contract-shapes.json` is stale w.r.t. its own recorder.** + `--record` wants to add an `empty_lists` block to two contracts and drop a + trailing newline. The agent refused to re-record rather than hide unrelated + drift in this row. **Someone will eventually re-record it into an unrelated + diff.** +3. **`viewer/serve.py` renders the chain view from `viewer/parsers.py`**, not + through `bin/lib`, so the viewer still shows `current` with no provenance. +4. **`measured` is `false` everywhere, by construction, until something re-runs + a metric.** Honest — and it means `stale` is Perry's only mechanical opinion + about a KR number. + +## Two notes on this project's own rules + +- The `current: 0` default the spec targeted lives in + `goals/state/linkage_TEMPLATE.md:13`, which writes it into every new register. + That is **authoring**, belongs to TASK-119's writer, and was correctly not + touched. `_num()` already returned `None` for an unwritten value, so V3 item 2 + was true but untested; it is now locked by four tests. +- **TASK-091's Definition of Done makes `bin/` a place where the history of that + defect cannot be written down.** The agent's first draft of a comment named the + deleted symbol and reddened `test_goals_writer`; it rewrote the comment to + describe the symbol without naming it. Same family as TASK-126 and TASK-112 — + a guard that forbids describing the thing it guards. + +## Process error, mine + +The worktree was cut from `feat/work-modes` **before** I committed the spec, so +the agent had to fetch it with `git checkout e71d7c0 -- `. Corrected for +TASK-122 and TASK-141: commit the spec, *then* cut the worktree. diff --git a/perry/evidence/2026-08/TASK-121-spec.md b/perry/evidence/2026-08/TASK-121-spec.md new file mode 100644 index 0000000..df9c78f --- /dev/null +++ b/perry/evidence/2026-08/TASK-121-spec.md @@ -0,0 +1,85 @@ +# TASK-121 — the sweep that finds checks reading live state runs once and is thrown away + +> Source: `perry/evidence/2026-08/TASK-113-dispatch-2026-08-20-1813.md` +> Dispatch mode: auto +> Executor: claude-subagent +> Estimated cycle: medium +> Subjective verification: no +> Touches architecture: no — it adds a guard over the test suite +> Deployed: no + +## Schema + +- **Owner**: Coding Agent +- **Priority**: P1 +- **Attribution**: unlinked + +## The class, and its eight known instances + +A check that reads **the project living around it** as its expected value goes +red on ordinary progress, and green again for reasons that have nothing to do +with what it measures. Every instance below is real and dated within four days: + +| # | check | what moved under it | +|---|---|---| +| 1–3 | `test_diagnose`, `test_one_line_break_rule`, `test_v5_signoff` | TASK-113 found and fixed three | +| 4 | a fourth, handed over mid-run | same row | +| 5 | a fifth the agent found itself — `DESIGN-900` | same row | +| 6 | `test_md_store § test_config_including_its_prose_section` | asserted every config record is a `setting`; **declaring one track reddened it** | +| 7 | `test_track_attribution § TestPerrysOwnProjectIsUnmoved` | asserted Perry itself has no track register; same declaration reddened it | +| 8 | `test_state_cost` ×2 | asserted `perry/tasks.jsonl` is unclaimed and `.perry/events.jsonl` rolls up under `.perry/` — **both true until PR #14 declared the two store files owned** | + +TASK-113 fixed instances 1–5 **by hand, in one pass, and the pass was thrown +away.** Instances 6–8 arrived afterwards. There is no mechanism; there is a +memory of having looked. + +## Deliverable + +A guard that finds this class **mechanically**, so the next instance is reported +rather than discovered by a human running the suite after a merge. + +**What "this class" is, precisely, is the hard part of this row** — and getting +it wrong in either direction makes the guard worthless: + +- too broad, and it flags every test that reads a fixture, which is all of them; +- too narrow, and it is a list of the eight above wearing a regex. + +The instances give you the shape to generalise from: each one asserted a +**literal about the project's current state** — a count, an id, a set membership, +a filename — where the *property* being tested was true independently of that +literal. Note that instance 8's literals were about **which paths the schema declares +Perry owns**, not about a board row — so a guard keyed only on `BOARD.md` or the +task store would have missed it. + +Report what you decided the class is, in the guard's own docstring, in the voice +of the surrounding modules — and **name what it deliberately does not catch.** + +## Verification — V3 + +1. **It finds instances it was not shown.** Reconstruct at least three of the + eight from git history — `test_md_store` and `test_track_attribution` before + their 2026-08-21 fixes, and one of TASK-113's — and show the guard flags them. + Reconstruct, do not hand-write an approximation. +2. **It does not flag the fixes.** The same three, after their repairs, are + clean. A guard that still flags the repaired form is measuring the wrong + thing. +3. **False-positive floor, stated as a number.** Run it over the whole suite as + it stands and report **every** hit. If the count is not zero, each survivor + is either a real instance — open a row for it — or a false positive you must + name and explain. **Do not silence one to reach zero.** +4. **Reverting the guard reddens its own test.** +5. `python3 tests/parallel -j 4`, `bash tests/run`, `python3 bin/perry-lint`, + `git diff --check`. + +## Files in scope + +- the guard, as a new test module or a check under `tests/` +- its own tests and fixtures + +## Out of scope + +- **Fixing any instance you find.** Report them; each is its own row. This row + ships the mechanism, not the repairs. +- `bin/perry-diagnose` and `tests/test_diagnose.py` — an unmerged branch (PR #22) + is editing both. Cutting across it would conflict. +- `perry/` — no project state changes; `git diff -- perry/` must end empty. diff --git a/perry/evidence/2026-08/TASK-122-dispatch-2026-08-21-result.md b/perry/evidence/2026-08/TASK-122-dispatch-2026-08-21-result.md new file mode 100644 index 0000000..602635e --- /dev/null +++ b/perry/evidence/2026-08/TASK-122-dispatch-2026-08-21-result.md @@ -0,0 +1,105 @@ +# TASK-122 — result + +> Date: 2026-08-21 · Executor: claude-subagent · PR: https://github.com/ranjiao/Perry/pull/25 +> Branch: `coding/task-122-bullet-padding` · Cycle time: ~50 min +> Code diff **2 files, +129/−5** — `bin/perry_store.py` +24/−5, +> `tests/test_md_store.py` +105/−0. (PR reports 32 files: the unpushed-ancestor +> sweep, fourth occurrence.) + +## The rule, and where the why is written + +> **"A table cell has boundaries; a bullet slot has neighbours"** — so only a +> cell may be handed padding it did not come with. + +`render_line` joins cells on `|`, which carries no whitespace of its own, so a +cell arriving as `single` must leave as `| split |`. A bullet slot is joined on +`""` between literal spans that **already hold every character around it** — the +span before the slot in `- Repo layout: single` is `'- Repo layout: '`, +separator space included. + +Mechanically it is **one variable**: `pad = " " if escape else ""`, used at both +places that invented padding — the disagreement branch (the reproduction) and the +whitespace-only branch. No new function, no branch keyed on a field name; the +same `escape` seam that already decided pipe-escaping now decides one more thing. + +It also **corrected a docstring that had gone false**: `cell_text` claimed +escaping was *"the ONE thing that differs between a table cell and a bullet +slot"*. Leaving that would be the stale prose that lets the next reader +re-introduce the padding. + +## Item 2, both ways, on one run + +Reverting **only** the rule, tests untouched: + +| reddened (3, all bullet) | did **not** redden (5, incl. the whole cell side) | +|---|---| +| `..._renders_byte_exact` | `test_a_table_cell_that_lost_its_padding_is_still_given_it_back` | +| `..._ends_without_a_trailing_space` | `test_a_track_row` (a board cell that disagrees) | +| `test_the_advertised_repair_survives_git_diff_check` | `test_a_config_setting`, both blank-marker tests | + +The sharpest case-2 test is the first in the right column: it is the +**padding-invention path itself**, `single` → `| split |`. It stayed green. +**The two paths are separated by `escape`; there is no bigger finding here.** + +The revert's own output is the bug verbatim: + +``` +AssertionError: Tuples differ: + (2, '.perry/config.md:6: trailing whitespace.\n+- State root: perry \n', '') + != (0, '', '') +``` + +## Item 4, run end to end rather than asserted + +A real `.perry/config.md` copied to a temp git repo, **declared through +`perry-conform declare`** — an earlier attempt with a bogus gate line was +correctly refused by the ADR-004 gate and redone — store written, drift planted, +then the advertised repair: + +``` +perry-config: rendered .../.perry/config.md from 9 stored record(s) +git diff --check exit=0 (silent) +6:- State root: perry$ ← cat -et; the $ is end-of-line, no trailing space +``` + +It is now a test, **and it asserts the value was actually restored** — otherwise +a clean `--check` would just be the cleanliness of a file nothing happened to. + +## One deliberate change beyond the reproduction + +A bullet whose slot is whitespace-only and whose store gains a value: +`- Code repo path: ` → before `- Code repo path: value ` (trailing space), after +`- Code repo path: value`. It kept the input's own space (`pad or raw`) rather +than dropping it, so the line neither gains whitespace nor loses what the author +wrote. **No file in the repo exercises this** — Perry's config uses `—` for +empties — but leaving that branch on the old rule would have made the fix a patch +instead of a rule. + +## It corrected the PMO, and the PMO was wrong + +The dispatch prompt said `test_diagnose`'s red *"has since been fixed on a +sibling branch"*. **It has not.** TASK-126's fix is on PR #22, **unmerged**, so +`feat/work-modes` still carries the red and every worktree cut from it inherits +it. The agent measured its own baseline, found the red, and **reported it rather +than absorbing it** — which is the behaviour every dispatch prompt asks for, +applied to the prompt itself. + +Worse, confirmed afterwards: the list is now +`['DESIGN-900', 'REL-00', 'ZZZ-404']`. **`ZZZ-404` came from +`TASK-126-spec.md` and `TASK-126-dispatch-2026-08-21-result.md`, both written by +the PMO** — the anti-vacuity example quoted in them. Writing the record about the +self-reference defect added a third instance of it. Third occurrence today; +first one caused while documenting the fix. + +## Two questions handed back + +1. A bullet slot genuinely empty in the source — `- Code repo path:` with no + space — renders `- Code repo path:value`. Faithful to *no whitespace the input + did not have*, and ugly. Unreachable from any current file; if a + `- Label: value` shape is wanted, that is a **normalization** rule and belongs + with whoever owns the config's shape, not in a renderer whose contract is + byte-comparison. +2. Nothing outside `describe_cell` proves the two paths stay separated. If a + third caller ever passes `escape=False` for a reason other than *"not in a + table"*, the flag's two meanings come apart — worth a conformance-style check + that the only producers of `escape=False` are slot descriptors. diff --git a/perry/evidence/2026-08/TASK-127-dispatch-2026-08-20-2045.md b/perry/evidence/2026-08/TASK-127-dispatch-2026-08-20-2045.md index 67972f3..158f414 100644 --- a/perry/evidence/2026-08/TASK-127-dispatch-2026-08-20-2045.md +++ b/perry/evidence/2026-08/TASK-127-dispatch-2026-08-20-2045.md @@ -51,10 +51,17 @@ one**, which was the right call. 5. A sixth contract added declaring one unemitted key, **with no edit to the check** → `discovered: 6` and that key reported -Mutation 2 is worth keeping: the first attempt planted the key on a **dead** -board-era path around line 4502 and produced nothing. `bin/perry-task` carries -two independent `startable` computations and only the store-backed one at ~4724 -is reachable. +Mutation 2 is worth keeping: the first attempt planted the key on a board-era +path around line 4502 and produced nothing. `bin/perry-task` carries two +independent `startable` computations. + +> **Corrected 2026-08-21 by TASK-141.** This record originally called that +> board-era path **dead** and said only the store-backed one at ~4724 was +> reachable. **That is wrong**: `_cmd_list_from_board` is called at +> `bin/perry-task:1525`. Both copies are live — which is why TASK-141 had to fix +> both, and why TASK-148 is open to collapse them into one. The mutation +> produced nothing because *that run* did not exercise the board path, not +> because the path is dead. ## The check's honest limit, stated by its author diff --git a/perry/evidence/2026-08/TASK-135-spec.md b/perry/evidence/2026-08/TASK-135-spec.md new file mode 100644 index 0000000..77d6a97 --- /dev/null +++ b/perry/evidence/2026-08/TASK-135-spec.md @@ -0,0 +1,89 @@ +# TASK-135 — a track can be declared but no existing row can be moved onto it + +> Source: `perry/evidence/2026-08/TASK-133-track-experiment.md` +> Dispatch mode: auto +> Executor: claude-subagent +> Estimated cycle: small +> Subjective verification: no +> Touches architecture: no — one subcommand, on a field the row already carries +> Deployed: no + +## Schema + +- **Owner**: Coding Agent +- **Priority**: P1 +- **Attribution**: KR-O1.1 (`perry/OKR.md` v2, Objective 1) + +## Measured 2026-08-20 + +`--track` is accepted by exactly two commands: `add`, at creation, and `route`, +which turns an intake row into a task. There is **no** `perry-task track `, +and `status --track` is refused. + +So a project that declares a second track **starts it empty and cannot move any +existing work onto it.** This repository declared `intake` (mode `queue`) that +day and it is still empty, while the six rows that genuinely arrived rather than +being decomposed — TASK-124, 125, 126, 130, 131, 132 — belong on it and cannot +get there. + +That is why KR-O1.1 was **not** met by the declaration: a track with no rows is +not a mode running on a live track. + +## Deliverable + +An existing row can change track. `--track` is accepted by a subcommand that +operates on a row that already exists — not only at creation and not only from +intake. + +Two behaviours the move must get right, because `route` already does and a +second path that does not would make `Arrived` mean one thing per entrance: + +1. **Moving onto a queue-mode track stamps `Arrived` and the track's first + post-intake `Stage`**, the same way `route` does. `today − Arrived` is the + number every SLA check measures, so a move that omits it silently exempts the + row from the only clock governing it. +2. **Moving off a track does not silently strand those fields.** Decide what + happens to `Stage` and `Arrived` when the destination track has no use for + them, and say why in the code — dropping them loses history, keeping them + leaves a queue's clock on a row no queue governs. + +A move to a track the project has not declared is **refused by name**, listing +the declared tracks — the same shape as `delegate`'s refusal for an undeclared +role card. + +## Verification — V3 + +1. **Six rows move.** Against a fixture whose register declares a `queue` track, + six existing rows move onto it and the board renders them with `Arrived` and + `Stage` populated; `perry-state --json` reports them under that track's + `stage_counts`. +2. **The refusal is by name.** Moving to an undeclared track names the track and + lists the declared ones; it does not create it and does not fail with a + traceback. +3. **Both directions of the field question.** Whatever you decided in + deliverable 2, a fixture proves it: move onto a queue track, then off it, and + assert the resulting record — including the case where the row had an + `Arrived` before the move. +4. **An event is written per move.** A track change nothing recorded is + TASK-139's shape, and this project has that row open already. +5. **`perry-lint` reports no drift after a move** — the board is a projection, + so a move that leaves the rendered file disagreeing with the store is a + half-landed write. +6. `python3 tests/parallel -j 4`, `bash tests/run`, `python3 bin/perry-lint`, + `git diff --check`. + +## Files in scope + +- `bin/perry-task` +- `schema/task-list-contract.md` **only if** the payload changes shape; adding a + subcommand does not. +- focused tests and fixtures + +## Out of scope + +- **Moving the six live rows.** This row ships the tool; using it on this + project's board is a separate act the user takes. `git diff -- perry/` must + end empty. +- Declaring or changing a track's mode — that is `.perry/config.md`, the user's + file. +- `conformance` checks about tracks (TASK-142's neighbourhood). diff --git a/perry/evidence/2026-08/TASK-141-dispatch-2026-08-21-result.md b/perry/evidence/2026-08/TASK-141-dispatch-2026-08-21-result.md new file mode 100644 index 0000000..31fe6f4 --- /dev/null +++ b/perry/evidence/2026-08/TASK-141-dispatch-2026-08-21-result.md @@ -0,0 +1,111 @@ +# TASK-141 — result + +> Date: 2026-08-21 · Executor: claude-subagent · PR: https://github.com/ranjiao/Perry/pull/26 +> Branch: `coding/task-141-stale-blocked` · Cycle time: ~30 min +> Code diff **8 files, +341/−17**. `perry-task/list` **1.11 → 1.12**, with a +> `semantics` entry — because a changed *meaning* is not covered by "1.x only +> adds keys", and the suite's own `test_a_minor_bump_carries_a_semantics_entry` +> enforces that. + +## Option 2, and the argument for the one it did not take + +`startable` stops letting a stored status mask an empty `blocked_by`, and the +payload names the disagreement as a new `tasks[].blocked_stale`. **The stored +status is left alone** — `list` reports, it does not rewrite a cell nobody asked +it to rewrite — so a stale row still *reads* `blocked` until somebody acts. That +cost is stated in the contract. + +What option 1 would have cost, in its words: it writes state the user did not +ask for, on rows the user did not name, as a side effect of closing a different +row — and verification item 4 then binds it to an event per write, so it becomes +a write-path change with a log-format obligation attached (TASK-139's shape), +not one computation. **And it fixes nothing already on disk**: boards currently +stale stay stale until each row is closed again, and any store written by hand, +imported or migrated re-creates the condition. + +> They are complementary, not alternatives — if option 1 ever lands, +> `blocked_stale` is what verifies it. + +## Both directions, from one payload built by the store's own writer + +``` +TASK-002 blocked depends_on=['TASK-001'] blocked_by=[] startable=True blocked_stale=True +TASK-004 blocked depends_on=['TASK-003'] blocked_by=['TASK-003'] startable=False blocked_stale=False +``` + +On the **pre-fix** tree the same fixture reproduces the original measurement +exactly — `TASK-002 blocked_by=[] startable=False`. Item 2 satisfied against +real pre-fix code rather than a recollection. + +## The mutation that matters is not the obvious one + +Reverting `startable` reddened exactly the two new tests, and **not** the +open-dependency test nor either boundary test. + +Then it ran the *wrong fix* — deleting the status check outright — and found +something worth keeping: + +> It reddened exactly the two boundary tests (a `blocked` row declaring no +> dependency; a `review` row). **It did NOT redden the open-dependency test**, +> because that direction is guarded by `blocked_by` itself, which the wrong fix +> leaves intact. + +So the test the spec leaned on for "don't just delete the check" would **not** +have caught the deletion. The boundary tests are what actually catch it. + +## The spec named one line; there were two + +`bin/perry-task` carries **two byte-for-byte copies** of the `startable` +computation, ~200 lines apart — `_cmd_list_from_board` (≈4506) and `cmd_list` +(≈4728). It fixed both. + +**This corrects a claim recorded in TASK-094's result**, which said only the +store-backed one was reachable. Verified here: `_cmd_list_from_board` is called +at `bin/perry-task:1525`. **Both are live**, and the earlier note in +`TASK-094-dispatch-2026-08-20-1958.md` is wrong on that point. + +Opened as a question by the agent: the next change to this rule has the same +two-readers-of-one-rule failure mode the contract document itself warns about. + +## Live board unchanged + +TASK-050 and TASK-067 still report `startable=False`, `blocked_stale=False`. +TASK-037/045 were unblocked by hand in `4c7e841`, so the live board carries no +stale row and this change is a **read no-op** on it. `git diff -- perry/` empty. + +## Its diagnosis of `REL-00` is wrong, and this is recorded because it matters + +The agent reported the baseline `test_diagnose` red as *"`REL-00` is a truncated +match against `REL-002` in README.md/SKILL.md prose"*. **It is not.** Tested +directly: + +``` +a file containing only REL-002 → harvested: ['REL-002'] no truncation +a file containing only REL-00 → harvested: ['REL-00'] a literal +``` + +The real source is a **literal bare `REL-00`**, written by the PMO into +`perry/journal/2026-08/2026-08-20.md:821` inside a V5 sign-off — *"the +dangling-id check reports [] — TASK-107 resolves and REL-00 is gone"* — and +since re-quoted in TASK-113's and TASK-126's records. Same self-reference class +as `DESIGN-900` and `ZZZ-404`, not a scanner defect. + +Reporting the red rather than absorbing it was right; the diagnosis attached to +it was not, and taking it at face value would have sent someone to fix a +non-existent truncation bug in `perry-explain`. + +## A flake, reported not absorbed + +`test_host_support § test_concurrent_mixed_registers_do_not_exceed_global_cap` +failed once under load — asserted 3 concurrent dispatches, got 2 — then passed +3/3 in isolation and in every other run. **Three agents at 4 workers each on 14 +cores.** Load-sensitive, not a regression; the same shared-cache race seen on +2026-08-20. + +## Contract + +1.11 → 1.12 with a `semantics` entry. Key parity unchanged at 0 / 17 — the new +key was documented in the same edit. `blocked_stale` is computed on every read +and **never lands in `tasks.jsonl`**: `perry_store.record()`'s fixed `STORED` +allowlist forbids it and a test pins that. A stored copy would go stale the same +way the status did, which is the bug being fixed. diff --git a/perry/evidence/2026-08/TASK-143-spec.md b/perry/evidence/2026-08/TASK-143-spec.md new file mode 100644 index 0000000..e2cff98 --- /dev/null +++ b/perry/evidence/2026-08/TASK-143-spec.md @@ -0,0 +1,85 @@ +# TASK-143 — two PRs each green on their own base merged into a red tree + +> Source: `.github/workflows/ci.yml`, and the merge that proved it +> Dispatch mode: auto +> Executor: claude-subagent +> Estimated cycle: small +> Subjective verification: no +> Touches architecture: no — one CI job's checkout, plus whatever it takes to +> report which pair disagreed +> Deployed: no + +## Schema + +- **Owner**: Coding Agent +- **Priority**: P1 +- **Attribution**: unlinked + +## Measured 2026-08-21, on the merge that had just happened + +`PR #14` (TASK-100) put both store files into `claims[]` at `e3f8621`. +`PR #15` (TASK-110) shipped `tests/test_state_cost.py` asserting the +**pre-claim** world — `perry/tasks.jsonl (unclaimed)` present, and +`.perry/events.jsonl` rolling up under the `.perry/` row. + +Each PR was **green on its own base.** The merged tree had **two red tests +neither PR could have seen**, and nobody found out until a human ran the suite +after the fact. + +The workflow is: + +```yaml +.github/workflows/ci.yml +on: + push: { branches: [main] } + pull_request: +``` + +A `pull_request` event checks out the **merge result** by default in GitHub +Actions — so the shape of the fix is not necessarily "check out something +different". **Establish what this workflow actually tested for #14 and #15 +before changing anything**; if the merge result was already what ran, the defect +is that each PR was tested against a base that then moved, and the fix is a +re-check at merge time rather than a different checkout. + +**Do not assume the diagnosis. Reproduce it.** + +## Deliverable + +A merge into the integration branch is checked against the **merged result**, +not only against each PR's own base — and when a pair disagrees, the report says +**which pair**, not just that something is red. + +Whether that is a workflow change, a job that re-runs on the branch tip, or a +required check that re-evaluates when the base moves, is yours to determine from +what you find. State the mechanism you rejected and why. + +## Verification — V3 + +1. **Reconstruct the pair.** From this repository's history, build the two + commits — the `claims[]` addition and the pre-claim test — and show your + mechanism **reports red before the merge lands**, where the old one did not. + This is the whole row; a change that cannot reproduce the original miss has + not been shown to fix it. +2. **A pair that genuinely does not interact still passes.** Two independent + changes must not be reported as conflicting. Without this the mechanism is + "always re-run everything and hope", which is not a check. +3. **The report names the pair.** Not "the suite is red" — which PR's change, + against which other, produced it. If the mechanism cannot attribute, say so + plainly rather than shipping a signal nobody can act on. +4. `python3 tests/parallel -j 4`, `python3 bin/perry-lint`, `git diff --check`. + +## Files in scope + +- `.github/workflows/ci.yml` +- a helper script under `tests/` if the mechanism needs one +- documentation of the mechanism where a contributor will meet it + +## Out of scope + +- **`perry/` — no project state changes.** `git diff -- perry/` must end empty. +- Fixing the two `test_state_cost` assertions. They were repaired on + `feat/work-modes` at `13cfe2f`; you are preventing the class, not that instance. +- Any change to what the suite runs, or to `tests/parallel` and `tests/run`. +- Branch protection settings and anything requiring repository admin — if the + honest fix needs one, **say so and stop**; that is the user's to apply. diff --git a/perry/journal/2026-08/2026-08-20.md b/perry/journal/2026-08/2026-08-20.md index c4a97a5..f7c43a8 100644 --- a/perry/journal/2026-08/2026-08-20.md +++ b/perry/journal/2026-08/2026-08-20.md @@ -179,6 +179,15 @@ - [TASK-122] evidence · — → evidence/2026-08/TASK-122-spec.md - [TASK-140] in_progress → review - [TASK-140] evidence · evidence/2026-08/TASK-140-dispatch-2026-08-21.md → evidence/2026-08/TASK-140-dispatch-2026-08-21-result.md +- [TASK-141] not_started → in_progress · started +- [TASK-141] evidence · — → evidence/2026-08/TASK-141-spec.md +- [TASK-120] in_progress → review +- [TASK-120] evidence · evidence/2026-08/TASK-120-spec.md → evidence/2026-08/TASK-120-dispatch-2026-08-21-result.md +- [TASK-144] — → not_started · the event log timestamp has no zone and the register has one, so ordering them is a guess · owner: Coding Agent · priority: P1 +- [TASK-145] — → not_started · the contract shape baseline is stale against its own recorder · owner: Coding Agent · priority: P2 +- [TASK-146] — → not_started · the viewer renders a KR current with no provenance because it does not go through the shared derivation · owner: Coding Agent · priority: P2 +- [TASK-143] not_started → in_progress · started +- [TASK-143] evidence · — → evidence/2026-08/TASK-143-spec.md ## Notes @@ -763,6 +772,39 @@ - **Out of scope**: — - **KR linkage**: unlinked +### TASK-144 — the event log timestamp has no zone and the register has one, so ordering them is a guess + +- **Owner**: Coding Agent +- **Priority**: P1 +- **Track / mode**: intake / queue +- **Deliverable**: what .perry/events.jsonl ts means is decided and written down — naive local, UTC, or offset-carrying — and every producer and consumer agrees, so a register updated field and an event can be ordered without stripping a zone +- **Verification**: measured 2026-08-21 by TASK-120: the register writes updated as ISO with a Z and the event log writes ts as naive local with none, so the Z is stripped rather than applied and a register written within hours of a task move can order wrongly. After: a fixture whose register assertion and task move straddle a zone boundary orders correctly, and reverting reddens it +- **Dependencies**: — +- **Out of scope**: — +- **KR linkage**: unlinked + +### TASK-145 — the contract shape baseline is stale against its own recorder + +- **Owner**: Coding Agent +- **Priority**: P2 +- **Track / mode**: intake / queue +- **Deliverable**: tests/fixtures/contract-shapes.json matches what --record produces, so re-recording it is a no-op and no unrelated drift can ride into a future diff +- **Verification**: measured 2026-08-21 by TASK-120: --record wants to add an empty_lists block to perry-decide/list and perry-task/list and to drop the file trailing newline. After: --record produces a byte-identical file; introducing a real shape change still makes it differ +- **Dependencies**: — +- **Out of scope**: — +- **KR linkage**: unlinked + +### TASK-146 — the viewer renders a KR current with no provenance because it does not go through the shared derivation + +- **Owner**: Coding Agent +- **Priority**: P2 +- **Track / mode**: intake / queue +- **Deliverable**: viewer/serve.py chain view shows a KR current together with whether it was asserted or measured and whether it has gone stale, by reading perry-state --json or the shared bin/lib derivation rather than re-deriving from viewer/parsers.py +- **Verification**: measured 2026-08-21 by TASK-120: the viewer reads viewer/parsers.py and never touches bin/lib, so P-O1.1 renders as 0-of-1 with no sign that the 0 is an assertion contradicted by four closed tasks. After: the same KR renders its provenance, and a KR whose assertion is stale is visibly marked +- **Dependencies**: — +- **Out of scope**: — +- **KR linkage**: unlinked + ## V5 sign-off **TASK-107 — V5 sign-off. Ran Jiao, 2026-08-20.** diff --git a/perry/journal/2026-08/2026-08-21.md b/perry/journal/2026-08/2026-08-21.md new file mode 100644 index 0000000..981a54b --- /dev/null +++ b/perry/journal/2026-08/2026-08-21.md @@ -0,0 +1,37 @@ +# 2026-08-21 + +## Status changes + +- [TASK-122] in_progress → review +- [TASK-122] evidence · evidence/2026-08/TASK-122-spec.md → evidence/2026-08/TASK-122-dispatch-2026-08-21-result.md +- [TASK-147] — → not_started · nothing outside describe_cell proves the table and bullet paths stay separated · owner: Coding Agent · priority: P2 +- [TASK-121] not_started → in_progress · started +- [TASK-121] evidence · — → evidence/2026-08/TASK-121-spec.md +- [TASK-141] in_progress → review +- [TASK-141] evidence · evidence/2026-08/TASK-141-spec.md → evidence/2026-08/TASK-141-dispatch-2026-08-21-result.md +- [TASK-148] — → not_started · two byte-identical copies of the startable rule live 200 lines apart in one file · owner: Coding Agent · priority: P1 +- [TASK-142] next action · read TASK-141 tasks[].blocked_stale rather than recomputing the predicate — a third statement of one rule is the defect TASK-148 is open for + +## New tasks added + +### TASK-147 — nothing outside describe_cell proves the table and bullet paths stay separated + +- **Owner**: Coding Agent +- **Priority**: P2 +- **Track / mode**: intake / queue +- **Deliverable**: a conformance-style check that the only producers of escape=False are slot descriptors, so the flag cannot quietly acquire a second meaning: today it answers both is this inside a table and may this be handed padding it did not come with, and a third caller passing it for some other reason splits those two apart with nothing to notice +- **Verification**: measured 2026-08-21 by TASK-122: the new padding class proves the separation for padding only, and no check covers the flag itself. After: introducing a caller that passes escape=False from outside a slot descriptor reddens, and the existing legitimate producers do not +- **Dependencies**: — +- **Out of scope**: — +- **KR linkage**: unlinked + +### TASK-148 — two byte-identical copies of the startable rule live 200 lines apart in one file + +- **Owner**: Coding Agent +- **Priority**: P1 +- **Track / mode**: intake / queue +- **Deliverable**: the startable computation is stated once and both list paths call it, so the next change to the rule cannot land in one copy and miss the other +- **Verification**: measured 2026-08-21 by TASK-141: bin/perry-task carries the rule at roughly line 4506 inside _cmd_list_from_board and again at roughly 4728 inside cmd_list, and both are reachable — _cmd_list_from_board is called at line 1525, which also corrects TASK-094 result claiming only the store-backed one was live. After: one definition, both callers, and changing the rule in the single place changes both payloads; a test pins that no second copy exists +- **Dependencies**: — +- **Out of scope**: — +- **KR linkage**: unlinked diff --git a/perry/tasks.jsonl b/perry/tasks.jsonl index 086c162..f5e4e83 100644 --- a/perry/tasks.jsonl +++ b/perry/tasks.jsonl @@ -104,7 +104,6 @@ {"id": "TASK-115", "title": "two guards on the read-only report have a hole beside them, found by mutation not by review", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-115-dispatch-2026-08-20-1751.md", "next_action": "dispatched 17:27 via claude-subagent; worktree pinned to feat/work-modes; awaiting completion", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-20T17:15:57", "order": null} {"id": "TASK-119", "title": "the linkage graph is documented as machine-written and no tool writes it", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T17:58:10", "order": 7} {"id": "TASK-113", "title": "three checks read the project living around them, so ordinary progress turns them red", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-113-dispatch-2026-08-20-1813.md", "next_action": "dispatched 17:09 via claude-subagent; worktree pinned to feat/work-modes; awaiting completion", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T17:04:40", "order": null} -{"id": "TASK-121", "title": "the sweep that found four more live-state assertions runs once and then is thrown away", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T18:13:04", "order": 9} {"id": "TASK-092", "title": "OKR.md and .perry/config.md become stores with renderers", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "evidence/2026-08/TASK-092-dispatch-2026-08-20-1654.md", "next_action": "V4 review re-dispatched after the first reviewer stalled at 600s; PR #16 is merged and nine rows wait on this row closing", "depends_on": ["TASK-090"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:50", "order": null, "summary": ""} {"id": "TASK-123", "title": "the goals writer takes the file as truth and derives the store, which is the opposite direction from the KR", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T18:30:45", "order": 11} {"id": "TASK-110", "title": "measure what Perry state costs and propose a retention policy, deleting nothing", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-110-dispatch-2026-08-20-1725.md", "next_action": "the user decides the retention policy; the measurement says the whole proposal recovers 1.4 days of growth", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T16:23:45", "order": null} @@ -127,14 +126,20 @@ {"id": "TASK-094", "title": "Delete the header rule and the row splitter for the three stores", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-094-dispatch-2026-08-20-1958.md", "next_action": "PR #20 merged but the row does NOT close on it: verification item 1 asked for 0 call sites and BOARD.md keeps 13 splits / 87 resolutions on four storeless registers — needs a scope decision, not a close", "depends_on": ["TASK-090", "TASK-092"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-19T10:27:51", "order": 1, "summary": ""} {"id": "TASK-037", "title": "perry-goals writer", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "unblocked: TASK-092 closed 2026-08-20. Re-scope per its own note — flag naming and the module-scope handler defect only; the rest was overtaken by TASK-092 and TASK-123", "depends_on": ["TASK-092"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-17T00:55:19", "order": 0, "summary": ""} {"id": "TASK-045", "title": "Retire the runtime tolerance branches, behind the conformance marker", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V4", "evidence": "—", "next_action": "unblocked: the whole chain closed — TASK-044 and TASK-047 are both done. The conformance marker enforces on this branch, which is the precondition this row was waiting for", "depends_on": ["TASK-044", "TASK-047"], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-17T15:59:34", "order": 2, "summary": ""} -{"id": "TASK-141", "title": "a row stays blocked after its blockers close, because the stored status masks the computed one", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-20", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T21:21:09", "order": 17} -{"id": "TASK-142", "title": "triage has no check for a row stranded by a process bug, and the one signal that fired was read as prose hygiene", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-20", "verification": "V3", "evidence": "—", "next_action": "design question answered 2026-08-20: it belongs in conformance, which triage already reads at step 0.5 — not as a new triage feature", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T21:24:30", "order": 18} -{"id": "TASK-143", "title": "two PRs each green on their own base merged into a red tree, and nothing checked the pair", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-20", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T23:09:32", "order": 19} {"id": "TASK-100", "title": "tasks.jsonl is in no claims[] entry, so a namespace collision on it cannot be reported", "owner": "Coding Agent", "status": "done", "priority": "P2", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-100-dispatch-2026-08-20-1730.md", "next_action": "PR merged; ready to close once origin is merged down and the suite re-run locally", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-19T11:44:02", "order": null, "summary": ""} {"id": "TASK-111", "title": "a test reads two files outside the repository, so it is green here and red on CI forever", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-111-dispatch-2026-08-20-1930.md", "next_action": "PR merged; ready to close once origin is merged down and the suite re-run locally", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T16:38:31", "order": null} {"id": "TASK-127", "title": "the contract docs and the payloads they describe are never diffed against each other", "summary": "", "owner": "Coding Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-127-dispatch-2026-08-20-2045.md", "next_action": "PR merged; ready to close once origin is merged down and the suite re-run locally", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T19:57:28", "order": null} {"id": "TASK-133", "title": "declare the first non-project track on Perry itself, and measure what a mixed spine costs", "summary": "", "owner": "User + Agent", "status": "done", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-133-track-experiment.md", "next_action": "PR merged; ready to close once origin is merged down and the suite re-run locally", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T20:29:08", "order": null} -{"id": "TASK-120", "title": "the linkage edges are read but never folded into KR progress", "summary": "", "owner": "Coding Agent", "status": "in_progress", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-120-spec.md", "next_action": "dispatched to claude-subagent; worktree pinned to 7c0bb99; state-schema.json scoped out so the gate passes without a release", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T17:58:10", "order": 8} {"id": "TASK-126", "title": "closing the dangling-id row requires writing the record that re-dangles it", "summary": "", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-126-dispatch-2026-08-21-result.md", "next_action": "PR #22 — the suite is fully green; verify the strong anti-vacuity case survives review, then close at V3", "depends_on": ["TASK-112"], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T19:57:10", "order": 12} -{"id": "TASK-122", "title": "the repair path the tools advertise leaves the file needing a whitespace fix", "summary": "", "owner": "Coding Agent", "status": "in_progress", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-122-spec.md", "next_action": "dispatched to claude-subagent; worktree pinned to 6c01b93; spec carries a live reproduction", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T18:30:44", "order": 10} {"id": "TASK-140", "title": "every mode contract slot is assigned to an axis, and the spine-to-unit map is written down", "summary": "", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-140-dispatch-2026-08-21-result.md", "next_action": "PR #23 — three open questions for the user, incl. whether an empty illegal-pair list discharges § 7 risk 2", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T21:08:52", "order": 16} +{"id": "TASK-120", "title": "the linkage edges are read but never folded into KR progress", "summary": "", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-120-dispatch-2026-08-21-result.md", "next_action": "PR #24 — contract 2.1; four findings handed back, two worth their own rows", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T17:58:10", "order": 8} +{"id": "TASK-144", "title": "the event log timestamp has no zone and the register has one, so ordering them is a guess", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-20", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T23:52:24", "order": 20} +{"id": "TASK-145", "title": "the contract shape baseline is stale against its own recorder", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-20", "verification": "V2", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-20T23:52:24", "order": 15} +{"id": "TASK-146", "title": "the viewer renders a KR current with no provenance because it does not go through the shared derivation", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-20", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-20T23:52:24", "order": 16} +{"id": "TASK-143", "title": "two PRs each green on their own base merged into a red tree, and nothing checked the pair", "summary": "", "owner": "Coding Agent", "status": "in_progress", "priority": "P1", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-20", "verification": "V3", "evidence": "evidence/2026-08/TASK-143-spec.md", "next_action": "dispatched to claude-subagent; worktree pinned to a10f897", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T23:09:32", "order": 19} +{"id": "TASK-122", "title": "the repair path the tools advertise leaves the file needing a whitespace fix", "summary": "", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-122-dispatch-2026-08-21-result.md", "next_action": "PR #25 — item 2 proved both ways on one run; two questions handed back", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T18:30:44", "order": 10} +{"id": "TASK-147", "title": "nothing outside describe_cell proves the table and bullet paths stay separated", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P2", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-21", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P2", "role": "", "created": "2026-08-21T00:03:52", "order": 17} +{"id": "TASK-121", "title": "the sweep that found four more live-state assertions runs once and then is thrown away", "summary": "", "owner": "Coding Agent", "status": "in_progress", "priority": "P1", "track": "main", "stage": "", "stage_since": "", "arrived": "", "verification": "V3", "evidence": "evidence/2026-08/TASK-121-spec.md", "next_action": "dispatched to claude-subagent; worktree pinned to 5dd745f", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T18:13:04", "order": 9} +{"id": "TASK-141", "title": "a row stays blocked after its blockers close, because the stored status masks the computed one", "summary": "", "owner": "Coding Agent", "status": "review", "priority": "P1", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-20", "verification": "V3", "evidence": "evidence/2026-08/TASK-141-dispatch-2026-08-21-result.md", "next_action": "PR #26 — contract 1.12; its REL-00 diagnosis was wrong and is corrected in the record", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T21:21:09", "order": 17} +{"id": "TASK-148", "title": "two byte-identical copies of the startable rule live 200 lines apart in one file", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-21", "verification": "V3", "evidence": "—", "next_action": "—", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-21T00:12:22", "order": 21} +{"id": "TASK-142", "title": "triage has no check for a row stranded by a process bug, and the one signal that fired was read as prose hygiene", "summary": "", "owner": "Coding Agent", "status": "not_started", "priority": "P1", "track": "intake", "stage": "triaged", "stage_since": "", "arrived": "2026-08-20", "verification": "V3", "evidence": "—", "next_action": "read TASK-141 tasks[].blocked_stale rather than recomputing the predicate — a third statement of one rule is the defect TASK-148 is open for", "depends_on": [], "commitment": "", "parent": "", "group": "P1", "role": "", "created": "2026-08-20T21:24:30", "order": 18} diff --git a/schema/events-list-contract.md b/schema/events-list-contract.md index 10965cf..cd9c200 100644 --- a/schema/events-list-contract.md +++ b/schema/events-list-contract.md @@ -49,7 +49,7 @@ is the answer. It is. |---|---|---| | `seq` | int | position in the log. Stable **until rotation**, which is what `rotated` is for | | `ts` | string | ISO-8601, **seconds**. Ties are real and are not duplicates | -| `event` | string | `add`, `start`, `status`, `done`, `drop`, `stage`, `prioritize`, `retitle`, `next`, `rung`, `evidence`, `depends`, `route` | +| `event` | string | `add`, `start`, `status`, `done`, `drop`, `stage`, `track`, `prioritize`, `retitle`, `next`, `rung`, `evidence`, `depends`, `route` | | `task` | string | the id this event is about | | `title_then` | string | **the title as written when the event was appended.** A retitled task's earlier events still carry the old name — correct for a history view, wrong the moment you render it as the row's *current* name. `perry-task/list § title` has that one | | `field` | string | which cell `from`/`to` describe — `status` on six events, `section`, `stage`, `title`, `next_action`, `verification`, `evidence` or `depends_on` on the rest | diff --git a/schema/task-list-contract.md b/schema/task-list-contract.md index e1a1f50..b483489 100644 --- a/schema/task-list-contract.md +++ b/schema/task-list-contract.md @@ -149,7 +149,7 @@ write time; a cycle introduced by an external store edit is reported by | Key | Type | |---|---| | `ts` | string — ISO-8601, **seconds** precision, local time, no zone suffix. **Ties are possible and are not duplicates** — two events one operation apart land in the same second routinely. Timeline order is array order and is authoritative; if you re-sort by `ts`, use a stable sort or you will reorder a `start` after the `status` that followed it. | -| `event` | string — `add`, `route`, `start`, `stage`, `status`, `prioritize`, `retitle`, `summary`, `next`, `rung`, `evidence`, `depends`, `done`, `drop` | +| `event` | string — `add`, `route`, `start`, `stage`, `track`, `status`, `prioritize`, `retitle`, `summary`, `next`, `rung`, `evidence`, `depends`, `done`, `drop` | | `from` | string \| null — **see `field` for what it refers to** | | `to` | string \| null — same | | `field` | string — **what `from`/`to` refer to on this event** (1.7) | @@ -166,6 +166,7 @@ Its value, per event: - **`status`** — on `add`, `route`, `start`, `status`, `done`, `drop`. A status value. - **`section`** — on `prioritize`. A **board section**: `P2` → `P1`, or a project's own heading such as `Open — 工程线`. - **`stage`** — on `stage`. A stage from the track's declared vocabulary. +- **`track`** — on `track`. A track declared in `.perry/config.md § Tracks`. **Not `stage`**, though a move re-stamps one: a consumer told the pair was a stage would resolve `main` → `intake` against a stage vocabulary that does not contain them. The stage and the `Arrived` the move produced ride on the stored event's own `stage` / `stage_from` / `arrived` / `arrived_from` keys. - **`title`** — on `retitle`. The row's title. - **`summary`** — on `summary`. The stable purpose/outcome explanation; `""` is an explicit clear. - **`next_action`** — on `next`. The next-action cell, often several hundred characters of prose. @@ -176,8 +177,8 @@ Its value, per event: The map's keys are asserted equal to the writer's own event set, so an event cannot ship without declaring what its pair means. The ask that produced this proposed `status` for everything except `prioritize`; that would have been -false for **seven** of the fourteen — `stage`, `retitle`, `summary`, `next`, -`rung`, `evidence` and `depends` — and a wrong word in the field whose job is to stop +false for **eight** of the fifteen — `stage`, `track`, `retitle`, `summary`, +`next`, `rung`, `evidence` and `depends` — and a wrong word in the field whose job is to stop you guessing is worse than no field. ### `conformance` — what task truth or its projection could not classify @@ -467,10 +468,10 @@ Also documented, not changed: the event enum in § A timeline entry listed 7 of were all shipping and none was named, so a front-end building its event handling from the spec met them first at runtime. -`field` is `status` on six events, and `section` / `stage` / `title` / +`field` is `status` on six events, and `section` / `stage` / `track` / `title` / `summary` / `next_action` / `verification` / `evidence` / `depends_on` on the rest. The ask proposed `status` for everything except `prioritize`; that is false for -**seven** of the fourteen — `stage`, `retitle`, `summary`, `next`, `rung`, +**eight** of the fifteen — `stage`, `track`, `retitle`, `summary`, `next`, `rung`, `evidence` and `depends` — and a wrong word in the field whose job is to stop you guessing is worse than no field. One line per version. `1.x` may only add keys; a removal or a retype is a major diff --git a/tests/test_prioritize.py b/tests/test_prioritize.py index b78279a..bc6f95e 100644 --- a/tests/test_prioritize.py +++ b/tests/test_prioritize.py @@ -478,7 +478,7 @@ def test_no_event_claims_a_pair_it_does_not_carry(self): which name a concept rather than a cell.""" payload_keys = set(self.mod.LIST_TASK_KEYS) if hasattr( self.mod, "LIST_TASK_KEYS") else None - allowed = {"status", "section", "stage", "title", "summary", + allowed = {"status", "section", "stage", "track", "title", "summary", "next_action", "verification", "evidence", "depends_on"} self.assertLessEqual(set(self.mod.EVENT_FIELD.values()), allowed) @@ -531,18 +531,32 @@ def test_the_docs_rebuttal_counts_what_the_map_holds(self): if f != "status" and e != "prioritize") doc = (TOOL.parent.parent / "schema" / "task-list-contract.md").read_text(encoding="utf-8") + # The TOTAL is read out of the prose too, rather than pinned in this + # regex. It was `of the fourteen`, hardcoded here — so the day a + # fifteenth task event landed, the doc could be corrected to say + # `fifteen` and this test would stop matching altogether and pass by + # finding nothing. A number in prose that restates a data structure is + # the defect this test exists for; leaving one of the two numbers + # outside its reach reintroduced it in the guard. claims = re.findall( r"proposed `status` for everything except `prioritize`;.*?" - r"\*\*(\w+)\*\* of the fourteen — (.*?) — and a wrong word", + r"\*\*(\w+)\*\* of the (\w+) — (.*?) — and a wrong word", doc, re.S) self.assertEqual(len(claims), 2, "the rebuttal is stated twice; both must be checked") - words = {"three": 3, "four": 4, "five": 5, "six": 6, "seven": 7} - for word, listed in claims: + words = {"three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, + "eight": 8, "nine": 9, "ten": 10, "eleven": 11, + "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15, + "sixteen": 16} + for word, total, listed in claims: self.assertEqual( words.get(word), len(mislabelled), f"doc says {word!r}, EVENT_FIELD says {len(mislabelled)}: " f"{mislabelled}") + self.assertEqual( + words.get(total), len(self.mod.TASK_EVENTS), + f"doc says the writer has {total!r} task events; TASK_EVENTS " + f"holds {len(self.mod.TASK_EVENTS)}") self.assertEqual(sorted(re.findall(r"`([a-z_]+)`", listed)), mislabelled, "the doc names different events than the map") diff --git a/tests/test_track_move.py b/tests/test_track_move.py new file mode 100644 index 0000000..531809c --- /dev/null +++ b/tests/test_track_move.py @@ -0,0 +1,505 @@ +"""A project could declare a second track and then move nothing onto it. + +`TASK-135`, out of `perry/evidence/2026-08/TASK-133-track-experiment.md`. + +`--track` was accepted by exactly two commands: `add`, at creation, and +`route`, which turns an intake row into a task. There was no +`perry-task track `. So a declared track **started empty and could not be +populated** — measured on Perry's own repository, which declared `intake` +(mode `queue`) on 2026-08-20 and left six rows that genuinely arrived stranded +on `main`. A track with no rows is not a mode running on a live track, which is +why declaring one did not meet KR-O1.1. + +The two behaviours these tests exist for, because `route` already has them and +a second entrance that did not would make `Arrived` mean one thing per +entrance: + +1. Onto a **queue** track the move stamps `Arrived` and the track's first + post-intake `Stage`. `today − Arrived` is the number every SLA check + measures, so a move that omitted it would silently exempt the row from the + only clock governing it. +2. Off a track, `Stage` / `Stage since` / `Arrived` are **cleared, and what + they held is recorded** in the journal line and the event. A clock left on a + row no queue governs is a live-looking number, and a non-empty `arrived` + additionally hides the row from `rows_with_no_computable_age` — the finding + that says "this row has no clock at all". + +Run: python3 -m unittest discover -s tests +""" + +from __future__ import annotations + +import json +import subprocess +import sys +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 +TASK = PERRY_HOME / "bin" / "perry-task" +STATE = PERRY_HOME / "bin" / "perry-state" +LINT = PERRY_HOME / "bin" / "perry-lint" + +TODAY = f"{date.today():%Y-%m-%d}" + +#: One `project` track, one `queue`, one `pipeline`, one second `queue`. The +#: fourth is not padding: "an existing `Arrived` is carried, never restamped" +#: is only observable across two tracks that both read one. +TRACKS = ( + "| main | project | OKR.md | — | — | — | — | V3 |\n" + "| intake | queue | standing | new→triaged→in_progress→resolved " + "| 6 | 5d | weekly | V3 |\n" + "| press | pipeline | commitments | — | — | 2w | 1w | V5 |\n" + "| ops | queue | standing | new→triaged→resolved | 3 | 3d | weekly | V2 |\n" +) + +CONFIG = ("# Perry configuration\n\n- State root: perry\n" + GATE_OFF + + "\n## Tracks\n\n" + "| Track | Mode | Spine | Stages | WIP | SLA | Cycle | Default rung |\n" + "|---|---|---|---|---|---|---|---|\n" + TRACKS) + +HEAD = ("| ID | Title | Owner | Status | Next action | Evidence " + "| Verification |\n|---|---|---|---|---|---|---|\n") + + +class Base(unittest.TestCase): + def project(self, rows: int = 1, heading: str = "P1") -> Path: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + (root / ".perry").mkdir() + (root / "perry").mkdir() + (root / ".perry" / "config.md").write_text(CONFIG, encoding="utf-8") + (root / "perry" / "BOARD.md").write_text( + f"# Board\n\n## {heading}\n\n" + HEAD, encoding="utf-8") + for n in range(rows): + self.ok(root, "add", "--title", f"row {n + 1}", + "--deliverable", "an artifact with a test", + "--verification", "perry-lint clean", "--next", "n", + *(["--group", heading] + if heading not in ("P0", "P1", "P2") else [])) + return root + + def cli(self, root: Path, *args: str): + r = subprocess.run([sys.executable, str(TASK), *args, + "--root", str(root), "--json"], + capture_output=True, text=True) + return r + + def ok(self, root: Path, *args: str) -> dict: + r = self.cli(root, *args) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + return json.loads(r.stdout) + + def tasks(self, root: Path) -> dict: + out = self.ok(root, "list", "--all") + return {t["id"]: t for t in out["tasks"]} + + def store(self, root: Path) -> dict: + lines = (root / "perry" / "tasks.jsonl").read_text( + encoding="utf-8").splitlines() + return {r["id"]: r for r in (json.loads(l) for l in lines if l.strip())} + + def events(self, root: Path) -> list[dict]: + path = root / ".perry" / "events.jsonl" + if not path.exists(): + return [] + return [json.loads(l) for l in + path.read_text(encoding="utf-8").splitlines() if l.strip()] + + def board(self, root: Path) -> str: + return (root / "perry" / "BOARD.md").read_text(encoding="utf-8") + + def state(self, root: Path) -> dict: + r = subprocess.run([sys.executable, str(STATE), "--json", + "--root", str(root)], capture_output=True, text=True) + self.assertEqual(r.returncode, 0, r.stderr) + return json.loads(r.stdout) + + +class TestSixRowsMove(Base): + """V3 item 1. The measured defect was that a declared track stays empty.""" + + def test_six_existing_rows_reach_the_declared_queue_track(self): + root = self.project(rows=6) + ids = sorted(self.tasks(root)) + self.assertEqual(len(ids), 6) + for tid in ids: + self.ok(root, "track", tid, "--track", "intake") + moved = self.tasks(root) + self.assertEqual(sorted(t["id"] for t in moved.values() + if t["track"] == "intake"), ids) + + def test_the_board_renders_arrived_and_stage_for_every_moved_row(self): + """The board is the projection. A move the store knows and the + rendered file does not is a half-landed write.""" + root = self.project(rows=6) + for tid in sorted(self.tasks(root)): + self.ok(root, "track", tid, "--track", "intake") + for t in self.tasks(root).values(): + self.assertEqual(t["stage"], "triaged", t["id"]) + self.assertEqual(t["arrived"], TODAY, t["id"]) + self.assertEqual(t["stage_since"], TODAY, t["id"]) + board = self.board(root) + self.assertIn("| Track | Stage | Stage since | Arrived |", board) + self.assertEqual(board.count(f"| intake | triaged | {TODAY} | {TODAY} |"), 6) + + def test_perry_state_reports_them_under_the_tracks_stage_counts(self): + """The reader the mode's own triage step runs off. It counted nothing + for `intake` because nothing could get there.""" + root = self.project(rows=6) + before = {t["track"]: t["stage_counts"] for t in + self.state(root)["project"]["config"]["tracks"]} + self.assertEqual(before["intake"], {}) + for tid in sorted(self.tasks(root)): + self.ok(root, "track", tid, "--track", "intake") + after = {t["track"]: t["stage_counts"] for t in + self.state(root)["project"]["config"]["tracks"]} + self.assertEqual(after["intake"], {"triaged": 6}) + + def test_the_stage_is_the_one_the_other_two_entrances_use(self): + """`add`, `route` and this all call `entry_stage`, so a queue row is + born past the stage that means "sitting in intake" whichever door it + came through. Three entrances that each picked their own would put one + track's rows in two vocabularies.""" + root = self.project(rows=1) + tid, = self.tasks(root) + self.ok(root, "track", tid, "--track", "intake") + born = self.ok(root, "add", "--title", "born on the track", + "--deliverable", "an artifact with a test", + "--verification", "v", "--next", "n", + "--track", "intake")["id"] + rows = self.tasks(root) + self.assertEqual(rows[tid]["stage"], rows[born]["stage"]) + + def test_nothing_else_about_the_row_moves(self): + """It keeps its id, its section and every other cell — the property + that makes this a move rather than a re-file.""" + root = self.project(rows=1) + tid, = self.tasks(root) + before = self.tasks(root)[tid] + self.ok(root, "track", tid, "--track", "intake") + after = self.tasks(root)[tid] + for key in ("id", "title", "owner", "status", "priority", "group", + "next_action", "evidence", "verification", "created"): + self.assertEqual(before[key], after[key], key) + + +class TestTheRefusalIsByName(Base): + """V3 item 2, in `delegate`'s shape for an undeclared role card.""" + + def refusal(self, root: Path): + r = self.cli(root, "track", next(iter(self.tasks(root))), + "--track", "nosuch") + self.assertEqual(r.returncode, 1, r.stdout + r.stderr) + self.assertNotIn("Traceback", r.stderr + r.stdout) + return json.loads(r.stdout)["refused"] + + def test_it_names_the_track_and_lists_the_declared_ones(self): + msg = self.refusal(self.project()) + self.assertIn("'nosuch'", msg) + for declared in ("main", "intake", "press", "ops"): + self.assertIn(declared, msg) + + def test_it_names_the_file_the_declaration_lives_in(self): + """A user who must name a declared track needs the set AND the way to + add the missing one. Printing the set alone — which is what a bare + Python list repr did — names the problem and not the fix.""" + msg = self.refusal(self.project()) + self.assertIn(".perry/config.md", msg) + self.assertIn("Nothing was written", msg) + + def test_it_creates_nothing(self): + root = self.project() + config_before = (root / ".perry" / "config.md").read_bytes() + board_before = self.board(root) + self.refusal(root) + self.assertEqual((root / ".perry" / "config.md").read_bytes(), + config_before) + self.assertEqual(self.board(root), board_before) + self.assertEqual([e for e in self.events(root) + if e.get("event") == "track"], []) + tracks = {t["track"] for t in + self.state(root)["project"]["config"]["tracks"]} + self.assertNotIn("nosuch", tracks) + + def test_a_move_to_the_track_the_row_is_already_on_is_refused(self): + """A no-op that still emitted an event would put a move in the + timeline that did not happen, and the timeline is what `list` reports + as history.""" + root = self.project() + tid, = self.tasks(root) + r = self.cli(root, "track", tid, "--track", "main") + self.assertEqual(r.returncode, 1) + self.assertIn("already on track", json.loads(r.stdout)["refused"]) + + def test_a_stage_outside_the_destinations_vocabulary_is_refused(self): + root = self.project() + tid, = self.tasks(root) + r = self.cli(root, "track", tid, "--track", "intake", + "--stage", "bogus") + self.assertEqual(r.returncode, 1) + self.assertIn("vocabulary", json.loads(r.stdout)["refused"]) + + def test_track_without_a_track_is_refused_rather_than_a_no_op(self): + root = self.project() + tid, = self.tasks(root) + r = self.cli(root, "track", tid) + self.assertEqual(r.returncode, 1) + self.assertIn("--track is required", json.loads(r.stdout)["refused"]) + + +class TestBothDirectionsOfTheFieldQuestion(Base): + """V3 item 3. The decision deliverable 2 asks for, asserted in a fixture + rather than argued in a docstring.""" + + def test_onto_a_queue_track_the_clock_starts(self): + root = self.project() + tid, = self.tasks(root) + out = self.ok(root, "track", tid, "--track", "intake") + self.assertEqual((out["stage"], out["arrived"]), ("triaged", TODAY)) + rec = self.store(root)[tid] + self.assertEqual(rec["track"], "intake") + self.assertEqual(rec["stage"], "triaged") + self.assertEqual(rec["arrived"], TODAY) + self.assertEqual(rec["stage_since"], TODAY) + + def test_off_a_queue_track_the_clock_is_cleared(self): + """Not kept: `Arrived` is a queue's clock, not provenance, and one + left on a row no queue governs is a live-looking number the next SLA + reader picks up. `cmd_route` already had to stop writing `Arrived` onto + non-queue rows for the mirror-image reason — a non-empty `arrived` + hides the row from `rows_with_no_computable_age`.""" + root = self.project() + tid, = self.tasks(root) + self.ok(root, "track", tid, "--track", "intake", + "--arrived", "2026-08-01") + self.ok(root, "track", tid, "--track", "main") + rec = self.store(root)[tid] + self.assertEqual(rec["track"], "main") + self.assertEqual(rec["arrived"], "") + self.assertEqual(rec["stage"], "") + self.assertEqual(rec["stage_since"], "") + + def test_what_was_cleared_is_recorded_on_both_surviving_surfaces(self): + """Dropping the CELL is not dropping the FACT — `drop`'s precedent, + applied one field down. The journal is append-only and the event + carries the pair, which is where every other post-removal question is + already answered from.""" + root = self.project() + tid, = self.tasks(root) + self.ok(root, "track", tid, "--track", "intake", + "--arrived", "2026-08-01") + self.ok(root, "track", tid, "--track", "main") + journal = (root / "perry" / "journal" / f"{date.today():%Y-%m}" + / f"{TODAY}.md").read_text(encoding="utf-8") + self.assertIn(f"[{tid}] track intake → main", journal) + self.assertIn("stage triaged → —", journal) + self.assertIn("arrived 2026-08-01 → —", journal) + off = [e for e in self.events(root) if e.get("event") == "track"][-1] + self.assertEqual(off["stage_from"], "triaged") + self.assertEqual(off["arrived_from"], "2026-08-01") + self.assertEqual((off["stage"], off["arrived"]), ("", "")) + + def test_an_existing_arrived_is_carried_never_restamped(self): + """The case the spec names. A queue → queue move that reset the clock + to today would erase an in-flight breach — the same exemption a missing + stamp buys, arriving through the other door.""" + root = self.project() + tid, = self.tasks(root) + self.ok(root, "track", tid, "--track", "intake", + "--arrived", "2026-08-01") + out = self.ok(root, "track", tid, "--track", "ops") + self.assertEqual(out["arrived"], "2026-08-01") + self.assertEqual(self.store(root)[tid]["arrived"], "2026-08-01") + + def test_the_stage_is_restamped_on_a_queue_to_queue_move(self): + """The vocabularies differ per track, so the stage cannot be carried + even when the clock is: `intake` has `in_progress` and `ops` does + not.""" + root = self.project() + tid, = self.tasks(root) + self.ok(root, "track", tid, "--track", "intake") + self.ok(root, "stage", tid, "--stage", "in_progress") + self.ok(root, "track", tid, "--track", "ops") + rec = self.store(root)[tid] + self.assertEqual(rec["stage"], "triaged") + self.assertEqual(rec["stage_since"], TODAY) + + def test_a_staged_non_queue_destination_gets_stage_since_and_no_arrived(self): + """`entry_stage` puts a pipeline row at `brief`, not past it — the + skip is a queue rule, because only a queue's first stage means + "sitting in intake".""" + root = self.project() + tid, = self.tasks(root) + self.ok(root, "track", tid, "--track", "intake") + self.ok(root, "track", tid, "--track", "press") + rec = self.store(root)[tid] + self.assertEqual(rec["stage"], "brief") + self.assertEqual(rec["stage_since"], TODAY) + self.assertEqual(rec["arrived"], "") + + def test_arrived_on_a_destination_that_cannot_read_it_is_refused(self): + """Accepting the flag and dropping the value is how `--rung` and + `--commitment` were each lost for a release. A refusal says the + destination has no clock for it.""" + root = self.project() + tid, = self.tasks(root) + r = self.cli(root, "track", tid, "--track", "press", + "--arrived", "2026-08-01") + self.assertEqual(r.returncode, 1) + self.assertIn("--arrived", json.loads(r.stdout)["refused"]) + + def test_the_move_survives_a_re_render(self): + """The board is rendered FROM the store. A `changed` map that listed + only `track` would leave yesterday's `Stage` and `Arrived` in the + record beside the new track, and the move would half-revert here.""" + root = self.project() + tid, = self.tasks(root) + self.ok(root, "track", tid, "--track", "intake") + before = self.board(root) + r = subprocess.run([sys.executable, str(PERRY_HOME / "bin" / "perry-tasks"), + "render", "--write", "--root", str(root)], + capture_output=True, text=True) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + self.assertEqual(self.board(root), before) + + +class TestAnEventPerMove(Base): + """V3 item 4. A track change nothing recorded is TASK-139's shape.""" + + def test_every_move_appends_exactly_one_track_event(self): + root = self.project(rows=3) + ids = sorted(self.tasks(root)) + for tid in ids: + self.ok(root, "track", tid, "--track", "intake") + moves = [e for e in self.events(root) if e.get("event") == "track"] + self.assertEqual([e["id"] for e in moves], ids) + + def test_the_event_says_where_from_and_where_to(self): + root = self.project() + tid, = self.tasks(root) + self.ok(root, "track", tid, "--track", "intake", "--reason", "it arrived") + e, = [e for e in self.events(root) if e.get("event") == "track"] + self.assertEqual((e["from"], e["to"]), ("main", "intake")) + self.assertEqual(e["field"], "track") + self.assertEqual(e["track"], "intake") + self.assertEqual(e["mode"], "queue") + self.assertEqual(e["reason"], "it arrived") + self.assertEqual(e["title"], "row 1") + + def test_the_pair_reaches_the_timeline_labelled_track(self): + """`timeline[].field` exists so a consumer needs no hardcoded set of + special cases. Labelling this pair `status` — or `stage` — would put a + track name in a field a reader resolves against a status enum or a + stage vocabulary.""" + root = self.project() + tid, = self.tasks(root) + self.ok(root, "track", tid, "--track", "intake") + entry, = [t for t in self.tasks(root)[tid]["timeline"] + if t["event"] == "track"] + self.assertEqual(entry["field"], "track") + self.assertEqual((entry["from"], entry["to"]), ("main", "intake")) + + def test_the_events_feed_carries_it(self): + root = self.project() + tid, = self.tasks(root) + self.ok(root, "track", tid, "--track", "intake") + feed = self.ok(root, "events")["events"] + move, = [e for e in feed if e["event"] == "track"] + self.assertEqual(move["field"], "track") + self.assertEqual(move["track"], "intake") + + def test_a_dry_run_writes_nothing(self): + root = self.project() + tid, = self.tasks(root) + before = (self.board(root), self.store(root), self.events(root)) + self.ok(root, "track", tid, "--track", "intake", "--dry-run") + self.assertEqual((self.board(root), self.store(root), + self.events(root)), before) + + +class TestTheProjectionAgreesAfterAMove(Base): + """V3 item 5. The board is a projection; a move that leaves the rendered + file disagreeing with the store has not finished.""" + + def lint(self, root: Path) -> list[dict]: + r = subprocess.run([sys.executable, str(LINT), "--json", + "--root", str(root)], capture_output=True, text=True) + return json.loads(r.stdout)["findings"] + + def test_perry_lint_reports_no_store_drift_after_a_move(self): + root = self.project(rows=6) + for tid in sorted(self.tasks(root)): + self.ok(root, "track", tid, "--track", "intake") + drift = [f for f in self.lint(root) + if str(f.get("code", "")).startswith("store-drift")] + self.assertEqual(drift, [], "the board disagrees with the store") + + def test_and_none_after_a_move_that_clears_the_clock(self): + """The clearing direction is the one a projection loses: a cell whose + value goes to `""` is exactly the write a renderer can silently skip.""" + root = self.project() + tid, = self.tasks(root) + self.ok(root, "track", tid, "--track", "intake") + self.ok(root, "track", tid, "--track", "main") + drift = [f for f in self.lint(root) + if str(f.get("code", "")).startswith("store-drift")] + self.assertEqual(drift, []) + + +class TestABoardWithItsOwnHeadings(Base): + """`Board.find` returns `P0`/`P1`/`P2` **or a project's own heading**, and + the two widenings live on different methods. `cmd_stage` widens only in the + first case, so on the board shape `--group` exists for — the only real + adopted project available — a stamp is dropped silently at exit 0. A track + move writes four such cells, so it asks the question for both shapes.""" + + def test_the_cells_land_under_a_projects_own_heading(self): + root = self.project(rows=1, heading="Open — 工程线") + tid, = self.tasks(root) + self.ok(root, "track", tid, "--track", "intake") + rec = self.store(root)[tid] + self.assertEqual(rec["track"], "intake") + self.assertEqual(rec["stage"], "triaged") + self.assertEqual(rec["arrived"], TODAY) + self.assertIn("| intake | triaged |", self.board(root)) + + +class TestTheSubcommandIsWiredEverywhereItHasToBe(unittest.TestCase): + """The four tables a subcommand has to appear in, each of which fails + silently on its own: absent from `COMMANDS` it is unreachable, absent from + the partition it drops out of the front-end contract, absent from + `EVENT_FIELD` it ships `""` for the pair's meaning, and absent from + `commit`'s `changed` map it lands in the store as a track with yesterday's + clock beside it.""" + + @classmethod + def setUpClass(cls): + import importlib.machinery + import importlib.util + spec = importlib.util.spec_from_loader( + "perry_task_track", + importlib.machinery.SourceFileLoader("perry_task_track", str(TASK))) + cls.mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.mod) + + def test_it_is_dispatchable_and_classified_as_a_task_event(self): + self.assertIn("track", self.mod.COMMANDS) + self.assertIn("track", self.mod.TASK_EVENTS) + self.assertNotIn("track", self.mod.SECTION_EVENTS) + self.assertIn("track", self.mod.TASK_ROW_COMMANDS) + + def test_the_pair_declares_what_it_refers_to(self): + self.assertEqual(self.mod.EVENT_FIELD["track"], "track") + + def test_the_write_carries_the_clock_fields_with_the_track(self): + source = TASK.read_text(encoding="utf-8") + self.assertIn( + '"track": ("track", "stage", "stage_since", "arrived"),', source, + "commit's `changed` map must carry the cells the move re-stamps, " + "or the store keeps yesterday's clock beside the new track") diff --git a/work/reference/subcommands.md b/work/reference/subcommands.md index a47a58b..4417034 100644 --- a/work/reference/subcommands.md +++ b/work/reference/subcommands.md @@ -450,6 +450,8 @@ Print the exact command — **in its `/perry …` form**, since this stri A pipeline- or inquiry-mode board must carry `Stage` and `Stage since`; a queue-mode board must carry `Stage` and `Arrived`. They are optional in the schema so that no pre-DESIGN-003 board is invalidated, **not** so a mode track can skip them — a track that does is missing the clock its own triage reads. +**An existing row changes track with `perry-task track --track `, never by hand.** The table above is about creation, and for a long time creation and `route` were the only two entrances a track had — so a project that declared a second track started it empty and had no tool path for the work already on the board. Moving a row is one command and it re-stamps the destination's clock in the same write: onto a `queue` track it sets `Stage` to the first post-intake stage and `Arrived` (carrying an existing one rather than restamping it, so a move cannot erase an in-flight breach); onto a staged non-queue track it sets `Stage` and `Stage since`; onto a track that reads neither it **clears** `Stage` / `Stage since` / `Arrived` and writes what they held into the journal line and the event. A track named in no row of `.perry/config.md § Tracks` is refused by name, with the declared ones listed — the tool does not create a track, because a typo that invented one would be counted as real by every reader afterwards. Editing the `Track` cell by hand instead drops the clock, which is the same defect this section records for `Arrived` one paragraph up. + **Creating a queue-mode row also creates `BOARD.md § Intake` if it is absent**, with its three columns (`Arrived`, `Request`, `Outcome`). Intake is the organ queue mode is built on and the first thing `triage` walks; a section nothing creates means step 0 no-ops forever, and `modes/queue.md`'s warning about a track "whose intake is always empty while work is clearly happening" would describe the guaranteed default rather than a risk. 1. **Create the row with the tool, not by hand.**