From a87ed1e0fef00667fd672b339262574d80d2b4ca Mon Sep 17 00:00:00 2001 From: Tin Dang Date: Tue, 14 Jul 2026 22:01:29 +0700 Subject: [PATCH 1/5] =?UTF-8?q?feat(method):=20harness-workspace-isolation?= =?UTF-8?q?=20=E2=80=94=20bounded=20root-walk=20so=20a=20nested=20WM=20wor?= =?UTF-8?q?kspace=20resolves=20its=20own=20project?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orientation-honesty task 1 of 5. The call-residuals pre-measure anatomy (benchmark/results/2026-07-callres-preflight-anatomy.md) proved 7-13 startup cmds/rep were HARNESS-induced: the benchmark runs each WM workspace at benchmark/runs//wm/workspace — INSIDE AIDD-Book's own .add/ tree — so the agent's first pre-init `status` walks up and resolves the PARENT project, then burns ~10 cmds grepping find_root/STATE_FILE internals before `init`. A real greenfield user never hits this; the number was partly metering the nesting. - add_engine/io_state.py :: find_root gains an OPT-IN env ceiling. When `ADD_ROOT_CEILING` is set, the upward walk stops at that dir (INCLUSIVE — the break is AFTER the state.json check so the workspace's own post-init .add/ still resolves) and never ascends above it. Both cwd and ceiling are .resolve()d so a symlinked tmpdir (macOS /var->/private/var) still matches. Env UNSET is byte-identical to the legacy walk — the guard is invisible to every non-harness caller (mirrors the engine's ADD_NO_UPDATE_CHECK/NO_COLOR opt-in idiom). Synced x4 engine twins. - benchmark/runner/core.py :: _invoke_once passes env={**os.environ, "ADD_ROOT_CEILING": str(cwd)} to the agent Popen, so every benchmark run scopes the agent's root-walk to its workspace. cmd_init already writes .add/ at cwd, so only the pre-init READ leaked — one env injection fixes it. - ENGINE_PKG_MD5 -> 955023db (add_engine package digest re-pinned, x4 twins); ENGINE_MD5 UNCHANGED (add.py untouched); SEAMS untouched (no add.py line moved). Red first: test_findroot_ceiling (engine — ancestor-above-ceiling resolved the PARENT before the code) + benchmark/tests/test_workspace_isolation (env UNSET). Green: 28 engine (incl. test_engine_extract_md5 PKG parity + test_engine_extract_io_state + test_engine_repin_parity) + 189 benchmark + full tooling fence. One human freeze at the frozen §3 contract. author: Tin Dang --- .../orientation-honesty/MILESTONE.md | 63 +++++ .add/state.json | 94 +++++++- .../tasks/harness-workspace-isolation/TASK.md | 217 ++++++++++++++++++ .../_bundled/tooling/add_engine/io_state.py | 13 +- .../add_method/_bundled/tooling/engine_pin.py | 2 +- add-method/tooling/add_engine/io_state.py | 13 +- add-method/tooling/engine_pin.py | 2 +- add-method/tooling/test_findroot_ceiling.py | 74 ++++++ .../2026-07-callres-preflight-anatomy.md | 86 +++++++ benchmark/runner/core.py | 4 + benchmark/tests/test_workspace_isolation.py | 67 ++++++ 11 files changed, 626 insertions(+), 9 deletions(-) create mode 100644 .add/milestones/orientation-honesty/MILESTONE.md create mode 100644 .add/tasks/harness-workspace-isolation/TASK.md create mode 100644 add-method/tooling/test_findroot_ceiling.py create mode 100644 benchmark/results/2026-07-callres-preflight-anatomy.md create mode 100644 benchmark/tests/test_workspace_isolation.py diff --git a/.add/milestones/orientation-honesty/MILESTONE.md b/.add/milestones/orientation-honesty/MILESTONE.md new file mode 100644 index 00000000..bf766ce0 --- /dev/null +++ b/.add/milestones/orientation-honesty/MILESTONE.md @@ -0,0 +1,63 @@ +# MILESTONE: Honest WM1 <=12: isolate the harness + make surfaces self-explaining + +goal: Reach an HONEST WM1 <=12 calls by (a) removing the harness-induced startup confusion so the benchmark measures the method not the nesting, and (b) making engine surfaces self-explaining so the agent stops probing `--help` (5.0 calls/rep) and grepping engine internals — the real reducible levers the call-residuals pre-measure anatomy found, distinct from the four the six-phase report named +rationale: sub-milestone — human decision 2026-07-14 'keep investigating before the paid re-measure'. The pre-measure anatomy (benchmark/results/2026-07-callres-preflight-anatomy.md) of the actual sixphase-r{1,2,3} transcripts proved: TRUE double-init=0 and unknown-command-typo=0 (two of call-residuals' four tasks target failure modes that never fire), the dominant unaddressed lever is `--help` flag-discovery (5.0/rep), and ~7-13 cmds/rep of startup confusion are HARNESS-induced (workspace nested inside AIDD-Book's own `.add/`). ≤12 is reachable only with lever B, measured honestly only after harness isolation. +stage: mvp · status: active · created: 2026-07-14T14:33:51+00:00 +release: pending +relates-to: call-residuals, add-bench-2 + +> SDD living doc for this milestone. Keep it THIN: breadth, shared decisions, and +> exit criteria only — per-task detail lives in each `.add/tasks//TASK.md`, +> written just-in-time. Update this doc whenever a task reveals a milestone gap. + +## Scope +In: (1) benchmark harness: run each WM workspace isolated from any ancestor `.add/` so the agent's first `status`/`init` resolves the workspace, not the parent repo — an honest baseline. (2) engine message-layer: `status`/`init` warns when it resolved an ANCESTOR project; `status`/`advance` emit the paste-ready NEXT command WITH flags (kills the `--help` flag-discovery habit); `advance` carries the guide's key hint (kills `guide` re-reads); `scope_violation`/gate message explains the resolution rule + a paste-ready `re-cross` (kills `_in_scope` spelunking). +Out: no change to the FROZEN 5-metric benchmark set, the oracle/judge, or the scoring math; no phase-lifecycle change; the paid re-measure itself (human-gated, a separate spend); the init-idempotent-nudge / help-habit-kill tasks already merged (kept — defensively correct even though their levers measured 0). + +## Ground (shared real-code context — gathered ONCE; every task's specify projects from this) +Touches (shared files · symbols): engine `add-method/tooling/add.py` (×4 twins) — `cmd_status`, `_next_footer`, `cmd_advance`, the `scope_violation` return path, root-resolution (`find_root`/`_root` in `add_engine/io_state.py`); `add-method/tooling/engine_pin.py` (×3 twins, ENGINE_MD5); `.add/SEAMS.md` line pins. Benchmark `benchmark/runner/core.py` (`execute_wm`, `workspace_dir`), `benchmark/pilot.py` (setup), `benchmark/tests/` for the isolation guard. +Anchors: `_active_task`, `_next_footer`, `_declared_scope`, `_in_scope`, `scope_violation`, `execute_wm`, `workspace_dir`, `PINNED_MODEL`. +Honors (conventions): message-layer tasks change NO gate/enforcement path (except the harness task, which IS a benchmark-enforcement change and runs the full flow); engine edits sync ×4 twins byte-identically + re-pin ENGINE_MD5 + migrate SEAMS; NEVER sync test files across twins; red-before-green; frozen §3 contract; recorded §6 gate; security = HARD-STOP. +Issues/Risks (shared): the `--help`/next-command hint must stay a HINT, never a gate (propose-not-impose, per scope-first-draft precedent); the harness isolation must not break oracle-injection isolation (`check_isolation.py`) or the prior-WM workspace carry-forward (`runs_root/arm/wm{n-1}/workspace`); a status/advance wording change ripples into any test pinning that phrase (the full fence is authority, not grep). + +## Shared decisions & glossary deltas (living — every task must honor these) +- "self-explaining surface" — an engine output that hands the agent the exact next action (command + flags, or the resolution rule + paste-ready repair) so it never needs `--help` or to grep engine internals. The measure of success is a call/turn the agent DIDN'T have to make. +- honest baseline — a benchmark number that measures the method, not the measurement apparatus; the workspace-nesting artifact is removed at the source, not annotated away. + +## Shared / risky contracts (freeze these first) +- harness workspace-isolation contract -> owning task `harness-workspace-isolation` (the runner boundary that stops root-walk at the workspace; freeze first — the other tasks assume the honest baseline) + +## Tasks (breadth-first decomposition; detail lives in each TASK.md) +- [ ] harness-workspace-isolation depends-on: none — the WM runner isolates the workspace from any ancestor `.add/` (root-walk boundary / tmpdir-style setup) so the agent's first `status`/`init` resolves the workspace, not AIDD-Book's parent project; kills the 7-13 startup-confusion cmds/rep at the source. FULL flow (benchmark-enforcement change). +- [ ] status-ancestor-warn depends-on: none — `status`/`init` prints a loud one-line warning when it resolved an ANCESTOR project ("no `.add/` here; using project at — run `init` to scope here"); real-world value for nested/monorepo dirs + defense-in-depth behind the harness fix. Message-layer. +- [ ] next-command-hints depends-on: none — LEVER B: `status`/`advance` emit the fully-formed NEXT command WITH its flags (paste-ready), so the agent stops running ` --help` before first use (5.0 calls/rep, the biggest call lever). Stays a hint, never a gate. Message-layer. +- [ ] guide-fold depends-on: none — LEVER E: `advance` output carries the current phase guide's key hint so the agent stops re-running `add.py guide` for orientation (1-2 calls/rep). Message-layer. +- [ ] scope-violation-explain depends-on: none — LEVER C: the `scope_violation`/return-to-build message explains the resolution rule (declared vs resolved paths) AND emits a paste-ready `re-cross --by ""`, so the agent stops grepping `_in_scope`/`_declared_scope` internals to recover (5-11 spelunking cmds/rep). Message-layer, gate-preserving. + +## Exit criteria (observable; map each to the task that delivers it) +- [ ] In a fresh workspace nested under an ancestor `.add/`, the WM runner's agent resolves the workspace's OWN (absent-then-init'd) project — no root-walk to the parent — verified by a benchmark isolation test (← harness-workspace-isolation) +- [ ] `status`/`init` run in a dir with no `.add/` but an ancestor `.add/` above prints the ancestor-resolved warning naming the resolved path + the `init` remedy (← status-ancestor-warn) +- [ ] `status` and `advance` output contains the exact next command with its required flags (e.g. `add.py freeze --by "" --cross`), copy-pasteable — pinned by a test asserting the flagged form (← next-command-hints) +- [ ] `advance` output carries the destination phase's key guide hint, so orientation needs no separate `guide` call — pinned by a test (← guide-fold) +- [ ] a `scope_violation` return-to-build prints the declared-vs-resolved paths AND a paste-ready `re-cross` line — pinned by a test (← scope-violation-explain) +- [ ] (paid, human-gated) WM1 re-measure IN THE ISOLATED HARNESS: calls <= 12 mean, fidelity >= 0.97 held, `--help` flag-probes <= 1/rep, zero startup root-walk confusion in transcripts + +## Close — ship review (AI fills when every task is done) +> Whole-milestone, cross-task review the AI fills in. Evidence behind the EXISTING engine gate. + +### Ship by domain (what changed, per bounded context) +- tooling : +- skill : +- book : + +### Cross-task evidence (one row per task) +- : gate= · tests= · residue= + +### Goal met? +- [ ] each Exit criterion above is satisfied by a Cross-task evidence row or a Ship-by-domain change (cite which) +- goal: reach an honest WM1 <=12 — the isolated-harness re-measure line is the proof. + +## Release steps (AI-DEFINED — engine records, human gate) +- [ ] open a PR from the Close ship-review above; the human reviews + merges +- [ ] (paid, human-gated) run the isolated-harness WM1 re-measure; record calls/fidelity vs the <=12 bar +- [ ] on MET: fold the call-residuals + orientation-honesty pair into the next release notes diff --git a/.add/state.json b/.add/state.json index bcb4aaa0..b64ac2c0 100644 --- a/.add/state.json +++ b/.add/state.json @@ -1,7 +1,7 @@ { "project": "AIDD / ADD Methodology", "stage": "mvp", - "active_task": "help-habit-kill", + "active_task": "harness-workspace-isolation", "tasks": { "add-check": { "title": "add.py check: validate .add project integrity", @@ -9658,10 +9658,75 @@ "email": "tindang.ht97@gmail.com", "source": "git" } + }, + "harness-workspace-isolation": { + "title": "WM runner isolates the workspace from any ancestor .add/ (bounded root-walk)", + "phase": "done", + "gate": "PASS", + "milestone": "orientation-honesty", + "depends_on": [], + "created": "2026-07-14T14:40:08+00:00", + "updated": "2026-07-14T15:00:52+00:00", + "freeze": { + "version": "v1", + "frozen_at": "2026-07-14T14:48:08+00:00", + "approved_by": "Tin Dang", + "actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } + }, + "flag_verified": true, + "tripwire": { + "contract_md5": "47ee5d59373cc3c817d1901c0583aa9a", + "tests": {} + }, + "scope": { + "declared": [ + "add-method/tooling/add_engine/io_state.py", + ".add/tooling/add_engine/io_state.py", + "add-method/.add/tooling/add_engine/io_state.py", + "add-method/src/add_method/_bundled/tooling/add_engine/io_state.py", + "add-method/tooling/engine_pin.py", + ".add/tooling/engine_pin.py", + "add-method/.add/tooling/engine_pin.py", + "add-method/src/add_method/_bundled/tooling/engine_pin.py", + "benchmark/runner/core.py", + "add-method/tooling/test_findroot_ceiling.py", + "benchmark/tests/test_workspace_isolation.py" + ], + "snapshot_md5": "5bc356691856491cbe1ede433036c6c8" + }, + "heal": { + "attempts": 2, + "history": [ + { + "at": "2026-07-14T14:58:08+00:00", + "reason": "scope_violation: task 'harness-workspace-isolation' touched outside its declared \u00a75 Scope \u2014 add-method/src/pilotspace_add.egg-info/SOURCES.txt \u00b7 tmp/harness-workspace-isolation-commit.txt (2 total)", + "source": "scope" + }, + { + "at": "2026-07-14T14:58:50+00:00", + "reason": "scope_violation: task 'harness-workspace-isolation' touched outside its declared \u00a75 Scope \u2014 add-method/src/pilotspace_add.egg-info/SOURCES.txt (1 total)", + "source": "scope" + } + ] + }, + "recross": { + "by": "Tin Dang", + "at": "2026-07-14T15:00:46+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-14T14:05:37+00:00", + "updated": "2026-07-14T15:00:52+00:00", "milestones": { "v1-1": { "title": "v1.1 \u2014 adoption & ergonomics", @@ -10089,9 +10154,26 @@ "email": "tindang.ht97@gmail.com", "source": "git" } + }, + "orientation-honesty": { + "title": "Honest WM1 <=12: isolate the harness + make surfaces self-explaining", + "goal": "", + "stage": "mvp", + "status": "active", + "created": "2026-07-14T14:33:51+00:00", + "updated": "2026-07-14T14:38:01+00:00", + "confirmed": true, + "confirmed_at": "2026-07-14T14:38:01+00:00", + "confirmed_by": "tindang", + "await_confirm": true, + "actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } } }, - "active_milestone": "call-residuals", + "active_milestone": "orientation-honesty", "archived": [ { "slug": "v5", @@ -10931,7 +11013,8 @@ "ceremony-to-effort", "call-floor", "six-phase-loop", - "call-residuals" + "call-residuals", + "orientation-honesty" ], "active_tasks": { "flow-simplification": "phase-review", @@ -10947,7 +11030,8 @@ "ceremony-to-effort": "fold-draft-at-close", "call-floor": "skill-orient-split", "six-phase-loop": "installer-shared-namespace-guard", - "call-residuals": "help-habit-kill" + "call-residuals": "help-habit-kill", + "orientation-honesty": "harness-workspace-isolation" }, "todos": [ { diff --git a/.add/tasks/harness-workspace-isolation/TASK.md b/.add/tasks/harness-workspace-isolation/TASK.md new file mode 100644 index 00000000..c5634741 --- /dev/null +++ b/.add/tasks/harness-workspace-isolation/TASK.md @@ -0,0 +1,217 @@ +# TASK: WM runner isolates the workspace from any ancestor .add/ (bounded root-walk) + +slug: harness-workspace-isolation · created: 2026-07-14 · stage: mvp +milestone: orientation-honesty +autonomy: auto +phase: done + +> One file = one task — fill top-to-bottom; the phase marker above is the single source of truth (`add.py phase`); unclear phase → its book chapter. + +--- + +## 1 · SPECIFY — the rules ▸ docs/03-step-1-specify.md + +> Project the expectations from the milestone Ground + this request — light, not re-invented. +Feature: a benchmark WM workspace nested under an ancestor `.add/` resolves its OWN project (absent-then-init'd), not the parent — via a bounded root-walk the harness scopes to the workspace. Kills the 7-13 startup-confusion cmds/rep the pre-measure anatomy found were HARNESS-induced (workspace lives inside AIDD-Book's own `.add/` tree), so the re-measure meters the method, not the nesting. +Framings weighed: env ceiling on `find_root` consumed by the harness (chosen — minimal, general, zero-behavior-change when unset; `init` already writes at cwd so only the pre-init READ walk leaks) · relocate runs outside the repo tree (breaks archived-run paths + carry-forward) · pre-seed a `.add/` boundary in the workspace (would read as an existing project / change the arm's init task) +Must: + + - `find_root` honors an env ceiling `ADD_ROOT_CEILING=`: the upward walk visits cur..ceiling INCLUSIVE and never ascends above it — a `.add/state.json` in an ancestor ABOVE the ceiling is not resolved (returns None if that is the only project) + - env unset (every real end-user invocation): `find_root` walks cur..filesystem-root exactly as today — byte-for-byte behavior unchanged (the ceiling is opt-in, harness-only) + - the WM runner sets `ADD_ROOT_CEILING=` in the agent subprocess env (`_invoke_once` Popen), so the agent's pre-init `status` in a fresh nested workspace resolves NO project (prints "run init") instead of the parent AIDD-Book project + - `io_state.py` synced across all 4 engine twins byte-identical; `ENGINE_PKG_MD5` re-pinned (add_engine package digest); `ENGINE_MD5` UNCHANGED (add.py not touched); SEAMS re-pinned only if an add.py line pin drifted (it won't — the edit is in io_state.py) + +Reject: + + - a `.add/state.json` strictly above the set ceiling -> find_root returns `None` (the ancestor project is NOT resolved) — the negative behavior the whole task turns on + - ceiling set but not on cwd's ancestor chain -> the break never fires; the walk proceeds to filesystem root as if unset (fail-open, no error — the ceiling only ever CONSTRAINS, never redirects) + +After: + + - a fresh nested workspace resolves its own project after `init`; a re-run of the sixphase anatomy shows zero `find_root`/`STATE_FILE` engine-internals spelunking at startup + - all existing add.py/add_engine tests stay green (env unset in every existing test) — the change is invisible outside the harness + +Assumptions — lowest-confidence first: + + ⚠ the startup confusion is caused ENTIRELY by `find_root`'s unbounded upward walk, not by `init` mislocating the new `.add/` — lowest confidence because I'm inferring cause from the transcript's grep targets; mitigated: `cmd_init` uses `base = Path(args.dir).resolve()` (cwd), so init already writes at the workspace regardless — verified in source; if wrong: the ceiling alone wouldn't fix it and init would also need scoping (add.py change + ENGINE_MD5 re-pin) + - [ ] the harness `_invoke_once` Popen is the single agent-launch point (setup steps run uv/pip, never add.py) — so one env injection covers every agent add.py call; confirm by reading core.py's invoke path + - [ ] `ADD_ROOT_CEILING` is not already a meaningful env name elsewhere in the engine — confirm by grep before naming + + +--- + +## 2 · SCENARIOS — pass/fail cases ▸ docs/04-step-2-scenarios.md + + + +```gherkin +Scenario: ancestor project above the ceiling is not resolved # M1, R1 + Given a dir W with no .add/ of its own, nested under A where A/.add/state.json exists + And the env ADD_ROOT_CEILING is set to W + When find_root() is called from inside W + Then it returns None + And A/.add/state.json is neither read as the project nor modified + +Scenario: env unset preserves the legacy walk # M2 + Given the same W nested under A/.add/state.json + And ADD_ROOT_CEILING is unset + When find_root() is called from inside W + Then it returns A/.add # byte-for-byte the behavior shipped today + +Scenario: the workspace's own project resolves once init has run # M1 + Given ADD_ROOT_CEILING is set to W and W/.add/state.json now exists + When find_root() is called from W or a subdir of W + Then it returns W/.add # the ceiling constrains the top, never the workspace itself + +Scenario: the runner scopes the agent env to the workspace # M3 + Given execute_wm drives a fake agent that records $ADD_ROOT_CEILING to a file + When the WM run completes + Then the recorded value equals the run's workspace dir + And no existing runner behavior (record/timeout/carry-forward) changes +``` + + + +--- + +## 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): `add_engine/io_state.py:find_root(start=None)` — add an `ADD_ROOT_CEILING` env-bounded break to the existing `for d in (cur, *cur.parents)` walk (×4 twins); `engine_pin.py:ENGINE_PKG_MD5` — re-aim the package-digest literal (×4 twins; `ENGINE_MD5` untouched); `benchmark/runner/core.py:_invoke_once` — pass `env={**os.environ, "ADD_ROOT_CEILING": str(cwd)}` to the agent `subprocess.Popen` (os already imported) +Context (working folder): the engine `add_engine/` package (4 twins) + the benchmark runner; no docs/config touched +Honors (patterns / conventions): the existing `os.environ.get(...)` opt-in idiom (cf. `ADD_NO_UPDATE_CHECK` in add.py, `NO_COLOR` in render.py) — a default-unset env that changes nothing when absent; twin-sync + `ENGINE_PKG_MD5` re-pin per engine-package edit (SEAMS unaffected — no add.py line moves) +Seams consulted: `.add/SEAMS.md` — no `_declared_scope`/add.py line pin drifts (edit is confined to io_state.py); the package parity gate is `test_engine_extract_md5.py` (TREES = canonical · `.add/tooling` · `_bundled`) +Anchors the contract cites: `find_root` · `ROOT_DIRNAME` · `STATE_FILE` · `_invoke_once` · `os.environ` +Issues/Risks: the ceiling must be INCLUSIVE (break AFTER visiting the ceiling dir) or the workspace's own post-init `.add/` would be skipped; the `resolve()` on both cwd and ceiling must match (symlinked tmpdirs on macOS — `/var` vs `/private/var`) so compare resolved paths; env unset MUST be a no-op or every existing add_engine test breaks +Related intent: orientation-honesty milestone rationale + `benchmark/results/2026-07-callres-preflight-anatomy.md` (lever A — harness-induced startup confusion) +Ground SHA: e9fc44a — stamped by freeze + +### Contract (freeze the shape — the HARD, tamper-guarded core) + +``` +find_root(start: Path | None = None) -> Path | None + cur = (start or Path.cwd()).resolve() + ceil = Path(os.environ["ADD_ROOT_CEILING"]).resolve() if os.environ.get("ADD_ROOT_CEILING") else None + for d in (cur, *cur.parents): + if (d / ROOT_DIRNAME / STATE_FILE).exists(): return d / ROOT_DIRNAME # unchanged match + if ceil is not None and d == ceil: break # NEW: stop at the ceiling (inclusive) + return None + · env unset -> ceil is None -> the break never fires -> walk cur..fs-root (LEGACY, byte-identical) + · env = W -> ancestor above W never visited -> None when the only .add/ is above W; W/.add/ still resolves + +benchmark/runner/core.py :: _invoke_once + subprocess.Popen(argv, cwd=str(cwd), env={**os.environ, "ADD_ROOT_CEILING": str(cwd)}, …) # else unchanged +``` + +Glossary deltas: `root-walk ceiling: an opt-in env (ADD_ROOT_CEILING) that bounds find_root's upward search at a dir inclusive — the mechanism that isolates a nested workspace from an ancestor project` +`Least-sure flag surfaced at freeze:` [contract] the resolved-path equality of the ceiling break (`d == ceil`) — on macOS a tmpdir resolves `/var`→`/private/var`, so both `cur` and `ceil` MUST be `.resolve()`d or the break silently never fires; the hard edge is that env-unset stays byte-identical to the legacy walk (any regression there breaks every add_engine test). +Status: FROZEN @ v1 — approved by Tin Dang +Reported: no + +### Build-strategy (the intended approach — SOFT: preferred; the builder self-improves and records what it ACTUALLY did at verify) +Scope (may touch): `add-method/tooling/add_engine/io_state.py` `.add/tooling/add_engine/io_state.py` `add-method/.add/tooling/add_engine/io_state.py` `add-method/src/add_method/_bundled/tooling/add_engine/io_state.py` `add-method/tooling/engine_pin.py` `.add/tooling/engine_pin.py` `add-method/.add/tooling/engine_pin.py` `add-method/src/add_method/_bundled/tooling/engine_pin.py` `benchmark/runner/core.py` `add-method/tooling/test_findroot_ceiling.py` `benchmark/tests/test_workspace_isolation.py` +Strategy (ordered batches): the build ORDER the Contract doesn't carry — 1. RED both tests. 2. edit `find_root` in the CANONICAL twin. 3. sync byte-identical → 3 io_state twins. 4. `env=` on `_invoke_once`. 5. recompute `package_digest` → `ENGINE_PKG_MD5` × the twins. 6. green: targeted + `test_engine_extract_md5` (PKG parity) + `test_engine_extract_io_state` + benchmark runner suite → full fence. +Approach (domain strategy): env-ceiling — see §1 Framings weighed (chosen over run-relocation / boundary-seeding); no re-narration here. +Data strategy: none — no persisted shape (Contract signature unchanged). +Pattern: default-unset env opt-in (Grounding Honors) + engine-package twin-sync/`ENGINE_PKG_MD5` re-pin. +Optimization stance: correctness-first, no budget — trusted-least facet is the resolve()-equality edge, surfaced in the §3 Least-sure flag (not repeated here). +Persona: methodology-engine-dev — load-bearing root-resolution → minimal guard, honest pins. +Spawn isolation (default): none — inline build (single focused change). +Known-problem fixes: (unique to build) stray `_bundled/__pycache__/add.cpython-*.pyc` regenerates on import → `find _bundled -name __pycache__ -exec rm -rf` before the PKG parity assertion. (Ceiling inclusiveness + resolve()-equality live in §3 Issues/Risks + the Least-sure flag.) + +--- + +## 4 · TESTS — failing-first suite (red) ▸ docs/06-step-4-tests.md + +Coverage target: +Plan (one test per scenario, asserting behavior not internals): + + - test_: arrange / act / assert + assert · covers: + + +Tests live in: `./tests/` · MUST run red (missing implementation) before Build. + +--- + +## 5 · BUILD — AI writes the code (execution) ▸ docs/07-step-5-build.md + +> The change plan — grounding + contract + build-strategy — was frozen in §3 PLAN. Build to it: honor the §3 Build-strategy Scope, follow the strategy (improve on it if the code teaches you better), and touch no test or the frozen contract. +Strategy actually used: as planned — added an `ADD_ROOT_CEILING` env read to `find_root` (resolve() both sides, break AFTER the state.json check so the ceiling is inclusive), synced byte-identical to the 3 other io_state twins (md5 6f06f2e7…), added `env={**os.environ, "ADD_ROOT_CEILING": str(cwd)}` to `_invoke_once`'s Popen, recomputed `package_digest`→`ENGINE_PKG_MD5` 955023db… across all 4 engine_pin twins (ENGINE_MD5 untouched). No divergence. RED→green: engine test_ancestor_above_ceiling_not_resolved + harness env-unset both flipped; env-unset legacy + off-chain fall-open + workspace-own asserts green throughout. +Safety rule (feature-specific): +Code lives in: `./src/` +Constraints: do NOT change any test or the frozen §3 contract; stay inside the §3 Build-strategy Scope; allow-list packages only; ask if unclear. + +--- + +## 6 · VERIFY — evidence + non-functional review ▸ docs/08-step-6-verify.md + +- [x] all tests pass — full tooling fence 3601 passed (FENCE_EXIT=0); benchmark suite 189 green; engine targeted 28 green +- [x] coverage did not decrease — 2 new tests added, none removed/weakened +- [x] no test or contract was altered during build — find_root/_invoke_once/engine_pin only; tests untouched since red +- [x] the green was EARNED — the ancestor-above-ceiling assert resolved the PARENT before the code (RED), returns None after; env-unset asserts the byte-identical legacy path so a broken guard would fail, not pass +- [x] concurrency / timing — none; a pure per-call env read, no shared state +- [x] no exposed secrets, injection openings, or unexpected dependencies — stdlib os only; env value is a path str, never eval'd +- [x] layering & dependencies follow CONVENTIONS.md — os.environ opt-in mirrors ADD_NO_UPDATE_CHECK/NO_COLOR +- [x] a person reviewed and approved the change — Tin Dang approved the frozen §3 contract + +### Build expectations — what "correct" looks like (fill BEFORE build; confirm each at the gate) +> OBSERVABLE outcomes a correct build must produce, derived from the §2 scenarios + §3 contract — evidence you can SEE, not test names. +- [ ] with `ADD_ROOT_CEILING=W` set, `io_state.find_root(W)` returns `None` when the only `.add/state.json` is in an ancestor above W — confirmed by test_findroot_ceiling.test_ancestor_above_ceiling_not_resolved flipping RED→green +- [ ] with the env UNSET, `find_root(W)` still returns the ancestor `A/.add` byte-identically — confirmed by test_env_unset_preserves_legacy_walk staying green + the full add_engine suite unbroken +- [ ] after `init`, `find_root(W)` and `find_root(W/sub)` return `W/.add` even with the ceiling set — confirmed by test_workspace_own_project_resolves_under_ceiling +- [ ] a WM run exports `ADD_ROOT_CEILING=` into the agent process — confirmed by the fake agent's recorded `ceiling_seen.txt` == the workspace dir (test_workspace_isolation) +- [ ] `ENGINE_PKG_MD5` equals `package_digest` across all 3 gated trees; `ENGINE_MD5` unchanged — confirmed by test_engine_extract_md5 + test_engine_repin_parity green + +### Deep checks — do not skim (fill the path that applies; the resolver judges which) +- [x] DIALECT — tests use real nested tmpdirs + real Path/env, the same dialect the runtime sees (not synthetic path strings) — resolve()-equality exercised for real +- [x] WIRING (code) — `ADD_ROOT_CEILING` is read in `find_root` (×4 twins) and written in `_invoke_once`; the harness test proves the write reaches the agent, the engine test proves the read bounds the walk +- [x] DEAD-CODE (code) — no orphaned symbol; the env read + break are on the live walk path +- [ ] SEMANTIC — n/a (code task) + +### Live-verify evidence — confirm the §3 PLAN grounding anchors still resolve (fill at the gate) +> Re-resolve every symbol the §3 Contract cites against the CURRENT tree (code moved since Ground SHA) — catch a stale anchor here, not later. +- [x] every symbol the §3 Contract cites still resolves — `find_root`, `ROOT_DIRNAME`, `STATE_FILE` in io_state.py; `_invoke_once`, `os.environ` in core.py — all present, edited in place +- [x] no anchor moved/renamed since Ground SHA e9fc44a + +### Refute-read verdict — the earned-green check (record it; required for an auto-PASS) +> Under auto, record the earned-green refute-read (the engine never spawns it — you do; NOT-EARNED -> `add.py heal`). Audit-measured (`refute_unrecorded`), never blocked; a human spot-audit is the backstop. +Verdict: EARNED +By: self · adversarially checked: probed the guard's failure modes — (1) an off-chain ceiling must not redirect (test_ceiling_off_the_chain_falls_open green), (2) the ceiling must be inclusive so the workspace's own post-init root still resolves (test_workspace_own_project_resolves_under_ceiling green incl. subdir), (3) env-unset must be byte-identical (test_env_unset + 189 benchmark + engine parity green). A vacuous pass is impossible: the RED test resolved the PARENT project before the code existed. + +### Advisor 3-lens verdict — sequential (security → concurrency → architecture) +> Lenses run in order; a Security HARD-STOP ends the checklist (leave the rest blank). Binding for sensitivity: mechanical (advisor-gate-relax); advisory otherwise. Audit-measured (`advisor_verdict_unrecorded`), never blocked. +Advisor: self +1. Security: CLEAR — env value is a filesystem path used only for a Path() compare, never eval'd/shell'd; no new dep; a malicious ADD_ROOT_CEILING can only NARROW resolution (fail-closed to None), never escalate +2. Concurrency: CLEAR — stateless per-call env read; no shared mutation +3. Architecture: CLEAR — opt-in env mirrors the engine's existing os.environ idiom; the harness consumes an engine capability, no new coupling +Verdict: PASS +Residue: none +Binding: advisory — architecture (a benchmark-enforcement + engine-package change; not mechanical) + +### GATE RECORD +Reported: yes — build evidence rendered (fence 3601 green, refute EARNED, advisor PASS) before this outcome +Outcome: PASS +Reviewed by: Tin Dang · date: 2026-07-14 + +--- + +## 7 · OBSERVE — feed the next loop ▸ docs/09-the-loop.md + +Watch (reuse scenarios as monitors): + +### Decisions (ADR) +- [AI] specify — chose env ceiling on `find_root` consumed by the harness; rejected relocate runs outside the repo tree (breaks archived-run paths + carry-forward) · pre-seed a `.add/` boundary in the workspace (would read as an existing project / change the arm's init task) +- [human] freeze — froze §3 @ v1 (approved by Tin Dang) +- [AI] build — approach: env-ceiling — see §1 Framings weighed (chosen over run-relocation / boundary-seeding); no re-narration here. +- [AI] build — data strategy: none — no persisted shape (Contract signature unchanged). +- [AI] build — pattern: default-unset env opt-in (Grounding Honors) + engine-package twin-sync/`ENGINE_PKG_MD5` re-pin. +- [AI] build — optimization stance: correctness-first, no budget — trusted-least facet is the resolve()-equality edge, surfaced in the §3 Least-sure flag (not repeated here). +- [AI] build — strategy used: as planned — added an `ADD_ROOT_CEILING` env read to `find_root` (resolve() both sides, break AFTER the state.json check so the ceiling is inclusive), synced byte-identical to the 3 other io_state twins (md5 6f06f2e7…), added `env={**os.environ, "ADD_ROOT_CEILING": str(cwd)}` to `_invoke_once`'s Popen, recomputed `package_digest`→`ENGINE_PKG_MD5` 955023db… across all 4 engine_pin twins (ENGINE_MD5 untouched). No divergence. RED→green: engine test_ancestor_above_ceiling_not_resolved + harness env-unset both flipped; env-unset legacy + off-chain fall-open + workspace-own asserts green throughout. +- [AI] verify — gate PASS (reviewed by Tin Dang) + +### Spec delta +One line per forward change, tagged `[SPEC · open|seeded|dropped]` + evidence — each re-enters at Specify (`deltas.md`). + +### Competency deltas +One lesson per line: `[DDD|SDD|UDD|TDD|ADD · open] the learning (evidence: …)` — see `deltas.md`. + diff --git a/add-method/src/add_method/_bundled/tooling/add_engine/io_state.py b/add-method/src/add_method/_bundled/tooling/add_engine/io_state.py index 35610bc0..8ffce3e9 100644 --- a/add-method/src/add_method/_bundled/tooling/add_engine/io_state.py +++ b/add-method/src/add_method/_bundled/tooling/add_engine/io_state.py @@ -117,11 +117,22 @@ def _atomic_write_many(writes: list[tuple[Path, str]]) -> None: # --- root finding + state load/save + the shared error primitive ------------ def find_root(start: Path | None = None) -> Path | None: - """Walk up from cwd to find a .add/ project root.""" + """Walk up from cwd to find a .add/ project root. + + Opt-in boundary: when `ADD_ROOT_CEILING` is set, the walk stops at that dir + (inclusive) and never ascends above it — so a workspace nested under an + ancestor project resolves ONLY its own root (or None before init), never the + parent's. Unset (every normal invocation) is the legacy walk to fs-root, + byte-identical. Both paths are resolved so a symlinked ceiling still matches. + """ cur = (start or Path.cwd()).resolve() + _ceil = os.environ.get("ADD_ROOT_CEILING") + ceil = Path(_ceil).resolve() if _ceil else None for d in (cur, *cur.parents): if (d / ROOT_DIRNAME / STATE_FILE).exists(): return d / ROOT_DIRNAME + if ceil is not None and d == ceil: + break return None def _require_root() -> Path: 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 96468f51..7c8388de 100644 --- a/add-method/src/add_method/_bundled/tooling/engine_pin.py +++ b/add-method/src/add_method/_bundled/tooling/engine_pin.py @@ -18,4 +18,4 @@ """ 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 +ENGINE_PKG_MD5 = "955023db4358bd3f80a22078bc365361" # re-aimed @ harness-workspace-isolation (io_state.find_root: opt-in ADD_ROOT_CEILING env bounds the upward walk so a nested workspace resolves its own project, not an ancestor). prior: fc40ad47… @ phase-merge-verify diff --git a/add-method/tooling/add_engine/io_state.py b/add-method/tooling/add_engine/io_state.py index 35610bc0..8ffce3e9 100644 --- a/add-method/tooling/add_engine/io_state.py +++ b/add-method/tooling/add_engine/io_state.py @@ -117,11 +117,22 @@ def _atomic_write_many(writes: list[tuple[Path, str]]) -> None: # --- root finding + state load/save + the shared error primitive ------------ def find_root(start: Path | None = None) -> Path | None: - """Walk up from cwd to find a .add/ project root.""" + """Walk up from cwd to find a .add/ project root. + + Opt-in boundary: when `ADD_ROOT_CEILING` is set, the walk stops at that dir + (inclusive) and never ascends above it — so a workspace nested under an + ancestor project resolves ONLY its own root (or None before init), never the + parent's. Unset (every normal invocation) is the legacy walk to fs-root, + byte-identical. Both paths are resolved so a symlinked ceiling still matches. + """ cur = (start or Path.cwd()).resolve() + _ceil = os.environ.get("ADD_ROOT_CEILING") + ceil = Path(_ceil).resolve() if _ceil else None for d in (cur, *cur.parents): if (d / ROOT_DIRNAME / STATE_FILE).exists(): return d / ROOT_DIRNAME + if ceil is not None and d == ceil: + break return None def _require_root() -> Path: diff --git a/add-method/tooling/engine_pin.py b/add-method/tooling/engine_pin.py index 96468f51..7c8388de 100644 --- a/add-method/tooling/engine_pin.py +++ b/add-method/tooling/engine_pin.py @@ -18,4 +18,4 @@ """ 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 +ENGINE_PKG_MD5 = "955023db4358bd3f80a22078bc365361" # re-aimed @ harness-workspace-isolation (io_state.find_root: opt-in ADD_ROOT_CEILING env bounds the upward walk so a nested workspace resolves its own project, not an ancestor). prior: fc40ad47… @ phase-merge-verify diff --git a/add-method/tooling/test_findroot_ceiling.py b/add-method/tooling/test_findroot_ceiling.py new file mode 100644 index 00000000..25433dc6 --- /dev/null +++ b/add-method/tooling/test_findroot_ceiling.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Red/green tests for harness-workspace-isolation (orientation-honesty, frozen §3 v1). + +`find_root` gains an OPT-IN env ceiling `ADD_ROOT_CEILING`: the upward walk stops at +that dir (inclusive), so a workspace nested under an ancestor `.add/` resolves its OWN +project — never the parent. Env unset MUST be byte-identical to the legacy walk. + +Env is scoped with patch.dict so it never leaks into sibling add_engine tests. + +Run: python3 -m unittest test_findroot_ceiling -v +""" +import json +import os +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from add_engine import io_state +from add_engine.constants import ROOT_DIRNAME, STATE_FILE + + +class _Nested(unittest.TestCase): + def setUp(self): + # A/ is a real project (A/.add/state.json); W = A/work is a fresh nested + # workspace with no .add/ of its own; sub is a subdir of W. + self.A = Path(tempfile.mkdtemp(prefix="add-fr-")).resolve() + self.addCleanup(shutil.rmtree, self.A, ignore_errors=True) + (self.A / ROOT_DIRNAME).mkdir() + (self.A / ROOT_DIRNAME / STATE_FILE).write_text(json.dumps({"schema": 1})) + self.W = self.A / "work" + self.sub = self.W / "sub" + self.sub.mkdir(parents=True) + + def _seed_workspace_project(self): + (self.W / ROOT_DIRNAME).mkdir() + (self.W / ROOT_DIRNAME / STATE_FILE).write_text(json.dumps({"schema": 1})) + + +class CeilingTest(_Nested): + def test_ancestor_above_ceiling_not_resolved(self): + # env=W, W has no .add/ yet -> the ancestor A/.add above the ceiling is NOT resolved. + with mock.patch.dict(os.environ, {"ADD_ROOT_CEILING": str(self.W)}): + self.assertIsNone(io_state.find_root(self.W), + "a project ABOVE the ceiling must not be resolved") + # and A/.add/state.json is untouched + self.assertTrue((self.A / ROOT_DIRNAME / STATE_FILE).exists()) + + def test_env_unset_preserves_legacy_walk(self): + env = {k: v for k, v in os.environ.items() if k != "ADD_ROOT_CEILING"} + with mock.patch.dict(os.environ, env, clear=True): + self.assertEqual(io_state.find_root(self.W), self.A / ROOT_DIRNAME, + "env unset must walk to the ancestor exactly as today") + + def test_workspace_own_project_resolves_under_ceiling(self): + self._seed_workspace_project() + with mock.patch.dict(os.environ, {"ADD_ROOT_CEILING": str(self.W)}): + self.assertEqual(io_state.find_root(self.W), self.W / ROOT_DIRNAME, + "the ceiling bounds the TOP, never the workspace's own root") + self.assertEqual(io_state.find_root(self.sub), self.W / ROOT_DIRNAME, + "a subdir under the ceiling still resolves the workspace root") + + def test_ceiling_off_the_chain_falls_open(self): + # ceiling that isn't an ancestor of cwd -> break never fires -> legacy walk. + off = Path(tempfile.mkdtemp(prefix="add-off-")).resolve() + self.addCleanup(shutil.rmtree, off, ignore_errors=True) + with mock.patch.dict(os.environ, {"ADD_ROOT_CEILING": str(off)}): + self.assertEqual(io_state.find_root(self.W), self.A / ROOT_DIRNAME, + "an off-chain ceiling must not redirect; walk proceeds") + + +if __name__ == "__main__": + unittest.main() diff --git a/benchmark/results/2026-07-callres-preflight-anatomy.md b/benchmark/results/2026-07-callres-preflight-anatomy.md new file mode 100644 index 00000000..3d49fc70 --- /dev/null +++ b/benchmark/results/2026-07-callres-preflight-anatomy.md @@ -0,0 +1,86 @@ +# call-residuals — pre-measure anatomy of the real WM1 runs — 2026-07-14 + +Purpose: before spending on the paid WM1 re-measure, anatomize the **actual** +`sixphase-r{1,2,3}/wm1` transcripts (the only call-instrumented real ADD runs) +to find *all* reducible call/turn/cost waste — not just the four levers the +six-phase report named. Method: parse `transcript.jsonl`, split every `add.py` +invocation into `--help` flag-discovery probes vs real calls, and reconstruct +the ordered command timeline with the agent's stated intent to root-cause each +re-read. + +## Headline: two of the four milestone tasks target levers that never fired + +| six-phase claim | reality in the 3 transcripts | task built | effect on calls | +|---|---|---|---| +| "double init ×2/rep" | **true double-init = 0** (real `init` ran exactly once each rep; the "×2" was `init --help` + real `init`, miscounted) | init-idempotent-nudge | **~0** (target never fires) | +| "the --help habit (typo)" | **invalid-choice / unknown-command = 0** (agent never mistyped a command) | help-habit-kill | **~0** (target never fires) | + +Both fixes are defensively correct and harmless, but they cannot move the call +count because their failure modes do not occur in the runs. + +## The real call split (per rep) + +| | r1 | r2 | r3 | mean | +|---|---|---|---|---| +| `--help` flag-discovery probes | 6 | 4 | 5 | **5.0** | +| real calls | 13 | 18 | 16 | 15.7 | +| **total (the "20.7" headline)** | 19 | 22 | 21 | **20.7** | + +The probes are deliberate flag discovery *before first use* of a correctly-typed +command: `add.py --help`, `init --help`, `new-task --help`, `freeze --help`, +`advance --help`, `lock --help`, `re-cross --help`. **No current task touches +this** — help-habit-kill only intercepts the typo/unknown-command case. + +## Ranked reducible waste (by real-run impact) + +| # | Lever | per rep | current task? | fix layer | +|---|---|---|---|---| +| **A** | **Startup root-walk confusion** — fresh workspace nested under an ancestor `.add/`; first `status` resolves the *parent* project; agent greps `find_root`/`STATE_FILE`/`_root` to understand why before `init` | **7–13 cmds** | ❌ none | **HARNESS** (isolate workspace from ancestor `.add/`) + optional status warn | +| **B** | **`--help` flag-discovery** before first use | **5.0 calls** | ❌ none | `status`/`advance` emit the *paste-ready next command with flags* | +| **C** | **Engine-mechanics spelunking** — grep/read of `_declared_scope`/`scope_violation`/`_in_scope`/`_SKIPPABLE_PHASES` to decode scope + skip behavior | 5–11 cmds | partial (scope-first-draft) | self-explaining `scope_violation` + skip messages | +| **D** | **status re-reads** — initial (caused by A) + final-ceremony reducible; middle two legit | ~2 reducible | status-orientation-diet ✓ | A fixes initial; drop final-ceremony | +| **E** | **guide re-reads** | 1–2 | ❌ | fold the guide hint into `advance` output | +| **F** | **advance multi-call** — `advance --fill` + `advance --help` + `advance` = 3 calls for 1 crossing (r2) | ~1 | subset of B | B covers it | + +## The measurement is partly contaminating the metric (First-Principles) + +Lever **A is 100% harness-induced**: the runner places the workspace at +`benchmark/runs//wm/workspace` — *inside* AIDD-Book, which has a `.add/` +at its root. `add.py`'s root-walk therefore finds the ancestor project. A real +greenfield user runs in a standalone dir and never triggers it. So the measured +**20.7 calls / 131 turns / $3.17 are inflated by the harness nesting** (mostly +turns/cost; ~1–2 add.py calls). Paying to re-measure without fixing this would +partly measure the apparatus, not the method. + +- **Harness fix (highest leverage, zero method change):** run the workspace + outside any ancestor `.add/` tree (tmpdir, or a root boundary so the walk stops + at the workspace). Removes A entirely, makes every future re-measure honest, + and cuts turns/cost/context-rot. +- **Method fix (complementary):** `status` warns when it resolved an *ancestor* + project ("no `.add/` here; using project at — run `init` to scope + here"). Helps real nested cases (monorepos, subdir projects) but is secondary + to the harness fix for benchmark honesty. + +## What actually moves ≤12 calls + +Irreducible 1-task lifecycle floor ≈ `init` + `new-task` + ~5 `advance` + +`freeze` + `gate` ≈ **~10 real calls**. On top of that: + +- **B (kill `--help` flag discovery): −4 calls** → the single biggest call lever, currently unaddressed. +- **D (status-diet, final-ceremony): −1** +- **E (guide-fold): −1** +- **C/scope-first-draft (re-cross): −1** + +Projected: 20.7 → ~12.7 with **B + D + E + scope all working**. ≤12 is reachable +but tight, and **B is required** — the merged milestone (B absent, A/help-typo +phantom) would land ~15–16. + +## Recommendation + +1. **Fix the harness first** (isolate the workspace) → honest baseline, cheaper reruns. +2. **Add lever B** (paste-ready next-command-with-flags) — the one call lever that makes ≤12 achievable. +3. Optionally fold E (guide) and self-explain C (scope_violation) — cheap, message-layer. +4. *Then* run the paid 3-rep re-measure against a milestone that can actually hit the bar, in a harness that measures the method rather than the nesting. + +Evidence: `benchmark/runs/sixphase-r{1,2,3}/wm1/transcript.jsonl` (335/… lines +each); anatomy reproducible from the parse in this session. diff --git a/benchmark/runner/core.py b/benchmark/runner/core.py index 55ab0e6f..451c8c8f 100644 --- a/benchmark/runner/core.py +++ b/benchmark/runner/core.py @@ -75,6 +75,10 @@ def _invoke_once( proc = subprocess.Popen( argv, cwd=str(cwd), # the agent starts inside its sandbox, never the pilot's cwd + # Scope the engine's root-walk to the workspace (harness-workspace-isolation): + # a run dir nested under the repo's own .add/ would otherwise resolve the + # PARENT project on the agent's first pre-init `status`, inflating startup. + env={**os.environ, "ADD_ROOT_CEILING": str(cwd)}, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, diff --git a/benchmark/tests/test_workspace_isolation.py b/benchmark/tests/test_workspace_isolation.py new file mode 100644 index 00000000..d9a292f1 --- /dev/null +++ b/benchmark/tests/test_workspace_isolation.py @@ -0,0 +1,67 @@ +"""Red/green test for harness-workspace-isolation (orientation-honesty, frozen §3 v1). + +The WM runner must scope the agent's root-walk to its workspace by exporting +`ADD_ROOT_CEILING=` into the agent subprocess env — so a workspace +nested under an ancestor `.add/` resolves its own project, not the parent. A fake +agent records the env value it saw; the runner behaviour is otherwise unchanged. + +Run: python3 -m pytest benchmark/tests/test_workspace_isolation.py -q +""" +import pathlib +import sys +import textwrap + +from benchmark.arms.loader import Arm +from benchmark.runner.core import execute_wm + + +def _arm(name: str = "fake-arm") -> Arm: + return Arm( + name=name, + setup_steps=[], + prompt_wrapper="raw", + pin="", + same_model=True, + token_ceiling=200000, + turn_ceiling=60, + ) + + +def _write_script(tmp_path: pathlib.Path, name: str, body: str) -> pathlib.Path: + script = tmp_path / name + script.write_text(textwrap.dedent(body)) + script.chmod(0o755) + return script + + +def test_runner_scopes_root_ceiling_to_workspace(tmp_path): + # the fake agent runs with cwd == workspace and records what ADD_ROOT_CEILING it saw + script = _write_script( + tmp_path, + "fake_env.py", + """ + #!/usr/bin/env python3 + import json, os, pathlib, sys + pathlib.Path("ceiling_seen.txt").write_text( + os.environ.get("ADD_ROOT_CEILING", "")) + print(json.dumps({"type": "tool_use", "name": "Write"})) + print(json.dumps({ + "type": "result", "total_cost_usd": 0.0, + "usage": {"input_tokens": 1, "output_tokens": 1, + "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}, + })) + sys.exit(0) + """, + ) + runs_root = tmp_path / "runs" + record = execute_wm( + _arm(), 1, + agent_cmd=[sys.executable, str(script)], + timeout_s=10, retries=1, runs_root=runs_root, + ) + assert record.status == "done" + workspace = runs_root / "fake-arm" / "wm1" / "workspace" + seen = (workspace / "ceiling_seen.txt").read_text().strip() + assert seen != "", "runner must export ADD_ROOT_CEILING into the agent env" + assert pathlib.Path(seen).resolve() == workspace.resolve(), ( + f"ceiling must equal the workspace dir; saw {seen!r}") From 1d9a21108d4290faf306f6c2909a70da928ee1b9 Mon Sep 17 00:00:00 2001 From: Tin Dang Date: Tue, 14 Jul 2026 22:20:43 +0700 Subject: [PATCH 2/5] =?UTF-8?q?feat(method):=20status-ancestor-warn=20?= =?UTF-8?q?=E2=80=94=20status=20names=20the=20ANCESTOR=20project=20it=20re?= =?UTF-8?q?solved=20+=20hands=20the=20exact=20init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orientation-honesty task 2 of 5. Behind the harness ceiling (task 1), a real user in a nested/monorepo dir with no local .add/ still gets `status` silently resolving an ANCESTOR project — the same confusion the pre-measure anatomy saw the benchmark agent grep find_root internals over. Now `status` says so and hands the fix. - new _ancestor_note(): when cwd has no .add/state.json of its own but find_root() walked up to one, returns a one-line note naming the resolved ancestor + the exact `add.py init --name "" --stage <...>` to scope a project HERE (the _require_root skip-error precedent — hand the command with flags, never a bare hint). Silent when cwd owns a project or none is reachable. - cmd_status prints it to STDERR on the FULL path only — --json/--brief/--section stay silent so machine callers and pipes are never polluted; exit code + the normal status body are unchanged. Message-layer only — no gate/enforcement/state change. Red first (test_status_ancestor_warn: note-present + two silent cases). Synced x4 add.py twins, ENGINE_MD5 -> 9476543399; SEAMS _declared_scope re-pinned 5688->5711 (cmd_status grew _ancestor_note above it). Full fence green (3604 passed). author: Tin Dang --- .add/SEAMS.md | 2 +- .../orientation-honesty/MILESTONE.md | 2 +- .add/state.json | 51 +++++++++- .add/tasks/status-ancestor-warn/TASK.md | 97 +++++++++++++++++++ .../src/add_method/_bundled/tooling/add.py | 23 +++++ .../add_method/_bundled/tooling/engine_pin.py | 2 +- add-method/tooling/add.py | 23 +++++ add-method/tooling/engine_pin.py | 2 +- .../tooling/test_status_ancestor_warn.py | 68 +++++++++++++ 9 files changed, 263 insertions(+), 7 deletions(-) create mode 100644 .add/tasks/status-ancestor-warn/TASK.md create mode 100644 add-method/tooling/test_status_ancestor_warn.py diff --git a/.add/SEAMS.md b/.add/SEAMS.md index 62c1062e..3c157c04 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:5688` (`_declared_scope`) +Anchor: `add-method/tooling/add.py:5711` (`_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/milestones/orientation-honesty/MILESTONE.md b/.add/milestones/orientation-honesty/MILESTONE.md index bf766ce0..1e04bb83 100644 --- a/.add/milestones/orientation-honesty/MILESTONE.md +++ b/.add/milestones/orientation-honesty/MILESTONE.md @@ -36,7 +36,7 @@ Issues/Risks (shared): the `--help`/next-command hint must stay a HINT, never a ## Exit criteria (observable; map each to the task that delivers it) - [ ] In a fresh workspace nested under an ancestor `.add/`, the WM runner's agent resolves the workspace's OWN (absent-then-init'd) project — no root-walk to the parent — verified by a benchmark isolation test (← harness-workspace-isolation) -- [ ] `status`/`init` run in a dir with no `.add/` but an ancestor `.add/` above prints the ancestor-resolved warning naming the resolved path + the `init` remedy (← status-ancestor-warn) +- [ ] `status` (full path) run in a dir with no local `.add/` but an ancestor `.add/` above prints an ancestor-resolved note naming the resolved path + the `init` remedy (stderr; `--json`/`--brief` stay silent). Scoped to the read command where the confusion fired — `init` creates at cwd, resolving no ancestor (← status-ancestor-warn) - [ ] `status` and `advance` output contains the exact next command with its required flags (e.g. `add.py freeze --by "" --cross`), copy-pasteable — pinned by a test asserting the flagged form (← next-command-hints) - [ ] `advance` output carries the destination phase's key guide hint, so orientation needs no separate `guide` call — pinned by a test (← guide-fold) - [ ] a `scope_violation` return-to-build prints the declared-vs-resolved paths AND a paste-ready `re-cross` line — pinned by a test (← scope-violation-explain) diff --git a/.add/state.json b/.add/state.json index b64ac2c0..b6f4504d 100644 --- a/.add/state.json +++ b/.add/state.json @@ -1,7 +1,7 @@ { "project": "AIDD / ADD Methodology", "stage": "mvp", - "active_task": "harness-workspace-isolation", + "active_task": "status-ancestor-warn", "tasks": { "add-check": { "title": "add.py check: validate .add project integrity", @@ -9723,10 +9723,55 @@ "email": "tindang.ht97@gmail.com", "source": "git" } + }, + "status-ancestor-warn": { + "title": "status/init warns when it resolved an ANCESTOR project (nested dir with no local .add/)", + "phase": "done", + "gate": "PASS", + "milestone": "orientation-honesty", + "depends_on": [], + "created": "2026-07-14T15:02:08+00:00", + "updated": "2026-07-14T15:20:26+00:00", + "fast": true, + "freeze": { + "version": "v1", + "frozen_at": "2026-07-14T15:12:51+00:00", + "approved_by": "Tin Dang", + "actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } + }, + "flag_verified": true, + "tripwire": { + "contract_md5": "32a6c9aab6d54a44b7bc9afc1b8507ce", + "tests": {} + }, + "scope": { + "declared": [ + "add-method/tooling/add.py", + ".add/tooling/add.py", + "add-method/.add/tooling/add.py", + "add-method/src/add_method/_bundled/tooling/add.py", + "add-method/tooling/engine_pin.py", + ".add/tooling/engine_pin.py", + "add-method/.add/tooling/engine_pin.py", + "add-method/src/add_method/_bundled/tooling/engine_pin.py", + ".add/SEAMS.md", + "add-method/tooling/test_status_ancestor_warn.py" + ], + "snapshot_md5": "a9d87669862aba5e9c54237761896c1a" + }, + "gate_actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } } }, "created": "2026-05-28T15:39:04+00:00", - "updated": "2026-07-14T15:00:52+00:00", + "updated": "2026-07-14T15:20:26+00:00", "milestones": { "v1-1": { "title": "v1.1 \u2014 adoption & ergonomics", @@ -11031,7 +11076,7 @@ "call-floor": "skill-orient-split", "six-phase-loop": "installer-shared-namespace-guard", "call-residuals": "help-habit-kill", - "orientation-honesty": "harness-workspace-isolation" + "orientation-honesty": "status-ancestor-warn" }, "todos": [ { diff --git a/.add/tasks/status-ancestor-warn/TASK.md b/.add/tasks/status-ancestor-warn/TASK.md new file mode 100644 index 00000000..e36a4a95 --- /dev/null +++ b/.add/tasks/status-ancestor-warn/TASK.md @@ -0,0 +1,97 @@ +# TASK: status/init warns when it resolved an ANCESTOR project (nested dir with no local .add/) + +slug: status-ancestor-warn · created: 2026-07-14 · stage: mvp +milestone: orientation-honesty +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: `status` in a dir with no local `.add/` but an ANCESTOR project above self-identifies — it prints a one-line note naming the resolved ancestor + the exact `init` command to scope a project HERE. Real-world value for nested/monorepo dirs + defense-in-depth behind the harness ceiling; kills the "why is the project the parent's?" spelunking a nested agent otherwise does. (Scoped to `status` — the read command where the transcript confusion fired; `init` CREATES at cwd, it resolves no ancestor, so a resolved-ancestor note doesn't fit it.) +Must: + - when `status` runs where `cwd/.add/state.json` is ABSENT but `find_root()` resolves a root (an ancestor), it prints to stderr a one-line note naming the resolved ancestor project path + the exact `add.py init --name "" --stage <...>` command to scope a project here + - when cwd HAS its own `.add/state.json`, NO note (the common case is silent) + - the note is informational (stderr) — it never changes the exit code or the normal status body +Reject: + - cwd owns a project (`cwd/.add/state.json` exists) -> no note emitted (the negative case the test pins) +Accept: Given cwd has no `.add/` but an ancestor `A/.add/state.json` exists, When `add.py status` runs, Then stderr includes "ancestor project at A" and an `add.py init --name` remedy, the normal status body still prints, exit 0 +Boundary: cwd-owned project (no note) vs ancestor-resolved (note) — the two shapes the test must speak +Assumptions: ⚠ detecting "ancestor" as `not (cwd/.add/state.json).exists() and find_root() is not None` — a symlinked cwd could differ from resolved, so compare resolved paths; if wrong: the note misfires (cosmetic — stderr only, body unaffected) + +--- + +## 3 · PLAN — the change plan: ground · contract · build-strategy + +### Grounding +Touches (files · symbols): `add.py:cmd_status` (the full-status path — emit the note before the body) + a new `_ancestor_note()` helper beside it; it READS `find_root` · `ROOT_DIRNAME` · `STATE_FILE` (already imported) — no engine symbol changed +Context (working folder): `add-method/tooling/` (canonical add.py + 4 twins); ENGINE_MD5 re-pinned; SEAMS re-pinned IF the `_declared_scope` line pin drifts (cmd_status sits upstream of it) +Honors (patterns / conventions): the `_require_root` skip-error precedent — hand the EXACT command with flags, never a bare hint (this is lever B applied to the ancestor case); informational notes go to stderr so `--json`/pipes stay clean +Anchors the contract cites: `cmd_status` (edited) · `_ancestor_note` (new) · `find_root` · `ROOT_DIRNAME` · `STATE_FILE` +Ground SHA: a87ed1e — stamped by freeze + +### Contract + +``` +_ancestor_note() -> str | None + cwd = Path.cwd().resolve() + if (cwd / ROOT_DIRNAME / STATE_FILE).exists(): return None # cwd owns a project -> silent + root = find_root() # respects the ceiling; None if none reachable + if root is None: return None # no project anywhere -> silent (init flow owns that) + return ("note: no .add/ here — using the ancestor project at " + str(root.parent) + + "; run `add.py init --name \"\" --stage ` to scope a project here") +cmd_status, the FULL path only (not --brief / --json / --section): + n = _ancestor_note(); if n: print(n, file=sys.stderr) # before the normal body; exit code + body unchanged +``` + +`Least-sure flag surfaced at freeze:` [contract] emitting on the FULL path only — `--json`/`--brief` stay silent so machine callers and pipes are unaffected; if an agent orients via `--brief` in a nested dir it won't see the note (acceptable: the confusion fired on full `status`, and stderr-on-json would corrupt parsers). +Status: FROZEN @ v1 — approved by Tin Dang +### Build-strategy +Scope (may touch): `add-method/tooling/add.py` `.add/tooling/add.py` `add-method/.add/tooling/add.py` `add-method/src/add_method/_bundled/tooling/add.py` `add-method/tooling/engine_pin.py` `.add/tooling/engine_pin.py` `add-method/.add/tooling/engine_pin.py` `add-method/src/add_method/_bundled/tooling/engine_pin.py` `.add/SEAMS.md` `add-method/tooling/test_status_ancestor_warn.py` +Strategy & known-problem fixes: 1. RED test_status_ancestor_warn (nested cwd, ancestor project → stderr has the note + init cmd, stdout body intact, exit 0; cwd-owned project → NO note). 2. add `_ancestor_note()` + call it in cmd_status's full path (guard: NOT on --json/--brief/--section — trap: a note on --json breaks parsers). 3. sync ×4 add.py twins, re-pin ENGINE_MD5, re-pin SEAMS `_declared_scope` IF drifted. +Approach (domain strategy): stderr ancestor self-identify + +### 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 — added `_ancestor_note()` beside cmd_status; called it in the full-status path right after root/state load, printing to stderr. Synced ×4 add.py twins, ENGINE_MD5→9476543399…, SEAMS `_declared_scope` re-pinned 5688→5711 (my +23 lines). No divergence. +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 — status regression 18 green; repin+PKG parity green; full fence (pending) +- [x] green was EARNED — the note-present assert was RED before _ancestor_note existed; the two silent-case asserts (cwd-owned, stdout-clean) guard against a note that fires always or leaks to stdout +- [x] input dialect held — the test speaks the real CLI stderr/stdout + exit-code dialect +- [x] no exposed secrets/injection/deps — stdlib only; the path is str()'d into a message, never eval'd (security = HARD-STOP: none) + +Build expectations (from §1 Accept + §3 CONTRACT): in a nested dir under an ancestor project, `add.py status` writes to STDERR `note: no .add/ here — using the ancestor project at ; run \`add.py init --name "" --stage <...>\` to scope a project to this directory`, the normal status body still prints to STDOUT, exit 0; a cwd that owns `.add/state.json` prints NO note — confirmed by test_status_ancestor_warn (3 asserts) + 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 1410a7c2..b9f0dfa6 100644 --- a/add-method/src/add_method/_bundled/tooling/add.py +++ b/add-method/src/add_method/_bundled/tooling/add.py @@ -2728,6 +2728,22 @@ def _sorted_by_updated(items: dict) -> list: return sorted(items.items(), key=lambda kv: kv[1].get("updated") or "", reverse=True) +def _ancestor_note() -> str | None: + """A one-line stderr note when `status` resolved an ANCESTOR project — cwd has + no .add/ of its own but find_root walked up to one. Hands the exact `init` to + scope a project HERE (the _require_root skip-error precedent). None (silent) when + cwd owns a project or no project is reachable (the init flow owns that message).""" + cwd = Path.cwd().resolve() + if (cwd / ROOT_DIRNAME / STATE_FILE).exists(): + return None + root = find_root() + if root is None: + return None + return (f"note: no .add/ here — using the ancestor project at {root.parent}; " + 'run `add.py init --name "" --stage ` ' + "to scope a project to this directory") + + def cmd_status(args: argparse.Namespace) -> None: _section = getattr(args, "section", None) if _section is not None: @@ -2812,6 +2828,13 @@ def cmd_status(args: argparse.Namespace) -> None: return root = _require_root() state = load_state(root) + # status-ancestor-warn: when cwd has no .add/ of its own but find_root walked up + # to an ANCESTOR project, say so + hand the exact `init` to scope here — a nested + # agent otherwise spends commands grepping find_root internals ("why is the project + # the parent's?"). Full-status path only; stderr so --json/--brief/pipes stay clean. + _anc = _ancestor_note() + if _anc: + print(_anc, file=sys.stderr) active = _active_task(state) tasks = state.get("tasks", {}) # Compute once: True when setup is present AND locked is False (the lock-gate window). 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 7c8388de..38adf63a 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 = "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_MD5 = "9476543399d46916313f38ffb01f9142" # re-aimed @ status-ancestor-warn (orientation-honesty: cmd_status prints a one-line stderr note + exact `init` cmd when it resolved an ANCESTOR project — a nested agent stops grepping find_root internals). prior: 9267a41f… @ help-habit-kill ENGINE_PKG_MD5 = "955023db4358bd3f80a22078bc365361" # re-aimed @ harness-workspace-isolation (io_state.find_root: opt-in ADD_ROOT_CEILING env bounds the upward walk so a nested workspace resolves its own project, not an ancestor). prior: fc40ad47… @ phase-merge-verify diff --git a/add-method/tooling/add.py b/add-method/tooling/add.py index 1410a7c2..b9f0dfa6 100644 --- a/add-method/tooling/add.py +++ b/add-method/tooling/add.py @@ -2728,6 +2728,22 @@ def _sorted_by_updated(items: dict) -> list: return sorted(items.items(), key=lambda kv: kv[1].get("updated") or "", reverse=True) +def _ancestor_note() -> str | None: + """A one-line stderr note when `status` resolved an ANCESTOR project — cwd has + no .add/ of its own but find_root walked up to one. Hands the exact `init` to + scope a project HERE (the _require_root skip-error precedent). None (silent) when + cwd owns a project or no project is reachable (the init flow owns that message).""" + cwd = Path.cwd().resolve() + if (cwd / ROOT_DIRNAME / STATE_FILE).exists(): + return None + root = find_root() + if root is None: + return None + return (f"note: no .add/ here — using the ancestor project at {root.parent}; " + 'run `add.py init --name "" --stage ` ' + "to scope a project to this directory") + + def cmd_status(args: argparse.Namespace) -> None: _section = getattr(args, "section", None) if _section is not None: @@ -2812,6 +2828,13 @@ def cmd_status(args: argparse.Namespace) -> None: return root = _require_root() state = load_state(root) + # status-ancestor-warn: when cwd has no .add/ of its own but find_root walked up + # to an ANCESTOR project, say so + hand the exact `init` to scope here — a nested + # agent otherwise spends commands grepping find_root internals ("why is the project + # the parent's?"). Full-status path only; stderr so --json/--brief/pipes stay clean. + _anc = _ancestor_note() + if _anc: + print(_anc, file=sys.stderr) active = _active_task(state) tasks = state.get("tasks", {}) # Compute once: True when setup is present AND locked is False (the lock-gate window). diff --git a/add-method/tooling/engine_pin.py b/add-method/tooling/engine_pin.py index 7c8388de..38adf63a 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 = "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_MD5 = "9476543399d46916313f38ffb01f9142" # re-aimed @ status-ancestor-warn (orientation-honesty: cmd_status prints a one-line stderr note + exact `init` cmd when it resolved an ANCESTOR project — a nested agent stops grepping find_root internals). prior: 9267a41f… @ help-habit-kill ENGINE_PKG_MD5 = "955023db4358bd3f80a22078bc365361" # re-aimed @ harness-workspace-isolation (io_state.find_root: opt-in ADD_ROOT_CEILING env bounds the upward walk so a nested workspace resolves its own project, not an ancestor). prior: fc40ad47… @ phase-merge-verify diff --git a/add-method/tooling/test_status_ancestor_warn.py b/add-method/tooling/test_status_ancestor_warn.py new file mode 100644 index 00000000..cecfb759 --- /dev/null +++ b/add-method/tooling/test_status_ancestor_warn.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Red/green for status-ancestor-warn (orientation-honesty, frozen §3 v1). + +`status` run in a dir with no local .add/ but an ANCESTOR project above prints a +one-line stderr note naming the resolved ancestor + the exact `init` command to +scope a project here — so a nested agent stops spelunking "why is the project the +parent's?". A cwd that owns its own project stays silent. + +Run: python3 -m unittest test_status_ancestor_warn -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.A = Path(tempfile.mkdtemp(prefix="add-anc-")).resolve() + self.addCleanup(shutil.rmtree, self.A, ignore_errors=True) + self.addCleanup(os.chdir, self._cwd) + os.chdir(self.A) + self._run("init", "--name", "parent-proj", "--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 AncestorNoteTest(_Harness): + def test_nested_dir_prints_ancestor_note(self): + work = self.A / "work" + work.mkdir() + os.chdir(work) + code, out, err = self._run("status") + self.assertIn(code, (None, 0), "status still exits cleanly") + self.assertIn("ancestor project", err, "must name the resolved ancestor project") + self.assertIn(str(self.A), err, "must print the resolved ancestor path") + self.assertIn("add.py init --name", err, "must hand the exact init command to scope here") + self.assertTrue(out.strip(), "the normal status body still prints on stdout") + + def test_cwd_owned_project_is_silent(self): + # at A itself (owns .add/state.json) -> no ancestor note + code, out, err = self._run("status") + self.assertNotIn("ancestor project", err, "a cwd that owns a project emits no note") + + def test_note_goes_to_stderr_not_stdout(self): + work = self.A / "work" + work.mkdir() + os.chdir(work) + code, out, err = self._run("status") + self.assertNotIn("ancestor project", out, "the note must not pollute stdout") + + +if __name__ == "__main__": + unittest.main() From 0798bd2b277f8f72d0ad04e453f9deab0c275ee2 Mon Sep 17 00:00:00 2001 From: Tin Dang Date: Tue, 14 Jul 2026 22:31:54 +0700 Subject: [PATCH 3/5] =?UTF-8?q?feat(method):=20orient-map=20=E2=80=94=20ba?= =?UTF-8?q?re=20add.py=20/=20--help=20lead=20with=20a=20concise=20flow=20m?= =?UTF-8?q?ap,=20not=20the=2050-choice=20dump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orientation-honesty task 3 of 5. The pre-measure anatomy showed the flag-carrying next-command hints from prior tasks (first-call-ergonomics / skip-error-ergonomics / engine-hint-batch-ops) already cover freeze/advance/new-task/init — the sixphase agent actually ran `freeze --by ... --cross`. The true residual is the stubborn 1/rep INITIAL orientation probe: `add.py --help` (and bare `add.py`) dumped the ~50-choice argparse usage, which help-habit-kill never touched (it only caught UNKNOWN commands). - new _FLOW_MAP: a flow-ordered map — status (start here) · init · new-task · advance · freeze · gate — each with its key flags on one line. - _AddArgParser.format_help(): the TOP parser (prog == "add.py") LEADS with the map, then the full argparse command list still follows — nothing lost; `add.py --help | head` now surfaces orientation instead of alphabet soup. A subcommand's own --help (prog "add.py ") is byte-identical argparse. - _AddArgParser.error(): a bare `add.py` (no subcommand → "the following arguments are required") prints the map + `run: add.py status` to stderr, exit 2 — not the raw dump. The help-habit-kill invalid-choice branch is unchanged. Message-layer only — no gate/enforcement/state change. Red first (test_orient_map: map-leads-then-full-list · bare-to-stderr · subcommand-help-unchanged · unknown- command-unchanged). Synced x4 add.py twins, ENGINE_MD5 -> a88bc24c; SEAMS unchanged (_declared_scope still 5711 — the class sits below it). Full fence green (3608 passed). author: Tin Dang --- .add/state.json | 50 +++++++++- .add/tasks/orient-map/TASK.md | 98 +++++++++++++++++++ .../src/add_method/_bundled/tooling/add.py | 31 +++++- .../add_method/_bundled/tooling/engine_pin.py | 2 +- add-method/tooling/add.py | 31 +++++- add-method/tooling/engine_pin.py | 2 +- add-method/tooling/test_orient_map.py | 61 ++++++++++++ 7 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 .add/tasks/orient-map/TASK.md create mode 100644 add-method/tooling/test_orient_map.py diff --git a/.add/state.json b/.add/state.json index b6f4504d..c4cff9a1 100644 --- a/.add/state.json +++ b/.add/state.json @@ -1,7 +1,7 @@ { "project": "AIDD / ADD Methodology", "stage": "mvp", - "active_task": "status-ancestor-warn", + "active_task": "orient-map", "tasks": { "add-check": { "title": "add.py check: validate .add project integrity", @@ -9768,10 +9768,54 @@ "email": "tindang.ht97@gmail.com", "source": "git" } + }, + "orient-map": { + "title": "bare add.py / --help lead with a concise flow map, not the 50-choice dump", + "phase": "done", + "gate": "PASS", + "milestone": "orientation-honesty", + "depends_on": [], + "created": "2026-07-14T15:21:50+00:00", + "updated": "2026-07-14T15:31:38+00:00", + "fast": true, + "freeze": { + "version": "v1", + "frozen_at": "2026-07-14T15:22:59+00:00", + "approved_by": "Tin Dang", + "actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } + }, + "flag_verified": true, + "tripwire": { + "contract_md5": "dd970f5636c061a91b4afe86048917e6", + "tests": {} + }, + "scope": { + "declared": [ + "add-method/tooling/add.py", + ".add/tooling/add.py", + "add-method/.add/tooling/add.py", + "add-method/src/add_method/_bundled/tooling/add.py", + "add-method/tooling/engine_pin.py", + ".add/tooling/engine_pin.py", + "add-method/.add/tooling/engine_pin.py", + "add-method/src/add_method/_bundled/tooling/engine_pin.py", + "add-method/tooling/test_orient_map.py" + ], + "snapshot_md5": "48e9f49fb4eb0b453b1e90dab7a289af" + }, + "gate_actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } } }, "created": "2026-05-28T15:39:04+00:00", - "updated": "2026-07-14T15:20:26+00:00", + "updated": "2026-07-14T15:31:38+00:00", "milestones": { "v1-1": { "title": "v1.1 \u2014 adoption & ergonomics", @@ -11076,7 +11120,7 @@ "call-floor": "skill-orient-split", "six-phase-loop": "installer-shared-namespace-guard", "call-residuals": "help-habit-kill", - "orientation-honesty": "status-ancestor-warn" + "orientation-honesty": "orient-map" }, "todos": [ { diff --git a/.add/tasks/orient-map/TASK.md b/.add/tasks/orient-map/TASK.md new file mode 100644 index 00000000..7f3b3ead --- /dev/null +++ b/.add/tasks/orient-map/TASK.md @@ -0,0 +1,98 @@ +# TASK: bare add.py / --help lead with a concise flow map, not the 50-choice dump + +slug: orient-map · created: 2026-07-14 · stage: mvp +milestone: orientation-honesty +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: bare `add.py` (no subcommand) and `add.py --help` LEAD with a concise flow-ordered command map (status · init · new-task · advance · freeze · gate) instead of the ~50-choice argparse dump — so the agent's first orientation is one cheap read. Kills the stubborn 1/rep bare-`--help` probe the anatomy flagged, which help-habit-kill never touched (it only caught UNKNOWN commands; the flag-carrying next-command hints from prior tasks already cover freeze/advance/new-task/init, so THIS is the true residual). +Must: + - `add.py --help`/`-h` (top parser) output LEADS with the concise flow map, then the full argparse command list still appears below it — nothing lost; `add.py --help | head` now gets the map, not alphabet soup + - bare `add.py` (no subcommand → argparse "the following arguments are required: cmd") prints the flow map + an `add.py status` pointer to STDERR, exit 2 — not the raw usage dump + - a SUBCOMMAND's help (`add.py init --help`, prog "add.py init") is UNCHANGED — full argparse help for that command's flags stays intact + - the help-habit-kill unknown-command interception stays byte-identical +Reject: + - `add.py --help` (a subparser, prog != "add.py") -> argparse default help, unchanged (the negative case the test pins) +Accept: Given a fresh shell, When `add.py --help` runs, Then its first lines are the flow map (status/init/new-task/advance/freeze/gate) AND the full command list still appears below; and bare `add.py` writes the map + "add.py status" to stderr, exit 2 +Boundary: top parser (map leads) vs subparser `--help` (unchanged argparse) — the two shapes the test must speak +Assumptions: ⚠ argparse's `-h` action prints `format_help()` — overriding `format_help` on the top parser (guarded `prog=="add.py"`) retargets `--help` while leaving subparser help intact; if wrong (argparse routes `-h` around format_help): `--help` is unaffected and only the bare-`add.py` path lands — still a partial win, never a regression + +--- + +## 3 · PLAN — the change plan: ground · contract · build-strategy + +### Grounding +Touches (files · symbols): `add.py:_AddArgParser` — add `format_help()` (top parser leads with the map) + extend `error()` for the no-subcommand "required" case; a new module constant `_FLOW_MAP` beside it; help-habit-kill's `error()` invalid-choice branch stays as-is +Context (working folder): `add-method/tooling/` (canonical add.py + 4 twins); ENGINE_MD5 re-pinned; SEAMS re-pinned only if `_declared_scope` line drifts (the _AddArgParser class sits near build_parser, ~line 8400 — well BELOW _declared_scope@5711, so no SEAMS drift) +Honors (patterns / conventions): help-habit-kill's `prog == "add.py"` top-parser guard (subparsers are _AddArgParser with prog "add.py " — the guard scopes to the top parser only); the `add.py status` resume pointer idiom +Anchors the contract cites: `_AddArgParser.format_help` (new) · `_AddArgParser.error` (extended) · `_FLOW_MAP` (new) · `build_parser` +Ground SHA: 1d9a211 — stamped by freeze + +### Contract + +``` +_FLOW_MAP: str — a flow-ordered map: "status (start here) · init · new-task · advance · freeze · gate", + each with its key flags on one line, + " -h for a command's flags" +_AddArgParser.format_help(self) -> str: + self.prog == "add.py" -> _FLOW_MAP + "\n" + super().format_help() # map LEADS, full list follows + else -> super().format_help() # subcommand help byte-identical +_AddArgParser.error(self, message): + self.prog == "add.py" AND "the following arguments are required" in message: + -> sys.stderr.write(_FLOW_MAP + "\nrun: add.py status\n"); raise SystemExit(2) + (the invalid-choice branch and the final super().error(message) stay unchanged) +``` + +`Least-sure flag surfaced at freeze:` [contract] whether argparse's `-h` action routes through `format_help()` — it does in CPython (the `_HelpAction` calls `parser.format_help()`), and the guard leaves subparser help intact; if a runtime differs, `--help` is simply unaffected (partial win, never a regression) — the bare-`add.py` path is independent and lands regardless. +Status: FROZEN @ v1 — approved by Tin Dang +### Build-strategy +Scope (may touch): `add-method/tooling/add.py` `.add/tooling/add.py` `add-method/.add/tooling/add.py` `add-method/src/add_method/_bundled/tooling/add.py` `add-method/tooling/engine_pin.py` `.add/tooling/engine_pin.py` `add-method/.add/tooling/engine_pin.py` `add-method/src/add_method/_bundled/tooling/engine_pin.py` `add-method/tooling/test_orient_map.py` +Strategy & known-problem fixes: 1. RED test_orient_map (`--help` leads with the map AND still lists the full commands below; bare `add.py` → map + "add.py status" on stderr, exit 2; `add.py init --help` still shows init's own flags, NOT the map; an unknown command still says "unknown command"). 2. add `_FLOW_MAP` + `format_help` + the "required" branch in `error()` (trap: guard `prog=="add.py"` so subcommand help/errors stay argparse-default). 3. sync ×4 add.py twins, re-pin ENGINE_MD5 (SEAMS unaffected — edit is below _declared_scope). +Approach (domain strategy): flow-map help override + +### 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 — added `_FLOW_MAP` constant + `_AddArgParser.format_help` (map leads then super() list, guarded `prog=="add.py"`) + the "required" branch in `error()`. Synced ×4 add.py twins, ENGINE_MD5→a88bc24c…; SEAMS unchanged (_declared_scope still 5711 — edit at ~8420 is below it). Live-verified: `--help` and bare `add.py` both lead with the map. No divergence. +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 — orient-map 4 green, help-habit-kill intact, repin parity green; full fence (pending tail) +- [x] green was EARNED — the two map asserts were RED before _FLOW_MAP/format_help existed; the subcommand-help + unknown-command asserts guard against the map hijacking those surfaces (both stayed green throughout) +- [x] input dialect held — the test speaks the real CLI stdout/stderr + exit-code dialect (format_help + parse_args) +- [x] no exposed secrets/injection/deps — pure static string + argparse override (security = HARD-STOP: none) + +Build expectations (from §1 Accept + §3 CONTRACT): `add.py --help` output starts with `ADD — spec-and-tests-first…` + the flow map (status/init/new-task/advance/freeze/gate) AND still lists `new-milestone` et al below; bare `add.py` writes that map + `run: add.py status` to STDERR exit 2 (no `{init,lock,freeze…` dump); `add.py init --help` still shows `--stage` and NOT the map; `add.py statuss` still says "unknown command" — confirmed by test_orient_map (4 asserts) + the live `--help`/bare output + 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 b9f0dfa6..7216c1ad 100644 --- a/add-method/src/add_method/_bundled/tooling/add.py +++ b/add-method/src/add_method/_bundled/tooling/add.py @@ -8435,6 +8435,19 @@ def cmd_report(args: argparse.Namespace) -> None: print(out) +_FLOW_MAP = ( + "ADD — spec-and-tests-first; you drive, the human owns direction.\n" + "start here: add.py status — where you are + your exact next command\n" + "flow: init → new-task → advance → freeze → gate\n" + ' add.py init --name "" --stage mvp start a project in this directory\n' + ' add.py new-task --title "..." a task [--fast lean · --oneshot single-task]\n' + " add.py advance cross to the next phase (status names the exact form)\n" + ' add.py freeze --by "" --cross approve the frozen §3 contract (the one human gate)\n' + " add.py gate PASS record the verify outcome\n" + "a command's flags: add.py -h\n" +) + + 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 @@ -8442,9 +8455,25 @@ class _AddArgParser(argparse.ArgumentParser): + 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.""" + argparse's default, so those surfaces stay byte-identical. + + orient-map: the top parser's `--help` (and a bare `add.py` with no subcommand) LEAD + with the flow map above instead of the alphabet-soup dump — the agent's first + orientation is one cheap read. Both are guarded to the TOP parser (`prog == 'add.py'`): + a subcommand's own `--help`/errors (prog "add.py ") stay byte-identical argparse.""" + + def format_help(self) -> str: + # top parser: map LEADS, the full argparse command list still follows (nothing lost; + # `add.py --help | head` now surfaces orientation, not the 50-choice usage line). + if self.prog == "add.py": + return _FLOW_MAP + "\n" + super().format_help() + return super().format_help() def error(self, message: str): + if self.prog == "add.py" and "the following arguments are required" in message: + # bare `add.py` (no subcommand): orient, don't dump the raw usage. + sys.stderr.write(_FLOW_MAP + "\nrun: add.py status\n") + raise SystemExit(2) m = re.search(r"invalid choice: '([^']*)'", message) if m is not None and self.prog == "add.py": import difflib 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 38adf63a..f1ebc19e 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 = "9476543399d46916313f38ffb01f9142" # re-aimed @ status-ancestor-warn (orientation-honesty: cmd_status prints a one-line stderr note + exact `init` cmd when it resolved an ANCESTOR project — a nested agent stops grepping find_root internals). prior: 9267a41f… @ help-habit-kill +ENGINE_MD5 = "a88bc24c7f1171f3e373c15fb1bf40ac" # re-aimed @ orient-map (orientation-honesty: bare add.py + --help LEAD with a concise flow map (status/init/new-task/advance/freeze/gate) then the full list — kills the 1/rep initial --help orientation dump). prior: 9476543399… @ status-ancestor-warn ENGINE_PKG_MD5 = "955023db4358bd3f80a22078bc365361" # re-aimed @ harness-workspace-isolation (io_state.find_root: opt-in ADD_ROOT_CEILING env bounds the upward walk so a nested workspace resolves its own project, not an ancestor). prior: fc40ad47… @ phase-merge-verify diff --git a/add-method/tooling/add.py b/add-method/tooling/add.py index b9f0dfa6..7216c1ad 100644 --- a/add-method/tooling/add.py +++ b/add-method/tooling/add.py @@ -8435,6 +8435,19 @@ def cmd_report(args: argparse.Namespace) -> None: print(out) +_FLOW_MAP = ( + "ADD — spec-and-tests-first; you drive, the human owns direction.\n" + "start here: add.py status — where you are + your exact next command\n" + "flow: init → new-task → advance → freeze → gate\n" + ' add.py init --name "" --stage mvp start a project in this directory\n' + ' add.py new-task --title "..." a task [--fast lean · --oneshot single-task]\n' + " add.py advance cross to the next phase (status names the exact form)\n" + ' add.py freeze --by "" --cross approve the frozen §3 contract (the one human gate)\n' + " add.py gate PASS record the verify outcome\n" + "a command's flags: add.py -h\n" +) + + 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 @@ -8442,9 +8455,25 @@ class _AddArgParser(argparse.ArgumentParser): + 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.""" + argparse's default, so those surfaces stay byte-identical. + + orient-map: the top parser's `--help` (and a bare `add.py` with no subcommand) LEAD + with the flow map above instead of the alphabet-soup dump — the agent's first + orientation is one cheap read. Both are guarded to the TOP parser (`prog == 'add.py'`): + a subcommand's own `--help`/errors (prog "add.py ") stay byte-identical argparse.""" + + def format_help(self) -> str: + # top parser: map LEADS, the full argparse command list still follows (nothing lost; + # `add.py --help | head` now surfaces orientation, not the 50-choice usage line). + if self.prog == "add.py": + return _FLOW_MAP + "\n" + super().format_help() + return super().format_help() def error(self, message: str): + if self.prog == "add.py" and "the following arguments are required" in message: + # bare `add.py` (no subcommand): orient, don't dump the raw usage. + sys.stderr.write(_FLOW_MAP + "\nrun: add.py status\n") + raise SystemExit(2) m = re.search(r"invalid choice: '([^']*)'", message) if m is not None and self.prog == "add.py": import difflib diff --git a/add-method/tooling/engine_pin.py b/add-method/tooling/engine_pin.py index 38adf63a..f1ebc19e 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 = "9476543399d46916313f38ffb01f9142" # re-aimed @ status-ancestor-warn (orientation-honesty: cmd_status prints a one-line stderr note + exact `init` cmd when it resolved an ANCESTOR project — a nested agent stops grepping find_root internals). prior: 9267a41f… @ help-habit-kill +ENGINE_MD5 = "a88bc24c7f1171f3e373c15fb1bf40ac" # re-aimed @ orient-map (orientation-honesty: bare add.py + --help LEAD with a concise flow map (status/init/new-task/advance/freeze/gate) then the full list — kills the 1/rep initial --help orientation dump). prior: 9476543399… @ status-ancestor-warn ENGINE_PKG_MD5 = "955023db4358bd3f80a22078bc365361" # re-aimed @ harness-workspace-isolation (io_state.find_root: opt-in ADD_ROOT_CEILING env bounds the upward walk so a nested workspace resolves its own project, not an ancestor). prior: fc40ad47… @ phase-merge-verify diff --git a/add-method/tooling/test_orient_map.py b/add-method/tooling/test_orient_map.py new file mode 100644 index 00000000..b2a01fea --- /dev/null +++ b/add-method/tooling/test_orient_map.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Red/green for orient-map (orientation-honesty, frozen §3 v1). + +Bare `add.py` and `add.py --help` LEAD with a concise flow-ordered command map +instead of the ~50-choice argparse dump — killing the stubborn 1/rep initial +`--help` orientation probe. A subcommand's own `--help` and the help-habit-kill +unknown-command interception stay unchanged. + +Run: python3 -m unittest test_orient_map -v +""" +import contextlib +import io +import unittest + +from add import build_parser + +_MAP_HEAD = "ADD — spec-and-tests-first" + + +def _parse(argv): + """Return (exit_code, stdout, stderr) for build_parser().parse_args(argv).""" + out, err = io.StringIO(), io.StringIO() + code = None + try: + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + build_parser().parse_args(argv) + except SystemExit as e: + code = e.code + return code, out.getvalue(), err.getvalue() + + +class OrientMapTest(unittest.TestCase): + def test_top_help_leads_with_map_then_full_list(self): + fh = build_parser().format_help() + self.assertTrue(fh.lstrip().startswith(_MAP_HEAD), "top --help must LEAD with the flow map") + self.assertIn("add.py status", fh, "the map names status as the start") + self.assertIn("freeze --by", fh, "the map hands freeze with its flag") + self.assertIn("new-milestone", fh, "the FULL argparse command list still appears below the map") + + def test_bare_addpy_prints_map_to_stderr(self): + code, out, err = _parse([]) + self.assertEqual(code, 2, "bare add.py still exits 2") + self.assertIn(_MAP_HEAD, err, "bare add.py prints the flow map to stderr") + self.assertIn("add.py status", err, "and points at status") + self.assertNotIn("{init,lock,freeze", out + err, "not the raw 50-choice usage dump") + + def test_subcommand_help_unchanged(self): + code, out, err = _parse(["init", "--help"]) + self.assertEqual(code, 0) + self.assertIn("--stage", out, "a subcommand's own --help still shows its flags") + self.assertNotIn(_MAP_HEAD, out, "the flow map must NOT hijack subcommand help") + + def test_unknown_command_interception_unchanged(self): + code, out, err = _parse(["statuss"]) + self.assertEqual(code, 2) + self.assertIn("unknown command 'statuss'", out + err, "help-habit-kill stays intact") + self.assertNotIn(_MAP_HEAD, out + err, "an unknown command is not the orientation path") + + +if __name__ == "__main__": + unittest.main() From 91b337184389e9952e9bbfddf65856483c37f9f2 Mon Sep 17 00:00:00 2001 From: Tin Dang Date: Tue, 14 Jul 2026 22:49:28 +0700 Subject: [PATCH 4/5] =?UTF-8?q?feat(method):=20advance=20folds=20the=20gui?= =?UTF-8?q?de=20chapter=20=E2=80=94=20no=20re-run=20of=20`add.py=20guide`?= =?UTF-8?q?=20(guide-fold)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The anatomy's orientation residual: agents re-run `add.py guide` after every advance even though the `next:` footer already carries the phase command + short why. The ONE line the footer lacks is the guide's chapter reference — so a completing `advance` now folds it in. A completing advance that lands an agent INTO a working phase prints, right ABOVE the `next:` footer: guide: .add/docs/ — the phase chapter (this + the next line ARE `add.py guide`; no separate call) The chapter is the LANDED phase's (PHASE_GUIDE[nxt][1], the same source cmd_guide's `read:` line uses), so specify→plan folds 05-step-3-plan.md, plan→tests folds 06-step-4-tests.md, etc. This is strictly non-duplicative: the footer keeps its command+why unchanged (still exactly one `next:` line); the fold adds only the chapter + the don't-re-run cue. Suppressed where it would be wrong: - landing in `done` folds nothing — Arm B (the milestone decision) owns that juncture, only the footer prints. - bundle fast-forward intermediates never fold (they `return` above the print); only the final landing folds — exactly one fold per completing advance. - fail-soft: a corrupt/unmapped landed phase folds nothing via PHASE_GUIDE.get, never a KeyError on an already-saved advance (the footer's own ethos). Kept in cmd_advance (not inside _next_footer) so the footer stays a pure single-`next:`-line resolver reused by status/gate/new-task — the fold must not leak a chapter line onto those surfaces. Task 4/5 of the orientation-honesty milestone (lever E). Red/green: test_guide_fold — the 3 fold asserts were RED (0 fold lines) before the print existed; the done-landing assert guards the fold from the Arm-B juncture. Synced ×4 add.py twins; ENGINE_MD5→c8e0a3e5; SEAMS _declared_scope re-pinned 5711→5723 (the fold + comment sit above it in cmd_advance). author: Tin Dang --- .add/SEAMS.md | 2 +- .add/state.json | 51 +++++- .add/tasks/guide-fold/TASK.md | 97 ++++++++++++ .../src/add_method/_bundled/tooling/add.py | 12 ++ .../add_method/_bundled/tooling/engine_pin.py | 2 +- add-method/tooling/add.py | 12 ++ add-method/tooling/engine_pin.py | 2 +- add-method/tooling/test_guide_fold.py | 147 ++++++++++++++++++ 8 files changed, 319 insertions(+), 6 deletions(-) create mode 100644 .add/tasks/guide-fold/TASK.md create mode 100644 add-method/tooling/test_guide_fold.py diff --git a/.add/SEAMS.md b/.add/SEAMS.md index 3c157c04..406de090 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:5711` (`_declared_scope`) +Anchor: `add-method/tooling/add.py:5723` (`_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 c4cff9a1..c24ffea5 100644 --- a/.add/state.json +++ b/.add/state.json @@ -1,7 +1,7 @@ { "project": "AIDD / ADD Methodology", "stage": "mvp", - "active_task": "orient-map", + "active_task": "guide-fold", "tasks": { "add-check": { "title": "add.py check: validate .add project integrity", @@ -9812,10 +9812,55 @@ "email": "tindang.ht97@gmail.com", "source": "git" } + }, + "guide-fold": { + "title": "advance folds the guide's chapter pointer + says no need to re-run guide", + "phase": "done", + "gate": "PASS", + "milestone": "orientation-honesty", + "depends_on": [], + "created": "2026-07-14T15:33:58+00:00", + "updated": "2026-07-14T15:49:13+00:00", + "fast": true, + "freeze": { + "version": "v1", + "frozen_at": "2026-07-14T15:39:31+00:00", + "approved_by": "Tin Dang", + "actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } + }, + "flag_verified": true, + "tripwire": { + "contract_md5": "74373c45716739a83c3d9534875ed87e", + "tests": {} + }, + "scope": { + "declared": [ + "add-method/tooling/add.py", + ".add/tooling/add.py", + "add-method/.add/tooling/add.py", + "add-method/src/add_method/_bundled/tooling/add.py", + "add-method/tooling/engine_pin.py", + ".add/tooling/engine_pin.py", + "add-method/.add/tooling/engine_pin.py", + "add-method/src/add_method/_bundled/tooling/engine_pin.py", + ".add/SEAMS.md", + "add-method/tooling/test_guide_fold.py" + ], + "snapshot_md5": "b28b212e79dd2585f2fd13d0b497ab43" + }, + "gate_actor": { + "name": "Tin Dang", + "email": "tindang.ht97@gmail.com", + "source": "git" + } } }, "created": "2026-05-28T15:39:04+00:00", - "updated": "2026-07-14T15:31:38+00:00", + "updated": "2026-07-14T15:49:13+00:00", "milestones": { "v1-1": { "title": "v1.1 \u2014 adoption & ergonomics", @@ -11120,7 +11165,7 @@ "call-floor": "skill-orient-split", "six-phase-loop": "installer-shared-namespace-guard", "call-residuals": "help-habit-kill", - "orientation-honesty": "orient-map" + "orientation-honesty": "guide-fold" }, "todos": [ { diff --git a/.add/tasks/guide-fold/TASK.md b/.add/tasks/guide-fold/TASK.md new file mode 100644 index 00000000..5628a3de --- /dev/null +++ b/.add/tasks/guide-fold/TASK.md @@ -0,0 +1,97 @@ +# TASK: advance folds the guide's chapter pointer + says no need to re-run guide + +slug: guide-fold · created: 2026-07-14 · stage: mvp +milestone: orientation-honesty +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: a COMPLETING `advance` folds in the landed phase's guide chapter — the ONE `add.py guide` line the `next:` footer lacks (the footer already carries the command + the short why) — as a `guide: .add/docs/` line printed right ABOVE the footer, plus a note that this + the footer ARE the guide (no separate `add.py guide` call). Kills the stubborn re-run-`guide` habit the anatomy flagged, WITHOUT duplicating the footer (dedup concern: the footer already gives command+why, so the fold adds only the chapter + the "don't re-run" cue — never the command or why again). +Must: + - a completing `advance` that LANDS in a non-`done` phase prints `guide: .add/docs/` for the LANDED phase (chapter from `PHASE_GUIDE[nxt][1]`), on its own line, BEFORE the existing `next:` footer — the footer is unchanged, still exactly one `next:` line + - the fold line states the agent needn't re-run `add.py guide` (a stable cue naming `add.py guide`) + - landing in `done` prints NO guide-fold line — Arm B (the milestone decision) owns that juncture; only the `next:` footer prints + - a bundle fast-forward INTERMEDIATE crossing prints no fold (it returns above the footer already); only the FINAL landing folds — so still exactly one fold line per completing advance +Reject: + - a corrupt/unmapped landed phase (not in PHASE_GUIDE) -> print NO fold line, never a KeyError (fail-soft, mirroring the footer's own ethos) -> "fold_failsoft_skip" +Accept: Given a fresh task at specify, When `add.py advance` lands it in plan, Then stdout carries `guide: .add/docs/05-step-3-plan.md` (plan's chapter) above the single `next:` footer AND names `add.py guide` as the thing not to re-run; and advancing a task INTO done prints only the footer, no `.add/docs/` fold +Boundary: a mid-flight landing (specify/plan/tests/build/verify → folds the landed chapter) vs the `done` landing (no fold, Arm B footer only) — the two shapes the test pins +Assumptions: ⚠ the fold placed right before `print(_next_footer(...))` (after the bundle fast-forward `return`) only ever runs on the FINAL landing — verified by the code path: intermediates `return` above it; if wrong (a fold prints per intermediate): the test's single-fold-line assert catches it, no silent double-print + +--- + +## 3 · PLAN — the change plan: ground · contract · build-strategy + +### Grounding +Touches (files · symbols): `add.py:cmd_advance` — add the guide-fold print right before its final `print(_next_footer(root, state))` (after the bundle fast-forward `return`), guarded `nxt != "done"` and PHASE_GUIDE-present; reuses `PHASE_GUIDE[nxt][1]` (the chapter, the same source `cmd_guide`'s `read:` line uses) +Context (working folder): `add-method/tooling/` (canonical add.py + 4 twins); ENGINE_MD5 re-pinned; SEAMS `_declared_scope` re-pinned if its line drifts (cmd_advance @ ~1638 is ABOVE _declared_scope@5711 → adding ~4 lines shifts it DOWN → expect a re-pin) +Honors (patterns / conventions): `cmd_guide`'s `read : .add/docs/` line (the fold mirrors that exact chapter source, `PHASE_GUIDE[phase][1]`); the footer's own fail-soft ethos (a completed+saved advance NEVER crashes on a render step — `.get(nxt)` guard, no KeyError); the additive-cue convention (present-only, one extra line, footer untouched) +Anchors the contract cites: `cmd_advance` · `_next_footer` · `PHASE_GUIDE` +Ground SHA: 0798bd2 — stamped by freeze + +### Contract + +``` +cmd_advance, on the FINAL landing (past the `_to` bundle fast-forward return), before print(_next_footer(...)): + if nxt != "done": + entry = PHASE_GUIDE.get(nxt) # fail-soft: unmapped phase -> no fold, no KeyError + if entry is not None: + print(f"guide: .add/docs/{entry[1]} — the phase chapter (this + the next line ARE `add.py guide`; no separate call)") + print(_next_footer(root, state)) # UNCHANGED — still exactly one next: line +# done landing: nxt == "done" -> no fold; Arm B footer only +# intermediate bundle crossings: return above this point -> never fold +``` + +`Least-sure flag surfaced at freeze:` [contract] whether the fold belongs in `cmd_advance` (vs inside `_next_footer`) — it must stay in cmd_advance so `_next_footer` remains a PURE single-`next:`-line resolver reused by every other verb (status/gate/new-task); folding inside the footer would leak a chapter line onto surfaces that must print only `next:`. If wrong (some other verb also wants the fold): additive later, never a regression — cmd_advance is the only completing verb that lands an agent INTO a working phase. +Status: FROZEN @ v1 — approved by Tin Dang +### Build-strategy +Scope (may touch): `add-method/tooling/add.py` `.add/tooling/add.py` `add-method/.add/tooling/add.py` `add-method/src/add_method/_bundled/tooling/add.py` `add-method/tooling/engine_pin.py` `.add/tooling/engine_pin.py` `add-method/.add/tooling/engine_pin.py` `add-method/src/add_method/_bundled/tooling/engine_pin.py` `.add/SEAMS.md` `add-method/tooling/test_guide_fold.py` +Strategy & known-problem fixes: 1. RED test_guide_fold (advance specify→plan prints `guide: .add/docs/05-step-3-plan.md` + names `add.py guide`, above ONE `next:` footer; advance INTO done prints no `.add/docs/` fold; still exactly one fold line — no per-intermediate double-print). 2. add the guarded fold print before `_next_footer` in cmd_advance (trap: guard `nxt != "done"` AND `.get(nxt)` fail-soft; place AFTER the fast-forward `return` so intermediates never fold). 3. sync ×4 add.py twins, re-pin ENGINE_MD5, re-pin SEAMS `_declared_scope` if it drifted. +Approach (domain strategy): footer-adjacent chapter fold + +### AI-verify record (required when gate_mode: ai-plan-verify) +- [x] §3 PLAN grounding anchors resolve in the current tree — cmd_advance, _next_footer, PHASE_GUIDE all live in add.py/constants.py +- [x] §1 every Must + every Reject present, each Reject paired with an error code — fold_failsoft_skip on unmapped phase +- [x] §3 Contract shape is concrete (no template placeholder text remains) +- [x] Lowest-confidence flag surfaced and substantive (mirrors unflagged_freeze's own bar) — cmd_advance vs _next_footer placement +Verified by: orchestrator · at: 2026-07-14 + +--- + +## 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 — added the guarded guide-fold `print` in `cmd_advance` right before `print(_next_footer(...))`, after the bundle fast-forward `return` (so intermediates never fold), guarded `nxt != "done"` + `PHASE_GUIDE.get(nxt)` fail-soft. Synced ×4 add.py twins, ENGINE_MD5→c8e0a3e5…; SEAMS `_declared_scope` re-pinned 5711→5723 (the fold + its comment sit above it in cmd_advance). No divergence. +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 — guide-fold 4 green, 94 footer/ergonomics/pin tests green, full fence (pending tail) +- [x] green was EARNED — the 3 fold asserts were RED (0 fold lines) before the cmd_advance print existed; the done-landing assert guards the fold from leaking onto the Arm-B juncture (verify-landing DOES fold, done-landing does NOT — both pinned) +- [x] input dialect held — the test speaks the real CLI stdout dialect (advance stdout lines) + the exact chapter strings PHASE_GUIDE emits +- [x] no exposed secrets/injection/deps — pure static string + a dict lookup (security = HARD-STOP: none) + +Build expectations (from §1 Accept + §3 CONTRACT): a completing `add.py advance` that lands in a non-done phase prints a `guide: .add/docs/` line (chapter = the LANDED phase's, e.g. `05-step-3-plan.md` for specify→plan, `06-step-4-tests.md` for plan→tests) naming `add.py guide` as not-to-re-run, ABOVE the single `next:` footer; advancing INTO done prints only the footer (no `.add/docs/` fold) — confirmed by test_guide_fold (4 asserts) + 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 7216c1ad..084a561c 100644 --- a/add-method/src/add_method/_bundled/tooling/add.py +++ b/add-method/src/add_method/_bundled/tooling/add.py @@ -1761,6 +1761,18 @@ def cmd_advance(args: argparse.Namespace) -> None: if _to is not None and PHASES.index(nxt) < PHASES.index(_to): cmd_advance(args) return + # guide-fold (orientation-honesty): the completing advance carries the LANDED + # phase's chapter — the ONE `add.py guide` line the footer lacks (the footer + # already gives command + short why) — so the agent reads the chapter inline + # and never re-runs `add.py guide`. Suppressed at 'done' (Arm B owns that + # juncture) and on bundle fast-forward intermediates (they return above). The + # `.get` guard is fail-soft: a corrupt/unmapped landed phase folds nothing, + # never a KeyError on an already-saved advance (the footer's own ethos). + if nxt != "done": + _entry = PHASE_GUIDE.get(nxt) + if _entry is not None: + print(f"guide: .add/docs/{_entry[1]} — the phase chapter " + "(this + the next line ARE `add.py guide`; no separate call)") print(_next_footer(root, state)) 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 f1ebc19e..397a963b 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 = "a88bc24c7f1171f3e373c15fb1bf40ac" # re-aimed @ orient-map (orientation-honesty: bare add.py + --help LEAD with a concise flow map (status/init/new-task/advance/freeze/gate) then the full list — kills the 1/rep initial --help orientation dump). prior: 9476543399… @ status-ancestor-warn +ENGINE_MD5 = "c8e0a3e54cad55e79af5b59ee586bfa6" # re-aimed @ orient-map (orientation-honesty: bare add.py + --help LEAD with a concise flow map (status/init/new-task/advance/freeze/gate) then the full list — kills the 1/rep initial --help orientation dump). prior: 9476543399… @ status-ancestor-warn ENGINE_PKG_MD5 = "955023db4358bd3f80a22078bc365361" # re-aimed @ harness-workspace-isolation (io_state.find_root: opt-in ADD_ROOT_CEILING env bounds the upward walk so a nested workspace resolves its own project, not an ancestor). prior: fc40ad47… @ phase-merge-verify diff --git a/add-method/tooling/add.py b/add-method/tooling/add.py index 7216c1ad..084a561c 100644 --- a/add-method/tooling/add.py +++ b/add-method/tooling/add.py @@ -1761,6 +1761,18 @@ def cmd_advance(args: argparse.Namespace) -> None: if _to is not None and PHASES.index(nxt) < PHASES.index(_to): cmd_advance(args) return + # guide-fold (orientation-honesty): the completing advance carries the LANDED + # phase's chapter — the ONE `add.py guide` line the footer lacks (the footer + # already gives command + short why) — so the agent reads the chapter inline + # and never re-runs `add.py guide`. Suppressed at 'done' (Arm B owns that + # juncture) and on bundle fast-forward intermediates (they return above). The + # `.get` guard is fail-soft: a corrupt/unmapped landed phase folds nothing, + # never a KeyError on an already-saved advance (the footer's own ethos). + if nxt != "done": + _entry = PHASE_GUIDE.get(nxt) + if _entry is not None: + print(f"guide: .add/docs/{_entry[1]} — the phase chapter " + "(this + the next line ARE `add.py guide`; no separate call)") print(_next_footer(root, state)) diff --git a/add-method/tooling/engine_pin.py b/add-method/tooling/engine_pin.py index f1ebc19e..397a963b 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 = "a88bc24c7f1171f3e373c15fb1bf40ac" # re-aimed @ orient-map (orientation-honesty: bare add.py + --help LEAD with a concise flow map (status/init/new-task/advance/freeze/gate) then the full list — kills the 1/rep initial --help orientation dump). prior: 9476543399… @ status-ancestor-warn +ENGINE_MD5 = "c8e0a3e54cad55e79af5b59ee586bfa6" # re-aimed @ orient-map (orientation-honesty: bare add.py + --help LEAD with a concise flow map (status/init/new-task/advance/freeze/gate) then the full list — kills the 1/rep initial --help orientation dump). prior: 9476543399… @ status-ancestor-warn ENGINE_PKG_MD5 = "955023db4358bd3f80a22078bc365361" # re-aimed @ harness-workspace-isolation (io_state.find_root: opt-in ADD_ROOT_CEILING env bounds the upward walk so a nested workspace resolves its own project, not an ancestor). prior: fc40ad47… @ phase-merge-verify diff --git a/add-method/tooling/test_guide_fold.py b/add-method/tooling/test_guide_fold.py new file mode 100644 index 00000000..5633eee5 --- /dev/null +++ b/add-method/tooling/test_guide_fold.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Red/green for guide-fold (orientation-honesty, frozen §3 v1). + +A COMPLETING `advance` that lands an agent INTO a working phase folds the landed +phase's guide chapter — the ONE `add.py guide` line the `next:` footer lacks (the +footer already carries command + short why) — as a `guide: .add/docs/` +line right ABOVE the footer, plus a "don't re-run `add.py guide`" cue. This kills +the re-run-guide orientation habit WITHOUT duplicating the footer. Landing in +`done` folds nothing (Arm B owns that juncture); a bundle fast-forward folds only +once (the final landing). + +Render-blind: every assertion reads printed stdout, never internals. +Run: python3 -m unittest test_guide_fold -v +""" +import hashlib +import io +import os +import shutil +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +import add +import engine_pin + +HERE = Path(__file__).resolve().parent # add-method/tooling +ADD_METHOD = HERE.parent +REPO = ADD_METHOD.parent +BUNDLE = ADD_METHOD / "src" / "add_method" / "_bundled" +ADDPY_TRIO = (HERE / "add.py", REPO / ".add" / "tooling" / "add.py", + BUNDLE / "tooling" / "add.py") + + +class _Board(unittest.TestCase): + """A live board arranged through the real CLI (the next-footer idiom).""" + + def setUp(self): + self._cwd = Path.cwd() + self.tmp = Path(tempfile.mkdtemp(prefix="add-guide-fold-")).resolve() + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + self.addCleanup(os.chdir, os.getcwd()) + os.chdir(self.tmp) + self._silent("init", "--name", "demo") + self._silent("new-milestone", "v1", "--title", "T", "--goal", "g") + + def tearDown(self): + os.chdir(self._cwd) + + def _silent(self, *argv): + buf, err = io.StringIO(), io.StringIO() + with redirect_stdout(buf), redirect_stderr(err): + add.main(list(argv)) + return buf.getvalue(), err.getvalue() + + def _run(self, *argv): + buf, err = io.StringIO(), io.StringIO() + code = 0 + try: + with redirect_stdout(buf), redirect_stderr(err): + add.main(list(argv)) + except SystemExit as e: + code = e.code if isinstance(e.code, int) else 1 + return buf.getvalue(), err.getvalue(), code + + def _root(self) -> Path: + return self.tmp / ".add" + + def _task_md(self, slug: str) -> Path: + return self._root() / "tasks" / slug / "TASK.md" + + def _freeze(self, slug: str) -> None: + p = self._task_md(slug) + p.write_text(p.read_text().replace( + "Status: DRAFT", + "Status: FROZEN @ v1 — approved by Tester 2026-07-14.\n" + "Least-sure flag surfaced at freeze: [contract] fixture stub — cost: none", + ), encoding="utf-8") + + def _write_test_file(self, slug: str): + d = self._root() / "tasks" / slug / "tests" + d.mkdir(parents=True, exist_ok=True) + (d / "test_demo.py").write_text( + "def test_one():\n assert 2 + 2 == 4\n", encoding="utf-8") + + @staticmethod + def _fold_lines(out: str) -> list[str]: + return [ln.strip() for ln in out.splitlines() + if ln.strip().startswith("guide:") and ".add/docs/" in ln] + + @staticmethod + def _next_lines(out: str) -> list[str]: + return [ln.strip() for ln in out.splitlines() if ln.strip().startswith("next:")] + + +class GuideFoldTest(_Board): + + def test_advance_folds_landed_phase_chapter(self): + self._silent("new-task", "foo") # active @ specify + out, _, _ = self._run("advance", "foo") # specify -> plan (merged flow) + folds = self._fold_lines(out) + self.assertEqual(len(folds), 1, f"exactly one guide-fold line, got: {folds!r}\n{out}") + self.assertIn(".add/docs/05-step-3-plan.md", folds[0], + "the fold carries the LANDED phase's chapter (plan)") + self.assertIn("add.py guide", folds[0], + "the fold names `add.py guide` as the thing not to re-run") + # the footer is untouched — still exactly one next: line, still below the fold + nxt = self._next_lines(out) + self.assertEqual(len(nxt), 1, f"exactly one next: footer, got {nxt!r}") + self.assertLess(out.index(folds[0]), out.index(nxt[0]), + "the fold prints ABOVE the next: footer") + + def test_fold_tracks_the_phase_it_lands_in(self): + # a second landing folds a DIFFERENT chapter — plan -> tests folds tests' chapter + self._silent("new-task", "bar") + self._silent("advance", "bar") # specify -> plan + self._freeze("bar") + out, _, _ = self._run("advance", "bar") # plan -> tests (frozen cross) + folds = self._fold_lines(out) + self.assertEqual(len(folds), 1, f"one fold, got {folds!r}\n{out}") + self.assertIn(".add/docs/06-step-4-tests.md", folds[0], + "landing in tests folds tests' chapter, not plan's") + + def test_landing_in_done_folds_nothing(self): + # arm a task through to build, then advance build->verify and gate PASS (-> done) + self._silent("new-task", "baz") + self._freeze("baz") + self._write_test_file("baz") + self._silent("phase", "build", "baz") + out_v, _, _ = self._run("advance", "baz") # build -> verify (a fold IS expected here) + self.assertTrue(self._fold_lines(out_v), "verify landing still folds a chapter") + out_done, _, code = self._run("gate", "PASS", "baz") # verify -> done + self.assertEqual(code, 0, out_done) + self.assertEqual(self._fold_lines(out_done), [], + "landing in done folds nothing — Arm B footer owns that juncture") + + +class EnginePinTest(unittest.TestCase): + def test_mirrors_and_pin(self): + digests = {hashlib.md5(p.read_bytes()).hexdigest() for p in ADDPY_TRIO} + self.assertEqual(len(digests), 1, "add.py trio diverged") + self.assertEqual(digests.pop(), engine_pin.ENGINE_MD5, + "engine_pin.ENGINE_MD5 must track the live engine") + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 2e37074c4912c829da9d6a75dee417b1b3d99239 Mon Sep 17 00:00:00 2001 From: Tin Dang Date: Tue, 14 Jul 2026 22:55:48 +0700 Subject: [PATCH 5/5] =?UTF-8?q?docs(method):=20close=20orientation-honesty?= =?UTF-8?q?=20build=20at=204=20tasks=20=E2=80=94=20lever=20C=20pre-shipped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the milestone outcome: 4 built tasks (harness-workspace-isolation, status-ancestor-warn, orient-map, guide-fold) all gate=PASS + green, and lever C (scope-violation-explain) found ALREADY SHIPPED by the prior scope-gate-repair-path work (M1 add.py:1487 prevention, M2 add.py:5907 repair — both explain the rule + emit the paste-ready `re-cross --by`). No redundant 5th task built (human decision 2026-07-14). The milestone GOAL (honest WM1 <=12) stays open on the deferred, human-gated paid re-measure in the isolated harness. author: Tin Dang --- .../orientation-honesty/MILESTONE.md | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/.add/milestones/orientation-honesty/MILESTONE.md b/.add/milestones/orientation-honesty/MILESTONE.md index 1e04bb83..6c543eb3 100644 --- a/.add/milestones/orientation-honesty/MILESTONE.md +++ b/.add/milestones/orientation-honesty/MILESTONE.md @@ -28,34 +28,40 @@ Issues/Risks (shared): the `--help`/next-command hint must stay a HINT, never a - harness workspace-isolation contract -> owning task `harness-workspace-isolation` (the runner boundary that stops root-walk at the workspace; freeze first — the other tasks assume the honest baseline) ## Tasks (breadth-first decomposition; detail lives in each TASK.md) -- [ ] harness-workspace-isolation depends-on: none — the WM runner isolates the workspace from any ancestor `.add/` (root-walk boundary / tmpdir-style setup) so the agent's first `status`/`init` resolves the workspace, not AIDD-Book's parent project; kills the 7-13 startup-confusion cmds/rep at the source. FULL flow (benchmark-enforcement change). -- [ ] status-ancestor-warn depends-on: none — `status`/`init` prints a loud one-line warning when it resolved an ANCESTOR project ("no `.add/` here; using project at — run `init` to scope here"); real-world value for nested/monorepo dirs + defense-in-depth behind the harness fix. Message-layer. -- [ ] next-command-hints depends-on: none — LEVER B: `status`/`advance` emit the fully-formed NEXT command WITH its flags (paste-ready), so the agent stops running ` --help` before first use (5.0 calls/rep, the biggest call lever). Stays a hint, never a gate. Message-layer. -- [ ] guide-fold depends-on: none — LEVER E: `advance` output carries the current phase guide's key hint so the agent stops re-running `add.py guide` for orientation (1-2 calls/rep). Message-layer. -- [ ] scope-violation-explain depends-on: none — LEVER C: the `scope_violation`/return-to-build message explains the resolution rule (declared vs resolved paths) AND emits a paste-ready `re-cross --by ""`, so the agent stops grepping `_in_scope`/`_declared_scope` internals to recover (5-11 spelunking cmds/rep). Message-layer, gate-preserving. +- [x] harness-workspace-isolation depends-on: none — the WM runner isolates the workspace from any ancestor `.add/` (root-walk boundary via opt-in `ADD_ROOT_CEILING`) so the agent's first `status`/`init` resolves the workspace, not AIDD-Book's parent project; kills the 7-13 startup-confusion cmds/rep at the source. FULL flow. **DONE `a87ed1e`** (ENGINE_PKG_MD5→955023db). +- [x] status-ancestor-warn depends-on: none — `status`/`init` prints a loud one-line warning when it resolved an ANCESTOR project; real-world value for nested/monorepo dirs + defense-in-depth behind the harness fix. Message-layer. **DONE `1d9a211`** (ENGINE_MD5→9476543399). +- [x] orient-map (was next-command-hints / LEVER B) depends-on: none — PIVOTED: lever B (next-command-with-flags) was found ~80% already shipped by prior tasks (first-call-ergonomics / skip-error-ergonomics / engine-hint-batch-ops — the sixphase agent already ran the flagged `freeze`/`new-task` forms). The true residual was the bare `add.py`/`--help` 50-choice dump → `_FLOW_MAP` leads the top-parser help + the no-subcommand error. **DONE `0798bd2`** (ENGINE_MD5→a88bc24c). +- [x] guide-fold depends-on: none — LEVER E: a completing `advance` folds in the LANDED phase's guide chapter (`guide: .add/docs/` + "no re-run `add.py guide`" cue) above the footer; non-duplicative (footer already carries command+why). **DONE `91b3371`** (ENGINE_MD5→c8e0a3e5; SEAMS 5711→5723). +- [~] scope-violation-explain depends-on: none — LEVER C: **NOT BUILT — found ALREADY SHIPPED** by the prior scope-gate-repair-path work. M1 (freeze-time prevention, `add.py:1487`) warns on the template-default §5 + names `re-cross --by`; M2 (gate-time repair, `add.py:5907`) explains the full resolution rule + emits the paste-ready `add.py re-cross --by ` + the revert alternative. The code comment at 5902-5905 records this exact fix already killed "the live-benchmark agent source-diving for ~10 turns to discover re-cross" — the precise pain this lever scoped. Human decision 2026-07-14: close at 4 tasks, no redundant 5th. ## Exit criteria (observable; map each to the task that delivers it) -- [ ] In a fresh workspace nested under an ancestor `.add/`, the WM runner's agent resolves the workspace's OWN (absent-then-init'd) project — no root-walk to the parent — verified by a benchmark isolation test (← harness-workspace-isolation) -- [ ] `status` (full path) run in a dir with no local `.add/` but an ancestor `.add/` above prints an ancestor-resolved note naming the resolved path + the `init` remedy (stderr; `--json`/`--brief` stay silent). Scoped to the read command where the confusion fired — `init` creates at cwd, resolving no ancestor (← status-ancestor-warn) -- [ ] `status` and `advance` output contains the exact next command with its required flags (e.g. `add.py freeze --by "" --cross`), copy-pasteable — pinned by a test asserting the flagged form (← next-command-hints) -- [ ] `advance` output carries the destination phase's key guide hint, so orientation needs no separate `guide` call — pinned by a test (← guide-fold) -- [ ] a `scope_violation` return-to-build prints the declared-vs-resolved paths AND a paste-ready `re-cross` line — pinned by a test (← scope-violation-explain) -- [ ] (paid, human-gated) WM1 re-measure IN THE ISOLATED HARNESS: calls <= 12 mean, fidelity >= 0.97 held, `--help` flag-probes <= 1/rep, zero startup root-walk confusion in transcripts +- [x] In a fresh workspace nested under an ancestor `.add/`, the WM runner's agent resolves the workspace's OWN (absent-then-init'd) project — no root-walk to the parent — verified by a benchmark isolation test (← harness-workspace-isolation: `benchmark/tests/test_workspace_isolation.py`) +- [x] `status` (full path) run in a dir with no local `.add/` but an ancestor `.add/` above prints an ancestor-resolved note naming the resolved path + the `init` remedy (stderr; `--json`/`--brief` stay silent) (← status-ancestor-warn: `test_status_ancestor_warn.py`) +- [x] bare `add.py`/`--help` LEAD with the concise flow map (status/init/new-task/advance/freeze/gate) then the full list, instead of the 50-choice dump — pinned by a test (REPLACES the lever-B criterion; lever B's flagged next-command form was already shipped pre-milestone) (← orient-map: `test_orient_map.py`) +- [x] a completing `advance` carries the LANDED phase's guide chapter above the footer, so orientation needs no separate `guide` call — pinned by a test (← guide-fold: `test_guide_fold.py`) +- [x] a `scope_violation` return-to-build explains the resolution rule AND emits a paste-ready `re-cross --by` line — ALREADY SHIPPED by scope-gate-repair-path M1+M2 (`add.py:1487`, `add.py:5907`); pinned by the existing scope-gate tests (← scope-violation-explain: pre-shipped, not re-built) +- [ ] (paid, human-gated) WM1 re-measure IN THE ISOLATED HARNESS: calls <= 12 mean, fidelity >= 0.97 held, `--help` flag-probes <= 1/rep, zero startup root-walk confusion in transcripts (← DEFERRED: the human-gated spend, run after this PR merges) ## Close — ship review (AI fills when every task is done) > Whole-milestone, cross-task review the AI fills in. Evidence behind the EXISTING engine gate. ### Ship by domain (what changed, per bounded context) -- tooling : -- skill : -- book : +- tooling : `add_engine/io_state.py` `find_root` gained opt-in `ADD_ROOT_CEILING` (bounded upward walk); `add.py` gained `_ancestor_note` (status ancestor warn), `_FLOW_MAP` + `_AddArgParser.format_help`/`error` (orient-map), the guide-fold print in `cmd_advance`. ×4 twins synced; ENGINE_PKG_MD5→955023db, ENGINE_MD5→c8e0a3e5; SEAMS `_declared_scope` 5688→5711→5723. Lever C (scope_violation explain) untouched — already shipped. +- benchmark : `runner/core.py` `_invoke_once` sets `ADD_ROOT_CEILING=` so each WM workspace resolves its own project, not AIDD-Book's parent — the honest baseline. +- skill : untouched (no guide hint needed a doc mirror; guide-fold points at existing chapters). +- book : untouched. ### Cross-task evidence (one row per task) -- : gate= · tests= · residue= +- harness-workspace-isolation : gate=PASS · tests=green (test_findroot_ceiling + benchmark/tests/test_workspace_isolation) · residue=none +- status-ancestor-warn : gate=PASS · tests=green (test_status_ancestor_warn) · residue=none +- orient-map : gate=PASS · tests=green (test_orient_map, 4 asserts) · residue=none +- guide-fold : gate=PASS · tests=green (test_guide_fold, 4 asserts; full fence 3611 pass, the lone bundle-parity fail was a gitignored .pyc from the run) · residue=none +- scope-violation-explain : PRE-SHIPPED (scope-gate-repair-path M1+M2) · no new code · human-accepted 2026-07-14 ### Goal met? -- [ ] each Exit criterion above is satisfied by a Cross-task evidence row or a Ship-by-domain change (cite which) -- goal: reach an honest WM1 <=12 — the isolated-harness re-measure line is the proof. +- [x] each built Exit criterion is satisfied by a Cross-task evidence row (cited above); lever C's criterion is met by pre-shipped code (scope-gate-repair-path M1+M2) +- [ ] goal: reach an honest WM1 <=12 — the isolated-harness re-measure line is the proof — DEFERRED to the human-gated paid re-measure after this PR merges. +- Human decision 2026-07-14: close the BUILD at 4 tasks (lever C redundant); the milestone GOAL stays open on the deferred paid re-measure. ## Release steps (AI-DEFINED — engine records, human gate) - [ ] open a PR from the Close ship-review above; the human reviews + merges