@pilotspace/add 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tooling/add.py CHANGED
@@ -25,250 +25,107 @@ import urllib.request
25
25
  from datetime import date, datetime, timedelta, timezone
26
26
  from pathlib import Path
27
27
 
28
- # --- constants ---------------------------------------------------------------
29
-
30
- ROOT_DIRNAME = ".add"
31
- STATE_FILE = "state.json"
32
- MILESTONE_FILE = "MILESTONE.md"
33
- # The project GOAL (v20) is read live from PROJECT.mdnever copied into state.json
34
- # (single-source; the foundation is the truth). A missing/blank source degrades to
35
- # this sentinel so the read-only orientation surfaces never blank or crash.
36
- GOAL_UNSET = "(unset — add a 'goal:' line to PROJECT.md)"
37
- STAGES = ("prototype", "poc", "mvp", "production")
38
- # v22 stage-graduation: the read-only cue `status` shows when the MVP is covered.
39
- # Worded as the ACTION (never a file) so it stands before graduate.md exists.
40
- GRADUATION_CUE = "MVP covered propose graduation"
41
- # release-altitude: the read-only cue `status` shows when ≥1 closed milestone is
42
- # unreleased. The 5th scope level (release.md). `{n}` is filled at print time; the
43
- # wording matches SKILL.md's "Beyond the bundle" cross-ref byte-for-byte.
44
- RELEASABLE_CUE = "releasable: {n} milestone(s) closed since last release"
45
- # the append-only release ledger lives at the PROJECT ROOT (the dir containing .add/),
46
- # a sibling of CHANGELOG.md — NOT inside .add/. The ledger IS the attribution source:
47
- # a milestone is "released" iff its slug appears on a `milestones:` row.
48
- RELEASES_FILE = "RELEASES.md"
49
- PHASES = ("ground", "specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done")
50
- GATES = ("none", "PASS", "RISK-ACCEPTED", "HARD-STOP")
51
- # heal-then-escalate (verify-integrity): the bounded self-heal loop cap. A CONFIRMED cheat
52
- # (mechanical tripwire divergence, or an agent-reported semantic refute-read finding) returns
53
- # the task to BUILD for an honest redo; after HEAL_CAP such attempts the next confirmed cheat
54
- # forces a HARD-STOP escalation to the human. MONOTONIC — attempts never auto-resets (a gamed
55
- # green is never auto-passed; the loop is never unbounded).
56
- HEAL_CAP = 3
28
+ try: # component-aware-add registry parse (Python 3.11+ stdlib)
29
+ import tomllib
30
+ except ModuleNotFoundError: # < 3.11: the registry is unsupported → degrade to opt-out
31
+ tomllib = None
32
+
33
+ # --- constants (moved to add_engine/constants.pyengine-modularization) ----
34
+ from add_engine.constants import * # noqa: F401,F403 (public constants via __all__)
35
+ from add_engine.constants import ( # the _-prefixed names (import * skips them)
36
+ _GITIGNORE_BODY, _GUIDE_BEGIN, _GUIDE_END,
37
+ _RULE_REF_LINE, _FALLBACK_TASK, _FALLBACK_TASK_FAST,
38
+ _DEFAULT_WIDTH,
39
+ _DELTA_RE, _EVIDENCE_RE, _SPEC_DELTA_RE, # shared delta regexes (taskdoc + deltas-web lint)
40
+ _AUTONOMY_LEVELS, # shared (autonomy resolvers + _AUTONOMY_ORDER/cmd_autonomy)
41
+ )
57
42
 
43
+ # --- terminal-render primitives (moved to add_engine/render.py) -------------
44
+ from add_engine.render import (
45
+ _bar, _phase_track, _use_ascii, _color_enabled, _term_width, _colorize, _clip, _wrap,
46
+ )
58
47
 
59
- def _phase_index(name: str) -> int:
60
- """Ordinal of a phase in PHASES; used to enforce forward-skip rules."""
61
- return PHASES.index(name)
48
+ # --- milestone-doc readers (moved to add_engine/milestones.py) --------------
49
+ from add_engine.milestones import (
50
+ _has_production_roadmap, _project_goal, _milestone_doc, _exit_criteria,
51
+ _exit_criteria_cited, _stage_criteria, _all_milestones_done,
52
+ )
62
53
 
63
- # `add.py guide` copy: per-phase (concrete next action, book chapter to read).
64
- # Keep the action wording aligned with each phase's EXIT line in the TASK template.
65
- PHASE_GUIDE = {
66
- "ground": ("gather the real codebase the task touches — files, symbols, signatures, conventions, and the anchor points the contract will cite; defer to PROJECT.md/CONVENTIONS.md and gather only the task delta",
67
- "02-the-flow.md"),
68
- "specify": ("state every rule — Must / Reject (+ named code) / After; rank assumptions lowest-confidence first and flag the biggest risk",
69
- "03-step-1-specify.md"),
70
- "scenarios": ("write one Given/When/Then per Must AND per Reject; every result observable",
71
- "04-step-2-scenarios.md"),
72
- "contract": ("freeze the shape — signature, fields, error codes; names match the glossary",
73
- "05-step-3-contract.md"),
74
- "tests": ("write one failing test per scenario; run them RED for the right reason",
75
- "06-step-4-tests.md"),
76
- "build": ("write the minimum code to pass the tests; change no test and no contract",
77
- "07-step-5-build.md"),
78
- "verify": ("run the suite + non-functional checks, then record the gate",
79
- "08-step-6-verify.md"),
80
- "observe": ("note what to watch + the spec delta for the next loop",
81
- "09-the-loop.md"),
82
- "done": ("this task is done — pick the next feature",
83
- "02-the-flow.md"),
84
- }
85
- # Phase -> who owns it, for the `--json` autonomy signal. An autonomous harness may run a
86
- # phase only when owner=="ai" (stop is false); every other phase is a checkpoint. The map
87
- # follows the book's who-does-what table (Verify is "human only"); `tests`/`build`/`observe`
88
- # are AI-led. A phase missing here is `unmapped_phase` (fail closed) — never defaulted.
89
- PHASE_OWNER = {
90
- "ground": "ai",
91
- "specify": "human", "scenarios": "human", "contract": "seam",
92
- "tests": "ai", "build": "ai", "verify": "human", "observe": "ai", "done": "human",
93
- }
94
- SETUP_FILES = ("PROJECT.md", "CONVENTIONS.md", "GLOSSARY.md", "MODEL_REGISTRY.md", "dependencies.allowlist", "DESIGN.md", "SOUL.md")
95
-
96
- # Scaffolded into .add/.gitignore at init so the engine's transient LOCAL artifacts
97
- # never reach git. Bare-filename patterns match at any depth under .add/ (tasks/,
98
- # milestones/, archive/). These are working state, not records: scope-snapshot.json
99
- # is the tests->build touch baseline the verify scope-gate reads from disk (the
100
- # durable scope declaration is the state.json anchor); pre-archive-state.bak.json is
101
- # archive-milestone's pre-delete recovery net — needed on disk, never in history;
102
- # .update-cache.json is the update-nudge's once-a-day registry throttle. All stay on
103
- # disk; git-ignoring them is hygiene, never deletion.
104
- _GITIGNORE_BODY = """\
105
- # ADD engine transient artifacts — local working state, never committed.
106
- # (Scaffolded by `add.py init`; edit freely — init never clobbers an existing copy.)
107
- scope-snapshot.json
108
- pre-archive-state.bak.json
109
- .update-cache.json
110
- """
54
+ # --- component/federation subsystem (moved to add_engine/components.py) ------
55
+ from add_engine.components import (
56
+ _confined, _components, _cite_region, _contracts, _federation,
57
+ _contract_snapshot, _in_scope,
58
+ )
111
59
 
112
- # Guideline-injection targets + version-stable markers. NEVER change these marker
113
- # strings: a re-run finds the old block by exact match, so changing them would
114
- # orphan every block written by a prior version (see TASK guideline-inject).
115
- GUIDELINE_FILES = ("AGENTS.md", "CLAUDE.md")
116
- _GUIDE_BEGIN = "<!-- ADD:BEGIN — managed by `add.py sync-guidelines`; do not edit inside -->"
117
- _GUIDE_END = "<!-- ADD:END -->"
118
-
119
- # Minimal embedded fallback so the tool still works if templates/ is missing
120
- # (circuit breaker: never hard-fail just because a template file was deleted).
121
- _FALLBACK_TASK = """# TASK: {title}
122
-
123
- slug: {slug} · created: {date} · stage: {stage}
124
- autonomy: auto
125
- phase: ground
126
-
127
- ## 0 · GROUND
128
- Touches (files · symbols · signatures):
129
- Honors (patterns / conventions):
130
- Anchors the contract cites:
131
-
132
- ## 1 · SPECIFY
133
- Feature:
134
- Framings weighed:
135
- Must:
136
- Reject:
137
- After:
138
- Assumptions — lowest-confidence first:
139
- ⚠ <most likely wrong> — lowest confidence because <why>; if wrong: <cost>
140
-
141
- ## 2 · SCENARIOS
142
- ## 3 · CONTRACT
143
- Status: DRAFT
144
- ## 4 · TESTS
145
- ## 5 · BUILD
146
- ## 6 · VERIFY
147
- ### GATE RECORD
148
- Outcome:
149
- ## 7 · OBSERVE
150
- ### Spec delta
151
- ### Competency deltas
152
- """
60
+ # --- update-nudge version helpers (moved to add_engine/version.py) ----------
61
+ from add_engine.version import (
62
+ _read_json_safe, _version_gt, _fetch_latest_version,
63
+ )
153
64
 
65
+ # --- changelog/RELEASES render helpers (moved to add_engine/release.py) -----
66
+ from add_engine.release import (
67
+ _releases_path, _closed_milestones, _key_decisions_for,
68
+ _build_in_flight, _render_changelog_block, _render_releases_row,
69
+ )
70
+
71
+ # --- TASK.md structural readers (moved to add_engine/taskdoc.py) ------------
72
+ from add_engine.taskdoc import (
73
+ _task_header, _count_test_defs, _primary_test_files, _tests_count,
74
+ _declared_test_files, _declared_tests_count, _tests_info, _task_prose,
75
+ _phase_spans, _raw_phase_bodies, _spec_delta_entries,
76
+ )
77
+
78
+ # --- autonomy-level resolvers (moved to add_engine/autonomy.py) -------------
79
+ from add_engine.autonomy import (
80
+ _autonomy_level, _effective_autonomy, _project_autonomy, _project_autonomy_token,
81
+ )
154
82
 
155
- # Fast-lane fallback: the minimal TASK.md variant (sections {0,1,3,4,5,6}; §2 + §7 dropped).
156
- # Mirrors templates/TASK.fast.md.tmpl's section set (circuit-breaker parity); a deleted
157
- # templates/ never hard-fails the fast lane. Keeps the trust floor: §3 freeze-flag + Status,
158
- # §6 GATE RECORD/Outcome, §0 Anchors, §4 red-before-build, §5 Scope.
159
- _FALLBACK_TASK_FAST = """# TASK: {title}
160
-
161
- slug: {slug} · created: {date} · stage: {stage}
162
- autonomy: auto
163
- phase: ground
164
- fast: true
165
-
166
- ## 0 · GROUND
167
- Touches (files · symbols):
168
- Anchors the contract cites:
169
-
170
- ## 1 · SPECIFY
171
- Feature:
172
- Must:
173
- Reject:
174
- Accept:
175
- Assumptions: ⚠ <most likely wrong> — why; if wrong: <cost>
176
-
177
- ## 3 · CONTRACT
178
- Least-sure flag surfaced at freeze:
179
- Status: DRAFT
180
-
181
- ## 4 · TESTS
182
- Plan:
183
- Tests live in: `./tests/` · MUST run red before Build.
184
-
185
- ## 5 · BUILD
186
- Scope (may touch): `./src/`
187
-
188
- ## 6 · VERIFY
189
- Build expectations (from §1 Accept + §3 CONTRACT):
190
- ### GATE RECORD
191
- Outcome:
192
- Reviewed by:
193
- """
194
83
 
84
+ def _phase_index(name: str) -> int:
85
+ """Ordinal of a phase in PHASES; used to enforce forward-skip rules."""
86
+ return PHASES.index(name)
195
87
 
196
- # --- low-level IO (designed for failure: atomic, no silent clobber) ----------
88
+ # --- low-level IO (moved to add_engine/io_state.py engine-modularization) -
89
+ from add_engine.io_state import ( # re-exported as module globals: callers use bare
90
+ _now, _atomic_write, _atomic_write_bytes, _atomic_write_many, # names so patches
91
+ find_root, _require_root, _migrate_state, _state_text_or_die, # on add.<name>
92
+ _die, # still resolve;
93
+ _CONFLICT_MARKER_RE, # conflict-marker re
94
+ _load_state_for_json, # --json state loader
95
+ _md5_text, _md5_file, # md5 hashing helpers
96
+ )
197
97
 
198
- def _now() -> str:
199
- return datetime.now(timezone.utc).isoformat(timespec="seconds")
200
98
 
99
+ # --- active milestone/task accessors (moved to add_engine/accessors.py) -------
100
+ from add_engine.accessors import (
101
+ _active_milestone, _active_task, _set_active_milestone,
102
+ _set_active_task, _activate_milestone, _deactivate_milestone,
103
+ )
201
104
 
202
- def _atomic_write(path: Path, text: str) -> None:
203
- """Write via a temp file in the same dir, then atomically replace.
105
+ # --- state load/save (KEPT in add.py: write-path pinned by add._atomic_write tests) -
204
106
 
205
- Avoids a half-written file if the process dies mid-write.
206
- """
207
- path.parent.mkdir(parents=True, exist_ok=True)
208
- fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
107
+ def load_state(root: Path) -> dict:
108
+ """Load + parse state.json, failing CLOSED. A git-conflicted file dies with a merge-specific
109
+ 'state_conflicted'; any other corrupt/unreadable file dies with a clean 'state_invalid'
110
+ message (never a raw traceback), so every command that loads state degrades gracefully
111
+ (design-for-failure). The parsed state is forward-migrated to the multi-active schema."""
209
112
  try:
210
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
211
- fh.write(text)
212
- os.replace(tmp, path)
213
- finally:
214
- if os.path.exists(tmp):
215
- os.unlink(tmp)
216
-
217
-
218
- def _atomic_write_many(writes: list[tuple[Path, str]]) -> None:
219
- """True all-or-nothing commit across N files — design-for-failure for a multi-file write.
220
-
221
- Phase 1 STAGES every (path, text) to a sibling `.tmp`, flushing + fsync-ing each, so the
222
- realistic IO failures (disk full, permission denied) surface HERE, before any target changes —
223
- and on any stage failure every staged temp is removed, so NOTHING is committed. Phase 2 then
224
- COMMITS each file by renaming any existing target ASIDE to a sibling `.bak`, then `os.replace`-ing
225
- the staged `.tmp` into place. If ANY commit rename raises, every file already committed is rolled
226
- back IN REVERSE (remove the landed new file, rename its `.bak` back, or leave it absent) and the
227
- original error re-raised — so the whole set is all-or-nothing: either every file holds its new
228
- text, or every file holds its prior content. Restoring is an atomic rename of an already-written
229
- `.bak` (no content held in memory, no re-write), the cheapest recovery under failing IO. Leftover
230
- `.tmp`/`.bak` siblings are removed on every exit path.
231
- """
232
- staged: list[tuple[str, Path]] = []
233
- committed: list[list] = [] # [path, bak_or_None, new_landed] per committed file
113
+ return _migrate_state(json.loads(_state_text_or_die(root)))
114
+ except (json.JSONDecodeError, OSError) as e:
115
+ _die(f"state_invalid: {root / STATE_FILE} is corrupt or unreadable "
116
+ f"({e.__class__.__name__}) — restore it from git or a backup")
117
+
118
+
119
+ def save_state(root: Path, state: dict) -> None:
120
+ state["updated"] = _now()
234
121
  try:
235
- for path, text in writes: # phase 1: stage every temp (fsync'd before any commit)
236
- path.parent.mkdir(parents=True, exist_ok=True)
237
- fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
238
- staged.append((tmp, path)) # track BEFORE write so a write/fsync failure still cleans it
239
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
240
- fh.write(text)
241
- fh.flush()
242
- os.fsync(fh.fileno())
243
- try:
244
- for tmp, path in staged: # phase 2: commit via rename-aside
245
- bak = None
246
- existed = path.exists()
247
- if existed:
248
- fd2, bak = tempfile.mkstemp(dir=str(path.parent), suffix=".bak")
249
- os.close(fd2)
250
- committed.append([path, bak, False]) # track .bak NOW so cleanup never leaks it
251
- if existed:
252
- os.replace(path, bak) # move the existing target aside
253
- os.replace(tmp, path) # move the new file in
254
- committed[-1][2] = True
255
- except OSError:
256
- for path, bak, landed in reversed(committed): # roll back, newest-first
257
- try:
258
- if landed and path.exists():
259
- os.unlink(path) # drop the new file we put in
260
- if bak is not None and not path.exists():
261
- os.replace(bak, path) # restore the prior target (atomic rename)
262
- except OSError:
263
- pass # best-effort restore under already-failing IO
264
- raise
265
- finally:
266
- for tmp, _ in staged: # leftover .tmp (failed/aborted stage) never persists
267
- if os.path.exists(tmp):
268
- os.unlink(tmp)
269
- for path, bak, landed in committed: # leftover .bak (success, or orphaned post-rollback)
270
- if bak is not None and os.path.exists(bak):
271
- os.unlink(bak)
122
+ _atomic_write(root / STATE_FILE, json.dumps(state, indent=2) + "\n")
123
+ except OSError as e:
124
+ # Fail CLOSED like load_state: a named, recoverable error — never a raw traceback. The
125
+ # atomic temp+replace leaves the prior state.json byte-unchanged, so it is safe to retry.
126
+ _die(f"state_write_failed: could not write {root / STATE_FILE} "
127
+ f"({e.__class__.__name__}) — the prior state.json is intact; "
128
+ "free disk / fix permissions and re-run")
272
129
 
273
130
 
274
131
  def _templates_dir() -> Path:
@@ -293,206 +150,18 @@ def _render_template(name: str, **subs: str) -> str:
293
150
  text = text.replace("{{" + key + "}}", val)
294
151
  return text
295
152
 
153
+ # --- state/markdown predicates (moved to add_engine/predicates.py) -----------
154
+ from add_engine.predicates import (
155
+ _phase_owner, _setup_locked, _milestone_confirmed, _section_unfilled,
156
+ _task_done,
157
+ )
296
158
 
297
- # --- state -------------------------------------------------------------------
298
-
299
- def find_root(start: Path | None = None) -> Path | None:
300
- """Walk up from cwd to find a .add/ project root."""
301
- cur = (start or Path.cwd()).resolve()
302
- for d in (cur, *cur.parents):
303
- if (d / ROOT_DIRNAME / STATE_FILE).exists():
304
- return d / ROOT_DIRNAME
305
- return None
306
-
307
-
308
- def _require_root() -> Path:
309
- root = find_root()
310
- if root is None:
311
- _die("no .add/ project found. Run `add.py init` first.")
312
- return root
313
-
314
-
315
- def _migrate_state(state: dict) -> dict:
316
- """Forward-migrate a single-active state to the multi-active schema (team-collaboration
317
- foundation). PURE · idempotent · TOTAL · never raises · no I/O.
318
-
319
- A state lacking the `active_milestones` key gains it — DERIVED from the scalar
320
- `active_milestone` (grandfather-by-missing-key, mirroring `_setup_locked`): None -> [],
321
- "x" -> ["x"]. A per-milestone active-task map `active_tasks` is added; the old global
322
- `active_task` is placed under its owning active milestone only when it genuinely belongs
323
- there, else it stays as the top-level scalar fallback (orphan rule, FROZEN decision (a)).
324
- The scalar `active_milestone` / `active_task` keys are KEPT as the N<=1 mirror so the
325
- not-yet-routed readers keep working. An already-migrated state (key present) is returned
326
- unchanged — never re-derived, never clobbered. Corrupt parsing stays the loader's job.
327
-
328
- PURE in the observable sense: the caller's dict is NEVER mutated — a state that needs
329
- migrating is upgraded on a fresh top-level copy (nested objects are shared but only read)."""
330
- if not isinstance(state, dict) or "active_milestones" in state:
331
- return state
332
- migrated = dict(state)
333
- active_ms = migrated.get("active_milestone")
334
- migrated["active_milestones"] = [] if active_ms is None else [active_ms]
335
- active_task = migrated.get("active_task")
336
- tasks = migrated.get("tasks") or {}
337
- owns = (active_ms is not None and active_task is not None
338
- and isinstance(tasks.get(active_task), dict)
339
- and tasks[active_task].get("milestone") == active_ms)
340
- migrated["active_tasks"] = {active_ms: active_task} if owns else {}
341
- return migrated
342
-
343
-
344
- # --- active milestone/task accessor seam (multi-active foundation) -----------
345
- #
346
- # Every engine call site reads & writes the active milestone(s)/task through these
347
- # four helpers, so multi-active SEMANTICS can land in one place later. Today they
348
- # preserve single-active behavior exactly: reads return the scalar mirror, writes
349
- # keep the scalar AND the new active_milestones/active_tasks structures in sync.
350
-
351
- def _active_milestone(state: dict) -> str | None:
352
- """The primary active milestone — the N<=1 scalar mirror (== active_milestones[0])."""
353
- return state.get("active_milestone")
354
-
355
-
356
- def _active_task(state: dict, milestone: str | None = None) -> str | None:
357
- """The active task: per-milestone when `milestone` is given (partial-state -> None),
358
- else the global/primary scalar active task. Total — never raises."""
359
- if milestone is None:
360
- return state.get("active_task")
361
- return (state.get("active_tasks") or {}).get(milestone)
362
-
363
-
364
- def _set_active_milestone(state: dict, slug: str | None) -> None:
365
- """Set the primary active milestone, keeping `active_milestones` consistent (N<=1 sync)."""
366
- state["active_milestone"] = slug
367
- state["active_milestones"] = [] if slug is None else [slug]
368
-
369
-
370
- def _set_active_task(state: dict, slug: str | None, milestone: str | None = None) -> None:
371
- """Set the active task, keeping the scalar mirror AND the per-milestone map in sync.
372
- With no owning active milestone the active task is scalar-only (the migration's orphan
373
- rule); clearing (slug is None) pops the milestone's entry."""
374
- state["active_task"] = slug
375
- ms = milestone if milestone is not None else _active_milestone(state)
376
- tasks_map = state.setdefault("active_tasks", {})
377
- if ms is None:
378
- return
379
- if slug is None:
380
- tasks_map.pop(ms, None)
381
- else:
382
- tasks_map[ms] = slug
383
-
384
-
385
- def _activate_milestone(state: dict, slug: str) -> None:
386
- """Add a milestone to the active SET (idempotent) and make it the primary focus,
387
- syncing the scalar active_task to that milestone's entry. Does NOT remove other members
388
- (this is how a user reaches N>=2 active milestones)."""
389
- ms_list = state.setdefault("active_milestones", [])
390
- if slug not in ms_list:
391
- ms_list.append(slug)
392
- state["active_milestone"] = slug
393
- state["active_task"] = (state.get("active_tasks") or {}).get(slug)
394
-
395
-
396
- def _deactivate_milestone(state: dict, slug: str) -> None:
397
- """Remove a milestone from the active SET, pop its active-task entry, and (if it was the
398
- primary) repoint the primary to the most-recent remaining member (or None when empty)."""
399
- ms_list = state.setdefault("active_milestones", [])
400
- if slug in ms_list:
401
- ms_list.remove(slug)
402
- (state.setdefault("active_tasks", {})).pop(slug, None)
403
- if state.get("active_milestone") == slug:
404
- new = ms_list[-1] if ms_list else None
405
- state["active_milestone"] = new
406
- state["active_task"] = (state.get("active_tasks") or {}).get(new) if new else None
407
-
408
-
409
- def _git_config(key: str) -> str | None:
410
- """Read one `git config --get <key>`, STRICTLY fail-soft: the engine's FIRST git call,
411
- so it never raises, never hangs, never shells. Returns the trimmed value, or None when
412
- git is absent / errors / times out / the value is empty."""
413
- if shutil.which("git") is None:
414
- return None
415
- try:
416
- out = subprocess.run(
417
- ["git", "config", "--get", key],
418
- capture_output=True, text=True, timeout=2,
419
- ).stdout.strip()
420
- except (OSError, subprocess.SubprocessError, ValueError):
421
- # OSError: git vanished between which() and run() / spawn error · SubprocessError:
422
- # TimeoutExpired · ValueError: a non-UTF-8 config value (latin-1 legacy name) makes
423
- # text=True decoding raise UnicodeDecodeError (a ValueError) — all fail soft to None.
424
- return None
425
- return out or None
426
-
427
-
428
- def _os_user() -> str:
429
- """The guaranteed non-empty OS floor. getpass.getuser() reads LOGNAME/USER/... then
430
- falls back to the passwd database — but in a bare container (no env var AND no passwd
431
- entry) CPython raises KeyError (OSError only on 3.13+). Catch broadly and return a
432
- sentinel so _whoami stays TOTAL: it always yields a non-empty name, never crashes."""
433
- try:
434
- return getpass.getuser() or "unknown"
435
- except (KeyError, OSError):
436
- return "unknown"
437
-
438
-
439
- def _whoami(state: dict) -> dict:
440
- """Resolve the current git-native ACTOR -> {name, email, source}. Priority:
441
- (1) an `actor_override` (whoami --set) with a non-blank name -> source 'override';
442
- (2) `git config user.name`/`user.email` -> source 'git';
443
- (3) the OS user (_os_user) -> source 'os', the guaranteed non-empty floor.
444
- Total: always returns a dict with a non-empty name; `email` may be None."""
445
- ov = state.get("actor_override")
446
- if ov and (ov.get("name") or "").strip():
447
- return {"name": ov["name"], "email": ov.get("email"), "source": "override"}
448
- name = _git_config("user.name")
449
- if name:
450
- return {"name": name, "email": _git_config("user.email"), "source": "git"}
451
- return {"name": _os_user(), "email": None, "source": "os"}
452
-
453
-
454
- def _actor_stamp(state: dict) -> dict:
455
- """The SINGLE source of the structured-actor stamp every engine-WRITTEN human action
456
- records — lock · gate · milestone-done · release (user-identity actor-stamping). It IS
457
- `_whoami(state)`: a TOTAL {name,email,source} (always a non-empty name), so a stamp can
458
- never fail or block a write. Descriptive only — no command's decision reads it."""
459
- return _whoami(state)
460
-
461
-
462
- def _render_actor_line(state: dict) -> str:
463
- """Render the actor stamp as one human-readable line: name, an optional angle-bracketed
464
- email, then the source in parens — used on the RELEASES.md row (no state.json write)."""
465
- a = _actor_stamp(state)
466
- email = f" <{a['email']}>" if a.get("email") else ""
467
- return f"{a['name']}{email} ({a['source']})"
468
-
469
-
470
- def _parse_actor_arg(s: str) -> dict:
471
- """Parse an `assign --owner`/`--assignee` value into a {name, email, source: "assigned"}
472
- actor (ownership-assignment). "Name <email>" -> both; a bare "Name" -> email None. TOTAL:
473
- a malformed value (no closing bracket) never raises — the whole stripped string is the name.
474
- `source` is "assigned" — a human typed this name (not git-resolved nor an ADD override)."""
475
- m = re.match(r"^\s*(.*?)\s*<([^>]*)>\s*$", s)
476
- if m:
477
- return {"name": m.group(1), "email": m.group(2) or None, "source": "assigned"}
478
- return {"name": s.strip(), "email": None, "source": "assigned"}
479
-
480
-
481
- def _actor_matches(rec_actor: dict | None, me: dict) -> bool:
482
- """Does a recorded owner/assignee actor identify the SAME person as `me` (multi-active-UX)?
483
- Email-first (the stabler key): when BOTH carry a non-empty email, emails decide; otherwise
484
- fall back to name-equality. Both comparisons are stripped + case-insensitive. TOTAL — a None,
485
- non-dict, or blank-name record returns False (an unowned/garbage slot is no one's)."""
486
- if not isinstance(rec_actor, dict):
487
- return False
488
- rec_name = (rec_actor.get("name") or "").strip()
489
- if not rec_name:
490
- return False
491
- rec_email = (rec_actor.get("email") or "").strip()
492
- me_email = (me.get("email") or "").strip()
493
- if rec_email and me_email:
494
- return rec_email.lower() == me_email.lower()
495
- return rec_name.lower() == (me.get("name") or "").strip().lower()
159
+ # --- git-native identity/actor seam (moved to add_engine/identity.py) --------
160
+ from add_engine import identity # qualified calls: identity._whoami(...)
161
+ from add_engine.identity import ( # re-exported for `add.<name>` attr compat
162
+ _git_config, _os_user, _whoami, _actor_stamp,
163
+ _render_actor_line, _parse_actor_arg, _actor_matches,
164
+ )
496
165
 
497
166
 
498
167
  def _my_work(state: dict, me: dict) -> list[dict]:
@@ -506,8 +175,8 @@ def _my_work(state: dict, me: dict) -> list[dict]:
506
175
  for slug, t in tasks.items():
507
176
  if not isinstance(t, dict) or t.get("milestone") not in active_set or _task_done(t):
508
177
  continue
509
- owns = _actor_matches(t.get("owner"), me)
510
- assigned = _actor_matches(t.get("assignee"), me)
178
+ owns = identity._actor_matches(t.get("owner"), me)
179
+ assigned = identity._actor_matches(t.get("assignee"), me)
511
180
  if not (owns or assigned):
512
181
  continue
513
182
  role = "both" if owns and assigned else ("owner" if owns else "assignee")
@@ -521,106 +190,6 @@ def _my_work(state: dict, me: dict) -> list[dict]:
521
190
  # A git conflict marker BEGINS a line with 7 of `<`, `=`, or `>` (`(?m)^…`). An unresolved
522
191
  # merge writes these into state.json, making it invalid JSON; the line-anchor keeps a
523
192
  # legitimate value (always on an INDENTED JSON line) from false-tripping the guard.
524
- _CONFLICT_MARKER_RE = re.compile(r"(?m)^(<{7}|={7}|>{7})")
525
-
526
-
527
- def _state_text_or_die(root: Path) -> str:
528
- """Read state.json's raw text, failing CLOSED with a merge-SPECIFIC `state_conflicted`
529
- message when it carries git conflict markers (an unresolved merge — the major's #1 failure
530
- mode). A genuine read OSError is NOT swallowed: it propagates to the caller, which maps it
531
- to its own existing code (state_invalid / no_state). The guard only READS — never writes."""
532
- text = (root / STATE_FILE).read_text(encoding="utf-8")
533
- if _CONFLICT_MARKER_RE.search(text):
534
- _die(f"state_conflicted: {root / STATE_FILE} has unresolved git merge markers "
535
- f"(<<<<<<< / ======= / >>>>>>>) — resolve them (or "
536
- f"`git checkout --ours/--theirs {STATE_FILE}`), then run `add.py doctor` to verify")
537
- return text
538
-
539
-
540
- def load_state(root: Path) -> dict:
541
- """Load + parse state.json, failing CLOSED. A git-conflicted file dies with a merge-specific
542
- 'state_conflicted'; any other corrupt/unreadable file dies with a clean 'state_invalid'
543
- message (never a raw traceback), so every command that loads state degrades gracefully
544
- (design-for-failure). The parsed state is forward-migrated to the multi-active schema."""
545
- try:
546
- return _migrate_state(json.loads(_state_text_or_die(root)))
547
- except (json.JSONDecodeError, OSError) as e:
548
- _die(f"state_invalid: {root / STATE_FILE} is corrupt or unreadable "
549
- f"({e.__class__.__name__}) — restore it from git or a backup")
550
-
551
-
552
- def _load_state_for_json() -> tuple[Path, dict]:
553
- """Fail-closed state load for `--json` paths: a missing project or unparseable
554
- state.json -> `no_state` on stderr + exit 1, with EMPTY stdout (never a partial
555
- JSON object a harness might parse). Built from State only — reads no docs/ chapter.
556
- The parsed state is forward-migrated to the multi-active schema before it is returned."""
557
- root = find_root()
558
- if root is None:
559
- _die("no_state")
560
- try:
561
- return root, _migrate_state(json.loads(_state_text_or_die(root)))
562
- except (json.JSONDecodeError, OSError):
563
- _die("no_state")
564
-
565
-
566
- def _phase_owner(phase: str) -> str:
567
- """Map a phase to its owner (human|seam|ai); `unmapped_phase` if absent (fail closed)."""
568
- owner = PHASE_OWNER.get(phase)
569
- if owner is None:
570
- _die("unmapped_phase")
571
- return owner
572
-
573
-
574
- def save_state(root: Path, state: dict) -> None:
575
- state["updated"] = _now()
576
- _atomic_write(root / STATE_FILE, json.dumps(state, indent=2) + "\n")
577
-
578
-
579
- def _setup_locked(state: dict) -> bool:
580
- """True when the project's setup is locked — i.e. the build-boundary gate is OPEN.
581
-
582
- A state with NO "setup" key is GRANDFATHERED-locked: plain `init` and every legacy
583
- project are never gated (the lock is opt-in via `init --await-lock`). The gate is
584
- therefore active in exactly one case: "setup" present AND locked is False."""
585
- return ("setup" not in state) or (state["setup"].get("locked") is True)
586
-
587
-
588
- def _milestone_confirmed(state: dict, mslug: str) -> bool:
589
- """True when milestone `mslug` is confirmed — i.e. the new-task gate is OPEN.
590
-
591
- Mirrors `_setup_locked` one level down. A milestone record with NO "confirmed" key is
592
- GRANDFATHERED-confirmed: every milestone created WITHOUT `--await-confirm` (and every
593
- pre-existing one) is never gated. Opt-in: `new-milestone --await-confirm` seeds confirmed:false,
594
- so the gate is active in exactly one case: the record is present AND confirmed is False. An
595
- unknown milestone is treated as confirmed here (existence is cmd_new_task's separate check)."""
596
- m = (state.get("milestones") or {}).get(mslug)
597
- if not isinstance(m, dict) or "confirmed" not in m:
598
- return True
599
- return m["confirmed"] is True
600
-
601
-
602
- def _section_unfilled(md_text: str, header: str) -> bool:
603
- """True iff the `header` section is PRESENT but UNFILLED — empty (no real bullet) or
604
- still a `<…>` template placeholder. ABSENT section -> False (grandfathered legacy);
605
- a filled section (>=1 real bullet, no `<…>`) -> False. Pure predicate — the shared
606
- placeholder test the fill gates use (contract-fill at confirm; build-expectations at build)."""
607
- body, in_sec, present = [], False, False
608
- for ln in md_text.splitlines():
609
- if ln.startswith(header):
610
- in_sec, present = True, True
611
- continue
612
- if in_sec:
613
- if ln.startswith("#"): # ANY next header (## or ###) ends our section
614
- break
615
- if ln.lstrip().startswith(">"): # skip blockquote GUIDANCE — it is not content
616
- continue
617
- body.append(ln)
618
- if not present:
619
- return False # absent -> grandfather
620
- text = "\n".join(body).strip()
621
- if not text:
622
- return True # present but empty
623
- return bool(re.search(r"<[^>\n]+>", text)) # a <…> placeholder remains
624
193
 
625
194
 
626
195
  def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None:
@@ -639,7 +208,7 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
639
208
  return # unreadable -> no-op (never blocks the gate)
640
209
  if "### GATE RECORD" not in text:
641
210
  return # nothing to mirror into
642
- actor = _actor_stamp(state)
211
+ actor = identity._actor_stamp(state)
643
212
  today = date.today().isoformat()
644
213
  # each rule matches ONLY a line still carrying a `<…>` placeholder -> grandfather a resolved line.
645
214
  rules = [
@@ -655,141 +224,22 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
655
224
  new = text
656
225
  for pat, repl in rules:
657
226
  new = re.sub(pat, repl, new, count=1)
227
+ # component-aware-add (per-component-verify): record WHICH green-bar a bound task gated
228
+ # against, right after the Outcome line. Unbound / no green_bar -> no line (byte-identical).
229
+ _bar = _task_green_bar(root, slug)
230
+ if _bar:
231
+ _line = f"component: {_task_component(root, slug)} · expected green-bar: {_bar}"
232
+ if _line not in new:
233
+ new = re.sub(r"(?m)^(Outcome:.*$)", lambda m: m.group(1) + "\n" + _line, new, count=1)
658
234
  if new != text: # no-op = no write (mtime stable)
659
235
  _atomic_write(f, new)
660
236
 
661
237
 
662
- def _die(msg: str, code: int = 1) -> None:
663
- print(f"add: error: {msg}", file=sys.stderr)
664
- raise SystemExit(code)
665
-
666
-
667
- # --- guideline injection (dynamic-by-reference; designed for failure) --------
668
- #
669
- # Inject one stable, marker-delimited ADD block into the project root's AGENTS.md
670
- # and CLAUDE.md. The block is DYNAMIC-BY-REFERENCE: it tells the agent to run
671
- # `add.py status` and read PROJECT.md — it never embeds live state (slug, phase,
672
- # gate). Auto-updated context files measurably hurt (ETH-Zurich: ~3% lower success,
673
- # 20%+ more cost), so the stable pointer is the whole point.
674
-
675
- def _guideline_block() -> str:
676
- """The canonical ADD block (markers + body, no trailing newline).
677
-
678
- Agent-agnostic by design (v14 agent-portability): the routing steps depend
679
- only on the CLI and plain files, so any agent — Claude, Cursor, Copilot,
680
- Codex — can follow them. Claude additionally gets the `add` skill."""
681
- return (
682
- f"{_GUIDE_BEGIN}\n"
683
- "## ADD — how to work in this repo\n"
684
- "\n"
685
- "This project uses **ADD (AI-Driven Development)**: you, the AI, drive the build;\n"
686
- "the human owns direction and verification. The loop below works for any agent —\n"
687
- "Claude, Cursor, Copilot, Codex — through the CLI alone. Before you change code:\n"
688
- "\n"
689
- "1. Run `python3 .add/tooling/add.py status` — where the project is and what's\n"
690
- " next (the resume point; read it first every session).\n"
691
- "2. Read `.add/PROJECT.md` — the foundation (domain · spec · UI/UX) every task\n"
692
- " builds on.\n"
693
- "3. Run `python3 .add/tooling/add.py guide` — it names the phase and the exact\n"
694
- " phase-guide file to read (the `guide :` line). Work ONLY that phase — each\n"
695
- " guide ends with its exit gate and the command to move on.\n"
696
- "\n"
697
- "The flow: INTAKE sizes a request into a milestone; each task runs the\n"
698
- "**specification bundle** — Spec+Scenarios+Contract+Tests as one bundle,\n"
699
- "ONE human approval at the frozen contract — then a self-driving build→verify\n"
700
- "run. Non-negotiable for every agent:\n"
701
- "Never weaken a test or edit a frozen contract to make a build pass; a security\n"
702
- "finding is always HARD-STOP — never auto-passed.\n"
703
- "\n"
704
- "On Claude Code the `add` skill drives this loop automatically; other agents\n"
705
- "follow the three steps. The book is in `.add/docs/`. This block is generated\n"
706
- "by `add.py sync-guidelines`; edit outside the markers, not inside.\n"
707
- f"{_GUIDE_END}"
708
- )
709
-
710
-
711
- def _inject_block(path: Path) -> str:
712
- """Write the ADD block into `path`. Returns created|updated|unchanged.
713
-
714
- - unchanged: on-disk block already matches -> no write, no .bak (idempotent).
715
- - updated: existing content changes -> back up the original to <path>.bak first.
716
- - created: file did not exist -> write the block, no .bak.
717
- User content outside the markers is always preserved.
718
- """
719
- block = _guideline_block()
720
- if path.exists():
721
- current = path.read_text(encoding="utf-8")
722
- begin = current.find(_GUIDE_BEGIN)
723
- if begin != -1:
724
- end = current.find(_GUIDE_END, begin)
725
- if end != -1: # replace only the marked region
726
- end += len(_GUIDE_END)
727
- new = current[:begin] + block + current[end:]
728
- else: # begin without end: corrupt — append fresh
729
- print(f"add: warning: {path.name}: found an ADD:BEGIN with no ADD:END "
730
- "— appending a fresh block; review the result", file=sys.stderr)
731
- new = current.rstrip("\n") + "\n\n" + block + "\n"
732
- else: # no block yet — append, keep user content
733
- new = current.rstrip("\n") + "\n\n" + block + "\n"
734
- if new == current:
735
- return "unchanged"
736
- _atomic_write(Path(str(path) + ".bak"), current) # rollback path before mutate
737
- _atomic_write(path, new)
738
- return "updated"
739
- _atomic_write(path, block + "\n")
740
- return "created"
741
-
742
-
743
- def _inject_guidelines(project_root: Path) -> list[tuple[str, str]]:
744
- """Inject the block into each guideline file under `project_root`.
745
-
746
- Symlink-dedup: targets resolving (os.path.realpath) to the same inode are
747
- written once, against the REAL file (never replacing the symlink with a
748
- regular file). Per-target OSError is isolated (warn+skip) so one unwritable
749
- file never aborts the run or `init`.
750
- """
751
- results: list[tuple[str, str]] = []
752
- seen: set[str] = set()
753
- for name in GUIDELINE_FILES:
754
- target = project_root / name
755
- real = os.path.realpath(target)
756
- if real in seen:
757
- continue
758
- seen.add(real)
759
- write_target = Path(real) if target.is_symlink() else target
760
- try:
761
- action = _inject_block(write_target)
762
- except (OSError, UnicodeDecodeError) as exc:
763
- # design for failure: an unwritable target OR a non-UTF-8 existing file
764
- # (e.g. a UTF-16 CLAUDE.md from a Windows editor) must not crash init or
765
- # abort the other target — warn and skip this one.
766
- print(f"add: warning: could not sync {name} — {exc}; skipped",
767
- file=sys.stderr)
768
- action = "skipped"
769
- results.append((name, action))
770
- return results
771
-
772
-
773
- # --- commands ----------------------------------------------------------------
774
-
775
- _INIT_EXCLUDE = {
776
- ".add", "AGENTS.md", "CLAUDE.md", ".git",
777
- ".gitignore", ".gitattributes", ".github", ".editorconfig", # VCS/CI/editor scaffolding — no domain signal
778
- "LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", # legal boilerplate — no domain signal
779
- } # README/docs/source are NOT excluded: they carry domain content adopt.md maps -> brownfield
780
-
781
-
782
- def _is_brownfield(base: Path) -> bool:
783
- """True when `base` already holds project content beyond the tool's own scaffolding.
784
-
785
- Judgment-free: a mechanical fact (does the dir hold a non-excluded entry?), so the
786
- autonomous-onboarding flow knows to map existing code into the living documentation. INTERPRETING
787
- that code stays with the AI (skill/add/adopt.md) — the engine only detects + signals."""
788
- if not base.is_dir():
789
- return False
790
- return any(child.name not in _INIT_EXCLUDE for child in base.iterdir())
791
-
792
-
238
+ # --- guidelines / CLAUDE.md-injection subsystem (moved to add_engine/guidelines.py) -
239
+ from add_engine.guidelines import (
240
+ _guideline_block, _inject_block, _rule_file_mode, _strip_inline_block,
241
+ _insert_rule_reference, _ensure_claude_reference, _inject_guidelines, _is_brownfield,
242
+ )
793
243
  def cmd_init(args: argparse.Namespace) -> None:
794
244
  base = Path(args.dir).resolve()
795
245
  root = base / ROOT_DIRNAME
@@ -841,7 +291,7 @@ def cmd_init(args: argparse.Namespace) -> None:
841
291
  state["setup"] = {"locked": False, "locked_at": None, "locked_by": None, "layers": []}
842
292
  save_state(root, state)
843
293
  # zero-config: give any agent a stable pointer into the ADD runtime.
844
- for name, action in _inject_guidelines(base):
294
+ for name, action in _inject_guidelines(base, getattr(args, "rule_file", False)):
845
295
  if action != "unchanged":
846
296
  print(f"{action:>9} {name}")
847
297
  print(f"initialised ADD project '{state['project']}' (stage: {state['stage']}) at {root}")
@@ -853,11 +303,15 @@ def cmd_init(args: argparse.Namespace) -> None:
853
303
  else:
854
304
  print("next: open Claude Code, run `/add`, and say what you want to build —")
855
305
  print(" the `add` skill sizes it into a milestone and drives the build with you.")
306
+ # setup hygiene (both branches): the .add/ folder IS the shared project state — commit it
307
+ # so the team shares one source of truth; its transient working files are already gitignored.
308
+ print("tip: commit the .add/ folder to git so your team shares the ADD state "
309
+ "(its transient files are already .gitignored).")
856
310
 
857
311
 
858
312
  def cmd_sync_guidelines(args: argparse.Namespace) -> None:
859
313
  project_root = _require_root().parent
860
- for name, action in _inject_guidelines(project_root):
314
+ for name, action in _inject_guidelines(project_root, getattr(args, "rule_file", False)):
861
315
  print(f"{action:>9} {name}")
862
316
 
863
317
 
@@ -938,6 +392,11 @@ def cmd_new_task(args: argparse.Namespace) -> None:
938
392
  f"autonomy token; new task seeded fail-safe '{autonomy}' "
939
393
  "(fix it with `add.py autonomy set <level> --project`)", file=sys.stderr)
940
394
 
395
+ # F8 (force-preserve-heal): a --force overwrite RE-CREATES the record; capture the prior
396
+ # MONOTONIC heal counter first so it survives. Else a task that accrued heal attempts (or
397
+ # was HARD-STOP escalated) could launder the cap (HEAL_CAP) to zero by re-creating itself —
398
+ # a zero-human cap bypass (the same invariant _heal_or_escalate guards: "never auto-resets").
399
+ prior_heal = state["tasks"].get(slug, {}).get("heal") if args.force else None
941
400
  state["tasks"][slug] = {
942
401
  "title": title,
943
402
  "phase": "ground",
@@ -947,6 +406,8 @@ def cmd_new_task(args: argparse.Namespace) -> None:
947
406
  "created": _now(),
948
407
  "updated": _now(),
949
408
  }
409
+ if prior_heal is not None:
410
+ state["tasks"][slug]["heal"] = prior_heal # monotonic — survives the --force re-create
950
411
  if from_delta:
951
412
  state["tasks"][slug]["from_delta"] = from_delta # lineage: seeded from <prior>
952
413
  if fast:
@@ -957,6 +418,11 @@ def cmd_new_task(args: argparse.Namespace) -> None:
957
418
  if milestone:
958
419
  print(f"linked to milestone '{milestone}'" +
959
420
  (f", depends-on {depends_on}" if depends_on else ""))
421
+ elif fast:
422
+ # blessed milestone-free fast lane (standalone-fast-task): a --fast task with no owning
423
+ # milestone is a DELIBERATE low-ceremony lane, not an orphan to nag — AFFIRM it.
424
+ print(f"standalone fast task '{slug}' — milestone-free by design (low-ceremony lane); "
425
+ f"attach later with `add.py set-milestone {slug} --milestone <id>` if it grows")
960
426
  else:
961
427
  # warn-never-block: the task is created (escape hatch), but nudge back toward the
962
428
  # intake -> milestone flow. Speaks of STRUCTURE (not attached), never the act.
@@ -1001,16 +467,6 @@ def _parse_deps(raw: str | None) -> list[str]:
1001
467
  return [d.strip() for d in raw.split(",") if d.strip()]
1002
468
 
1003
469
 
1004
- def _task_done(t: dict) -> bool:
1005
- # Matrix 3: a task is done when Verify reads PASS *or a signed RISK-ACCEPTED*.
1006
- # Both completing gates advance phase to "done" (cmd_gate), and a waiver is
1007
- # signed at gate time — so a verdict gate is enough here; we need not re-read
1008
- # the waiver. HARD-STOP never reaches "done". A bare `phase done` (escape
1009
- # hatch, gate still "none") deliberately does NOT count: completion needs a
1010
- # recorded verdict, not just a phase marker.
1011
- return t.get("phase") == "done" and t.get("gate") in ("PASS", "RISK-ACCEPTED")
1012
-
1013
-
1014
470
  def _archived_task_slugs(state: dict) -> set[str]:
1015
471
  """Slugs of tasks that left active state via archive — all were PASS-done at
1016
472
  archive time, so a dep on one of them counts as satisfied (not dangling).
@@ -1034,16 +490,94 @@ def _resolve_task(state: dict, slug: str | None) -> str:
1034
490
  return slug
1035
491
 
1036
492
 
493
+ def _build_entry(root: Path, state: dict, slug: str) -> None:
494
+ """The shared tests->build entry guards + snapshots (task phase-build-guard, F4).
495
+
496
+ Extracted VERBATIM from cmd_advance's `nxt == "build"` block so BOTH `advance` and the
497
+ `phase build` admin override run the identical gate stack — the freeze gate, the
498
+ build-expectations gate, the unflagged-freeze check + flag stamp, the tamper tripwire, and
499
+ the §5 scope snapshot. validate-then-write: every `_die` precedes the first state mutation,
500
+ so a refused entry leaves the task byte-unchanged. The heal loop sets phase=build DIRECTLY
501
+ and never routes here, so it stays exempt.
502
+ """
503
+ # the OPTED-IN crossing guards (fast-lane + flow-enforcement): a task whose PARENT
504
+ # milestone opted into --await-confirm is held to the trust floor at tests->build. A
505
+ # task under a plain / no milestone is never gated (every existing advance-to-build flow
506
+ # stays green). validate-then-write — every refusal runs BEFORE the tripwire/scope
507
+ # snapshots below, writing nothing; the task stays at `tests`.
508
+ _ms = state["tasks"][slug].get("milestone")
509
+ _optin = bool(_ms) and (state.get("milestones") or {}).get(_ms, {}).get("await_confirm") is True
510
+ raw3 = _raw_phase_bodies(root, slug).get(3, "")
511
+ # freeze-before-build gate (fast-lane): "collapse-never-skip" made REAL — the freeze is
512
+ # engine-enforced for an opted-in milestone OR any --fast task (the fast arm, fast-new-task-flag:
513
+ # a fast task is held to the floor under ANY milestone, so the lighter lane never drops the
514
+ # trust seam). PRECEDES build-expectations: you freeze §3 before pre-declaring §6's outcomes.
515
+ _freeze_gated = _optin or state["tasks"][slug].get("fast") is True
516
+ if _freeze_gated and not _contract_frozen(raw3):
517
+ _die("contract_not_frozen: freeze §3 before crossing into build — approve "
518
+ f"the contract in {slug}'s TASK.md (Status: FROZEN @ vN)")
519
+ # build-expectations gate (flow-enforcement): an opted-in task may not enter build until
520
+ # its §6 `### Build expectations` are pre-declared — so verify checks the build is RIGHT,
521
+ # not just green. Same opt-in switch as the contract-fill gate, one level out.
522
+ if _optin:
523
+ if _section_unfilled(_raw_phase_bodies(root, slug).get(6, ""), "### Build expectations"):
524
+ _die("build_expectations_unfilled: fill the §6 '### Build expectations' block "
525
+ f"of {slug}'s TASK.md before crossing into build")
526
+ if _contract_frozen(raw3):
527
+ if not _flag_well_formed(raw3):
528
+ _die("unflagged_freeze: a frozen §3 must surface a well-formed "
529
+ "'Least-sure flag surfaced at freeze:' unit (>=1 [part] tag "
530
+ "+ substantive content; bare 'none' only as 'none material — "
531
+ "biggest risk: X') before crossing into build")
532
+ state["tasks"][slug]["flag_verified"] = True
533
+ # tamper tripwire (verify-integrity): snapshot the red test files + the frozen
534
+ # §3 md5s so the verify gate can prove the green was EARNED, not edited into
535
+ # place. UNCONDITIONAL overwrite — a legit change-request that re-crosses
536
+ # tests->build re-snapshots cleanly. Co-witnessed by flag_verified (above).
537
+ state["tasks"][slug]["tripwire"] = _tripwire_snapshot(root, slug, raw3)
538
+ # §5 scope gate (build-scope-lock): when the task declares its Scope, freeze
539
+ # the project tree into a sidecar (payload) + a state.json anchor (md5 of the
540
+ # sidecar bytes). Same UNCONDITIONAL-overwrite semantics as the tripwire.
541
+ # UNDECLARED (no Scope line) takes no snapshot — grandfathered, never retro-red
542
+ # — and CLEANS UP a previous declaration's leftovers (v3): a declared->
543
+ # undeclared re-cross pops the stale anchor + unlinks the stale sidecar, so
544
+ # "UNDECLARED is never refused" holds on every path.
545
+ declared = _declared_scope(root, slug)
546
+ side = root / "tasks" / slug / "scope-snapshot.json"
547
+ if declared is not None:
548
+ payload = json.dumps({"version": 1,
549
+ "files": _scope_walk(root.parent.resolve())},
550
+ sort_keys=True)
551
+ _atomic_write(side, payload) # temp+replace, like save_state — a crash can't leave a
552
+ # torn sidecar (payload verbatim, no newline → md5 anchor holds)
553
+ state["tasks"][slug]["scope"] = {"declared": declared,
554
+ "snapshot_md5": _md5_text(payload)}
555
+ else:
556
+ state["tasks"][slug].pop("scope", None)
557
+ try:
558
+ side.unlink()
559
+ except OSError:
560
+ pass
561
+
562
+
1037
563
  def cmd_phase(args: argparse.Namespace) -> None:
1038
564
  root = _require_root()
1039
565
  state = load_state(root)
1040
566
  slug = _resolve_task(state, args.slug)
1041
567
  if args.phase not in PHASES:
1042
568
  _die(f"phase must be one of: {', '.join(PHASES)}")
569
+ # phase-build-guard (F4): the admin override is NOT a backdoor around the tests->build gate
570
+ # stack — setting a task to build runs the SAME _build_entry guards `advance` runs (freeze
571
+ # gate · build-expectations · flag check · tamper tripwire · scope snapshot), so verify's
572
+ # _tamper_guard is armed and a freeze-gated DRAFT §3 is refused. Other targets are unchanged.
573
+ # validate-then-write: a refusal raises BEFORE the phase is set, so nothing moves. The heal
574
+ # loop sets phase=build directly (never via cmd_phase) and so stays exempt.
575
+ if args.phase == "build":
576
+ _build_entry(root, state, slug)
1043
577
  state["tasks"][slug]["phase"] = args.phase
1044
578
  state["tasks"][slug]["updated"] = _now()
1045
- _sync_task_marker(root, slug, args.phase)
1046
- save_state(root, state)
579
+ save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
580
+ _sync_task_marker(root, slug, args.phase) # then mirror into TASK.md (best-effort) — no split-brain
1047
581
  print(f"task '{slug}' phase -> {args.phase}")
1048
582
  print(_next_footer(root, state))
1049
583
 
@@ -1061,73 +595,71 @@ def cmd_advance(args: argparse.Namespace) -> None:
1061
595
  # into build/verify/observe/done is refused until `add.py lock`.
1062
596
  if not _setup_locked(state) and nxt in ("build", "verify", "observe", "done"):
1063
597
  _die("setup_unlocked: lock the foundation first — add.py lock")
598
+ # intra-milestone cross-component HOLD (cross-component-milestone): a consumer of a DECLARED
599
+ # contract may not enter §3 (scenarios->contract) until its producer froze — proven by the
600
+ # snapshot the producer's contract->tests crossing writes (task 3). This orders a BE→FE slice
601
+ # inside ONE milestone (the FE stays downstream of the frozen endpoint). Validate-before-write:
602
+ # the HARD-STOP precedes the phase bump, so the task stays at `scenarios`. Undeclared id / no
603
+ # `consumes:` header -> no hold (byte-identical; a typo'd id is a cmd_check registry finding).
604
+ if nxt == "contract":
605
+ _cid = _task_consumes(root, slug)
606
+ _cmap = _contracts(root)
607
+ if _cid and _cid in _cmap and not _contract_snapshot(root, _cid).exists():
608
+ _die(f"producer_contract_unfrozen: the producer '{_cmap[_cid].get('producer', '?')}' of "
609
+ f"contract '{_cid}' must freeze its contract before you write §3 — wait for "
610
+ f".add/contracts/{_cid}.json")
1064
611
  # flag-first freeze guard (task unflagged-freeze): a FROZEN §3 may not cross
1065
612
  # into build without a WELL-FORMED lowest-confidence flag. On pass, stamp the
1066
613
  # verified marker so `audit` enforces the flag on THIS record only (open/new
1067
614
  # freezes — the unmarked predecessors are never retro-redded). REFUSE writes
1068
615
  # nothing (fail-closed); below the build boundary the flag is never checked.
1069
616
  if nxt == "build":
1070
- # the OPTED-IN crossing guards (fast-lane + flow-enforcement): a task whose PARENT
1071
- # milestone opted into --await-confirm is held to the trust floor at tests->build. A
1072
- # task under a plain / no milestone is never gated (every existing advance-to-build flow
1073
- # stays green). validate-then-write — every refusal runs BEFORE the tripwire/scope
1074
- # snapshots below, writing nothing; the task stays at `tests`.
1075
- _ms = state["tasks"][slug].get("milestone")
1076
- _optin = bool(_ms) and (state.get("milestones") or {}).get(_ms, {}).get("await_confirm") is True
1077
- raw3 = _raw_phase_bodies(root, slug).get(3, "")
1078
- # freeze-before-build gate (fast-lane): "collapse-never-skip" made REAL the freeze is
1079
- # engine-enforced for an opted-in milestone OR any --fast task (the fast arm, fast-new-task-flag:
1080
- # a fast task is held to the floor under ANY milestone, so the lighter lane never drops the
1081
- # trust seam). PRECEDES build-expectations: you freeze §3 before pre-declaring §6's outcomes.
1082
- _freeze_gated = _optin or state["tasks"][slug].get("fast") is True
1083
- if _freeze_gated and not _contract_frozen(raw3):
1084
- _die("contract_not_frozen: freeze §3 before crossing into build — approve "
1085
- f"the contract in {slug}'s TASK.md (Status: FROZEN @ vN)")
1086
- # build-expectations gate (flow-enforcement): an opted-in task may not enter build until
1087
- # its §6 `### Build expectations` are pre-declared — so verify checks the build is RIGHT,
1088
- # not just green. Same opt-in switch as the contract-fill gate, one level out.
1089
- if _optin:
1090
- if _section_unfilled(_raw_phase_bodies(root, slug).get(6, ""), "### Build expectations"):
1091
- _die("build_expectations_unfilled: fill the §6 '### Build expectations' block "
1092
- f"of {slug}'s TASK.md before crossing into build")
1093
- if _contract_frozen(raw3):
1094
- if not _flag_well_formed(raw3):
1095
- _die("unflagged_freeze: a frozen §3 must surface a well-formed "
1096
- "'Least-sure flag surfaced at freeze:' unit (>=1 [part] tag "
1097
- "+ substantive content; bare 'none' only as 'none material — "
1098
- "biggest risk: X') before crossing into build")
1099
- state["tasks"][slug]["flag_verified"] = True
1100
- # tamper tripwire (verify-integrity): snapshot the red test files + the frozen
1101
- # §3 md5s so the verify gate can prove the green was EARNED, not edited into
1102
- # place. UNCONDITIONAL overwrite — a legit change-request that re-crosses
1103
- # tests->build re-snapshots cleanly. Co-witnessed by flag_verified (above).
1104
- state["tasks"][slug]["tripwire"] = _tripwire_snapshot(root, slug, raw3)
1105
- # §5 scope gate (build-scope-lock): when the task declares its Scope, freeze
1106
- # the project tree into a sidecar (payload) + a state.json anchor (md5 of the
1107
- # sidecar bytes). Same UNCONDITIONAL-overwrite semantics as the tripwire.
1108
- # UNDECLARED (no Scope line) takes no snapshot — grandfathered, never retro-red
1109
- # — and CLEANS UP a previous declaration's leftovers (v3): a declared->
1110
- # undeclared re-cross pops the stale anchor + unlinks the stale sidecar, so
1111
- # "UNDECLARED is never refused" holds on every path.
1112
- declared = _declared_scope(root, slug)
1113
- side = root / "tasks" / slug / "scope-snapshot.json"
1114
- if declared is not None:
1115
- payload = json.dumps({"version": 1,
1116
- "files": _scope_walk(root.parent.resolve())},
1117
- sort_keys=True)
1118
- side.write_text(payload, encoding="utf-8")
1119
- state["tasks"][slug]["scope"] = {"declared": declared,
1120
- "snapshot_md5": _md5_text(payload)}
1121
- else:
1122
- state["tasks"][slug].pop("scope", None)
617
+ # the tests->build entry guards + snapshots now live in the shared _build_entry helper
618
+ # (task phase-build-guard, F4) so `advance` and the `phase build` admin override run the
619
+ # IDENTICAL gate stack. Behavior here is byte-unchanged this is a pure extraction.
620
+ _build_entry(root, state, slug)
621
+ # cross-component contract artifact (cross-component-contract): the contract->tests crossing
622
+ # is the producer's freeze-approval moment. A `produces:` task WRITES the immutable snapshot;
623
+ # a `consumes:` task PINS the live hash a missing/unreadable snapshot HARD-STOPS here (the
624
+ # phase stays at `contract`, nothing pinned), so a consumer never builds against a guessed
625
+ # shape. No role / no contracts neither branch is taken (byte-identical).
626
+ if nxt == "tests":
627
+ _prod = _task_produces(root, slug)
628
+ if _prod:
629
+ raw3c = _raw_phase_bodies(root, slug).get(3, "")
630
+ vm = re.search(r"FROZEN @ (v\d+)", raw3c)
631
+ snap = {"id": _prod, "producer": (_contracts(root).get(_prod) or {}).get("producer", "?"),
632
+ "task": slug, "version": vm.group(1) if vm else "?",
633
+ "frozen": date.today().isoformat(), "hash": _contract_body_hash(raw3c)}
634
+ sp = _contract_snapshot(root, _prod)
635
+ cur_snap = None
636
+ if sp.exists():
637
+ try:
638
+ cur_snap = json.loads(sp.read_text(encoding="utf-8"))
639
+ except (OSError, ValueError):
640
+ cur_snap = None
641
+ # idempotent: re-write only when the shape-hash or version actually changed (so a
642
+ # no-op re-cross leaves the file and its `frozen` date — byte-identical).
643
+ if not (cur_snap and cur_snap.get("hash") == snap["hash"]
644
+ and cur_snap.get("version") == snap["version"]):
645
+ sp.parent.mkdir(parents=True, exist_ok=True)
646
+ _atomic_write(sp, json.dumps(snap, sort_keys=True))
647
+ _cons = _task_consumes(root, slug)
648
+ if _cons:
649
+ sp = _contract_snapshot(root, _cons)
1123
650
  try:
1124
- side.unlink()
1125
- except OSError:
1126
- pass
651
+ pinned = json.loads(sp.read_text(encoding="utf-8")).get("hash")
652
+ except (OSError, ValueError, AttributeError):
653
+ pinned = None
654
+ if not pinned: # absent / unreadable / valid-JSON-but-no-hash all fail loud
655
+ _die(f"contract_snapshot_missing: no readable hashed .add/contracts/{_cons}.json — the "
656
+ f"producer of '{_cons}' must freeze its contract first "
657
+ "(never build against a guessed shape)")
658
+ state["tasks"][slug]["contract_pin"] = {"id": _cons, "hash": pinned}
1127
659
  state["tasks"][slug]["phase"] = nxt
1128
660
  state["tasks"][slug]["updated"] = _now()
1129
- _sync_task_marker(root, slug, nxt)
1130
- save_state(root, state)
661
+ save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
662
+ _sync_task_marker(root, slug, nxt) # then mirror into TASK.md (best-effort) — no split-brain
1131
663
  print(f"task '{slug}' phase {cur} -> {nxt}")
1132
664
  if nxt == "observe":
1133
665
  # OBSERVE is where this loop's lessons get captured (TASK.md §7) — suggest routing
@@ -1155,25 +687,18 @@ _RISK_HIGH_RE = re.compile(r"(?:^|·)[ \t]*risk:[ \t]*high\b", re.MULTILINE)
1155
687
 
1156
688
  # the explicit 3-mode autonomy dial (task explicit-autonomy-dial): an ordered ladder
1157
689
  # manual < conservative < auto, declared as a per-task `autonomy:` header token.
1158
- _AUTONOMY_LEVELS = ("manual", "conservative", "auto")
1159
690
  # anchored to a DECLARATION position — line-start `autonomy:` OR the inline slug-line form
1160
691
  # `… · autonomy: conservative` (the `·`-preceded shape) — never a title/prose substring; the
1161
692
  # value stops at space/`<`/`#`/`|` so an unfilled `<manual | … >` placeholder captures nothing
1162
693
  # and reads as UNSET.
1163
- _AUTONOMY_LINE_RE = re.compile(r"(?:^|·)[ \t]*autonomy:[ \t]*([^\s<#|]+)", re.MULTILINE)
1164
-
1165
694
 
1166
- def _autonomy_level(hdr: str):
1167
- """The declared autonomy rung from a TASK.md header region (HTML comments
1168
- already stripped by _task_header). Returns a member of _AUTONOMY_LEVELS, or
1169
- None when no `autonomy:` line is present (UNSET — an unfilled `<…>` placeholder,
1170
- whose value the regex declines, counts as unset), or "?" when a REAL token outside
1171
- the set was written (unknown). PURE."""
1172
- m = _AUTONOMY_LINE_RE.search(hdr)
1173
- if not m:
1174
- return None
1175
- tok = m.group(1).strip().lower()
1176
- return tok if tok in _AUTONOMY_LEVELS else "?"
695
+ # component-aware-add: a task binds to a registered component via a `component: <name>`
696
+ # header token the SAME anchored grammar as autonomy (line-start or the `·`-inline
697
+ # slug-line form), and an unfilled `<name>` placeholder captures nothing (reads UNBOUND).
698
+ _COMPONENT_LINE_RE = re.compile(r"(?:^|·)[ \t]*component:[ \t]*([^\s<#|]+)", re.MULTILINE)
699
+ # cross-component-contract: a task's role in a cross-component seam same anchored grammar.
700
+ _PRODUCES_LINE_RE = re.compile(r"(?:^|·)[ \t]*produces:[ \t]*([^\s<#|]+)", re.MULTILINE)
701
+ _CONSUMES_LINE_RE = re.compile(r"(?:^|·)[ \t]*consumes:[ \t]*([^\s<#|]+)", re.MULTILINE)
1177
702
 
1178
703
 
1179
704
  def _autonomy_lowered(hdr: str) -> bool:
@@ -1182,26 +707,6 @@ def _autonomy_lowered(hdr: str) -> bool:
1182
707
  return _autonomy_level(hdr) in ("manual", "conservative")
1183
708
 
1184
709
 
1185
- def _task_header(root: Path, slug: str) -> str:
1186
- """The TASK.md header region — where declared tokens (risk · autonomy)
1187
- live — with HTML comments stripped. Missing file -> '' (no tokens)."""
1188
- try:
1189
- text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8")
1190
- except OSError:
1191
- return ""
1192
- return re.sub(r"<!--.*?-->", "", text.split("\n## ", 1)[0], flags=re.S)
1193
-
1194
-
1195
- def _effective_autonomy(root: Path, state: dict, slug: str) -> str:
1196
- """The autonomy rung that governs `slug` right now: the task's own declared rung,
1197
- falling back to the project default when the task line is UNSET (None) or an
1198
- unrecognized token ("?") — the same fail-safe chain cmd_new_task seeds from
1199
- (_project_autonomy: absent -> auto, garbled -> conservative). PURE. `state` is unused
1200
- today; it is kept in the signature beside _driver_stop for symmetry."""
1201
- lvl = _autonomy_level(_task_header(root, slug))
1202
- return lvl if lvl in _AUTONOMY_LEVELS else _project_autonomy(root)
1203
-
1204
-
1205
710
  def _driver_stop(root: Path, state: dict, slug: str, phase: str) -> bool:
1206
711
  """True iff a HUMAN owns the next step for `phase` under the effective autonomy — the
1207
712
  SINGLE source the footer marker and the guide TEXT marker both render (task
@@ -1260,6 +765,22 @@ def cmd_gate(args: argparse.Namespace) -> None:
1260
765
  # §5 scope gate (build-scope-lock): touched ⊆ declared, or a named refusal —
1261
766
  # same placement discipline as the tripwire (before the waiver, never on HARD-STOP).
1262
767
  _scope_guard(root, state, slug)
768
+ # consumer-stale gate (consumer-stale-gate): a `consumes:` task whose pinned producer
769
+ # contract hash has drifted from the live snapshot built against an out-of-date shape —
770
+ # refuse the completing outcome (same before-the-waiver discipline). Re-pin to recover.
771
+ _consumer_stale_guard(root, state, slug)
772
+ # per-component verify (component-aware-add): a component-bound task with a declared
773
+ # green_bar must CITE that bar in its §6 evidence before a completing outcome — the
774
+ # engine never runs the suite, it checks the right bar was recorded. Unbound / no
775
+ # green_bar -> _bar is None -> this is skipped (byte-identical). HARD-STOP never here.
776
+ _bar = _task_green_bar(root, slug)
777
+ # the cite must live in the user-authored Build-expectations evidence region (_cite_region,
778
+ # v3): excludes the §6 checklist boilerplate + the GATE RECORD template/stamp, works for both
779
+ # the standard and fast-lane §6 shapes. Unbound / no green_bar -> _bar None -> skipped.
780
+ if _bar and _bar not in _cite_region(_raw_phase_bodies(root, slug).get(6, "")):
781
+ _die(f"component_green_bar_uncited: task '{slug}' is bound to component "
782
+ f"'{_task_component(root, slug)}'; its §6 Build-expectations must cite the "
783
+ f"green-bar '{_bar}' — record the evidence that bar was met before PASS")
1263
784
  if args.outcome == "RISK-ACCEPTED":
1264
785
  # A waiver must be SIGNED: owner, ticket, expiry (glossary). Stored in state
1265
786
  # so a later `check` can read/expire it. Refuse a partial waiver outright.
@@ -1272,13 +793,17 @@ def cmd_gate(args: argparse.Namespace) -> None:
1272
793
  }
1273
794
  if completing:
1274
795
  state["tasks"][slug]["phase"] = "done"
1275
- _sync_task_marker(root, slug, "done")
1276
796
  state["tasks"][slug]["gate"] = args.outcome
1277
- state["tasks"][slug]["gate_actor"] = _actor_stamp(state) # WHO recorded the verdict (every outcome)
797
+ state["tasks"][slug]["gate_actor"] = identity._actor_stamp(state) # WHO recorded the verdict (every outcome)
1278
798
  state["tasks"][slug]["updated"] = _now()
1279
- save_state(root, state)
799
+ save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
800
+ if completing:
801
+ _sync_task_marker(root, slug, "done") # then mirror the phase into TASK.md — no split-brain
1280
802
  _stamp_gate_record(root, state, slug, args.outcome) # mirror the verdict into §6 (Finding C)
1281
803
  print(f"task '{slug}' gate -> {args.outcome}")
804
+ _gbar = _task_green_bar(root, slug) # per-component-verify: surface the bound bar
805
+ if _gbar:
806
+ print(f"component: {_task_component(root, slug)} · expected green-bar: {_gbar}")
1282
807
  # the engine-sourced next step (next-footer-engine): a completing gate hands off to the
1283
808
  # state arm; HARD-STOP routes to "resolve HARD-STOP …" — converging the old bespoke line.
1284
809
  print(_next_footer(root, state))
@@ -1358,6 +883,45 @@ def cmd_autonomy(args: argparse.Namespace) -> None:
1358
883
  _print_autonomy(root, state, slug)
1359
884
 
1360
885
 
886
+ def cmd_todo(args: argparse.Namespace) -> None:
887
+ """Capture / list / close a lightweight backlog todo (task: todo-capture).
888
+
889
+ A todo is a JOTTED IDEA, not a task — it carries no spec/contract/gate. It lets you
890
+ record an intent without sizing it. Promote one to a real task with
891
+ `add.py new-task <slug> --fast` when you decide to build it. Stored in state["todos"]
892
+ as {id (1-based = max+1), text, created, status:"open"|"done"}.
893
+ """
894
+ root = _require_root() # reused -> "no .add/ project found …"
895
+ state = load_state(root)
896
+ todos = state.get("todos")
897
+ if not isinstance(todos, list): # absent / corrupt -> fresh list (drift-safe)
898
+ todos = state["todos"] = []
899
+ done_id = getattr(args, "done", None)
900
+ if done_id is not None: # --done <id> : close an OPEN todo
901
+ for t in todos:
902
+ if isinstance(t, dict) and t.get("id") == done_id and t.get("status") == "open":
903
+ t["status"] = "done"
904
+ save_state(root, state)
905
+ print(f"todo #{done_id} done")
906
+ return
907
+ _die(f"todo_unknown: no open todo #{done_id}")
908
+ if args.text is not None: # capture attempt (text positional present)
909
+ text = args.text.strip()
910
+ if not text:
911
+ _die("todo_empty: a todo needs text")
912
+ new_id = max((t.get("id", 0) for t in todos if isinstance(t, dict)), default=0) + 1
913
+ todos.append({"id": new_id, "text": text, "created": _now(), "status": "open"})
914
+ save_state(root, state)
915
+ print(f"captured todo #{new_id}: {text}")
916
+ return
917
+ open_todos = [t for t in todos if isinstance(t, dict) and t.get("status") == "open"]
918
+ if not open_todos: # bare `todo` -> list OPEN todos
919
+ print("no open todos")
920
+ return
921
+ for t in open_todos:
922
+ print(f"#{t.get('id')} {t.get('text')}")
923
+
924
+
1361
925
  def cmd_reopen(args: argparse.Namespace) -> None:
1362
926
  """Return an already-`done` task to an earlier phase with a never-silent record.
1363
927
 
@@ -1392,8 +956,8 @@ def cmd_reopen(args: argparse.Namespace) -> None:
1392
956
  t["phase"] = target
1393
957
  t["gate"] = "none"
1394
958
  t["updated"] = now
1395
- _sync_task_marker(root, slug, target)
1396
- save_state(root, state)
959
+ save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
960
+ _sync_task_marker(root, slug, target) # then mirror into TASK.md (best-effort) — no split-brain
1397
961
  print(f"task '{slug}' reopened: done -> {target} (reason recorded); gate reset to none")
1398
962
  print(_next_footer(root, state))
1399
963
 
@@ -1444,7 +1008,7 @@ def cmd_lock(args: argparse.Namespace) -> None:
1444
1008
  when = _now()
1445
1009
  # ONE atomic write — no partial lock state.
1446
1010
  state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers,
1447
- "actor": _actor_stamp(state)} # structured actor alongside the free-text locked_by
1011
+ "actor": identity._actor_stamp(state)} # structured actor alongside the free-text locked_by
1448
1012
  save_state(root, state)
1449
1013
  if getattr(args, "json", False):
1450
1014
  print(json.dumps(
@@ -1471,7 +1035,7 @@ def cmd_whoami(args: argparse.Namespace) -> None:
1471
1035
  _die("actor_name_blank")
1472
1036
  state["actor_override"] = {"name": args.name, "email": args.email or None}
1473
1037
  save_state(root, state)
1474
- who = _whoami(state)
1038
+ who = identity._whoami(state)
1475
1039
  if getattr(args, "json", False):
1476
1040
  print(json.dumps(who, separators=(",", ":")))
1477
1041
  return
@@ -1500,14 +1064,14 @@ def cmd_assign(args: argparse.Namespace) -> None:
1500
1064
  _die("unknown_slug")
1501
1065
  # parse + validate ALL flags BEFORE the first write — a blank name is rejected on the
1502
1066
  # PARSED name (so "<>" or " <a@x.io>", whose name parses empty, is caught like " ").
1503
- parsed_owner = _parse_actor_arg(args.owner) if args.owner is not None else None
1504
- parsed_assignee = _parse_actor_arg(args.assignee) if args.assignee is not None else None
1067
+ parsed_owner = identity._parse_actor_arg(args.owner) if args.owner is not None else None
1068
+ parsed_assignee = identity._parse_actor_arg(args.assignee) if args.assignee is not None else None
1505
1069
  if parsed_owner is not None and not parsed_owner["name"].strip():
1506
1070
  _die("owner_name_blank")
1507
1071
  if parsed_assignee is not None and not parsed_assignee["name"].strip():
1508
1072
  _die("assignee_name_blank")
1509
1073
  if parsed_owner is None and parsed_assignee is None:
1510
- who = _whoami(state)
1074
+ who = identity._whoami(state)
1511
1075
  rec["owner"] = dict(who)
1512
1076
  rec["assignee"] = dict(who)
1513
1077
  else:
@@ -1541,15 +1105,6 @@ def cmd_unassign(args: argparse.Namespace) -> None:
1541
1105
  print(f"unassigned {args.slug} ({', '.join(roles)})")
1542
1106
 
1543
1107
 
1544
- def _has_production_roadmap(state: dict) -> bool:
1545
- """True iff ≥1 milestone in state has stage == "production" (STATUS-AGNOSTIC).
1546
- The single source of the stage-graduation floor (v22 graduate-guide): the guard counts
1547
- that a production-roadmap RECORD exists — it never judges whether those milestones are
1548
- done/good/sufficient (gather-not-judge). An archived-out-of-state roadmap falls to --force."""
1549
- return any(m.get("stage") == "production"
1550
- for m in state.get("milestones", {}).values())
1551
-
1552
-
1553
1108
  def cmd_stage(args: argparse.Namespace) -> None:
1554
1109
  root = _require_root()
1555
1110
  state = load_state(root)
@@ -1575,6 +1130,38 @@ def cmd_stage(args: argparse.Namespace) -> None:
1575
1130
  print(_next_footer(root, state))
1576
1131
 
1577
1132
 
1133
+ def _done_resume(root: Path, state: dict, slug: str) -> tuple[str, str, str]:
1134
+ """At a DONE task, what should the agent do NEXT? Classify from the task's
1135
+ milestone exit-criteria tally (_exit_criteria) so the orient surfaces (status,
1136
+ guide) STEER into the loop instead of always saying "start the next feature".
1137
+
1138
+ Returns (headline, next_step, chapter) where chapter is a docs/ filename:
1139
+ LOOP-JUNCTURE total>0 and met<total -> name the unmet goal, route to the loop
1140
+ GOAL-MET total>0 and met==total -> point at milestone-done
1141
+ PLAIN no criteria / no milestone / any read error -> today's "next feature"
1142
+ PURE and fail-closed (design-for-failure): a missing milestone or unreadable
1143
+ MILESTONE.md degrades to PLAIN — it never raises into a status/guide print path."""
1144
+ PLAIN = ("this task is done",
1145
+ "start the next feature -> add.py new-task <slug>",
1146
+ "02-the-flow.md")
1147
+ try:
1148
+ ms = ((state.get("tasks") or {}).get(slug) or {}).get("milestone")
1149
+ if not ms:
1150
+ return PLAIN
1151
+ met, total = _exit_criteria(root, ms)
1152
+ except Exception: # noqa: BLE001 — never break orient output
1153
+ return PLAIN
1154
+ if total > 0 and met < total:
1155
+ return (f"milestone '{ms}' goal not met ({met}/{total} exit criteria)",
1156
+ "propose the next tasks from open deltas / the unscaffolded plan -> add.py deltas",
1157
+ "09-the-loop.md")
1158
+ if total > 0 and met == total:
1159
+ return (f"milestone '{ms}' goal met ({met}/{total})",
1160
+ f"close it -> add.py milestone-done {ms}",
1161
+ "09-the-loop.md")
1162
+ return PLAIN
1163
+
1164
+
1578
1165
  def cmd_status(args: argparse.Namespace) -> None:
1579
1166
  if getattr(args, "json", False):
1580
1167
  root, state = _load_state_for_json()
@@ -1590,7 +1177,7 @@ def cmd_status(args: argparse.Namespace) -> None:
1590
1177
  grad_ready, grad_met, grad_total = _graduation_ready(root, state)
1591
1178
  print(json.dumps({
1592
1179
  "project": state.get("project"), "stage": state.get("stage"),
1593
- "actor": _whoami(state),
1180
+ "actor": identity._whoami(state),
1594
1181
  "active_task": _active_task(state),
1595
1182
  "active_milestones": list(state.get("active_milestones") or []),
1596
1183
  "active_tasks": dict(state.get("active_tasks") or {}),
@@ -1615,7 +1202,7 @@ def cmd_status(args: argparse.Namespace) -> None:
1615
1202
  print(f"project autonomy: {_project_autonomy(root)} (default — new tasks inherit)")
1616
1203
  # git-native actor (user-identity): who ADD sees you as this session — the identity every
1617
1204
  # human-owned stamp records. Always present (the resolver is TOTAL). Read-only, no write.
1618
- _who = _whoami(state)
1205
+ _who = identity._whoami(state)
1619
1206
  _who_email = f" <{_who['email']}>" if _who.get("email") else ""
1620
1207
  print(f"actor : {_who['name']}{_who_email} (source: {_who['source']})")
1621
1208
  print(f"stage : {state.get('stage', '(unknown)')}")
@@ -1682,6 +1269,12 @@ def cmd_status(args: argparse.Namespace) -> None:
1682
1269
  _rel = _releasable(root, state)
1683
1270
  if _rel:
1684
1271
  print(f" → {RELEASABLE_CUE.format(n=len(_rel))}")
1272
+ # loose-task release cue (loose-task-release): a SEPARATE additive line — done milestone-free
1273
+ # tasks not yet attributed to a RELEASES.md `loose tasks:` row. Peer to the milestone cue (its
1274
+ # constant is untouched); fires even with zero releasable milestones. Fail-open ledger read.
1275
+ _loose = _releasable_loose_tasks(root, state)
1276
+ if _loose:
1277
+ print(f" → releasable: {len(_loose)} loose task(s) since last release")
1685
1278
 
1686
1279
  # fast-lane marker (fast-new-task-flag): tag an ACTIVE fast task so the lane is visible at a
1687
1280
  # glance. Presentation-only, existence-gated — a plain/absent active task is byte-unchanged.
@@ -1769,8 +1362,16 @@ def cmd_status(args: argparse.Namespace) -> None:
1769
1362
  elif active and active in tasks:
1770
1363
  ph = tasks[active]["phase"]
1771
1364
  if ph == "done":
1365
+ # loop-aware resume (loop-aware-orient): a done task is NOT always "start the
1366
+ # next feature" — if its milestone goal is unmet we are at the loop juncture, so
1367
+ # STEER into the loop; if met, point at the close. PLAIN stays byte-identical.
1368
+ _hl, _nxt, _chap = _done_resume(root, state, active)
1772
1369
  print(f"\nresume : task '{active}' is done ({tasks[active]['gate']}).")
1773
- print(" start the next feature: add.py new-task <slug>")
1370
+ if _chap == "02-the-flow.md":
1371
+ print(" start the next feature: add.py new-task <slug>")
1372
+ else:
1373
+ print(f" {_hl} — {_nxt}")
1374
+ print(f" (the loop: .add/docs/{_chap})")
1774
1375
  else:
1775
1376
  print(f"\nresume : task '{active}' is at phase '{ph}'.")
1776
1377
  print(f" read .add/tasks/{active}/TASK.md and continue that phase.")
@@ -1820,6 +1421,10 @@ def cmd_guide(args: argparse.Namespace) -> None:
1820
1421
  phase = t.get("phase")
1821
1422
  owner = _phase_owner(phase) # _die unmapped_phase before any stdout
1822
1423
  action, chapter = PHASE_GUIDE[phase] # phase is mapped, so PHASE_GUIDE has it too
1424
+ if phase == "done": # loop-aware-orient: steer the --json surface too
1425
+ _hl, _nxt, _chap = _done_resume(json_root, state, slug)
1426
+ if _chap != "02-the-flow.md": # loop juncture / goal met; PLAIN stays unchanged
1427
+ action, chapter = _nxt, _chap
1823
1428
  print(json.dumps({"task": slug, "phase": phase, "owner": owner,
1824
1429
  "stop": owner != "ai", "next_step": action,
1825
1430
  "chapter": f".add/docs/{chapter}", "gate": t.get("gate"),
@@ -1840,6 +1445,10 @@ def cmd_guide(args: argparse.Namespace) -> None:
1840
1445
  if entry is None: # corrupted/hand-edited state.json — fail clean, not KeyError
1841
1446
  _die(f"task '{slug}' has unknown phase '{phase}' (state.json corrupted?)")
1842
1447
  action, chapter = entry
1448
+ if phase == "done": # loop-aware-orient: steer at the loop juncture
1449
+ _hl, _nxt, _chap = _done_resume(root, state, slug)
1450
+ if _chap != "02-the-flow.md": # loop juncture / goal met; PLAIN stays unchanged
1451
+ action, chapter = _nxt, _chap
1843
1452
  # the guide names the driver too (task gate-owner-marker) — the SAME _driver_stop the
1844
1453
  # footer renders, on the next-step line. Computed AFTER the unknown-phase guard above,
1845
1454
  # so a bad phase fails clean and never reaches the marker (it invents no default).
@@ -1856,7 +1465,10 @@ def cmd_guide(args: argparse.Namespace) -> None:
1856
1465
  if phase == "verify":
1857
1466
  print("then : add.py gate PASS | RISK-ACCEPTED | HARD-STOP")
1858
1467
  elif phase == "done":
1859
- print("then : start the next feature -> add.py new-task <slug>")
1468
+ if chapter != "02-the-flow.md": # loop juncture / goal met -> the steered command
1469
+ print(f"then : {action}")
1470
+ else:
1471
+ print("then : start the next feature -> add.py new-task <slug>")
1860
1472
  else:
1861
1473
  print("then : add.py advance")
1862
1474
 
@@ -2292,6 +1904,41 @@ def _missing_captures(root: Path) -> list[str]:
2292
1904
  if not any((cap_dir / f"{n}.{ext}").is_file() for ext in _CAPTURE_EXTS)]
2293
1905
 
2294
1906
 
1907
+ def cmd_federate(args: argparse.Namespace) -> None:
1908
+ """Multi-repo federation: pull a producer repo's published, immutable contract snapshot into
1909
+ this repo. Mono vs multi-repo differ ONLY in snapshot-transport — this lands the byte-copy at
1910
+ the SAME local `.add/contracts/<id>.json` the monorepo path (tasks 3/4) already reads, so a
1911
+ `consumes: <id>` task then holds/pins identically. Designed-for-failure: unknown / missing /
1912
+ invalid / version-mismatched sources HARD-STOP and land NOTHING (never build blind)."""
1913
+ root = find_root()
1914
+ if root is None:
1915
+ _die("no_project")
1916
+ fid = args.id
1917
+ fed = _federation(root)
1918
+ if fid not in fed:
1919
+ _die(f"federation_unknown: no [federation.{fid}] in components.toml — declare the producer "
1920
+ f"repo's published snapshot source before pulling")
1921
+ source = (root.parent / fed[fid]["source"])
1922
+ try:
1923
+ raw = source.read_bytes() # bytes — the landed snapshot must be a byte-for-byte copy
1924
+ except OSError:
1925
+ _die(f"federation_source_missing: cannot read the producer snapshot at '{fed[fid]['source']}' "
1926
+ f"(resolved {source}) — publish/commit it in the producer repo first")
1927
+ try:
1928
+ snap = json.loads(raw.decode("utf-8"))
1929
+ except (json.JSONDecodeError, ValueError, UnicodeDecodeError):
1930
+ snap = None
1931
+ if not isinstance(snap, dict) or snap.get("id") != fid or not snap.get("hash"):
1932
+ _die(f"federation_snapshot_invalid: the source for '{fid}' is not a valid contract snapshot "
1933
+ f"(needs JSON with matching id + a hash) — refusing to land a guessed shape")
1934
+ pin = fed[fid]["pin"]
1935
+ if pin and snap.get("version") != pin:
1936
+ _die(f"federation_version_mismatch: [federation.{fid}] pins '{pin}' but the source is "
1937
+ f"'{snap.get('version')}' — bump the pin or wait for the producer to publish {pin}")
1938
+ _atomic_write_bytes(_contract_snapshot(root, fid), raw)
1939
+ print(f"federated '{fid}' {snap.get('version', '?')} {snap['hash']} from {fed[fid]['source']}")
1940
+
1941
+
2295
1942
  def cmd_check(args: argparse.Namespace) -> None:
2296
1943
  """Read-only integrity check of the .add project. Exit 1 if anything fails."""
2297
1944
  as_json = getattr(args, "json", False)
@@ -2314,6 +1961,7 @@ def cmd_check(args: argparse.Namespace) -> None:
2314
1961
  milestones = state.get("milestones") if isinstance(state.get("milestones"), dict) else {}
2315
1962
  archived_slugs = _archived_task_slugs(state) # archived deps still resolve
2316
1963
  warnings: list[tuple[str, str]] = [] # (name, reason) — nudges that NEVER feed `failed`
1964
+ infos: list[tuple[str, str]] = [] # (name, reason) — affirmations; NEVER feed `warned`/`failed`
2317
1965
  for slug, t in tasks.items():
2318
1966
  task_md = root / "tasks" / slug / "TASK.md"
2319
1967
  checks.append((task_md.exists(), f"task '{slug}' has TASK.md", "file missing"))
@@ -2325,6 +1973,10 @@ def cmd_check(args: argparse.Namespace) -> None:
2325
1973
  if ms is not None:
2326
1974
  checks.append((ms in milestones, f"task '{slug}' milestone resolves",
2327
1975
  f"unknown milestone {ms!r}"))
1976
+ elif t.get("fast"):
1977
+ # blessed milestone-free fast lane (standalone-fast-task): a --fast task with no
1978
+ # milestone is DELIBERATE — a soft INFO affirmation, never a WARN/orphan nudge.
1979
+ infos.append((f"task '{slug}'", "— standalone fast lane (milestone-free by design)"))
2328
1980
  else:
2329
1981
  # warn-never-block: a task outside a milestone is a structural nudge back toward
2330
1982
  # the intake flow — NOT a failure. Names structure, never the act of intake.
@@ -2340,6 +1992,29 @@ def cmd_check(args: argparse.Namespace) -> None:
2340
1992
  if _alvl is None and t.get("phase") not in ("done", "observe"):
2341
1993
  warnings.append((f"task '{slug}'", "has no explicit autonomy level (implicit_autonomy) "
2342
1994
  "— run `add.py autonomy set <level>` to set it"))
1995
+ # per-component-verify: a bound task whose component declares no green_bar can't be
1996
+ # gated on a bar — surface it (WARN, never red). Unbound / "?" -> silent.
1997
+ _tc = _task_component(root, slug)
1998
+ if _tc and _tc != "?" and not (_components(root).get(_tc) or {}).get("green_bar"):
1999
+ warnings.append((f"task '{slug}'", f"component_green_bar_unset — bound component '{_tc}' "
2000
+ "declares no green_bar; the per-component gate cannot check a bar"))
2001
+ # cross-component-contract: a consumer whose pinned hash drifted from the live snapshot
2002
+ # (the producer re-froze a CHANGED shape) — the §7-stale cue. Degrade-safe (unreadable
2003
+ # snapshot ⇒ no finding here; the missing-snapshot HARD-STOP lives at the advance crossing).
2004
+ _pin = t.get("contract_pin")
2005
+ if _pin:
2006
+ try:
2007
+ _live = json.loads(_contract_snapshot(root, _pin["id"]).read_text(encoding="utf-8")).get("hash")
2008
+ except (OSError, ValueError, KeyError, TypeError, AttributeError):
2009
+ _live = None
2010
+ if _live is None: # missing / corrupt / hash-less ⇒ SURFACE, never mask
2011
+ warnings.append((f"task '{slug}'", f"contract_snapshot_unreadable — pinned contract "
2012
+ f"'{_pin.get('id')}' snapshot is missing or corrupt; re-publish the "
2013
+ "producer contract (cannot confirm the pin is current)"))
2014
+ elif _live != _pin.get("hash"):
2015
+ warnings.append((f"task '{slug}'", f"contract_consumer_stale — pinned contract "
2016
+ f"'{_pin.get('id')}' changed shape since pin; re-pin (re-cross contract→tests) "
2017
+ "after reviewing the producer's new frozen shape"))
2343
2018
  for dep in t.get("depends_on") or []:
2344
2019
  checks.append((dep in tasks or dep in archived_slugs,
2345
2020
  f"task '{slug}' dep '{dep}' resolves", "unknown task"))
@@ -2453,6 +2128,26 @@ def cmd_check(args: argparse.Namespace) -> None:
2453
2128
  checks.append((cycle is None, "task dependencies are acyclic",
2454
2129
  f"cycle: {' -> '.join(cycle)}" if cycle else ""))
2455
2130
 
2131
+ # component registry (component-aware-add): a malformed .add/components.toml, a root
2132
+ # escaping the project, or a task binding an unregistered component are integrity FAILS
2133
+ # — fail-closed RED (like wave_ledger_malformed), loud but never a crash (the readers
2134
+ # themselves degrade-safe). Silent when there is no components.toml.
2135
+ for _ccode, _cdetail in _component_findings(root):
2136
+ checks.append((False, f"component registry ({_ccode})", _cdetail))
2137
+ # cross-component-contract: a [contract.<id>] naming an unregistered producer is an
2138
+ # integrity FAIL (same fail-closed RED discipline; the readers stay degrade-safe).
2139
+ for _ccode, _cdetail in _contract_findings(root):
2140
+ checks.append((False, f"contract registry ({_ccode})", _cdetail))
2141
+ # multirepo-federation: a declared [federation.<id>] whose producer-repo source is unreadable
2142
+ # is a BROKEN JOIN — surface it EARLY as a WARN (never red alone; `federate pull` is where it
2143
+ # HARD-STOPs). Silent when no federation is declared (opt-in / byte-identical).
2144
+ for _fid, _fspec in _federation(root).items():
2145
+ if not (root.parent / _fspec["source"]).is_file():
2146
+ warnings.append((f"federation '{_fid}'",
2147
+ f"federation_source_unreadable — the producer snapshot at "
2148
+ f"'{_fspec['source']}' is missing/unreadable; `federate pull {_fid}` "
2149
+ "will hard-stop until the producer repo publishes it"))
2150
+
2456
2151
  # UDD foundation (udd-check-lint): lint a project's named set under .add/design/ —
2457
2152
  # composes the token + catalog/tree validators + the cross-file prop-token resolution.
2458
2153
  # Silent when absent; read-only; fail-closed on malformed JSON.
@@ -2471,10 +2166,15 @@ def cmd_check(args: argparse.Namespace) -> None:
2471
2166
  passed = sum(1 for ok, _, _ in checks if ok)
2472
2167
  failed = len(checks) - passed
2473
2168
  if as_json:
2169
+ # `infos`/`informed` are ADDITIVE (standalone-fast-task) — affirmations that never feed
2170
+ # `warned`/`failed`; existing keys are untouched so prior consumers keep working.
2474
2171
  print(json.dumps({"passed": passed, "failed": failed,
2475
2172
  "warned": len(warnings),
2476
2173
  "warnings": [{"name": name, "reason": reason}
2477
2174
  for name, reason in warnings],
2175
+ "informed": len(infos),
2176
+ "infos": [{"name": name, "reason": reason}
2177
+ for name, reason in infos],
2478
2178
  "checks": [{"ok": ok, "name": desc,
2479
2179
  "reason": reason if not ok else ""}
2480
2180
  for ok, desc, reason in checks]}))
@@ -2483,6 +2183,8 @@ def cmd_check(args: argparse.Namespace) -> None:
2483
2183
  print(f"PASS {desc}" if ok else f"FAIL {desc}: {reason}")
2484
2184
  for name, reason in warnings:
2485
2185
  print(f"WARN {name} {reason}")
2186
+ for name, reason in infos:
2187
+ print(f"INFO {name} {reason}")
2486
2188
  summary = f"check: {passed} passed, {failed} failed"
2487
2189
  if warnings:
2488
2190
  summary += f" ({len(warnings)} warnings)" # frozen §3: summary gains "(N warnings)"
@@ -2564,7 +2266,7 @@ def cmd_mine(args: argparse.Namespace) -> None:
2564
2266
  if root is None:
2565
2267
  _die("no_project")
2566
2268
  state = load_state(root)
2567
- me = _parse_actor_arg(args.actor) if getattr(args, "actor", None) else _whoami(state)
2269
+ me = identity._parse_actor_arg(args.actor) if getattr(args, "actor", None) else identity._whoami(state)
2568
2270
  rows = _my_work(state, me)
2569
2271
  if getattr(args, "json", False):
2570
2272
  print(json.dumps({"actor": me, "tasks": rows}))
@@ -2783,7 +2485,7 @@ def cmd_milestone_confirm(args: argparse.Namespace) -> None:
2783
2485
  m["confirmed"] = True
2784
2486
  m["confirmed_at"] = _now()
2785
2487
  m["confirmed_by"] = who
2786
- m["actor"] = _actor_stamp(state) # structured actor alongside the free-text confirmed_by
2488
+ m["actor"] = identity._actor_stamp(state) # structured actor alongside the free-text confirmed_by
2787
2489
  m["updated"] = _now()
2788
2490
  save_state(root, state)
2789
2491
  print(f"confirmed milestone '{slug}' (by {who}) — new-task is now open for it.")
@@ -3036,7 +2738,7 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
3036
2738
  # Stamp WHO closed it BEFORE rendering the retro, so the persisted exit report records
3037
2739
  # the closer (identity-in-status: the retro IS the report `report <ms>` re-renders, so both
3038
2740
  # must reflect the same final state). In-memory only here — save_state below commits it.
3039
- state["milestones"][slug]["done_actor"] = _actor_stamp(state)
2741
+ state["milestones"][slug]["done_actor"] = identity._actor_stamp(state)
3040
2742
  # Fail-closed: render+persist the exit report (RETRO.md) BEFORE committing the
3041
2743
  # status flip, so a write failure rolls back naturally (status never commits ->
3042
2744
  # no done-without-retro state). The retro step is read-only on state.json.
@@ -3339,165 +3041,14 @@ def _sync_task_marker(root: Path, slug: str, phase: str) -> None:
3339
3041
  # state.json; prose (observe delta, deltas) is parsed from each TASK.md and
3340
3042
  # fails CLOSED to `(unknown)` rather than omitting silently.
3341
3043
 
3342
- _DEFAULT_WIDTH = 72 # fixed width for the persisted/canonical render (RETRO.md)
3343
3044
  # Two glyph tiers. Alignment is correct only with ASCII in column-positioned
3344
3045
  # cells (every ASCII char is 1 display cell); Unicode glyphs sit at line-END
3345
3046
  # (the PROGRESS track) or in non-aligned rows, where width can't break columns.
3346
3047
  _UNICODE = {"reached": "●", "current": "◉", "pending": "○", "h": "═", "rule": "─", "bullet": "•"}
3347
3048
  _ASCII = {"reached": "#", "current": ">", "pending": ".", "h": "=", "rule": "-", "bullet": "*"}
3348
3049
  _GATE_SHORT = {"PASS": "PASS", "RISK-ACCEPTED": "RISK", "HARD-STOP": "STOP", "none": "—"}
3349
- _ANSI = {"green": "\x1b[32m", "yellow": "\x1b[33m", "red": "\x1b[31m",
3350
- "dim": "\x1b[2m", "reset": "\x1b[0m"}
3351
-
3352
-
3353
- def _bar(num: int, den: int, cells: int, g: dict) -> str:
3354
- """A progress bar; 0/0 -> all-empty (no divide-by-zero)."""
3355
- filled = 0 if den <= 0 else round(num / den * cells)
3356
- filled = max(0, min(cells, filled))
3357
- return g["reached"] * filled + g["pending"] * (cells - filled)
3358
-
3359
-
3360
- def _phase_track(phase: str, g: dict) -> str:
3361
- """Compact 9-cell pipeline (no labels — a single legend explains it):
3362
- reached · current · pending. A done task -> all reached."""
3363
- try:
3364
- ci = PHASES.index(phase)
3365
- except ValueError:
3366
- ci = 0
3367
- cells = []
3368
- for i in range(len(PHASES)):
3369
- if phase == "done" or i < ci:
3370
- cells.append(g["reached"])
3371
- elif i == ci:
3372
- cells.append(g["current"])
3373
- else:
3374
- cells.append(g["pending"])
3375
- return "".join(cells)
3376
-
3377
-
3378
- def _use_ascii() -> bool:
3379
- """ASCII tier when the terminal can't render Unicode (non-UTF-8 / dumb)."""
3380
- enc = (getattr(sys.stdout, "encoding", "") or "").lower()
3381
- return ("utf" not in enc) or (os.environ.get("TERM") == "dumb")
3382
-
3383
-
3384
- def _color_enabled() -> bool:
3385
- """Color only on an interactive tty, honoring NO_COLOR and TERM."""
3386
- return (sys.stdout.isatty() and not os.environ.get("NO_COLOR")
3387
- and os.environ.get("TERM", "") not in ("dumb", ""))
3388
-
3389
-
3390
- def _term_width() -> int:
3391
- try:
3392
- import shutil
3393
- return min(max(shutil.get_terminal_size().columns, 64), 100)
3394
- except Exception:
3395
- return _DEFAULT_WIDTH
3396
-
3397
-
3398
- def _colorize(s: str) -> str:
3399
- """Apply ANSI to status tokens — redundant to the text, never the sole signal.
3400
- Applied ONLY to tty stdout; the persisted RETRO.md string stays plain."""
3401
- c = _ANSI
3402
- s = re.sub(r"\bDONE\b", c["green"] + "DONE" + c["reset"], s)
3403
- s = re.sub(r"\bBLOCKED\b", c["red"] + "BLOCKED" + c["reset"], s)
3404
- s = re.sub(r"\bPASS\b", c["green"] + "PASS" + c["reset"], s)
3405
- s = re.sub(r"\bRISK\b", c["yellow"] + "RISK" + c["reset"], s)
3406
- s = re.sub(r"\bSTOP\b", c["red"] + "STOP" + c["reset"], s)
3407
- return s
3408
-
3409
-
3410
- def _project_goal(root: Path) -> str:
3411
- """The project GOAL — the value of the first `goal:` line in PROJECT.md, else
3412
- GOAL_UNSET. Read-only and fail-closed: a missing/unreadable foundation or a
3413
- blank value degrades to the sentinel (orientation never raises). Mirrors how
3414
- _milestone_doc reads the milestone goal — the foundation is the single source."""
3415
- f = root / "PROJECT.md"
3416
- try:
3417
- for line in f.read_text(encoding="utf-8").splitlines():
3418
- if line.startswith("goal:"):
3419
- return line.split(":", 1)[1].strip() or GOAL_UNSET
3420
- except OSError:
3421
- pass
3422
- return GOAL_UNSET
3423
-
3424
-
3425
- def _project_autonomy_token(root: Path):
3426
- """The RAW autonomy declaration in PROJECT.md — a recognized rung, None when no
3427
- declaration line is present, or "?" for a real-but-unrecognized token. Uses the
3428
- anchored _autonomy_level (a title/prose substring is never a declaration) with
3429
- HTML comments stripped. Unreadable foundation -> None. Read-only and PURE."""
3430
- try:
3431
- text = (root / "PROJECT.md").read_text(encoding="utf-8")
3432
- except OSError:
3433
- return None
3434
- return _autonomy_level(re.sub(r"<!--.*?-->", "", text, flags=re.S))
3435
-
3436
-
3437
- def _project_autonomy(root: Path) -> str:
3438
- """The autonomy rung a new task INHERITS from the project default. Fail-SAFE:
3439
- no declaration -> "auto" (the method default; v7: absent = auto); an unrecognized
3440
- token -> "conservative" (NEVER silently "auto"); an unreadable foundation -> "auto".
3441
- Read-only and PURE — mirrors _project_goal; the seed source for cmd_new_task."""
3442
- tok = _project_autonomy_token(root)
3443
- return "auto" if tok is None else ("conservative" if tok == "?" else tok)
3444
-
3445
-
3446
- def _milestone_doc(root: Path, mslug: str) -> tuple[str, str]:
3447
- """(title, goal) from MILESTONE.md; ('(unknown)','(unknown)') if the doc is gone."""
3448
- f = root / "milestones" / mslug / MILESTONE_FILE
3449
- if not f.exists():
3450
- return "(unknown)", "(unknown)"
3451
- title, goal = "(unknown)", "(unknown)"
3452
- for line in f.read_text(encoding="utf-8").splitlines():
3453
- if line.startswith("# MILESTONE:"):
3454
- title = line.split(":", 1)[1].strip() or "(unknown)"
3455
- elif line.startswith("goal:"):
3456
- goal = line.split(":", 1)[1].strip() or "(unknown)"
3457
- break
3458
- return title, goal
3459
-
3460
-
3461
- def _exit_criteria(root: Path, mslug: str) -> tuple[int, int]:
3462
- """(met, total) checkbox tally inside MILESTONE.md's 'Exit criteria' section."""
3463
- f = root / "milestones" / mslug / MILESTONE_FILE
3464
- if not f.exists():
3465
- return 0, 0
3466
- m = re.search(r"## Exit criteria.*?(?=\n## |\Z)", f.read_text(encoding="utf-8"), re.S)
3467
- if not m:
3468
- return 0, 0
3469
- sec = m.group(0)
3470
- met = len(re.findall(r"- \[x\]", sec))
3471
- total = met + len(re.findall(r"- \[ \]", sec))
3472
- return met, total
3473
-
3474
-
3475
3050
  # A non-empty `(verify: <citation>)` on an exit-criterion line — at least one non-whitespace
3476
3051
  # char inside, so a bare `(verify:)`/`(verify: )` does NOT count (the mid-text substring trap).
3477
- _VERIFY_CITE_RE = re.compile(r"\(verify:\s*\S.*?\)", re.I)
3478
-
3479
-
3480
- def _exit_criteria_cited(root: Path, mslug: str) -> tuple[int, int]:
3481
- """(cited, total) over MILESTONE.md's 'Exit criteria' section. total = every
3482
- `- [ ]`/`- [x]` criterion line; cited = those carrying a NON-EMPTY
3483
- `(verify: <citation>)`. Read-only and PURE; missing file/section -> (0, 0).
3484
- Mirrors _exit_criteria (the checkbox tally) — an ADDITIVE classification beside
3485
- it; it never touches `milestone_goal_unmet`."""
3486
- f = root / "milestones" / mslug / MILESTONE_FILE
3487
- if not f.exists():
3488
- return 0, 0
3489
- m = re.search(r"## Exit criteria.*?(?=\n## |\Z)", f.read_text(encoding="utf-8"), re.S)
3490
- if not m:
3491
- return 0, 0
3492
- cited = total = 0
3493
- for ln in m.group(0).splitlines():
3494
- if re.match(r"\s*- \[[ x]\]", ln):
3495
- total += 1
3496
- if _VERIFY_CITE_RE.search(ln):
3497
- cited += 1
3498
- return cited, total
3499
-
3500
-
3501
3052
  def _goal_auto_ready(root: Path, mslug: str) -> bool:
3502
3053
  """True iff the milestone goal is AUTO-READY: its Exit criteria has >= 1 criterion
3503
3054
  AND every one cites a verifier (cited == total) — so the engine can self-verify the
@@ -3507,32 +3058,6 @@ def _goal_auto_ready(root: Path, mslug: str) -> bool:
3507
3058
  return total >= 1 and cited == total
3508
3059
 
3509
3060
 
3510
- def _stage_criteria(root: Path) -> tuple[int, int]:
3511
- """(met, total) checkbox tally inside PROJECT.md's 'Stage goal criteria' section — the
3512
- PROJECT.md analog of _exit_criteria (v22): the human's stage-covered affirmation. Read-only
3513
- and fail-closed to (0, 0): a missing file, a missing section, or any read error never raises
3514
- and never fabricates a cue (so an unreadable foundation withholds graduation, design-for-failure)."""
3515
- try:
3516
- text = (root / "PROJECT.md").read_text(encoding="utf-8")
3517
- except OSError:
3518
- return 0, 0
3519
- m = re.search(r"## Stage goal criteria.*?(?=\n## |\Z)", text, re.S)
3520
- if not m:
3521
- return 0, 0
3522
- sec = m.group(0)
3523
- met = len(re.findall(r"- \[x\]", sec))
3524
- total = met + len(re.findall(r"- \[ \]", sec))
3525
- return met, total
3526
-
3527
-
3528
- def _all_milestones_done(state: dict) -> bool:
3529
- """True when the project HAS milestones and EVERY one is status=done (v22). Archived
3530
- milestones are absent from state['milestones'] (removed by the archive lifecycle), so they
3531
- do not count; a project with zero milestones is not 'covered' and returns False."""
3532
- ms = state.get("milestones") or {}
3533
- return bool(ms) and all(m.get("status") == "done" for m in ms.values())
3534
-
3535
-
3536
3061
  def _graduation_ready(root: Path, state: dict) -> tuple[bool, int, int]:
3537
3062
  """(ready, met, total) for the stage-graduation cue (v22): every milestone done AND the
3538
3063
  human's stage-goal-criteria all checked (total>0 and met==total). The SINGLE source the
@@ -3542,93 +3067,6 @@ def _graduation_ready(root: Path, state: dict) -> tuple[bool, int, int]:
3542
3067
  return ready, met, total
3543
3068
 
3544
3069
 
3545
- def _count_test_defs(f: Path) -> int:
3546
- """`def test_` occurrences in one file — the ONE counting regex (primary and
3547
- §4-declared fallback share it by construction). OSError -> 0, fail-closed."""
3548
- try:
3549
- return len(re.findall(r"^\s*def test_", f.read_text(encoding="utf-8"), re.M))
3550
- except OSError:
3551
- return 0
3552
-
3553
-
3554
- def _primary_test_files(root: Path, slug: str) -> list[Path]:
3555
- """The PRIMARY test set — *.py directly in the task's tests/ dir (the stable
3556
- path). A list so the tamper tripwire can hash exactly what the engine counts."""
3557
- d = root / "tasks" / slug / "tests"
3558
- if not d.is_dir():
3559
- return []
3560
- return sorted(d.glob("*.py"))
3561
-
3562
-
3563
- def _tests_count(root: Path, slug: str) -> int:
3564
- return sum(_count_test_defs(f) for f in _primary_test_files(root, slug))
3565
-
3566
-
3567
- def _confined(p: Path, rootp: Path) -> bool:
3568
- """True only if p resolves (symlinks followed) inside rootp; errors -> False.
3569
- The v2 confinement check — no read is attempted on a path that fails it."""
3570
- try:
3571
- return p.resolve().is_relative_to(rootp)
3572
- except OSError:
3573
- return False
3574
-
3575
-
3576
- def _declared_test_files(root: Path, slug: str) -> list[Path]:
3577
- """Resolve the §4 'Tests live in:' declared path(s) to a deduped file list. PURE.
3578
- Tokens are the backticked spans on the FIRST declaring line of the raw §4 body.
3579
- Resolution: './…' -> task dir · contains '/' -> project root (parent of .add) ·
3580
- bare name -> sibling of the previous resolved token (else task dir). A directory
3581
- token yields the *.py files directly inside it; resolved files are deduped.
3582
- v2 confinement: every path must resolve inside the project root — '..' traversal,
3583
- absolute tokens, and symlink escapes are all dropped, fail-closed."""
3584
- body = _raw_phase_bodies(root, slug).get(4, "")
3585
- m = re.search(r"^\s*Tests live in:.*$", body, re.M)
3586
- if not m:
3587
- return []
3588
- tdir = root / "tasks" / slug
3589
- rootp = root.parent.resolve()
3590
- files: list[Path] = []
3591
- prev_dir = None
3592
- for tok in re.findall(r"`([^`]+)`", m.group(0)):
3593
- tok = tok.strip()
3594
- if tok.startswith("./"):
3595
- p = tdir / tok[2:]
3596
- elif "/" in tok:
3597
- p = root.parent / tok
3598
- else:
3599
- p = (prev_dir or tdir) / tok
3600
- try:
3601
- if not _confined(p, rootp):
3602
- continue
3603
- if p.is_dir():
3604
- cand, prev_dir = sorted(f for f in p.glob("*.py")
3605
- if _confined(f, rootp)), p
3606
- elif p.is_file() and p.suffix == ".py":
3607
- cand, prev_dir = [p], p.parent
3608
- else:
3609
- continue
3610
- except OSError:
3611
- continue
3612
- files.extend(f for f in cand if f not in files)
3613
- return files
3614
-
3615
-
3616
- def _declared_tests_count(root: Path, slug: str) -> int:
3617
- """Count tests at the §4 'Tests live in:' declared path(s). PURE, fail-closed 0."""
3618
- return sum(_count_test_defs(f) for f in _declared_test_files(root, slug))
3619
-
3620
-
3621
- def _tests_info(root: Path, slug: str) -> tuple[int, bool]:
3622
- """(count, declared). The tests/ dir count ALWAYS wins when > 0; otherwise the
3623
- §4-declared fallback — flagged True only when it supplied a non-zero count, so
3624
- a true zero stays a bare, honest 0."""
3625
- primary = _tests_count(root, slug)
3626
- if primary > 0:
3627
- return primary, False
3628
- declared = _declared_tests_count(root, slug)
3629
- return (declared, True) if declared > 0 else (0, False)
3630
-
3631
-
3632
3070
  def _resolved_test_files(root: Path, slug: str) -> list[Path]:
3633
3071
  """The file set the engine treats as this task's tests — the PRIMARY set wins
3634
3072
  when it yields any test defs, else the §4-declared set (mirrors _tests_info's
@@ -3639,19 +3077,6 @@ def _resolved_test_files(root: Path, slug: str) -> list[Path]:
3639
3077
  return _declared_test_files(root, slug)
3640
3078
 
3641
3079
 
3642
- def _md5_text(s: str) -> str:
3643
- return hashlib.md5(s.encode("utf-8")).hexdigest()
3644
-
3645
-
3646
- def _md5_file(p: Path) -> str | None:
3647
- """md5 of a file's bytes; None on ANY read error (fail-closed — a tracked file
3648
- that cannot be read counts as DIVERGED at the gate, never a crash)."""
3649
- try:
3650
- return hashlib.md5(p.read_bytes()).hexdigest()
3651
- except OSError:
3652
- return None
3653
-
3654
-
3655
3080
  def _tripwire_snapshot(root: Path, slug: str, raw3: str) -> dict:
3656
3081
  """Freeze the md5 of the resolved red test files + the frozen §3 contract — the
3657
3082
  tamper baseline (verify-integrity). Keys are project-root-relative paths (stable
@@ -3701,6 +3126,119 @@ _SCOPE_EXCLUDE_FILES = (".DS_Store",) # plus *.pyc / *.tsbuildi
3701
3126
  _SCOPE_EXCLUDE_SUFFIXES = (".pyc", ".tsbuildinfo")
3702
3127
 
3703
3128
 
3129
+ # ── component registry (component-aware-add): declared components + task binding ─────
3130
+ # OPT-IN + DEGRADE-SAFE: with no .add/components.toml every reader is byte-identical to
3131
+ # pre-component ADD. A read NEVER raises (absent/unreadable/malformed → {} / dropped
3132
+ # cover); the loud surface is _component_findings, consumed by the scope gate (cmd_check).
3133
+ def _component_root(root: Path, name: str) -> str | None:
3134
+ """Project-root-relative path (trailing '/') of component `name`'s root, or None
3135
+ when the name is absent OR the root escapes the project (fail-closed — grants no
3136
+ scope cover, mirroring _declared_scope's _confined drop). PURE."""
3137
+ spec = _components(root).get(name)
3138
+ if not spec:
3139
+ return None
3140
+ rootp = root.parent.resolve()
3141
+ p = root.parent / spec["root"]
3142
+ if not _confined(p, rootp):
3143
+ return None
3144
+ try:
3145
+ return str(p.resolve().relative_to(rootp)).rstrip("/") + "/"
3146
+ except (OSError, ValueError):
3147
+ return None
3148
+
3149
+
3150
+ def _task_component(root: Path, slug: str):
3151
+ """The component a task binds to via its `component:` header token (anchored like
3152
+ autonomy). None = no line / unfilled `<…>` placeholder; "?" = a real token absent
3153
+ from the registry; otherwise the component name. PURE."""
3154
+ m = _COMPONENT_LINE_RE.search(_task_header(root, slug))
3155
+ if not m:
3156
+ return None
3157
+ tok = m.group(1).strip()
3158
+ return tok if tok in _components(root) else "?"
3159
+
3160
+
3161
+ def _task_green_bar(root: Path, slug: str) -> str | None:
3162
+ """The green_bar phrase of the task's bound component (per-component-verify), else
3163
+ None — unbound, "?", or no green_bar declared all yield None. PURE."""
3164
+ comp = _task_component(root, slug)
3165
+ if not comp or comp == "?":
3166
+ return None
3167
+ return (_components(root).get(comp) or {}).get("green_bar") or None
3168
+
3169
+
3170
+ def _component_findings(root: Path) -> list[tuple[str, str]]:
3171
+ """The loud gate surface for the registry — the codes a degrade-safe read passes
3172
+ over silently. Consumed by cmd_check (the scope_violation surface). [] when clean."""
3173
+ findings: list[tuple[str, str]] = []
3174
+ try:
3175
+ raw = (root / "components.toml").read_bytes()
3176
+ except OSError:
3177
+ return findings # absent/unreadable = opt-out, nothing to report
3178
+ data = None
3179
+ if tomllib is None:
3180
+ findings.append(("components_malformed", "components.toml present but tomllib unavailable (Python < 3.11)"))
3181
+ else:
3182
+ try:
3183
+ data = tomllib.loads(raw.decode("utf-8"))
3184
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError) as e:
3185
+ findings.append(("components_malformed", f"components.toml: {e}"))
3186
+ if data is not None:
3187
+ rootp = root.parent.resolve()
3188
+ for name, spec in (data.get("component") or {}).items():
3189
+ if name == "?":
3190
+ findings.append(("components_malformed", "component name '?' is reserved (the unknown-binding sentinel)"))
3191
+ continue
3192
+ if not isinstance(spec, dict) or not isinstance(spec.get("root"), str):
3193
+ findings.append(("components_malformed", f"[component.{name}] missing required `root`"))
3194
+ continue
3195
+ if not _confined(root.parent / spec["root"], rootp):
3196
+ findings.append(("component_root_outside", f"[component.{name}] root {spec['root']!r} escapes the project"))
3197
+ known = set(_components(root))
3198
+ try:
3199
+ task_dirs = sorted(p for p in (root / "tasks").iterdir() if p.is_dir())
3200
+ except OSError:
3201
+ task_dirs = [] # unreadable tasks/ degrades safe — never crash a read
3202
+ for d in task_dirs:
3203
+ tc = _task_component(root, d.name)
3204
+ if tc is not None and tc not in known: # "?" or a stale name
3205
+ findings.append(("component_unknown", f"task {d.name} binds an unregistered component"))
3206
+ return findings
3207
+
3208
+
3209
+ # ── cross-component contracts (cross-component-contract) ──────────────────────────────────
3210
+ # OPT-IN + DEGRADE-SAFE, like the component readers: no [contract.*] / no produces|consumes
3211
+ # header ⇒ every path below is byte-identical to pre-contract ADD. A read NEVER raises.
3212
+ def _task_produces(root: Path, slug: str) -> str | None:
3213
+ m = _PRODUCES_LINE_RE.search(_task_header(root, slug))
3214
+ return m.group(1).strip() if m else None
3215
+
3216
+
3217
+ def _task_consumes(root: Path, slug: str) -> str | None:
3218
+ m = _CONSUMES_LINE_RE.search(_task_header(root, slug))
3219
+ return m.group(1).strip() if m else None
3220
+
3221
+
3222
+ def _contract_body_hash(raw3: str) -> str:
3223
+ """md5 of the §3 contract SHAPE — the first ```fenced``` block, whitespace-normalized. The
3224
+ version stamp + freeze flags are excluded (fallback strips Status:/flag/change-request lines)
3225
+ so a pure version bump does NOT churn pinned consumers stale. PURE."""
3226
+ m = re.search(r"```(.*?)```", raw3, re.DOTALL)
3227
+ body = m.group(1) if m else re.sub(r"(?m)^(Status:|.*surfaced at freeze:|v\d+ CHANGE REQUEST).*$", "", raw3)
3228
+ return _md5_text(re.sub(r"\s+", " ", body).strip())
3229
+
3230
+
3231
+ def _contract_findings(root: Path) -> list[tuple[str, str]]:
3232
+ """The loud gate surface for cross-component contracts — [] when clean / opted-out."""
3233
+ findings: list[tuple[str, str]] = []
3234
+ known = set(_components(root))
3235
+ for cid, spec in _contracts(root).items():
3236
+ if spec["producer"] not in known:
3237
+ findings.append(("contract_producer_unknown",
3238
+ f"[contract.{cid}] producer {spec['producer']!r} is not a declared component"))
3239
+ return findings
3240
+
3241
+
3704
3242
  def _declared_scope(root: Path, slug: str) -> list[str] | None:
3705
3243
  """Resolve the §5 'Scope (may touch):' declaration to project-root-relative
3706
3244
  strings (directory tokens keep a trailing '/'). The frozen scope-decl-template
@@ -3710,11 +3248,19 @@ def _declared_scope(root: Path, slug: str) -> list[str] | None:
3710
3248
  root, fail-closed — with ONE divergence: a directory token covers its WHOLE
3711
3249
  subtree (containment, judged by _in_scope). None = no Scope line (UNDECLARED,
3712
3250
  grandfathered — never retro-red); [] = a line whose every token was dropped
3713
- (a garbage declaration grants NO cover)."""
3251
+ (a garbage declaration grants NO cover).
3252
+
3253
+ component-aware-add: when the task binds a known `component:` (_task_component),
3254
+ that component's root subtree (_component_root) is APPENDED to the resolved tokens
3255
+ (dedup) — composing with the explicit declaration, never redrawing token resolution.
3256
+ A bound task with NO Scope line returns [component_root] (not None); an UNBOUND task
3257
+ is byte-identical to before."""
3258
+ comp = _task_component(root, slug)
3259
+ croot = _component_root(root, comp) if comp and comp != "?" else None
3714
3260
  body = _raw_phase_bodies(root, slug).get(5, "")
3715
3261
  m = re.search(r"^\s*Scope \(may touch\):.*$", body, re.M)
3716
3262
  if not m:
3717
- return None
3263
+ return [croot] if croot else None
3718
3264
  tdir = root / "tasks" / slug
3719
3265
  rootp = root.parent.resolve()
3720
3266
  out: list[str] = []
@@ -3740,21 +3286,11 @@ def _declared_scope(root: Path, slug: str) -> list[str] | None:
3740
3286
  continue
3741
3287
  if rel not in out:
3742
3288
  out.append(rel)
3289
+ if croot and croot not in out: # component-aware-add: compose, never redraw
3290
+ out.append(croot)
3743
3291
  return out
3744
3292
 
3745
3293
 
3746
- def _in_scope(rel: str, declared: list[str]) -> bool:
3747
- """True when rel falls under any declared token — exact match for a file
3748
- token, whole-subtree prefix containment for a directory token ('…/')."""
3749
- for tok in declared:
3750
- if tok.endswith("/"):
3751
- if rel.startswith(tok) or rel == tok.rstrip("/"):
3752
- return True
3753
- elif rel == tok:
3754
- return True
3755
- return False
3756
-
3757
-
3758
3294
  def _scope_walk(rootp: Path) -> dict[str, str]:
3759
3295
  """{project-root-relative path: md5} over the project tree, pruning
3760
3296
  _SCOPE_EXCLUDE_DIRS at any depth and skipping bytecode/OS junk +
@@ -3885,6 +3421,28 @@ def _heal_or_escalate(root: Path, state: dict, slug: str, *, reason: str, source
3885
3421
  raise SystemExit(3) # redo signal (distinct from _die's 1, argparse's 2)
3886
3422
 
3887
3423
 
3424
+ def _consumer_stale_guard(root: Path, state: dict, slug: str) -> None:
3425
+ """Refuse a COMPLETING gate when a `consumes:` task's pinned producer contract hash is STALE
3426
+ (the producer re-froze a CHANGED shape since the pin) — the consumer built against an
3427
+ out-of-date contract (consumer-stale-gate, the gate twin of cmd_check's contract_consumer_stale
3428
+ warning). Recoverable, not a cheat: re-pin by re-crossing contract->tests after reviewing the
3429
+ new frozen shape. Degrade-safe — an unreadable/missing live snapshot is NOT decided here (it
3430
+ stays a cmd_check warning + the advance-time contract_snapshot_missing HARD-STOP); only a
3431
+ CONFIRMED hash drift blocks. Placed with the other completing guards, BEFORE the waiver write,
3432
+ so a stale pin is never launderable through RISK-ACCEPTED; HARD-STOP never reaches here."""
3433
+ pin = state["tasks"][slug].get("contract_pin")
3434
+ if not pin:
3435
+ return
3436
+ try:
3437
+ live = json.loads(_contract_snapshot(root, pin["id"]).read_text(encoding="utf-8")).get("hash")
3438
+ except (OSError, ValueError, KeyError, TypeError, AttributeError):
3439
+ return # unreadable -> surfaced by cmd_check, not confirmable as stale here
3440
+ if live is not None and live != pin.get("hash"):
3441
+ _die(f"contract_consumer_stale: task '{slug}' pinned contract '{pin['id']}' changed shape "
3442
+ "since the pin (the producer re-froze) — re-pin by re-crossing contract->tests after "
3443
+ "reviewing the producer's new frozen shape; never complete against a stale contract")
3444
+
3445
+
3888
3446
  def _tamper_guard(root: Path, state: dict, slug: str) -> None:
3889
3447
  """HARD-STOP a COMPLETING gate when the tripwire shows tampering — the method's
3890
3448
  first mechanical cheat block (verify-integrity). Tri-state, co-witnessed by
@@ -3911,89 +3469,6 @@ def _tamper_guard(root: Path, state: dict, slug: str) -> None:
3911
3469
  reason="tamper_detected:" + ",".join(diffs), source="tamper")
3912
3470
 
3913
3471
 
3914
- def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
3915
- """(observe_delta, [delta lines]) from the task's TASK.md §7 — captured at FULL
3916
- fidelity: both fields wrap across physical lines in real files, so continuation
3917
- lines are JOINED. Scoped to the OBSERVE section so we read the FIELD, not §1 prose
3918
- that names it. Fail-closed to '(unknown)' on a missing file / `<...>` placeholder."""
3919
- f = root / "tasks" / slug / "TASK.md"
3920
- if not f.exists():
3921
- return "(unknown)", []
3922
- text = f.read_text(encoding="utf-8")
3923
- m7 = re.search(r"##\s*7\s*·\s*OBSERVE.*\Z", text, re.S)
3924
- section = m7.group(0) if m7 else text
3925
- lines = section.splitlines()
3926
- # observe: prefer the first OPEN SPEC delta from the "### Spec delta" block; fall
3927
- # back to the legacy "Spec delta for the next loop:" free-text field (archived
3928
- # tasks predate the block); else "(unknown)".
3929
- observe = "(unknown)"
3930
- for unit in _spec_delta_entries(section):
3931
- m = _SPEC_DELTA_RE.match(unit[0])
3932
- if m.group(2) != "open":
3933
- continue
3934
- tail = " ".join([m.group(3).strip(), *unit[1:]]).strip()
3935
- em = _EVIDENCE_RE.match(tail)
3936
- first = (em.group(1).strip() if em else tail)
3937
- if first and not first.startswith("<"):
3938
- observe = first
3939
- break
3940
- if observe == "(unknown)":
3941
- for i, ln in enumerate(lines):
3942
- m = re.match(r"\s*Spec delta for the next loop:\s*(.*)", ln)
3943
- if not m:
3944
- continue
3945
- parts = [m.group(1).strip()]
3946
- for nxt in lines[i + 1:]:
3947
- t = nxt.strip()
3948
- if not t or t.startswith("#") or t.startswith("- ") or t.startswith("Watch"):
3949
- break
3950
- parts.append(t)
3951
- joined = " ".join(p for p in parts if p).strip()
3952
- if joined and not joined.startswith("<"):
3953
- observe = joined
3954
- break
3955
-
3956
- # deltas: each "- [COMP · status] ..." plus its indented continuation lines
3957
- deltas, i = [], 0
3958
- while i < len(lines):
3959
- m = _DELTA_RE.match(lines[i])
3960
- if not m:
3961
- i += 1
3962
- continue
3963
- parts, j = [m.group(3).strip()], i + 1
3964
- while j < len(lines):
3965
- t = lines[j].strip()
3966
- if not t or t.startswith("#") or _DELTA_RE.match(lines[j]):
3967
- break
3968
- parts.append(t)
3969
- j += 1
3970
- deltas.append(f"{m.group(1)} · {m.group(2)} · {' '.join(parts).strip()}")
3971
- i = j
3972
- return observe, deltas
3973
-
3974
-
3975
- def _clip(s: str, maxlen: int) -> str:
3976
- """Trim a string to fit a fixed-width frame, ellipsizing if it overruns."""
3977
- return s if len(s) <= maxlen else s[:maxlen - 1].rstrip() + "…"
3978
-
3979
-
3980
- def _wrap(text: str, width: int, label: str) -> list[str]:
3981
- """Wrap `text` to `width`; the first line carries `label`, continuations are
3982
- blank-indented to the same width (so a multi-line goal shows 'goal' once)."""
3983
- cont = " " * len(label)
3984
- lines, cur = [], ""
3985
- for w in text.split():
3986
- if cur and len(cur) + 1 + len(w) > width:
3987
- lines.append(cur)
3988
- cur = w
3989
- else:
3990
- cur = f"{cur} {w}".strip()
3991
- if cur:
3992
- lines.append(cur)
3993
- lines = lines or ["(unknown)"]
3994
- return [(label if i == 0 else cont) + ln for i, ln in enumerate(lines)]
3995
-
3996
-
3997
3472
  def report_data(root: Path, state: dict, mslug: str) -> dict:
3998
3473
  """The single source of FACTS for a milestone report — pure, NO writes.
3999
3474
  Both the text dashboard (render_report) and `report --json` render from this,
@@ -4075,43 +3550,6 @@ def _clean_phase_body(body: str) -> str:
4075
3550
  return "\n".join(lines) if meaningful else "(empty)"
4076
3551
 
4077
3552
 
4078
- def _phase_spans(text: str) -> dict[int, str]:
4079
- """Split a TASK.md into RAW §1–§7 bodies keyed by section number — the ONE
4080
- canonical heading scan (`^##\\s*<n>\\s*·`, case/locale-proof); a body runs from
4081
- its heading to the next `## `/`---`/EOF. RAW = byte-faithful lines, no cleaning:
4082
- the decision-marker extractor (decide-digest) depends on byte-verbatim text.
4083
- KNOWN LIMIT: a §body containing a line-start `## ` or bare `---` truncates early —
4084
- today's TASK.md bodies don't (box-chars ─═, `### ` sub-heads)."""
4085
- lines = text.splitlines()
4086
- head = re.compile(r"^##\s*(\d+)\s*·")
4087
- starts: dict[int, int] = {}
4088
- for idx, ln in enumerate(lines):
4089
- m = head.match(ln)
4090
- if m:
4091
- n = int(m.group(1))
4092
- if 0 <= n <= 7 and n not in starts:
4093
- starts[n] = idx
4094
- out: dict[int, str] = {}
4095
- for n, idx in starts.items():
4096
- body_lines = []
4097
- for ln in lines[idx + 1:]:
4098
- if re.match(r"^##\s", ln) or re.match(r"^---\s*$", ln):
4099
- break
4100
- body_lines.append(ln)
4101
- out[n] = "\n".join(body_lines)
4102
- return out
4103
-
4104
-
4105
- def _raw_phase_bodies(root: Path, slug: str) -> dict[int, str]:
4106
- """RAW §bodies for one task (byte-faithful, for marker extraction). PURE.
4107
- Missing/unreadable TASK.md -> {} (fail-closed, like task_phases)."""
4108
- f = root / "tasks" / slug / "TASK.md"
4109
- try:
4110
- return _phase_spans(f.read_text(encoding="utf-8"))
4111
- except OSError:
4112
- return {}
4113
-
4114
-
4115
3553
  def task_phases(root: Path, slug: str) -> list[dict]:
4116
3554
  """The frozen per-task PHASE-DETAIL shape (v9-1): parse TASK.md §0–§7 into eight
4117
3555
  blocks ground→observe. PURE — NO writes. Each entry is
@@ -4698,10 +4136,6 @@ _DELTA_STATUSES = ("open", "folded", "rejected")
4698
4136
  # un-stripped lines directly; callers that pre-strip their input
4699
4137
  # (e.g. _collect_open_deltas, _lint_task_deltas) match the same way (\s*
4700
4138
  # matches zero). Anchored at line-start via re.match.
4701
- _DELTA_RE = re.compile(
4702
- r"\s*-\s*\[\s*(DDD|SDD|UDD|TDD|ADD)\s*·\s*(open|folded|rejected)\s*\]\s*(.+)$"
4703
- )
4704
- _EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
4705
4139
 
4706
4140
  # SPEC-delta track — a SEPARATE resolution lifecycle from the competency deltas
4707
4141
  # above. SPEC shares the "- [TAG · status]" LINE shape but its statuses are
@@ -4710,9 +4144,6 @@ _EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
4710
4144
  # tag to its legal status set so the ONE lint can reject a cross-set pairing
4711
4145
  # ([SPEC · folded], [SDD · seeded]) without a parallel grammar.
4712
4146
  _SPEC_STATUSES = ("open", "seeded", "dropped")
4713
- _SPEC_DELTA_RE = re.compile(
4714
- r"\s*-\s*\[\s*(SPEC)\s*·\s*(open|seeded|dropped)\s*\]\s*(.+)$"
4715
- )
4716
4147
  _STATUS_SETS = {**{c: _DELTA_STATUSES for c in _COMPETENCY_ORDER}, "SPEC": _SPEC_STATUSES}
4717
4148
 
4718
4149
  # Broad structural tag detector: finds ANY "- [tok · tok]" line (valid OR malformed).
@@ -4868,32 +4299,6 @@ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
4868
4299
  return by_comp
4869
4300
 
4870
4301
 
4871
- def _spec_delta_entries(text: str) -> list[list[str]]:
4872
- """Group a "### Spec delta" block into entries (tag line + continuation lines).
4873
-
4874
- Same grouping discipline as _collect_open_deltas' competency pass, keyed on
4875
- _SPEC_DELTA_RE: a tag line starts an entry; a non-"- " line continues it; a
4876
- blank/comment or a new "- " item ends it. Returns [] when the block is absent."""
4877
- bm = re.search(r"###\s*Spec delta\s*\n(.*?)(?=\n##|\Z)", text, re.S)
4878
- if not bm:
4879
- return []
4880
- entries: list[list[str]] = []
4881
- current: list[str] | None = None
4882
- for line in bm.group(1).splitlines():
4883
- stripped = line.strip()
4884
- if not stripped or stripped.startswith("<!--"):
4885
- current = None
4886
- continue
4887
- if _SPEC_DELTA_RE.match(stripped):
4888
- current = [stripped]
4889
- entries.append(current)
4890
- elif current is not None and not stripped.startswith("-"):
4891
- current.append(stripped) # genuine wrap of the current entry
4892
- else:
4893
- current = None # a new / malformed list item ends the run
4894
- return entries
4895
-
4896
-
4897
4302
  def _collect_open_spec_deltas(root: Path) -> list[dict]:
4898
4303
  """Scan every .add/tasks/*/TASK.md "### Spec delta" block for OPEN SPEC deltas.
4899
4304
 
@@ -5221,6 +4626,15 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
5221
4626
  elif gate != "none" and outcomes[0] != gate:
5222
4627
  f(slug, "gate_record_mismatch",
5223
4628
  f"§6 records {outcomes[0]} but state.json records {gate}")
4629
+ elif gate == "none":
4630
+ # F13 ungated_verdict: §6 carries a verdict the engine never gated.
4631
+ # cmd_gate is the ONLY writer of `gate` (it also marks done), so a
4632
+ # done/observe task at gate=="none" reached its verdict without the
4633
+ # engine — a hand-stamped §6 or an `advance` past verify. Constraint 4
4634
+ # requires a RECORDED outcome; a §6 verdict without one is not trusted.
4635
+ f(slug, "ungated_verdict",
4636
+ f"§6 records {outcomes[0]} but state.json recorded no gate (gate=none) — "
4637
+ f"the verdict was written without the engine gate")
5224
4638
  sec = _AUDIT_SECURITY_RE.search(s6)
5225
4639
  marked = bool(sec and ("NOTE" in sec.group(0) or "⚠" in sec.group(0)))
5226
4640
  rev = _AUDIT_REVIEWED_RE.search(s6)
@@ -5406,12 +4820,6 @@ def cmd_graduation_report(args: argparse.Namespace) -> None:
5406
4820
  print("\n".join(L))
5407
4821
 
5408
4822
 
5409
- def _releases_path(root: Path) -> Path:
5410
- """The append-only release ledger — at the PROJECT ROOT (root IS the .add dir, so its
5411
- parent), a sibling of CHANGELOG.md. NOT inside .add/."""
5412
- return root.parent / RELEASES_FILE
5413
-
5414
-
5415
4823
  def _released_milestones(root: Path) -> set[str]:
5416
4824
  """Slugs already attributed to a release — the union of every `milestones:` row in
5417
4825
  RELEASES.md. Fail-OPEN: a missing/unreadable/malformed ledger yields the empty set, so
@@ -5432,21 +4840,6 @@ def _released_milestones(root: Path) -> set[str]:
5432
4840
  return out
5433
4841
 
5434
4842
 
5435
- def _closed_milestones(state: dict) -> list[dict]:
5436
- """Every CLOSED milestone (its milestone-done gate passed): LIVE done milestones
5437
- (status == 'done', still in state) + ARCHIVED milestones (all were PASS-done before
5438
- archive — see _archived_task_slugs). Each: {slug, title, tier}."""
5439
- out: list[dict] = []
5440
- for slug, m in (state.get("milestones") or {}).items():
5441
- if m.get("status") == "done":
5442
- out.append({"slug": slug, "title": m.get("title", slug), "tier": "live"})
5443
- for rec in state.get("archived") or []:
5444
- if rec.get("slug"):
5445
- out.append({"slug": rec["slug"], "title": rec.get("title", rec["slug"]),
5446
- "tier": "archived"})
5447
- return out
5448
-
5449
-
5450
4843
  def _releasable(root: Path, state: dict) -> list[dict]:
5451
4844
  """Closed milestones NOT yet attributed to any RELEASES.md row — the cut's candidate
5452
4845
  bundle. Drives BOTH the `→ releasable: N` status cue and release-report. READ-ONLY."""
@@ -5454,19 +4847,38 @@ def _releasable(root: Path, state: dict) -> list[dict]:
5454
4847
  return [m for m in _closed_milestones(state) if m["slug"] not in released]
5455
4848
 
5456
4849
 
5457
- def _key_decisions_for(root: Path, slug: str) -> list[str]:
5458
- """Best-effort §Key-Decisions rows from PROJECT.md that NAME this milestone slug — the
5459
- consolidated decisions the changelog can cite. Fail-open: a missing section / unreadable
5460
- foundation / no slug match -> [] (a gather never raises). READ-ONLY."""
4850
+ def _released_loose_tasks(root: Path) -> set[str]:
4851
+ """Slugs already attributed to a release as LOOSE tasks — the union of every
4852
+ `loose tasks:` row in RELEASES.md. The exact parallel of _released_milestones:
4853
+ fail-OPEN (a missing/unreadable/malformed ledger yields the empty set, so every
4854
+ done loose task reads as still-releasable). READ-ONLY."""
5461
4855
  try:
5462
- text = (root / "PROJECT.md").read_text(encoding="utf-8")
4856
+ text = _releases_path(root).read_text(encoding="utf-8")
5463
4857
  except OSError:
5464
- return []
5465
- m = re.search(r"^#{1,6}[^\n]*key decision[^\n]*$(.*?)(?=^#{1,6}\s|\Z)", text, re.S | re.M | re.I)
5466
- if not m:
5467
- return []
5468
- return [st.lstrip("-* ").strip() for st in (ln.strip() for ln in m.group(1).splitlines())
5469
- if st.startswith(("-", "*")) and slug in st]
4858
+ return set() # no ledger -> nothing released yet
4859
+ out: set[str] = set()
4860
+ for line in text.splitlines():
4861
+ st = line.strip()
4862
+ if st.lower().startswith("loose tasks:"):
4863
+ for tok in re.split(r"[,\s]+", st.split(":", 1)[1]):
4864
+ tok = tok.strip()
4865
+ if tok and tok.lower() != "none":
4866
+ out.add(tok)
4867
+ return out
4868
+
4869
+
4870
+ def _releasable_loose_tasks(root: Path, state: dict) -> list[dict]:
4871
+ """Done milestone-free tasks NOT yet attributed to any RELEASES.md `loose tasks:` row —
4872
+ the loose half of the cut's candidate bundle, peer to _releasable. A loose task is a
4873
+ first-class releasable item: milestone-free (a standalone, fast OR full) AND done (a
4874
+ completing gate). Drives the loose status cue + release_data["loose"]. READ-ONLY."""
4875
+ released = _released_loose_tasks(root)
4876
+ out: list[dict] = []
4877
+ for slug, t in (state.get("tasks") or {}).items():
4878
+ if (isinstance(t, dict) and t.get("milestone") is None and _task_done(t)
4879
+ and slug not in released):
4880
+ out.append({"slug": slug, "title": t.get("title", slug)})
4881
+ return out
5470
4882
 
5471
4883
 
5472
4884
  def release_data(root: Path, state: dict) -> dict:
@@ -5531,15 +4943,19 @@ def release_data(root: Path, state: dict) -> dict:
5531
4943
  monitors.append({"slug": slug, "watch": st})
5532
4944
  break
5533
4945
 
4946
+ # loose — done milestone-free tasks not yet attributed (the cut's loose bundle, peer to releasable)
4947
+ loose = _releasable_loose_tasks(root, state)
4948
+
5534
4949
  return {
5535
4950
  "releasable": releasable,
5536
4951
  "changed": changed,
5537
4952
  "waivers": waivers,
5538
4953
  "blockers": blockers,
5539
4954
  "monitors": monitors,
4955
+ "loose": loose,
5540
4956
  "summary": {
5541
4957
  "releasable": len(releasable), "changed": len(changed), "waivers": len(waivers),
5542
- "blockers": len(blockers), "monitors": len(monitors),
4958
+ "blockers": len(blockers), "monitors": len(monitors), "loose": len(loose),
5543
4959
  },
5544
4960
  }
5545
4961
 
@@ -5583,14 +4999,6 @@ def cmd_release_report(args: argparse.Namespace) -> None:
5583
4999
  print("\n".join(L))
5584
5000
 
5585
5001
 
5586
- def _build_in_flight(state: dict) -> bool:
5587
- """release_tests_red proxy (PURE): is any ACTIVE task mid-build without a recorded green gate
5588
- — phase ∈ {build, verify} AND gate == 'none'? The tool-agnostic engine never runs the suite,
5589
- so an entered-but-ungated build is the recorded-evidence stand-in for 'the suite is red'."""
5590
- return any(t.get("phase") in ("build", "verify") and t.get("gate") == "none"
5591
- for t in (state.get("tasks") or {}).values())
5592
-
5593
-
5594
5002
  def _prepend_block(existing: str, header: str, block: str) -> str:
5595
5003
  """Newest-first prepend: insert `block` directly under the top H1 `header`, creating the
5596
5004
  header when `existing` is empty / headerless. Existing content is preserved VERBATIM
@@ -5603,37 +5011,6 @@ def _prepend_block(existing: str, header: str, block: str) -> str:
5603
5011
  return f"{block}{existing}" # no recognized header -> block goes on top, verbatim tail
5604
5012
 
5605
5013
 
5606
- def _render_changelog_block(version: str, day: str, bundle: list[dict],
5607
- changed_by_slug: dict) -> str:
5608
- """A CHANGELOG block: `## <version> — <date>` + one bullet per bundled milestone (title +
5609
- carried-delta / key-decision counts from release_data['changed'])."""
5610
- lines = [f"## {version} — {day}", ""]
5611
- if bundle:
5612
- for m in bundle:
5613
- c = changed_by_slug.get(m["slug"], {})
5614
- lines.append(f"- {m['title']} — {c.get('carried_deltas', 0)} carried · "
5615
- f"{len(c.get('key_decisions', []))} key decision(s)")
5616
- else:
5617
- lines.append("- (no milestone bundled)")
5618
- return "\n".join(lines) + "\n\n"
5619
-
5620
-
5621
- def _render_releases_row(version: str, day: str, bundle: list[dict],
5622
- waiver_slugs: list[str], evidence: str | None,
5623
- actor: str | None = None) -> str:
5624
- """One append-only RELEASES.md row — the attribution source (`milestones:` membership).
5625
- The `actor:` line records WHO cut the release (structured-actor stamping); absent on a
5626
- legacy row (back-compat) when no actor is supplied."""
5627
- ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
5628
- wv = ", ".join(waiver_slugs) if waiver_slugs else "none"
5629
- actor_line = f"actor: {actor}\n" if actor else ""
5630
- return (f"## {version} — {day}\n"
5631
- f"milestones: {ms}\n"
5632
- f"waivers: {wv}\n"
5633
- f"{actor_line}"
5634
- f"evidence: {evidence or 'recorded by add.py release'}\n\n")
5635
-
5636
-
5637
5014
  def cmd_release(args: argparse.Namespace) -> None:
5638
5015
  """GUARDED, record-only: cut a version. Enforce the 4-code readiness floor, then RECORD by
5639
5016
  prepending CHANGELOG.md + an append-only RELEASES.md row (whose `milestones:` line attributes
@@ -5657,9 +5034,11 @@ def cmd_release(args: argparse.Namespace) -> None:
5657
5034
  _die("release_tests_red: a build is in flight without a recorded green gate — finish and "
5658
5035
  "gate it first, or pass --force to override.")
5659
5036
  bundle = _releasable(root, state)
5660
- if not forced and not bundle:
5037
+ loose_bundle = _releasable_loose_tasks(root, state)
5038
+ if not forced and not bundle and not loose_bundle:
5661
5039
  _die("release_no_closed_milestone: nothing closed-and-unreleased to bundle — the cut "
5662
- "would be a no-op. Close a milestone first, or pass --force to override.")
5040
+ "would be a no-op. Close a milestone (or a standalone task) first, or pass --force "
5041
+ "to override.")
5663
5042
  if not forced and d["waivers"] and not disclosed:
5664
5043
  _die("release_undisclosed_waiver: a RISK-ACCEPTED waiver rides into this release — pass "
5665
5044
  "--with-waivers to disclose it in the notes, or --force to override.")
@@ -5677,7 +5056,7 @@ def cmd_release(args: argparse.Namespace) -> None:
5677
5056
  new_rel = _prepend_block(rel_before, "# Releases",
5678
5057
  _render_releases_row(args.version, day, bundle, waiver_slugs,
5679
5058
  getattr(args, "evidence", None),
5680
- _render_actor_line(state)))
5059
+ identity._render_actor_line(state), loose_bundle))
5681
5060
  try: # CHANGELOG + RELEASES as one all-or-nothing commit
5682
5061
  _atomic_write_many([(changelog_path, new_cl), (releases_path, new_rel)])
5683
5062
  except OSError as e:
@@ -5686,7 +5065,9 @@ def cmd_release(args: argparse.Namespace) -> None:
5686
5065
 
5687
5066
  # NO save_state — attribution lives in RELEASES.md (the cue re-reads it), never state.json
5688
5067
  ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
5689
- print(f"released {args.version} recorded {len(bundle)} milestone(s): {ms}")
5068
+ lt = ", ".join(t["slug"] for t in loose_bundle) if loose_bundle else "none"
5069
+ print(f"released {args.version} — recorded {len(bundle)} milestone(s): {ms} "
5070
+ f"+ {len(loose_bundle)} loose task(s): {lt}")
5690
5071
  print(" CHANGELOG.md + RELEASES.md updated (project root). The engine records; "
5691
5072
  "you run the tag / publish / deploy.")
5692
5073
  if forced:
@@ -5847,6 +5228,9 @@ def build_parser() -> argparse.ArgumentParser:
5847
5228
  pi.add_argument("--force", action="store_true", help="reset state.json if present")
5848
5229
  pi.add_argument("--await-lock", dest="await_lock", action="store_true",
5849
5230
  help="seed an unlocked setup; gates new-task/advance/gate until `add.py lock`")
5231
+ pi.add_argument("--rule-file", dest="rule_file", action="store_true",
5232
+ help="write the ADD block to .claude/rules/add-workflows.md and reference it "
5233
+ "from CLAUDE.md (auto-on for ccsk projects with a .ccsk/ dir)")
5850
5234
  pi.set_defaults(func=cmd_init)
5851
5235
 
5852
5236
  pl = sub.add_parser("lock",
@@ -6004,6 +5388,13 @@ def build_parser() -> argparse.ArgumentParser:
6004
5388
  help="confirm a RAISE toward auto (a human-owned trust escalation)")
6005
5389
  pan.set_defaults(func=cmd_autonomy, _opt_positionals=("a1", "a2"))
6006
5390
 
5391
+ pto = sub.add_parser("todo", help="capture / list / close a lightweight backlog todo (jot an idea)")
5392
+ pto.add_argument("text", nargs="?", default=None,
5393
+ help="todo text to capture; omit to LIST open todos")
5394
+ pto.add_argument("--done", type=int, default=None, metavar="ID",
5395
+ help="close an open todo by id")
5396
+ pto.set_defaults(func=cmd_todo)
5397
+
6007
5398
  pr = sub.add_parser("reopen", help="return a done task to an earlier phase with a recorded reason")
6008
5399
  pr.add_argument("slug", nargs="?", default=None)
6009
5400
  # --to / --reason are validated in-body (not argparse choices) so the named reject
@@ -6033,6 +5424,14 @@ def build_parser() -> argparse.ArgumentParser:
6033
5424
  pck.add_argument("--json", action="store_true", help="machine-readable JSON output")
6034
5425
  pck.set_defaults(func=cmd_check)
6035
5426
 
5427
+ pfed = sub.add_parser("federate", help="multi-repo: pull a producer repo's published, immutable "
5428
+ "contract snapshot into this repo (fail-loud)")
5429
+ pfedsub = pfed.add_subparsers(dest="action", required=True)
5430
+ pfedpull = pfedsub.add_parser("pull", help="land [federation.<id>].source at the local "
5431
+ ".add/contracts/<id>.json (hard-stops on a bad source)")
5432
+ pfedpull.add_argument("id", help="the contract id declared under [federation.<id>]")
5433
+ pfedpull.set_defaults(func=cmd_federate)
5434
+
6036
5435
  pdoc = sub.add_parser("doctor", help="read-only diagnosis of state.json integrity + "
6037
5436
  "referential consistency (run after a git merge)")
6038
5437
  pdoc.set_defaults(func=cmd_doctor)
@@ -6053,6 +5452,9 @@ def build_parser() -> argparse.ArgumentParser:
6053
5452
 
6054
5453
  psg = sub.add_parser("sync-guidelines",
6055
5454
  help="(re)write the ADD guideline block into AGENTS.md + CLAUDE.md")
5455
+ psg.add_argument("--rule-file", dest="rule_file", action="store_true",
5456
+ help="relocate CLAUDE.md's block to .claude/rules/add-workflows.md + reference "
5457
+ "it (auto-on for ccsk projects)")
6056
5458
  psg.set_defaults(func=cmd_sync_guidelines)
6057
5459
 
6058
5460
  pgd = sub.add_parser("guide", help="print the one concrete next step for the active task")
@@ -6164,16 +5566,6 @@ def _rebind_optional_positionals(parser: argparse.ArgumentParser,
6164
5566
  # silently does nothing and nothing is lost. It NEVER changes a command's stdout or exit.
6165
5567
  _UPDATE_CACHE = ".update-cache.json"
6166
5568
  _UPDATE_TTL = timedelta(hours=24) # hit the registry at most once / day
6167
- _REGISTRY_LATEST = "https://registry.npmjs.org/@pilotspace/add/latest"
6168
-
6169
-
6170
- def _read_json_safe(path: Path):
6171
- try:
6172
- return json.loads(path.read_text(encoding="utf-8"))
6173
- except (OSError, json.JSONDecodeError):
6174
- return None
6175
-
6176
-
6177
5569
  def _write_json_safe(path: Path, obj) -> None:
6178
5570
  try:
6179
5571
  path.write_text(json.dumps(obj, indent=2) + "\n", encoding="utf-8")
@@ -6181,33 +5573,6 @@ def _write_json_safe(path: Path, obj) -> None:
6181
5573
  pass
6182
5574
 
6183
5575
 
6184
- def _version_gt(a: str, b: str) -> bool:
6185
- """True if version a is newer than b (dotted numeric; prerelease suffix dropped)."""
6186
- def key(v: str):
6187
- out = []
6188
- for part in str(v).split("."):
6189
- part = part.split("-", 1)[0]
6190
- out.append((0, int(part)) if part.isdigit() else (1, part))
6191
- return out
6192
- try:
6193
- return key(a) > key(b)
6194
- except Exception:
6195
- return False
6196
-
6197
-
6198
- def _fetch_latest_version(timeout: float = 1.5):
6199
- """GET the registry's latest version. Returns a string, or None on ANY failure
6200
- (offline, timeout, bad payload) — the caller treats None as 'unknown, skip'."""
6201
- try:
6202
- req = urllib.request.Request(_REGISTRY_LATEST, headers={"Accept": "application/json"})
6203
- with urllib.request.urlopen(req, timeout=timeout) as resp:
6204
- data = json.loads(resp.read().decode("utf-8"))
6205
- v = data.get("version")
6206
- return v if isinstance(v, str) and v else None
6207
- except Exception:
6208
- return None
6209
-
6210
-
6211
5576
  def _cached_latest(add_dir: Path):
6212
5577
  """The registry's latest version, throttled: served from .update-cache.json within
6213
5578
  the TTL, else refreshed over the network (fail-open). None when unknown."""