From 9c4eeeb93a5cd13dd7227fcaa38b68f4f566a39c Mon Sep 17 00:00:00 2001 From: Tin Dang Date: Tue, 14 Jul 2026 18:10:39 +0700 Subject: [PATCH 1/6] =?UTF-8?q?feat(method):=20init-idempotent-nudge=20?= =?UTF-8?q?=E2=80=94=20re-init=20is=20an=20exit-0=20no-op=20resume=20point?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit call-residuals task 1/4. The WM1 re-measure showed every rep re-running `init` on an already-initialised project (+2-4 calls/rep — the double-init lever). - cmd_init: a re-init WITHOUT --force is now a LOUD NO-OP — exit 0, print the resume pointer, write NOTHING (early return before any seed). --force still resets; a fresh init still seeds. Reverses the prior nonzero-refusal contract. - cmd_status: the default view opens with "project exists — do not re-init (use --force to reset)" when state.json is present, so the agent never re-calls init in the first place. --brief/--json/--section unaffected. Change-request supersession (human-approved, not a weakening): the frozen init-resume-pointer v1 refusal contract is replaced by the exit-0 no-op; the three legacy tests pinning it (test_init_resume_pointer, test_add's reinit test) flip to the new contract, and test_init_idempotent_nudge pins the full new behavior (5 asserts, red-first). Zero enforcement-path change (milestone OUT-of-scope). Engine synced ×4 twins, ENGINE_MD5 -> ee4ef957, SEAMS _declared_scope pin 5653->5670. Full fence green (3575 tests). One human freeze at the frozen §3 contract; scope re-crossed once after the pin/twin/test touches widened §5. author: Tin Dang --- .add/SEAMS.md | 2 +- .add/state.json | 71 ++++++++++++-- .add/tasks/init-idempotent-nudge/TASK.md | 97 +++++++++++++++++++ .../src/add_method/_bundled/tooling/add.py | 19 +++- add-method/tooling/add.py | 19 +++- add-method/tooling/engine_pin.py | 2 +- add-method/tooling/test_add.py | 14 ++- .../tooling/test_init_idempotent_nudge.py | 83 ++++++++++++++++ .../tooling/test_init_resume_pointer.py | 18 ++-- 9 files changed, 303 insertions(+), 22 deletions(-) create mode 100644 .add/tasks/init-idempotent-nudge/TASK.md create mode 100644 add-method/tooling/test_init_idempotent_nudge.py diff --git a/.add/SEAMS.md b/.add/SEAMS.md index c955556c..74b37346 100644 --- a/.add/SEAMS.md +++ b/.add/SEAMS.md @@ -54,7 +54,7 @@ Citations: 232 files reference "byte-identical" in `.add/tasks/` — method: ## scope-token-grammar Name: §5 "Scope (may touch):" token-resolution grammar -Anchor: `add-method/tooling/add.py:5653` (`_declared_scope`) +Anchor: `add-method/tooling/add.py:5670` (`_declared_scope`) Contract: `_declared_scope` reads ONLY the first physical line after the §5 header — a wrapped multi-line list silently truncates. Each backticked token then resolves independently: `./...` = this task's dir, any token containing `/` = project-root-relative, diff --git a/.add/state.json b/.add/state.json index 6411dcc9..93fc1216 100644 --- a/.add/state.json +++ b/.add/state.json @@ -1,7 +1,7 @@ { "project": "AIDD / ADD Methodology", "stage": "mvp", - "active_task": null, + "active_task": "init-idempotent-nudge", "tasks": { "add-check": { "title": "add.py check: validate .add project integrity", @@ -9366,10 +9366,59 @@ "email": "tindang.ht97@gmail.com", "source": "git" } + }, + "init-idempotent-nudge": { + "title": "init on an existing project is a loud no-op resume pointer", + "phase": "done", + "gate": "PASS", + "milestone": "call-residuals", + "depends_on": [], + "created": "2026-07-14T10:41:44+00:00", + "updated": "2026-07-14T11:10:06+00:00", + "fast": true, + "freeze": { + "version": "v1", + "frozen_at": "2026-07-14T10:57:51+00:00", + "approved_by": "Tin Dang", + "actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } + }, + "flag_verified": true, + "tripwire": { + "contract_md5": "d1fbc081e7e61534c07d6ea3e4e419ce", + "tests": {} + }, + "scope": { + "declared": [], + "snapshot_md5": "a284323b0d97a8044a7df8866269ac65" + }, + "heal": { + "attempts": 1, + "history": [ + { + "at": "2026-07-14T11:08:34+00:00", + "reason": "scope_violation: task 'init-idempotent-nudge' touched outside its declared \u00a75 Scope \u2014 add-method/src/add_method/_bundled/tooling/add.py \u00b7 add-method/tooling/engine_pin.py \u00b7 add-method/tooling/test_add.py (3 total)", + "source": "scope" + } + ] + }, + "recross": { + "by": "Tin Dang", + "at": "2026-07-14T11:09:53+00:00", + "from_phase": "build" + }, + "gate_actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } } }, "created": "2026-05-28T15:39:04+00:00", - "updated": "2026-07-14T05:39:19+00:00", + "updated": "2026-07-14T11:10:06+00:00", "milestones": { "v1-1": { "title": "v1.1 \u2014 adoption & ergonomics", @@ -9787,11 +9836,16 @@ "stage": "mvp", "status": "active", "created": "2026-07-14T04:33:38+00:00", - "updated": "2026-07-14T04:33:38+00:00", - "confirmed": false, - "confirmed_at": null, - "confirmed_by": null, - "await_confirm": true + "updated": "2026-07-14T10:41:44+00:00", + "confirmed": true, + "confirmed_at": "2026-07-14T10:41:44+00:00", + "confirmed_by": "tindang", + "await_confirm": true, + "actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } } }, "active_milestone": "call-residuals", @@ -10649,7 +10703,8 @@ "plan-legibility": "relations-surface", "ceremony-to-effort": "fold-draft-at-close", "call-floor": "skill-orient-split", - "six-phase-loop": "installer-shared-namespace-guard" + "six-phase-loop": "installer-shared-namespace-guard", + "call-residuals": "init-idempotent-nudge" }, "todos": [ { diff --git a/.add/tasks/init-idempotent-nudge/TASK.md b/.add/tasks/init-idempotent-nudge/TASK.md new file mode 100644 index 00000000..eb7e74ff --- /dev/null +++ b/.add/tasks/init-idempotent-nudge/TASK.md @@ -0,0 +1,97 @@ +# TASK: init on an existing project is a loud no-op resume pointer + +slug: init-idempotent-nudge · created: 2026-07-14 · stage: mvp +milestone: call-residuals +autonomy: auto +phase: done +fast: true + +> Fast lane — one small task, minimal sections, filled top-to-bottom. The trust floor still +> holds: a FROZEN §3 contract · ≥1 red test before build · a recorded §6 gate (security = HARD-STOP). +> The acceptance scenario collapses into §1 `Accept:`; the observe note is one optional line at the gate. + +--- + +## 1 · SPECIFY — the rules + +> Project the expectations from the milestone Ground + this request — light, not re-invented. +Feature: idempotent init — re-running `init` on an initialized project is a loud no-op resume pointer, and `status` tells the agent not to re-init in the first place (kills the +2–4 calls/rep double-init lever). +Must: + - `init` on a project WITH state.json and WITHOUT `--force` exits 0, prints the resume pointer (`already initialised … — resume: add.py status`), and re-seeds NOTHING (no state / .gitignore / survivor-template / .bak write) + - `status` (default view) opens with a "project exists — do not re-init (use --force to reset)" line whenever state.json is present + - `init --force` on an existing project still resets (unchanged); `init` on a fresh dir still seeds + exits 0 (unchanged) +Reject: + - none new — this task REPLACES the prior nonzero refusal (init-resume-pointer v1) with an exit-0 no-op; that frozen test is superseded by change-request, not weakened +Accept: Given an initialized project, When `add.py init` runs again without --force, Then it exits 0, writes nothing under .add/, and prints the resume pointer (not the old nonzero "already initialised" error) +Boundary: none — no external input shape (CLI invocation only; state.json present vs absent is the only branch) +Assumptions: ⚠ the double-init call is a second `init` MID-session (the first legitimately seeds) — making the repeat an exit-0 no-op is safe because `--force` still resets; if wrong (a user WANTED a reset): they must add --force, and the message names it + +--- + +## 3 · PLAN — the change plan: ground · contract · build-strategy + +### Grounding +Touches (files · symbols): `add-method/tooling/add.py:cmd_init` (the state-exists guard that today `_die`s on a re-init without --force) · `add-method/tooling/add.py:cmd_status` (the default-view opening, before the project banner) · `add-method/tooling/test_init_resume_pointer.py:ResumePointerTest.test_refusal_names_resume` (the frozen v1 contract this supersedes: nonzero exit → exit 0) +Context (working folder): `add-method/tooling/` (canonical engine) — the change syncs byte-identical to the two twins (`.add/tooling/add.py`, `add-method/src/add_method/_bundled/tooling/add.py`) after build; SEAMS.md + engine_pin ENGINE_MD5 re-pinned +Honors (patterns / conventions): the `_die` (exit≠0) vs `print(...)+return` (exit 0) idiom; the survivor-file "never clobber / never write blank" skip idiom stays intact (early-return simply skips the whole seed block); no gate/freeze/tamper/scope enforcement path touched (milestone OUT-of-scope) +Anchors the contract cites: `cmd_init`, `cmd_status`, `state.json` (STATE_FILE), `--force` +Ground SHA: e1a967a — stamped by freeze + +### Contract + +``` +cmd_init(args), state.json PRESENT, args.force is False: + → stdout: "add: already initialised at — resume: add.py status" (+ active-task/next pointer if resolvable) + → writes: NOTHING under .add/ (no state.json, .gitignore, survivor template, or .bak) + → exit: 0 # was: _die → exit 2 (superseded; init-resume-pointer v1) +cmd_init(args), args.force is True → unchanged (resets, exit 0) +cmd_init(args), state.json ABSENT → unchanged (seeds, exit 0) +cmd_status default view, state.json PRESENT: + → the opening includes a line: "project exists — do not re-init (use --force to reset)" +cmd_status, no .add project → unchanged ("no .add/ project found …") +``` + +`Least-sure flag surfaced at freeze:` [contract] the exact placement/wording of the status "project exists — do not re-init" line — banner-head vs a dedicated line; if wrong: cosmetic one-line move, no behavior change. +Status: FROZEN @ v1 — approved by Tin Dang +### Build-strategy +Scope (may touch): add-method/tooling/add.py add-method/src/add_method/_bundled/tooling/add.py add-method/.add/tooling/add.py add-method/tooling/engine_pin.py add-method/tooling/test_add.py add-method/tooling/test_init_resume_pointer.py add-method/tooling/test_init_idempotent_nudge.py +Strategy & known-problem fixes: (1) in `cmd_init`, replace the `_die(...)` at the `state_path.exists() and not args.force` guard with `print()` + `return` — exit 0, and because it returns before the `tasks/.mkdir` + survivor-template loop, nothing is re-seeded (trap dodged: must return BEFORE any write, and must NOT fall through to --run-mode/PROJECT.md edits). (2) in `cmd_status` default path, emit the "project exists — do not re-init (use --force to reset)" line when state.json is present (trap: place it in the plain-status path only — do not perturb `--brief`, `--json`, or `--section` outputs). (3) tests RED first (below); the superseded `test_init_resume_pointer` flips `assertNotEqual(code,0)`→`assertEqual(code,0)` + asserts no-write — done in the TESTS phase, flagged as change-request supersession. (4) sync ×3 twins, re-pin ENGINE_MD5 + SEAMS. +Approach (domain strategy): message-layer only — early-return idempotence + one orientation line; correctness-first, zero enforcement-path change. + +### AI-verify record (required when gate_mode: ai-plan-verify) +- [ ] §3 PLAN grounding anchors resolve in the current tree +- [ ] §1 every Must + every Reject present, each Reject paired with an error code +- [ ] §3 Contract shape is concrete (no template placeholder text remains) +- [ ] Lowest-confidence flag surfaced and substantive (mirrors unflagged_freeze's own bar) +Verified by: · at: + +--- + +## 4 · TESTS — failing-first (red) + +Plan: test_ — assert the §1 Accept line's Then (behavior, not internals). +Tests live in: `./tests/` · MUST run red (missing implementation) before Build. + +--- + +## 5 · BUILD — AI writes the code (execution) + +> The change plan was frozen in §3 PLAN. Build to it: honor the §3 Build-strategy Scope; improve on the strategy if the code teaches you better. +Strategy actually used: as planned — cmd_init early-returns (print resume pointer + optional active-task, exit 0) before any seed when state.json exists and not --force; cmd_status default view prints "project exists — do not re-init (use --force to reset)" as its opening line (plain path only). Change-request supersession touched three legacy tests (test_init_resume_pointer, test_add's reinit test → new no-op contract) + the new test_init_idempotent_nudge. ×3 twins synced, ENGINE_MD5→ee4ef957, SEAMS _declared_scope pin 5653→5670. +Code lives in: `./src/` · Constraints: change no test, no frozen contract; stay inside the §3 Build-strategy Scope; allow-list packages only. + +--- + +## 6 · VERIFY — evidence + gate + +- [ ] all tests pass · coverage held · no test or contract altered during build +- [ ] green was EARNED — no overfit / vacuous asserts / stubbed-away logic +- [ ] input dialect held — tests speak the spec's example formats (spec-dialect floor) +- [ ] no exposed secrets, injection openings, or unexpected dependencies (security = HARD-STOP) + +Build expectations (from §1 Accept + §3 CONTRACT): a second `add.py init` (no --force) on an initialized project exits 0, writes nothing under .add/, and prints "already initialised … resume: add.py status"; `add.py status` opens with "project exists — do not re-init"; --force still resets; fresh init still seeds — confirmed by test_init_idempotent_nudge (5 asserts) + the superseded test_init_resume_pointer / test_add reinit tests, all green. + +### GATE RECORD +Outcome: PASS +Reviewed by: Tin Dang · date: 2026-07-14 + diff --git a/add-method/src/add_method/_bundled/tooling/add.py b/add-method/src/add_method/_bundled/tooling/add.py index 3716a803..74bce14d 100644 --- a/add-method/src/add_method/_bundled/tooling/add.py +++ b/add-method/src/add_method/_bundled/tooling/add.py @@ -591,7 +591,19 @@ def cmd_init(args: argparse.Namespace) -> None: root = base / ROOT_DIRNAME state_path = root / STATE_FILE if state_path.exists() and not args.force: - _die(f"already initialised at {root} (use --force to reset state) — resume: add.py status") + # idempotent init (init-idempotent-nudge): a re-init is a LOUD NO-OP, not a + # refusal — exit 0 so a second init costs the agent no recovery call, and + # write NOTHING (return before any seed). --force still resets (falls + # through below). The message still names the resume command. + msg = f"already initialised at {root} — resume: add.py status" + try: + _active = _active_task(load_state(root)) + if _active: + msg += f" (active task: {_active})" + except Exception: + pass + print(f"add: {msg}") + return (root / "tasks").mkdir(parents=True, exist_ok=True) # Keep the engine's transient local artifacts out of git. Never-clobber: a @@ -2798,6 +2810,11 @@ def cmd_status(args: argparse.Namespace) -> None: # Compute once: True when setup is present AND locked is False (the lock-gate window). # Reuses the canonical helper — do NOT write a parallel predicate. unlocked = not _setup_locked(state) + # init-idempotent-nudge: reaching here means _require_root passed, i.e. state.json + # is present — open with the do-not-init nudge so an agent re-orienting never + # re-runs `init` (the double-init call lever). Plain-status path only; the + # --brief/--json/--section views returned above are unaffected. + print("project exists — do not re-init (use --force to reset)") print(f"project : {state.get('project', '(unknown)')}") # project autonomy default (task init-auto-default): the posture new tasks INHERIT, # read LIVE from PROJECT.md so the human sees the project-wide throttle every session. diff --git a/add-method/tooling/add.py b/add-method/tooling/add.py index 3716a803..74bce14d 100644 --- a/add-method/tooling/add.py +++ b/add-method/tooling/add.py @@ -591,7 +591,19 @@ def cmd_init(args: argparse.Namespace) -> None: root = base / ROOT_DIRNAME state_path = root / STATE_FILE if state_path.exists() and not args.force: - _die(f"already initialised at {root} (use --force to reset state) — resume: add.py status") + # idempotent init (init-idempotent-nudge): a re-init is a LOUD NO-OP, not a + # refusal — exit 0 so a second init costs the agent no recovery call, and + # write NOTHING (return before any seed). --force still resets (falls + # through below). The message still names the resume command. + msg = f"already initialised at {root} — resume: add.py status" + try: + _active = _active_task(load_state(root)) + if _active: + msg += f" (active task: {_active})" + except Exception: + pass + print(f"add: {msg}") + return (root / "tasks").mkdir(parents=True, exist_ok=True) # Keep the engine's transient local artifacts out of git. Never-clobber: a @@ -2798,6 +2810,11 @@ def cmd_status(args: argparse.Namespace) -> None: # Compute once: True when setup is present AND locked is False (the lock-gate window). # Reuses the canonical helper — do NOT write a parallel predicate. unlocked = not _setup_locked(state) + # init-idempotent-nudge: reaching here means _require_root passed, i.e. state.json + # is present — open with the do-not-init nudge so an agent re-orienting never + # re-runs `init` (the double-init call lever). Plain-status path only; the + # --brief/--json/--section views returned above are unaffected. + print("project exists — do not re-init (use --force to reset)") print(f"project : {state.get('project', '(unknown)')}") # project autonomy default (task init-auto-default): the posture new tasks INHERIT, # read LIVE from PROJECT.md so the human sees the project-wide throttle every session. diff --git a/add-method/tooling/engine_pin.py b/add-method/tooling/engine_pin.py index 922b2945..711362b1 100644 --- a/add-method/tooling/engine_pin.py +++ b/add-method/tooling/engine_pin.py @@ -17,5 +17,5 @@ this file only ever holds the newest pointer. """ -ENGINE_MD5 = "bf89b03351ae87d72fb3a2dc5f5729af" # re-aimed @ build-entry-spec-echo (six-phase-loop 6/6: _spec_echo pure-read helper + fail-open tail call in _build_entry — the tick INTO build re-renders §1 Must/Reject + the §3 contract head). prior: 5d5e0538… @ phase-merge-verify +ENGINE_MD5 = "ee4ef9577fa20c4a804752bcfe3e9f76" # re-aimed @ init-idempotent-nudge (call-residuals: cmd_init re-init is an exit-0 no-op resume pointer, not a refusal; cmd_status default view opens with a "do not re-init" line — kills the double-init call lever). prior: bf89b033… @ build-entry-spec-echo ENGINE_PKG_MD5 = "fc40ad47544db6f5204b6197b95daf04" # re-aimed @ phase-merge-verify (constants.py: PHASES drops observe; _SKIPPABLE_PHASES=(); PHASE_GUIDE/OWNER/GROUPS/AGENT follow). prior: 870a4ce0… @ phase-merge-specify diff --git a/add-method/tooling/test_add.py b/add-method/tooling/test_add.py index faaba88b..31ccc02c 100644 --- a/add-method/tooling/test_add.py +++ b/add-method/tooling/test_add.py @@ -49,10 +49,18 @@ def test_init_creates_state_and_setup_files(self): self.assertEqual(st["stage"], "mvp") self.assertIsNone(st["active_task"]) - def test_init_refuses_clobber_without_force(self): + def test_reinit_is_noop_without_force(self): + # init-idempotent-nudge (call-residuals): a re-init WITHOUT --force is now a + # LOUD NO-OP (exit 0, writes nothing), not a SystemExit refusal — superseding + # the earlier clobber-refusal contract. Full contract in + # test_init_idempotent_nudge.py. self._run("init") - with self.assertRaises(SystemExit): - self._run("init") + before = {p: p.read_bytes() + for p in (Path(self.tmp) / ".add").rglob("*") if p.is_file()} + self._run("init") # must NOT raise SystemExit + after = {p: p.read_bytes() + for p in (Path(self.tmp) / ".add").rglob("*") if p.is_file()} + self.assertEqual(before, after, "a no-op re-init must write nothing") # --- gitignore scaffold (keep transient engine artifacts out of git) --- def test_init_scaffolds_gitignore(self): diff --git a/add-method/tooling/test_init_idempotent_nudge.py b/add-method/tooling/test_init_idempotent_nudge.py new file mode 100644 index 00000000..de58826e --- /dev/null +++ b/add-method/tooling/test_init_idempotent_nudge.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Red/green tests for init-idempotent-nudge (call-residuals, frozen §3 v1): +the WM1 re-measure showed every rep re-running `init` on an already-initialised +project (+2–4 calls/rep — the double-init lever). This task makes the repeat a +LOUD NO-OP: exit 0, print the resume pointer, write nothing — and makes `status` +open with a "do not re-init" line so the agent never re-calls init in the first +place. Supersedes init-resume-pointer v1 (which kept it a nonzero refusal). + +Run: python3 -m unittest test_init_idempotent_nudge -v +""" +import io +import os +import shutil +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +import add + + +class _Harness(unittest.TestCase): + def setUp(self): + self._cwd = Path.cwd() + self.tmp = Path(tempfile.mkdtemp(prefix="add-iin-")).resolve() + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + self.addCleanup(os.chdir, self._cwd) + os.chdir(self.tmp) + + def _run(self, *argv): + out, err = io.StringIO(), io.StringIO() + code = 0 + try: + with redirect_stdout(out), redirect_stderr(err): + add.main(list(argv)) + except SystemExit as e: + code = e.code if isinstance(e.code, int) else 1 + return out.getvalue() + err.getvalue(), code + + def _snapshot(self): + return {p: p.read_bytes() for p in (self.tmp / ".add").rglob("*") if p.is_file()} + + +class ReinitNoopTest(_Harness): + def test_reinit_is_exit0_noop_resume(self): + out, code = self._run("init", "--name", "demo", "--stage", "mvp") + self.assertEqual(code, 0, out) + before = self._snapshot() + out, code = self._run("init", "--name", "demo", "--stage", "mvp") + self.assertEqual(code, 0, f"a re-init must be an exit-0 no-op now: {out}") + self.assertIn("already initialised", out, "the existing message head survives") + self.assertIn("resume: add.py status", out, "the no-op must name the resume command") + self.assertEqual(before, self._snapshot(), + "a no-op re-init must write nothing under .add/") + + def test_force_still_resets(self): + self._run("init", "--name", "demo", "--stage", "mvp") + out, code = self._run("init", "--name", "demo", "--stage", "mvp", "--force") + self.assertEqual(code, 0, f"--force must still reset: {out}") + + def test_fresh_init_still_seeds(self): + out, code = self._run("init", "--name", "demo", "--stage", "mvp") + self.assertEqual(code, 0, out) + self.assertTrue((self.tmp / ".add" / "state.json").exists(), + "a fresh init must still seed state.json") + + +class StatusDoNotInitTest(_Harness): + def test_status_opens_with_do_not_init_when_project_exists(self): + self._run("init", "--name", "demo", "--stage", "mvp") + out, code = self._run("status") + self.assertEqual(code, 0, out) + self.assertIn("do not re-init", out, + "status must tell the agent the project exists — do not re-init") + + def test_status_no_project_is_unchanged(self): + out, code = self._run("status") + self.assertNotIn("do not re-init", out, + "the do-not-init line must only appear when a project exists") + + +if __name__ == "__main__": + unittest.main() diff --git a/add-method/tooling/test_init_resume_pointer.py b/add-method/tooling/test_init_resume_pointer.py index 66cca3c6..5c90f24d 100644 --- a/add-method/tooling/test_init_resume_pointer.py +++ b/add-method/tooling/test_init_resume_pointer.py @@ -1,8 +1,12 @@ #!/usr/bin/env python3 -"""Red/green tests for the init resume pointer (task init-resume-pointer, frozen v1): +"""Red/green tests for the init resume pointer (task init-resume-pointer, frozen v1 +— SUPERSEDED by init-idempotent-nudge, call-residuals, frozen §3 v1): the WM1 re-measure showed every rep re-running `init` on an already-initialised -project (+2 calls/rep). The refusal now names the resume command in the same -message; it stays a refusal (nonzero exit, no tree write). +project (+2–4 calls/rep). The message still names the resume command, but the +re-init is now an exit-0 NO-OP (was a nonzero refusal): a re-init should not cost +the agent a recovery call. It still writes nothing to the tree. `--force` still +resets. (The full new contract — incl. the status "do not re-init" line — is +pinned in test_init_idempotent_nudge.py.) Run: python3 -m unittest test_init_resume_pointer -v """ @@ -40,17 +44,17 @@ def _snapshot(self): class ResumePointerTest(_Harness): - def test_refusal_names_resume(self): # M1+M2 + def test_reinit_names_resume(self): # M1+M2 (superseded contract) out, code = self._run("init", "--name", "demo", "--stage", "mvp") self.assertEqual(code, 0, out) before = self._snapshot() out, code = self._run("init", "--name", "demo", "--stage", "mvp") - self.assertNotEqual(code, 0, "the second init must still refuse") + self.assertEqual(code, 0, "a re-init is now an exit-0 no-op (init-idempotent-nudge)") self.assertIn("already initialised", out, "the existing message head survives") self.assertIn("resume: add.py status", out, - "the refusal must name the resume command") + "the no-op must name the resume command") self.assertEqual(before, self._snapshot(), - "a refused init must write nothing") + "a no-op re-init must write nothing") def test_force_still_resets(self): # R1 self._run("init", "--name", "demo", "--stage", "mvp") From 5fe143bdf1adca0f0ef1e6a8a09c74d667138f72 Mon Sep 17 00:00:00 2001 From: Tin Dang Date: Tue, 14 Jul 2026 19:04:32 +0700 Subject: [PATCH 2/6] =?UTF-8?q?feat(method):=20scope-first-draft=20?= =?UTF-8?q?=E2=80=94=20paste-ready=20=C2=A75=20Scope=20line=20at=20freeze?= =?UTF-8?q?=20when=20Touches=20uncovered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit call-residuals task 2/4. The WM1 re-measure showed post-freeze re-cross repairs driven by a too-narrow §5 Scope: the tokens resolve [ok], yet a §3 Touches path sits outside them, so the gate trips scope_violation → return-to-build → re-cross (the measured call lever). The freeze scope-echo already printed a per-token "note: … outside the declared scope"; this escalates it to a fix. - _scope_echo: in the non-empty-resolved branch, collect the uncovered §3 Touches paths ONCE (reused by the existing per-token note loop). When any are uncovered, print exactly one paste-ready line: scope (paste-ready — declared misses §3 Touches): Scope (may touch): where = the declared tokens followed by each uncovered Touches path, space-joined, order-preserving, de-duplicated. The agent pastes the corrected line at freeze instead of re-deriving scope at the gate. Propose-not-impose: printed to stdout only, TASK.md is never written. Zero enforcement change — the UNDECLARED / garbage / all-missing "proposed" path is untouched (milestone OUT-of-scope). Red test first (test_scope_first_draft, 3 asserts: emits-when-uncovered · silent-when-covered · never-writes). Engine synced ×4 twins, ENGINE_MD5 -> 3b438d30, SEAMS _declared_scope pin 5670->5677. Full fence green. One human freeze at the frozen §3 contract; scope re-crossed once after the engine_pin twins widened §5. author: Tin Dang --- .add/SEAMS.md | 2 +- .add/state.json | 63 +++++++++- .add/tasks/scope-first-draft/TASK.md | 96 +++++++++++++++ .../src/add_method/_bundled/tooling/add.py | 13 +- .../add_method/_bundled/tooling/engine_pin.py | 2 +- add-method/tooling/add.py | 13 +- add-method/tooling/engine_pin.py | 2 +- add-method/tooling/test_scope_first_draft.py | 112 ++++++++++++++++++ 8 files changed, 291 insertions(+), 12 deletions(-) create mode 100644 .add/tasks/scope-first-draft/TASK.md create mode 100644 add-method/tooling/test_scope_first_draft.py diff --git a/.add/SEAMS.md b/.add/SEAMS.md index 74b37346..2c0499a8 100644 --- a/.add/SEAMS.md +++ b/.add/SEAMS.md @@ -54,7 +54,7 @@ Citations: 232 files reference "byte-identical" in `.add/tasks/` — method: ## scope-token-grammar Name: §5 "Scope (may touch):" token-resolution grammar -Anchor: `add-method/tooling/add.py:5670` (`_declared_scope`) +Anchor: `add-method/tooling/add.py:5677` (`_declared_scope`) Contract: `_declared_scope` reads ONLY the first physical line after the §5 header — a wrapped multi-line list silently truncates. Each backticked token then resolves independently: `./...` = this task's dir, any token containing `/` = project-root-relative, diff --git a/.add/state.json b/.add/state.json index 93fc1216..68bf8356 100644 --- a/.add/state.json +++ b/.add/state.json @@ -1,7 +1,7 @@ { "project": "AIDD / ADD Methodology", "stage": "mvp", - "active_task": "init-idempotent-nudge", + "active_task": "scope-first-draft", "tasks": { "add-check": { "title": "add.py check: validate .add project integrity", @@ -9415,10 +9415,67 @@ "email": "tindang.ht97@gmail.com", "source": "git" } + }, + "scope-first-draft": { + "title": "freeze scope echo emits a paste-ready \u00a75 Scope line when declared tokens miss \u00a73 Touches", + "phase": "done", + "gate": "PASS", + "milestone": "call-residuals", + "depends_on": [], + "created": "2026-07-14T11:11:08+00:00", + "updated": "2026-07-14T12:03:17+00:00", + "fast": true, + "freeze": { + "version": "v1", + "frozen_at": "2026-07-14T11:15:41+00:00", + "approved_by": "Tin Dang", + "actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } + }, + "flag_verified": true, + "tripwire": { + "contract_md5": "5d65a05e4bf27cc8623b6c6fdb6c6244", + "tests": {} + }, + "scope": { + "declared": [ + "add-method/tooling/add.py", + "add-method/src/add_method/_bundled/tooling/add.py", + "add-method/.add/tooling/add.py", + "add-method/tooling/engine_pin.py", + "add-method/src/add_method/_bundled/tooling/engine_pin.py", + "add-method/.add/tooling/engine_pin.py", + "add-method/tooling/test_scope_first_draft.py" + ], + "snapshot_md5": "f38a25ed9f2f6ac8b1fdfe3e4b1d66d5" + }, + "heal": { + "attempts": 1, + "history": [ + { + "at": "2026-07-14T12:02:23+00:00", + "reason": "scope_violation: task 'scope-first-draft' touched outside its declared \u00a75 Scope \u2014 add-method/src/add_method/_bundled/tooling/engine_pin.py (1 total)", + "source": "scope" + } + ] + }, + "recross": { + "by": "Tin Dang", + "at": "2026-07-14T12:03:02+00:00", + "from_phase": "build" + }, + "gate_actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } } }, "created": "2026-05-28T15:39:04+00:00", - "updated": "2026-07-14T11:10:06+00:00", + "updated": "2026-07-14T12:03:17+00:00", "milestones": { "v1-1": { "title": "v1.1 \u2014 adoption & ergonomics", @@ -10704,7 +10761,7 @@ "ceremony-to-effort": "fold-draft-at-close", "call-floor": "skill-orient-split", "six-phase-loop": "installer-shared-namespace-guard", - "call-residuals": "init-idempotent-nudge" + "call-residuals": "scope-first-draft" }, "todos": [ { diff --git a/.add/tasks/scope-first-draft/TASK.md b/.add/tasks/scope-first-draft/TASK.md new file mode 100644 index 00000000..d7909e07 --- /dev/null +++ b/.add/tasks/scope-first-draft/TASK.md @@ -0,0 +1,96 @@ +# TASK: freeze scope echo emits a paste-ready §5 Scope line when declared tokens miss §3 Touches + +slug: scope-first-draft · created: 2026-07-14 · stage: mvp +milestone: call-residuals +autonomy: auto +phase: done +fast: true + +> Fast lane — one small task, minimal sections, filled top-to-bottom. The trust floor still +> holds: a FROZEN §3 contract · ≥1 red test before build · a recorded §6 gate (security = HARD-STOP). +> The acceptance scenario collapses into §1 `Accept:`; the observe note is one optional line at the gate. + +--- + +## 1 · SPECIFY — the rules + +> Project the expectations from the milestone Ground + this request — light, not re-invented. +Feature: paste-ready scope line at freeze — when the declared §5 Scope resolves [ok] but MISSES a §3 Touches path, the freeze scope-echo prints ONE ready-to-paste corrected "Scope (may touch): …" line (declared tokens + the uncovered Touches paths), so the agent fixes scope AT freeze instead of hitting a post-freeze re-cross repair (the measured lever). +Must: + - when `_scope_echo` finds ≥1 §3 Touches path NOT covered by a resolved, non-empty declared scope, it prints a single paste-ready line: `scope (paste-ready — declared misses §3 Touches): Scope (may touch): ` + - the paste-ready line is propose-not-impose: printed to stdout, never written into TASK.md + - unchanged: the per-token "note: §3 Touches cites outside the declared scope" lines, and the UNDECLARED / garbage / all-missing "scope (proposed …)" path +Reject: + - none — pure additive echo; no gate / scope ENFORCEMENT change (milestone OUT-of-scope) +Accept: Given a frozen task whose §5 Scope resolves [ok] but omits a §3 Touches path, When the scope echo runs at freeze, Then stdout contains a paste-ready "Scope (may touch): …" line that merges the declared tokens with the uncovered Touches path +Boundary: none — reads TASK.md §3 Touches + the §5 declaration only; no external input shape +Assumptions: ⚠ the too-narrow §5 is the dominant post-freeze re-cross cause (task 1 init-idempotent-nudge just hit it) — a paste-ready line turns a re-derive into a copy-paste; if wrong (agent ignores the line): no harm, still propose-only + +--- + +## 3 · PLAN — the change plan: ground · contract · build-strategy + +### Grounding +Touches (files · symbols): `add-method/tooling/add.py:_scope_echo` — the ONLY symbol edited (add the paste-ready branch after the per-token "note:" loop, ~L1073-1077); it READS `_touches_paths` · `_in_scope` · `_declared_scope` unchanged +Context (working folder): `add-method/tooling/` (canonical engine). The full file write-set — canonical add.py, its 3 engine twins, engine_pin.py, the new test — is the §5 Scope below (not re-listed here); ENGINE_MD5 + SEAMS `_declared_scope` pin re-aimed as part of any engine edit +Honors (patterns / conventions): propose-not-impose (PRINT, never write TASK.md); `_scope_echo` is already fail-open-wrapped at its freeze call site; reuses `_in_scope`/`_touches_paths` — no parallel predicate +Anchors the contract cites: `_scope_echo` (edited) · `_touches_paths`, `_in_scope`, `_declared_scope` (read) +Ground SHA: 9c4eeeb — stamped by freeze + +### Contract + +``` +_scope_echo, resolved declared scope is non-empty AND ≥1 §3 Touches path is not _in_scope(tok, resolved): + → (unchanged) per uncovered tok: "note: §3 Touches cites outside the declared scope" + → (NEW) exactly one line: "scope (paste-ready — declared misses §3 Touches): Scope (may touch): " + where = the resolved declared tokens followed by each uncovered §3 Touches path, + space-joined, order-preserving, de-duplicated + → printed to stdout only; TASK.md is NOT written +_scope_echo, all other cases (UNDECLARED · every-token-dropped · all-[MISSING] · fully-covered): + → unchanged (existing "scope (proposed from §3 Touches): …" behaviour intact) +``` + +`Least-sure flag surfaced at freeze:` [contract] the exact prefix wording of the paste-ready line ("scope (paste-ready — declared misses §3 Touches):") — if wrong, cosmetic one-string change, no behaviour shift. +Status: FROZEN @ v1 — approved by Tin Dang +### Build-strategy +Scope (may touch): `add-method/tooling/add.py` `add-method/src/add_method/_bundled/tooling/add.py` `add-method/.add/tooling/add.py` `add-method/tooling/engine_pin.py` `add-method/src/add_method/_bundled/tooling/engine_pin.py` `add-method/.add/tooling/engine_pin.py` `add-method/tooling/test_scope_first_draft.py` +Strategy & known-problem fixes: (1) in `_scope_echo`, in the `else` (non-empty resolved) branch, after the per-token note loop, collect `uncovered = [tok for tok in _touches_paths() if not _in_scope(tok, resolved)]`; if `uncovered`, print the paste-ready line merging `resolved + [u for u in uncovered if u not in resolved]` (trap: dedupe + preserve order; do NOT touch the UNDECLARED/garbage/all-missing block below it). (2) red test first: a task whose §5 resolves [ok] but whose §3 Touches names a wider real path → echo stdout contains "Scope (may touch):" + the missing path (declare the full §5 scope UP FRONT — the task-1 lesson). (3) sync ×4 twins, re-pin ENGINE_MD5 + SEAMS. +Approach (domain strategy): message-layer only — one additive echo branch reusing existing predicates; propose-not-impose, correctness-first, zero enforcement change. + +### AI-verify record (required when gate_mode: ai-plan-verify) +- [ ] §3 PLAN grounding anchors resolve in the current tree +- [ ] §1 every Must + every Reject present, each Reject paired with an error code +- [ ] §3 Contract shape is concrete (no template placeholder text remains) +- [ ] Lowest-confidence flag surfaced and substantive (mirrors unflagged_freeze's own bar) +Verified by: · at: + +--- + +## 4 · TESTS — failing-first (red) + +Plan: test_ — assert the §1 Accept line's Then (behavior, not internals). +Tests live in: `./tests/` · MUST run red (missing implementation) before Build. + +--- + +## 5 · BUILD — AI writes the code (execution) + +> The change plan was frozen in §3 PLAN. Build to it: honor the §3 Build-strategy Scope; improve on the strategy if the code teaches you better. +Strategy actually used: as planned — in `_scope_echo`'s non-empty-resolved branch, collected `uncovered = [tok for tok in _touches_paths() if not _in_scope(tok, resolved)]` (one list, reused by the existing per-token note loop), and when non-empty printed ONE `scope (paste-ready — declared misses §3 Touches): Scope (may touch): ` line. Zero enforcement change; the UNDECLARED/garbage/all-missing "proposed" block below is untouched. Synced ×4 twins, ENGINE_MD5→3b438d30, SEAMS _declared_scope pin 5670→5677. +Code lives in: `./src/` · Constraints: change no test, no frozen contract; stay inside the §3 Build-strategy Scope; allow-list packages only. + +--- + +## 6 · VERIFY — evidence + gate + +- [x] all tests pass · coverage held · no test or contract altered during build (full fence exit 0) +- [x] green was EARNED — RED confirmed first (uncovered case failed for want of the line; the two guards passed), then GREEN after the additive branch +- [x] input dialect held — test fixtures speak the real §3 Touches / §5 backticked-token dialect +- [x] no exposed secrets, injection openings, or unexpected dependencies (pure read/print, security = HARD-STOP: none) + +Build expectations (from §1 Accept + §3 CONTRACT): a frozen task whose §5 Scope resolves [ok] but omits a §3 Touches path makes the freeze scope-echo emit ONE paste-ready "scope (paste-ready — declared misses §3 Touches): Scope (may touch): " line to stdout, merging declared tokens with the uncovered Touches path, deduped/order-preserving, writing nothing to TASK.md; fully-covered and UNDECLARED/garbage/all-missing cases unchanged — confirmed by test_scope_first_draft (3 asserts: emits-when-uncovered · silent-when-covered · never-writes) + green parity/seams/engine guards. + +### GATE RECORD +Outcome: PASS +Reviewed by: Tin Dang · date: 2026-07-14 + diff --git a/add-method/src/add_method/_bundled/tooling/add.py b/add-method/src/add_method/_bundled/tooling/add.py index 74bce14d..acf05a1a 100644 --- a/add-method/src/add_method/_bundled/tooling/add.py +++ b/add-method/src/add_method/_bundled/tooling/add.py @@ -1072,9 +1072,16 @@ def _touches_paths() -> list[str]: missing_all = not any(ok for _, ok in marks) # scope-coverage-hint: the too-narrow class behind the measured re-cross # repairs — tokens resolve [ok] yet the build's real targets sit outside them. - for tok in _touches_paths(): - if not _in_scope(tok, resolved): - print(f"note: §3 Touches cites {tok} outside the declared scope") + uncovered = [tok for tok in _touches_paths() if not _in_scope(tok, resolved)] + for tok in uncovered: + print(f"note: §3 Touches cites {tok} outside the declared scope") + # scope-first-draft: escalate the per-token notes to ONE paste-ready corrected + # line — declared tokens + the uncovered Touches paths — so the fix is a copy, + # not a re-derive (turns a post-freeze re-cross repair into a freeze-time edit). + if uncovered: + merged = list(resolved) + [u for u in uncovered if u not in resolved] + print("scope (paste-ready — declared misses §3 Touches): Scope (may touch): " + + " ".join(merged)) if resolved is None or not resolved or missing_all: paths = _touches_paths() if paths: diff --git a/add-method/src/add_method/_bundled/tooling/engine_pin.py b/add-method/src/add_method/_bundled/tooling/engine_pin.py index 922b2945..28ce1a6b 100644 --- a/add-method/src/add_method/_bundled/tooling/engine_pin.py +++ b/add-method/src/add_method/_bundled/tooling/engine_pin.py @@ -17,5 +17,5 @@ this file only ever holds the newest pointer. """ -ENGINE_MD5 = "bf89b03351ae87d72fb3a2dc5f5729af" # re-aimed @ build-entry-spec-echo (six-phase-loop 6/6: _spec_echo pure-read helper + fail-open tail call in _build_entry — the tick INTO build re-renders §1 Must/Reject + the §3 contract head). prior: 5d5e0538… @ phase-merge-verify +ENGINE_MD5 = "3b438d30e0a6de007b9cfd8e4e0210fe" # re-aimed @ scope-first-draft (call-residuals: _scope_echo escalates the per-token "note:" lines to ONE paste-ready "Scope (may touch): …" line merging declared tokens + uncovered §3 Touches — turns a post-freeze re-cross repair into a freeze-time copy-paste). prior: ee4ef957… @ init-idempotent-nudge ENGINE_PKG_MD5 = "fc40ad47544db6f5204b6197b95daf04" # re-aimed @ phase-merge-verify (constants.py: PHASES drops observe; _SKIPPABLE_PHASES=(); PHASE_GUIDE/OWNER/GROUPS/AGENT follow). prior: 870a4ce0… @ phase-merge-specify diff --git a/add-method/tooling/add.py b/add-method/tooling/add.py index 74bce14d..acf05a1a 100644 --- a/add-method/tooling/add.py +++ b/add-method/tooling/add.py @@ -1072,9 +1072,16 @@ def _touches_paths() -> list[str]: missing_all = not any(ok for _, ok in marks) # scope-coverage-hint: the too-narrow class behind the measured re-cross # repairs — tokens resolve [ok] yet the build's real targets sit outside them. - for tok in _touches_paths(): - if not _in_scope(tok, resolved): - print(f"note: §3 Touches cites {tok} outside the declared scope") + uncovered = [tok for tok in _touches_paths() if not _in_scope(tok, resolved)] + for tok in uncovered: + print(f"note: §3 Touches cites {tok} outside the declared scope") + # scope-first-draft: escalate the per-token notes to ONE paste-ready corrected + # line — declared tokens + the uncovered Touches paths — so the fix is a copy, + # not a re-derive (turns a post-freeze re-cross repair into a freeze-time edit). + if uncovered: + merged = list(resolved) + [u for u in uncovered if u not in resolved] + print("scope (paste-ready — declared misses §3 Touches): Scope (may touch): " + + " ".join(merged)) if resolved is None or not resolved or missing_all: paths = _touches_paths() if paths: diff --git a/add-method/tooling/engine_pin.py b/add-method/tooling/engine_pin.py index 711362b1..28ce1a6b 100644 --- a/add-method/tooling/engine_pin.py +++ b/add-method/tooling/engine_pin.py @@ -17,5 +17,5 @@ this file only ever holds the newest pointer. """ -ENGINE_MD5 = "ee4ef9577fa20c4a804752bcfe3e9f76" # re-aimed @ init-idempotent-nudge (call-residuals: cmd_init re-init is an exit-0 no-op resume pointer, not a refusal; cmd_status default view opens with a "do not re-init" line — kills the double-init call lever). prior: bf89b033… @ build-entry-spec-echo +ENGINE_MD5 = "3b438d30e0a6de007b9cfd8e4e0210fe" # re-aimed @ scope-first-draft (call-residuals: _scope_echo escalates the per-token "note:" lines to ONE paste-ready "Scope (may touch): …" line merging declared tokens + uncovered §3 Touches — turns a post-freeze re-cross repair into a freeze-time copy-paste). prior: ee4ef957… @ init-idempotent-nudge ENGINE_PKG_MD5 = "fc40ad47544db6f5204b6197b95daf04" # re-aimed @ phase-merge-verify (constants.py: PHASES drops observe; _SKIPPABLE_PHASES=(); PHASE_GUIDE/OWNER/GROUPS/AGENT follow). prior: 870a4ce0… @ phase-merge-specify diff --git a/add-method/tooling/test_scope_first_draft.py b/add-method/tooling/test_scope_first_draft.py new file mode 100644 index 00000000..c9c11aa9 --- /dev/null +++ b/add-method/tooling/test_scope_first_draft.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Red/green tests for scope-first-draft (call-residuals, frozen §3 v1): +the WM1 re-measure's post-freeze re-cross repairs come from a too-narrow §5 Scope +that resolves [ok] yet misses a §3 Touches path. The freeze scope-echo already +prints a per-token "note: … outside the declared scope"; this task escalates that +to ONE paste-ready "Scope (may touch): …" line merging the declared tokens with +the uncovered Touches paths, so the agent fixes scope AT freeze (copy-paste), +never at the gate (return-to-build + re-cross). Propose-not-impose: printed, never +written. + +Run: python3 -m unittest test_scope_first_draft -v +""" +import io +import os +import shutil +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +import add + + +class _Harness(unittest.TestCase): + def setUp(self): + self._cwd = Path.cwd() + self.tmp = Path(tempfile.mkdtemp(prefix="add-sfd-")).resolve() + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + self.addCleanup(os.chdir, self._cwd) + os.chdir(self.tmp) + self._silent("init", "--name", "demo", "--stage", "mvp") + self._silent("new-task", "t", "--fast", "--title", "x") + self.root = self.tmp / ".add" + self.task_md = self.root / "tasks" / "t" / "TASK.md" + + def _silent(self, *argv): + out, err = io.StringIO(), io.StringIO() + try: + with redirect_stdout(out), redirect_stderr(err): + add.main(list(argv)) + except SystemExit: + pass + + def _mkfile(self, rel: str): + # Touches path-heads must EXIST under the project root to be considered. + p = self.tmp / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("x\n") + + def _write_plan(self, touches: str, scope: str): + # Replace the §3 Touches + §5 Scope lines with the fixture's. + text = self.task_md.read_text() + lines = [] + for ln in text.splitlines(): + if ln.lstrip().startswith("Touches (files"): + lines.append(f"Touches (files · symbols): {touches}") + elif ln.lstrip().startswith("Scope (may touch):"): + lines.append(f"Scope (may touch): {scope}") + else: + lines.append(ln) + self.task_md.write_text("\n".join(lines) + "\n") + + def _echo(self): + out = io.StringIO() + with redirect_stdout(out): + add._scope_echo(self.root, "t") + return out.getvalue() + + +class PasteReadyLineTest(_Harness): + def test_paste_ready_line_when_touches_uncovered(self): + # §5 declares only pkg/a.py (resolves [ok]); §3 Touches also names pkg/b.py. + self._mkfile("pkg/a.py") + self._mkfile("pkg/b.py") + self._write_plan( + touches="`pkg/a.py:foo` · `pkg/b.py:bar`", + scope="`pkg/a.py`", + ) + out = self._echo() + self.assertIn("Scope (may touch):", out, + "a paste-ready Scope line must be emitted when Touches is uncovered") + self.assertIn("paste-ready", out, "the escalation must be labelled paste-ready") + self.assertIn("pkg/a.py", out, "declared token stays in the merged line") + self.assertIn("pkg/b.py", out, + "the uncovered §3 Touches path joins the merged line") + + def test_no_paste_ready_line_when_fully_covered(self): + # §5 already covers the §3 Touches path — no escalation. + self._mkfile("pkg/a.py") + self._write_plan( + touches="`pkg/a.py:foo`", + scope="`pkg/a.py`", + ) + out = self._echo() + self.assertNotIn("paste-ready", out, + "no paste-ready line when the declared scope already covers Touches") + + def test_paste_ready_never_writes_task_md(self): + self._mkfile("pkg/a.py") + self._mkfile("pkg/b.py") + self._write_plan( + touches="`pkg/a.py:foo` · `pkg/b.py:bar`", + scope="`pkg/a.py`", + ) + before = self.task_md.read_bytes() + self._echo() + self.assertEqual(before, self.task_md.read_bytes(), + "propose-not-impose: the echo must never write TASK.md") + + +if __name__ == "__main__": + unittest.main() From cfe7f5ae92d4f5c7af24369a785aa3e39a466e63 Mon Sep 17 00:00:00 2001 From: Tin Dang Date: Tue, 14 Jul 2026 19:21:35 +0700 Subject: [PATCH 3/6] =?UTF-8?q?feat(method):=20template-touches-scope-dedu?= =?UTF-8?q?p=20=E2=80=94=20=C2=A73=20Touches=20names=20symbols,=20=C2=A75?= =?UTF-8?q?=20Scope=20owns=20the=20file=20write-set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit call-residuals task 3. Both TASK templates (full + fast) invited the SAME file list twice: §3 Grounding `Touches (files · symbols)` and §5 Build-strategy `Scope (may touch)`. Agents duly authored the write-set twice — the exact duplication scope-first-draft's own Grounding had to be hand-fixed for this session ("fix grounding and plan as duplicated scope, touch"). - reworded the 4 placeholder VALUES (2 templates × Touches + Scope): · §3 Touches → "name symbols, not the full file list — §5 Scope owns the write-set" · §5 Scope → "the file write-set, the single source of truth (§3 Touches names symbols, this names files); scope-lock source" - LABELS + the `./src/` default token are byte-unchanged, so the label-integrity guard (test_seams_template_wiring) and the twin-byte guard (test_bundle_parity) stay green. - migrated the one ripple: test_fastlane_ground_lite now matches the Touches line by label + `<…>` regex instead of the exact old placeholder — robust to this and any future reword (greens against both). A content-pin migrating with its template, not a weakening. Documentation-layer only — no engine/parser/gate change, so no ENGINE_MD5/SEAMS re-pin. Red test first (test_template_touches_scope_dedup, 4 asserts across both tracked template trees). Synced ×4 template twins. Full fence green (3582 tests). One human freeze at the frozen §3 contract. author: Tin Dang --- .add/state.json | 47 +++++++- .../template-touches-scope-dedup/TASK.md | 101 ++++++++++++++++++ .../tooling/templates/TASK.fast.md.tmpl | 4 +- .../_bundled/tooling/templates/TASK.md.tmpl | 4 +- .../tooling/templates/TASK.fast.md.tmpl | 4 +- add-method/tooling/templates/TASK.md.tmpl | 4 +- .../tooling/test_fastlane_ground_lite.py | 17 +-- .../test_template_touches_scope_dedup.py | 73 +++++++++++++ 8 files changed, 236 insertions(+), 18 deletions(-) create mode 100644 .add/tasks/template-touches-scope-dedup/TASK.md create mode 100644 add-method/tooling/test_template_touches_scope_dedup.py diff --git a/.add/state.json b/.add/state.json index 68bf8356..d468e221 100644 --- a/.add/state.json +++ b/.add/state.json @@ -1,7 +1,7 @@ { "project": "AIDD / ADD Methodology", "stage": "mvp", - "active_task": "scope-first-draft", + "active_task": "template-touches-scope-dedup", "tasks": { "add-check": { "title": "add.py check: validate .add project integrity", @@ -9472,10 +9472,51 @@ "email": "tindang.ht97@gmail.com", "source": "git" } + }, + "template-touches-scope-dedup": { + "title": "templates dedup: \u00a73 Touches names symbols, \u00a75 Scope owns the file write-set", + "phase": "done", + "gate": "PASS", + "milestone": "call-residuals", + "depends_on": [], + "created": "2026-07-14T12:05:57+00:00", + "updated": "2026-07-14T12:20:53+00:00", + "fast": true, + "freeze": { + "version": "v1", + "frozen_at": "2026-07-14T12:11:30+00:00", + "approved_by": "Tin Dang", + "actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } + }, + "flag_verified": true, + "tripwire": { + "contract_md5": "8cf394016a210500452e7e51b97fb08e", + "tests": {} + }, + "scope": { + "declared": [ + "add-method/tooling/templates/TASK.md.tmpl", + "add-method/tooling/templates/TASK.fast.md.tmpl", + "add-method/src/add_method/_bundled/tooling/templates/TASK.md.tmpl", + "add-method/src/add_method/_bundled/tooling/templates/TASK.fast.md.tmpl", + "add-method/tooling/test_template_touches_scope_dedup.py", + "add-method/tooling/test_fastlane_ground_lite.py" + ], + "snapshot_md5": "b7f5f106059c82c625382e3c54cc48e6" + }, + "gate_actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } } }, "created": "2026-05-28T15:39:04+00:00", - "updated": "2026-07-14T12:03:17+00:00", + "updated": "2026-07-14T12:20:53+00:00", "milestones": { "v1-1": { "title": "v1.1 \u2014 adoption & ergonomics", @@ -10761,7 +10802,7 @@ "ceremony-to-effort": "fold-draft-at-close", "call-floor": "skill-orient-split", "six-phase-loop": "installer-shared-namespace-guard", - "call-residuals": "scope-first-draft" + "call-residuals": "template-touches-scope-dedup" }, "todos": [ { diff --git a/.add/tasks/template-touches-scope-dedup/TASK.md b/.add/tasks/template-touches-scope-dedup/TASK.md new file mode 100644 index 00000000..5056c7af --- /dev/null +++ b/.add/tasks/template-touches-scope-dedup/TASK.md @@ -0,0 +1,101 @@ +# TASK: templates dedup: §3 Touches names symbols, §5 Scope owns the file write-set + +slug: template-touches-scope-dedup · created: 2026-07-14 · stage: mvp +milestone: call-residuals +autonomy: auto +phase: done +fast: true + +> Fast lane — one small task, minimal sections, filled top-to-bottom. The trust floor still +> holds: a FROZEN §3 contract · ≥1 red test before build · a recorded §6 gate (security = HARD-STOP). +> The acceptance scenario collapses into §1 `Accept:`; the observe note is one optional line at the gate. + +--- + +## 1 · SPECIFY — the rules + +> Project the expectations from the milestone Ground + this request — light, not re-invented. +Feature: templates de-duplicate the §3-Touches / §5-Scope file list — the two TASK templates (full + fast) nudge the agent to write the file write-set ONCE (§5 Scope owns it) and let §3 Touches name only symbols/anchors, killing the double file-list authoring the WM1 re-measure showed (the same duplication scope-first-draft's own Grounding had to fix by hand). +Must: + - both templates' §3 `Touches (files · symbols…):` placeholder VALUE tells the agent to name symbols/anchors and NOT re-list the full file set, pointing to §5 Scope as the write-set owner + - both templates' §5 `Scope (may touch):` placeholder VALUE declares itself the single source of truth for the file write-set, pointing back to §3 Touches as the symbol namer + - the field LABELS are byte-unchanged (`Touches (files · symbols…):` · `Scope (may touch):`) — only the `<…>` placeholder guidance text changes; no engine/parser/gate behavior touched + - canonical and bundle template trees stay byte-identical (test_bundle_parity) +Reject: + - none — documentation-string change only; no code path, parser, or gate altered (milestone OUT-of-scope). The one label-integrity guard (test_seams_template_wiring pins the LABELS) must stay green unchanged +Accept: Given the full and fast TASK templates, When an agent reads §3 Grounding and §5 Build-strategy, Then the §3 Touches placeholder says "name symbols, not the full file list — §5 Scope owns the write-set" and the §5 Scope placeholder says "single source of truth for the file write-set", so the file list is authored once +Boundary: none — no external input shape; the change is static template text read by humans/agents, asserted by string-presence +Assumptions: ⚠ the duplicated file list (Touches listing files AND Scope listing the same files) is a real token/call cost, not just cosmetic — evidence: scope-first-draft's Grounding had to be hand-deduped this session on the user's "fix grounding and plan as duplicated scope, touch" instruction; if wrong (agents ignore the hint): no harm, pure guidance text + +--- + +## 3 · PLAN — the change plan: ground · contract · build-strategy + +### Grounding +Touches (files · symbols): the §3 Touches + §5 Scope placeholder lines in both TASK templates (`TASK.md.tmpl:Touches`/`:Scope`, `TASK.fast.md.tmpl:Touches`/`:Scope`) — plus the one content-pinning test that migrates with the fast placeholder (`test_fastlane_ground_lite.py:_replace-target`) +Context (working folder): `add-method/tooling/templates/` (canonical). The full file write-set — both canonical templates, both bundle twins, the two gitignored dogfood twins, the new + migrated tests — is the §5 Scope below (not re-listed here) +Honors (patterns / conventions): LABELS are frozen (test_seams_template_wiring pins them) — reword only the `<…>` value; propose-not-impose in the templates themselves (the whole task is guidance text); test_bundle_parity holds canonical==bundle +Anchors the contract cites: the §3 `Touches (files · symbols…):` placeholder value · the §5 `Scope (may touch):` placeholder value · both LABELS (unchanged) · test_bundle_parity +Ground SHA: 5fe143b — stamped by freeze + +### Contract + +``` +TASK.md.tmpl AND TASK.fast.md.tmpl, §3 Grounding `Touches (files · symbols…):` LINE: + → LABEL byte-unchanged; placeholder <…> value now instructs: name the symbols/anchors + you'll edit + how each is keyed, and "name symbols, not the full file list — §5 Scope + owns the write-set" +TASK.md.tmpl AND TASK.fast.md.tmpl, §5 Build-strategy `Scope (may touch):` LINE: + → LABEL + the `./src/` default token byte-unchanged; placeholder <…> value now declares + "the single source of truth for the file write-set (§3 Touches names symbols, this + names files); scope-lock source" +INVARIANTS (unchanged): + → no engine/parser/gate/_declared_scope/_ground lint behavior changes — pure template text + → canonical templates == bundle templates (byte) · both LABELS still present, original order + (test_seams_template_wiring + test_bundle_parity stay green) +``` + +`Least-sure flag surfaced at freeze:` [contract] the exact placeholder wording — it is pure guidance text, so a reword is a cosmetic one-string change with no behavior shift; the only hard edge is keeping the LABELS + `./src/` token byte-identical so the two pin guards stay green. +Status: FROZEN @ v1 — approved by Tin Dang +### Build-strategy +Scope (may touch): `add-method/tooling/templates/TASK.md.tmpl` `add-method/tooling/templates/TASK.fast.md.tmpl` `add-method/src/add_method/_bundled/tooling/templates/TASK.md.tmpl` `add-method/src/add_method/_bundled/tooling/templates/TASK.fast.md.tmpl` `add-method/tooling/test_template_touches_scope_dedup.py` `add-method/tooling/test_fastlane_ground_lite.py` +Strategy & known-problem fixes: (1) RED first: new test_template_touches_scope_dedup asserts BOTH templates' §3 Touches value carries the "not the full file list / §5 Scope owns the write-set" phrase AND §5 Scope value carries the "single source of truth" phrase — RED now (absent). (2) reword the 4 placeholder values (2 templates × Touches+Scope), LABELS + `./src/` token byte-untouched (trap: test_seams_template_wiring pins the labels; test_bundle_parity pins canonical==bundle — sync both trees). (3) migrate the ripple: test_fastlane_ground_lite.py replaces the OLD fast Touches placeholder string in 2 spots — update those replace() targets to the new value (a content-pin migrating with its template, NOT a weakening), done in TESTS phase. (4) sync ×4 template twins (2 tracked + 2 gitignored dogfood). No ENGINE_MD5 / SEAMS change (no add.py edit). +Approach (domain strategy): documentation-layer only — 4 placeholder rewords + one content-pin migration; correctness-first, zero engine behavior change, dedup-by-guidance. + +### AI-verify record (required when gate_mode: ai-plan-verify) +- [ ] §3 PLAN grounding anchors resolve in the current tree +- [ ] §1 every Must + every Reject present, each Reject paired with an error code +- [ ] §3 Contract shape is concrete (no template placeholder text remains) +- [ ] Lowest-confidence flag surfaced and substantive (mirrors unflagged_freeze's own bar) +Verified by: · at: + +--- + +## 4 · TESTS — failing-first (red) + +Plan: test_ — assert the §1 Accept line's Then (behavior, not internals). +Tests live in: `./tests/` · MUST run red (missing implementation) before Build. + +--- + +## 5 · BUILD — AI writes the code (execution) + +> The change plan was frozen in §3 PLAN. Build to it: honor the §3 Build-strategy Scope; improve on the strategy if the code teaches you better. +Strategy actually used: as planned — reworded the 4 placeholder VALUES (2 templates × Touches+Scope): §3 Touches now says "name symbols, not the full file list — §5 Scope owns the write-set"; §5 Scope now says "the file write-set, the single source of truth (§3 Touches names symbols, this names files)". LABELS + `./src/` token byte-untouched. The one ripple test (test_fastlane_ground_lite) was migrated in the TESTS phase to match the Touches line by label + `<…>` regex (robust to any future reword — greens against both old and new). Synced ×4 template twins (canonical + bundle tracked, 2 gitignored dogfood). No add.py edit → no ENGINE_MD5/SEAMS change. +Code lives in: `./src/` · Constraints: change no test, no frozen contract; stay inside the §3 Build-strategy Scope; allow-list packages only. + +--- + +## 6 · VERIFY — evidence + gate + +- [x] all tests pass · coverage held · no test or contract altered during build (full fence) +- [x] green was EARNED — target asserts (touches + scope) RED first for want of the reworded text; labels/parity green throughout; GREEN only after the reword +- [x] input dialect held — the test speaks the real template placeholder dialect (label + `<…>` value) +- [x] no exposed secrets, injection openings, or unexpected dependencies (pure documentation-string change; security = HARD-STOP: none) + +Build expectations (from §1 Accept + §3 CONTRACT): both TASK templates' §3 Touches placeholder reads "name symbols, not the full file list — §5 Scope owns the write-set" and §5 Scope reads "the single source of truth for the file write-set"; labels + `./src/` token unchanged; canonical==bundle — confirmed by test_template_touches_scope_dedup (4 asserts) + test_seams_template_wiring (labels) + test_bundle_parity (twin bytes) + migrated test_fastlane_ground_lite, all green in the full fence. + +### GATE RECORD +Outcome: PASS +Reviewed by: Tin Dang · date: 2026-07-14 + diff --git a/add-method/src/add_method/_bundled/tooling/templates/TASK.fast.md.tmpl b/add-method/src/add_method/_bundled/tooling/templates/TASK.fast.md.tmpl index 99f41f16..52af89be 100644 --- a/add-method/src/add_method/_bundled/tooling/templates/TASK.fast.md.tmpl +++ b/add-method/src/add_method/_bundled/tooling/templates/TASK.fast.md.tmpl @@ -29,7 +29,7 @@ Assumptions: ⚠ — why; if wrong: (or "no ## 3 · PLAN — the change plan: ground · contract · build-strategy ### Grounding -Touches (files · symbols): +Touches (files · symbols): Context (working folder): Honors (patterns / conventions): Anchors the contract cites: @@ -45,7 +45,7 @@ Ground SHA: +Scope (may touch): `./src/` Strategy & known-problem fixes: Approach (domain strategy): diff --git a/add-method/src/add_method/_bundled/tooling/templates/TASK.md.tmpl b/add-method/src/add_method/_bundled/tooling/templates/TASK.md.tmpl index 415944c7..c4959360 100644 --- a/add-method/src/add_method/_bundled/tooling/templates/TASK.md.tmpl +++ b/add-method/src/add_method/_bundled/tooling/templates/TASK.md.tmpl @@ -58,7 +58,7 @@ Scenario: # ## 3 · PLAN — the change plan: ground · contract · build-strategy ▸ docs/05-step-3-plan.md ### Grounding (the real code the contract will cite — gather BEFORE you freeze) -Touches (files · symbols · signatures): +Touches (files · symbols · signatures): Context (working folder): Honors (patterns / conventions): Seams consulted: @@ -81,7 +81,7 @@ Status: DRAFT Reported: ### Build-strategy (the intended approach — SOFT: preferred; the builder self-improves and records what it ACTUALLY did at verify) -Scope (may touch): `./src/` +Scope (may touch): `./src/` Strategy (ordered batches): <1. … 2. … — the planned build order; guidance, not enforced; let the named Persona's domain stance shape the approach, not just architecture patterns> Approach (domain strategy): Data strategy: diff --git a/add-method/tooling/templates/TASK.fast.md.tmpl b/add-method/tooling/templates/TASK.fast.md.tmpl index 99f41f16..52af89be 100644 --- a/add-method/tooling/templates/TASK.fast.md.tmpl +++ b/add-method/tooling/templates/TASK.fast.md.tmpl @@ -29,7 +29,7 @@ Assumptions: ⚠ — why; if wrong: (or "no ## 3 · PLAN — the change plan: ground · contract · build-strategy ### Grounding -Touches (files · symbols): +Touches (files · symbols): Context (working folder): Honors (patterns / conventions): Anchors the contract cites: @@ -45,7 +45,7 @@ Ground SHA: +Scope (may touch): `./src/` Strategy & known-problem fixes: Approach (domain strategy): diff --git a/add-method/tooling/templates/TASK.md.tmpl b/add-method/tooling/templates/TASK.md.tmpl index 415944c7..c4959360 100644 --- a/add-method/tooling/templates/TASK.md.tmpl +++ b/add-method/tooling/templates/TASK.md.tmpl @@ -58,7 +58,7 @@ Scenario: # ## 3 · PLAN — the change plan: ground · contract · build-strategy ▸ docs/05-step-3-plan.md ### Grounding (the real code the contract will cite — gather BEFORE you freeze) -Touches (files · symbols · signatures): +Touches (files · symbols · signatures): Context (working folder): Honors (patterns / conventions): Seams consulted: @@ -81,7 +81,7 @@ Status: DRAFT Reported: ### Build-strategy (the intended approach — SOFT: preferred; the builder self-improves and records what it ACTUALLY did at verify) -Scope (may touch): `./src/` +Scope (may touch): `./src/` Strategy (ordered batches): <1. … 2. … — the planned build order; guidance, not enforced; let the named Persona's domain stance shape the approach, not just architecture patterns> Approach (domain strategy): Data strategy: diff --git a/add-method/tooling/test_fastlane_ground_lite.py b/add-method/tooling/test_fastlane_ground_lite.py index 9386f50a..a586e8d6 100644 --- a/add-method/tooling/test_fastlane_ground_lite.py +++ b/add-method/tooling/test_fastlane_ground_lite.py @@ -11,6 +11,7 @@ import contextlib import io import os +import re import shutil import tempfile import unittest @@ -81,18 +82,20 @@ def _check_out(self): def test_scaffold_contains_field(self): # scenario 3 self.assertIn("Ground SHA:", _ground_block(self.md.read_text(encoding="utf-8"))) + # template-touches-scope-dedup: match the Touches line by label + any <…> placeholder + # (the dedup reworded the placeholder value), so these stay green across future rewords. + _TOUCHES_PLACEHOLDER = re.compile(r"Touches \(files · symbols\): <[^\n]*>") + _TOUCHES_LINEREF = "Touches (files · symbols): add.py l.42 — the audit printer" + def test_warn_fires_on_lineref_placeholder_sha(self): # scenario 4 - t = self.md.read_text(encoding="utf-8").replace( - "Touches (files · symbols): ", - "Touches (files · symbols): add.py l.42 — the audit printer") + t = self._TOUCHES_PLACEHOLDER.sub( + self._TOUCHES_LINEREF, self.md.read_text(encoding="utf-8")) self.md.write_text(t, encoding="utf-8") self.assertIn("cites line numbers", self._check_out()) def test_warn_clears_when_sha_filled(self): # scenario 5 - t = self.md.read_text(encoding="utf-8").replace( - "Touches (files · symbols): ", - "Touches (files · symbols): add.py l.42 — the audit printer") - import re + t = self._TOUCHES_PLACEHOLDER.sub( + self._TOUCHES_LINEREF, self.md.read_text(encoding="utf-8")) t = re.sub(r"Ground SHA: <[^>\n]*>", "Ground SHA: abc1234", t) self.md.write_text(t, encoding="utf-8") self.assertNotIn("cites line numbers", self._check_out()) diff --git a/add-method/tooling/test_template_touches_scope_dedup.py b/add-method/tooling/test_template_touches_scope_dedup.py new file mode 100644 index 00000000..06a0888c --- /dev/null +++ b/add-method/tooling/test_template_touches_scope_dedup.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Red/green tests for template-touches-scope-dedup (call-residuals, frozen §3 v1): +the two TASK templates invited the SAME file list twice — §3 `Touches (files · symbols)` +and §5 `Scope (may touch)` — so agents authored the write-set twice (the duplication +scope-first-draft's own Grounding had to hand-fix this session). The dedup rewords the +placeholder VALUES so §3 Touches names symbols and points to §5 Scope as the write-set +owner, and §5 Scope declares itself the single source of truth. LABELS stay byte-frozen +(test_seams_template_wiring pins them); canonical==bundle stays byte-identical +(test_bundle_parity). This asserts the guidance text across BOTH tracked template trees. + +Run: python3 -m unittest test_template_touches_scope_dedup -v +""" +import re +import unittest +from pathlib import Path + +_HERE = Path(__file__).resolve().parent # add-method/tooling +_TREES = [ + _HERE / "templates", # canonical + _HERE.parent / "src" / "add_method" / "_bundled" / "tooling" / "templates", # bundle +] +_FILES = ["TASK.md.tmpl", "TASK.fast.md.tmpl"] + + +def _line(text: str, label: str) -> str: + for ln in text.splitlines(): + if ln.lstrip().startswith(label): + return ln + return "" + + +class TemplateDedupTest(unittest.TestCase): + def _templates(self): + for tree in _TREES: + for name in _FILES: + p = tree / name + self.assertTrue(p.exists(), f"missing template: {p}") + yield p, p.read_text(encoding="utf-8") + + def test_touches_points_to_scope_as_write_set_owner(self): + # §3 Touches placeholder must tell the agent NOT to re-list files, → §5 Scope owns it. + for p, text in self._templates(): + touches = _line(text, "Touches (files") + self.assertIn("not the full file list", touches, + f"{p}: §3 Touches must say 'not the full file list'") + self.assertRegex(touches, r"§5 Scope", + f"{p}: §3 Touches must point to §5 Scope as the write-set owner") + + def test_scope_declares_single_source_of_truth(self): + # §5 Scope placeholder must declare itself the one owner of the file write-set. + for p, text in self._templates(): + scope = _line(text, "Scope (may touch):") + self.assertIn("single source of truth", scope, + f"{p}: §5 Scope must declare 'single source of truth' for the write-set") + + def test_labels_and_src_token_byte_unchanged(self): + # the dedup rewords ONLY the <…> value — labels + the ./src/ default stay frozen. + for p, text in self._templates(): + touches = _line(text, "Touches (files") + self.assertRegex(touches, r"^Touches \(files · symbols(?: · signatures)?\): ", + f"{p}: Touches LABEL must be byte-unchanged") + scope = _line(text, "Scope (may touch):") + self.assertIn("`./src/`", scope, f"{p}: §5 Scope must keep the ./src/ default token") + + def test_canonical_and_bundle_byte_identical(self): + for name in _FILES: + canon = (_TREES[0] / name).read_text(encoding="utf-8") + bundle = (_TREES[1] / name).read_text(encoding="utf-8") + self.assertEqual(canon, bundle, f"{name}: canonical and bundle template must be byte-identical") + + +if __name__ == "__main__": + unittest.main() From 86d9d229c07cdd5035c5dc3a09314c3169e6e4d4 Mon Sep 17 00:00:00 2001 From: Tin Dang Date: Tue, 14 Jul 2026 20:03:35 +0700 Subject: [PATCH 4/6] =?UTF-8?q?feat(method):=20template-hint-dedup=20?= =?UTF-8?q?=E2=80=94=20hints=20demand=20distinct=20content,=20killing=20?= =?UTF-8?q?=C2=A75/=C2=A76/exit-criteria=20restatement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit call-residuals task 4. Investigating the recent TASK.md files surfaced a restatement class: this session's own fast tasks wrote §5 Approach as an echo of the Strategy stance and §6 Build-expectations as a paraphrase of §1 Accept, and milestone Exit-criteria restated each task's plan line. The fields were being re-authored from text already frozen upstream. Two of the four candidates turned out to be load-bearing GATES, not ceremony — so the fix tightens the HINT (never drops the field), keeping every gate intact: - fast TASK.fast.md.tmpl §5 Approach -> a <=6-word domain-technique TAG, "NOT a restatement of the Strategy above" - fast §5 Strategy-actually-used -> "as planned" or ONLY divergences ("don't re-narrate") - fast §6 Build-expectations -> a CONCRETE observable you can SEE (printed line / exit code / file byte), "NOT a paraphrase of §1 Accept". The `<...>` wrapper + label stay, so the build-expectations gate (add.py:1446) still fires on the unfilled shipped template — NON-weakening. - MILESTONE.md.tmpl Exit-criteria -> the SEEN outcome only, "NOT the task's plan line"; the `(<- )` mapping stays (goal_auto_ready unchanged). The full TASK.md.tmpl §6 block already guards this ("evidence you can SEE, not test names") — the redundancy was fast-lane-only, so the full template is untouched. Doc-layer only -> NO ENGINE_MD5/SEAMS re-pin. Red test first (test_template_hint_dedup, 6 asserts across both tracked template trees, incl. a gate-still-fires + parity guard). Full fence caught one downstream pin (test_strategy_facets.py:FAST_FACET_LINE pinned the old Approach placeholder) — migrated it to the new text (still one collapsed line, same §5 position: the facets fast-collapse invariant holds; a pin migration, not a weakening), widened §5, re-crossed. Synced x4 twins per template. Full fence green (3588 tests). One human freeze at the frozen §3 contract. author: Tin Dang --- .add/state.json | 52 +++++++++- .add/tasks/template-hint-dedup/TASK.md | 98 +++++++++++++++++++ .../tooling/templates/MILESTONE.md.tmpl | 2 +- .../tooling/templates/TASK.fast.md.tmpl | 6 +- .../tooling/templates/MILESTONE.md.tmpl | 2 +- .../tooling/templates/TASK.fast.md.tmpl | 6 +- add-method/tooling/test_strategy_facets.py | 9 +- .../tooling/test_template_hint_dedup.py | 85 ++++++++++++++++ 8 files changed, 246 insertions(+), 14 deletions(-) create mode 100644 .add/tasks/template-hint-dedup/TASK.md create mode 100644 add-method/tooling/test_template_hint_dedup.py diff --git a/.add/state.json b/.add/state.json index d468e221..cce6c8da 100644 --- a/.add/state.json +++ b/.add/state.json @@ -1,7 +1,7 @@ { "project": "AIDD / ADD Methodology", "stage": "mvp", - "active_task": "template-touches-scope-dedup", + "active_task": "template-hint-dedup", "tasks": { "add-check": { "title": "add.py check: validate .add project integrity", @@ -9513,10 +9513,56 @@ "email": "tindang.ht97@gmail.com", "source": "git" } + }, + "template-hint-dedup": { + "title": "template hints demand distinct content: kill \u00a75/\u00a76/exit-criteria restatement", + "phase": "done", + "gate": "PASS", + "milestone": "call-residuals", + "depends_on": [], + "created": "2026-07-14T12:43:57+00:00", + "updated": "2026-07-14T13:03:12+00:00", + "fast": true, + "freeze": { + "version": "v1", + "frozen_at": "2026-07-14T12:46:29+00:00", + "approved_by": "Tin Dang", + "actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } + }, + "flag_verified": true, + "tripwire": { + "contract_md5": "597c4434274b7de7e860e48f91e8962e", + "tests": {} + }, + "scope": { + "declared": [ + "add-method/tooling/templates/TASK.fast.md.tmpl", + "add-method/tooling/templates/MILESTONE.md.tmpl", + "add-method/src/add_method/_bundled/tooling/templates/TASK.fast.md.tmpl", + "add-method/src/add_method/_bundled/tooling/templates/MILESTONE.md.tmpl", + "add-method/tooling/test_template_hint_dedup.py", + "add-method/tooling/test_strategy_facets.py" + ], + "snapshot_md5": "93ae9adf15dc0e629986b007accb0e42" + }, + "recross": { + "by": "Tin Dang", + "at": "2026-07-14T12:56:25+00:00", + "from_phase": "build" + }, + "gate_actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } } }, "created": "2026-05-28T15:39:04+00:00", - "updated": "2026-07-14T12:20:53+00:00", + "updated": "2026-07-14T13:03:12+00:00", "milestones": { "v1-1": { "title": "v1.1 \u2014 adoption & ergonomics", @@ -10802,7 +10848,7 @@ "ceremony-to-effort": "fold-draft-at-close", "call-floor": "skill-orient-split", "six-phase-loop": "installer-shared-namespace-guard", - "call-residuals": "template-touches-scope-dedup" + "call-residuals": "template-hint-dedup" }, "todos": [ { diff --git a/.add/tasks/template-hint-dedup/TASK.md b/.add/tasks/template-hint-dedup/TASK.md new file mode 100644 index 00000000..781d1329 --- /dev/null +++ b/.add/tasks/template-hint-dedup/TASK.md @@ -0,0 +1,98 @@ +# TASK: template hints demand distinct content: kill §5/§6/exit-criteria restatement + +slug: template-hint-dedup · created: 2026-07-14 · stage: mvp +milestone: call-residuals +autonomy: auto +phase: done +fast: true + +> Fast lane — one small task, minimal sections, filled top-to-bottom. The trust floor still +> holds: a FROZEN §3 contract · ≥1 red test before build · a recorded §6 gate (security = HARD-STOP). +> The acceptance scenario collapses into §1 `Accept:`; the observe note is one optional line at the gate. + +--- + +## 1 · SPECIFY — the rules + +> Project the expectations from the milestone Ground + this request — light, not re-invented. +Feature: template hints demand DISTINCT content — three fast-lane §5/§6 placeholder hints + the milestone Exit-criteria hint are tightened so the agent writes each field's OWN content instead of restating text already frozen upstream (§1 Accept, §3 Strategy, the task's plan line). Kills the measured restatement class the recent TASK.md files showed (this session's own tasks wrote §5 Approach as a Strategy-stance echo and §6 Build-expectations as an Accept paraphrase). NON-weakening: every gate (build-expectations-gate, goal_auto_ready) keeps firing — only the guidance value changes. +Must: + - fast `TASK.fast.md.tmpl` §5 `Approach (domain strategy):` hint asks for a ≤6-word domain-technique TAG and says it is NOT a restatement of the Strategy above + - fast §5 `Strategy actually used:` hint asks for "as planned" or ONLY the divergences from §3 Strategy (don't re-narrate it) + - fast §6 `Build expectations` hint asks for the CONCRETE observable (printed line / exit code / file byte you can SEE) and says NOT a paraphrase of §1 Accept — the `### Build expectations` gate still fires on an unfilled `<…>` placeholder (unchanged) + - `MILESTONE.md.tmpl` `## Exit criteria` hint asks for the SEEN outcome only, NOT the task's plan line (the `(← )` mapping stays) + - LABELS + the `(from §1 Accept + §3 CONTRACT)` / `(← )` structure byte-unchanged; canonical == bundle for both templates +Reject: + - none — documentation-string change only; no code path, parser, or gate altered (milestone OUT-of-scope). The build-expectations gate + goal_auto_ready gate must stay green unchanged +Accept: Given the fast TASK template and the MILESTONE template, When an agent reads §5/§6 and the Exit-criteria hint, Then each hint demands the field's distinct/concrete content (technique tag · divergences-only · a SEEN observable not an Accept paraphrase · the observed outcome not the plan line), so no field restates frozen upstream text +Boundary: none — static template text asserted by string-presence; no external input shape +Assumptions: ⚠ tightening the HINT (not adding a gate) is enough to change agent behavior — evidence: the deduped Touches/Scope hint from the prior task already scaffolded correctly into THIS task; if wrong (agents still restate): no harm, the gates are untouched and it stays pure guidance + +--- + +## 3 · PLAN — the change plan: ground · contract · build-strategy + +### Grounding +Touches (files · symbols): the 4 placeholder-hint lines — `TASK.fast.md.tmpl:Approach`/`:Strategy-actually-used`/`:Build-expectations` + `MILESTONE.md.tmpl:Exit-criteria`; no engine symbol edited (pure template text) +Context (working folder): `add-method/tooling/templates/` (canonical). The full write-set — fast TASK template + milestone template, both bundle twins, the two gitignored dogfood twins, the new test — is the §5 Scope below +Honors (patterns / conventions): the [[template-touches-scope-dedup]] recipe just applied — reword only the `<…>` VALUE, keep LABELS byte-frozen; test_bundle_parity holds canonical==bundle; the full `TASK.md.tmpl` §6 `### Build expectations` block already says "evidence you can SEE, not test names" (unchanged — the redundancy is fast-lane-only) +Anchors the contract cites: the 4 hint VALUES (edited) · the build-expectations gate (add.py:1446, unchanged) · goal_auto_ready (unchanged) · test_bundle_parity +Ground SHA: cfe7f5a — stamped by freeze + +### Contract + +``` +TASK.fast.md.tmpl §5 `Approach (domain strategy):` → hint VALUE demands a ≤6-word technique TAG, "NOT a restatement of the Strategy above" +TASK.fast.md.tmpl §5 `Strategy actually used:` → hint VALUE demands "as planned" or ONLY divergences ("don't re-narrate") +TASK.fast.md.tmpl §6 `Build expectations (…):` → hint VALUE demands a CONCRETE SEEN observable, "NOT a paraphrase of §1 Accept" +MILESTONE.md.tmpl `## Exit criteria` `- [ ]` line → hint VALUE demands the SEEN outcome only, "NOT the task's plan line"; the `(← )` mapping stays +INVARIANTS (unchanged): + → LABELS + the `(from §1 Accept + §3 CONTRACT)` and `(← )` structure byte-identical + → no engine/gate change: build-expectations gate (add.py:1446) still fires on unfilled `<…>`; goal_auto_ready unchanged + → canonical == bundle for BOTH templates (test_bundle_parity green) +``` + +`Least-sure flag surfaced at freeze:` [contract] the exact hint wording — pure guidance text, cosmetic; the only hard edge is keeping the `<…>` wrapper (so the build-expectations gate still detects the shipped placeholder as unfilled) + the labels byte-identical. +Status: FROZEN @ v1 — approved by Tin Dang +### Build-strategy +Scope (may touch): `add-method/tooling/templates/TASK.fast.md.tmpl` `add-method/tooling/templates/MILESTONE.md.tmpl` `add-method/src/add_method/_bundled/tooling/templates/TASK.fast.md.tmpl` `add-method/src/add_method/_bundled/tooling/templates/MILESTONE.md.tmpl` `add-method/tooling/test_template_hint_dedup.py` `add-method/tooling/test_strategy_facets.py` +Strategy & known-problem fixes: (1) RED first: new test_template_hint_dedup asserts the 4 hint VALUES carry their distinct-content phrase ("NOT a restatement" · "divergences"/"don't re-narrate" · "NOT a paraphrase"/"SEE" · "NOT the task's plan line") across both tracked template trees — RED now. (2) reword the 4 `<…>` values, keeping LABELS + `<…>` wrapper + `(← )`/`(from …)` structure (trap: the build-expectations gate detects `<…>` placeholders — keep the wrapper or the shipped template reads as "filled" and the gate stops firing). (3) sync ×4 twins per template (2 tracked + 2 gitignored). No add.py edit → NO ENGINE_MD5/SEAMS change. +Approach (domain strategy): doc-layer hint-tighten, gate-preserving + +### AI-verify record (required when gate_mode: ai-plan-verify) +- [ ] §3 PLAN grounding anchors resolve in the current tree +- [ ] §1 every Must + every Reject present, each Reject paired with an error code +- [ ] §3 Contract shape is concrete (no template placeholder text remains) +- [ ] Lowest-confidence flag surfaced and substantive (mirrors unflagged_freeze's own bar) +Verified by: · at: + +--- + +## 4 · TESTS — failing-first (red) + +Plan: test_ — assert the §1 Accept line's Then (behavior, not internals). +Tests live in: `./tests/` · MUST run red (missing implementation) before Build. + +--- + +## 5 · BUILD — AI writes the code (execution) + +> The change plan was frozen in §3 PLAN. Build to it: honor the §3 Build-strategy Scope; improve on the strategy if the code teaches you better. +Strategy actually used: as planned for the 4 rewords; ONE unplanned ripple caught by the full fence — `test_strategy_facets.py:FAST_FACET_LINE` pinned the exact OLD Approach placeholder (a value-pin my §3 Grounding missed). Migrated that constant to the new Approach text (still one collapsed line, same §5 position — the facets fast-collapse invariant holds; a pin migration, NOT a weakening), widened §5 Scope to cover it, re-crossed. Reworded the 4 `<…>` hint values, kept labels + `<…>` wrapper + `(from…)`/`(← slug)`, synced ×4 twins per template. No engine edit. +Code lives in: `./src/` · Constraints: change no test, no frozen contract; stay inside the §3 Build-strategy Scope; allow-list packages only. + +--- + +## 6 · VERIFY — evidence + gate + +- [x] all tests pass · coverage held · no test or contract altered during build (full fence) +- [x] green was EARNED — the 4 hint-content asserts RED first, gate-placeholder + parity green throughout; GREEN only after the reword +- [x] input dialect held — the test speaks the real template placeholder dialect (label + `<…>` value) +- [x] no exposed secrets, injection openings, or unexpected dependencies (pure template text; security = HARD-STOP: none) + +Build expectations (from §1 Accept + §3 CONTRACT): grep of `templates/TASK.fast.md.tmpl` prints "NOT a restatement" on the Approach line, "diverged"+"don't re-narrate" on Strategy-actually-used, "NOT a paraphrase"+"SEE" on Build-expectations; `templates/MILESTONE.md.tmpl` prints "NOT the task's plan line" on the exit-criteria line; each still wraps a `<…>` placeholder and canonical==bundle — confirmed by test_template_hint_dedup (6 asserts) + test_build_expectations_gate + test_bundle_parity green in the full fence. + +### GATE RECORD +Outcome: PASS +Reviewed by: Tin Dang · date: 2026-07-14 + diff --git a/add-method/src/add_method/_bundled/tooling/templates/MILESTONE.md.tmpl b/add-method/src/add_method/_bundled/tooling/templates/MILESTONE.md.tmpl index d39a2874..606b2250 100644 --- a/add-method/src/add_method/_bundled/tooling/templates/MILESTONE.md.tmpl +++ b/add-method/src/add_method/_bundled/tooling/templates/MILESTONE.md.tmpl @@ -42,7 +42,7 @@ Issues/Risks (shared): depends-on: ## Exit criteria (observable; map each to the task that delivers it) -- [ ] User can (← ) +- [ ] User can (← ) ## Close — ship review (AI fills when every task is done — the evidence behind the engine gate, read before the boxes are checked) > Whole-milestone, cross-task review the AI fills in. It is the evidence behind the EXISTING engine diff --git a/add-method/src/add_method/_bundled/tooling/templates/TASK.fast.md.tmpl b/add-method/src/add_method/_bundled/tooling/templates/TASK.fast.md.tmpl index 52af89be..41f19e51 100644 --- a/add-method/src/add_method/_bundled/tooling/templates/TASK.fast.md.tmpl +++ b/add-method/src/add_method/_bundled/tooling/templates/TASK.fast.md.tmpl @@ -47,7 +47,7 @@ Status: DRAFT ### Build-strategy Scope (may touch): `./src/` Strategy & known-problem fixes: -Approach (domain strategy): +Approach (domain strategy): ### AI-verify record (required when gate_mode: ai-plan-verify) @@ -69,7 +69,7 @@ Tests live in: `./tests/` · MUST run red (missing implementation) before Build. ## 5 · BUILD — AI writes the code (execution) > The change plan was frozen in §3 PLAN. Build to it: honor the §3 Build-strategy Scope; improve on the strategy if the code teaches you better. -Strategy actually used: +Strategy actually used: Code lives in: `./src/` · Constraints: change no test, no frozen contract; stay inside the §3 Build-strategy Scope; allow-list packages only. --- @@ -81,7 +81,7 @@ Code lives in: `./src/` · Constraints: change no test, no frozen contract; - [ ] input dialect held — tests speak the spec's example formats (spec-dialect floor) - [ ] no exposed secrets, injection openings, or unexpected dependencies (security = HARD-STOP) -Build expectations (from §1 Accept + §3 CONTRACT): > +Build expectations (from §1 Accept + §3 CONTRACT): > ### GATE RECORD Outcome: diff --git a/add-method/tooling/templates/MILESTONE.md.tmpl b/add-method/tooling/templates/MILESTONE.md.tmpl index d39a2874..606b2250 100644 --- a/add-method/tooling/templates/MILESTONE.md.tmpl +++ b/add-method/tooling/templates/MILESTONE.md.tmpl @@ -42,7 +42,7 @@ Issues/Risks (shared): depends-on: ## Exit criteria (observable; map each to the task that delivers it) -- [ ] User can (← ) +- [ ] User can (← ) ## Close — ship review (AI fills when every task is done — the evidence behind the engine gate, read before the boxes are checked) > Whole-milestone, cross-task review the AI fills in. It is the evidence behind the EXISTING engine diff --git a/add-method/tooling/templates/TASK.fast.md.tmpl b/add-method/tooling/templates/TASK.fast.md.tmpl index 52af89be..41f19e51 100644 --- a/add-method/tooling/templates/TASK.fast.md.tmpl +++ b/add-method/tooling/templates/TASK.fast.md.tmpl @@ -47,7 +47,7 @@ Status: DRAFT ### Build-strategy Scope (may touch): `./src/` Strategy & known-problem fixes: -Approach (domain strategy): +Approach (domain strategy): ### AI-verify record (required when gate_mode: ai-plan-verify) @@ -69,7 +69,7 @@ Tests live in: `./tests/` · MUST run red (missing implementation) before Build. ## 5 · BUILD — AI writes the code (execution) > The change plan was frozen in §3 PLAN. Build to it: honor the §3 Build-strategy Scope; improve on the strategy if the code teaches you better. -Strategy actually used: +Strategy actually used: Code lives in: `./src/` · Constraints: change no test, no frozen contract; stay inside the §3 Build-strategy Scope; allow-list packages only. --- @@ -81,7 +81,7 @@ Code lives in: `./src/` · Constraints: change no test, no frozen contract; - [ ] input dialect held — tests speak the spec's example formats (spec-dialect floor) - [ ] no exposed secrets, injection openings, or unexpected dependencies (security = HARD-STOP) -Build expectations (from §1 Accept + §3 CONTRACT): > +Build expectations (from §1 Accept + §3 CONTRACT): > ### GATE RECORD Outcome: diff --git a/add-method/tooling/test_strategy_facets.py b/add-method/tooling/test_strategy_facets.py index 5c9be1b8..35430363 100644 --- a/add-method/tooling/test_strategy_facets.py +++ b/add-method/tooling/test_strategy_facets.py @@ -78,9 +78,12 @@ def _existing(*paths): "readability — or \"correctness-first, no budget\"; never blank; ⚠-mark the facet you trust " "least; risk: high -> consult add-advisor; advisory, never a gate>", ) -FAST_FACET_LINE = ("Approach (domain strategy): ") +# template-hint-dedup: the fast §5 Approach hint migrated from a stance-restating one-liner +# to a ≤6-word technique TAG ("NOT a restatement of the Strategy above") — still ONE collapsed +# line in the same §5 position (the facets feature's fast-collapse invariant holds). +FAST_FACET_LINE = ("Approach (domain strategy): ") def _md5(p: Path) -> str: diff --git a/add-method/tooling/test_template_hint_dedup.py b/add-method/tooling/test_template_hint_dedup.py new file mode 100644 index 00000000..638c1ce5 --- /dev/null +++ b/add-method/tooling/test_template_hint_dedup.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Red/green tests for template-hint-dedup (call-residuals, frozen §3 v1): +the recent fast TASK.md files restated frozen upstream text — §5 Approach echoed the +Strategy stance, §6 Build-expectations paraphrased §1 Accept, and the milestone +Exit-criteria restated the task's plan line. This tightens those 4 placeholder HINTS +to demand each field's OWN distinct/concrete content. NON-weakening: the LABELS + the +`<…>` wrapper stay, so the build-expectations gate still fires on an unfilled template; +no engine/gate code changes. Asserts the hint VALUES across both tracked template trees. + +Run: python3 -m unittest test_template_hint_dedup -v +""" +import unittest +from pathlib import Path + +_HERE = Path(__file__).resolve().parent # add-method/tooling +_TREES = [ + _HERE / "templates", # canonical + _HERE.parent / "src" / "add_method" / "_bundled" / "tooling" / "templates", # bundle +] + + +def _line(text: str, needle: str) -> str: + for ln in text.splitlines(): + if needle in ln: + return ln + return "" + + +class HintDedupTest(unittest.TestCase): + def _fast(self): + for tree in _TREES: + p = tree / "TASK.fast.md.tmpl" + self.assertTrue(p.exists(), f"missing: {p}") + yield p, p.read_text(encoding="utf-8") + + def _milestone(self): + for tree in _TREES: + p = tree / "MILESTONE.md.tmpl" + self.assertTrue(p.exists(), f"missing: {p}") + yield p, p.read_text(encoding="utf-8") + + def test_approach_hint_forbids_restatement(self): + for p, text in self._fast(): + ln = _line(text, "Approach (domain strategy):") + self.assertIn("NOT a restatement", ln, + f"{p}: §5 Approach hint must forbid restating the Strategy") + + def test_strategy_used_hint_asks_divergences_only(self): + for p, text in self._fast(): + ln = _line(text, "Strategy actually used:") + self.assertRegex(ln, r"diverg", f"{p}: §5 Strategy-actually-used hint must ask for divergences only") + self.assertIn("re-narrate", ln, f"{p}: hint must say don't re-narrate §3 Strategy") + + def test_build_expectations_hint_demands_seen_observable(self): + for p, text in self._fast(): + ln = _line(text, "Build expectations (from") + self.assertIn("NOT a paraphrase", ln, + f"{p}: §6 Build-expectations hint must forbid paraphrasing §1 Accept") + self.assertIn("SEE", ln, f"{p}: hint must demand a concrete SEEN observable") + + def test_build_expectations_placeholder_still_gated(self): + # NON-weakening: the shipped hint must remain a `<…>` placeholder so the + # build-expectations gate still reads it as unfilled. Label + `(from …)` kept. + for p, text in self._fast(): + ln = _line(text, "Build expectations (from") + self.assertIn("Build expectations (from §1 Accept + §3 CONTRACT):", ln, + f"{p}: label + derivation note must be byte-unchanged") + self.assertRegex(ln, r"<[^>]*>", f"{p}: hint must stay a `<…>` placeholder (gate detects it)") + + def test_milestone_exit_criteria_hint_forbids_plan_line(self): + for p, text in self._milestone(): + ln = _line(text, "User can )", ln, f"{p}: the `(← )` mapping must stay") + + def test_canonical_and_bundle_byte_identical(self): + for name in ("TASK.fast.md.tmpl", "MILESTONE.md.tmpl"): + canon = (_TREES[0] / name).read_text(encoding="utf-8") + bundle = (_TREES[1] / name).read_text(encoding="utf-8") + self.assertEqual(canon, bundle, f"{name}: canonical and bundle must be byte-identical") + + +if __name__ == "__main__": + unittest.main() From aaee31709164d1ed6b32d6d1731f6071cc69a47b Mon Sep 17 00:00:00 2001 From: Tin Dang Date: Tue, 14 Jul 2026 20:53:02 +0700 Subject: [PATCH 5/6] =?UTF-8?q?feat(method):=20status-orientation-diet=20?= =?UTF-8?q?=E2=80=94=20plain=20status=20leads=20with=20a=20resume=20glance?= =?UTF-8?q?=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit call-residuals task 5. The WM1 anatomy measured 3-4 status re-reads/rep. The resume info already lives in the plain `status` view — but at line ~67 of a 69-line dump, so an agent scrolls past project/personas/milestones/tasks to find its place and re-reads to re-orient. - cmd_status plain view now leads (right after the do-not-init line, before `project :`) with a 2-line "now" glance card when an active task exists: now : '' · phase= · next: TASK.md: .add/tasks//TASK.md · re-orient: add.py status --brief It reuses `_next_footer` (the ONE next-verb composer, also used by --brief) — no divergent wording. Distinct "now" label so it never collides with the bottom `resume :` block (~8 tests pin that). Guarded on an active task — setup and no-task paths stay byte-identical. --brief / --json / --section unchanged. Additive stdout reorder — no gate / enforcement / state change (milestone OUT-of-scope). Red test first (test_status_orientation_diet, 5 asserts incl. positioned-before-project + brief-unchanged + no-card-guard). Engine synced x4 twins, ENGINE_MD5 -> c0c972e2, SEAMS _declared_scope pin 5677->5688. Full fence green (3593 tests). One human freeze; scope re-crossed once after the engine_pin twins widened §5. author: Tin Dang --- .add/SEAMS.md | 2 +- .add/state.json | 63 +++++++++++- .add/tasks/status-orientation-diet/TASK.md | 98 +++++++++++++++++++ .../src/add_method/_bundled/tooling/add.py | 11 +++ .../add_method/_bundled/tooling/engine_pin.py | 2 +- add-method/tooling/add.py | 11 +++ add-method/tooling/engine_pin.py | 2 +- .../tooling/test_status_orientation_diet.py | 90 +++++++++++++++++ 8 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 .add/tasks/status-orientation-diet/TASK.md create mode 100644 add-method/tooling/test_status_orientation_diet.py diff --git a/.add/SEAMS.md b/.add/SEAMS.md index 2c0499a8..62c1062e 100644 --- a/.add/SEAMS.md +++ b/.add/SEAMS.md @@ -54,7 +54,7 @@ Citations: 232 files reference "byte-identical" in `.add/tasks/` — method: ## scope-token-grammar Name: §5 "Scope (may touch):" token-resolution grammar -Anchor: `add-method/tooling/add.py:5677` (`_declared_scope`) +Anchor: `add-method/tooling/add.py:5688` (`_declared_scope`) Contract: `_declared_scope` reads ONLY the first physical line after the §5 header — a wrapped multi-line list silently truncates. Each backticked token then resolves independently: `./...` = this task's dir, any token containing `/` = project-root-relative, diff --git a/.add/state.json b/.add/state.json index cce6c8da..901e195f 100644 --- a/.add/state.json +++ b/.add/state.json @@ -1,7 +1,7 @@ { "project": "AIDD / ADD Methodology", "stage": "mvp", - "active_task": "template-hint-dedup", + "active_task": "status-orientation-diet", "tasks": { "add-check": { "title": "add.py check: validate .add project integrity", @@ -9559,10 +9559,67 @@ "email": "tindang.ht97@gmail.com", "source": "git" } + }, + "status-orientation-diet": { + "title": "default status leads with a resume glance card (phase + next verb + file)", + "phase": "done", + "gate": "PASS", + "milestone": "call-residuals", + "depends_on": [], + "created": "2026-07-14T13:40:52+00:00", + "updated": "2026-07-14T13:52:36+00:00", + "fast": true, + "freeze": { + "version": "v1", + "frozen_at": "2026-07-14T13:42:36+00:00", + "approved_by": "Tin Dang", + "actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } + }, + "flag_verified": true, + "tripwire": { + "contract_md5": "4b56400f82ab5aaf6e1582add3ba30d4", + "tests": {} + }, + "scope": { + "declared": [ + "add-method/tooling/add.py", + "add-method/src/add_method/_bundled/tooling/add.py", + "add-method/.add/tooling/add.py", + "add-method/tooling/engine_pin.py", + "add-method/src/add_method/_bundled/tooling/engine_pin.py", + "add-method/.add/tooling/engine_pin.py", + "add-method/tooling/test_status_orientation_diet.py" + ], + "snapshot_md5": "56229ca03d9665c6d4ff61221f0405b0" + }, + "heal": { + "attempts": 1, + "history": [ + { + "at": "2026-07-14T13:52:16+00:00", + "reason": "scope_violation: task 'status-orientation-diet' touched outside its declared \u00a75 Scope \u2014 add-method/src/add_method/_bundled/tooling/engine_pin.py (1 total)", + "source": "scope" + } + ] + }, + "recross": { + "by": "Tin Dang", + "at": "2026-07-14T13:52:35+00:00", + "from_phase": "build" + }, + "gate_actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } } }, "created": "2026-05-28T15:39:04+00:00", - "updated": "2026-07-14T13:03:12+00:00", + "updated": "2026-07-14T13:52:36+00:00", "milestones": { "v1-1": { "title": "v1.1 \u2014 adoption & ergonomics", @@ -10848,7 +10905,7 @@ "ceremony-to-effort": "fold-draft-at-close", "call-floor": "skill-orient-split", "six-phase-loop": "installer-shared-namespace-guard", - "call-residuals": "template-hint-dedup" + "call-residuals": "status-orientation-diet" }, "todos": [ { diff --git a/.add/tasks/status-orientation-diet/TASK.md b/.add/tasks/status-orientation-diet/TASK.md new file mode 100644 index 00000000..2a5dd083 --- /dev/null +++ b/.add/tasks/status-orientation-diet/TASK.md @@ -0,0 +1,98 @@ +# TASK: default status leads with a resume glance card (phase + next verb + file) + +slug: status-orientation-diet · created: 2026-07-14 · stage: mvp +milestone: call-residuals +autonomy: auto +phase: done +fast: true + +> Fast lane — one small task, minimal sections, filled top-to-bottom. The trust floor still +> holds: a FROZEN §3 contract · ≥1 red test before build · a recorded §6 gate (security = HARD-STOP). +> The acceptance scenario collapses into §1 `Accept:`; the observe note is one optional line at the gate. + +--- + +## 1 · SPECIFY — the rules + +> Project the expectations from the milestone Ground + this request — light, not re-invented. +Feature: default `status` leads with a resume glance card — the plain view opens (right after the do-not-init line) with the active task's phase + next verb + resume file, so a SINGLE status read orients the agent instead of the 3-4×/rep re-reads the WM1 anatomy measured (the resume info exists today but sits at line ~67 of a 69-line dump). +Must: + - `status` (plain view) with an active task prints a resume glance card as its FIRST content after the do-not-init line: the active slug · `phase=` · the next-verb line (reusing `_next_footer`) · the resume file `.add/tasks//TASK.md` + - the card appears BEFORE the `project :` line (top-of-view, one glance) and reuses the existing next-verb composer — no divergent wording + - every existing plain-view line (do-not-init · project · … · the bottom resume block) still prints, in the same relative order, just shifted down (additive) + - `--brief` / `--json` / `--section` views unchanged; no active task → no card (setup/no-task paths byte-unchanged) +Reject: + - none — message-layer reorder/surface only; no gate / enforcement / state change (milestone OUT-of-scope) +Accept: Given a project with an active in-progress task, When `add.py status` runs, Then the output's first lines (before `project :`) name the task, its phase, the next verb, and its TASK.md path — so no re-read is needed to resume +Boundary: none — no external input shape; the branch is active-task-present vs absent +Assumptions: ⚠ agents re-read because the resume sits at the BOTTOM, not because it's absent — evidence: the 69-line default view carries the full resume block only at line ~67; if wrong (they re-read for other reasons): the top card still can't hurt, it's additive and derived + +--- + +## 3 · PLAN — the change plan: ground · contract · build-strategy + +### Grounding +Touches (files · symbols): `add-method/tooling/add.py:cmd_status` (insert the card right after the do-not-init line, add.py:2824, before the `project :` line); it READS `_active_task` · `_next_footer` (the ONE next-verb composer, also used by --brief) — both unchanged +Context (working folder): `add-method/tooling/` (canonical engine). The full write-set — canonical add.py, its 3 engine twins, engine_pin.py, the new test — is the §5 Scope below; ENGINE_MD5 + SEAMS re-aimed as part of any engine edit +Honors (patterns / conventions): the plain-view "additive — every existing line stays put" idiom (same as the goal/run-mode lines added before it); reuse `_next_footer` (no parallel next-verb logic); the summary-first pattern (report-template) — glance card at top, detailed resume block stays at the bottom for the loop-chapter steering +Anchors the contract cites: `cmd_status` (edited) · `_active_task`, `_next_footer` (read) · `.add/tasks//TASK.md` (the resume file) +Ground SHA: 86d9d22 — stamped by freeze + +### Contract + +``` +cmd_status, PLAIN view (not --brief/--json/--section), active task present in state.tasks: + → immediately AFTER "project exists — do not re-init …" and BEFORE "project :", print: + line 1: "now : '' · phase= · <_next_footer(root, state)>" + line 2: " TASK.md: .add/tasks//TASK.md · re-orient: add.py status --brief" + → every existing plain-view line still prints, same relative order, shifted down (additive) +cmd_status, PLAIN view, NO active task (setup / done-with-none): + → no card (byte-unchanged) +cmd_status --brief / --json / --section: + → unchanged +``` + +`Least-sure flag surfaced at freeze:` [contract] the card LABEL ("now") + exact 2-line shape — cosmetic; if wrong, a one-string relabel. The one hard edge: the card must NOT reprint the bottom `resume :` block's prefix (distinct "now" label) so the ~8 tests pinning that block stay green. +Status: FROZEN @ v1 — approved by Tin Dang +### Build-strategy +Scope (may touch): `add-method/tooling/add.py` `add-method/src/add_method/_bundled/tooling/add.py` `add-method/.add/tooling/add.py` `add-method/tooling/engine_pin.py` `add-method/src/add_method/_bundled/tooling/engine_pin.py` `add-method/.add/tooling/engine_pin.py` `add-method/tooling/test_status_orientation_diet.py` +Strategy & known-problem fixes: (1) RED first: new test_status_orientation_diet asserts plain `status` with an active in-progress task emits a "now :" card carrying the phase + the next verb + the `.add/tasks//TASK.md` path, positioned BEFORE the "project :" line; and --brief stays 2-line. (2) in cmd_status after the do-not-init print (add.py:2824), when `_active_task` resolves in state.tasks, print the 2-line card reusing `_next_footer` (trap: distinct "now" label — do NOT collide with the bottom "resume :" block that ~8 tests pin; and guard on active-task-present so setup/no-task paths stay byte-identical). (3) sync ×4 twins, re-pin ENGINE_MD5 + SEAMS. +Approach (domain strategy): summary-first glance card, additive + +### AI-verify record (required when gate_mode: ai-plan-verify) +- [ ] §3 PLAN grounding anchors resolve in the current tree +- [ ] §1 every Must + every Reject present, each Reject paired with an error code +- [ ] §3 Contract shape is concrete (no template placeholder text remains) +- [ ] Lowest-confidence flag surfaced and substantive (mirrors unflagged_freeze's own bar) +Verified by: · at: + +--- + +## 4 · TESTS — failing-first (red) + +Plan: test_ — assert the §1 Accept line's Then (behavior, not internals). +Tests live in: `./tests/` · MUST run red (missing implementation) before Build. + +--- + +## 5 · BUILD — AI writes the code (execution) + +> The change plan was frozen in §3 PLAN. Build to it: honor the §3 Build-strategy Scope; improve on the strategy if the code teaches you better. +Strategy actually used: as planned — no divergence. Inserted the 2-line "now" card in cmd_status right after the do-not-init print, guarded on `_active_task in state.tasks`, reusing `_next_footer`. Synced ×4 twins, ENGINE_MD5→c0c972e2, SEAMS _declared_scope 5677→5688. +Code lives in: `./src/` · Constraints: change no test, no frozen contract; stay inside the §3 Build-strategy Scope; allow-list packages only. + +--- + +## 6 · VERIFY — evidence + gate + +- [x] all tests pass · coverage held · no test or contract altered during build (full fence) +- [x] green was EARNED — the 3 card asserts RED first (positioned before project:, phase, file); brief-unchanged + no-card guards green throughout; GREEN only after the insert +- [x] input dialect held — the test speaks the real status stdout dialect +- [x] no exposed secrets, injection openings, or unexpected dependencies (pure stdout reorder; security = HARD-STOP: none) + +Build expectations (from §1 Accept + §3 CONTRACT): `add.py status` on a project with an active task prints, as line 2 (before `project :`), `now : '' · phase= · next: ` then ` TASK.md: .add/tasks//TASK.md · re-orient: add.py status --brief`; `--brief` stays 2 lines; no card when no active task — confirmed by test_status_orientation_diet (5 asserts) + the live dogfood status showing the card first + green engine/seams/parity guards in the full fence. + +### GATE RECORD +Outcome: PASS +Reviewed by: Tin Dang · date: 2026-07-14 + diff --git a/add-method/src/add_method/_bundled/tooling/add.py b/add-method/src/add_method/_bundled/tooling/add.py index acf05a1a..6d006922 100644 --- a/add-method/src/add_method/_bundled/tooling/add.py +++ b/add-method/src/add_method/_bundled/tooling/add.py @@ -2822,6 +2822,17 @@ def cmd_status(args: argparse.Namespace) -> None: # re-runs `init` (the double-init call lever). Plain-status path only; the # --brief/--json/--section views returned above are unaffected. print("project exists — do not re-init (use --force to reset)") + # status-orientation-diet: lead the plain view with a resume glance card so a SINGLE + # status read carries phase + next verb + the resume file — the resume block already + # exists but sits at line ~67 of the dump, driving the measured 3-4x/rep re-reads. + # Additive (every line below stays put); reuses _next_footer (the ONE next-verb + # composer, also used by --brief); distinct "now" label so it never collides with the + # bottom "resume :" block. Guarded on an active task — no card on setup/no-task paths. + _now_active = _active_task(state) + if _now_active and _now_active in (state.get("tasks") or {}): + _now_ph = (state["tasks"][_now_active] or {}).get("phase", "?") + print(f"now : '{_now_active}' · phase={_now_ph} · {_next_footer(root, state)}") + print(f" TASK.md: .add/tasks/{_now_active}/TASK.md · re-orient: add.py status --brief") print(f"project : {state.get('project', '(unknown)')}") # project autonomy default (task init-auto-default): the posture new tasks INHERIT, # read LIVE from PROJECT.md so the human sees the project-wide throttle every session. diff --git a/add-method/src/add_method/_bundled/tooling/engine_pin.py b/add-method/src/add_method/_bundled/tooling/engine_pin.py index 28ce1a6b..92b5b61f 100644 --- a/add-method/src/add_method/_bundled/tooling/engine_pin.py +++ b/add-method/src/add_method/_bundled/tooling/engine_pin.py @@ -17,5 +17,5 @@ this file only ever holds the newest pointer. """ -ENGINE_MD5 = "3b438d30e0a6de007b9cfd8e4e0210fe" # re-aimed @ scope-first-draft (call-residuals: _scope_echo escalates the per-token "note:" lines to ONE paste-ready "Scope (may touch): …" line merging declared tokens + uncovered §3 Touches — turns a post-freeze re-cross repair into a freeze-time copy-paste). prior: ee4ef957… @ init-idempotent-nudge +ENGINE_MD5 = "c0c972e2ace3e8434906e2b480191da5" # re-aimed @ status-orientation-diet (call-residuals: cmd_status plain view leads with a "now" resume glance card — active slug · phase · next verb · TASK.md path — reusing _next_footer, so a single status read orients without the measured 3-4x/rep re-reads). prior: 3b438d30… @ scope-first-draft ENGINE_PKG_MD5 = "fc40ad47544db6f5204b6197b95daf04" # re-aimed @ phase-merge-verify (constants.py: PHASES drops observe; _SKIPPABLE_PHASES=(); PHASE_GUIDE/OWNER/GROUPS/AGENT follow). prior: 870a4ce0… @ phase-merge-specify diff --git a/add-method/tooling/add.py b/add-method/tooling/add.py index acf05a1a..6d006922 100644 --- a/add-method/tooling/add.py +++ b/add-method/tooling/add.py @@ -2822,6 +2822,17 @@ def cmd_status(args: argparse.Namespace) -> None: # re-runs `init` (the double-init call lever). Plain-status path only; the # --brief/--json/--section views returned above are unaffected. print("project exists — do not re-init (use --force to reset)") + # status-orientation-diet: lead the plain view with a resume glance card so a SINGLE + # status read carries phase + next verb + the resume file — the resume block already + # exists but sits at line ~67 of the dump, driving the measured 3-4x/rep re-reads. + # Additive (every line below stays put); reuses _next_footer (the ONE next-verb + # composer, also used by --brief); distinct "now" label so it never collides with the + # bottom "resume :" block. Guarded on an active task — no card on setup/no-task paths. + _now_active = _active_task(state) + if _now_active and _now_active in (state.get("tasks") or {}): + _now_ph = (state["tasks"][_now_active] or {}).get("phase", "?") + print(f"now : '{_now_active}' · phase={_now_ph} · {_next_footer(root, state)}") + print(f" TASK.md: .add/tasks/{_now_active}/TASK.md · re-orient: add.py status --brief") print(f"project : {state.get('project', '(unknown)')}") # project autonomy default (task init-auto-default): the posture new tasks INHERIT, # read LIVE from PROJECT.md so the human sees the project-wide throttle every session. diff --git a/add-method/tooling/engine_pin.py b/add-method/tooling/engine_pin.py index 28ce1a6b..92b5b61f 100644 --- a/add-method/tooling/engine_pin.py +++ b/add-method/tooling/engine_pin.py @@ -17,5 +17,5 @@ this file only ever holds the newest pointer. """ -ENGINE_MD5 = "3b438d30e0a6de007b9cfd8e4e0210fe" # re-aimed @ scope-first-draft (call-residuals: _scope_echo escalates the per-token "note:" lines to ONE paste-ready "Scope (may touch): …" line merging declared tokens + uncovered §3 Touches — turns a post-freeze re-cross repair into a freeze-time copy-paste). prior: ee4ef957… @ init-idempotent-nudge +ENGINE_MD5 = "c0c972e2ace3e8434906e2b480191da5" # re-aimed @ status-orientation-diet (call-residuals: cmd_status plain view leads with a "now" resume glance card — active slug · phase · next verb · TASK.md path — reusing _next_footer, so a single status read orients without the measured 3-4x/rep re-reads). prior: 3b438d30… @ scope-first-draft ENGINE_PKG_MD5 = "fc40ad47544db6f5204b6197b95daf04" # re-aimed @ phase-merge-verify (constants.py: PHASES drops observe; _SKIPPABLE_PHASES=(); PHASE_GUIDE/OWNER/GROUPS/AGENT follow). prior: 870a4ce0… @ phase-merge-specify diff --git a/add-method/tooling/test_status_orientation_diet.py b/add-method/tooling/test_status_orientation_diet.py new file mode 100644 index 00000000..544bfb6b --- /dev/null +++ b/add-method/tooling/test_status_orientation_diet.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Red/green tests for status-orientation-diet (call-residuals, frozen §3 v1): +the WM1 anatomy measured 3-4 status re-reads/rep. The resume info already exists in +the plain `status` view — but at line ~67 of a 69-line dump, so agents re-read to +find it. This leads the plain view with a compact "now :" glance card (active +slug · phase · next verb · resume file) placed BEFORE the "project :" line, reusing +the existing `_next_footer` composer. Additive: every existing line stays; --brief / +--json / --section and the no-active-task path are byte-unchanged. + +Run: python3 -m unittest test_status_orientation_diet -v +""" +import io +import os +import shutil +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +import add + + +class _Harness(unittest.TestCase): + def setUp(self): + self._cwd = Path.cwd() + self.tmp = Path(tempfile.mkdtemp(prefix="add-sod-")).resolve() + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + self.addCleanup(os.chdir, self._cwd) + os.chdir(self.tmp) + self._silent("init", "--name", "demo", "--stage", "mvp") + self._silent("new-task", "orient", "--fast", "--title", "x") + + def _silent(self, *argv): + out, err = io.StringIO(), io.StringIO() + try: + with redirect_stdout(out), redirect_stderr(err): + add.main(list(argv)) + except SystemExit: + pass + + def _run(self, *argv): + out = io.StringIO() + try: + with redirect_stdout(out), redirect_stderr(io.StringIO()): + add.main(list(argv)) + except SystemExit: + pass + return out.getvalue() + + +class GlanceCardTest(_Harness): + def test_plain_status_leads_with_now_card(self): + out = self._run("status") + self.assertIn("now :", out, "plain status must emit a 'now' glance card") + self.assertIn("orient", out, "the card must name the active task") + self.assertRegex(out, r"now :.*phase=", "the card must carry phase=") + self.assertIn(".add/tasks/orient/TASK.md", out, "the card must name the resume file") + + def test_card_appears_before_project_line(self): + out = self._run("status") + self.assertIn("now :", out) + self.assertIn("project :", out) + self.assertLess(out.index("now :"), out.index("project :"), + "the glance card must lead the view — before 'project :'") + + def test_card_carries_the_next_verb(self): + # reuses _next_footer, whose line starts with "next:" + out = self._run("status") + card = next((ln for ln in out.splitlines() if ln.startswith("now :")), "") + self.assertIn("next:", card, "the card line must carry the next-verb (from _next_footer)") + + def test_brief_view_unchanged_two_lines(self): + out = self._run("status", "--brief").strip() + self.assertNotIn("now :", out, "--brief must not grow the glance card") + self.assertEqual(len(out.splitlines()), 2, "--brief stays the 2-line resume essentials") + + def test_no_card_when_no_active_task(self): + # a fresh project with the task removed from active — the no-active path stays clean. + # simulate by asserting the card is guarded on an active task: after a project with + # no tasks, 'now :' must be absent. + fresh = Path(tempfile.mkdtemp(prefix="add-sod2-")).resolve() + self.addCleanup(shutil.rmtree, fresh, ignore_errors=True) + os.chdir(fresh) + self._silent("init", "--name", "empty", "--stage", "mvp") + out = self._run("status") + self.assertNotIn("now :", out, "no active task → no glance card") + + +if __name__ == "__main__": + unittest.main() From 19047155b7aac734e7516e7146e679eff8131fbf Mon Sep 17 00:00:00 2001 From: Tin Dang Date: Tue, 14 Jul 2026 21:05:59 +0700 Subject: [PATCH 6/6] =?UTF-8?q?feat(method):=20help-habit-kill=20=E2=80=94?= =?UTF-8?q?=20unknown=20command=20gets=20a=20concise=20close-match,=20not?= =?UTF-8?q?=20the=2050-choice=20dump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit call-residuals task 6 (final). A mistyped top-level command made argparse dump the full ~50-choice usage — unreadable at a glance, so the agent's reflex is `--help` or a re-read (the measured 1/rep call lever). - new _AddArgParser(argparse.ArgumentParser) overrides error() to intercept ONLY the top-level unknown-command case (prog == "add.py" + an "invalid choice" message): add.py: unknown command 'statuss' — did you mean 'status'? see where you are + all commands: add.py status difflib.get_close_matches (lazy-imported) picks the suggestion; when nothing is close the suggestion is omitted but the status pointer stays. Exit 2. No 50-choice dump, no "--help" mention. - every OTHER parse error delegates to super().error(): a subcommand's own invalid choice (`stage `), a missing positional, unrecognized arguments — all keep argparse's default, so test_graduate_guard / test_argv_portability stay green. - build_parser now instantiates _AddArgParser for the top-level parser only. Message-layer only — no gate / enforcement / state change (milestone OUT-of-scope). Red test first (test_help_habit_kill, 4 asserts incl. the no-near-match variant + the valid-command invariant). Engine synced x4 twins, ENGINE_MD5 -> 9267a41f (SEAMS unchanged — _declared_scope sits above the new class). Full fence green (3597 tests). One human freeze at the frozen §3 contract. author: Tin Dang --- .add/state.json | 48 +++++++++- .add/tasks/help-habit-kill/TASK.md | 95 +++++++++++++++++++ .../src/add_method/_bundled/tooling/add.py | 29 +++++- .../add_method/_bundled/tooling/engine_pin.py | 2 +- add-method/tooling/add.py | 29 +++++- add-method/tooling/engine_pin.py | 2 +- add-method/tooling/test_help_habit_kill.py | 75 +++++++++++++++ 7 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 .add/tasks/help-habit-kill/TASK.md create mode 100644 add-method/tooling/test_help_habit_kill.py diff --git a/.add/state.json b/.add/state.json index 901e195f..bcb4aaa0 100644 --- a/.add/state.json +++ b/.add/state.json @@ -1,7 +1,7 @@ { "project": "AIDD / ADD Methodology", "stage": "mvp", - "active_task": "status-orientation-diet", + "active_task": "help-habit-kill", "tasks": { "add-check": { "title": "add.py check: validate .add project integrity", @@ -9616,10 +9616,52 @@ "email": "tindang.ht97@gmail.com", "source": "git" } + }, + "help-habit-kill": { + "title": "unknown subcommand \u2192 concise close-match + resume pointer, no 50-choice dump", + "phase": "done", + "gate": "PASS", + "milestone": "call-residuals", + "depends_on": [], + "created": "2026-07-14T13:53:33+00:00", + "updated": "2026-07-14T14:05:37+00:00", + "fast": true, + "freeze": { + "version": "v1", + "frozen_at": "2026-07-14T13:55:27+00:00", + "approved_by": "Tin Dang", + "actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } + }, + "flag_verified": true, + "tripwire": { + "contract_md5": "c5aeefb7091dddd186124ad96edccc3a", + "tests": {} + }, + "scope": { + "declared": [ + "add-method/tooling/add.py", + "add-method/src/add_method/_bundled/tooling/add.py", + "add-method/.add/tooling/add.py", + "add-method/tooling/engine_pin.py", + "add-method/src/add_method/_bundled/tooling/engine_pin.py", + "add-method/.add/tooling/engine_pin.py", + "add-method/tooling/test_help_habit_kill.py" + ], + "snapshot_md5": "9ce320655dd39844c52f83cccb44b534" + }, + "gate_actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } } }, "created": "2026-05-28T15:39:04+00:00", - "updated": "2026-07-14T13:52:36+00:00", + "updated": "2026-07-14T14:05:37+00:00", "milestones": { "v1-1": { "title": "v1.1 \u2014 adoption & ergonomics", @@ -10905,7 +10947,7 @@ "ceremony-to-effort": "fold-draft-at-close", "call-floor": "skill-orient-split", "six-phase-loop": "installer-shared-namespace-guard", - "call-residuals": "status-orientation-diet" + "call-residuals": "help-habit-kill" }, "todos": [ { diff --git a/.add/tasks/help-habit-kill/TASK.md b/.add/tasks/help-habit-kill/TASK.md new file mode 100644 index 00000000..a6d36e8f --- /dev/null +++ b/.add/tasks/help-habit-kill/TASK.md @@ -0,0 +1,95 @@ +# TASK: unknown subcommand → concise close-match + resume pointer, no 50-choice dump + +slug: help-habit-kill · created: 2026-07-14 · stage: mvp +milestone: call-residuals +autonomy: auto +phase: done +fast: true + +> Fast lane — one small task, minimal sections, filled top-to-bottom. The trust floor still +> holds: a FROZEN §3 contract · ≥1 red test before build · a recorded §6 gate (security = HARD-STOP). +> The acceptance scenario collapses into §1 `Accept:`; the observe note is one optional line at the gate. + +--- + +## 1 · SPECIFY — the rules + +> Project the expectations from the milestone Ground + this request — light, not re-invented. +Feature: an unknown top-level subcommand self-corrects inline — instead of argparse dumping the full 50-choice usage (which the agent answers with a `--help` or a re-read, the measured 1/rep lever), a mistyped command prints a concise "unknown command 'X' — did you mean ''?" + a resume pointer to `add.py status`, so misuse is corrected in one glance without a --help round-trip. +Must: + - an unknown TOP-LEVEL command (`add.py `) prints to stderr `add.py: unknown command ''`, a close-match suggestion `— did you mean ''?` when difflib finds one, and a resume pointer `see where you are + all commands: add.py status`; exit 2 + - the full 50-choice usage dump is NOT printed for that case, and NO surface says "run --help" + - subcommand-level errors (missing slug, a subcommand's own invalid choice like `stage `) keep argparse's default behavior (top-level-only interception, `prog == "add.py"`); every valid command is unaffected +Reject: + - unknown top-level command -> "unknown command ''" (exit 2) (replaces the raw argparse choices dump for this one case) +Accept: Given a mistyped top-level command (e.g. `add.py statuss`), When it runs, Then stderr says "unknown command 'statuss' — did you mean 'status'?" and points to `add.py status`, without the 50-choice dump or any "--help" mention, exiting 2 +Boundary: two variants the test must speak — a bogus command WITH a near match (`statuss`→`status`) and one with NO near match (`zzqq`, suggestion omitted, still points to status) +Assumptions: ⚠ the 50-choice dump is what triggers the --help/re-read, not the exit code — evidence: the dump is ~4 lines of 50 comma-separated names, unreadable at a glance; if wrong (agents --help for other reasons): no harm, the concise error is strictly more actionable + +--- + +## 3 · PLAN — the change plan: ground · contract · build-strategy + +### Grounding +Touches (files · symbols): `add-method/tooling/add.py:build_parser` (make the top-level parser a subclass whose `error()` intercepts the unknown-command case) + a new `_AddArgParser(argparse.ArgumentParser)` class beside it; it READS `difflib.get_close_matches` (stdlib) — no engine symbol changed +Context (working folder): `add-method/tooling/` (canonical engine). The full write-set — canonical add.py, its 3 engine twins, engine_pin.py, the new test — is the §5 Scope below; ENGINE_MD5 + SEAMS re-aimed as part of any engine edit +Honors (patterns / conventions): fail-open + minimal-surface (intercept ONE case, delegate all else to `super().error()`); the top-level `prog == "add.py"` guard keeps subcommand-arg errors (test_graduate_guard's `stage ` invalid-choice, test_argv_portability's unrecognized-arguments) on argparse defaults +Anchors the contract cites: `build_parser` (edited) · `_AddArgParser.error` (new) · `difflib.get_close_matches` (read) · `add.py status` (the resume pointer) +Ground SHA: aaee317 — stamped by freeze + +### Contract + +``` +_AddArgParser(argparse.ArgumentParser).error(message), self.prog == "add.py" AND message matches "invalid choice: ''": + → stderr line 1: "add.py: unknown command ''" + (" — did you mean ''?" if difflib.get_close_matches(X, , n=1) else "") + → stderr line 2: "see where you are + all commands: add.py status" + → exit 2; does NOT print the argparse usage dump; contains no "--help" +_AddArgParser.error, any OTHER message (or a subparser whose prog != "add.py"): + → super().error(message) (argparse default — unchanged) +build_parser: the top-level ArgumentParser is _AddArgParser; every subparser + all valid dispatch unchanged +``` + +`Least-sure flag surfaced at freeze:` [contract] the exact stderr wording — cosmetic; the hard edge is the `prog == "add.py"` + "invalid choice" match being NARROW enough to leave subcommand-arg errors (missing slug, `stage `) on the argparse default, so test_graduate_guard / test_argv_portability stay green. +Status: FROZEN @ v1 — approved by Tin Dang +### Build-strategy +Scope (may touch): `add-method/tooling/add.py` `add-method/src/add_method/_bundled/tooling/add.py` `add-method/.add/tooling/add.py` `add-method/tooling/engine_pin.py` `add-method/src/add_method/_bundled/tooling/engine_pin.py` `add-method/.add/tooling/engine_pin.py` `add-method/tooling/test_help_habit_kill.py` +Strategy & known-problem fixes: (1) RED first: new test_help_habit_kill asserts `add.py statuss` → stderr "unknown command 'statuss'" + "did you mean 'status'?" + "add.py status", NO "invalid choice"/no 50-choice dump/no "--help", exit 2; `add.py zzqq` → concise, no suggestion, still points to status; a valid command still runs. (2) add `_AddArgParser` overriding `error()`, gate on `self.prog == "add.py"` + a regex on "invalid choice: '(...)'", pull subcommand names from the `_SubParsersAction.choices`, difflib the closest; delegate every other error to `super().error()` (trap: keep the guard NARROW so subcommand invalid-choice / unrecognized-arguments tests stay green). (3) sync ×4 twins, re-pin ENGINE_MD5 + SEAMS. +Approach (domain strategy): narrow argparse error interception + +### AI-verify record (required when gate_mode: ai-plan-verify) +- [ ] §3 PLAN grounding anchors resolve in the current tree +- [ ] §1 every Must + every Reject present, each Reject paired with an error code +- [ ] §3 Contract shape is concrete (no template placeholder text remains) +- [ ] Lowest-confidence flag surfaced and substantive (mirrors unflagged_freeze's own bar) +Verified by: · at: + +--- + +## 4 · TESTS — failing-first (red) + +Plan: test_ — assert the §1 Accept line's Then (behavior, not internals). +Tests live in: `./tests/` · MUST run red (missing implementation) before Build. + +--- + +## 5 · BUILD — AI writes the code (execution) + +> The change plan was frozen in §3 PLAN. Build to it: honor the §3 Build-strategy Scope; improve on the strategy if the code teaches you better. +Strategy actually used: as planned — no divergence. Added `_AddArgParser(argparse.ArgumentParser)` overriding `error()`; guards on `prog == "add.py"` + a `re.search` for "invalid choice: '(X)'", pulls choices from the `_SubParsersAction`, `difflib.get_close_matches` (lazy-imported) for the suggestion, else `super().error()`. `build_parser` now instantiates `_AddArgParser`. Synced ×4 twins, ENGINE_MD5→9267a41f. SEAMS unchanged (_declared_scope at 5688 is above the new class). +Code lives in: `./src/` · Constraints: change no test, no frozen contract; stay inside the §3 Build-strategy Scope; allow-list packages only. + +--- + +## 6 · VERIFY — evidence + gate + +- [x] all tests pass · coverage held · no test or contract altered during build (full fence) +- [x] green was EARNED — the 3 intercept asserts RED first (suggestion · no-dump/no-help · no-near-match); valid-command invariant green throughout; GREEN only after the subclass +- [x] input dialect held — the test speaks the real CLI stderr + exit-code dialect +- [x] no exposed secrets, injection openings, or unexpected dependencies (stdlib difflib only; security = HARD-STOP: none) + +Build expectations (from §1 Accept + §3 CONTRACT): `add.py statuss` prints to stderr `add.py: unknown command 'statuss' — did you mean 'status'?` then `see where you are + all commands: add.py status`, exit 2, with no "invalid choice"/50-choice dump/"--help"; `add.py zzqqxx` omits the suggestion but still points to status; `add.py stage ` and missing-slug errors keep argparse defaults — confirmed by test_help_habit_kill (4 asserts) + the live `add.py statuss` output + green test_graduate_guard/test_argv_portability in the full fence. + +### GATE RECORD +Outcome: PASS +Reviewed by: Tin Dang · date: 2026-07-14 + diff --git a/add-method/src/add_method/_bundled/tooling/add.py b/add-method/src/add_method/_bundled/tooling/add.py index 6d006922..1410a7c2 100644 --- a/add-method/src/add_method/_bundled/tooling/add.py +++ b/add-method/src/add_method/_bundled/tooling/add.py @@ -8412,8 +8412,35 @@ def cmd_report(args: argparse.Namespace) -> None: print(out) +class _AddArgParser(argparse.ArgumentParser): + """help-habit-kill: on an unknown TOP-LEVEL command, argparse dumps the full + ~50-choice usage — unreadable at a glance, so the agent's reflex is `--help` or a + re-read (the measured 1/rep call lever). Intercept ONLY that case (`prog == 'add.py'` + + an "invalid choice" message) with a concise "unknown command 'X' — did you mean + ''?" plus a pointer to `add.py status`. Every other parse error — a subcommand's + own invalid choice, a missing positional, unrecognized arguments — delegates to + argparse's default, so those surfaces stay byte-identical.""" + + def error(self, message: str): + m = re.search(r"invalid choice: '([^']*)'", message) + if m is not None and self.prog == "add.py": + import difflib + bad = m.group(1) + choices: list[str] = [] + for action in self._actions: + if isinstance(action, argparse._SubParsersAction): + choices = list(action.choices) + break + near = difflib.get_close_matches(bad, choices, n=1) + hint = f" — did you mean '{near[0]}'?" if near else "" + sys.stderr.write(f"add.py: unknown command '{bad}'{hint}\n") + sys.stderr.write("see where you are + all commands: add.py status\n") + raise SystemExit(2) + super().error(message) + + def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(prog="add.py", description="ADD scaffolder + state tracker") + p = _AddArgParser(prog="add.py", description="ADD scaffolder + state tracker") sub = p.add_subparsers(dest="cmd", required=True) pi = sub.add_parser("init", help="create a .add/ project here") diff --git a/add-method/src/add_method/_bundled/tooling/engine_pin.py b/add-method/src/add_method/_bundled/tooling/engine_pin.py index 92b5b61f..96468f51 100644 --- a/add-method/src/add_method/_bundled/tooling/engine_pin.py +++ b/add-method/src/add_method/_bundled/tooling/engine_pin.py @@ -17,5 +17,5 @@ this file only ever holds the newest pointer. """ -ENGINE_MD5 = "c0c972e2ace3e8434906e2b480191da5" # re-aimed @ status-orientation-diet (call-residuals: cmd_status plain view leads with a "now" resume glance card — active slug · phase · next verb · TASK.md path — reusing _next_footer, so a single status read orients without the measured 3-4x/rep re-reads). prior: 3b438d30… @ scope-first-draft +ENGINE_MD5 = "9267a41fdfd15185175146cc36c84a9d" # re-aimed @ help-habit-kill (call-residuals: _AddArgParser.error intercepts the top-level unknown-command case with a concise "unknown command 'X' — did you mean ''?" + an "add.py status" pointer instead of the ~50-choice usage dump — kills the 1/rep --help reflex). prior: c0c972e2… @ status-orientation-diet ENGINE_PKG_MD5 = "fc40ad47544db6f5204b6197b95daf04" # re-aimed @ phase-merge-verify (constants.py: PHASES drops observe; _SKIPPABLE_PHASES=(); PHASE_GUIDE/OWNER/GROUPS/AGENT follow). prior: 870a4ce0… @ phase-merge-specify diff --git a/add-method/tooling/add.py b/add-method/tooling/add.py index 6d006922..1410a7c2 100644 --- a/add-method/tooling/add.py +++ b/add-method/tooling/add.py @@ -8412,8 +8412,35 @@ def cmd_report(args: argparse.Namespace) -> None: print(out) +class _AddArgParser(argparse.ArgumentParser): + """help-habit-kill: on an unknown TOP-LEVEL command, argparse dumps the full + ~50-choice usage — unreadable at a glance, so the agent's reflex is `--help` or a + re-read (the measured 1/rep call lever). Intercept ONLY that case (`prog == 'add.py'` + + an "invalid choice" message) with a concise "unknown command 'X' — did you mean + ''?" plus a pointer to `add.py status`. Every other parse error — a subcommand's + own invalid choice, a missing positional, unrecognized arguments — delegates to + argparse's default, so those surfaces stay byte-identical.""" + + def error(self, message: str): + m = re.search(r"invalid choice: '([^']*)'", message) + if m is not None and self.prog == "add.py": + import difflib + bad = m.group(1) + choices: list[str] = [] + for action in self._actions: + if isinstance(action, argparse._SubParsersAction): + choices = list(action.choices) + break + near = difflib.get_close_matches(bad, choices, n=1) + hint = f" — did you mean '{near[0]}'?" if near else "" + sys.stderr.write(f"add.py: unknown command '{bad}'{hint}\n") + sys.stderr.write("see where you are + all commands: add.py status\n") + raise SystemExit(2) + super().error(message) + + def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(prog="add.py", description="ADD scaffolder + state tracker") + p = _AddArgParser(prog="add.py", description="ADD scaffolder + state tracker") sub = p.add_subparsers(dest="cmd", required=True) pi = sub.add_parser("init", help="create a .add/ project here") diff --git a/add-method/tooling/engine_pin.py b/add-method/tooling/engine_pin.py index 92b5b61f..96468f51 100644 --- a/add-method/tooling/engine_pin.py +++ b/add-method/tooling/engine_pin.py @@ -17,5 +17,5 @@ this file only ever holds the newest pointer. """ -ENGINE_MD5 = "c0c972e2ace3e8434906e2b480191da5" # re-aimed @ status-orientation-diet (call-residuals: cmd_status plain view leads with a "now" resume glance card — active slug · phase · next verb · TASK.md path — reusing _next_footer, so a single status read orients without the measured 3-4x/rep re-reads). prior: 3b438d30… @ scope-first-draft +ENGINE_MD5 = "9267a41fdfd15185175146cc36c84a9d" # re-aimed @ help-habit-kill (call-residuals: _AddArgParser.error intercepts the top-level unknown-command case with a concise "unknown command 'X' — did you mean ''?" + an "add.py status" pointer instead of the ~50-choice usage dump — kills the 1/rep --help reflex). prior: c0c972e2… @ status-orientation-diet ENGINE_PKG_MD5 = "fc40ad47544db6f5204b6197b95daf04" # re-aimed @ phase-merge-verify (constants.py: PHASES drops observe; _SKIPPABLE_PHASES=(); PHASE_GUIDE/OWNER/GROUPS/AGENT follow). prior: 870a4ce0… @ phase-merge-specify diff --git a/add-method/tooling/test_help_habit_kill.py b/add-method/tooling/test_help_habit_kill.py new file mode 100644 index 00000000..49bda806 --- /dev/null +++ b/add-method/tooling/test_help_habit_kill.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Red/green tests for help-habit-kill (call-residuals, frozen §3 v1): +a mistyped top-level command makes argparse dump the full 50-choice usage — unreadable +at a glance, so the agent's reflex is `--help` or a re-read (the measured 1/rep lever). +This intercepts ONLY that case (top-level `prog == "add.py"` invalid-choice) with a +concise "unknown command 'X' — did you mean ''?" + a pointer to `add.py status`. +Every other parse error (missing slug, a subcommand's own invalid choice) keeps +argparse's default behaviour. + +Run: python3 -m unittest test_help_habit_kill -v +""" +import io +import os +import shutil +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +import add + + +class _Harness(unittest.TestCase): + def setUp(self): + self._cwd = Path.cwd() + self.tmp = Path(tempfile.mkdtemp(prefix="add-hhk-")).resolve() + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + self.addCleanup(os.chdir, self._cwd) + os.chdir(self.tmp) + self._run("init", "--name", "demo", "--stage", "mvp") + + def _run(self, *argv): + out, err = io.StringIO(), io.StringIO() + code = None + try: + with redirect_stdout(out), redirect_stderr(err): + add.main(list(argv)) + except SystemExit as e: + code = e.code + return code, out.getvalue(), err.getvalue() + + +class UnknownCommandTest(_Harness): + def test_close_match_suggested_and_status_pointer(self): + code, out, err = self._run("statuss") + blob = out + err + self.assertEqual(code, 2, "an unknown command still exits 2") + self.assertIn("unknown command 'statuss'", blob) + self.assertIn("did you mean 'status'?", blob, "the closest command must be suggested") + self.assertIn("add.py status", blob, "a resume pointer to status must be present") + + def test_no_choices_dump_and_no_help_mention(self): + code, out, err = self._run("statuss") + blob = out + err + self.assertNotIn("invalid choice", blob, "the raw argparse invalid-choice text must be gone") + self.assertNotIn("--help", blob, "no surface may tell the agent to run --help") + # the 50-choice dump lists many commands comma-separated; a concise error names few. + self.assertNotIn("graduation-report", blob, "the full choices dump must not print") + + def test_no_near_match_still_points_to_status(self): + code, out, err = self._run("zzqqxx") + blob = out + err + self.assertEqual(code, 2) + self.assertIn("unknown command 'zzqqxx'", blob) + self.assertNotIn("did you mean", blob, "no suggestion when nothing is close") + self.assertIn("add.py status", blob, "still points to status") + + def test_valid_command_unaffected(self): + code, out, err = self._run("status", "--brief") + self.assertIn(code, (None, 0), "a valid command exits cleanly") + self.assertNotIn("unknown command", out + err, "a valid command is never flagged as unknown") + + +if __name__ == "__main__": + unittest.main()