diff --git a/.add/milestones/affordance-truth.md b/.add/milestones/affordance-truth.md
index 3c9866ee..414c57cc 100644
--- a/.add/milestones/affordance-truth.md
+++ b/.add/milestones/affordance-truth.md
@@ -1,9 +1,10 @@
---
type: Milestone
title: The engine's affordances name the beat you are actually on
-status: direction
+status: done
generated: { by: add/3.2.0, at: 2026-08-17 }
-verified: []
+verified:
+ - { by: "Tin Dang", at: 2026-09-01, act: check, authority: process, via: process, boxes: "EXIT:1,2,3,4,5,6,7" }
---
## CARD
goal: Make every `next:` the engine prints name a verb that can actually succeed against the node it names, so a node that was created and never authored is visible as such instead of reading identical to one that is ready for approval.
@@ -80,21 +81,28 @@ risks:
fails at publish, late.
## EXIT
-- [ ] A Task or Milestone that still carries template placeholders is never advised to `freeze` — every
- surface that derives a `next:` for it names authoring instead, proven by a test per surface and not
- by reading the diff (← authoring-beat-named)
-- [ ] The advice-time predicate is the SAME one the refusals use, so a node the engine advises to freeze
+- [x] A Task or Milestone that still carries template placeholders is never advised to `freeze` by any
+ surface that CAN read its body — `new`, `todo`, the CARD scaffold and `freeze` itself — proven by a
+ test per surface and not by reading the diff. `status` derives the same beat from T0 signals alone,
+ because `build-orient`'s frozen R:T2SCAN forbids it reading a body; the one shape that escapes it
+ (authored `gives:`, template RULES) is recorded in `_is_scaffold`'s docstring and caught by `todo`
+ and `freeze`. AMENDED 2026-09-01: the original wording said "every surface", which no surface bound
+ by R:T2SCAN can satisfy (← authoring-beat-named)
+- [x] The advice-time predicate is the SAME one the refusals use, so a node the engine advises to freeze
is a node `freeze` accepts — no third notion of "authored" enters the engine (← authoring-beat-named)
-- [ ] `test_new_scaffold.py`'s pinned affordance string is re-aimed at the corrected verb rather than
+- [x] `test_new_scaffold.py`'s pinned affordance string is re-aimed at the corrected verb rather than
dropped, so the scaffold's `next:` stays a pinned interface (← authoring-beat-named)
-- [ ] `freeze` refuses a Milestone whose CARD, SCOPE, GROUND or EXIT are still template, proven by a
- check that is red against today's engine — which records the stamp (← authoring-beat-named)
-- [ ] All four engine twins carry the change and the MD5 pins are re-aimed; both test roots green
+- [x] `freeze` refuses a Milestone whose CARD `goal:`, CARD `why:` or `## EXIT` criteria are still
+ template, proven by a check that is red against today's engine — which records the stamp. AMENDED
+ 2026-09-01: narrowed from CARD · SCOPE · GROUND · EXIT to the three the milestone lifecycle
+ actually reads, since `milestone_done` refuses on `why:` and on the EXIT tally, and a guard
+ reaching SCOPE and GROUND would refuse real milestones whose ground is thin (← authoring-beat-named)
+- [x] All four engine twins carry the change and the MD5 pins are re-aimed; both test roots green
(← authoring-beat-named)
-- [ ] Every skill-tree sentence claiming a command shows or prints something is proven by DRIVING that
+- [x] Every skill-tree sentence claiming a command shows or prints something is proven by DRIVING that
command and reading its stdout — a string found in `add.py` satisfies nothing, since that is
precisely what let `goal not met (m/n exit criteria)` survive (← claimed-output-guard)
-- [ ] The two false `loop.md` claims are repaired in all three live skill trees, and the guard is shown
+- [x] The two false `loop.md` claims are repaired in all three live skill trees, and the guard is shown
RED against the unrepaired tree first — a guard that never refused is not evidence
(← claimed-output-guard)
diff --git a/.add/runs/authoring-beat-named-r1.xml b/.add/runs/authoring-beat-named-r1.xml
new file mode 100644
index 00000000..edb3f3a7
--- /dev/null
+++ b/.add/runs/authoring-beat-named-r1.xml
@@ -0,0 +1 @@
+/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_cut_flags.py:73: dogfood: asserts add-skill's own cut record ('1921 -> 1865') in its dev bundle; not portable/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_cut_flags.py:86: dogfood: asserts add-skill's own measured saving in its dev bundle; not portable/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_doctor.py:212: dogfood: asserts add-skill's 8 dev-bundle orphan receipts; a fresh bundle has none/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_doctor.py:238: dogfood: asserts add-skill's 65 F2 claims in its dev bundle; a fresh bundle has none/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_evidence_ids.py:101: dogfood: reads add-skill's own dev node .add/tasks/repair-evidence-ids.md; not present in a fresh bundle/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_graph.py:231: dogfood: asserts add-skill's own dev-bundle magic numbers (>=25 nodes); re-point when add-skill-2 grows its own bundle/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_node_io.py:205: dogfood: asserts add-skill's own dev-bundle (>=20 nodes); re-point when add-skill-2 grows its own bundle
\ No newline at end of file
diff --git a/.add/runs/authoring-beat-named-r2.xml b/.add/runs/authoring-beat-named-r2.xml
new file mode 100644
index 00000000..4dbec6ee
--- /dev/null
+++ b/.add/runs/authoring-beat-named-r2.xml
@@ -0,0 +1 @@
+/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_cut_flags.py:73: dogfood: asserts add-skill's own cut record ('1921 -> 1865') in its dev bundle; not portable/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_cut_flags.py:86: dogfood: asserts add-skill's own measured saving in its dev bundle; not portable/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_doctor.py:212: dogfood: asserts add-skill's 8 dev-bundle orphan receipts; a fresh bundle has none/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_doctor.py:238: dogfood: asserts add-skill's 65 F2 claims in its dev bundle; a fresh bundle has none/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_evidence_ids.py:101: dogfood: reads add-skill's own dev node .add/tasks/repair-evidence-ids.md; not present in a fresh bundle/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_graph.py:231: dogfood: asserts add-skill's own dev-bundle magic numbers (>=25 nodes); re-point when add-skill-2 grows its own bundle/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_node_io.py:205: dogfood: asserts add-skill's own dev-bundle (>=20 nodes); re-point when add-skill-2 grows its own bundle
\ No newline at end of file
diff --git a/.add/runs/authoring-beat-named-r3.xml b/.add/runs/authoring-beat-named-r3.xml
new file mode 100644
index 00000000..edfd5630
--- /dev/null
+++ b/.add/runs/authoring-beat-named-r3.xml
@@ -0,0 +1 @@
+/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_cut_flags.py:73: dogfood: asserts add-skill's own cut record ('1921 -> 1865') in its dev bundle; not portable/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_cut_flags.py:86: dogfood: asserts add-skill's own measured saving in its dev bundle; not portable/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_doctor.py:212: dogfood: asserts add-skill's 8 dev-bundle orphan receipts; a fresh bundle has none/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_doctor.py:238: dogfood: asserts add-skill's 65 F2 claims in its dev bundle; a fresh bundle has none/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_evidence_ids.py:101: dogfood: reads add-skill's own dev node .add/tasks/repair-evidence-ids.md; not present in a fresh bundle/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_graph.py:231: dogfood: asserts add-skill's own dev-bundle magic numbers (>=25 nodes); re-point when add-skill-2 grows its own bundle/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_node_io.py:205: dogfood: asserts add-skill's own dev-bundle (>=20 nodes); re-point when add-skill-2 grows its own bundle
\ No newline at end of file
diff --git a/.add/runs/claimed-output-guard-r1.xml b/.add/runs/claimed-output-guard-r1.xml
new file mode 100644
index 00000000..fad40956
--- /dev/null
+++ b/.add/runs/claimed-output-guard-r1.xml
@@ -0,0 +1 @@
+/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_cut_flags.py:73: dogfood: asserts add-skill's own cut record ('1921 -> 1865') in its dev bundle; not portable/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_cut_flags.py:86: dogfood: asserts add-skill's own measured saving in its dev bundle; not portable/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_doctor.py:212: dogfood: asserts add-skill's 8 dev-bundle orphan receipts; a fresh bundle has none/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_doctor.py:238: dogfood: asserts add-skill's 65 F2 claims in its dev bundle; a fresh bundle has none/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_evidence_ids.py:101: dogfood: reads add-skill's own dev node .add/tasks/repair-evidence-ids.md; not present in a fresh bundle/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_graph.py:231: dogfood: asserts add-skill's own dev-bundle magic numbers (>=25 nodes); re-point when add-skill-2 grows its own bundle/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_node_io.py:205: dogfood: asserts add-skill's own dev-bundle (>=20 nodes); re-point when add-skill-2 grows its own bundle
\ No newline at end of file
diff --git a/.add/runs/claimed-output-guard-r2.xml b/.add/runs/claimed-output-guard-r2.xml
new file mode 100644
index 00000000..eb676e7b
--- /dev/null
+++ b/.add/runs/claimed-output-guard-r2.xml
@@ -0,0 +1 @@
+/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_cut_flags.py:73: dogfood: asserts add-skill's own cut record ('1921 -> 1865') in its dev bundle; not portable/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_cut_flags.py:86: dogfood: asserts add-skill's own measured saving in its dev bundle; not portable/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_doctor.py:212: dogfood: asserts add-skill's 8 dev-bundle orphan receipts; a fresh bundle has none/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_doctor.py:238: dogfood: asserts add-skill's 65 F2 claims in its dev bundle; a fresh bundle has none/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_evidence_ids.py:101: dogfood: reads add-skill's own dev node .add/tasks/repair-evidence-ids.md; not present in a fresh bundle/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_graph.py:231: dogfood: asserts add-skill's own dev-bundle magic numbers (>=25 nodes); re-point when add-skill-2 grows its own bundle/Users/tindang/workspaces/tind-repo/AIDD-Book/add-method/tests/engine/test_node_io.py:205: dogfood: asserts add-skill's own dev-bundle (>=20 nodes); re-point when add-skill-2 grows its own bundle
\ No newline at end of file
diff --git a/.add/specs/method.md b/.add/specs/method.md
index 1de6c487..6ea3bc01 100644
--- a/.add/specs/method.md
+++ b/.add/specs/method.md
@@ -13,6 +13,8 @@ how work proceeds, and what a gate costs
## Deltas
-
+- [ADD · open] A node authored before a merge goes STALE and must be re-measured before it is frozen: nine add.py line anchors had drifted, a check pinned a verb count that had changed, and a milestone criterion described a guard scope the maintainer had since narrowed. Trust a nodes prose about the engine only after driving the engine. (evidence: /tasks/authoring-beat-named.md)
+- [ADD · open] An unrecognised sensitivity value silently degrades the authority floor instead of refusing: SENSITIVITY_FLOOR maps mechanical|data|architecture|security, and .get(sens, "process") turns any other word into the LOWEST floor. Two nodes declared sensitivity: high and gated at process where they meant plan. Same class as the rest of this branch — an unknown reads as clean. (evidence: authority_for add.py:1293 · .add/tasks/authoring-beat-named.md)
- [ADD · open] A guard's INPUT PARSER is part of the guard: `_changed_paths` read git's porcelain -z stream as if every record carried a status prefix and every path were repo-parent-relative, so the sensitive-path refusal fired on paths that did not exist and missed the file actually edited. A refusal is only as true as the stream it reads. (evidence: tests/engine/test_premerge_review_fixes.py)
- [ADD · open] A guard that fires on a malformed thing and never on a missing one is a guard you get past by DELETING, not by forging — an absent section reads as clean to every consumer. Check for the ABSENCE of what is required. (evidence: /tasks/sealed-gate-enforcement.md gate PASS · runs/2.md)
- [ADD · open] The gate binds covers: referents by BARE test id, so a guard name defined in two files (test_guard_messages_name_their_target) binds to neither. Name a guard after its subject. (evidence: /tasks/box-check-verb.d/runs/2.md)
diff --git a/.add/specs/quality.md b/.add/specs/quality.md
index d2086a62..87c573de 100644
--- a/.add/specs/quality.md
+++ b/.add/specs/quality.md
@@ -13,6 +13,7 @@ what counts as proof
## Deltas
-
+- [TDD · open] The gate binds covers: referents by BARE test id, so a PARAMETRIZED check binds NOTHING — pytest reports it as test_x[param]. A green parametrized check can leave its rule unbound while reading as covered. A module name (test_tree_parity) binds nothing either; cite the real test function names. (evidence: /tasks/claimed-output-guard.md gate)
- [TDD · open] A gate that cannot READ its input must refuse, never tally zero. Teaching the goal-gate to skip fenced blocks meant an unclosed fence emptied the tally, and `total == 0` takes the 'no exit criteria' branch — which CLOSES the milestone with unmet criteria in the file. (evidence: tests/engine/test_premerge_review_fixes.py)
- [TDD · open] Never take a timestamp from the clock to compare against a filesystem. Flooring the clock to the second fixed the coarse-filesystem false-stale but blunted the check; a sentinel written on the SAME filesystem fixes it without losing any discrimination the filesystem offers. (evidence: tests/engine/test_premerge_review_fixes.py)
- [TDD · open] Every capability a doc PROMISES is a test nobody wrote: the sentence "a box the AI ticked never reads as a human's" was false for two minor versions because no check bound prose to behaviour. (evidence: /tasks/sealed-gate-enforcement.md M6 · runs/2.md)
diff --git a/.add/tasks/authoring-beat-named.md b/.add/tasks/authoring-beat-named.md
index fc501409..57816b92 100644
--- a/.add/tasks/authoring-beat-named.md
+++ b/.add/tasks/authoring-beat-named.md
@@ -1,9 +1,9 @@
---
type: Task
title: A node that was never authored is never advised to freeze
-status: direction
+status: done
depth: standard
-sensitivity: high
+sensitivity: architecture
milestone: affordance-truth
scope:
- add-method/tooling/add.py
@@ -23,15 +23,26 @@ generated: { by: add/3.2.0, at: 2026-08-17 }
verified:
- { by: "Tin Dang", at: 2026-08-17, act: freeze, authority: human, direction: "sha256:41b3ac4db4d97b63" }
- { by: "Claude (owner-delegated, Tin Dang 2026-08-17)", at: 2026-08-17, act: replan, authority: process, note: "A5's taken reading is FALSIFIED, found minutes after the freeze by the freeze itself. A5 took 'write-time correction for new, plus the existing card_drift repair thereafter'. card_drift cannot repair this: it compares the CARD's beat token against the RAW frontmatter status: field (add.py:1534, said != status), and freeze does not move status: — the beat is DERIVED from _is_frozen. So both nodes frozen today read 'beat: direction · next: add freeze ' in their own CARD while todo and status derive 'build', and add doctor reports them CLEAN. A freshly frozen node advertises the verb it has already passed, and the drift detector calls it current. This is the SAME root cause a fourth time — two notions of beat, one raw and one derived, with different surfaces reading different ones — so it belongs to S5, which this task already declares. Scope consequence: the corrected derivation must be what card_drift compares against, not status:, and test_new_card_line_names_authoring gains a sibling proving a frozen node's CARD names brief. RULES and CHECKS were NOT edited; this records the amendment against the seal rather than reopening it." }
+ - { by: "cli", at: 2026-09-01, act: brief, authority: process, brief: "sha256:7291e16b93f7d7a9" }
+ - { by: "Tin Dang", at: 2026-09-01, act: refreeze, authority: human, direction: "sha256:82ba080dc510dc23" }
+ - { by: "Tin Dang", at: 2026-09-01, act: refreeze, authority: human, direction: "sha256:c1d94f1351fd8fc1" }
+ - { by: "cli", at: 2026-09-01, act: brief, authority: process, brief: "sha256:3231915f97689a65" }
+ - { by: "Tin Dang", at: 2026-09-01, act: brief, authority: process, brief: "sha256:3231915f97689a65" }
+ - { by: "process:run", at: 2026-09-01, act: run, authority: process, outcome: PASS, receipt: /tasks/authoring-beat-named.d/runs/1.md }
+ - { by: "process:run", at: 2026-09-01, act: run, authority: process, outcome: PASS, receipt: /tasks/authoring-beat-named.d/runs/2.md }
+ - { by: "Tin Dang", at: 2026-09-01, act: refreeze, authority: human, direction: "sha256:ece19b17de5b4fb2" }
+ - { by: "Tin Dang", at: 2026-09-01, act: brief, authority: process, brief: "sha256:e6eaf64d8f889ab6" }
+ - { by: "process:run", at: 2026-09-01, act: run, authority: process, outcome: PASS, receipt: /tasks/authoring-beat-named.d/runs/3.md }
+ - { by: "Tin Dang", at: 2026-09-01, act: gate, authority: plan, outcome: PASS, receipt: /tasks/authoring-beat-named.d/runs/3.md, brief: "sha256:e6eaf64d8f889ab6" }
---
## CARD
goal: Make every surface that derives a `next:` for an unauthored node name the authoring work instead of `add freeze`, using the same predicate the refusals already use.
why: `direction.md` states the design — "There is no author verb — you fill those sections by editing that
file directly" — and no affordance in the engine knows it. `BEAT_NEXT["direction"]` maps the whole beat
- to `add freeze {slug}`, so five surfaces recommend a verb that `add.py:1260` is guaranteed to refuse. The
+ to `add freeze {slug}`, so five surfaces recommend a verb that `add.py:1394` is guaranteed to refuse. The
cost is not the wasted call; it is that a scaffold and a finished contract are indistinguishable from
`status`, so a node can sit unauthored for days inside a milestone that reads as in-progress.
-beat: direction · next: add freeze authoring-beat-named
+beat: done · next: add status
## RULES
@@ -41,7 +52,8 @@ beat: direction · next: add freeze authoring-beat-named
- M4 A node carrying a freeze stamp is untouched: its `brief` · `run` · `gate` · `done` affordances are byte-identical before and after.
- M5 All four live engine twins carry the change — `add-method/tooling/`, `add-method/src/add_method/_bundled/tooling/`, `add-method/.add/tooling/`, and this bundle's `.add/tooling/` — and the `ENGINE_MD5` / `ENGINE_PKG_MD5` pins are re-aimed. Both test roots (`add-method/tests/` and `add-method/tooling/`) are green.
- M6 `tests/engine/test_new_scaffold.py`'s pinned affordance assertion is RE-AIMED at the corrected string, not deleted. The scaffold's `next:` stays a pinned interface; it is the value that was wrong, not the pinning.
-- M7 `freeze` REFUSES a Milestone whose CARD, SCOPE, GROUND or EXIT are still template. Today it accepts one and records the stamp — measured, not inferred: `add new Milestone probe-ms` followed by `add freeze probe-ms` returns `freeze recorded at authority process` with `goal: ` untouched. `placeholders_in` reads only RULES · ASSUMPTIONS · CHECKS (add.py:2270), and a Milestone body has none of those sections, so the guard is Task-only by construction and silently vacuous on the other lifecycle type.
+- M7 `freeze` REFUSES a Milestone whose CARD `goal:`, CARD `why:`, or `## EXIT` criteria are still template — and ONLY those three. Deliberately narrower than the Task guard (decided 2026-09-01): they are the three the milestone lifecycle already depends on, since `milestone_done` refuses on `why:` and on the EXIT tally. A guard reaching SCOPE and GROUND too would refuse real milestones whose ground is thin — including this bundle's own `v3-final-collateral` — and a guard everyone learns to widen past is worse than a narrow one that holds. Today it accepts one and records the stamp — measured, not inferred: `add new Milestone probe-ms` followed by `add freeze probe-ms` returns `freeze recorded at authority process` with `goal: ` untouched. `placeholders_in` reads only RULES · ASSUMPTIONS · CHECKS (add.py:2595), and a Milestone body has none of those sections, so the guard is Task-only by construction and silently vacuous on the other lifecycle type.
+- M8 `card_drift` compares the CARD's beat token against the DERIVED beat, not the raw `status:` field. Recorded by the 2026-08-17 replan as A5 FALSIFIED: `freeze` never moves `status:`, so a freshly frozen node's CARD advertises `next: add freeze ` — the verb it has just passed — while `todo` and `status` derive `build`, and `add doctor` reports it CLEAN. Measured live on this node. Same root cause a fourth time: two notions of beat, one raw and one derived, read by different surfaces.
- R:SECOND_TRUTH Introducing a new predicate, flag or frontmatter field meaning "authored" alongside the one the refusals use. Two notions is how advice and refusal come to disagree, which is the defect one layer up. -> "SECOND_TRUTH"
@@ -70,8 +82,8 @@ every `gives:` surface is swept on every dimension; `[] n/a · ` retir
## PLAN
contract: A derived third direction state. `_beat_of` gains a `scaffold` reading between "created" and
"frozen", computed from `placeholders_in` + `gives_unauthored` — the same calls the refusals make, not a
- copy. `BEAT_NEXT` gains the matching entry. `status` (:1777), `todo`, `new` (:1242) and `BODIES["Task"]`
- (:1092) all resolve through it, so one map stays the single source. `_is_frozen` is consulted FIRST so a
+ copy. `BEAT_NEXT` gains the matching entry. `status` (:2015), `todo` (:1970), `new` (:1319) and `BODIES["Task"]`
+ (:1091) all resolve through it, so one map stays the single source. `_is_frozen` is consulted FIRST so a
stamped node is never re-read as scaffold. No verb is added; no `status:` value changes; every refusal
message stays exactly as it is.
scope: add-method/tooling/add.py · add-method/tests/engine · add-method/tooling ·
@@ -87,15 +99,15 @@ regression floor: both test roots green — `add-method/tests/` and `add-method/
- E1 Authored RULES with a still-template `gives:` — `placeholders_in` says authored, `gives_unauthored` says not. The two predicates disagree, and freeze refuses on the second.
- E2 A node carrying BOTH a freeze stamp and placeholders — reachable only from a pre-3.0 bundle, since the 3.0 guard forbids creating it.
- E3 An empty frontier: no open task at all. `status` must keep emitting its existing `add new task ` / `add new milestone ` affordance untouched.
-- E4 A Milestone scaffold. `placeholders_in` reads only RULES · ASSUMPTIONS · CHECKS (add.py:2270) and scans only lines beginning `- ` (:2272); a Milestone body has none of those three sections, so the predicate returns `[]` for ANY milestone and `freeze` stamps it approved — measured on a scratch bundle, not inferred. The `why:` gap surfaces only at `milestone-done` (:1409), which is the far end of the milestone. This is the M7 case and the reason `v3-final-collateral` has read as a live milestone since 2026-08-11.
-- E5 The `quick` one-call lane (add.py:2970-2978) rewrites the body to `beat: build · next: add run` and freezes inside the same call. It must be untouched — a scaffold detector that fired on its `` / `` slots would make the one-call lane unclosable by construction.
+- E4 A Milestone scaffold. `placeholders_in` reads only RULES · ASSUMPTIONS · CHECKS (add.py:2595) and scans only lines beginning `- ` (:2597); a Milestone body has none of those three sections, so the predicate returns `[]` for ANY milestone and `freeze` stamps it approved — measured on a scratch bundle, not inferred. The `why:` gap surfaces only at `milestone-done` (:1681), which is the far end of the milestone. This is the M7 case and the reason `v3-final-collateral` has read as a live milestone since 2026-08-11 — its `goal:` is still ``, so the narrowed M7 refuses it, which is the intended outcome and a one-time authoring cost on this bundle.
+- E5 The `quick` one-call lane (add.py:3363) rewrites the body to `beat: build · next: add run` and freezes inside the same call. It must be untouched — a scaffold detector that fired on its `` / `` slots would make the one-call lane unclosable by construction.
## CHECKS
- test_status_advises_authoring_for_a_scaffold_task · covers: M1, R:GREEN_BY_SOURCE · drives `status` against a real unauthored task node; the `next:` line names authoring and contains no `add freeze`
- test_todo_advises_authoring_for_a_scaffold_task · covers: M1, R:GREEN_BY_SOURCE · drives `todo`; the per-node arrow no longer contradicts its own `(gives: unauthored)` annotation
- test_new_returns_authoring_advice · covers: M1, R:GREEN_BY_SOURCE · the message `new` returns for a freshly written Task names authoring
-- test_new_card_line_names_authoring · covers: M1, M6 · the `beat:` line inside the created FILE names authoring, read back from disk
-- test_status_advises_authoring_for_a_scaffold_milestone · covers: M1, A2 · the Milestone path gets the same treatment as the Task path
+- test_new_card_line_names_authoring · covers: M1, M6, A12 · the `beat:` line inside the created FILE names authoring, read back from disk
+- test_new_advises_authoring_for_a_scaffold_milestone · covers: M1, A2 · the Milestone path gets the same treatment as the Task path — driven against `new`, NOT `status`, whose `next:` line targets the task frontier and never names a milestone at all, so asserting there would pass without the fix
- test_advice_and_freeze_agree_over_a_fixture_table · covers: M2, A3, R:SECOND_TRUTH · for every fixture, "advised to freeze" and "freeze accepts" are the same boolean — no node is advised toward a refusal and none is advised away from a stamp it would earn
- test_authoring_advice_matches_the_freeze_refusal_sentence · covers: M3, A1 · the advice string and the refusal's `next:` are the same instruction, and it carries a runnable `add …` continuation
- test_frozen_node_affordances_are_unchanged · covers: M4, A9 · a stamped node's `brief`/`run`/`gate`/`done` affordances are byte-identical to the pre-change engine
@@ -105,13 +117,19 @@ regression floor: both test roots green — `add-method/tests/` and `add-method/
- test_freeze_stamp_wins_over_placeholders · covers: E2, A9 · a pre-3.0 stamped node is not dragged back to authoring
- test_empty_frontier_affordance_is_unchanged · covers: E3, M4 · the no-open-task path is untouched
- test_freeze_refuses_a_pure_milestone_scaffold · covers: M7, E4 · `add new Milestone` then `add freeze` — refused, naming the template sections; red against today's engine, which records the stamp
+- test_milestone_guard_is_narrow_by_design · covers: M7 · a Milestone with an authored goal, why and EXIT but a still-template GROUND FREEZES — the guard reaches the three fields the lifecycle depends on and stops there
- test_milestone_guard_names_sections_the_body_actually_has · covers: R:VACUOUS_GUARD · the guard is driven against a scaffold it must refuse AND an authored milestone it must accept, so a guard looking in an absent section fails the first half rather than passing both
- test_quick_lane_is_unaffected · covers: E5, R:NEW_VERB · the one-call lane still opens, runs and gates in one call
- test_frontier_order_is_unchanged · covers: A10 · `ready()` ordering is identical before and after
- test_new_scaffold_pins_the_corrected_affordance · covers: M6 · the re-aimed assertion, still pinning a literal string
-- test_no_new_verb_in_the_cli_surface · covers: R:NEW_VERB · the verb list is unchanged at 22
+- test_no_new_verb_in_the_cli_surface · covers: R:NEW_VERB · the verb list is unchanged at 23
- test_status_frontmatter_vocabulary_is_unchanged · covers: R:STATUS_ENUM · no new `status:` value is written or accepted
-- test_tree_parity · covers: M5, R:ONE_TREE · all four twins byte-identical and the MD5 pins re-aimed (existing check, extended to this change)
+- test_frozen_node_card_names_brief · covers: M8, S5 · the sibling the replan named — a frozen node's own CARD names the build entry, not the approval it already has
+- test_card_drift_compares_the_derived_beat · covers: M8, A5 · a frozen node whose CARD still says `direction` is REPORTED, where today `doctor` calls it clean
+- test_add_py_matches_ENGINE_MD5 · covers: M5, R:ONE_TREE · all four twins byte-identical and the MD5 pins re-aimed (existing check, extended to this change)
+- test_engine_bundle_matches_canonical · covers: M5, R:ONE_TREE · the bundled twin is byte-identical to the canonical engine
+- test_a_half_authored_node_still_reads_unauthored · covers: A4 · the probe — one remaining placeholder still reads unauthored, and freeze agrees
+- test_cold_resume_reaches_authoring_without_a_refusal · covers: A11 · the probe — the cold-resume path reaches authoring without first running a verb that refuses
red-first: every check MUST fail first.
## EVIDENCE
diff --git a/.add/tasks/claimed-output-guard.md b/.add/tasks/claimed-output-guard.md
index 1b9764a3..13701643 100644
--- a/.add/tasks/claimed-output-guard.md
+++ b/.add/tasks/claimed-output-guard.md
@@ -1,9 +1,9 @@
---
type: Task
title: A skill claim about what the engine prints is bound to the engine printing it
-status: direction
+status: done
depth: standard
-sensitivity: high
+sensitivity: architecture
milestone: affordance-truth
scope:
- .claude/skills/add
@@ -19,6 +19,12 @@ gives:
generated: { by: add/3.2.0, at: 2026-08-17 }
verified:
- { by: "Tin Dang", at: 2026-08-17, act: freeze, authority: human, direction: "sha256:44a8759bd6f4ceca" }
+ - { by: "Tin Dang", at: 2026-09-01, act: brief, authority: process, brief: "sha256:5fcd3b4e21513512" }
+ - { by: "process:run", at: 2026-09-01, act: run, authority: process, outcome: PASS, receipt: /tasks/claimed-output-guard.d/runs/1.md }
+ - { by: "Tin Dang", at: 2026-09-01, act: refreeze, authority: human, direction: "sha256:ad84e8ee3f1e2ae6" }
+ - { by: "Tin Dang", at: 2026-09-01, act: brief, authority: process, brief: "sha256:430d6843fc19842b" }
+ - { by: "process:run", at: 2026-09-01, act: run, authority: process, outcome: PASS, receipt: /tasks/claimed-output-guard.d/runs/2.md }
+ - { by: "Tin Dang", at: 2026-09-01, act: gate, authority: plan, outcome: PASS, receipt: /tasks/claimed-output-guard.d/runs/2.md, brief: "sha256:430d6843fc19842b" }
---
## CARD
goal: Bind every skill-tree sentence that says the engine PRINTS something to a driven command that proves it prints it, and repair the two `loop.md` claims that do not.
@@ -31,7 +37,7 @@ why: `promised-capability-guard` closed this class for the READMEs and its own `
all. Both sit in the loop's Gather step and the first is named as THE cue that starts the loop, so an
agent following the skill waits for a signal the engine never sends. A claim about output cannot be
checked by finding a string in a source file; it has to be checked by running the command.
-beat: direction · next: add freeze claimed-output-guard
+beat: done · next: add status
## RULES
@@ -103,6 +109,11 @@ regression floor: both test roots green — `add-method/tests/` and `add-method/
- test_status_flag_modes_are_driven_as_registered · covers: E2 · a claim naming a flagged form is proven against that form, not against the bare command
- test_parenthetical_claims_are_registered · covers: E4 · the `milestone-done` parenthetical resolves as an entry rather than being filtered as prose
- test_skill_tree_mirror_parity · covers: E5, R:TWO_TREE · the three trees are identical, and a pre-existing gap is reported rather than normalised
+- test_failure_quotes_the_sentence_not_just_its_location · covers: A1 · the refusal carries the sentence itself, since no reviewer is assumed
+- test_only_rendering_sentences_are_collected · covers: A2 · a command named in prose is not a claim; naming a rendering is
+- test_claims_are_reread_from_disk_on_every_run · covers: A4 · the corpus is re-read every run, never snapshotted
+- test_the_source_tree_is_the_one_the_engine_ships · covers: A9 · `add-method/skill/add/` is the source, the other two mirrors
+- test_a_costly_state_is_constructed_or_named · covers: E3, M5 · the goal-unmet state is actually constructed, not dropped as expensive
red-first: every check MUST fail first. The guard is authored and run RED against the unrepaired tree before any sentence is corrected — a guard that has never refused is not evidence.
## EVIDENCE
diff --git a/.claude/skills/add/loop.md b/.claude/skills/add/loop.md
index 7aeed34b..6ee0acec 100644
--- a/.claude/skills/add/loop.md
+++ b/.claude/skills/add/loop.md
@@ -39,12 +39,14 @@ milestone not done. One gate, no quiet way around it.
## The loop
-Every task done but the goal unmet? `add status` shows `goal not met (m/n exit criteria)`. That is
+Every task done but the goal unmet? `add milestone-done ` refuses with
+`milestone_goal_unmet (m/n exit criteria)` and the milestone stays active. That is
the cue:
1. **Gather** the carried inventory:
- open lessons — `add deltas` (still `open`);
- - planned-but-unscaffolded tasks — the plan-vs-state line in `add status`;
+ - planned-but-unscaffolded tasks — the `scaffold` beat in `add todo`, which lists a node
+ that was created and never authored;
- any reopened task — one a deepened verify returned to the flow (below).
2. **Propose** the next tasks — with the best-fit advisor-flow persona loaded BEFORE drafting
(`personas.md` § planning; a roster-less bundle skips silently): for each carried item worth
diff --git a/add-method/skill/add/loop.md b/add-method/skill/add/loop.md
index 7aeed34b..6ee0acec 100644
--- a/add-method/skill/add/loop.md
+++ b/add-method/skill/add/loop.md
@@ -39,12 +39,14 @@ milestone not done. One gate, no quiet way around it.
## The loop
-Every task done but the goal unmet? `add status` shows `goal not met (m/n exit criteria)`. That is
+Every task done but the goal unmet? `add milestone-done ` refuses with
+`milestone_goal_unmet (m/n exit criteria)` and the milestone stays active. That is
the cue:
1. **Gather** the carried inventory:
- open lessons — `add deltas` (still `open`);
- - planned-but-unscaffolded tasks — the plan-vs-state line in `add status`;
+ - planned-but-unscaffolded tasks — the `scaffold` beat in `add todo`, which lists a node
+ that was created and never authored;
- any reopened task — one a deepened verify returned to the flow (below).
2. **Propose** the next tasks — with the best-fit advisor-flow persona loaded BEFORE drafting
(`personas.md` § planning; a roster-less bundle skips silently): for each carried item worth
diff --git a/add-method/src/add_method/_bundled/skill/add/loop.md b/add-method/src/add_method/_bundled/skill/add/loop.md
index 7aeed34b..6ee0acec 100644
--- a/add-method/src/add_method/_bundled/skill/add/loop.md
+++ b/add-method/src/add_method/_bundled/skill/add/loop.md
@@ -39,12 +39,14 @@ milestone not done. One gate, no quiet way around it.
## The loop
-Every task done but the goal unmet? `add status` shows `goal not met (m/n exit criteria)`. That is
+Every task done but the goal unmet? `add milestone-done ` refuses with
+`milestone_goal_unmet (m/n exit criteria)` and the milestone stays active. That is
the cue:
1. **Gather** the carried inventory:
- open lessons — `add deltas` (still `open`);
- - planned-but-unscaffolded tasks — the plan-vs-state line in `add status`;
+ - planned-but-unscaffolded tasks — the `scaffold` beat in `add todo`, which lists a node
+ that was created and never authored;
- any reopened task — one a deepened verify returned to the flow (below).
2. **Propose** the next tasks — with the best-fit advisor-flow persona loaded BEFORE drafting
(`personas.md` § planning; a roster-less bundle skips silently): for each carried item worth
diff --git a/add-method/src/add_method/_bundled/tooling/add.py b/add-method/src/add_method/_bundled/tooling/add.py
index 8208c687..2f710726 100644
--- a/add-method/src/add_method/_bundled/tooling/add.py
+++ b/add-method/src/add_method/_bundled/tooling/add.py
@@ -1090,7 +1090,8 @@ def refuse(why: str, fix: str) -> tuple:
"Persona": "personas", "Prompt": "prompts", "Run": "runs"}
BODIES = {
"Task": "## CARD\ngoal: \nwhy: \n"
- "beat: direction · next: add freeze {slug}\n\n"
+ "beat: scaffold · next: author {slug}'s RULES, ASSUMPTIONS and CHECKS, "
+ "then add freeze {slug}\n\n"
"## RULES\n\n- M1 \n\n\n"
"- R: -> \"\"\n\n\n"
# The line the author fills STARTS from "the request does not say" — the
@@ -1371,7 +1372,10 @@ def new(root, node_type: str, slug: str, **fields) -> tuple:
scaffold = BODIES.get(node_type, "## CARD\ngoal: \n").replace("{slug}", slug)
write(path, "---\n" + "\n".join(lines) + "\n---\n" + scaffold)
# freeze is a lifecycle act — a Persona/Prompt/Run is done the moment it is written.
- nxt = f"add freeze {slug}" if node_type in LIFECYCLE_TYPES else "add status"
+ # A file of placeholders is a scaffold, and `freeze` is guaranteed to refuse one — so the
+ # message `new` hands back names the authoring work, not the approval that follows it.
+ nxt = (AUTHOR_NEXT.get(node_type, AUTHOR_NEXT["Task"]).format(slug=slug)
+ if node_type in LIFECYCLE_TYPES else "add status")
return "/" + rel, f"created {rel}\nnext: {nxt}"
@@ -1391,6 +1395,15 @@ def freeze(root, cid: str, by: str, authority: str = None) -> tuple:
slug = cid.rsplit("/", 1)[-1][:-3]
node_t2 = read(entry["path"], "T2")
+ if (entry.get("fm") or {}).get("type") == "Milestone":
+ # The guard `placeholders_in` could never make: it reads RULES · ASSUMPTIONS · CHECKS and a
+ # Milestone body carries none of those three, so it returned [] for EVERY milestone and the
+ # ONE human approval was stampable against a node stating no goal and no exit criterion.
+ ms_stubs = _milestone_stubs(node_t2)
+ if ms_stubs:
+ return False, (f"cannot freeze `{slug}` — this milestone is still a scaffold: "
+ + " · ".join(ms_stubs)
+ + f"\nnext: {AUTHOR_NEXT['Milestone'].format(slug=slug)}")
stubs = placeholders_in(node_t2)
if stubs:
return None, (f"cannot freeze `{slug}` — the node still carries template placeholders: "
@@ -1778,8 +1791,18 @@ def milestone_archive(root, cid: str) -> tuple:
BEAT_KEYS = ("beat", "state")
# The one canonical next verb per beat — read by `status`'s frontier hint and `render_card`, so a
# repaired CARD's `next:` matches its beat instead of freezing at the direction-time affordance.
-BEAT_NEXT = {"direction": "add freeze {slug}", "build": "add run {slug} -- ",
+# The authoring beat has NO VERB by design — `direction.md`: "There is no author verb — you fill
+# those sections by editing that file directly". So its advice names the WORK and the verb that
+# follows it, matching `freeze`'s own refusal sentence word for word, and still carries a runnable
+# `add …` continuation so an agent matching on a leading verb keeps a cue (A1).
+AUTHOR_NEXT = {
+ "Task": "author {slug}'s RULES, ASSUMPTIONS and CHECKS, then add freeze {slug}",
+ "Milestone": "author {slug}'s goal, why and EXIT criteria, then add freeze {slug}",
+}
+BEAT_NEXT = {"scaffold": AUTHOR_NEXT["Task"], "direction": "add freeze {slug}",
+ "build": "add run {slug} -- ",
"verify": "add gate {slug}", "done": "add status"}
+BEAT_NAMES = ("scaffold", "direction", "build", "verify", "done")
# What a cold reader needs, in order. `Run` is absent on purpose — see `status`.
ORIENT_RANK = {"Project": 0, "Milestone": 1, "Task": 2, "Spec": 5, "Persona": 6, "Prompt": 7}
@@ -1792,6 +1815,64 @@ def _is_frozen(node) -> bool:
return any(isinstance(s, dict) and s.get("act") in ("freeze", "refreeze") for s in stamps)
+def _milestone_stubs(node: dict) -> list:
+ """The Milestone fields still template, among the three the lifecycle actually reads.
+
+ Deliberately narrower than the Task guard (decided 2026-09-01). `milestone_done` already
+ refuses on `why:` and on the `## EXIT` tally, so goal · why · EXIT are what the milestone
+ lifecycle depends on. A guard reaching SCOPE and GROUND too would refuse real milestones
+ whose ground is thin, and a guard everyone learns to widen past is worse than a narrow one
+ that holds.
+ """
+ body = node.get("body") or ""
+ out = []
+ for line in card_of(body).splitlines():
+ key, sep, value = line.partition(":")
+ if sep and key.strip() in ("goal", "why") and PLACEHOLDER.search(value):
+ out.append(f"CARD `{key.strip()}:`")
+ exit_body = _section_of(body, "EXIT")
+ boxes = _box_lines(exit_body) if _fence_balanced(exit_body) else []
+ if not boxes or any(PLACEHOLDER.search(text) for _, _, text, _ in boxes):
+ out.append("`## EXIT` criteria")
+ return out
+
+
+def _is_scaffold(node, t2=None) -> bool:
+ """True for a node that was created and never authored. ONE definition, two tiers.
+
+ Calls the SAME predicates the refusals call — `gives_unauthored` at T0, `placeholders_in`
+ (or `_milestone_stubs`) at T2 — never a copy of them (R:SECOND_TRUTH). Two notions of
+ "authored" is exactly how advice and refusal came to disagree, which is the defect one
+ layer up.
+
+ The T2 half runs ONLY when the caller already holds the body. `status` must not read a
+ single body — that is `build-orient`'s R:T2SCAN, a Reject frozen before this task existed
+ and not this task's to weaken — so it gets the T0 answer, while `freeze` and `todo`, which
+ both already read the body for their own reasons, get the complete one. The tiers cannot
+ disagree in DIRECTION: T0 saying scaffold is always right, and the T2 half only ever adds.
+
+ Residual, recorded rather than hidden: a node with an authored `gives:` but still-template
+ RULES reads authored to `status` alone. `todo` and `freeze` both catch it.
+
+ A freeze stamp WINS over any placeholder: a pre-3.0 bundle can carry both, and dragging an
+ approved node back into authoring advice would undo an approval that was actually given (A9).
+ An unreadable body advises authoring — the conservative direction, since the alternative is
+ to recommend a verb whose refusal is the author's first news of the problem (A7).
+ """
+ if _is_frozen(node):
+ return False
+ fm = node.get("fm") or {}
+ if fm.get("type") not in LIFECYCLE_TYPES:
+ return False
+ if fm.get("type") == "Task" and gives_unauthored(node):
+ return True # T0, and enough on its own
+ if t2 is None:
+ return False # the caller holds no body — T0 is the whole answer
+ if fm.get("type") == "Milestone":
+ return bool(_milestone_stubs(t2))
+ return bool(placeholders_in(t2))
+
+
def replan(root, cid: str, note: str, by: str = "builder") -> tuple:
"""Record a steering amendment on a frozen task — one additive act stamp, the seal untouched.
@@ -1836,16 +1917,20 @@ def card_drift(graph: dict) -> list:
"""
out = []
for cid, node in graph.items():
- status = (node["fm"] or {}).get("status")
- if not status:
+ if not (node["fm"] or {}).get("status"):
continue
+ # The DERIVED beat, never the raw `status:` field. `freeze` does not move `status:`, so a
+ # freshly frozen node advertised `next: add freeze ` — the approval it had just
+ # passed — while `todo` and `status` derived `build`, and this reported it CLEAN
+ # (2026-08-17 replan, A5 falsified). Two notions of beat, read by different surfaces.
+ beat = _beat_of(node)
card = card_of(read(node["path"], "T2")["body"])
for line in card.splitlines():
key, sep, value = line.partition(":")
if sep and key.strip() in BEAT_KEYS:
said = value.split("·")[0].strip()
- if said and said != status and said in ("direction", "build", "verify", "done"):
- out.append((cid, key.strip(), said, status))
+ if said and said != beat and said in BEAT_NAMES:
+ out.append((cid, key.strip(), said, beat))
return out
@@ -1863,9 +1948,12 @@ def render_card(root, cid: str) -> tuple:
for i, line in enumerate(lines):
if line.startswith(f"{key}:") and said in line:
# Rebuild the WHOLE beat line, not just the token: the `next:` on it froze at the
- # direction-time affordance, so a done card kept reading `next: add freeze`. BEAT_NEXT
- # gives the beat its true next verb. Still exactly one line changes (idempotence holds).
- nxt = BEAT_NEXT.get(status, "add status").format(slug=slug)
+ # direction-time affordance, so a done card kept reading `next: add freeze`. Through
+ # `_next_verb`, not BEAT_NEXT directly — that is the one map every other surface
+ # resolves through, and it alone knows a sealed-but-unbriefed task owes `add brief`
+ # before its run (R:UNBRIEFED). Reading the map raw put a third answer on the CARD.
+ # Still exactly one line changes (idempotence holds).
+ nxt = _next_verb(graph, cid)
lines[i] = f"{key}: {status} · next: {nxt}\n"
break
write(path, f"---\n{node['raw']}\n---\n{''.join(lines)}")
@@ -1916,7 +2004,7 @@ def locate(root, query: str) -> tuple:
return hits, f"{len(hits)} node(s) scope `{query}`:\n" + "\n".join(lines) + "\nnext: add status"
-def _beat_of(node) -> str:
+def _beat_of(node, t2=None) -> str:
"""A task's beat, DERIVED from its stamps — the same reasoning as `_is_frozen`.
`status` runs `direction → done`: nothing in `freeze`/`run` advances it, so the field cannot
@@ -1930,7 +2018,9 @@ def _beat_of(node) -> str:
stamps = [s for s in (fm.get("verified") or []) if isinstance(s, dict)]
if any(s.get("act") == "run" for s in stamps):
return "verify"
- return "build" if _is_frozen(node) else "direction"
+ if _is_frozen(node):
+ return "build"
+ return "scaffold" if _is_scaffold(node, t2) else "direction"
def _brief_entered(stamps: list, receipt_cid: str = None) -> bool:
@@ -1952,11 +2042,15 @@ def _brief_entered(stamps: list, receipt_cid: str = None) -> bool:
for i, s in enumerate(stamps))
-def _next_verb(graph: dict, cid: str) -> str:
- """The one runnable next command for a task, by its stamp-derived beat."""
+def _next_verb(graph: dict, cid: str, t2=None) -> str:
+ """The one runnable next command for a task, by its stamp-derived beat.
+
+ `t2` is the node's body when the caller already holds it — `todo` does. Without it the beat
+ is derived at T0, which is what `status` requires (`build-orient`'s R:T2SCAN).
+ """
slug = cid.rsplit("/", 1)[-1][:-3]
node = graph[cid]
- beat = _beat_of(node)
+ beat = _beat_of(node, t2)
fm = node.get("fm") or {}
# W1 (R:UNBRIEFED): at the build beat the ENTRY comes first — a sealed, unbriefed task
# points at `add brief`, and moves on to the run the moment the entry is recorded.
@@ -1964,6 +2058,8 @@ def _next_verb(graph: dict, cid: str) -> str:
and str(fm.get("depth") or "standard") != "quick" \
and sealed_direction(fm) and not _brief_entered(fm.get("verified") or []):
return f"add brief {slug}"
+ if beat == "scaffold":
+ return AUTHOR_NEXT.get(str(fm.get("type")), AUTHOR_NEXT["Task"]).format(slug=slug)
return BEAT_NEXT.get(beat, "add status").format(slug=slug)
@@ -1972,18 +2068,28 @@ def todo(root, milestone: str = None) -> tuple:
verb. Optionally restricted to one milestone. Read-only — `(items, note)`, never a write."""
graph = scan(Path(root))
msel = _wave_slug(milestone) if milestone else None
- items = []
+ # ONE body read per open task, reused by the hint loop below — deriving the beat and then
+ # re-reading the same file to build its hint read every direction-beat node twice.
+ items, bodies = [], {}
for cid in active(graph):
fm = graph[cid]["fm"] or {}
if fm.get("type") != "Task":
continue
if msel and _wave_slug(fm.get("milestone")) != msel:
continue
- items.append((cid, _beat_of(graph[cid]), _next_verb(graph, cid)))
+ # ONE body read per node, handed to both derivations — `todo` is not T0-bound (it already
+ # reads the body for the unswept-pairs hint), so its arrow gets the COMPLETE scaffold
+ # answer rather than the T0 half `status` must settle for.
+ try:
+ t2 = read(graph[cid]["path"], "T2")
+ except (OSError, ValueError, KeyError, TypeError):
+ t2 = None
+ bodies[cid] = t2
+ items.append((cid, _beat_of(graph[cid], t2), _next_verb(graph, cid, t2)))
if not items:
where = f" under `{milestone}`" if milestone else ""
return [], f"nothing open{where}\nnext: add status"
- order = {"direction": 0, "build": 1, "verify": 2}
+ order = {"scaffold": -1, "direction": 0, "build": 1, "verify": 2}
items.sort(key=lambda it: (order.get(it[1], 9), it[0]))
lines, beat = [], None
for cid, st, nxt in items:
@@ -1995,7 +2101,7 @@ def todo(root, milestone: str = None) -> tuple:
# Progressive, so freeze CONFIRMS work already done instead of ambushing the
# author with the whole matrix at the moment they expected to be finished. A
# gate first met as a wall earns a reputation for obstruction, not for catching.
- node_t2 = read(graph[cid]["path"], "T2")
+ node_t2 = bodies.get(cid) or read(graph[cid]["path"], "T2")
if gives_unauthored(node_t2) and _section_of(node_t2.get("body") or "",
"ASSUMPTIONS").strip():
hint = " (gives: unauthored — no surfaces to sweep)"
@@ -2085,10 +2191,10 @@ def keep(cid):
nxt = f"next: add gate {waiting[0].rsplit('/', 1)[-1][:-3]}"
elif frontier:
f0 = frontier[0]
- # An unfrozen frontier task still needs its direction authored + frozen; brief would compile a
- # node of placeholders. A frozen one is ready to hand to a builder. Pick the verb by the stamp.
- verb = "brief" if _is_frozen(graph[f0]) else "freeze"
- nxt = f"next: add {verb} {f0.rsplit('/', 1)[-1][:-3]}"
+ # Through `_next_verb`, so this hint and `todo`'s arrow cannot disagree — the stamp test
+ # that used to live here was a third reading of the beat, and a node that was created and
+ # never authored got advised toward the freeze that is structurally guaranteed to refuse it.
+ nxt = f"next: {_next_verb(graph, f0)}"
elif any((n["fm"] or {}).get("type") == "Milestone" for n in graph.values()):
nxt = "next: add new task "
else:
diff --git a/add-method/tests/engine/test_authoring_beat.py b/add-method/tests/engine/test_authoring_beat.py
new file mode 100644
index 00000000..3fc2da5f
--- /dev/null
+++ b/add-method/tests/engine/test_authoring_beat.py
@@ -0,0 +1,386 @@
+"""A node that was never authored is never advised to freeze.
+
+`direction.md` states the design — "There is no author verb — you fill those sections by editing
+that file directly" — and no affordance in the engine knew it. `BEAT_NEXT["direction"]` mapped the
+whole beat to `add freeze {slug}`, so from the moment `add new Task` wrote a file of placeholders,
+five surfaces recommended a verb `freeze` is structurally guaranteed to refuse (add.py:1394).
+
+On Milestones the same advice is not refused — it SUCCEEDS. `placeholders_in` reads only
+RULES · ASSUMPTIONS · CHECKS (add.py:2595) and a Milestone body carries none of those, so the
+guard is Task-only by construction and silently vacuous on the other lifecycle type: ADD's one
+human approval could be stamped against a node stating no goal, no scope, no exit criterion.
+
+Every check here drives a real surface against a real node. Grepping `add.py` for the new string
+would prove the string exists, not that any surface emits it — which is the exact shape of check
+that let the defect ship (R:GREEN_BY_SOURCE).
+"""
+import re
+import sys
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(REPO / "tooling"))
+import add # noqa: E402
+
+AUTHORING = "author" # the corrected advice names the authoring work
+
+
+def _recommends_freeze(text: str, slug: str) -> bool:
+ """Does this advice recommend `freeze` as the IMMEDIATE next action?
+
+ Not a substring test. M3 requires the advice to match `freeze`'s own refusal sentence word
+ for word — "author 's RULES, ASSUMPTIONS and CHECKS, then add freeze " — which
+ NAMES the verb that follows the authoring, and must, or an agent matching on a leading
+ `add …` loses its cue entirely (A1). What M1 forbids is recommending freeze as the thing to
+ run NOW. So the test is position, not presence.
+ """
+ for line in text.splitlines():
+ line = line.strip().removeprefix("next:").strip()
+ line = re.sub(r"^[·\s]*\S+\s+→\s+", "", line) # todo's `· slug → ` arrow
+ if line.startswith(f"add freeze {slug}"):
+ return True
+ return False
+
+
+def _bundle(tmp_path):
+ add.init(tmp_path, "code", "T")
+ return tmp_path
+
+
+def _scaffold(root, slug="scaffold", node_type="Task"):
+ """A node exactly as `new` writes it — every section still template."""
+ cid, msg = add.new(root, node_type, slug, title=slug)
+ return cid, msg
+
+
+def _authored_task(root, slug="authored", **fields):
+ """A Task the placeholder guard accepts — so these checks probe the ADVICE, not authoring."""
+ cid, _ = add.new(root, "Task", slug, title=slug, **fields)
+ p = root / cid.lstrip("/")
+ t = p.read_text(encoding="utf-8")
+ t = t.replace("- S1 ",
+ "- S1 the lister")
+ t = re.sub(r"## RULES\n\n.*?\n",
+ "## RULES\n\n- M1 the lister returns only the caller's rows\n", t, flags=re.S)
+ t = re.sub(r"\n.*?\n",
+ '\n- R:LEAK another tenant\'s row is returned -> "LEAK"\n', t, flags=re.S)
+ t = re.sub(r"## ASSUMPTIONS\n.*?\nevery `gives:`", "## ASSUMPTIONS\n" + "".join(
+ f"- A{i} [{d}] covers: S1 · the request does not say; taking the plain reading -> minor\n"
+ for i, d in enumerate(("who", "which", "when", "absent", "order", "experience"), 1)
+ ) + "every `gives:`", t, flags=re.S)
+ t = re.sub(r"## CHECKS\n.*?\nred-first",
+ "## CHECKS\n- test_only_own_rows · covers: M1, R:LEAK · proves isolation\nred-first",
+ t, flags=re.S)
+ p.write_text(t, encoding="utf-8")
+ return cid
+
+
+def _milestone_authored_narrowly(root, slug="narrow-ms"):
+ """goal · why · EXIT authored; SCOPE and GROUND deliberately left template.
+
+ This is the boundary M7 was narrowed to (decided 2026-09-01): the three fields the milestone
+ lifecycle already depends on, since `milestone_done` refuses on `why:` and on the EXIT tally.
+ """
+ cid, _ = add.new(root, "Milestone", slug, title=slug)
+ p = root / cid.lstrip("/")
+ t = p.read_text(encoding="utf-8")
+ t = t.replace("goal: ", "goal: prove the guard stops where it was told to stop")
+ t = re.sub(r"why: <[^>]*>", "why: a guard everyone widens past is worse than a narrow one", t)
+ t = t.replace("- [ ] (← )", "- [ ] the one real criterion (← some-task)")
+ p.write_text(t, encoding="utf-8")
+ return cid
+
+
+def _authored_milestone(root, slug="real-ms"):
+ """A Milestone with a stated goal, why, scope, ground and one exit criterion."""
+ cid, _ = add.new(root, "Milestone", slug, title=slug)
+ p = root / cid.lstrip("/")
+ t = p.read_text(encoding="utf-8")
+ t = t.replace("goal: ", "goal: prove an authored milestone is still accepted")
+ t = re.sub(r"why: <[^>]*>", "why: the guard must refuse a scaffold without refusing real work", t)
+ t = re.sub(r"<[a-z_][^>\n]*>", "authored", t) # every remaining template slot
+ p.write_text(t, encoding="utf-8")
+ return cid
+
+
+# ---- M1 · the five surfaces --------------------------------------------------------------
+
+def test_status_advises_authoring_for_a_scaffold_task(tmp_path):
+ """covers: M1, R:GREEN_BY_SOURCE — drives `status`, reads its emitted text."""
+ root = _bundle(tmp_path)
+ _scaffold(root, "s1")
+ out = add.status(root)
+ assert not _recommends_freeze(out, "s1"), f"status advised a verb freeze would refuse:\n{out}"
+ assert AUTHORING in out.lower(), out
+
+
+def test_todo_advises_authoring_for_a_scaffold_task(tmp_path):
+ """covers: M1, R:GREEN_BY_SOURCE — the arrow must not contradict its own annotation."""
+ root = _bundle(tmp_path)
+ _scaffold(root, "s2")
+ _, note = add.todo(root)
+ assert not _recommends_freeze(note, "s2"), f"todo advised a verb freeze would refuse:\n{note}"
+ assert AUTHORING in note.lower(), note
+
+
+def test_new_returns_authoring_advice(tmp_path):
+ """covers: M1, R:GREEN_BY_SOURCE — the message `new` hands back."""
+ root = _bundle(tmp_path)
+ _, msg = _scaffold(root, "s3")
+ assert not _recommends_freeze(msg, "s3"), msg
+ assert AUTHORING in msg.lower(), msg
+
+
+def test_new_card_line_names_authoring(tmp_path):
+ """covers: M1, M6 — the `beat:` line inside the FILE, read back from disk."""
+ root = _bundle(tmp_path)
+ cid, _ = _scaffold(root, "s4")
+ body = (root / cid.lstrip("/")).read_text(encoding="utf-8")
+ card = next(ln for ln in body.splitlines() if ln.startswith("beat:"))
+ assert not card.strip().startswith("beat: direction"), card
+ assert AUTHORING in card.lower(), card
+
+
+def test_new_advises_authoring_for_a_scaffold_milestone(tmp_path):
+ """covers: M1, A2 — the Milestone path gets the same treatment as the Task path.
+
+ Driven against `new`, NOT `status`: `status`'s `next:` line targets the task frontier and
+ never names a milestone at all, so asserting there would pass without the fix (the vacuous
+ shape R:VACUOUS_GUARD forbids). `new` is where the milestone advice is actually emitted —
+ and unlike the Task path, the freeze it recommends SUCCEEDS.
+ """
+ root = _bundle(tmp_path)
+ _, msg = _scaffold(root, "s5", node_type="Milestone")
+ assert not _recommends_freeze(msg, "s5"), f"a milestone scaffold was advised to freeze:\n{msg}"
+ assert AUTHORING in msg.lower(), msg
+
+
+# ---- M2 · one notion of "authored" -------------------------------------------------------
+
+def test_advice_and_freeze_agree_over_a_fixture_table(tmp_path):
+ """covers: M2, A3, R:SECOND_TRUTH — advised-to-freeze and freeze-accepts are one boolean."""
+ root = _bundle(tmp_path)
+ cases = {"tpl": _scaffold(root, "tpl")[0], "real": _authored_task(root, "real")}
+ graph = add.scan(root)
+ for name, cid in cases.items():
+ slug = cid.rsplit("/", 1)[-1][:-3]
+ advised = _recommends_freeze(add._next_verb(graph, cid), slug)
+ accepted = bool(add.freeze(root, cid, by="probe")[0])
+ assert advised == accepted, (
+ f"{name}: advice says freeze={advised} but freeze returned {accepted} — "
+ f"two notions of authored")
+
+
+def test_authoring_advice_matches_the_freeze_refusal_sentence(tmp_path):
+ """covers: M3, A1 — one instruction, and it carries a runnable `add …` continuation."""
+ root = _bundle(tmp_path)
+ cid, _ = _scaffold(root, "s6")
+ advice = add._next_verb(add.scan(root), cid)
+ refusal = add.freeze(root, cid, by="probe")[1]
+ assert AUTHORING in advice.lower(), advice
+ assert "s6" in advice, advice
+ assert "add " in advice, f"an agent matching on `add ` loses its cue entirely: {advice}"
+ assert AUTHORING in refusal.lower(), refusal
+
+
+# ---- M4 · a frozen node is untouched ------------------------------------------------------
+
+def test_frozen_node_affordances_are_unchanged(tmp_path):
+ """covers: M4, A9 — a stamped node still points at the build beat."""
+ root = _bundle(tmp_path)
+ cid = _authored_task(root, "sealed")
+ assert bool(add.freeze(root, cid, by="probe")[0]) is True
+ nxt = add._next_verb(add.scan(root), cid)
+ assert AUTHORING not in nxt.lower(), f"a frozen node was dragged back to authoring: {nxt}"
+ assert "add brief sealed" == nxt, nxt
+
+
+def test_freeze_stamp_wins_over_placeholders(tmp_path):
+ """covers: E2, A9 — a pre-3.0 bundle's stamped-but-templated node is not re-authored."""
+ root = _bundle(tmp_path)
+ cid = _authored_task(root, "legacy")
+ add.freeze(root, cid, by="probe")
+ p = root / cid.lstrip("/")
+ p.write_text(p.read_text(encoding="utf-8").replace(
+ "- M1 the lister returns only the caller's rows",
+ "- M1 "), encoding="utf-8")
+ nxt = add._next_verb(add.scan(root), cid)
+ assert AUTHORING not in nxt.lower(), f"frozen must win over placeholders: {nxt}"
+
+
+# ---- the absent / degenerate readings ------------------------------------------------------
+
+def test_unknown_beat_still_falls_back_to_status(tmp_path):
+ """covers: A6 — an unrecognised beat degrades, never raises."""
+ assert add.BEAT_NEXT.get("no-such-beat", "add status") == "add status"
+
+
+def test_unreadable_body_advises_authoring(tmp_path):
+ """covers: A7 — cannot read the body -> advise authoring, the conservative direction."""
+ root = _bundle(tmp_path)
+ cid, _ = _scaffold(root, "s7")
+ p = root / cid.lstrip("/")
+ fm = p.read_text(encoding="utf-8").split("---")[1]
+ p.write_text(f"---{fm}---\n", encoding="utf-8") # frontmatter only, no body
+ nxt = add._next_verb(add.scan(root), cid)
+ assert AUTHORING in nxt.lower(), f"an unreadable body was advised to freeze: {nxt}"
+
+
+def test_authored_rules_with_template_gives_reads_unauthored(tmp_path):
+ """covers: E1, A8 — the two predicates are combined, not chosen between."""
+ root = _bundle(tmp_path)
+ cid = _authored_task(root, "s8")
+ p = root / cid.lstrip("/")
+ p.write_text(p.read_text(encoding="utf-8").replace(
+ "- S1 the lister",
+ "- S1 "), encoding="utf-8")
+ nxt = add._next_verb(add.scan(root), cid)
+ assert AUTHORING in nxt.lower(), f"template `gives:` still read as authored: {nxt}"
+
+
+def test_empty_frontier_affordance_is_unchanged(tmp_path):
+ """covers: E3, M4 — the no-open-task path is untouched."""
+ root = _bundle(tmp_path)
+ out = add.status(root)
+ assert "add new" in out, out
+
+
+def test_frontier_order_is_unchanged(tmp_path):
+ """covers: A10 — `ready()` ordering is identical before and after."""
+ root = _bundle(tmp_path)
+ for s in ("b-two", "a-one", "c-three"):
+ _scaffold(root, s)
+ assert add.ready(add.scan(root)) == sorted(add.ready(add.scan(root)))
+
+
+# ---- M7 · the Milestone guard that was never there -----------------------------------------
+
+def test_freeze_refuses_a_pure_milestone_scaffold(tmp_path):
+ """covers: M7, E4 — RED against today's engine, which records the stamp."""
+ root = _bundle(tmp_path)
+ cid, _ = _scaffold(root, "ms-tpl", node_type="Milestone")
+ ok, note = add.freeze(root, cid, by="probe")
+ assert bool(ok) is False, f"a milestone stating no goal took the ONE human approval: {note}"
+
+
+def test_milestone_guard_is_narrow_by_design(tmp_path):
+ """covers: M7 — authored goal · why · EXIT with a TEMPLATE GROUND still freezes.
+
+ M7 was narrowed on purpose. A guard reaching SCOPE and GROUND too would refuse real
+ milestones whose ground is thin, and a guard everyone learns to widen past is worse than
+ a narrow one that holds.
+ """
+ root = _bundle(tmp_path)
+ cid = _milestone_authored_narrowly(root, "narrow")
+ ok, note = add.freeze(root, cid, by="probe")
+ assert bool(ok) is True, f"the guard over-reached past goal/why/EXIT: {note}"
+
+
+def test_milestone_guard_names_sections_the_body_actually_has(tmp_path):
+ """covers: R:VACUOUS_GUARD — driven against BOTH halves.
+
+ A guard that looks in a section a Milestone body does not contain returns clean and passes
+ the accept half while failing the refuse half. Both must hold.
+ """
+ root = _bundle(tmp_path)
+ tpl, _ = _scaffold(root, "ms-vac", node_type="Milestone")
+ real = _authored_milestone(root, "ms-real")
+ assert bool(add.freeze(root, tpl, by="probe")[0]) is False, "the scaffold half"
+ assert bool(add.freeze(root, real, by="probe")[0]) is True, \
+ "the authored half — the guard over-refused"
+
+
+# ---- the closed rejects ---------------------------------------------------------------------
+
+def test_quick_lane_is_unaffected(tmp_path):
+ """covers: E5, R:NEW_VERB — a scaffold detector must not fire on the one-call lane's slots."""
+ root = _bundle(tmp_path)
+ cid = _authored_task(root, "q1", depth="quick")
+ assert bool(add.freeze(root, cid, by="probe")[0]) is True
+
+
+def test_no_new_verb_in_the_cli_surface():
+ """covers: R:NEW_VERB — the verb list is unchanged at 23."""
+ sys.path.insert(0, str(REPO / "tooling"))
+ import cli
+ verbs = [a.choices.keys() for a in cli.build_parser()._subparsers._group_actions]
+ assert len(list(verbs[0])) == 23, "this task changes what the engine SAYS, never its verb set"
+
+
+def test_status_frontmatter_vocabulary_is_unchanged(tmp_path):
+ """covers: R:STATUS_ENUM — the beat is DERIVED; no new `status:` value is written."""
+ root = _bundle(tmp_path)
+ cid, _ = _scaffold(root, "s9")
+ fm = add.scan(root)[cid]["fm"]
+ assert fm["status"] == "direction", fm["status"]
+ assert "scaffold" not in add.ACTIVE_STATES
+
+
+# ---- M8 · the replan's falsified A5 ---------------------------------------------------------
+
+def test_frozen_node_card_names_brief(tmp_path):
+ """covers: M8, S5 — the sibling the 2026-08-17 replan named.
+
+ A freshly frozen node advertised `next: add freeze ` in its own CARD — the approval it
+ had just passed — because `freeze` does not move `status:` and the CARD line is written once.
+ """
+ root = _bundle(tmp_path)
+ cid = _authored_task(root, "sealed-card")
+ assert bool(add.freeze(root, cid, by="probe")[0]) is True
+ add.render_card(root, cid)
+ card = next(ln for ln in (root / cid.lstrip("/"))
+ .read_text(encoding="utf-8").splitlines() if ln.startswith("beat:"))
+ assert "add freeze" not in card, f"a frozen node advertises the verb it already passed: {card}"
+ assert "brief" in card, card
+
+
+def test_card_drift_compares_the_derived_beat(tmp_path):
+ """covers: M8 — today `card_drift` compares against the RAW `status:` field.
+
+ `freeze` leaves `status: direction`, so the CARD's stale `direction` matches it and the
+ drift detector reports CLEAN on a node whose derived beat has moved to `build`.
+ """
+ root = _bundle(tmp_path)
+ cid = _authored_task(root, "drifted")
+ assert bool(add.freeze(root, cid, by="probe")[0]) is True
+ graph = add.scan(root)
+ assert add._beat_of(graph[cid]) == "build", "precondition: the derived beat moved"
+ drift = [d for d in add.card_drift(graph) if d[0] == cid]
+ assert drift, "card_drift called a node clean whose CARD names a beat it has left"
+
+
+def test_a_half_authored_node_still_reads_unauthored(tmp_path):
+ """covers: A4 — the probe: one remaining placeholder still reads unauthored.
+
+ A4 took all-gone, matching `freeze` exactly. Taking first-real-edit instead would advise a
+ half-authored node toward the freeze that refuses it — today's defect with a smaller window.
+ """
+ root = _bundle(tmp_path)
+ cid = _authored_task(root, "half")
+ p = root / cid.lstrip("/")
+ p.write_text(p.read_text(encoding="utf-8").replace(
+ "- M1 the lister returns only the caller's rows",
+ "- M1 "), encoding="utf-8")
+ graph = add.scan(root)
+ t2 = add.read(graph[cid]["path"], "T2")
+
+ assert add._is_scaffold(graph[cid], t2) is True, "one placeholder left must still read unauthored"
+ assert bool(add.freeze(root, cid, by="probe")[0]) is False, "and freeze must agree"
+
+
+def test_cold_resume_reaches_authoring_without_a_refusal(tmp_path):
+ """covers: A11 — the probe: the cold-resume path reaches authoring without first running a
+ verb that refuses.
+
+ A11's reader is the agent resuming a session having read nothing else: it reads `next:` and
+ acts. Before this, it ran `freeze`, read a refusal, and spent a turn rediscovering what
+ `status` could have said in the line it already printed.
+ """
+ root = _bundle(tmp_path)
+ _scaffold(root, "cold")
+ nxt = add.status(root).splitlines()[-1].removeprefix("next:").strip()
+
+ assert not _recommends_freeze(nxt, "cold"), nxt
+ assert AUTHORING in nxt.lower() and "cold" in nxt, nxt
+ # and the verb it DOES name, when reached, is not a refusal
+ assert "add freeze cold" in nxt, "the follow-on verb stays named, so an agent keeps a cue"
diff --git a/add-method/tests/engine/test_doctor.py b/add-method/tests/engine/test_doctor.py
index 452cd9f8..d3bef28a 100644
--- a/add-method/tests/engine/test_doctor.py
+++ b/add-method/tests/engine/test_doctor.py
@@ -318,7 +318,10 @@ def test_sync_repairs_card_drift(bundle):
cid, _ = add.new(bundle, "Task", "drifted-card", title="a card that disagrees with its stamps")
path = bundle / cid.lstrip("/")
n = add.read(path, "T2")
- add.write(path, f"---\n{n['raw']}\n---\n" + n["body"].replace("beat: direction", "beat: done"))
+ # the scaffold's own beat token moved to `scaffold` (authoring-beat-named, 2026-09-01), so
+ # the fixture drifts it from there — replacing a string the body no longer carries was a
+ # silent no-op, and the assertion below caught it.
+ add.write(path, f"---\n{n['raw']}\n---\n" + n["body"].replace("beat: scaffold", "beat: done"))
assert add.card_drift(add.load(bundle)), "the fixture did not actually drift"
add.doctor_sync(bundle)
assert not add.card_drift(add.load(bundle)), "sync left the CARD drifted"
diff --git a/add-method/tests/engine/test_new_scaffold.py b/add-method/tests/engine/test_new_scaffold.py
index d6f399cf..0011ff2f 100644
--- a/add-method/tests/engine/test_new_scaffold.py
+++ b/add-method/tests/engine/test_new_scaffold.py
@@ -17,4 +17,20 @@ def test_new_task_card_has_no_unexpanded_slug_marker(tmp_path):
cid, _ = add.new(tmp_path, "Task", "mul-fn", title="Add mul")
text = (tmp_path / cid.lstrip("/")).read_text(encoding="utf-8")
assert "{slug}" not in text, "the CARD scaffold leaked an unexpanded {slug} marker"
- assert "next: add freeze mul-fn" in text, "the CARD `next:` affordance does not name the real slug"
+ assert "mul-fn" in text, "the CARD `next:` affordance does not name the real slug"
+
+
+def test_new_scaffold_pins_the_corrected_affordance(tmp_path):
+ """covers: M6 — RE-AIMED, not deleted. The scaffold's `next:` stays a pinned interface;
+ it was the VALUE that was wrong, not the pinning.
+
+ A freshly created node carries nothing but placeholders, and `freeze` is structurally
+ guaranteed to refuse it (add.py:1394) — so the one string the CARD must not carry is the
+ verb that refusal names.
+ """
+ add.init(tmp_path, "code", "T")
+ cid, _ = add.new(tmp_path, "Task", "mul-fn", title="Add mul")
+ card = next(ln for ln in (tmp_path / cid.lstrip("/"))
+ .read_text(encoding="utf-8").splitlines() if ln.startswith("beat:"))
+ assert not card.strip().startswith("beat: direction"), card
+ assert "author" in card.lower() and "mul-fn" in card, card
diff --git a/add-method/tests/engine/test_todo.py b/add-method/tests/engine/test_todo.py
index 5f32c204..87064a11 100644
--- a/add-method/tests/engine/test_todo.py
+++ b/add-method/tests/engine/test_todo.py
@@ -76,7 +76,10 @@ def test_todo_beat_is_stamp_derived(tmp_path, draft):
beats = {cid.rsplit("/", 1)[-1][:-3]: st for cid, st, _ in items}
verbs = {cid.rsplit("/", 1)[-1][:-3]: nxt for cid, _, nxt in items}
assert beats["open1"] == "build", f"a frozen task is at the build beat: {beats}"
- assert beats["open2"] == "direction", f"an unfrozen task stays at direction: {beats}"
+ # RE-AIMED (authoring-beat-named, 2026-09-01): an unfrozen task that was never AUTHORED is
+ # now its own beat. The Must this covers is that the beat comes from the stamps rather than
+ # the `status:` field, and that still holds — it is the token that moved, not the derivation.
+ assert beats["open2"] == "scaffold", f"an unauthored task is at the scaffold beat: {beats}"
# beta-2 (R:UNBRIEFED): the build beat's first verb is the ENTRY — a sealed task that has
# not recorded a brief points at `add brief`; the run hint takes over once it has.
assert "add brief" in verbs["open1"], f"the build beat points at the entry first: {verbs}"
diff --git a/add-method/tests/skill/test_claimed_output_guard.py b/add-method/tests/skill/test_claimed_output_guard.py
new file mode 100644
index 00000000..fd02d4af
--- /dev/null
+++ b/add-method/tests/skill/test_claimed_output_guard.py
@@ -0,0 +1,387 @@
+"""A sentence claiming the engine PRINTS something is proven by running the command.
+
+`promised-capability-guard` closed this class for the READMEs and its own `why:` named the gap
+it left — those guards check nouns the engine EXPOSES, never capabilities the prose PROMISES.
+`loop.md` is where the gap bit: it told the reader `add status` shows
+`goal not met (m/n exit criteria)` and pointed at "the plan-vs-state line in `add status`".
+`status` prints neither. The first string did not even live in `add.py` any more — it had been
+reworded to `milestone_goal_unmet` inside `milestone_done`'s refusal — so an existence anchor of
+the kind the README guard uses would have resolved it and passed (R:GREP_ANCHOR).
+
+The anchor here is CAPTURED STDOUT from a driven command, never a string found in a source file.
+"""
+import hashlib
+import pathlib
+import re
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+import pytest
+
+REPO = Path(__file__).resolve().parents[2]
+ROOT = REPO.parent
+sys.path.insert(0, str(REPO / "tooling"))
+import add # noqa: E402
+
+TREES = (
+ ROOT / ".claude/skills/add",
+ REPO / "skill/add",
+ REPO / "src/add_method/_bundled/skill/add",
+)
+
+# A line that names a backticked `add ` AND claims it renders something.
+CLAIM = re.compile(r"`add [a-z-]+[^`]*`")
+RENDERS = re.compile(r"\b(shows|prints|displays|reports|lists|names)\b")
+
+
+# ---- the drivers: each puts a bundle into the state its claim describes ---------------------
+
+def _drive_persona_dash(tmp_path):
+ add.init(tmp_path, "code", "T")
+ add.new(tmp_path, "Persona", "a-lens", title="A lens")
+ return add.status(tmp_path)
+
+
+def _drive_goal_unmet(tmp_path):
+ add.init(tmp_path, "code", "T")
+ cid, _ = add.new(tmp_path, "Milestone", "m", title="m")
+ p = tmp_path / cid.lstrip("/")
+ t = p.read_text(encoding="utf-8")
+ t = t.replace("goal: ", "goal: real").replace(
+ "- [ ] (← )", "- [ ] a real criterion")
+ t = re.sub(r"why: <[^>]*>", "why: real", t)
+ p.write_text(t, encoding="utf-8")
+ return add.milestone_done(tmp_path, cid)[1]
+
+
+def _drive_scaffold_beat(tmp_path):
+ add.init(tmp_path, "code", "T")
+ add.new(tmp_path, "Task", "unauthored", title="unauthored")
+ return add.todo(tmp_path)[1]
+
+
+def _drive_status_names_the_beat(tmp_path):
+ add.init(tmp_path, "code", "T")
+ add.new(tmp_path, "Task", "named-beat", title="named beat")
+ return add.status(tmp_path)
+
+
+def _drive_todo_counts_unswept(tmp_path):
+ """A node authored far enough to reach the DIRECTION beat, with its sweep incomplete.
+
+ The countdown is the direction beat's hint. A node still carrying template RULES is at the
+ `scaffold` beat and is told to author instead — better guidance, and not this claim's state.
+ """
+ add.init(tmp_path, "code", "T")
+ cid, _ = add.new(tmp_path, "Task", "counting", title="counting")
+ p = tmp_path / cid.lstrip("/")
+ t = p.read_text(encoding="utf-8")
+ t = t.replace("- S1 ",
+ "- S1 a real surface")
+ t = re.sub(r"## RULES\n\n.*?\n",
+ "## RULES\n\n- M1 a real rule\n", t, flags=re.S)
+ t = re.sub(r"\n.*?\n",
+ '\n- R:BAD a real reject -> "BAD"\n', t, flags=re.S)
+ # only THREE of the six dimensions swept — so three pairs remain to count down
+ t = re.sub(r"## ASSUMPTIONS\n.*?\nevery `gives:`", "## ASSUMPTIONS\n" + "".join(
+ f"- A{i} [{d}] covers: S1 · nothing stated; plain reading -> minor\n"
+ for i, d in enumerate(("who", "which", "when"), 1)) + "every `gives:`", t, flags=re.S)
+ t = re.sub(r"## CHECKS\n.*?\nred-first",
+ "## CHECKS\n- test_a_real_check · covers: M1, R:BAD · proves it\nred-first",
+ t, flags=re.S)
+ p.write_text(t, encoding="utf-8")
+ return add.todo(tmp_path)[1]
+
+
+def _drive_deltas(tmp_path):
+ add.init(tmp_path, "code", "T")
+ add.learn(tmp_path, "method", "a lesson worth keeping", evidence="ref")
+ return add.deltas(tmp_path)[1]
+
+
+# ---- the registry: claim -> (driver, the substring its stdout MUST carry) -------------------
+#
+# Keyed by the file the claim lives in plus a fragment identifying the sentence, so a reworded
+# claim falls out of the registry and fails as unregistered rather than silently matching.
+REGISTRY = {
+ ("seed.md", "[—]"): (_drive_persona_dash, "[—]"),
+ ("loop.md", "milestone_goal_unmet"): (_drive_goal_unmet, "milestone_goal_unmet"),
+ ("loop.md", "scaffold"): (_drive_scaffold_beat, "scaffold"),
+ ("deltas.md", "files, lists, and folds"): (_drive_deltas, "open"),
+ ("SKILL.md", "names next"): (_drive_status_names_the_beat, "next:"),
+ ("SKILL.md", "counts them down"): (_drive_todo_counts_unswept, "unswept"),
+}
+UNPROVABLE = {} # a claim whose bundle state cannot be built — reported BY NAME (M5)
+
+
+def _claims_in(tree):
+ """Every line in a skill file that names a backticked `add ` AND claims a rendering."""
+ out = []
+ for f in sorted(tree.glob("*.md")):
+ for i, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
+ if CLAIM.search(line) and RENDERS.search(line):
+ out.append((f.name, i, line.strip()))
+ return out
+
+
+def _key_for(name, line):
+ for (fname, frag) in REGISTRY:
+ if fname == name and frag in line:
+ return (fname, frag)
+ return None
+
+
+def verify(trees, registry=None, tmp=None):
+ """(proven, unregistered, unprovable, message) — the guard, as one callable.
+
+ Returning the parts rather than asserting lets the checks below probe the guard's own
+ behaviour: that its two failure causes read differently (A7), that its message names both
+ remedies (A10), and that it can still be shown RED against an unrepaired tree (A8).
+ """
+ registry = REGISTRY if registry is None else registry
+ unregistered, proven, unprovable = [], [], []
+ for tree in trees:
+ for name, i, line in _claims_in(tree):
+ if _key_for(name, line) is None:
+ unregistered.append(f"{tree.parent.name}/{name}:{i} {line}")
+ for key in sorted(registry):
+ if key in UNPROVABLE:
+ unprovable.append(f"{key[0]}::{key[1]} — {UNPROVABLE[key]}")
+ continue
+ driver, must_carry = registry[key]
+ work = pathlib.Path(tempfile.mkdtemp()) if tmp is None else tmp
+ out = driver(work)
+ (proven if must_carry in out else unprovable).append(f"{key[0]}::{key[1]}")
+ msg = ""
+ if unregistered:
+ msg += ("UNREGISTERED — these sentences claim the engine renders something and no driver "
+ "proves it. Register a driven proof, or REWORD the sentence to what the engine "
+ "does today (rewording is the intended repair):\n " + "\n ".join(unregistered))
+ if unprovable:
+ msg += ("\nUNPROVEN — registered, driven, and the command did not print it:\n "
+ + "\n ".join(unprovable))
+ return proven, unregistered, unprovable, msg
+
+
+# ---- the sixteen checks the frozen contract names -------------------------------------------
+
+def test_registry_covers_every_output_claim():
+ """covers: M1, M2, A3 — an unregistered claim fails quoting its text, file and line."""
+ _, unregistered, _, msg = verify(TREES)
+ assert not unregistered, msg
+
+
+def test_each_claim_is_proven_by_driven_stdout(tmp_path):
+ """covers: M1, R:GREP_ANCHOR — proven from captured stdout, never by reading add.py."""
+ proven, _, unprovable, msg = verify(TREES)
+ assert not unprovable, msg
+ assert proven, "the registry proved nothing at all"
+ # that the anchor is STDOUT rather than engine source is proven properly by
+ # test_source_presence_alone_does_not_satisfy_a_claim, which constructs the case.
+
+
+def test_source_presence_alone_does_not_satisfy_a_claim(tmp_path):
+ """covers: R:GREP_ANCHOR, E1 — a string that IS in add.py but is NOT printed still fails.
+
+ E1 named `goal not met (m/n exit criteria)` at add.py:1424 as this case. That string has
+ since been reworded to `milestone_goal_unmet`, so the claim is now false in an even
+ stronger way — nothing anchors it anywhere. The property is proven with a synthetic entry
+ instead: a token present in the engine source and absent from the driven stdout.
+ """
+ token = "SENSITIVITY_FLOOR" # unmistakably present in add.py
+ assert token in (REPO / "tooling/add.py").read_text(encoding="utf-8")
+ fake = {("seed.md", "[—]"): (_drive_persona_dash, token)}
+ _, _, unprovable, _ = verify([], registry=fake, tmp=tmp_path)
+ assert unprovable, "a source-only string satisfied a claim — the anchor is not stdout"
+
+
+def test_claim_not_satisfied_by_its_own_words_elsewhere(tmp_path):
+ """covers: R:SELF_PROVING — a claim's text appearing in another skill file proves nothing."""
+ phrase = "plan-vs-state"
+ (TREES[0] / "loop.md").read_text(encoding="utf-8")
+ fake = {("seed.md", "[—]"): (_drive_persona_dash, phrase)}
+ _, _, unprovable, _ = verify([], registry=fake, tmp=tmp_path)
+ assert unprovable, "a claim was satisfied by corpus text rather than by engine output"
+
+
+def test_unprovable_claims_are_named_and_counted():
+ """covers: M5, A6 — an unconstructible state is named, never skipped."""
+ proven, _, unprovable, _ = verify(TREES)
+ assert len(proven) == len(REGISTRY) - len(UNPROVABLE), \
+ f"proven {len(proven)} of {len(REGISTRY)} registered — some claim was silently dropped"
+ assert not UNPROVABLE, f"unprovable claims must be reworded or driven: {UNPROVABLE}"
+
+
+def test_unregistered_and_failing_read_differently(tmp_path):
+ """covers: A7 — the two failure causes produce distinct messages."""
+ _, _, unprovable, unproven_msg = verify(
+ [], registry={("seed.md", "[—]"): (_drive_persona_dash, "NEVER-PRINTED")}, tmp=tmp_path)
+ assert "UNPROVEN" in unproven_msg and "UNREGISTERED" not in unproven_msg, unproven_msg
+ assert unprovable
+
+
+def test_failure_message_names_both_remedies(tmp_path):
+ """covers: A10 — register a driven proof, or reword; rewording named as intended."""
+ fake_tree = tmp_path / "skills/add"
+ fake_tree.mkdir(parents=True)
+ (fake_tree / "loop.md").write_text("`add status` shows a thing nobody registered.\n",
+ encoding="utf-8")
+ _, unregistered, _, msg = verify([fake_tree])
+ assert unregistered
+ assert "Register a driven proof" in msg and "REWORD" in msg and "intended repair" in msg, msg
+
+
+def test_guard_is_red_on_the_unrepaired_tree(tmp_path):
+ """covers: A8, M2 — driven against the tree as it stood, the guard names both sentences."""
+ unrepaired = tmp_path / "skills/add"
+ unrepaired.mkdir(parents=True)
+ (unrepaired / "loop.md").write_text(
+ "Every task done but the goal unmet? `add status` shows `goal not met (m/n exit criteria)`.\n"
+ " - planned-but-unscaffolded tasks — the plan-vs-state line in `add status`;\n",
+ encoding="utf-8")
+ _, unregistered, _, msg = verify([unrepaired])
+ assert any("goal not met" in u for u in unregistered), msg
+ assert "plan-vs-state" in msg or len(unregistered) >= 1, msg
+
+
+def test_loop_claims_are_repaired_in_all_three_trees():
+ """covers: M3, R:TWO_TREE — neither false sentence survives in any live tree.
+
+ NOT parametrized: `gate` binds `covers:` referents by BARE test id, and a parametrized name
+ reports as `test_x[param]`, which binds nothing. The rule this covers would have read as
+ unbound while the check sat green.
+ """
+ survived = []
+ for tree in TREES:
+ text = (tree / "loop.md").read_text(encoding="utf-8")
+ if "goal not met (m/n exit criteria)" in text:
+ survived.append(f"{tree}: the false status claim")
+ if "plan-vs-state" in text:
+ survived.append(f"{tree}: the plan-vs-state claim, which has no implementation")
+ assert not survived, "a two-tree repair ships a mirror gap:\n " + "\n ".join(survived)
+
+
+def test_failure_quotes_the_sentence_not_just_its_location(tmp_path):
+ """covers: A1 — no reviewer is assumed, so the refusal must carry the sentence itself."""
+ tree = tmp_path / "skills/add"
+ tree.mkdir(parents=True)
+ sentence = "`add status` shows a brand new thing nobody registered."
+ (tree / "loop.md").write_text(sentence + "\n", encoding="utf-8")
+ _, unregistered, _, msg = verify([tree])
+ assert sentence in msg, f"the editor cannot see WHAT was rejected:\n{msg}"
+ assert "loop.md:1" in msg, msg
+
+
+def test_only_rendering_sentences_are_collected(tmp_path):
+ """covers: A2 — a command named in prose is not an output claim; naming a rendering is."""
+ tree = tmp_path / "skills/add"
+ tree.mkdir(parents=True)
+ (tree / "loop.md").write_text(
+ "Run `add freeze ` to close direction — this describes the method, not output.\n"
+ "`add deltas` lists every open lesson.\n", encoding="utf-8")
+ collected = [line for _, _, line in _claims_in(tree)]
+ assert len(collected) == 1, collected
+ assert "lists" in collected[0], collected
+
+
+def test_claims_are_reread_from_disk_on_every_run(tmp_path):
+ """covers: A4 — the claim must hold on every run against the WORKING TREE.
+
+ A snapshot taken once would let a `status` change drop a line and the skill's checks stay
+ green until someone re-imported the module.
+ """
+ tree = tmp_path / "skills/add"
+ tree.mkdir(parents=True)
+ f = tree / "loop.md"
+ f.write_text("nothing claimed here.\n", encoding="utf-8")
+ assert _claims_in(tree) == []
+ f.write_text("`add deltas` lists every open lesson.\n", encoding="utf-8")
+ assert len(_claims_in(tree)) == 1, "the corpus was cached rather than re-read"
+
+
+def test_the_source_tree_is_the_one_the_engine_ships():
+ """covers: A9 — `add-method/skill/add/` is the source; the other two are mirrors."""
+ assert TREES[1] == REPO / "skill/add", TREES[1]
+ for f in sorted(TREES[1].glob("*.md")):
+ for mirror in (TREES[0], TREES[2]):
+ assert (mirror / f.name).exists(), f"{mirror} is missing {f.name}"
+
+
+def test_a_costly_state_is_constructed_or_named(tmp_path):
+ """covers: E3, M5 — a claim needing a fully driven milestone is built, not skipped.
+
+ E3 is the reason M5 exists: the goal-unmet state is constructible only by putting a
+ milestone into it. This proves it is actually constructed — the driver authors the
+ milestone and reads the real refusal — rather than being dropped as too expensive.
+ """
+ out = _drive_goal_unmet(tmp_path)
+ assert "milestone_goal_unmet" in out and "exit criteria" in out, out
+ assert not UNPROVABLE, f"a claim was parked as unprovable instead of driven: {UNPROVABLE}"
+
+
+def test_repaired_sentences_are_registered():
+ """covers: A5, M6 — the replacements are themselves entries, each driven in one command."""
+ text = (TREES[0] / "loop.md").read_text(encoding="utf-8")
+ assert "milestone_goal_unmet" in text and "`scaffold` beat" in text, text[:200]
+ assert ("loop.md", "milestone_goal_unmet") in REGISTRY
+ assert ("loop.md", "scaffold") in REGISTRY
+
+
+def test_repaired_gather_step_still_has_a_trigger(tmp_path):
+ """covers: M6, A11 — the Gather step's cue names something an agent can observe."""
+ cue = _drive_goal_unmet(tmp_path)
+ assert "milestone_goal_unmet" in cue and "exit criteria" in cue, cue
+
+
+def test_no_engine_output_was_added():
+ """covers: M4, R:FEATURE_CREEP — add.py is untouched by this task.
+
+ Building the missing `status` surface is a real improvement and a SEPARATE task. A guard
+ that ships having made its own claims true has never once refused anything.
+ """
+ diff = subprocess.run(["git", "diff", "HEAD", "--", "tooling/add.py"],
+ cwd=str(REPO), capture_output=True, text=True)
+ assert diff.stdout.strip() == "", "this task changed the engine:\n" + diff.stdout[:800]
+
+
+def test_no_true_claim_was_deleted():
+ """covers: R:CULL — a true statement about engine output belongs in the skill."""
+ claims = _claims_in(TREES[0])
+ assert len(claims) >= 4, f"the corpus lost claims rather than repairing them: {claims}"
+ text = (TREES[0] / "seed.md").read_text(encoding="utf-8")
+ assert "[—]" in text, "a TRUE claim was culled to reach green"
+
+
+def test_status_flag_modes_are_driven_as_registered(tmp_path):
+ """covers: E2 — a claim naming a flagged form is proven against that form."""
+ add.init(tmp_path, "code", "T")
+ add.new(tmp_path, "Task", "flagged", title="flagged")
+ bare, allf = add.status(tmp_path), add.status(tmp_path, all=True)
+ assert "next:" in bare and "next:" in allf
+ for (fname, frag) in REGISTRY:
+ line = next((l for _, _, l in _claims_in(TREES[0])
+ if fname in (f.name for f in [TREES[0] / fname]) and frag in l), None)
+ if line and "--all" in line:
+ assert "--all" in repr(REGISTRY[(fname, frag)][0]), \
+ f"{fname}::{frag} names a flag but is driven bare"
+
+
+def test_parenthetical_claims_are_registered():
+ """covers: E4 — a command named inside a parenthetical is still an output claim."""
+ text = (TREES[0] / "loop.md").read_text(encoding="utf-8")
+ assert "`add milestone-done ` REFUSES" in text, "the milestone-done claim moved"
+ _, unregistered, _, msg = verify(TREES)
+ assert not any("milestone-done" in u and "REFUSES" in u for u in unregistered), msg
+
+
+def test_skill_tree_mirror_parity():
+ """covers: E5, R:TWO_TREE — the three trees are identical; a pre-existing gap is reported."""
+ drift = []
+ for f in sorted(TREES[0].glob("*.md")):
+ digests = {str(t): hashlib.md5((t / f.name).read_bytes()).hexdigest()
+ for t in TREES if (t / f.name).exists()}
+ if len(set(digests.values())) > 1 or len(digests) != len(TREES):
+ drift.append(f"{f.name}: {digests}")
+ assert not drift, "the live skill trees have drifted:\n " + "\n ".join(drift)
diff --git a/add-method/tooling/add.py b/add-method/tooling/add.py
index 8208c687..2f710726 100644
--- a/add-method/tooling/add.py
+++ b/add-method/tooling/add.py
@@ -1090,7 +1090,8 @@ def refuse(why: str, fix: str) -> tuple:
"Persona": "personas", "Prompt": "prompts", "Run": "runs"}
BODIES = {
"Task": "## CARD\ngoal: \nwhy: \n"
- "beat: direction · next: add freeze {slug}\n\n"
+ "beat: scaffold · next: author {slug}'s RULES, ASSUMPTIONS and CHECKS, "
+ "then add freeze {slug}\n\n"
"## RULES\n\n- M1 \n\n\n"
"- R: -> \"\"\n\n\n"
# The line the author fills STARTS from "the request does not say" — the
@@ -1371,7 +1372,10 @@ def new(root, node_type: str, slug: str, **fields) -> tuple:
scaffold = BODIES.get(node_type, "## CARD\ngoal: \n").replace("{slug}", slug)
write(path, "---\n" + "\n".join(lines) + "\n---\n" + scaffold)
# freeze is a lifecycle act — a Persona/Prompt/Run is done the moment it is written.
- nxt = f"add freeze {slug}" if node_type in LIFECYCLE_TYPES else "add status"
+ # A file of placeholders is a scaffold, and `freeze` is guaranteed to refuse one — so the
+ # message `new` hands back names the authoring work, not the approval that follows it.
+ nxt = (AUTHOR_NEXT.get(node_type, AUTHOR_NEXT["Task"]).format(slug=slug)
+ if node_type in LIFECYCLE_TYPES else "add status")
return "/" + rel, f"created {rel}\nnext: {nxt}"
@@ -1391,6 +1395,15 @@ def freeze(root, cid: str, by: str, authority: str = None) -> tuple:
slug = cid.rsplit("/", 1)[-1][:-3]
node_t2 = read(entry["path"], "T2")
+ if (entry.get("fm") or {}).get("type") == "Milestone":
+ # The guard `placeholders_in` could never make: it reads RULES · ASSUMPTIONS · CHECKS and a
+ # Milestone body carries none of those three, so it returned [] for EVERY milestone and the
+ # ONE human approval was stampable against a node stating no goal and no exit criterion.
+ ms_stubs = _milestone_stubs(node_t2)
+ if ms_stubs:
+ return False, (f"cannot freeze `{slug}` — this milestone is still a scaffold: "
+ + " · ".join(ms_stubs)
+ + f"\nnext: {AUTHOR_NEXT['Milestone'].format(slug=slug)}")
stubs = placeholders_in(node_t2)
if stubs:
return None, (f"cannot freeze `{slug}` — the node still carries template placeholders: "
@@ -1778,8 +1791,18 @@ def milestone_archive(root, cid: str) -> tuple:
BEAT_KEYS = ("beat", "state")
# The one canonical next verb per beat — read by `status`'s frontier hint and `render_card`, so a
# repaired CARD's `next:` matches its beat instead of freezing at the direction-time affordance.
-BEAT_NEXT = {"direction": "add freeze {slug}", "build": "add run {slug} -- ",
+# The authoring beat has NO VERB by design — `direction.md`: "There is no author verb — you fill
+# those sections by editing that file directly". So its advice names the WORK and the verb that
+# follows it, matching `freeze`'s own refusal sentence word for word, and still carries a runnable
+# `add …` continuation so an agent matching on a leading verb keeps a cue (A1).
+AUTHOR_NEXT = {
+ "Task": "author {slug}'s RULES, ASSUMPTIONS and CHECKS, then add freeze {slug}",
+ "Milestone": "author {slug}'s goal, why and EXIT criteria, then add freeze {slug}",
+}
+BEAT_NEXT = {"scaffold": AUTHOR_NEXT["Task"], "direction": "add freeze {slug}",
+ "build": "add run {slug} -- ",
"verify": "add gate {slug}", "done": "add status"}
+BEAT_NAMES = ("scaffold", "direction", "build", "verify", "done")
# What a cold reader needs, in order. `Run` is absent on purpose — see `status`.
ORIENT_RANK = {"Project": 0, "Milestone": 1, "Task": 2, "Spec": 5, "Persona": 6, "Prompt": 7}
@@ -1792,6 +1815,64 @@ def _is_frozen(node) -> bool:
return any(isinstance(s, dict) and s.get("act") in ("freeze", "refreeze") for s in stamps)
+def _milestone_stubs(node: dict) -> list:
+ """The Milestone fields still template, among the three the lifecycle actually reads.
+
+ Deliberately narrower than the Task guard (decided 2026-09-01). `milestone_done` already
+ refuses on `why:` and on the `## EXIT` tally, so goal · why · EXIT are what the milestone
+ lifecycle depends on. A guard reaching SCOPE and GROUND too would refuse real milestones
+ whose ground is thin, and a guard everyone learns to widen past is worse than a narrow one
+ that holds.
+ """
+ body = node.get("body") or ""
+ out = []
+ for line in card_of(body).splitlines():
+ key, sep, value = line.partition(":")
+ if sep and key.strip() in ("goal", "why") and PLACEHOLDER.search(value):
+ out.append(f"CARD `{key.strip()}:`")
+ exit_body = _section_of(body, "EXIT")
+ boxes = _box_lines(exit_body) if _fence_balanced(exit_body) else []
+ if not boxes or any(PLACEHOLDER.search(text) for _, _, text, _ in boxes):
+ out.append("`## EXIT` criteria")
+ return out
+
+
+def _is_scaffold(node, t2=None) -> bool:
+ """True for a node that was created and never authored. ONE definition, two tiers.
+
+ Calls the SAME predicates the refusals call — `gives_unauthored` at T0, `placeholders_in`
+ (or `_milestone_stubs`) at T2 — never a copy of them (R:SECOND_TRUTH). Two notions of
+ "authored" is exactly how advice and refusal came to disagree, which is the defect one
+ layer up.
+
+ The T2 half runs ONLY when the caller already holds the body. `status` must not read a
+ single body — that is `build-orient`'s R:T2SCAN, a Reject frozen before this task existed
+ and not this task's to weaken — so it gets the T0 answer, while `freeze` and `todo`, which
+ both already read the body for their own reasons, get the complete one. The tiers cannot
+ disagree in DIRECTION: T0 saying scaffold is always right, and the T2 half only ever adds.
+
+ Residual, recorded rather than hidden: a node with an authored `gives:` but still-template
+ RULES reads authored to `status` alone. `todo` and `freeze` both catch it.
+
+ A freeze stamp WINS over any placeholder: a pre-3.0 bundle can carry both, and dragging an
+ approved node back into authoring advice would undo an approval that was actually given (A9).
+ An unreadable body advises authoring — the conservative direction, since the alternative is
+ to recommend a verb whose refusal is the author's first news of the problem (A7).
+ """
+ if _is_frozen(node):
+ return False
+ fm = node.get("fm") or {}
+ if fm.get("type") not in LIFECYCLE_TYPES:
+ return False
+ if fm.get("type") == "Task" and gives_unauthored(node):
+ return True # T0, and enough on its own
+ if t2 is None:
+ return False # the caller holds no body — T0 is the whole answer
+ if fm.get("type") == "Milestone":
+ return bool(_milestone_stubs(t2))
+ return bool(placeholders_in(t2))
+
+
def replan(root, cid: str, note: str, by: str = "builder") -> tuple:
"""Record a steering amendment on a frozen task — one additive act stamp, the seal untouched.
@@ -1836,16 +1917,20 @@ def card_drift(graph: dict) -> list:
"""
out = []
for cid, node in graph.items():
- status = (node["fm"] or {}).get("status")
- if not status:
+ if not (node["fm"] or {}).get("status"):
continue
+ # The DERIVED beat, never the raw `status:` field. `freeze` does not move `status:`, so a
+ # freshly frozen node advertised `next: add freeze ` — the approval it had just
+ # passed — while `todo` and `status` derived `build`, and this reported it CLEAN
+ # (2026-08-17 replan, A5 falsified). Two notions of beat, read by different surfaces.
+ beat = _beat_of(node)
card = card_of(read(node["path"], "T2")["body"])
for line in card.splitlines():
key, sep, value = line.partition(":")
if sep and key.strip() in BEAT_KEYS:
said = value.split("·")[0].strip()
- if said and said != status and said in ("direction", "build", "verify", "done"):
- out.append((cid, key.strip(), said, status))
+ if said and said != beat and said in BEAT_NAMES:
+ out.append((cid, key.strip(), said, beat))
return out
@@ -1863,9 +1948,12 @@ def render_card(root, cid: str) -> tuple:
for i, line in enumerate(lines):
if line.startswith(f"{key}:") and said in line:
# Rebuild the WHOLE beat line, not just the token: the `next:` on it froze at the
- # direction-time affordance, so a done card kept reading `next: add freeze`. BEAT_NEXT
- # gives the beat its true next verb. Still exactly one line changes (idempotence holds).
- nxt = BEAT_NEXT.get(status, "add status").format(slug=slug)
+ # direction-time affordance, so a done card kept reading `next: add freeze`. Through
+ # `_next_verb`, not BEAT_NEXT directly — that is the one map every other surface
+ # resolves through, and it alone knows a sealed-but-unbriefed task owes `add brief`
+ # before its run (R:UNBRIEFED). Reading the map raw put a third answer on the CARD.
+ # Still exactly one line changes (idempotence holds).
+ nxt = _next_verb(graph, cid)
lines[i] = f"{key}: {status} · next: {nxt}\n"
break
write(path, f"---\n{node['raw']}\n---\n{''.join(lines)}")
@@ -1916,7 +2004,7 @@ def locate(root, query: str) -> tuple:
return hits, f"{len(hits)} node(s) scope `{query}`:\n" + "\n".join(lines) + "\nnext: add status"
-def _beat_of(node) -> str:
+def _beat_of(node, t2=None) -> str:
"""A task's beat, DERIVED from its stamps — the same reasoning as `_is_frozen`.
`status` runs `direction → done`: nothing in `freeze`/`run` advances it, so the field cannot
@@ -1930,7 +2018,9 @@ def _beat_of(node) -> str:
stamps = [s for s in (fm.get("verified") or []) if isinstance(s, dict)]
if any(s.get("act") == "run" for s in stamps):
return "verify"
- return "build" if _is_frozen(node) else "direction"
+ if _is_frozen(node):
+ return "build"
+ return "scaffold" if _is_scaffold(node, t2) else "direction"
def _brief_entered(stamps: list, receipt_cid: str = None) -> bool:
@@ -1952,11 +2042,15 @@ def _brief_entered(stamps: list, receipt_cid: str = None) -> bool:
for i, s in enumerate(stamps))
-def _next_verb(graph: dict, cid: str) -> str:
- """The one runnable next command for a task, by its stamp-derived beat."""
+def _next_verb(graph: dict, cid: str, t2=None) -> str:
+ """The one runnable next command for a task, by its stamp-derived beat.
+
+ `t2` is the node's body when the caller already holds it — `todo` does. Without it the beat
+ is derived at T0, which is what `status` requires (`build-orient`'s R:T2SCAN).
+ """
slug = cid.rsplit("/", 1)[-1][:-3]
node = graph[cid]
- beat = _beat_of(node)
+ beat = _beat_of(node, t2)
fm = node.get("fm") or {}
# W1 (R:UNBRIEFED): at the build beat the ENTRY comes first — a sealed, unbriefed task
# points at `add brief`, and moves on to the run the moment the entry is recorded.
@@ -1964,6 +2058,8 @@ def _next_verb(graph: dict, cid: str) -> str:
and str(fm.get("depth") or "standard") != "quick" \
and sealed_direction(fm) and not _brief_entered(fm.get("verified") or []):
return f"add brief {slug}"
+ if beat == "scaffold":
+ return AUTHOR_NEXT.get(str(fm.get("type")), AUTHOR_NEXT["Task"]).format(slug=slug)
return BEAT_NEXT.get(beat, "add status").format(slug=slug)
@@ -1972,18 +2068,28 @@ def todo(root, milestone: str = None) -> tuple:
verb. Optionally restricted to one milestone. Read-only — `(items, note)`, never a write."""
graph = scan(Path(root))
msel = _wave_slug(milestone) if milestone else None
- items = []
+ # ONE body read per open task, reused by the hint loop below — deriving the beat and then
+ # re-reading the same file to build its hint read every direction-beat node twice.
+ items, bodies = [], {}
for cid in active(graph):
fm = graph[cid]["fm"] or {}
if fm.get("type") != "Task":
continue
if msel and _wave_slug(fm.get("milestone")) != msel:
continue
- items.append((cid, _beat_of(graph[cid]), _next_verb(graph, cid)))
+ # ONE body read per node, handed to both derivations — `todo` is not T0-bound (it already
+ # reads the body for the unswept-pairs hint), so its arrow gets the COMPLETE scaffold
+ # answer rather than the T0 half `status` must settle for.
+ try:
+ t2 = read(graph[cid]["path"], "T2")
+ except (OSError, ValueError, KeyError, TypeError):
+ t2 = None
+ bodies[cid] = t2
+ items.append((cid, _beat_of(graph[cid], t2), _next_verb(graph, cid, t2)))
if not items:
where = f" under `{milestone}`" if milestone else ""
return [], f"nothing open{where}\nnext: add status"
- order = {"direction": 0, "build": 1, "verify": 2}
+ order = {"scaffold": -1, "direction": 0, "build": 1, "verify": 2}
items.sort(key=lambda it: (order.get(it[1], 9), it[0]))
lines, beat = [], None
for cid, st, nxt in items:
@@ -1995,7 +2101,7 @@ def todo(root, milestone: str = None) -> tuple:
# Progressive, so freeze CONFIRMS work already done instead of ambushing the
# author with the whole matrix at the moment they expected to be finished. A
# gate first met as a wall earns a reputation for obstruction, not for catching.
- node_t2 = read(graph[cid]["path"], "T2")
+ node_t2 = bodies.get(cid) or read(graph[cid]["path"], "T2")
if gives_unauthored(node_t2) and _section_of(node_t2.get("body") or "",
"ASSUMPTIONS").strip():
hint = " (gives: unauthored — no surfaces to sweep)"
@@ -2085,10 +2191,10 @@ def keep(cid):
nxt = f"next: add gate {waiting[0].rsplit('/', 1)[-1][:-3]}"
elif frontier:
f0 = frontier[0]
- # An unfrozen frontier task still needs its direction authored + frozen; brief would compile a
- # node of placeholders. A frozen one is ready to hand to a builder. Pick the verb by the stamp.
- verb = "brief" if _is_frozen(graph[f0]) else "freeze"
- nxt = f"next: add {verb} {f0.rsplit('/', 1)[-1][:-3]}"
+ # Through `_next_verb`, so this hint and `todo`'s arrow cannot disagree — the stamp test
+ # that used to live here was a third reading of the beat, and a node that was created and
+ # never authored got advised toward the freeze that is structurally guaranteed to refuse it.
+ nxt = f"next: {_next_verb(graph, f0)}"
elif any((n["fm"] or {}).get("type") == "Milestone" for n in graph.values()):
nxt = "next: add new task "
else:
diff --git a/add-method/tooling/engine_pin.py b/add-method/tooling/engine_pin.py
index 8bf21648..366b048f 100644
--- a/add-method/tooling/engine_pin.py
+++ b/add-method/tooling/engine_pin.py
@@ -17,7 +17,7 @@
this file only ever holds the newest pointer.
"""
-ENGINE_MD5 = "0456c609b4160c2e5683c4becbbce516" # re-aimed @ premerge-review: `_changed_paths` reads the porcelain -z stream as git writes it (unstripped, renames pairwise, rebased onto the bundle parent), the junit staleness test floors the start time, and the goal-gate refuses an EXIT it cannot read. prior: 8fe73c60… @ enforcement-gaps
+ENGINE_MD5 = "1bf617103cdfbe76006238e0c771658c" # re-aimed @ authoring-beat-named: a node that was never authored is never advised to freeze (the `scaffold` beat), `freeze` refuses a template Milestone, and `card_drift` compares the DERIVED beat. prior: 0456c609… @ premerge-review
# ADD 3.0 (ABF-1): the engine is a flat two-file pair (add.py + cli.py), no add_engine/ package.
# ENGINE_PKG_MD5 is repurposed to pin the dispatch entry cli.py (the second engine file).
ENGINE_PKG_MD5 = "fced1ae9ad5743024aaa2610e5a99a7c" # re-aimed @ enforcement-gaps (`check` records the caller context: tty vs process). prior: 5c37c3e0… @ box-check-verb