@pilotspace/add 1.10.0 → 1.12.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
@@ -30,274 +30,102 @@ try: # component-aware-add registry parse (Python 3.11+ st
30
30
  except ModuleNotFoundError: # < 3.11: the registry is unsupported → degrade to opt-out
31
31
  tomllib = None
32
32
 
33
- # --- constants ---------------------------------------------------------------
34
-
35
- ROOT_DIRNAME = ".add"
36
- STATE_FILE = "state.json"
37
- MILESTONE_FILE = "MILESTONE.md"
38
- # The project GOAL (v20) is read live from PROJECT.md — never copied into state.json
39
- # (single-source; the foundation is the truth). A missing/blank source degrades to
40
- # this sentinel so the read-only orientation surfaces never blank or crash.
41
- GOAL_UNSET = "(unset — add a 'goal:' line to PROJECT.md)"
42
- STAGES = ("prototype", "poc", "mvp", "production")
43
- # v22 stage-graduation: the read-only cue `status` shows when the MVP is covered.
44
- # Worded as the ACTION (never a file) so it stands before graduate.md exists.
45
- GRADUATION_CUE = "MVP covered → propose graduation"
46
- # release-altitude: the read-only cue `status` shows when ≥1 closed milestone is
47
- # unreleased. The 5th scope level (release.md). `{n}` is filled at print time; the
48
- # wording matches SKILL.md's "Beyond the bundle" cross-ref byte-for-byte.
49
- RELEASABLE_CUE = "releasable: {n} milestone(s) closed since last release"
50
- # the append-only release ledger lives at the PROJECT ROOT (the dir containing .add/),
51
- # a sibling of CHANGELOG.md — NOT inside .add/. The ledger IS the attribution source:
52
- # a milestone is "released" iff its slug appears on a `milestones:` row.
53
- RELEASES_FILE = "RELEASES.md"
54
- PHASES = ("ground", "specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done")
55
- GATES = ("none", "PASS", "RISK-ACCEPTED", "HARD-STOP")
56
- # heal-then-escalate (verify-integrity): the bounded self-heal loop cap. A CONFIRMED cheat
57
- # (mechanical tripwire divergence, or an agent-reported semantic refute-read finding) returns
58
- # the task to BUILD for an honest redo; after HEAL_CAP such attempts the next confirmed cheat
59
- # forces a HARD-STOP escalation to the human. MONOTONIC — attempts never auto-resets (a gamed
60
- # green is never auto-passed; the loop is never unbounded).
61
- HEAL_CAP = 3
33
+ # --- constants (moved to add_engine/constants.py — engine-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
+ )
62
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
+ )
63
47
 
64
- def _phase_index(name: str) -> int:
65
- """Ordinal of a phase in PHASES; used to enforce forward-skip rules."""
66
- 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
+ )
67
53
 
68
- # `add.py guide` copy: per-phase (concrete next action, book chapter to read).
69
- # Keep the action wording aligned with each phase's EXIT line in the TASK template.
70
- PHASE_GUIDE = {
71
- "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",
72
- "02-the-flow.md"),
73
- "specify": ("state every rule — Must / Reject (+ named code) / After; rank assumptions lowest-confidence first and flag the biggest risk",
74
- "03-step-1-specify.md"),
75
- "scenarios": ("write one Given/When/Then per Must AND per Reject; every result observable",
76
- "04-step-2-scenarios.md"),
77
- "contract": ("freeze the shape — signature, fields, error codes; names match the glossary",
78
- "05-step-3-contract.md"),
79
- "tests": ("write one failing test per scenario; run them RED for the right reason",
80
- "06-step-4-tests.md"),
81
- "build": ("write the minimum code to pass the tests; change no test and no contract",
82
- "07-step-5-build.md"),
83
- "verify": ("run the suite + non-functional checks, then record the gate",
84
- "08-step-6-verify.md"),
85
- "observe": ("note what to watch + the spec delta for the next loop",
86
- "09-the-loop.md"),
87
- "done": ("this task is done — pick the next feature",
88
- "02-the-flow.md"),
89
- }
90
- # Phase -> who owns it, for the `--json` autonomy signal. An autonomous harness may run a
91
- # phase only when owner=="ai" (stop is false); every other phase is a checkpoint. The map
92
- # follows the book's who-does-what table (Verify is "human only"); `tests`/`build`/`observe`
93
- # are AI-led. A phase missing here is `unmapped_phase` (fail closed) — never defaulted.
94
- PHASE_OWNER = {
95
- "ground": "ai",
96
- "specify": "human", "scenarios": "human", "contract": "seam",
97
- "tests": "ai", "build": "ai", "verify": "human", "observe": "ai", "done": "human",
98
- }
99
- SETUP_FILES = ("PROJECT.md", "CONVENTIONS.md", "GLOSSARY.md", "MODEL_REGISTRY.md", "dependencies.allowlist", "DESIGN.md", "SOUL.md")
100
-
101
- # Scaffolded into .add/.gitignore at init so the engine's transient LOCAL artifacts
102
- # never reach git. Bare-filename patterns match at any depth under .add/ (tasks/,
103
- # milestones/, archive/). These are working state, not records: scope-snapshot.json
104
- # is the tests->build touch baseline the verify scope-gate reads from disk (the
105
- # durable scope declaration is the state.json anchor); pre-archive-state.bak.json is
106
- # archive-milestone's pre-delete recovery net — needed on disk, never in history;
107
- # .update-cache.json is the update-nudge's once-a-day registry throttle. All stay on
108
- # disk; git-ignoring them is hygiene, never deletion.
109
- _GITIGNORE_BODY = """\
110
- # ADD engine transient artifacts — local working state, never committed.
111
- # (Scaffolded by `add.py init`; edit freely — init never clobbers an existing copy.)
112
- scope-snapshot.json
113
- pre-archive-state.bak.json
114
- .update-cache.json
115
- """
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
+ )
116
59
 
117
- # Guideline-injection targets + version-stable markers. NEVER change these marker
118
- # strings: a re-run finds the old block by exact match, so changing them would
119
- # orphan every block written by a prior version (see TASK guideline-inject).
120
- GUIDELINE_FILES = ("AGENTS.md", "CLAUDE.md")
121
- _GUIDE_BEGIN = "<!-- ADD:BEGIN — managed by `add.py sync-guidelines`; do not edit inside -->"
122
- _GUIDE_END = "<!-- ADD:END -->"
123
-
124
- # Rule-file mode (ccsk-style projects): instead of inlining the block into CLAUDE.md,
125
- # write it to a dedicated rule file under .claude/rules/ and leave a one-line reference
126
- # in CLAUDE.md's Workflows section. .claude/rules/ is a CLAUDE-only convention, so this
127
- # mode only ever relocates CLAUDE.md — AGENTS.md/.clinerules keep the inline block.
128
- RULES_FILE_REL = Path(".claude") / "rules" / "add-workflows.md"
129
- # Headings (most→least specific) a project may already use to group rule/workflow links.
130
- # Match is case-insensitive on the heading TEXT, at any `#` level.
131
- WORKFLOW_HEADINGS = ("Rules & Workflows", "Workflows", "Rules")
132
- _RULE_REF_LINE = "- ADD (AI-Driven Development) Workflows rules: ./.claude/rules/add-workflows.md"
133
-
134
- # Minimal embedded fallback so the tool still works if templates/ is missing
135
- # (circuit breaker: never hard-fail just because a template file was deleted).
136
- _FALLBACK_TASK = """# TASK: {title}
137
-
138
- slug: {slug} · created: {date} · stage: {stage}
139
- autonomy: auto
140
- phase: ground
141
-
142
- ## 0 · GROUND
143
- Touches (files · symbols · signatures):
144
- Honors (patterns / conventions):
145
- Anchors the contract cites:
146
-
147
- ## 1 · SPECIFY
148
- Feature:
149
- Framings weighed:
150
- Must:
151
- Reject:
152
- After:
153
- Assumptions — lowest-confidence first:
154
- ⚠ <most likely wrong> — lowest confidence because <why>; if wrong: <cost>
155
-
156
- ## 2 · SCENARIOS
157
- ## 3 · CONTRACT
158
- Status: DRAFT
159
- ## 4 · TESTS
160
- ## 5 · BUILD
161
- ## 6 · VERIFY
162
- ### GATE RECORD
163
- Outcome:
164
- ## 7 · OBSERVE
165
- ### Spec delta
166
- ### Competency deltas
167
- """
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
+ )
168
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
+ )
169
70
 
170
- # Fast-lane fallback: the minimal TASK.md variant (sections {0,1,3,4,5,6}; §2 + §7 dropped).
171
- # Mirrors templates/TASK.fast.md.tmpl's section set (circuit-breaker parity); a deleted
172
- # templates/ never hard-fails the fast lane. Keeps the trust floor: §3 freeze-flag + Status,
173
- # §6 GATE RECORD/Outcome, §0 Anchors, §4 red-before-build, §5 Scope.
174
- _FALLBACK_TASK_FAST = """# TASK: {title}
175
-
176
- slug: {slug} · created: {date} · stage: {stage}
177
- autonomy: auto
178
- phase: ground
179
- fast: true
180
-
181
- ## 0 · GROUND
182
- Touches (files · symbols):
183
- Anchors the contract cites:
184
-
185
- ## 1 · SPECIFY
186
- Feature:
187
- Must:
188
- Reject:
189
- Accept:
190
- Assumptions: ⚠ <most likely wrong> — why; if wrong: <cost>
191
-
192
- ## 3 · CONTRACT
193
- Least-sure flag surfaced at freeze:
194
- Status: DRAFT
195
-
196
- ## 4 · TESTS
197
- Plan:
198
- Tests live in: `./tests/` · MUST run red before Build.
199
-
200
- ## 5 · BUILD
201
- Scope (may touch): `./src/`
202
-
203
- ## 6 · VERIFY
204
- Build expectations (from §1 Accept + §3 CONTRACT):
205
- ### GATE RECORD
206
- Outcome:
207
- Reviewed by:
208
- """
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
+ )
209
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
+ )
210
82
 
211
- # --- low-level IO (designed for failure: atomic, no silent clobber) ----------
212
83
 
213
- def _now() -> str:
214
- return datetime.now(timezone.utc).isoformat(timespec="seconds")
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)
215
87
 
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
+ )
216
97
 
217
- def _atomic_write(path: Path, text: str) -> None:
218
- """Write via a temp file in the same dir, then atomically replace.
219
98
 
220
- Avoids a half-written file if the process dies mid-write.
221
- """
222
- path.parent.mkdir(parents=True, exist_ok=True)
223
- fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
224
- try:
225
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
226
- fh.write(text)
227
- os.replace(tmp, path)
228
- finally:
229
- if os.path.exists(tmp):
230
- os.unlink(tmp)
231
-
232
-
233
- def _atomic_write_bytes(path: Path, data: bytes) -> None:
234
- """Binary sibling of `_atomic_write` — lands `data` UNCHANGED (no newline translation), so a
235
- byte-for-byte copy stays exact. Same temp-then-replace crash-safety."""
236
- path.parent.mkdir(parents=True, exist_ok=True)
237
- fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
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
+ )
104
+
105
+ # --- state load/save (KEPT in add.py: write-path pinned by add._atomic_write tests) -
106
+
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."""
238
112
  try:
239
- with os.fdopen(fd, "wb") as fh:
240
- fh.write(data)
241
- os.replace(tmp, path)
242
- finally:
243
- if os.path.exists(tmp):
244
- os.unlink(tmp)
245
-
246
-
247
- def _atomic_write_many(writes: list[tuple[Path, str]]) -> None:
248
- """True all-or-nothing commit across N files — design-for-failure for a multi-file write.
249
-
250
- Phase 1 STAGES every (path, text) to a sibling `.tmp`, flushing + fsync-ing each, so the
251
- realistic IO failures (disk full, permission denied) surface HERE, before any target changes —
252
- and on any stage failure every staged temp is removed, so NOTHING is committed. Phase 2 then
253
- COMMITS each file by renaming any existing target ASIDE to a sibling `.bak`, then `os.replace`-ing
254
- the staged `.tmp` into place. If ANY commit rename raises, every file already committed is rolled
255
- back IN REVERSE (remove the landed new file, rename its `.bak` back, or leave it absent) and the
256
- original error re-raised — so the whole set is all-or-nothing: either every file holds its new
257
- text, or every file holds its prior content. Restoring is an atomic rename of an already-written
258
- `.bak` (no content held in memory, no re-write), the cheapest recovery under failing IO. Leftover
259
- `.tmp`/`.bak` siblings are removed on every exit path.
260
- """
261
- staged: list[tuple[str, Path]] = []
262
- 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()
263
121
  try:
264
- for path, text in writes: # phase 1: stage every temp (fsync'd before any commit)
265
- path.parent.mkdir(parents=True, exist_ok=True)
266
- fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
267
- staged.append((tmp, path)) # track BEFORE write so a write/fsync failure still cleans it
268
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
269
- fh.write(text)
270
- fh.flush()
271
- os.fsync(fh.fileno())
272
- try:
273
- for tmp, path in staged: # phase 2: commit via rename-aside
274
- bak = None
275
- existed = path.exists()
276
- if existed:
277
- fd2, bak = tempfile.mkstemp(dir=str(path.parent), suffix=".bak")
278
- os.close(fd2)
279
- committed.append([path, bak, False]) # track .bak NOW so cleanup never leaks it
280
- if existed:
281
- os.replace(path, bak) # move the existing target aside
282
- os.replace(tmp, path) # move the new file in
283
- committed[-1][2] = True
284
- except OSError:
285
- for path, bak, landed in reversed(committed): # roll back, newest-first
286
- try:
287
- if landed and path.exists():
288
- os.unlink(path) # drop the new file we put in
289
- if bak is not None and not path.exists():
290
- os.replace(bak, path) # restore the prior target (atomic rename)
291
- except OSError:
292
- pass # best-effort restore under already-failing IO
293
- raise
294
- finally:
295
- for tmp, _ in staged: # leftover .tmp (failed/aborted stage) never persists
296
- if os.path.exists(tmp):
297
- os.unlink(tmp)
298
- for path, bak, landed in committed: # leftover .bak (success, or orphaned post-rollback)
299
- if bak is not None and os.path.exists(bak):
300
- 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")
301
129
 
302
130
 
303
131
  def _templates_dir() -> Path:
@@ -322,221 +150,37 @@ def _render_template(name: str, **subs: str) -> str:
322
150
  text = text.replace("{{" + key + "}}", val)
323
151
  return text
324
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
+ )
325
158
 
326
- # --- state -------------------------------------------------------------------
327
-
328
- def find_root(start: Path | None = None) -> Path | None:
329
- """Walk up from cwd to find a .add/ project root."""
330
- cur = (start or Path.cwd()).resolve()
331
- for d in (cur, *cur.parents):
332
- if (d / ROOT_DIRNAME / STATE_FILE).exists():
333
- return d / ROOT_DIRNAME
334
- return None
335
-
336
-
337
- def _require_root() -> Path:
338
- root = find_root()
339
- if root is None:
340
- _die("no .add/ project found. Run `add.py init` first.")
341
- return root
342
-
343
-
344
- def _migrate_state(state: dict) -> dict:
345
- """Forward-migrate a single-active state to the multi-active schema (team-collaboration
346
- foundation). PURE · idempotent · TOTAL · never raises · no I/O.
347
-
348
- A state lacking the `active_milestones` key gains it — DERIVED from the scalar
349
- `active_milestone` (grandfather-by-missing-key, mirroring `_setup_locked`): None -> [],
350
- "x" -> ["x"]. A per-milestone active-task map `active_tasks` is added; the old global
351
- `active_task` is placed under its owning active milestone only when it genuinely belongs
352
- there, else it stays as the top-level scalar fallback (orphan rule, FROZEN decision (a)).
353
- The scalar `active_milestone` / `active_task` keys are KEPT as the N<=1 mirror so the
354
- not-yet-routed readers keep working. An already-migrated state (key present) is returned
355
- unchanged — never re-derived, never clobbered. Corrupt parsing stays the loader's job.
356
-
357
- PURE in the observable sense: the caller's dict is NEVER mutated — a state that needs
358
- migrating is upgraded on a fresh top-level copy (nested objects are shared but only read)."""
359
- if not isinstance(state, dict) or "active_milestones" in state:
360
- return state
361
- migrated = dict(state)
362
- active_ms = migrated.get("active_milestone")
363
- migrated["active_milestones"] = [] if active_ms is None else [active_ms]
364
- active_task = migrated.get("active_task")
365
- tasks = migrated.get("tasks") or {}
366
- owns = (active_ms is not None and active_task is not None
367
- and isinstance(tasks.get(active_task), dict)
368
- and tasks[active_task].get("milestone") == active_ms)
369
- migrated["active_tasks"] = {active_ms: active_task} if owns else {}
370
- return migrated
371
-
372
-
373
- # --- active milestone/task accessor seam (multi-active foundation) -----------
374
- #
375
- # Every engine call site reads & writes the active milestone(s)/task through these
376
- # four helpers, so multi-active SEMANTICS can land in one place later. Today they
377
- # preserve single-active behavior exactly: reads return the scalar mirror, writes
378
- # keep the scalar AND the new active_milestones/active_tasks structures in sync.
379
-
380
- def _active_milestone(state: dict) -> str | None:
381
- """The primary active milestone — the N<=1 scalar mirror (== active_milestones[0])."""
382
- return state.get("active_milestone")
383
-
384
-
385
- def _active_task(state: dict, milestone: str | None = None) -> str | None:
386
- """The active task: per-milestone when `milestone` is given (partial-state -> None),
387
- else the global/primary scalar active task. Total — never raises."""
388
- if milestone is None:
389
- return state.get("active_task")
390
- return (state.get("active_tasks") or {}).get(milestone)
391
-
392
-
393
- def _set_active_milestone(state: dict, slug: str | None) -> None:
394
- """Set the primary active milestone, keeping `active_milestones` consistent (N<=1 sync)."""
395
- state["active_milestone"] = slug
396
- state["active_milestones"] = [] if slug is None else [slug]
397
-
398
-
399
- def _set_active_task(state: dict, slug: str | None, milestone: str | None = None) -> None:
400
- """Set the active task, keeping the scalar mirror AND the per-milestone map in sync.
401
- With no owning active milestone the active task is scalar-only (the migration's orphan
402
- rule); clearing (slug is None) pops the milestone's entry."""
403
- state["active_task"] = slug
404
- ms = milestone if milestone is not None else _active_milestone(state)
405
- tasks_map = state.setdefault("active_tasks", {})
406
- if ms is None:
407
- return
408
- if slug is None:
409
- tasks_map.pop(ms, None)
410
- else:
411
- tasks_map[ms] = slug
412
-
413
-
414
- def _activate_milestone(state: dict, slug: str) -> None:
415
- """Add a milestone to the active SET (idempotent) and make it the primary focus,
416
- syncing the scalar active_task to that milestone's entry. Does NOT remove other members
417
- (this is how a user reaches N>=2 active milestones)."""
418
- ms_list = state.setdefault("active_milestones", [])
419
- if slug not in ms_list:
420
- ms_list.append(slug)
421
- state["active_milestone"] = slug
422
- state["active_task"] = (state.get("active_tasks") or {}).get(slug)
423
-
424
-
425
- def _deactivate_milestone(state: dict, slug: str) -> None:
426
- """Remove a milestone from the active SET, pop its active-task entry, and (if it was the
427
- primary) repoint the primary to the most-recent remaining member (or None when empty)."""
428
- ms_list = state.setdefault("active_milestones", [])
429
- if slug in ms_list:
430
- ms_list.remove(slug)
431
- (state.setdefault("active_tasks", {})).pop(slug, None)
432
- if state.get("active_milestone") == slug:
433
- new = ms_list[-1] if ms_list else None
434
- state["active_milestone"] = new
435
- state["active_task"] = (state.get("active_tasks") or {}).get(new) if new else None
436
-
437
-
438
- def _git_config(key: str) -> str | None:
439
- """Read one `git config --get <key>`, STRICTLY fail-soft: the engine's FIRST git call,
440
- so it never raises, never hangs, never shells. Returns the trimmed value, or None when
441
- git is absent / errors / times out / the value is empty."""
442
- if shutil.which("git") is None:
443
- return None
444
- try:
445
- out = subprocess.run(
446
- ["git", "config", "--get", key],
447
- capture_output=True, text=True, timeout=2,
448
- ).stdout.strip()
449
- except (OSError, subprocess.SubprocessError, ValueError):
450
- # OSError: git vanished between which() and run() / spawn error · SubprocessError:
451
- # TimeoutExpired · ValueError: a non-UTF-8 config value (latin-1 legacy name) makes
452
- # text=True decoding raise UnicodeDecodeError (a ValueError) — all fail soft to None.
453
- return None
454
- return out or None
455
-
456
-
457
- def _os_user() -> str:
458
- """The guaranteed non-empty OS floor. getpass.getuser() reads LOGNAME/USER/... then
459
- falls back to the passwd database — but in a bare container (no env var AND no passwd
460
- entry) CPython raises KeyError (OSError only on 3.13+). Catch broadly and return a
461
- sentinel so _whoami stays TOTAL: it always yields a non-empty name, never crashes."""
462
- try:
463
- return getpass.getuser() or "unknown"
464
- except (KeyError, OSError):
465
- return "unknown"
466
-
467
-
468
- def _whoami(state: dict) -> dict:
469
- """Resolve the current git-native ACTOR -> {name, email, source}. Priority:
470
- (1) an `actor_override` (whoami --set) with a non-blank name -> source 'override';
471
- (2) `git config user.name`/`user.email` -> source 'git';
472
- (3) the OS user (_os_user) -> source 'os', the guaranteed non-empty floor.
473
- Total: always returns a dict with a non-empty name; `email` may be None."""
474
- ov = state.get("actor_override")
475
- if ov and (ov.get("name") or "").strip():
476
- return {"name": ov["name"], "email": ov.get("email"), "source": "override"}
477
- name = _git_config("user.name")
478
- if name:
479
- return {"name": name, "email": _git_config("user.email"), "source": "git"}
480
- return {"name": _os_user(), "email": None, "source": "os"}
481
-
482
-
483
- def _actor_stamp(state: dict) -> dict:
484
- """The SINGLE source of the structured-actor stamp every engine-WRITTEN human action
485
- records — lock · gate · milestone-done · release (user-identity actor-stamping). It IS
486
- `_whoami(state)`: a TOTAL {name,email,source} (always a non-empty name), so a stamp can
487
- never fail or block a write. Descriptive only — no command's decision reads it."""
488
- return _whoami(state)
489
-
490
-
491
- def _render_actor_line(state: dict) -> str:
492
- """Render the actor stamp as one human-readable line: name, an optional angle-bracketed
493
- email, then the source in parens — used on the RELEASES.md row (no state.json write)."""
494
- a = _actor_stamp(state)
495
- email = f" <{a['email']}>" if a.get("email") else ""
496
- return f"{a['name']}{email} ({a['source']})"
497
-
498
-
499
- def _parse_actor_arg(s: str) -> dict:
500
- """Parse an `assign --owner`/`--assignee` value into a {name, email, source: "assigned"}
501
- actor (ownership-assignment). "Name <email>" -> both; a bare "Name" -> email None. TOTAL:
502
- a malformed value (no closing bracket) never raises — the whole stripped string is the name.
503
- `source` is "assigned" — a human typed this name (not git-resolved nor an ADD override)."""
504
- m = re.match(r"^\s*(.*?)\s*<([^>]*)>\s*$", s)
505
- if m:
506
- return {"name": m.group(1), "email": m.group(2) or None, "source": "assigned"}
507
- return {"name": s.strip(), "email": None, "source": "assigned"}
508
-
509
-
510
- def _actor_matches(rec_actor: dict | None, me: dict) -> bool:
511
- """Does a recorded owner/assignee actor identify the SAME person as `me` (multi-active-UX)?
512
- Email-first (the stabler key): when BOTH carry a non-empty email, emails decide; otherwise
513
- fall back to name-equality. Both comparisons are stripped + case-insensitive. TOTAL — a None,
514
- non-dict, or blank-name record returns False (an unowned/garbage slot is no one's)."""
515
- if not isinstance(rec_actor, dict):
516
- return False
517
- rec_name = (rec_actor.get("name") or "").strip()
518
- if not rec_name:
519
- return False
520
- rec_email = (rec_actor.get("email") or "").strip()
521
- me_email = (me.get("email") or "").strip()
522
- if rec_email and me_email:
523
- return rec_email.lower() == me_email.lower()
524
- 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
+ )
525
165
 
526
166
 
527
- def _my_work(state: dict, me: dict) -> list[dict]:
528
- """The "my work" lens (multi-active-UX): across ALL active milestones, the NOT-done tasks
529
- whose owner OR assignee is `me`. Returns ordered rows {slug, milestone, phase, role} with
530
- role in {owner, assignee, both}, sorted by active-milestone order then slug. PURE · no I/O."""
167
+ def _my_work(state: dict, me: dict, scope_all: bool = False) -> list[dict]:
168
+ """The "my work" lens (multi-active-UX): the NOT-done tasks whose owner OR assignee is `me`.
169
+ By default the lens is the active SET; `scope_all=True` (mine-all-lens) widens it to EVERY
170
+ milestone plus loose (milestone-less) tasks. Returns ordered rows {slug, milestone, phase,
171
+ role} with role in {owner, assignee, both}, sorted by active-milestone order then slug
172
+ (non-active/loose sort after the active block, then by slug). PURE · no I/O."""
531
173
  active = list(state.get("active_milestones") or [])
532
174
  active_set = set(active)
533
175
  tasks = state.get("tasks") if isinstance(state.get("tasks"), dict) else {}
534
176
  rows: list[dict] = []
535
177
  for slug, t in tasks.items():
536
- if not isinstance(t, dict) or t.get("milestone") not in active_set or _task_done(t):
178
+ if not isinstance(t, dict) or _task_done(t):
537
179
  continue
538
- owns = _actor_matches(t.get("owner"), me)
539
- assigned = _actor_matches(t.get("assignee"), me)
180
+ if not scope_all and t.get("milestone") not in active_set:
181
+ continue
182
+ owns = identity._actor_matches(t.get("owner"), me)
183
+ assigned = identity._actor_matches(t.get("assignee"), me)
540
184
  if not (owns or assigned):
541
185
  continue
542
186
  role = "both" if owns and assigned else ("owner" if owns else "assignee")
@@ -550,106 +194,6 @@ def _my_work(state: dict, me: dict) -> list[dict]:
550
194
  # A git conflict marker BEGINS a line with 7 of `<`, `=`, or `>` (`(?m)^…`). An unresolved
551
195
  # merge writes these into state.json, making it invalid JSON; the line-anchor keeps a
552
196
  # legitimate value (always on an INDENTED JSON line) from false-tripping the guard.
553
- _CONFLICT_MARKER_RE = re.compile(r"(?m)^(<{7}|={7}|>{7})")
554
-
555
-
556
- def _state_text_or_die(root: Path) -> str:
557
- """Read state.json's raw text, failing CLOSED with a merge-SPECIFIC `state_conflicted`
558
- message when it carries git conflict markers (an unresolved merge — the major's #1 failure
559
- mode). A genuine read OSError is NOT swallowed: it propagates to the caller, which maps it
560
- to its own existing code (state_invalid / no_state). The guard only READS — never writes."""
561
- text = (root / STATE_FILE).read_text(encoding="utf-8")
562
- if _CONFLICT_MARKER_RE.search(text):
563
- _die(f"state_conflicted: {root / STATE_FILE} has unresolved git merge markers "
564
- f"(<<<<<<< / ======= / >>>>>>>) — resolve them (or "
565
- f"`git checkout --ours/--theirs {STATE_FILE}`), then run `add.py doctor` to verify")
566
- return text
567
-
568
-
569
- def load_state(root: Path) -> dict:
570
- """Load + parse state.json, failing CLOSED. A git-conflicted file dies with a merge-specific
571
- 'state_conflicted'; any other corrupt/unreadable file dies with a clean 'state_invalid'
572
- message (never a raw traceback), so every command that loads state degrades gracefully
573
- (design-for-failure). The parsed state is forward-migrated to the multi-active schema."""
574
- try:
575
- return _migrate_state(json.loads(_state_text_or_die(root)))
576
- except (json.JSONDecodeError, OSError) as e:
577
- _die(f"state_invalid: {root / STATE_FILE} is corrupt or unreadable "
578
- f"({e.__class__.__name__}) — restore it from git or a backup")
579
-
580
-
581
- def _load_state_for_json() -> tuple[Path, dict]:
582
- """Fail-closed state load for `--json` paths: a missing project or unparseable
583
- state.json -> `no_state` on stderr + exit 1, with EMPTY stdout (never a partial
584
- JSON object a harness might parse). Built from State only — reads no docs/ chapter.
585
- The parsed state is forward-migrated to the multi-active schema before it is returned."""
586
- root = find_root()
587
- if root is None:
588
- _die("no_state")
589
- try:
590
- return root, _migrate_state(json.loads(_state_text_or_die(root)))
591
- except (json.JSONDecodeError, OSError):
592
- _die("no_state")
593
-
594
-
595
- def _phase_owner(phase: str) -> str:
596
- """Map a phase to its owner (human|seam|ai); `unmapped_phase` if absent (fail closed)."""
597
- owner = PHASE_OWNER.get(phase)
598
- if owner is None:
599
- _die("unmapped_phase")
600
- return owner
601
-
602
-
603
- def save_state(root: Path, state: dict) -> None:
604
- state["updated"] = _now()
605
- _atomic_write(root / STATE_FILE, json.dumps(state, indent=2) + "\n")
606
-
607
-
608
- def _setup_locked(state: dict) -> bool:
609
- """True when the project's setup is locked — i.e. the build-boundary gate is OPEN.
610
-
611
- A state with NO "setup" key is GRANDFATHERED-locked: plain `init` and every legacy
612
- project are never gated (the lock is opt-in via `init --await-lock`). The gate is
613
- therefore active in exactly one case: "setup" present AND locked is False."""
614
- return ("setup" not in state) or (state["setup"].get("locked") is True)
615
-
616
-
617
- def _milestone_confirmed(state: dict, mslug: str) -> bool:
618
- """True when milestone `mslug` is confirmed — i.e. the new-task gate is OPEN.
619
-
620
- Mirrors `_setup_locked` one level down. A milestone record with NO "confirmed" key is
621
- GRANDFATHERED-confirmed: every milestone created WITHOUT `--await-confirm` (and every
622
- pre-existing one) is never gated. Opt-in: `new-milestone --await-confirm` seeds confirmed:false,
623
- so the gate is active in exactly one case: the record is present AND confirmed is False. An
624
- unknown milestone is treated as confirmed here (existence is cmd_new_task's separate check)."""
625
- m = (state.get("milestones") or {}).get(mslug)
626
- if not isinstance(m, dict) or "confirmed" not in m:
627
- return True
628
- return m["confirmed"] is True
629
-
630
-
631
- def _section_unfilled(md_text: str, header: str) -> bool:
632
- """True iff the `header` section is PRESENT but UNFILLED — empty (no real bullet) or
633
- still a `<…>` template placeholder. ABSENT section -> False (grandfathered legacy);
634
- a filled section (>=1 real bullet, no `<…>`) -> False. Pure predicate — the shared
635
- placeholder test the fill gates use (contract-fill at confirm; build-expectations at build)."""
636
- body, in_sec, present = [], False, False
637
- for ln in md_text.splitlines():
638
- if ln.startswith(header):
639
- in_sec, present = True, True
640
- continue
641
- if in_sec:
642
- if ln.startswith("#"): # ANY next header (## or ###) ends our section
643
- break
644
- if ln.lstrip().startswith(">"): # skip blockquote GUIDANCE — it is not content
645
- continue
646
- body.append(ln)
647
- if not present:
648
- return False # absent -> grandfather
649
- text = "\n".join(body).strip()
650
- if not text:
651
- return True # present but empty
652
- return bool(re.search(r"<[^>\n]+>", text)) # a <…> placeholder remains
653
197
 
654
198
 
655
199
  def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None:
@@ -668,7 +212,7 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
668
212
  return # unreadable -> no-op (never blocks the gate)
669
213
  if "### GATE RECORD" not in text:
670
214
  return # nothing to mirror into
671
- actor = _actor_stamp(state)
215
+ actor = identity._actor_stamp(state)
672
216
  today = date.today().isoformat()
673
217
  # each rule matches ONLY a line still carrying a `<…>` placeholder -> grandfather a resolved line.
674
218
  rules = [
@@ -695,251 +239,11 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
695
239
  _atomic_write(f, new)
696
240
 
697
241
 
698
- def _die(msg: str, code: int = 1) -> None:
699
- print(f"add: error: {msg}", file=sys.stderr)
700
- raise SystemExit(code)
701
-
702
-
703
- # --- guideline injection (dynamic-by-reference; designed for failure) --------
704
- #
705
- # Inject one stable, marker-delimited ADD block into the project root's AGENTS.md
706
- # and CLAUDE.md. The block is DYNAMIC-BY-REFERENCE: it tells the agent to run
707
- # `add.py status` and read PROJECT.md — it never embeds live state (slug, phase,
708
- # gate). Auto-updated context files measurably hurt (ETH-Zurich: ~3% lower success,
709
- # 20%+ more cost), so the stable pointer is the whole point.
710
-
711
- def _guideline_block() -> str:
712
- """The canonical ADD block (markers + body, no trailing newline).
713
-
714
- Agent-agnostic by design (v14 agent-portability): the routing steps depend
715
- only on the CLI and plain files, so any agent — Claude, Cursor, Copilot,
716
- Codex — can follow them. Claude additionally gets the `add` skill."""
717
- return (
718
- f"{_GUIDE_BEGIN}\n"
719
- "## ADD — how to work in this repo\n"
720
- "\n"
721
- "This project uses **ADD (AI-Driven Development)**: you, the AI, drive the build;\n"
722
- "the human owns direction and verification. The loop below works for any agent —\n"
723
- "Claude, Cursor, Copilot, Codex — through the CLI alone. Before you change code:\n"
724
- "\n"
725
- "1. Run `python3 .add/tooling/add.py status` — where the project is and what's\n"
726
- " next (the resume point; read it first every session).\n"
727
- "2. Read `.add/PROJECT.md` — the foundation (domain · spec · UI/UX) every task\n"
728
- " builds on.\n"
729
- "3. Run `python3 .add/tooling/add.py guide` — it names the phase and the exact\n"
730
- " phase-guide file to read (the `guide :` line). Work ONLY that phase — each\n"
731
- " guide ends with its exit gate and the command to move on.\n"
732
- "\n"
733
- "The flow: INTAKE sizes a request into a milestone; each task runs the\n"
734
- "**specification bundle** — Spec+Scenarios+Contract+Tests as one bundle,\n"
735
- "ONE human approval at the frozen contract — then a self-driving build→verify\n"
736
- "run. Non-negotiable for every agent:\n"
737
- "Never weaken a test or edit a frozen contract to make a build pass; a security\n"
738
- "finding is always HARD-STOP — never auto-passed.\n"
739
- "\n"
740
- "On Claude Code the `add` skill drives this loop automatically; other agents\n"
741
- "follow the three steps. The book is in `.add/docs/`. This block is generated\n"
742
- "by `add.py sync-guidelines`; edit outside the markers, not inside.\n"
743
- f"{_GUIDE_END}"
744
- )
745
-
746
-
747
- def _inject_block(path: Path) -> str:
748
- """Write the ADD block into `path`. Returns created|updated|unchanged.
749
-
750
- - unchanged: on-disk block already matches -> no write, no .bak (idempotent).
751
- - updated: existing content changes -> back up the original to <path>.bak first.
752
- - created: file did not exist -> write the block, no .bak.
753
- User content outside the markers is always preserved.
754
- """
755
- block = _guideline_block()
756
- if path.exists():
757
- current = path.read_text(encoding="utf-8")
758
- begin = current.find(_GUIDE_BEGIN)
759
- if begin != -1:
760
- end = current.find(_GUIDE_END, begin)
761
- if end != -1: # replace only the marked region
762
- end += len(_GUIDE_END)
763
- new = current[:begin] + block + current[end:]
764
- else: # begin without end: corrupt — append fresh
765
- print(f"add: warning: {path.name}: found an ADD:BEGIN with no ADD:END "
766
- "— appending a fresh block; review the result", file=sys.stderr)
767
- new = current.rstrip("\n") + "\n\n" + block + "\n"
768
- else: # no block yet — append, keep user content
769
- new = current.rstrip("\n") + "\n\n" + block + "\n"
770
- if new == current:
771
- return "unchanged"
772
- _atomic_write(Path(str(path) + ".bak"), current) # rollback path before mutate
773
- _atomic_write(path, new)
774
- return "updated"
775
- _atomic_write(path, block + "\n")
776
- return "created"
777
-
778
-
779
- def _rule_file_mode(project_root: Path, flag: bool = False) -> bool:
780
- """True when the ADD block should live in .claude/rules/add-workflows.md (referenced
781
- from CLAUDE.md) instead of inline. Re-derived from disk EACH phase — no persisted
782
- state — so an explicit `--rule-file` at install carries into init via the rule file it
783
- leaves behind. Three triggers: the explicit flag, a ccsk project (.ccsk/ sibling to
784
- .claude/), or a rule file already written by a prior run. Pure + fail-soft."""
785
- if flag:
786
- return True
787
- try:
788
- if (project_root / ".ccsk").is_dir():
789
- return True
790
- if (project_root / RULES_FILE_REL).exists():
791
- return True
792
- except OSError:
793
- pass
794
- return False
795
-
796
-
797
- def _strip_inline_block(text: str) -> str:
798
- """Remove an inline ADD:BEGIN..ADD:END region (migration to rule-file mode), collapsing
799
- the blank-line gap it leaves behind. An unterminated BEGIN (no END) is left as-is rather
800
- than eating the rest of the file (design-for-failure)."""
801
- begin = text.find(_GUIDE_BEGIN)
802
- if begin == -1:
803
- return text
804
- end = text.find(_GUIDE_END, begin)
805
- if end == -1:
806
- return text
807
- end += len(_GUIDE_END)
808
- head = text[:begin].rstrip("\n")
809
- tail = text[end:].lstrip("\n")
810
- if head and tail:
811
- return head + "\n\n" + tail
812
- return head or tail
813
-
814
-
815
- def _insert_rule_reference(text: str) -> str:
816
- """Insert the ADD rule-file bullet under an existing Workflows/Rules heading, or append a
817
- fresh '## Workflows' section when none is found. Caller guarantees the bullet is absent."""
818
- lines = text.split("\n")
819
- heading_idx = -1
820
- for i, line in enumerate(lines):
821
- m = re.match(r"^(#{1,6})\s+(.*?)\s*$", line)
822
- if m and any(m.group(2).strip().lower() == h.lower() for h in WORKFLOW_HEADINGS):
823
- heading_idx = i
824
- break
825
- if heading_idx == -1: # no section — append a fresh one
826
- body = text.rstrip("\n")
827
- sep = "\n\n" if body else ""
828
- return f"{body}{sep}## Workflows\n\n{_RULE_REF_LINE}\n"
829
- level = len(re.match(r"^(#{1,6})", lines[heading_idx]).group(1))
830
- end = len(lines) # section ends at next same/higher heading or EOF
831
- for j in range(heading_idx + 1, len(lines)):
832
- m = re.match(r"^(#{1,6})\s+", lines[j])
833
- if m and len(m.group(1)) <= level:
834
- end = j
835
- break
836
- insert_at = heading_idx + 1 # after the last non-blank line in the section
837
- for j in range(heading_idx + 1, end):
838
- if lines[j].strip():
839
- insert_at = j + 1
840
- lines.insert(insert_at, _RULE_REF_LINE)
841
- return "\n".join(lines)
842
-
843
-
844
- def _ensure_claude_reference(claude_md: Path) -> str:
845
- """Make CLAUDE.md reference the ADD rule file under a Workflows/Rules heading, migrating
846
- any prior inline ADD block out. Returns created|updated|unchanged.
847
-
848
- - Strips a prior inline ADD:BEGIN..END block (rule-file mode supersedes inline).
849
- - If a reference to add-workflows.md already exists -> no bullet change.
850
- - Else inserts the bullet into the first matching section, or appends '## Workflows'.
851
- .bak on change; idempotent. User content outside the touched region is preserved.
852
- """
853
- existed = claude_md.exists()
854
- current = claude_md.read_text(encoding="utf-8") if existed else ""
855
- new = _strip_inline_block(current)
856
- if "add-workflows.md" not in new:
857
- new = _insert_rule_reference(new)
858
- if not new.endswith("\n"):
859
- new += "\n"
860
- if new == current:
861
- return "unchanged"
862
- if existed:
863
- _atomic_write(Path(str(claude_md) + ".bak"), current) # rollback path before mutate
864
- _atomic_write(claude_md, new)
865
- return "updated" if existed else "created"
866
-
867
-
868
- def _inject_guidelines(project_root: Path, rule_file: bool = False) -> list[tuple[str, str]]:
869
- """Inject the block into each guideline file under `project_root`.
870
-
871
- Symlink-dedup: targets resolving (os.path.realpath) to the same inode are
872
- written once, against the REAL file (never replacing the symlink with a
873
- regular file). Per-target OSError is isolated (warn+skip) so one unwritable
874
- file never aborts the run or `init`.
875
-
876
- Rule-file mode (ccsk projects / `--rule-file`): CLAUDE.md's full block is relocated
877
- to .claude/rules/add-workflows.md and CLAUDE.md keeps only a reference bullet. This is
878
- CLAUDE-only — AGENTS.md (and any other guideline file) keeps the inline block.
879
- """
880
- results: list[tuple[str, str]] = []
881
- seen: set[str] = set()
882
- mode = _rule_file_mode(project_root, rule_file)
883
- for name in GUIDELINE_FILES:
884
- if name == "CLAUDE.md" and mode:
885
- rules_path = project_root / RULES_FILE_REL
886
- real = os.path.realpath(rules_path)
887
- if real not in seen:
888
- seen.add(real)
889
- try:
890
- action = _inject_block(rules_path)
891
- except (OSError, UnicodeDecodeError) as exc:
892
- print(f"add: warning: could not sync {RULES_FILE_REL} — {exc}; skipped",
893
- file=sys.stderr)
894
- action = "skipped"
895
- results.append((str(RULES_FILE_REL), action))
896
- try:
897
- ref_action = _ensure_claude_reference(project_root / "CLAUDE.md")
898
- except (OSError, UnicodeDecodeError) as exc:
899
- print(f"add: warning: could not sync CLAUDE.md — {exc}; skipped",
900
- file=sys.stderr)
901
- ref_action = "skipped"
902
- results.append(("CLAUDE.md", ref_action))
903
- continue
904
- target = project_root / name
905
- real = os.path.realpath(target)
906
- if real in seen:
907
- continue
908
- seen.add(real)
909
- write_target = Path(real) if target.is_symlink() else target
910
- try:
911
- action = _inject_block(write_target)
912
- except (OSError, UnicodeDecodeError) as exc:
913
- # design for failure: an unwritable target OR a non-UTF-8 existing file
914
- # (e.g. a UTF-16 CLAUDE.md from a Windows editor) must not crash init or
915
- # abort the other target — warn and skip this one.
916
- print(f"add: warning: could not sync {name} — {exc}; skipped",
917
- file=sys.stderr)
918
- action = "skipped"
919
- results.append((name, action))
920
- return results
921
-
922
-
923
- # --- commands ----------------------------------------------------------------
924
-
925
- _INIT_EXCLUDE = {
926
- ".add", "AGENTS.md", "CLAUDE.md", ".git",
927
- ".gitignore", ".gitattributes", ".github", ".editorconfig", # VCS/CI/editor scaffolding — no domain signal
928
- "LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", # legal boilerplate — no domain signal
929
- } # README/docs/source are NOT excluded: they carry domain content adopt.md maps -> brownfield
930
-
931
-
932
- def _is_brownfield(base: Path) -> bool:
933
- """True when `base` already holds project content beyond the tool's own scaffolding.
934
-
935
- Judgment-free: a mechanical fact (does the dir hold a non-excluded entry?), so the
936
- autonomous-onboarding flow knows to map existing code into the living documentation. INTERPRETING
937
- that code stays with the AI (skill/add/adopt.md) — the engine only detects + signals."""
938
- if not base.is_dir():
939
- return False
940
- return any(child.name not in _INIT_EXCLUDE for child in base.iterdir())
941
-
942
-
242
+ # --- guidelines / CLAUDE.md-injection subsystem (moved to add_engine/guidelines.py) -
243
+ from add_engine.guidelines import (
244
+ _guideline_block, _inject_block, _rule_file_mode, _strip_inline_block,
245
+ _insert_rule_reference, _ensure_claude_reference, _inject_guidelines, _is_brownfield,
246
+ )
943
247
  def cmd_init(args: argparse.Namespace) -> None:
944
248
  base = Path(args.dir).resolve()
945
249
  root = base / ROOT_DIRNAME
@@ -1003,6 +307,10 @@ def cmd_init(args: argparse.Namespace) -> None:
1003
307
  else:
1004
308
  print("next: open Claude Code, run `/add`, and say what you want to build —")
1005
309
  print(" the `add` skill sizes it into a milestone and drives the build with you.")
310
+ # setup hygiene (both branches): the .add/ folder IS the shared project state — commit it
311
+ # so the team shares one source of truth; its transient working files are already gitignored.
312
+ print("tip: commit the .add/ folder to git so your team shares the ADD state "
313
+ "(its transient files are already .gitignored).")
1006
314
 
1007
315
 
1008
316
  def cmd_sync_guidelines(args: argparse.Namespace) -> None:
@@ -1088,6 +396,11 @@ def cmd_new_task(args: argparse.Namespace) -> None:
1088
396
  f"autonomy token; new task seeded fail-safe '{autonomy}' "
1089
397
  "(fix it with `add.py autonomy set <level> --project`)", file=sys.stderr)
1090
398
 
399
+ # F8 (force-preserve-heal): a --force overwrite RE-CREATES the record; capture the prior
400
+ # MONOTONIC heal counter first so it survives. Else a task that accrued heal attempts (or
401
+ # was HARD-STOP escalated) could launder the cap (HEAL_CAP) to zero by re-creating itself —
402
+ # a zero-human cap bypass (the same invariant _heal_or_escalate guards: "never auto-resets").
403
+ prior_heal = state["tasks"].get(slug, {}).get("heal") if args.force else None
1091
404
  state["tasks"][slug] = {
1092
405
  "title": title,
1093
406
  "phase": "ground",
@@ -1097,6 +410,8 @@ def cmd_new_task(args: argparse.Namespace) -> None:
1097
410
  "created": _now(),
1098
411
  "updated": _now(),
1099
412
  }
413
+ if prior_heal is not None:
414
+ state["tasks"][slug]["heal"] = prior_heal # monotonic — survives the --force re-create
1100
415
  if from_delta:
1101
416
  state["tasks"][slug]["from_delta"] = from_delta # lineage: seeded from <prior>
1102
417
  if fast:
@@ -1107,6 +422,11 @@ def cmd_new_task(args: argparse.Namespace) -> None:
1107
422
  if milestone:
1108
423
  print(f"linked to milestone '{milestone}'" +
1109
424
  (f", depends-on {depends_on}" if depends_on else ""))
425
+ elif fast:
426
+ # blessed milestone-free fast lane (standalone-fast-task): a --fast task with no owning
427
+ # milestone is a DELIBERATE low-ceremony lane, not an orphan to nag — AFFIRM it.
428
+ print(f"standalone fast task '{slug}' — milestone-free by design (low-ceremony lane); "
429
+ f"attach later with `add.py set-milestone {slug} --milestone <id>` if it grows")
1110
430
  else:
1111
431
  # warn-never-block: the task is created (escape hatch), but nudge back toward the
1112
432
  # intake -> milestone flow. Speaks of STRUCTURE (not attached), never the act.
@@ -1145,22 +465,80 @@ def cmd_drop_delta(args: argparse.Namespace) -> None:
1145
465
  print(_next_footer(root, state))
1146
466
 
1147
467
 
468
+ # a §3 still carrying this template placeholder is NOT a drafted contract yet
469
+ _CONTRACT_TEMPLATE_RE = re.compile(r"<METHOD>")
470
+
471
+
472
+ def _next_freeze_version(state: dict, slug: str) -> str:
473
+ """v1 on the first freeze; N+1 of the highest prior freeze version recorded on the
474
+ task's state record on a re-freeze (after a change request). PURE — reads state only."""
475
+ prior = ((state.get("tasks") or {}).get(slug) or {}).get("freeze") or {}
476
+ m = re.fullmatch(r"v(\d+)", str(prior.get("version", "")))
477
+ return f"v{int(m.group(1)) + 1}" if m else "v1"
478
+
479
+
480
+ def cmd_freeze(args: argparse.Namespace) -> None:
481
+ """The §3 contract-freeze write command — the 5th engine-WRITTEN human approval (task
482
+ freeze-actor-stamp), joining lock · gate · milestone-done · release. Flips the target
483
+ task's §3 `Status: DRAFT` -> `FROZEN @ vN — approved by <name>` AND records a structured
484
+ actor on the task's state record (mirrors cmd_lock's `setup.actor`), so the audit trail
485
+ has no hole at freeze. The human RUNS it as their approval — never pre-stamped.
486
+
487
+ validate-then-write: every refusal fires before any write. Writes TASK.md first, then
488
+ state; a crash between degrades to today's legacy text-only freeze (never corrupt state),
489
+ design-for-failure."""
490
+ root = _require_root()
491
+ state = load_state(root)
492
+ raw_slug = getattr(args, "slug", None)
493
+ if not raw_slug and not _active_task(state):
494
+ _die("no_active_task: no task given and no active task is set")
495
+ slug = _resolve_task(state, raw_slug) # unknown slug -> _die
496
+ task_md = root / "tasks" / slug / "TASK.md"
497
+ text = task_md.read_text(encoding="utf-8")
498
+ raw3 = _phase_spans(text).get(3, "")
499
+ phase = (state["tasks"].get(slug) or {}).get("phase", "specify")
500
+ # --- validate (no writes); error precedence: frozen -> not-drafted -> unflagged ---
501
+ if _contract_frozen(raw3):
502
+ _die(f"already_frozen: {slug}'s §3 is already FROZEN — re-freeze only via a change "
503
+ f"request back to SPECIFY")
504
+ if _phase_index(phase) < _phase_index("contract") or _CONTRACT_TEMPLATE_RE.search(raw3):
505
+ _die(f"contract_not_drafted: {slug}'s §3 is not a drafted contract yet — reach the "
506
+ f"`contract` phase and replace the template before freezing")
507
+ if not _flag_well_formed(raw3):
508
+ _die(f"unflagged_freeze: {slug}'s §3 must surface a well-formed lowest-confidence flag "
509
+ f"('Least-sure flag surfaced at freeze:' + a [part] tag) before it freezes")
510
+ # --- write ---
511
+ ver = _next_freeze_version(state, slug)
512
+ who = args.by or identity._actor_stamp(state)["name"]
513
+ # flip the `Status: DRAFT` line WITHIN the §3 region only — a bare `Status: DRAFT` in
514
+ # §1/§2 prose must never be frozen by mistake (refute-read finding). §3 span runs from
515
+ # its `## 3 ·` heading to the next `## `/`---`/EOF (same boundary as _phase_spans).
516
+ h3 = re.search(r"(?m)^##\s*3\s*·.*$", text)
517
+ if not h3:
518
+ _die(f"contract_not_drafted: {slug}'s TASK.md has no §3 CONTRACT section")
519
+ seg_start = h3.end()
520
+ nxt = re.search(r"(?m)^(?:##\s|---\s*$)", text[seg_start:])
521
+ seg_end = seg_start + (nxt.start() if nxt else len(text) - seg_start)
522
+ new_seg, n = re.subn(r"(?m)^(\s*)Status:\s*DRAFT\s*$",
523
+ lambda m: f"{m.group(1)}Status: FROZEN @ {ver} — approved by {who}",
524
+ text[seg_start:seg_end], count=1)
525
+ if n == 0:
526
+ _die(f"contract_not_drafted: {slug}'s §3 has no 'Status: DRAFT' line to freeze")
527
+ new_text = text[:seg_start] + new_seg + text[seg_end:]
528
+ _atomic_write(task_md, new_text) # TASK.md first (audit source of truth)
529
+ state["tasks"][slug]["freeze"] = {"version": ver, "frozen_at": _now(),
530
+ "approved_by": who, "actor": identity._actor_stamp(state)}
531
+ save_state(root, state)
532
+ print(f"froze §3 of {slug} @ {ver} — approved by {who}")
533
+ print(_next_footer(root, state))
534
+
535
+
1148
536
  def _parse_deps(raw: str | None) -> list[str]:
1149
537
  if not raw:
1150
538
  return []
1151
539
  return [d.strip() for d in raw.split(",") if d.strip()]
1152
540
 
1153
541
 
1154
- def _task_done(t: dict) -> bool:
1155
- # Matrix 3: a task is done when Verify reads PASS *or a signed RISK-ACCEPTED*.
1156
- # Both completing gates advance phase to "done" (cmd_gate), and a waiver is
1157
- # signed at gate time — so a verdict gate is enough here; we need not re-read
1158
- # the waiver. HARD-STOP never reaches "done". A bare `phase done` (escape
1159
- # hatch, gate still "none") deliberately does NOT count: completion needs a
1160
- # recorded verdict, not just a phase marker.
1161
- return t.get("phase") == "done" and t.get("gate") in ("PASS", "RISK-ACCEPTED")
1162
-
1163
-
1164
542
  def _archived_task_slugs(state: dict) -> set[str]:
1165
543
  """Slugs of tasks that left active state via archive — all were PASS-done at
1166
544
  archive time, so a dep on one of them counts as satisfied (not dangling).
@@ -1184,16 +562,94 @@ def _resolve_task(state: dict, slug: str | None) -> str:
1184
562
  return slug
1185
563
 
1186
564
 
565
+ def _build_entry(root: Path, state: dict, slug: str) -> None:
566
+ """The shared tests->build entry guards + snapshots (task phase-build-guard, F4).
567
+
568
+ Extracted VERBATIM from cmd_advance's `nxt == "build"` block so BOTH `advance` and the
569
+ `phase build` admin override run the identical gate stack — the freeze gate, the
570
+ build-expectations gate, the unflagged-freeze check + flag stamp, the tamper tripwire, and
571
+ the §5 scope snapshot. validate-then-write: every `_die` precedes the first state mutation,
572
+ so a refused entry leaves the task byte-unchanged. The heal loop sets phase=build DIRECTLY
573
+ and never routes here, so it stays exempt.
574
+ """
575
+ # the OPTED-IN crossing guards (fast-lane + flow-enforcement): a task whose PARENT
576
+ # milestone opted into --await-confirm is held to the trust floor at tests->build. A
577
+ # task under a plain / no milestone is never gated (every existing advance-to-build flow
578
+ # stays green). validate-then-write — every refusal runs BEFORE the tripwire/scope
579
+ # snapshots below, writing nothing; the task stays at `tests`.
580
+ _ms = state["tasks"][slug].get("milestone")
581
+ _optin = bool(_ms) and (state.get("milestones") or {}).get(_ms, {}).get("await_confirm") is True
582
+ raw3 = _raw_phase_bodies(root, slug).get(3, "")
583
+ # freeze-before-build gate (fast-lane): "collapse-never-skip" made REAL — the freeze is
584
+ # engine-enforced for an opted-in milestone OR any --fast task (the fast arm, fast-new-task-flag:
585
+ # a fast task is held to the floor under ANY milestone, so the lighter lane never drops the
586
+ # trust seam). PRECEDES build-expectations: you freeze §3 before pre-declaring §6's outcomes.
587
+ _freeze_gated = _optin or state["tasks"][slug].get("fast") is True
588
+ if _freeze_gated and not _contract_frozen(raw3):
589
+ _die("contract_not_frozen: freeze §3 before crossing into build — approve "
590
+ f"the contract in {slug}'s TASK.md (Status: FROZEN @ vN)")
591
+ # build-expectations gate (flow-enforcement): an opted-in task may not enter build until
592
+ # its §6 `### Build expectations` are pre-declared — so verify checks the build is RIGHT,
593
+ # not just green. Same opt-in switch as the contract-fill gate, one level out.
594
+ if _optin:
595
+ if _section_unfilled(_raw_phase_bodies(root, slug).get(6, ""), "### Build expectations"):
596
+ _die("build_expectations_unfilled: fill the §6 '### Build expectations' block "
597
+ f"of {slug}'s TASK.md before crossing into build")
598
+ if _contract_frozen(raw3):
599
+ if not _flag_well_formed(raw3):
600
+ _die("unflagged_freeze: a frozen §3 must surface a well-formed "
601
+ "'Least-sure flag surfaced at freeze:' unit (>=1 [part] tag "
602
+ "+ substantive content; bare 'none' only as 'none material — "
603
+ "biggest risk: X') before crossing into build")
604
+ state["tasks"][slug]["flag_verified"] = True
605
+ # tamper tripwire (verify-integrity): snapshot the red test files + the frozen
606
+ # §3 md5s so the verify gate can prove the green was EARNED, not edited into
607
+ # place. UNCONDITIONAL overwrite — a legit change-request that re-crosses
608
+ # tests->build re-snapshots cleanly. Co-witnessed by flag_verified (above).
609
+ state["tasks"][slug]["tripwire"] = _tripwire_snapshot(root, slug, raw3)
610
+ # §5 scope gate (build-scope-lock): when the task declares its Scope, freeze
611
+ # the project tree into a sidecar (payload) + a state.json anchor (md5 of the
612
+ # sidecar bytes). Same UNCONDITIONAL-overwrite semantics as the tripwire.
613
+ # UNDECLARED (no Scope line) takes no snapshot — grandfathered, never retro-red
614
+ # — and CLEANS UP a previous declaration's leftovers (v3): a declared->
615
+ # undeclared re-cross pops the stale anchor + unlinks the stale sidecar, so
616
+ # "UNDECLARED is never refused" holds on every path.
617
+ declared = _declared_scope(root, slug)
618
+ side = root / "tasks" / slug / "scope-snapshot.json"
619
+ if declared is not None:
620
+ payload = json.dumps({"version": 1,
621
+ "files": _scope_walk(root.parent.resolve())},
622
+ sort_keys=True)
623
+ _atomic_write(side, payload) # temp+replace, like save_state — a crash can't leave a
624
+ # torn sidecar (payload verbatim, no newline → md5 anchor holds)
625
+ state["tasks"][slug]["scope"] = {"declared": declared,
626
+ "snapshot_md5": _md5_text(payload)}
627
+ else:
628
+ state["tasks"][slug].pop("scope", None)
629
+ try:
630
+ side.unlink()
631
+ except OSError:
632
+ pass
633
+
634
+
1187
635
  def cmd_phase(args: argparse.Namespace) -> None:
1188
636
  root = _require_root()
1189
637
  state = load_state(root)
1190
638
  slug = _resolve_task(state, args.slug)
1191
639
  if args.phase not in PHASES:
1192
640
  _die(f"phase must be one of: {', '.join(PHASES)}")
641
+ # phase-build-guard (F4): the admin override is NOT a backdoor around the tests->build gate
642
+ # stack — setting a task to build runs the SAME _build_entry guards `advance` runs (freeze
643
+ # gate · build-expectations · flag check · tamper tripwire · scope snapshot), so verify's
644
+ # _tamper_guard is armed and a freeze-gated DRAFT §3 is refused. Other targets are unchanged.
645
+ # validate-then-write: a refusal raises BEFORE the phase is set, so nothing moves. The heal
646
+ # loop sets phase=build directly (never via cmd_phase) and so stays exempt.
647
+ if args.phase == "build":
648
+ _build_entry(root, state, slug)
1193
649
  state["tasks"][slug]["phase"] = args.phase
1194
650
  state["tasks"][slug]["updated"] = _now()
1195
- _sync_task_marker(root, slug, args.phase)
1196
- save_state(root, state)
651
+ save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
652
+ _sync_task_marker(root, slug, args.phase) # then mirror into TASK.md (best-effort) — no split-brain
1197
653
  print(f"task '{slug}' phase -> {args.phase}")
1198
654
  print(_next_footer(root, state))
1199
655
 
@@ -1230,63 +686,10 @@ def cmd_advance(args: argparse.Namespace) -> None:
1230
686
  # freezes — the unmarked predecessors are never retro-redded). REFUSE writes
1231
687
  # nothing (fail-closed); below the build boundary the flag is never checked.
1232
688
  if nxt == "build":
1233
- # the OPTED-IN crossing guards (fast-lane + flow-enforcement): a task whose PARENT
1234
- # milestone opted into --await-confirm is held to the trust floor at tests->build. A
1235
- # task under a plain / no milestone is never gated (every existing advance-to-build flow
1236
- # stays green). validate-then-write — every refusal runs BEFORE the tripwire/scope
1237
- # snapshots below, writing nothing; the task stays at `tests`.
1238
- _ms = state["tasks"][slug].get("milestone")
1239
- _optin = bool(_ms) and (state.get("milestones") or {}).get(_ms, {}).get("await_confirm") is True
1240
- raw3 = _raw_phase_bodies(root, slug).get(3, "")
1241
- # freeze-before-build gate (fast-lane): "collapse-never-skip" made REAL — the freeze is
1242
- # engine-enforced for an opted-in milestone OR any --fast task (the fast arm, fast-new-task-flag:
1243
- # a fast task is held to the floor under ANY milestone, so the lighter lane never drops the
1244
- # trust seam). PRECEDES build-expectations: you freeze §3 before pre-declaring §6's outcomes.
1245
- _freeze_gated = _optin or state["tasks"][slug].get("fast") is True
1246
- if _freeze_gated and not _contract_frozen(raw3):
1247
- _die("contract_not_frozen: freeze §3 before crossing into build — approve "
1248
- f"the contract in {slug}'s TASK.md (Status: FROZEN @ vN)")
1249
- # build-expectations gate (flow-enforcement): an opted-in task may not enter build until
1250
- # its §6 `### Build expectations` are pre-declared — so verify checks the build is RIGHT,
1251
- # not just green. Same opt-in switch as the contract-fill gate, one level out.
1252
- if _optin:
1253
- if _section_unfilled(_raw_phase_bodies(root, slug).get(6, ""), "### Build expectations"):
1254
- _die("build_expectations_unfilled: fill the §6 '### Build expectations' block "
1255
- f"of {slug}'s TASK.md before crossing into build")
1256
- if _contract_frozen(raw3):
1257
- if not _flag_well_formed(raw3):
1258
- _die("unflagged_freeze: a frozen §3 must surface a well-formed "
1259
- "'Least-sure flag surfaced at freeze:' unit (>=1 [part] tag "
1260
- "+ substantive content; bare 'none' only as 'none material — "
1261
- "biggest risk: X') before crossing into build")
1262
- state["tasks"][slug]["flag_verified"] = True
1263
- # tamper tripwire (verify-integrity): snapshot the red test files + the frozen
1264
- # §3 md5s so the verify gate can prove the green was EARNED, not edited into
1265
- # place. UNCONDITIONAL overwrite — a legit change-request that re-crosses
1266
- # tests->build re-snapshots cleanly. Co-witnessed by flag_verified (above).
1267
- state["tasks"][slug]["tripwire"] = _tripwire_snapshot(root, slug, raw3)
1268
- # §5 scope gate (build-scope-lock): when the task declares its Scope, freeze
1269
- # the project tree into a sidecar (payload) + a state.json anchor (md5 of the
1270
- # sidecar bytes). Same UNCONDITIONAL-overwrite semantics as the tripwire.
1271
- # UNDECLARED (no Scope line) takes no snapshot — grandfathered, never retro-red
1272
- # — and CLEANS UP a previous declaration's leftovers (v3): a declared->
1273
- # undeclared re-cross pops the stale anchor + unlinks the stale sidecar, so
1274
- # "UNDECLARED is never refused" holds on every path.
1275
- declared = _declared_scope(root, slug)
1276
- side = root / "tasks" / slug / "scope-snapshot.json"
1277
- if declared is not None:
1278
- payload = json.dumps({"version": 1,
1279
- "files": _scope_walk(root.parent.resolve())},
1280
- sort_keys=True)
1281
- side.write_text(payload, encoding="utf-8")
1282
- state["tasks"][slug]["scope"] = {"declared": declared,
1283
- "snapshot_md5": _md5_text(payload)}
1284
- else:
1285
- state["tasks"][slug].pop("scope", None)
1286
- try:
1287
- side.unlink()
1288
- except OSError:
1289
- pass
689
+ # the tests->build entry guards + snapshots now live in the shared _build_entry helper
690
+ # (task phase-build-guard, F4) so `advance` and the `phase build` admin override run the
691
+ # IDENTICAL gate stack. Behavior here is byte-unchanged this is a pure extraction.
692
+ _build_entry(root, state, slug)
1290
693
  # cross-component contract artifact (cross-component-contract): the contract->tests crossing
1291
694
  # is the producer's freeze-approval moment. A `produces:` task WRITES the immutable snapshot;
1292
695
  # a `consumes:` task PINS the live hash — a missing/unreadable snapshot HARD-STOPS here (the
@@ -1327,8 +730,8 @@ def cmd_advance(args: argparse.Namespace) -> None:
1327
730
  state["tasks"][slug]["contract_pin"] = {"id": _cons, "hash": pinned}
1328
731
  state["tasks"][slug]["phase"] = nxt
1329
732
  state["tasks"][slug]["updated"] = _now()
1330
- _sync_task_marker(root, slug, nxt)
1331
- save_state(root, state)
733
+ save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
734
+ _sync_task_marker(root, slug, nxt) # then mirror into TASK.md (best-effort) — no split-brain
1332
735
  print(f"task '{slug}' phase {cur} -> {nxt}")
1333
736
  if nxt == "observe":
1334
737
  # OBSERVE is where this loop's lessons get captured (TASK.md §7) — suggest routing
@@ -1356,12 +759,10 @@ _RISK_HIGH_RE = re.compile(r"(?:^|·)[ \t]*risk:[ \t]*high\b", re.MULTILINE)
1356
759
 
1357
760
  # the explicit 3-mode autonomy dial (task explicit-autonomy-dial): an ordered ladder
1358
761
  # manual < conservative < auto, declared as a per-task `autonomy:` header token.
1359
- _AUTONOMY_LEVELS = ("manual", "conservative", "auto")
1360
762
  # anchored to a DECLARATION position — line-start `autonomy:` OR the inline slug-line form
1361
763
  # `… · autonomy: conservative` (the `·`-preceded shape) — never a title/prose substring; the
1362
764
  # value stops at space/`<`/`#`/`|` so an unfilled `<manual | … >` placeholder captures nothing
1363
765
  # and reads as UNSET.
1364
- _AUTONOMY_LINE_RE = re.compile(r"(?:^|·)[ \t]*autonomy:[ \t]*([^\s<#|]+)", re.MULTILINE)
1365
766
 
1366
767
  # component-aware-add: a task binds to a registered component via a `component: <name>`
1367
768
  # header token — the SAME anchored grammar as autonomy (line-start or the `·`-inline
@@ -1372,45 +773,12 @@ _PRODUCES_LINE_RE = re.compile(r"(?:^|·)[ \t]*produces:[ \t]*([^\s<#|]+)", re.M
1372
773
  _CONSUMES_LINE_RE = re.compile(r"(?:^|·)[ \t]*consumes:[ \t]*([^\s<#|]+)", re.MULTILINE)
1373
774
 
1374
775
 
1375
- def _autonomy_level(hdr: str):
1376
- """The declared autonomy rung from a TASK.md header region (HTML comments
1377
- already stripped by _task_header). Returns a member of _AUTONOMY_LEVELS, or
1378
- None when no `autonomy:` line is present (UNSET — an unfilled `<…>` placeholder,
1379
- whose value the regex declines, counts as unset), or "?" when a REAL token outside
1380
- the set was written (unknown). PURE."""
1381
- m = _AUTONOMY_LINE_RE.search(hdr)
1382
- if not m:
1383
- return None
1384
- tok = m.group(1).strip().lower()
1385
- return tok if tok in _AUTONOMY_LEVELS else "?"
1386
-
1387
-
1388
776
  def _autonomy_lowered(hdr: str) -> bool:
1389
777
  """True iff the declared rung is high-risk-safe (manual or conservative). A
1390
778
  high-risk scope must be lowered to one of these; `auto` and UNSET are not."""
1391
779
  return _autonomy_level(hdr) in ("manual", "conservative")
1392
780
 
1393
781
 
1394
- def _task_header(root: Path, slug: str) -> str:
1395
- """The TASK.md header region — where declared tokens (risk · autonomy)
1396
- live — with HTML comments stripped. Missing file -> '' (no tokens)."""
1397
- try:
1398
- text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8")
1399
- except OSError:
1400
- return ""
1401
- return re.sub(r"<!--.*?-->", "", text.split("\n## ", 1)[0], flags=re.S)
1402
-
1403
-
1404
- def _effective_autonomy(root: Path, state: dict, slug: str) -> str:
1405
- """The autonomy rung that governs `slug` right now: the task's own declared rung,
1406
- falling back to the project default when the task line is UNSET (None) or an
1407
- unrecognized token ("?") — the same fail-safe chain cmd_new_task seeds from
1408
- (_project_autonomy: absent -> auto, garbled -> conservative). PURE. `state` is unused
1409
- today; it is kept in the signature beside _driver_stop for symmetry."""
1410
- lvl = _autonomy_level(_task_header(root, slug))
1411
- return lvl if lvl in _AUTONOMY_LEVELS else _project_autonomy(root)
1412
-
1413
-
1414
782
  def _driver_stop(root: Path, state: dict, slug: str, phase: str) -> bool:
1415
783
  """True iff a HUMAN owns the next step for `phase` under the effective autonomy — the
1416
784
  SINGLE source the footer marker and the guide TEXT marker both render (task
@@ -1469,6 +837,10 @@ def cmd_gate(args: argparse.Namespace) -> None:
1469
837
  # §5 scope gate (build-scope-lock): touched ⊆ declared, or a named refusal —
1470
838
  # same placement discipline as the tripwire (before the waiver, never on HARD-STOP).
1471
839
  _scope_guard(root, state, slug)
840
+ # consumer-stale gate (consumer-stale-gate): a `consumes:` task whose pinned producer
841
+ # contract hash has drifted from the live snapshot built against an out-of-date shape —
842
+ # refuse the completing outcome (same before-the-waiver discipline). Re-pin to recover.
843
+ _consumer_stale_guard(root, state, slug)
1472
844
  # per-component verify (component-aware-add): a component-bound task with a declared
1473
845
  # green_bar must CITE that bar in its §6 evidence before a completing outcome — the
1474
846
  # engine never runs the suite, it checks the right bar was recorded. Unbound / no
@@ -1493,11 +865,12 @@ def cmd_gate(args: argparse.Namespace) -> None:
1493
865
  }
1494
866
  if completing:
1495
867
  state["tasks"][slug]["phase"] = "done"
1496
- _sync_task_marker(root, slug, "done")
1497
868
  state["tasks"][slug]["gate"] = args.outcome
1498
- state["tasks"][slug]["gate_actor"] = _actor_stamp(state) # WHO recorded the verdict (every outcome)
869
+ state["tasks"][slug]["gate_actor"] = identity._actor_stamp(state) # WHO recorded the verdict (every outcome)
1499
870
  state["tasks"][slug]["updated"] = _now()
1500
- save_state(root, state)
871
+ save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
872
+ if completing:
873
+ _sync_task_marker(root, slug, "done") # then mirror the phase into TASK.md — no split-brain
1501
874
  _stamp_gate_record(root, state, slug, args.outcome) # mirror the verdict into §6 (Finding C)
1502
875
  print(f"task '{slug}' gate -> {args.outcome}")
1503
876
  _gbar = _task_green_bar(root, slug) # per-component-verify: surface the bound bar
@@ -1582,6 +955,45 @@ def cmd_autonomy(args: argparse.Namespace) -> None:
1582
955
  _print_autonomy(root, state, slug)
1583
956
 
1584
957
 
958
+ def cmd_todo(args: argparse.Namespace) -> None:
959
+ """Capture / list / close a lightweight backlog todo (task: todo-capture).
960
+
961
+ A todo is a JOTTED IDEA, not a task — it carries no spec/contract/gate. It lets you
962
+ record an intent without sizing it. Promote one to a real task with
963
+ `add.py new-task <slug> --fast` when you decide to build it. Stored in state["todos"]
964
+ as {id (1-based = max+1), text, created, status:"open"|"done"}.
965
+ """
966
+ root = _require_root() # reused -> "no .add/ project found …"
967
+ state = load_state(root)
968
+ todos = state.get("todos")
969
+ if not isinstance(todos, list): # absent / corrupt -> fresh list (drift-safe)
970
+ todos = state["todos"] = []
971
+ done_id = getattr(args, "done", None)
972
+ if done_id is not None: # --done <id> : close an OPEN todo
973
+ for t in todos:
974
+ if isinstance(t, dict) and t.get("id") == done_id and t.get("status") == "open":
975
+ t["status"] = "done"
976
+ save_state(root, state)
977
+ print(f"todo #{done_id} done")
978
+ return
979
+ _die(f"todo_unknown: no open todo #{done_id}")
980
+ if args.text is not None: # capture attempt (text positional present)
981
+ text = args.text.strip()
982
+ if not text:
983
+ _die("todo_empty: a todo needs text")
984
+ new_id = max((t.get("id", 0) for t in todos if isinstance(t, dict)), default=0) + 1
985
+ todos.append({"id": new_id, "text": text, "created": _now(), "status": "open"})
986
+ save_state(root, state)
987
+ print(f"captured todo #{new_id}: {text}")
988
+ return
989
+ open_todos = [t for t in todos if isinstance(t, dict) and t.get("status") == "open"]
990
+ if not open_todos: # bare `todo` -> list OPEN todos
991
+ print("no open todos")
992
+ return
993
+ for t in open_todos:
994
+ print(f"#{t.get('id')} {t.get('text')}")
995
+
996
+
1585
997
  def cmd_reopen(args: argparse.Namespace) -> None:
1586
998
  """Return an already-`done` task to an earlier phase with a never-silent record.
1587
999
 
@@ -1616,8 +1028,8 @@ def cmd_reopen(args: argparse.Namespace) -> None:
1616
1028
  t["phase"] = target
1617
1029
  t["gate"] = "none"
1618
1030
  t["updated"] = now
1619
- _sync_task_marker(root, slug, target)
1620
- save_state(root, state)
1031
+ save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
1032
+ _sync_task_marker(root, slug, target) # then mirror into TASK.md (best-effort) — no split-brain
1621
1033
  print(f"task '{slug}' reopened: done -> {target} (reason recorded); gate reset to none")
1622
1034
  print(_next_footer(root, state))
1623
1035
 
@@ -1668,7 +1080,7 @@ def cmd_lock(args: argparse.Namespace) -> None:
1668
1080
  when = _now()
1669
1081
  # ONE atomic write — no partial lock state.
1670
1082
  state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers,
1671
- "actor": _actor_stamp(state)} # structured actor alongside the free-text locked_by
1083
+ "actor": identity._actor_stamp(state)} # structured actor alongside the free-text locked_by
1672
1084
  save_state(root, state)
1673
1085
  if getattr(args, "json", False):
1674
1086
  print(json.dumps(
@@ -1695,7 +1107,7 @@ def cmd_whoami(args: argparse.Namespace) -> None:
1695
1107
  _die("actor_name_blank")
1696
1108
  state["actor_override"] = {"name": args.name, "email": args.email or None}
1697
1109
  save_state(root, state)
1698
- who = _whoami(state)
1110
+ who = identity._whoami(state)
1699
1111
  if getattr(args, "json", False):
1700
1112
  print(json.dumps(who, separators=(",", ":")))
1701
1113
  return
@@ -1724,14 +1136,14 @@ def cmd_assign(args: argparse.Namespace) -> None:
1724
1136
  _die("unknown_slug")
1725
1137
  # parse + validate ALL flags BEFORE the first write — a blank name is rejected on the
1726
1138
  # PARSED name (so "<>" or " <a@x.io>", whose name parses empty, is caught like " ").
1727
- parsed_owner = _parse_actor_arg(args.owner) if args.owner is not None else None
1728
- parsed_assignee = _parse_actor_arg(args.assignee) if args.assignee is not None else None
1139
+ parsed_owner = identity._parse_actor_arg(args.owner) if args.owner is not None else None
1140
+ parsed_assignee = identity._parse_actor_arg(args.assignee) if args.assignee is not None else None
1729
1141
  if parsed_owner is not None and not parsed_owner["name"].strip():
1730
1142
  _die("owner_name_blank")
1731
1143
  if parsed_assignee is not None and not parsed_assignee["name"].strip():
1732
1144
  _die("assignee_name_blank")
1733
1145
  if parsed_owner is None and parsed_assignee is None:
1734
- who = _whoami(state)
1146
+ who = identity._whoami(state)
1735
1147
  rec["owner"] = dict(who)
1736
1148
  rec["assignee"] = dict(who)
1737
1149
  else:
@@ -1765,15 +1177,6 @@ def cmd_unassign(args: argparse.Namespace) -> None:
1765
1177
  print(f"unassigned {args.slug} ({', '.join(roles)})")
1766
1178
 
1767
1179
 
1768
- def _has_production_roadmap(state: dict) -> bool:
1769
- """True iff ≥1 milestone in state has stage == "production" (STATUS-AGNOSTIC).
1770
- The single source of the stage-graduation floor (v22 graduate-guide): the guard counts
1771
- that a production-roadmap RECORD exists — it never judges whether those milestones are
1772
- done/good/sufficient (gather-not-judge). An archived-out-of-state roadmap falls to --force."""
1773
- return any(m.get("stage") == "production"
1774
- for m in state.get("milestones", {}).values())
1775
-
1776
-
1777
1180
  def cmd_stage(args: argparse.Namespace) -> None:
1778
1181
  root = _require_root()
1779
1182
  state = load_state(root)
@@ -1846,7 +1249,7 @@ def cmd_status(args: argparse.Namespace) -> None:
1846
1249
  grad_ready, grad_met, grad_total = _graduation_ready(root, state)
1847
1250
  print(json.dumps({
1848
1251
  "project": state.get("project"), "stage": state.get("stage"),
1849
- "actor": _whoami(state),
1252
+ "actor": identity._whoami(state),
1850
1253
  "active_task": _active_task(state),
1851
1254
  "active_milestones": list(state.get("active_milestones") or []),
1852
1255
  "active_tasks": dict(state.get("active_tasks") or {}),
@@ -1871,7 +1274,7 @@ def cmd_status(args: argparse.Namespace) -> None:
1871
1274
  print(f"project autonomy: {_project_autonomy(root)} (default — new tasks inherit)")
1872
1275
  # git-native actor (user-identity): who ADD sees you as this session — the identity every
1873
1276
  # human-owned stamp records. Always present (the resolver is TOTAL). Read-only, no write.
1874
- _who = _whoami(state)
1277
+ _who = identity._whoami(state)
1875
1278
  _who_email = f" <{_who['email']}>" if _who.get("email") else ""
1876
1279
  print(f"actor : {_who['name']}{_who_email} (source: {_who['source']})")
1877
1280
  print(f"stage : {state.get('stage', '(unknown)')}")
@@ -1938,6 +1341,12 @@ def cmd_status(args: argparse.Namespace) -> None:
1938
1341
  _rel = _releasable(root, state)
1939
1342
  if _rel:
1940
1343
  print(f" → {RELEASABLE_CUE.format(n=len(_rel))}")
1344
+ # loose-task release cue (loose-task-release): a SEPARATE additive line — done milestone-free
1345
+ # tasks not yet attributed to a RELEASES.md `loose tasks:` row. Peer to the milestone cue (its
1346
+ # constant is untouched); fires even with zero releasable milestones. Fail-open ledger read.
1347
+ _loose = _releasable_loose_tasks(root, state)
1348
+ if _loose:
1349
+ print(f" → releasable: {len(_loose)} loose task(s) since last release")
1941
1350
 
1942
1351
  # fast-lane marker (fast-new-task-flag): tag an ACTIVE fast task so the lane is visible at a
1943
1352
  # glance. Presentation-only, existence-gated — a plain/absent active task is byte-unchanged.
@@ -1966,6 +1375,14 @@ def cmd_status(args: argparse.Namespace) -> None:
1966
1375
  _so = _fmt_actor(_owner_rec) if _owner_rec.get("name") else ""
1967
1376
  _own_frag = f" · owner: {_so}" if _so else ""
1968
1377
  print(f" {_mk} {_m:<20} task={_tk or '(none)'} phase={_ph}{_tag}{_own_frag}")
1378
+ # queued backlog (queue-resume-surface): surface milestones awaiting promotion so a
1379
+ # multi-milestone session resumes cleanly — `active` is what you're on, `queued` is what's
1380
+ # next. ADDITIVE + present-only: silent when zero queued (byte-identical), exactly like the
1381
+ # release/loose/streams cues; reads state, writes nothing, changes no command decision.
1382
+ _queued = [ms for ms, m in milestones.items() if m.get("status") == "queued"]
1383
+ if _queued:
1384
+ print(f"queued : {len(_queued)} milestone(s) next — {', '.join(_queued)}")
1385
+ print(f" promote next: add.py activate {_queued[0]}")
1969
1386
  # surface the active task's autonomy level (task explicit-autonomy-dial) so the human
1970
1387
  # reads the throttle every session; "unset" when no explicit `autonomy:` line is present.
1971
1388
  if active and active in tasks:
@@ -2624,6 +2041,7 @@ def cmd_check(args: argparse.Namespace) -> None:
2624
2041
  milestones = state.get("milestones") if isinstance(state.get("milestones"), dict) else {}
2625
2042
  archived_slugs = _archived_task_slugs(state) # archived deps still resolve
2626
2043
  warnings: list[tuple[str, str]] = [] # (name, reason) — nudges that NEVER feed `failed`
2044
+ infos: list[tuple[str, str]] = [] # (name, reason) — affirmations; NEVER feed `warned`/`failed`
2627
2045
  for slug, t in tasks.items():
2628
2046
  task_md = root / "tasks" / slug / "TASK.md"
2629
2047
  checks.append((task_md.exists(), f"task '{slug}' has TASK.md", "file missing"))
@@ -2635,6 +2053,10 @@ def cmd_check(args: argparse.Namespace) -> None:
2635
2053
  if ms is not None:
2636
2054
  checks.append((ms in milestones, f"task '{slug}' milestone resolves",
2637
2055
  f"unknown milestone {ms!r}"))
2056
+ elif t.get("fast"):
2057
+ # blessed milestone-free fast lane (standalone-fast-task): a --fast task with no
2058
+ # milestone is DELIBERATE — a soft INFO affirmation, never a WARN/orphan nudge.
2059
+ infos.append((f"task '{slug}'", "— standalone fast lane (milestone-free by design)"))
2638
2060
  else:
2639
2061
  # warn-never-block: a task outside a milestone is a structural nudge back toward
2640
2062
  # the intake flow — NOT a failure. Names structure, never the act of intake.
@@ -2824,10 +2246,15 @@ def cmd_check(args: argparse.Namespace) -> None:
2824
2246
  passed = sum(1 for ok, _, _ in checks if ok)
2825
2247
  failed = len(checks) - passed
2826
2248
  if as_json:
2249
+ # `infos`/`informed` are ADDITIVE (standalone-fast-task) — affirmations that never feed
2250
+ # `warned`/`failed`; existing keys are untouched so prior consumers keep working.
2827
2251
  print(json.dumps({"passed": passed, "failed": failed,
2828
2252
  "warned": len(warnings),
2829
2253
  "warnings": [{"name": name, "reason": reason}
2830
2254
  for name, reason in warnings],
2255
+ "informed": len(infos),
2256
+ "infos": [{"name": name, "reason": reason}
2257
+ for name, reason in infos],
2831
2258
  "checks": [{"ok": ok, "name": desc,
2832
2259
  "reason": reason if not ok else ""}
2833
2260
  for ok, desc, reason in checks]}))
@@ -2836,6 +2263,8 @@ def cmd_check(args: argparse.Namespace) -> None:
2836
2263
  print(f"PASS {desc}" if ok else f"FAIL {desc}: {reason}")
2837
2264
  for name, reason in warnings:
2838
2265
  print(f"WARN {name} {reason}")
2266
+ for name, reason in infos:
2267
+ print(f"INFO {name} {reason}")
2839
2268
  summary = f"check: {passed} passed, {failed} failed"
2840
2269
  if warnings:
2841
2270
  summary += f" ({len(warnings)} warnings)" # frozen §3: summary gains "(N warnings)"
@@ -2890,6 +2319,39 @@ def _doctor_findings(root: Path) -> list[str]:
2890
2319
  if m is not None and m not in milestones:
2891
2320
  findings.append(f"task '{slug}' references missing milestone '{m}' — fix: set its "
2892
2321
  "milestone to a real one (or none)")
2322
+ # value-domain checks (doctor-value-checks): gate/phase enum (required) · owner/assignee
2323
+ # shape (optional) · archived consistency. Present-but-invalid + missing-required only;
2324
+ # absent owner/assignee is fine. Pure/total — .get-guarded, isinstance-checked, never raises.
2325
+ for slug, t in tasks.items():
2326
+ if not isinstance(t, dict):
2327
+ continue
2328
+ g = t.get("gate")
2329
+ if g is None:
2330
+ findings.append(f"task '{slug}' is missing its gate — fix: one of {', '.join(GATES)}")
2331
+ elif g not in GATES:
2332
+ findings.append(f"task '{slug}' has invalid gate '{g}' — fix: one of {', '.join(GATES)}")
2333
+ p = t.get("phase")
2334
+ if p is None:
2335
+ findings.append(f"task '{slug}' is missing its phase — fix: one of {', '.join(PHASES)}")
2336
+ elif p not in PHASES:
2337
+ findings.append(f"task '{slug}' has invalid phase '{p}' — fix: one of {', '.join(PHASES)}")
2338
+ for role in ("owner", "assignee"):
2339
+ v = t.get(role)
2340
+ if v is not None and not (isinstance(v, dict) and isinstance(v.get("name"), str) and v.get("name")):
2341
+ findings.append(f"task '{slug}' has a malformed {role} — fix: an actor object "
2342
+ "{name, email, source} or remove it")
2343
+ archived = state.get("archived") if isinstance(state.get("archived"), list) else []
2344
+ for a in archived:
2345
+ if not isinstance(a, dict):
2346
+ continue
2347
+ aslug = a.get("slug")
2348
+ if aslug is not None and aslug in milestones:
2349
+ findings.append(f"archived milestone '{aslug}' is also a live milestone — fix: remove "
2350
+ "the live duplicate or the archived entry")
2351
+ ts = a.get("task_slugs")
2352
+ if isinstance(ts, list) and isinstance(a.get("tasks"), int) and a.get("tasks") != len(ts):
2353
+ findings.append(f"archived milestone '{aslug}' task count {a.get('tasks')} ≠ {len(ts)} "
2354
+ "listed — fix: reconcile its task_slugs")
2893
2355
  return findings
2894
2356
 
2895
2357
 
@@ -2910,25 +2372,29 @@ def cmd_doctor(args: argparse.Namespace) -> None:
2910
2372
 
2911
2373
 
2912
2374
  def cmd_mine(args: argparse.Namespace) -> None:
2913
- """Read-only `add.py mine`: across all active milestones, the not-done tasks owned-by or
2914
- assigned-to the resolved actor (`_whoami`, or `--actor "Name <email>"`). Text or `--json`.
2915
- An empty queue is a plain exit-0 line, not an error. NEVER writes state."""
2375
+ """Read-only `add.py mine`: the not-done tasks owned-by or assigned-to the resolved actor
2376
+ (`_whoami`, or `--actor "Name <email>"`). Default lens is the active SET; `--all` widens it
2377
+ to EVERY milestone plus loose (milestone-less) tasks. Text or `--json`. An empty queue is a
2378
+ plain exit-0 line, not an error. NEVER writes state."""
2916
2379
  root = find_root()
2917
2380
  if root is None:
2918
2381
  _die("no_project")
2919
2382
  state = load_state(root)
2920
- me = _parse_actor_arg(args.actor) if getattr(args, "actor", None) else _whoami(state)
2921
- rows = _my_work(state, me)
2383
+ me = identity._parse_actor_arg(args.actor) if getattr(args, "actor", None) else identity._whoami(state)
2384
+ scope_all = getattr(args, "all", False)
2385
+ rows = _my_work(state, me, scope_all=scope_all)
2922
2386
  if getattr(args, "json", False):
2923
2387
  print(json.dumps({"actor": me, "tasks": rows}))
2924
2388
  return
2389
+ scope = "all" if scope_all else "active"
2925
2390
  who = _fmt_actor(me) or me.get("name", "you")
2926
2391
  if not rows:
2927
- print(f"mine: no open tasks for {who} across active milestones")
2392
+ print(f"mine: no open tasks for {who} across {scope} milestones")
2928
2393
  return
2929
- print(f"mine: {who} — {len(rows)} open task(s) across active milestones:")
2394
+ print(f"mine: {who} — {len(rows)} open task(s) across {scope} milestones:")
2930
2395
  for r in rows:
2931
- print(f" {r['slug']:<24} [{r['milestone']}] phase={r['phase']} ({r['role']})")
2396
+ loc = f"[{r['milestone']}]" if r["milestone"] else "[loose]"
2397
+ print(f" {r['slug']:<24} {loc} phase={r['phase']} ({r['role']})")
2932
2398
 
2933
2399
 
2934
2400
  # ---------------------------------------------------------------------------
@@ -3075,6 +2541,12 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
3075
2541
  slug = args.slug
3076
2542
  if not slug.replace("-", "").replace("_", "").isalnum():
3077
2543
  _die("bad_slug")
2544
+ # Prefer a short DESCRIPTIVE slug over a bare version (v2, v1-1, 1.2): a descriptive
2545
+ # name keeps the milestones list legible. Advisory only — never blocks (matches the
2546
+ # engine's `note:` convention); a deliberate version slug still creates.
2547
+ if re.match(r"^v?\d+([._-]\d+)*$", slug, re.IGNORECASE):
2548
+ print(f"note: slug '{slug}' looks like a bare version — prefer a short "
2549
+ f"descriptive name (e.g. 'payment-retries'). Creating anyway.")
3078
2550
  state.setdefault("milestones", {})
3079
2551
  mdir = root / "milestones" / slug
3080
2552
  mfile = mdir / MILESTONE_FILE
@@ -3082,17 +2554,24 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
3082
2554
  _die("milestone_exists")
3083
2555
  mdir.mkdir(parents=True, exist_ok=True)
3084
2556
  title = args.title or slug.replace("-", " ").replace("_", " ").title()
2557
+ # One _now() instant feeds BOTH the MILESTONE.md render and the state record, so the
2558
+ # human-facing `created:` is a full ISO timestamp provably equal to state.json.
2559
+ now = _now()
3085
2560
  _atomic_write(mfile, _render_template(
3086
2561
  "MILESTONE.md", title=title, goal=args.goal or "<goal>",
3087
- stage=args.stage, date=date.today().isoformat()))
2562
+ stage=args.stage, date=now))
3088
2563
  # confirm-parent gate (OPT-IN, mirrors `init --await-lock`): `--await-confirm` seeds the
3089
2564
  # milestone UNCONFIRMED so new-task is held until `add.py milestone-confirm`. WITHOUT the flag
3090
2565
  # NO `confirmed` key is written → grandfathered-confirmed → no gate (so the existing engine
3091
2566
  # tests stay byte-green). The guided skill flow passes the flag at the human-review point.
3092
2567
  await_confirm = bool(getattr(args, "await_confirm", False))
2568
+ # --queued (OPT-IN): create the milestone non-active (status=queued) without stealing focus.
2569
+ # The active set is left UNCHANGED so the default path (no flag) stays byte-identical. Promote
2570
+ # later with `activate` (queued→active). Foundation for roadmap intake (1 active + N queued).
2571
+ queued = bool(getattr(args, "queued", False))
3093
2572
  record = {
3094
2573
  "title": title, "goal": args.goal or "", "stage": args.stage,
3095
- "status": "active", "created": _now(), "updated": _now(),
2574
+ "status": "queued" if queued else "active", "created": now, "updated": now,
3096
2575
  }
3097
2576
  if await_confirm:
3098
2577
  # `await_confirm` is the STABLE opt-in marker (set ONLY here, at creation). `confirmed`
@@ -3100,11 +2579,22 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
3100
2579
  # milestone too, so a later build-entry gate must key on `await_confirm`, not `confirmed`.
3101
2580
  record.update(confirmed=False, confirmed_at=None, confirmed_by=None, await_confirm=True)
3102
2581
  state["milestones"][slug] = record
3103
- _set_active_milestone(state, slug)
2582
+ if not queued:
2583
+ # PRESERVE the active SET (new-milestone-add-focus): ADD this milestone + focus it, rather
2584
+ # than REPLACING the set and evicting the others. Single-active is identical ([] -> [slug]);
2585
+ # a user who already had P active now keeps P active alongside the new primary.
2586
+ _activate_milestone(state, slug)
3104
2587
  save_state(root, state)
3105
2588
  print(f"created milestone '{slug}' -> {mfile}")
3106
- print("active milestone set." + ("" if not await_confirm else
3107
- " (unconfirmedshow the MILESTONE.md, then: add.py milestone-confirm " + slug + ")"))
2589
+ if queued:
2590
+ print(f"queued (not active) promote it with: add.py activate {slug}")
2591
+ # surface the recorded confirm gate for a queued+await_confirm milestone (queued-await-confirm-hint):
2592
+ # additive — prints ONLY when await_confirm, so plain `--queued` output stays byte-identical.
2593
+ if await_confirm:
2594
+ print(f" (unconfirmed — after promote: add.py milestone-confirm {slug})")
2595
+ else:
2596
+ print("active milestone set." + ("" if not await_confirm else
2597
+ " (unconfirmed — show the MILESTONE.md, then: add.py milestone-confirm " + slug + ")"))
3108
2598
  print(_next_footer(root, state)) # converges the old "Decompose it into tasks: …" hint
3109
2599
 
3110
2600
 
@@ -3136,7 +2626,7 @@ def cmd_milestone_confirm(args: argparse.Namespace) -> None:
3136
2626
  m["confirmed"] = True
3137
2627
  m["confirmed_at"] = _now()
3138
2628
  m["confirmed_by"] = who
3139
- m["actor"] = _actor_stamp(state) # structured actor alongside the free-text confirmed_by
2629
+ m["actor"] = identity._actor_stamp(state) # structured actor alongside the free-text confirmed_by
3140
2630
  m["updated"] = _now()
3141
2631
  save_state(root, state)
3142
2632
  print(f"confirmed milestone '{slug}' (by {who}) — new-task is now open for it.")
@@ -3193,25 +2683,34 @@ def cmd_ready(args: argparse.Namespace) -> None:
3193
2683
 
3194
2684
 
3195
2685
  def _wave_schedule(state: dict, mslug: str) -> dict:
3196
- """Pure, total: derive the DAG schedule for milestone `mslug` from state — never
3197
- mutates, never raises on dict input. Returns one of:
2686
+ """One-element wrapper: a single milestone's schedule is the merge over just [mslug].
2687
+ Output is byte-identical to the historical per-milestone scheduler (the suite is the oracle)."""
2688
+ return _wave_schedule_merged(state, [mslug])
2689
+
2690
+
2691
+ def _wave_schedule_merged(state: dict, mslugs: list[str]) -> dict:
2692
+ """Pure, total: derive ONE DAG schedule over the UNION of open members across `mslugs`
2693
+ (a single-element list is the historical per-milestone case) — never mutates, never raises
2694
+ on dict input. Returns one of:
3198
2695
  {"cycle": [slug, ...]} — unschedulable cycle
3199
2696
  {"waves", "critical_path", "critical_path_len", "tiers", "blocked"} — a schedule
3200
2697
 
3201
2698
  A dep is SATISFIED (does not block) if it is archived or `_task_done` — the SAME
3202
- predicate cmd_ready uses. A not-done dep that is an OPEN MEMBER of this milestone
3203
- forces a later wave. A not-done dep that is NOT an open member (external/unknown)
3204
- is UNSATISFIABLE here -> the task is `blocked`, never scheduled. Critical path is the
3205
- longest chain (most tasks) through the scheduled sub-DAG; ties break by sorted slug.
3206
- Tier is advisory: `top` on the critical path, `mid` elsewhere (scheduled tasks only)."""
2699
+ predicate cmd_ready uses. A not-done dep that is an OPEN MEMBER of ANY target milestone
2700
+ forces a later wave (so a cross-milestone dep ORDERS, it does not block). A not-done dep
2701
+ that is NOT an open member of any target (external/unknown) is UNSATISFIABLE here -> the
2702
+ task is `blocked`, never scheduled. Critical path is the longest chain (most tasks) through
2703
+ the scheduled sub-DAG; ties break by sorted slug. Tier is advisory: `top` on the critical
2704
+ path, `mid` elsewhere (scheduled tasks only)."""
3207
2705
  tasks = state.get("tasks") or {}
3208
2706
  archived = _archived_task_slugs(state)
2707
+ targetset = set(mslugs)
3209
2708
 
3210
2709
  def _ok(d: str) -> bool: # satisfied externally / already done
3211
2710
  return d in archived or (d in tasks and _task_done(tasks[d]))
3212
2711
 
3213
2712
  open_members = {s: t for s, t in tasks.items()
3214
- if t.get("milestone") == mslug and not _task_done(t)}
2713
+ if t.get("milestone") in targetset and not _task_done(t)}
3215
2714
 
3216
2715
  # partition open members into blocked vs schedulable — to a FIXED POINT, so blocking
3217
2716
  # propagates transitively: a task is blocked if any dep is unsatisfiable here, where
@@ -3312,16 +2811,71 @@ def _wave_block_lines(state: dict, mslug: str, sched: dict) -> list[str]:
3312
2811
  return lines
3313
2812
 
3314
2813
 
2814
+ def _wave_block_lines_merged(state: dict, mslugs: list[str], sched: dict) -> list[str]:
2815
+ """The text lines `waves --merge` renders for ONE unified schedule over the milestone SET:
2816
+ a `merged: …` header naming the set + each scheduled task tagged with its `[milestone]` so
2817
+ cross-milestone tasks are unambiguous. Critical-path / tier-hint / blocked lines mirror
2818
+ `_wave_block_lines`."""
2819
+ n = len(mslugs)
2820
+ lines = [f"merged: {' + '.join(mslugs)} ({n} milestone{'s' if n != 1 else ''})"]
2821
+ if not sched["waves"]:
2822
+ if sched["blocked"]:
2823
+ for s in sched["blocked"]:
2824
+ lines.append(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2825
+ else:
2826
+ lines.append("all tasks done — nothing to schedule")
2827
+ return lines
2828
+ scheduled_set = {x for w in sched["waves"] for x in w}
2829
+ for i, wave in enumerate(sched["waves"], start=1):
2830
+ parts = []
2831
+ for s in wave:
2832
+ label = f"{s} [{state['tasks'][s].get('milestone')}]"
2833
+ md = sorted(d for d in (state["tasks"][s].get("depends_on") or [])
2834
+ if d in scheduled_set)
2835
+ parts.append(f"{label} (deps: {', '.join(md)})" if md else label)
2836
+ lines.append(f"wave {i}: {', '.join(parts)}")
2837
+ crit = sched["critical_path"]
2838
+ lines.append(f"critical path: {' → '.join(crit)} ({sched['critical_path_len']} tasks)")
2839
+ tops = [s for s, tier in sched["tiers"].items() if tier == "top"]
2840
+ mids = [s for s, tier in sched["tiers"].items() if tier == "mid"]
2841
+ lines.append(f"tier hint: top → {', '.join(tops)}; mid → {', '.join(mids) or '(none)'}")
2842
+ for s in sched["blocked"]:
2843
+ lines.append(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2844
+ return lines
2845
+
2846
+
3315
2847
  def cmd_waves(args: argparse.Namespace) -> None:
3316
2848
  """READ-ONLY DAG scheduler: print the topological waves, critical path, advisory tier hint,
3317
- and blocked set. With no --milestone it spans EVERY active milestone (cross-active-waves);
3318
- a single target / --milestone renders byte-identically. Writes nothing; no `next:` footer."""
2849
+ and blocked set. With no --milestone it spans EVERY active milestone (cross-active-waves)
2850
+ as SEPARATE streams; a single target / --milestone renders byte-identically. With --merge it
2851
+ unifies the active SET into ONE schedule so cross-milestone deps order, not block.
2852
+ Writes nothing; no `next:` footer."""
3319
2853
  is_json = getattr(args, "json", False)
3320
2854
  if is_json:
3321
2855
  _, state = _load_state_for_json()
3322
2856
  else:
3323
2857
  state = load_state(_require_root())
3324
2858
  mslug_arg = getattr(args, "milestone", None)
2859
+ if getattr(args, "merge", False):
2860
+ if mslug_arg: # explicit target → a 1-milestone merge (NOT a conflict)
2861
+ if mslug_arg not in (state.get("milestones") or {}):
2862
+ _die(f"unknown_milestone: '{mslug_arg}' is not a milestone in this project")
2863
+ targets = [mslug_arg]
2864
+ else:
2865
+ primary = _active_milestone(state)
2866
+ if not primary:
2867
+ _die("no_active_milestone: no active milestone and no --milestone given")
2868
+ targets = [primary] + [m for m in (state.get("active_milestones") or [])
2869
+ if m != primary]
2870
+ sched = _wave_schedule_merged(state, targets)
2871
+ if "cycle" in sched:
2872
+ _die(f"dependency_cycle: not-done deps form a cycle "
2873
+ f"({' -> '.join(sched['cycle'])}) — no valid schedule")
2874
+ if is_json:
2875
+ print(json.dumps({"merged": targets, **sched}))
2876
+ else:
2877
+ print("\n".join(_wave_block_lines_merged(state, targets, sched)))
2878
+ return
3325
2879
  if mslug_arg:
3326
2880
  targets = [mslug_arg] # explicit single target — unchanged
3327
2881
  else:
@@ -3389,7 +2943,7 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
3389
2943
  # Stamp WHO closed it BEFORE rendering the retro, so the persisted exit report records
3390
2944
  # the closer (identity-in-status: the retro IS the report `report <ms>` re-renders, so both
3391
2945
  # must reflect the same final state). In-memory only here — save_state below commits it.
3392
- state["milestones"][slug]["done_actor"] = _actor_stamp(state)
2946
+ state["milestones"][slug]["done_actor"] = identity._actor_stamp(state)
3393
2947
  # Fail-closed: render+persist the exit report (RETRO.md) BEFORE committing the
3394
2948
  # status flip, so a write failure rolls back naturally (status never commits ->
3395
2949
  # no done-without-retro state). The retro step is read-only on state.json.
@@ -3589,6 +3143,11 @@ def cmd_activate(args: argparse.Namespace) -> None:
3589
3143
  _die("unknown_milestone")
3590
3144
  if state["milestones"][slug].get("status") == "done":
3591
3145
  _die("milestone_done")
3146
+ # PROMOTE a queued milestone: activating it flips queued→active (human-gated promotion —
3147
+ # the chosen verb, reusing `activate` rather than a separate `promote`). An already-active
3148
+ # milestone is just refocused (status unchanged), keeping the default path byte-identical.
3149
+ if state["milestones"][slug].get("status") == "queued":
3150
+ state["milestones"][slug]["status"] = "active"
3592
3151
  _activate_milestone(state, slug)
3593
3152
  save_state(root, state)
3594
3153
  print(f"activated '{slug}' — active: {', '.join(state['active_milestones'])}")
@@ -3692,165 +3251,14 @@ def _sync_task_marker(root: Path, slug: str, phase: str) -> None:
3692
3251
  # state.json; prose (observe delta, deltas) is parsed from each TASK.md and
3693
3252
  # fails CLOSED to `(unknown)` rather than omitting silently.
3694
3253
 
3695
- _DEFAULT_WIDTH = 72 # fixed width for the persisted/canonical render (RETRO.md)
3696
3254
  # Two glyph tiers. Alignment is correct only with ASCII in column-positioned
3697
3255
  # cells (every ASCII char is 1 display cell); Unicode glyphs sit at line-END
3698
3256
  # (the PROGRESS track) or in non-aligned rows, where width can't break columns.
3699
3257
  _UNICODE = {"reached": "●", "current": "◉", "pending": "○", "h": "═", "rule": "─", "bullet": "•"}
3700
3258
  _ASCII = {"reached": "#", "current": ">", "pending": ".", "h": "=", "rule": "-", "bullet": "*"}
3701
3259
  _GATE_SHORT = {"PASS": "PASS", "RISK-ACCEPTED": "RISK", "HARD-STOP": "STOP", "none": "—"}
3702
- _ANSI = {"green": "\x1b[32m", "yellow": "\x1b[33m", "red": "\x1b[31m",
3703
- "dim": "\x1b[2m", "reset": "\x1b[0m"}
3704
-
3705
-
3706
- def _bar(num: int, den: int, cells: int, g: dict) -> str:
3707
- """A progress bar; 0/0 -> all-empty (no divide-by-zero)."""
3708
- filled = 0 if den <= 0 else round(num / den * cells)
3709
- filled = max(0, min(cells, filled))
3710
- return g["reached"] * filled + g["pending"] * (cells - filled)
3711
-
3712
-
3713
- def _phase_track(phase: str, g: dict) -> str:
3714
- """Compact 9-cell pipeline (no labels — a single legend explains it):
3715
- reached · current · pending. A done task -> all reached."""
3716
- try:
3717
- ci = PHASES.index(phase)
3718
- except ValueError:
3719
- ci = 0
3720
- cells = []
3721
- for i in range(len(PHASES)):
3722
- if phase == "done" or i < ci:
3723
- cells.append(g["reached"])
3724
- elif i == ci:
3725
- cells.append(g["current"])
3726
- else:
3727
- cells.append(g["pending"])
3728
- return "".join(cells)
3729
-
3730
-
3731
- def _use_ascii() -> bool:
3732
- """ASCII tier when the terminal can't render Unicode (non-UTF-8 / dumb)."""
3733
- enc = (getattr(sys.stdout, "encoding", "") or "").lower()
3734
- return ("utf" not in enc) or (os.environ.get("TERM") == "dumb")
3735
-
3736
-
3737
- def _color_enabled() -> bool:
3738
- """Color only on an interactive tty, honoring NO_COLOR and TERM."""
3739
- return (sys.stdout.isatty() and not os.environ.get("NO_COLOR")
3740
- and os.environ.get("TERM", "") not in ("dumb", ""))
3741
-
3742
-
3743
- def _term_width() -> int:
3744
- try:
3745
- import shutil
3746
- return min(max(shutil.get_terminal_size().columns, 64), 100)
3747
- except Exception:
3748
- return _DEFAULT_WIDTH
3749
-
3750
-
3751
- def _colorize(s: str) -> str:
3752
- """Apply ANSI to status tokens — redundant to the text, never the sole signal.
3753
- Applied ONLY to tty stdout; the persisted RETRO.md string stays plain."""
3754
- c = _ANSI
3755
- s = re.sub(r"\bDONE\b", c["green"] + "DONE" + c["reset"], s)
3756
- s = re.sub(r"\bBLOCKED\b", c["red"] + "BLOCKED" + c["reset"], s)
3757
- s = re.sub(r"\bPASS\b", c["green"] + "PASS" + c["reset"], s)
3758
- s = re.sub(r"\bRISK\b", c["yellow"] + "RISK" + c["reset"], s)
3759
- s = re.sub(r"\bSTOP\b", c["red"] + "STOP" + c["reset"], s)
3760
- return s
3761
-
3762
-
3763
- def _project_goal(root: Path) -> str:
3764
- """The project GOAL — the value of the first `goal:` line in PROJECT.md, else
3765
- GOAL_UNSET. Read-only and fail-closed: a missing/unreadable foundation or a
3766
- blank value degrades to the sentinel (orientation never raises). Mirrors how
3767
- _milestone_doc reads the milestone goal — the foundation is the single source."""
3768
- f = root / "PROJECT.md"
3769
- try:
3770
- for line in f.read_text(encoding="utf-8").splitlines():
3771
- if line.startswith("goal:"):
3772
- return line.split(":", 1)[1].strip() or GOAL_UNSET
3773
- except OSError:
3774
- pass
3775
- return GOAL_UNSET
3776
-
3777
-
3778
- def _project_autonomy_token(root: Path):
3779
- """The RAW autonomy declaration in PROJECT.md — a recognized rung, None when no
3780
- declaration line is present, or "?" for a real-but-unrecognized token. Uses the
3781
- anchored _autonomy_level (a title/prose substring is never a declaration) with
3782
- HTML comments stripped. Unreadable foundation -> None. Read-only and PURE."""
3783
- try:
3784
- text = (root / "PROJECT.md").read_text(encoding="utf-8")
3785
- except OSError:
3786
- return None
3787
- return _autonomy_level(re.sub(r"<!--.*?-->", "", text, flags=re.S))
3788
-
3789
-
3790
- def _project_autonomy(root: Path) -> str:
3791
- """The autonomy rung a new task INHERITS from the project default. Fail-SAFE:
3792
- no declaration -> "auto" (the method default; v7: absent = auto); an unrecognized
3793
- token -> "conservative" (NEVER silently "auto"); an unreadable foundation -> "auto".
3794
- Read-only and PURE — mirrors _project_goal; the seed source for cmd_new_task."""
3795
- tok = _project_autonomy_token(root)
3796
- return "auto" if tok is None else ("conservative" if tok == "?" else tok)
3797
-
3798
-
3799
- def _milestone_doc(root: Path, mslug: str) -> tuple[str, str]:
3800
- """(title, goal) from MILESTONE.md; ('(unknown)','(unknown)') if the doc is gone."""
3801
- f = root / "milestones" / mslug / MILESTONE_FILE
3802
- if not f.exists():
3803
- return "(unknown)", "(unknown)"
3804
- title, goal = "(unknown)", "(unknown)"
3805
- for line in f.read_text(encoding="utf-8").splitlines():
3806
- if line.startswith("# MILESTONE:"):
3807
- title = line.split(":", 1)[1].strip() or "(unknown)"
3808
- elif line.startswith("goal:"):
3809
- goal = line.split(":", 1)[1].strip() or "(unknown)"
3810
- break
3811
- return title, goal
3812
-
3813
-
3814
- def _exit_criteria(root: Path, mslug: str) -> tuple[int, int]:
3815
- """(met, total) checkbox tally inside MILESTONE.md's 'Exit criteria' section."""
3816
- f = root / "milestones" / mslug / MILESTONE_FILE
3817
- if not f.exists():
3818
- return 0, 0
3819
- m = re.search(r"## Exit criteria.*?(?=\n## |\Z)", f.read_text(encoding="utf-8"), re.S)
3820
- if not m:
3821
- return 0, 0
3822
- sec = m.group(0)
3823
- met = len(re.findall(r"- \[x\]", sec))
3824
- total = met + len(re.findall(r"- \[ \]", sec))
3825
- return met, total
3826
-
3827
-
3828
3260
  # A non-empty `(verify: <citation>)` on an exit-criterion line — at least one non-whitespace
3829
3261
  # char inside, so a bare `(verify:)`/`(verify: )` does NOT count (the mid-text substring trap).
3830
- _VERIFY_CITE_RE = re.compile(r"\(verify:\s*\S.*?\)", re.I)
3831
-
3832
-
3833
- def _exit_criteria_cited(root: Path, mslug: str) -> tuple[int, int]:
3834
- """(cited, total) over MILESTONE.md's 'Exit criteria' section. total = every
3835
- `- [ ]`/`- [x]` criterion line; cited = those carrying a NON-EMPTY
3836
- `(verify: <citation>)`. Read-only and PURE; missing file/section -> (0, 0).
3837
- Mirrors _exit_criteria (the checkbox tally) — an ADDITIVE classification beside
3838
- it; it never touches `milestone_goal_unmet`."""
3839
- f = root / "milestones" / mslug / MILESTONE_FILE
3840
- if not f.exists():
3841
- return 0, 0
3842
- m = re.search(r"## Exit criteria.*?(?=\n## |\Z)", f.read_text(encoding="utf-8"), re.S)
3843
- if not m:
3844
- return 0, 0
3845
- cited = total = 0
3846
- for ln in m.group(0).splitlines():
3847
- if re.match(r"\s*- \[[ x]\]", ln):
3848
- total += 1
3849
- if _VERIFY_CITE_RE.search(ln):
3850
- cited += 1
3851
- return cited, total
3852
-
3853
-
3854
3262
  def _goal_auto_ready(root: Path, mslug: str) -> bool:
3855
3263
  """True iff the milestone goal is AUTO-READY: its Exit criteria has >= 1 criterion
3856
3264
  AND every one cites a verifier (cited == total) — so the engine can self-verify the
@@ -3860,32 +3268,6 @@ def _goal_auto_ready(root: Path, mslug: str) -> bool:
3860
3268
  return total >= 1 and cited == total
3861
3269
 
3862
3270
 
3863
- def _stage_criteria(root: Path) -> tuple[int, int]:
3864
- """(met, total) checkbox tally inside PROJECT.md's 'Stage goal criteria' section — the
3865
- PROJECT.md analog of _exit_criteria (v22): the human's stage-covered affirmation. Read-only
3866
- and fail-closed to (0, 0): a missing file, a missing section, or any read error never raises
3867
- and never fabricates a cue (so an unreadable foundation withholds graduation, design-for-failure)."""
3868
- try:
3869
- text = (root / "PROJECT.md").read_text(encoding="utf-8")
3870
- except OSError:
3871
- return 0, 0
3872
- m = re.search(r"## Stage goal criteria.*?(?=\n## |\Z)", text, re.S)
3873
- if not m:
3874
- return 0, 0
3875
- sec = m.group(0)
3876
- met = len(re.findall(r"- \[x\]", sec))
3877
- total = met + len(re.findall(r"- \[ \]", sec))
3878
- return met, total
3879
-
3880
-
3881
- def _all_milestones_done(state: dict) -> bool:
3882
- """True when the project HAS milestones and EVERY one is status=done (v22). Archived
3883
- milestones are absent from state['milestones'] (removed by the archive lifecycle), so they
3884
- do not count; a project with zero milestones is not 'covered' and returns False."""
3885
- ms = state.get("milestones") or {}
3886
- return bool(ms) and all(m.get("status") == "done" for m in ms.values())
3887
-
3888
-
3889
3271
  def _graduation_ready(root: Path, state: dict) -> tuple[bool, int, int]:
3890
3272
  """(ready, met, total) for the stage-graduation cue (v22): every milestone done AND the
3891
3273
  human's stage-goal-criteria all checked (total>0 and met==total). The SINGLE source the
@@ -3895,93 +3277,6 @@ def _graduation_ready(root: Path, state: dict) -> tuple[bool, int, int]:
3895
3277
  return ready, met, total
3896
3278
 
3897
3279
 
3898
- def _count_test_defs(f: Path) -> int:
3899
- """`def test_` occurrences in one file — the ONE counting regex (primary and
3900
- §4-declared fallback share it by construction). OSError -> 0, fail-closed."""
3901
- try:
3902
- return len(re.findall(r"^\s*def test_", f.read_text(encoding="utf-8"), re.M))
3903
- except OSError:
3904
- return 0
3905
-
3906
-
3907
- def _primary_test_files(root: Path, slug: str) -> list[Path]:
3908
- """The PRIMARY test set — *.py directly in the task's tests/ dir (the stable
3909
- path). A list so the tamper tripwire can hash exactly what the engine counts."""
3910
- d = root / "tasks" / slug / "tests"
3911
- if not d.is_dir():
3912
- return []
3913
- return sorted(d.glob("*.py"))
3914
-
3915
-
3916
- def _tests_count(root: Path, slug: str) -> int:
3917
- return sum(_count_test_defs(f) for f in _primary_test_files(root, slug))
3918
-
3919
-
3920
- def _confined(p: Path, rootp: Path) -> bool:
3921
- """True only if p resolves (symlinks followed) inside rootp; errors -> False.
3922
- The v2 confinement check — no read is attempted on a path that fails it."""
3923
- try:
3924
- return p.resolve().is_relative_to(rootp)
3925
- except OSError:
3926
- return False
3927
-
3928
-
3929
- def _declared_test_files(root: Path, slug: str) -> list[Path]:
3930
- """Resolve the §4 'Tests live in:' declared path(s) to a deduped file list. PURE.
3931
- Tokens are the backticked spans on the FIRST declaring line of the raw §4 body.
3932
- Resolution: './…' -> task dir · contains '/' -> project root (parent of .add) ·
3933
- bare name -> sibling of the previous resolved token (else task dir). A directory
3934
- token yields the *.py files directly inside it; resolved files are deduped.
3935
- v2 confinement: every path must resolve inside the project root — '..' traversal,
3936
- absolute tokens, and symlink escapes are all dropped, fail-closed."""
3937
- body = _raw_phase_bodies(root, slug).get(4, "")
3938
- m = re.search(r"^\s*Tests live in:.*$", body, re.M)
3939
- if not m:
3940
- return []
3941
- tdir = root / "tasks" / slug
3942
- rootp = root.parent.resolve()
3943
- files: list[Path] = []
3944
- prev_dir = None
3945
- for tok in re.findall(r"`([^`]+)`", m.group(0)):
3946
- tok = tok.strip()
3947
- if tok.startswith("./"):
3948
- p = tdir / tok[2:]
3949
- elif "/" in tok:
3950
- p = root.parent / tok
3951
- else:
3952
- p = (prev_dir or tdir) / tok
3953
- try:
3954
- if not _confined(p, rootp):
3955
- continue
3956
- if p.is_dir():
3957
- cand, prev_dir = sorted(f for f in p.glob("*.py")
3958
- if _confined(f, rootp)), p
3959
- elif p.is_file() and p.suffix == ".py":
3960
- cand, prev_dir = [p], p.parent
3961
- else:
3962
- continue
3963
- except OSError:
3964
- continue
3965
- files.extend(f for f in cand if f not in files)
3966
- return files
3967
-
3968
-
3969
- def _declared_tests_count(root: Path, slug: str) -> int:
3970
- """Count tests at the §4 'Tests live in:' declared path(s). PURE, fail-closed 0."""
3971
- return sum(_count_test_defs(f) for f in _declared_test_files(root, slug))
3972
-
3973
-
3974
- def _tests_info(root: Path, slug: str) -> tuple[int, bool]:
3975
- """(count, declared). The tests/ dir count ALWAYS wins when > 0; otherwise the
3976
- §4-declared fallback — flagged True only when it supplied a non-zero count, so
3977
- a true zero stays a bare, honest 0."""
3978
- primary = _tests_count(root, slug)
3979
- if primary > 0:
3980
- return primary, False
3981
- declared = _declared_tests_count(root, slug)
3982
- return (declared, True) if declared > 0 else (0, False)
3983
-
3984
-
3985
3280
  def _resolved_test_files(root: Path, slug: str) -> list[Path]:
3986
3281
  """The file set the engine treats as this task's tests — the PRIMARY set wins
3987
3282
  when it yields any test defs, else the §4-declared set (mirrors _tests_info's
@@ -3992,19 +3287,6 @@ def _resolved_test_files(root: Path, slug: str) -> list[Path]:
3992
3287
  return _declared_test_files(root, slug)
3993
3288
 
3994
3289
 
3995
- def _md5_text(s: str) -> str:
3996
- return hashlib.md5(s.encode("utf-8")).hexdigest()
3997
-
3998
-
3999
- def _md5_file(p: Path) -> str | None:
4000
- """md5 of a file's bytes; None on ANY read error (fail-closed — a tracked file
4001
- that cannot be read counts as DIVERGED at the gate, never a crash)."""
4002
- try:
4003
- return hashlib.md5(p.read_bytes()).hexdigest()
4004
- except OSError:
4005
- return None
4006
-
4007
-
4008
3290
  def _tripwire_snapshot(root: Path, slug: str, raw3: str) -> dict:
4009
3291
  """Freeze the md5 of the resolved red test files + the frozen §3 contract — the
4010
3292
  tamper baseline (verify-integrity). Keys are project-root-relative paths (stable
@@ -4058,31 +3340,6 @@ _SCOPE_EXCLUDE_SUFFIXES = (".pyc", ".tsbuildinfo")
4058
3340
  # OPT-IN + DEGRADE-SAFE: with no .add/components.toml every reader is byte-identical to
4059
3341
  # pre-component ADD. A read NEVER raises (absent/unreadable/malformed → {} / dropped
4060
3342
  # cover); the loud surface is _component_findings, consumed by the scope gate (cmd_check).
4061
- def _components(root: Path) -> dict[str, dict]:
4062
- """The registry from .add/components.toml → {name: {root, verify, green_bar,
4063
- language}}. `root` required per entry; an entry missing it is skipped (the finding
4064
- surface reports it). `verify` is stored OPAQUE — parsed as data, NEVER executed. PURE."""
4065
- if tomllib is None:
4066
- return {}
4067
- try:
4068
- raw = (root / "components.toml").read_bytes()
4069
- except OSError:
4070
- return {}
4071
- try:
4072
- data = tomllib.loads(raw.decode("utf-8"))
4073
- except (tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
4074
- return {}
4075
- out: dict[str, dict] = {}
4076
- for name, spec in (data.get("component") or {}).items():
4077
- # "?" is the reserved unknown-binding sentinel (_task_component) — a component
4078
- # named "?" would collide and silently drop cover, so it never registers.
4079
- if name == "?" or not isinstance(spec, dict) or not isinstance(spec.get("root"), str):
4080
- continue
4081
- out[name] = {"root": spec["root"], "verify": spec.get("verify"),
4082
- "green_bar": spec.get("green_bar"), "language": spec.get("language")}
4083
- return out
4084
-
4085
-
4086
3343
  def _component_root(root: Path, name: str) -> str | None:
4087
3344
  """Project-root-relative path (trailing '/') of component `name`'s root, or None
4088
3345
  when the name is absent OR the root escapes the project (fail-closed — grants no
@@ -4120,22 +3377,6 @@ def _task_green_bar(root: Path, slug: str) -> str | None:
4120
3377
  return (_components(root).get(comp) or {}).get("green_bar") or None
4121
3378
 
4122
3379
 
4123
- def _cite_region(body: str) -> str:
4124
- """The user-authored "Build expectations" evidence region of a §6 body, stamp-stripped —
4125
- the only place a per-component green-bar cite counts (per-component-verify, v3). PURE.
4126
-
4127
- The marker matches BOTH template shapes: the standard "### Build expectations …" heading AND
4128
- the fast-lane bare "Build expectations (from …):" line, running up to the GATE RECORD sub-block.
4129
- So the top-of-§6 checklist ("- [ ] all tests pass") and the "Outcome: <PASS|…>" placeholder are
4130
- excluded, and a component-bound FAST task is still citable. The trailing strip removes the
4131
- engine's own "component: … · expected green-bar: …" stamp wherever it landed, so a stamp that
4132
- fell inside the region can never self-satisfy the gate. No marker -> "" (fail-closed for a bound
4133
- task: it must declare its evidence)."""
4134
- m = re.search(r"(?im)^#*[ \t]*Build expectations\b.*?(?=\n#+[ \t]*GATE RECORD\b|\Z)", body, re.DOTALL)
4135
- region = m.group(0) if m else ""
4136
- return re.sub(r"(?m)^component:.*·.*expected green-bar:.*$", "", region)
4137
-
4138
-
4139
3380
  def _component_findings(root: Path) -> list[tuple[str, str]]:
4140
3381
  """The loud gate surface for the registry — the codes a degrade-safe read passes
4141
3382
  over silently. Consumed by cmd_check (the scope_violation surface). [] when clean."""
@@ -4178,46 +3419,6 @@ def _component_findings(root: Path) -> list[tuple[str, str]]:
4178
3419
  # ── cross-component contracts (cross-component-contract) ──────────────────────────────────
4179
3420
  # OPT-IN + DEGRADE-SAFE, like the component readers: no [contract.*] / no produces|consumes
4180
3421
  # header ⇒ every path below is byte-identical to pre-contract ADD. A read NEVER raises.
4181
- def _contracts(root: Path) -> dict[str, dict]:
4182
- """[contract.<id>] from .add/components.toml -> {id: {producer: str, consumers: list[str]}}.
4183
- A malformed entry (producer not a str) is skipped (the finding surface reports it). PURE."""
4184
- if tomllib is None:
4185
- return {}
4186
- try:
4187
- data = tomllib.loads((root / "components.toml").read_bytes().decode("utf-8"))
4188
- except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
4189
- return {}
4190
- out: dict[str, dict] = {}
4191
- for cid, spec in (data.get("contract") or {}).items():
4192
- if not isinstance(spec, dict) or not isinstance(spec.get("producer"), str):
4193
- continue
4194
- cons = spec.get("consumers")
4195
- out[cid] = {"producer": spec["producer"],
4196
- "consumers": [c for c in cons if isinstance(c, str)] if isinstance(cons, list) else []}
4197
- return out
4198
-
4199
-
4200
- def _federation(root: Path) -> dict[str, dict]:
4201
- """[federation.<id>] from .add/components.toml -> {id: {source: str, pin: str|None}}.
4202
- The cross-REPO join: a consumer repo names where a producer repo's published snapshot lives.
4203
- A malformed entry (no string source) is skipped; a non-string `pin` degrades to None. Degrade-safe
4204
- — never raises. PURE. On Python < 3.11 (no tomllib) this returns {} like the other component
4205
- readers, so `federate` reports federation_unknown — components.toml needs a 3.11+ runtime."""
4206
- if tomllib is None:
4207
- return {}
4208
- try:
4209
- data = tomllib.loads((root / "components.toml").read_bytes().decode("utf-8"))
4210
- except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
4211
- return {}
4212
- out: dict[str, dict] = {}
4213
- for fid, spec in (data.get("federation") or {}).items():
4214
- if not isinstance(spec, dict) or not isinstance(spec.get("source"), str):
4215
- continue
4216
- pin = spec.get("pin")
4217
- out[fid] = {"source": spec["source"], "pin": pin if isinstance(pin, str) else None}
4218
- return out
4219
-
4220
-
4221
3422
  def _task_produces(root: Path, slug: str) -> str | None:
4222
3423
  m = _PRODUCES_LINE_RE.search(_task_header(root, slug))
4223
3424
  return m.group(1).strip() if m else None
@@ -4228,10 +3429,6 @@ def _task_consumes(root: Path, slug: str) -> str | None:
4228
3429
  return m.group(1).strip() if m else None
4229
3430
 
4230
3431
 
4231
- def _contract_snapshot(root: Path, cid: str) -> Path:
4232
- return root / "contracts" / f"{cid}.json"
4233
-
4234
-
4235
3432
  def _contract_body_hash(raw3: str) -> str:
4236
3433
  """md5 of the §3 contract SHAPE — the first ```fenced``` block, whitespace-normalized. The
4237
3434
  version stamp + freeze flags are excluded (fallback strips Status:/flag/change-request lines)
@@ -4304,18 +3501,6 @@ def _declared_scope(root: Path, slug: str) -> list[str] | None:
4304
3501
  return out
4305
3502
 
4306
3503
 
4307
- def _in_scope(rel: str, declared: list[str]) -> bool:
4308
- """True when rel falls under any declared token — exact match for a file
4309
- token, whole-subtree prefix containment for a directory token ('…/')."""
4310
- for tok in declared:
4311
- if tok.endswith("/"):
4312
- if rel.startswith(tok) or rel == tok.rstrip("/"):
4313
- return True
4314
- elif rel == tok:
4315
- return True
4316
- return False
4317
-
4318
-
4319
3504
  def _scope_walk(rootp: Path) -> dict[str, str]:
4320
3505
  """{project-root-relative path: md5} over the project tree, pruning
4321
3506
  _SCOPE_EXCLUDE_DIRS at any depth and skipping bytecode/OS junk +
@@ -4446,6 +3631,28 @@ def _heal_or_escalate(root: Path, state: dict, slug: str, *, reason: str, source
4446
3631
  raise SystemExit(3) # redo signal (distinct from _die's 1, argparse's 2)
4447
3632
 
4448
3633
 
3634
+ def _consumer_stale_guard(root: Path, state: dict, slug: str) -> None:
3635
+ """Refuse a COMPLETING gate when a `consumes:` task's pinned producer contract hash is STALE
3636
+ (the producer re-froze a CHANGED shape since the pin) — the consumer built against an
3637
+ out-of-date contract (consumer-stale-gate, the gate twin of cmd_check's contract_consumer_stale
3638
+ warning). Recoverable, not a cheat: re-pin by re-crossing contract->tests after reviewing the
3639
+ new frozen shape. Degrade-safe — an unreadable/missing live snapshot is NOT decided here (it
3640
+ stays a cmd_check warning + the advance-time contract_snapshot_missing HARD-STOP); only a
3641
+ CONFIRMED hash drift blocks. Placed with the other completing guards, BEFORE the waiver write,
3642
+ so a stale pin is never launderable through RISK-ACCEPTED; HARD-STOP never reaches here."""
3643
+ pin = state["tasks"][slug].get("contract_pin")
3644
+ if not pin:
3645
+ return
3646
+ try:
3647
+ live = json.loads(_contract_snapshot(root, pin["id"]).read_text(encoding="utf-8")).get("hash")
3648
+ except (OSError, ValueError, KeyError, TypeError, AttributeError):
3649
+ return # unreadable -> surfaced by cmd_check, not confirmable as stale here
3650
+ if live is not None and live != pin.get("hash"):
3651
+ _die(f"contract_consumer_stale: task '{slug}' pinned contract '{pin['id']}' changed shape "
3652
+ "since the pin (the producer re-froze) — re-pin by re-crossing contract->tests after "
3653
+ "reviewing the producer's new frozen shape; never complete against a stale contract")
3654
+
3655
+
4449
3656
  def _tamper_guard(root: Path, state: dict, slug: str) -> None:
4450
3657
  """HARD-STOP a COMPLETING gate when the tripwire shows tampering — the method's
4451
3658
  first mechanical cheat block (verify-integrity). Tri-state, co-witnessed by
@@ -4472,89 +3679,6 @@ def _tamper_guard(root: Path, state: dict, slug: str) -> None:
4472
3679
  reason="tamper_detected:" + ",".join(diffs), source="tamper")
4473
3680
 
4474
3681
 
4475
- def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
4476
- """(observe_delta, [delta lines]) from the task's TASK.md §7 — captured at FULL
4477
- fidelity: both fields wrap across physical lines in real files, so continuation
4478
- lines are JOINED. Scoped to the OBSERVE section so we read the FIELD, not §1 prose
4479
- that names it. Fail-closed to '(unknown)' on a missing file / `<...>` placeholder."""
4480
- f = root / "tasks" / slug / "TASK.md"
4481
- if not f.exists():
4482
- return "(unknown)", []
4483
- text = f.read_text(encoding="utf-8")
4484
- m7 = re.search(r"##\s*7\s*·\s*OBSERVE.*\Z", text, re.S)
4485
- section = m7.group(0) if m7 else text
4486
- lines = section.splitlines()
4487
- # observe: prefer the first OPEN SPEC delta from the "### Spec delta" block; fall
4488
- # back to the legacy "Spec delta for the next loop:" free-text field (archived
4489
- # tasks predate the block); else "(unknown)".
4490
- observe = "(unknown)"
4491
- for unit in _spec_delta_entries(section):
4492
- m = _SPEC_DELTA_RE.match(unit[0])
4493
- if m.group(2) != "open":
4494
- continue
4495
- tail = " ".join([m.group(3).strip(), *unit[1:]]).strip()
4496
- em = _EVIDENCE_RE.match(tail)
4497
- first = (em.group(1).strip() if em else tail)
4498
- if first and not first.startswith("<"):
4499
- observe = first
4500
- break
4501
- if observe == "(unknown)":
4502
- for i, ln in enumerate(lines):
4503
- m = re.match(r"\s*Spec delta for the next loop:\s*(.*)", ln)
4504
- if not m:
4505
- continue
4506
- parts = [m.group(1).strip()]
4507
- for nxt in lines[i + 1:]:
4508
- t = nxt.strip()
4509
- if not t or t.startswith("#") or t.startswith("- ") or t.startswith("Watch"):
4510
- break
4511
- parts.append(t)
4512
- joined = " ".join(p for p in parts if p).strip()
4513
- if joined and not joined.startswith("<"):
4514
- observe = joined
4515
- break
4516
-
4517
- # deltas: each "- [COMP · status] ..." plus its indented continuation lines
4518
- deltas, i = [], 0
4519
- while i < len(lines):
4520
- m = _DELTA_RE.match(lines[i])
4521
- if not m:
4522
- i += 1
4523
- continue
4524
- parts, j = [m.group(3).strip()], i + 1
4525
- while j < len(lines):
4526
- t = lines[j].strip()
4527
- if not t or t.startswith("#") or _DELTA_RE.match(lines[j]):
4528
- break
4529
- parts.append(t)
4530
- j += 1
4531
- deltas.append(f"{m.group(1)} · {m.group(2)} · {' '.join(parts).strip()}")
4532
- i = j
4533
- return observe, deltas
4534
-
4535
-
4536
- def _clip(s: str, maxlen: int) -> str:
4537
- """Trim a string to fit a fixed-width frame, ellipsizing if it overruns."""
4538
- return s if len(s) <= maxlen else s[:maxlen - 1].rstrip() + "…"
4539
-
4540
-
4541
- def _wrap(text: str, width: int, label: str) -> list[str]:
4542
- """Wrap `text` to `width`; the first line carries `label`, continuations are
4543
- blank-indented to the same width (so a multi-line goal shows 'goal' once)."""
4544
- cont = " " * len(label)
4545
- lines, cur = [], ""
4546
- for w in text.split():
4547
- if cur and len(cur) + 1 + len(w) > width:
4548
- lines.append(cur)
4549
- cur = w
4550
- else:
4551
- cur = f"{cur} {w}".strip()
4552
- if cur:
4553
- lines.append(cur)
4554
- lines = lines or ["(unknown)"]
4555
- return [(label if i == 0 else cont) + ln for i, ln in enumerate(lines)]
4556
-
4557
-
4558
3682
  def report_data(root: Path, state: dict, mslug: str) -> dict:
4559
3683
  """The single source of FACTS for a milestone report — pure, NO writes.
4560
3684
  Both the text dashboard (render_report) and `report --json` render from this,
@@ -4636,43 +3760,6 @@ def _clean_phase_body(body: str) -> str:
4636
3760
  return "\n".join(lines) if meaningful else "(empty)"
4637
3761
 
4638
3762
 
4639
- def _phase_spans(text: str) -> dict[int, str]:
4640
- """Split a TASK.md into RAW §1–§7 bodies keyed by section number — the ONE
4641
- canonical heading scan (`^##\\s*<n>\\s*·`, case/locale-proof); a body runs from
4642
- its heading to the next `## `/`---`/EOF. RAW = byte-faithful lines, no cleaning:
4643
- the decision-marker extractor (decide-digest) depends on byte-verbatim text.
4644
- KNOWN LIMIT: a §body containing a line-start `## ` or bare `---` truncates early —
4645
- today's TASK.md bodies don't (box-chars ─═, `### ` sub-heads)."""
4646
- lines = text.splitlines()
4647
- head = re.compile(r"^##\s*(\d+)\s*·")
4648
- starts: dict[int, int] = {}
4649
- for idx, ln in enumerate(lines):
4650
- m = head.match(ln)
4651
- if m:
4652
- n = int(m.group(1))
4653
- if 0 <= n <= 7 and n not in starts:
4654
- starts[n] = idx
4655
- out: dict[int, str] = {}
4656
- for n, idx in starts.items():
4657
- body_lines = []
4658
- for ln in lines[idx + 1:]:
4659
- if re.match(r"^##\s", ln) or re.match(r"^---\s*$", ln):
4660
- break
4661
- body_lines.append(ln)
4662
- out[n] = "\n".join(body_lines)
4663
- return out
4664
-
4665
-
4666
- def _raw_phase_bodies(root: Path, slug: str) -> dict[int, str]:
4667
- """RAW §bodies for one task (byte-faithful, for marker extraction). PURE.
4668
- Missing/unreadable TASK.md -> {} (fail-closed, like task_phases)."""
4669
- f = root / "tasks" / slug / "TASK.md"
4670
- try:
4671
- return _phase_spans(f.read_text(encoding="utf-8"))
4672
- except OSError:
4673
- return {}
4674
-
4675
-
4676
3763
  def task_phases(root: Path, slug: str) -> list[dict]:
4677
3764
  """The frozen per-task PHASE-DETAIL shape (v9-1): parse TASK.md §0–§7 into eight
4678
3765
  blocks ground→observe. PURE — NO writes. Each entry is
@@ -5259,10 +4346,6 @@ _DELTA_STATUSES = ("open", "folded", "rejected")
5259
4346
  # un-stripped lines directly; callers that pre-strip their input
5260
4347
  # (e.g. _collect_open_deltas, _lint_task_deltas) match the same way (\s*
5261
4348
  # matches zero). Anchored at line-start via re.match.
5262
- _DELTA_RE = re.compile(
5263
- r"\s*-\s*\[\s*(DDD|SDD|UDD|TDD|ADD)\s*·\s*(open|folded|rejected)\s*\]\s*(.+)$"
5264
- )
5265
- _EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
5266
4349
 
5267
4350
  # SPEC-delta track — a SEPARATE resolution lifecycle from the competency deltas
5268
4351
  # above. SPEC shares the "- [TAG · status]" LINE shape but its statuses are
@@ -5271,9 +4354,6 @@ _EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
5271
4354
  # tag to its legal status set so the ONE lint can reject a cross-set pairing
5272
4355
  # ([SPEC · folded], [SDD · seeded]) without a parallel grammar.
5273
4356
  _SPEC_STATUSES = ("open", "seeded", "dropped")
5274
- _SPEC_DELTA_RE = re.compile(
5275
- r"\s*-\s*\[\s*(SPEC)\s*·\s*(open|seeded|dropped)\s*\]\s*(.+)$"
5276
- )
5277
4357
  _STATUS_SETS = {**{c: _DELTA_STATUSES for c in _COMPETENCY_ORDER}, "SPEC": _SPEC_STATUSES}
5278
4358
 
5279
4359
  # Broad structural tag detector: finds ANY "- [tok · tok]" line (valid OR malformed).
@@ -5429,32 +4509,6 @@ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
5429
4509
  return by_comp
5430
4510
 
5431
4511
 
5432
- def _spec_delta_entries(text: str) -> list[list[str]]:
5433
- """Group a "### Spec delta" block into entries (tag line + continuation lines).
5434
-
5435
- Same grouping discipline as _collect_open_deltas' competency pass, keyed on
5436
- _SPEC_DELTA_RE: a tag line starts an entry; a non-"- " line continues it; a
5437
- blank/comment or a new "- " item ends it. Returns [] when the block is absent."""
5438
- bm = re.search(r"###\s*Spec delta\s*\n(.*?)(?=\n##|\Z)", text, re.S)
5439
- if not bm:
5440
- return []
5441
- entries: list[list[str]] = []
5442
- current: list[str] | None = None
5443
- for line in bm.group(1).splitlines():
5444
- stripped = line.strip()
5445
- if not stripped or stripped.startswith("<!--"):
5446
- current = None
5447
- continue
5448
- if _SPEC_DELTA_RE.match(stripped):
5449
- current = [stripped]
5450
- entries.append(current)
5451
- elif current is not None and not stripped.startswith("-"):
5452
- current.append(stripped) # genuine wrap of the current entry
5453
- else:
5454
- current = None # a new / malformed list item ends the run
5455
- return entries
5456
-
5457
-
5458
4512
  def _collect_open_spec_deltas(root: Path) -> list[dict]:
5459
4513
  """Scan every .add/tasks/*/TASK.md "### Spec delta" block for OPEN SPEC deltas.
5460
4514
 
@@ -5782,6 +4836,15 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
5782
4836
  elif gate != "none" and outcomes[0] != gate:
5783
4837
  f(slug, "gate_record_mismatch",
5784
4838
  f"§6 records {outcomes[0]} but state.json records {gate}")
4839
+ elif gate == "none":
4840
+ # F13 ungated_verdict: §6 carries a verdict the engine never gated.
4841
+ # cmd_gate is the ONLY writer of `gate` (it also marks done), so a
4842
+ # done/observe task at gate=="none" reached its verdict without the
4843
+ # engine — a hand-stamped §6 or an `advance` past verify. Constraint 4
4844
+ # requires a RECORDED outcome; a §6 verdict without one is not trusted.
4845
+ f(slug, "ungated_verdict",
4846
+ f"§6 records {outcomes[0]} but state.json recorded no gate (gate=none) — "
4847
+ f"the verdict was written without the engine gate")
5785
4848
  sec = _AUDIT_SECURITY_RE.search(s6)
5786
4849
  marked = bool(sec and ("NOTE" in sec.group(0) or "⚠" in sec.group(0)))
5787
4850
  rev = _AUDIT_REVIEWED_RE.search(s6)
@@ -5967,12 +5030,6 @@ def cmd_graduation_report(args: argparse.Namespace) -> None:
5967
5030
  print("\n".join(L))
5968
5031
 
5969
5032
 
5970
- def _releases_path(root: Path) -> Path:
5971
- """The append-only release ledger — at the PROJECT ROOT (root IS the .add dir, so its
5972
- parent), a sibling of CHANGELOG.md. NOT inside .add/."""
5973
- return root.parent / RELEASES_FILE
5974
-
5975
-
5976
5033
  def _released_milestones(root: Path) -> set[str]:
5977
5034
  """Slugs already attributed to a release — the union of every `milestones:` row in
5978
5035
  RELEASES.md. Fail-OPEN: a missing/unreadable/malformed ledger yields the empty set, so
@@ -5993,21 +5050,6 @@ def _released_milestones(root: Path) -> set[str]:
5993
5050
  return out
5994
5051
 
5995
5052
 
5996
- def _closed_milestones(state: dict) -> list[dict]:
5997
- """Every CLOSED milestone (its milestone-done gate passed): LIVE done milestones
5998
- (status == 'done', still in state) + ARCHIVED milestones (all were PASS-done before
5999
- archive — see _archived_task_slugs). Each: {slug, title, tier}."""
6000
- out: list[dict] = []
6001
- for slug, m in (state.get("milestones") or {}).items():
6002
- if m.get("status") == "done":
6003
- out.append({"slug": slug, "title": m.get("title", slug), "tier": "live"})
6004
- for rec in state.get("archived") or []:
6005
- if rec.get("slug"):
6006
- out.append({"slug": rec["slug"], "title": rec.get("title", rec["slug"]),
6007
- "tier": "archived"})
6008
- return out
6009
-
6010
-
6011
5053
  def _releasable(root: Path, state: dict) -> list[dict]:
6012
5054
  """Closed milestones NOT yet attributed to any RELEASES.md row — the cut's candidate
6013
5055
  bundle. Drives BOTH the `→ releasable: N` status cue and release-report. READ-ONLY."""
@@ -6015,19 +5057,38 @@ def _releasable(root: Path, state: dict) -> list[dict]:
6015
5057
  return [m for m in _closed_milestones(state) if m["slug"] not in released]
6016
5058
 
6017
5059
 
6018
- def _key_decisions_for(root: Path, slug: str) -> list[str]:
6019
- """Best-effort §Key-Decisions rows from PROJECT.md that NAME this milestone slug — the
6020
- consolidated decisions the changelog can cite. Fail-open: a missing section / unreadable
6021
- foundation / no slug match -> [] (a gather never raises). READ-ONLY."""
5060
+ def _released_loose_tasks(root: Path) -> set[str]:
5061
+ """Slugs already attributed to a release as LOOSE tasks — the union of every
5062
+ `loose tasks:` row in RELEASES.md. The exact parallel of _released_milestones:
5063
+ fail-OPEN (a missing/unreadable/malformed ledger yields the empty set, so every
5064
+ done loose task reads as still-releasable). READ-ONLY."""
6022
5065
  try:
6023
- text = (root / "PROJECT.md").read_text(encoding="utf-8")
5066
+ text = _releases_path(root).read_text(encoding="utf-8")
6024
5067
  except OSError:
6025
- return []
6026
- m = re.search(r"^#{1,6}[^\n]*key decision[^\n]*$(.*?)(?=^#{1,6}\s|\Z)", text, re.S | re.M | re.I)
6027
- if not m:
6028
- return []
6029
- return [st.lstrip("-* ").strip() for st in (ln.strip() for ln in m.group(1).splitlines())
6030
- if st.startswith(("-", "*")) and slug in st]
5068
+ return set() # no ledger -> nothing released yet
5069
+ out: set[str] = set()
5070
+ for line in text.splitlines():
5071
+ st = line.strip()
5072
+ if st.lower().startswith("loose tasks:"):
5073
+ for tok in re.split(r"[,\s]+", st.split(":", 1)[1]):
5074
+ tok = tok.strip()
5075
+ if tok and tok.lower() != "none":
5076
+ out.add(tok)
5077
+ return out
5078
+
5079
+
5080
+ def _releasable_loose_tasks(root: Path, state: dict) -> list[dict]:
5081
+ """Done milestone-free tasks NOT yet attributed to any RELEASES.md `loose tasks:` row —
5082
+ the loose half of the cut's candidate bundle, peer to _releasable. A loose task is a
5083
+ first-class releasable item: milestone-free (a standalone, fast OR full) AND done (a
5084
+ completing gate). Drives the loose status cue + release_data["loose"]. READ-ONLY."""
5085
+ released = _released_loose_tasks(root)
5086
+ out: list[dict] = []
5087
+ for slug, t in (state.get("tasks") or {}).items():
5088
+ if (isinstance(t, dict) and t.get("milestone") is None and _task_done(t)
5089
+ and slug not in released):
5090
+ out.append({"slug": slug, "title": t.get("title", slug)})
5091
+ return out
6031
5092
 
6032
5093
 
6033
5094
  def release_data(root: Path, state: dict) -> dict:
@@ -6092,15 +5153,19 @@ def release_data(root: Path, state: dict) -> dict:
6092
5153
  monitors.append({"slug": slug, "watch": st})
6093
5154
  break
6094
5155
 
5156
+ # loose — done milestone-free tasks not yet attributed (the cut's loose bundle, peer to releasable)
5157
+ loose = _releasable_loose_tasks(root, state)
5158
+
6095
5159
  return {
6096
5160
  "releasable": releasable,
6097
5161
  "changed": changed,
6098
5162
  "waivers": waivers,
6099
5163
  "blockers": blockers,
6100
5164
  "monitors": monitors,
5165
+ "loose": loose,
6101
5166
  "summary": {
6102
5167
  "releasable": len(releasable), "changed": len(changed), "waivers": len(waivers),
6103
- "blockers": len(blockers), "monitors": len(monitors),
5168
+ "blockers": len(blockers), "monitors": len(monitors), "loose": len(loose),
6104
5169
  },
6105
5170
  }
6106
5171
 
@@ -6144,14 +5209,6 @@ def cmd_release_report(args: argparse.Namespace) -> None:
6144
5209
  print("\n".join(L))
6145
5210
 
6146
5211
 
6147
- def _build_in_flight(state: dict) -> bool:
6148
- """release_tests_red proxy (PURE): is any ACTIVE task mid-build without a recorded green gate
6149
- — phase ∈ {build, verify} AND gate == 'none'? The tool-agnostic engine never runs the suite,
6150
- so an entered-but-ungated build is the recorded-evidence stand-in for 'the suite is red'."""
6151
- return any(t.get("phase") in ("build", "verify") and t.get("gate") == "none"
6152
- for t in (state.get("tasks") or {}).values())
6153
-
6154
-
6155
5212
  def _prepend_block(existing: str, header: str, block: str) -> str:
6156
5213
  """Newest-first prepend: insert `block` directly under the top H1 `header`, creating the
6157
5214
  header when `existing` is empty / headerless. Existing content is preserved VERBATIM
@@ -6164,37 +5221,6 @@ def _prepend_block(existing: str, header: str, block: str) -> str:
6164
5221
  return f"{block}{existing}" # no recognized header -> block goes on top, verbatim tail
6165
5222
 
6166
5223
 
6167
- def _render_changelog_block(version: str, day: str, bundle: list[dict],
6168
- changed_by_slug: dict) -> str:
6169
- """A CHANGELOG block: `## <version> — <date>` + one bullet per bundled milestone (title +
6170
- carried-delta / key-decision counts from release_data['changed'])."""
6171
- lines = [f"## {version} — {day}", ""]
6172
- if bundle:
6173
- for m in bundle:
6174
- c = changed_by_slug.get(m["slug"], {})
6175
- lines.append(f"- {m['title']} — {c.get('carried_deltas', 0)} carried · "
6176
- f"{len(c.get('key_decisions', []))} key decision(s)")
6177
- else:
6178
- lines.append("- (no milestone bundled)")
6179
- return "\n".join(lines) + "\n\n"
6180
-
6181
-
6182
- def _render_releases_row(version: str, day: str, bundle: list[dict],
6183
- waiver_slugs: list[str], evidence: str | None,
6184
- actor: str | None = None) -> str:
6185
- """One append-only RELEASES.md row — the attribution source (`milestones:` membership).
6186
- The `actor:` line records WHO cut the release (structured-actor stamping); absent on a
6187
- legacy row (back-compat) when no actor is supplied."""
6188
- ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
6189
- wv = ", ".join(waiver_slugs) if waiver_slugs else "none"
6190
- actor_line = f"actor: {actor}\n" if actor else ""
6191
- return (f"## {version} — {day}\n"
6192
- f"milestones: {ms}\n"
6193
- f"waivers: {wv}\n"
6194
- f"{actor_line}"
6195
- f"evidence: {evidence or 'recorded by add.py release'}\n\n")
6196
-
6197
-
6198
5224
  def cmd_release(args: argparse.Namespace) -> None:
6199
5225
  """GUARDED, record-only: cut a version. Enforce the 4-code readiness floor, then RECORD by
6200
5226
  prepending CHANGELOG.md + an append-only RELEASES.md row (whose `milestones:` line attributes
@@ -6218,9 +5244,11 @@ def cmd_release(args: argparse.Namespace) -> None:
6218
5244
  _die("release_tests_red: a build is in flight without a recorded green gate — finish and "
6219
5245
  "gate it first, or pass --force to override.")
6220
5246
  bundle = _releasable(root, state)
6221
- if not forced and not bundle:
5247
+ loose_bundle = _releasable_loose_tasks(root, state)
5248
+ if not forced and not bundle and not loose_bundle:
6222
5249
  _die("release_no_closed_milestone: nothing closed-and-unreleased to bundle — the cut "
6223
- "would be a no-op. Close a milestone first, or pass --force to override.")
5250
+ "would be a no-op. Close a milestone (or a standalone task) first, or pass --force "
5251
+ "to override.")
6224
5252
  if not forced and d["waivers"] and not disclosed:
6225
5253
  _die("release_undisclosed_waiver: a RISK-ACCEPTED waiver rides into this release — pass "
6226
5254
  "--with-waivers to disclose it in the notes, or --force to override.")
@@ -6238,7 +5266,7 @@ def cmd_release(args: argparse.Namespace) -> None:
6238
5266
  new_rel = _prepend_block(rel_before, "# Releases",
6239
5267
  _render_releases_row(args.version, day, bundle, waiver_slugs,
6240
5268
  getattr(args, "evidence", None),
6241
- _render_actor_line(state)))
5269
+ identity._render_actor_line(state), loose_bundle))
6242
5270
  try: # CHANGELOG + RELEASES as one all-or-nothing commit
6243
5271
  _atomic_write_many([(changelog_path, new_cl), (releases_path, new_rel)])
6244
5272
  except OSError as e:
@@ -6247,7 +5275,9 @@ def cmd_release(args: argparse.Namespace) -> None:
6247
5275
 
6248
5276
  # NO save_state — attribution lives in RELEASES.md (the cue re-reads it), never state.json
6249
5277
  ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
6250
- print(f"released {args.version} recorded {len(bundle)} milestone(s): {ms}")
5278
+ lt = ", ".join(t["slug"] for t in loose_bundle) if loose_bundle else "none"
5279
+ print(f"released {args.version} — recorded {len(bundle)} milestone(s): {ms} "
5280
+ f"+ {len(loose_bundle)} loose task(s): {lt}")
6251
5281
  print(" CHANGELOG.md + RELEASES.md updated (project root). The engine records; "
6252
5282
  "you run the tag / publish / deploy.")
6253
5283
  if forced:
@@ -6422,6 +5452,14 @@ def build_parser() -> argparse.ArgumentParser:
6422
5452
  pl.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
6423
5453
  pl.set_defaults(func=cmd_lock)
6424
5454
 
5455
+ pfz = sub.add_parser("freeze",
5456
+ help="freeze a task's §3 contract (the human approval) — stamps "
5457
+ "FROZEN @ vN + a structured actor on the task record")
5458
+ pfz.add_argument("slug", nargs="?", default=None,
5459
+ help="task to freeze (default: the active task)")
5460
+ pfz.add_argument("--by", default=None, help="approver name (default: the resolved actor)")
5461
+ pfz.set_defaults(func=cmd_freeze)
5462
+
6425
5463
  pwho = sub.add_parser("whoami",
6426
5464
  help="show / set the git-native actor (git config -> OS user; --name to override)")
6427
5465
  # --name (set) and --unset (clear) are mutually exclusive — argparse rejects the
@@ -6481,6 +5519,10 @@ def build_parser() -> argparse.ArgumentParser:
6481
5519
  pm.add_argument("--goal", default=None, help="one-sentence outcome")
6482
5520
  pm.add_argument("--stage", default="mvp", choices=STAGES)
6483
5521
  pm.add_argument("--force", action="store_true", help="overwrite MILESTONE.md if present")
5522
+ pm.add_argument("--queued", action="store_true",
5523
+ help="create the milestone QUEUED (status=queued), not active: it is recorded "
5524
+ "and its MILESTONE.md written, but the active focus is unchanged. Promote it "
5525
+ "later with `activate <slug>`. Foundation for roadmap intake (1 active + N queued).")
6484
5526
  pm.add_argument("--await-confirm", action="store_true",
6485
5527
  help="opt into the confirm-parent gate: seed the milestone unconfirmed so "
6486
5528
  "new-task is held until `milestone-confirm` (mirrors `init --await-lock`); "
@@ -6501,6 +5543,8 @@ def build_parser() -> argparse.ArgumentParser:
6501
5543
  "waves + critical path + advisory tier hint")
6502
5544
  pwa.add_argument("--milestone", default=None,
6503
5545
  help="milestone slug to schedule (default: the active milestone)")
5546
+ pwa.add_argument("--merge", action="store_true",
5547
+ help="unify the active SET into ONE schedule (cross-milestone deps order, not block)")
6504
5548
  pwa.add_argument("--json", action="store_true", help="machine-readable JSON output")
6505
5549
  pwa.set_defaults(func=cmd_waves)
6506
5550
 
@@ -6568,6 +5612,13 @@ def build_parser() -> argparse.ArgumentParser:
6568
5612
  help="confirm a RAISE toward auto (a human-owned trust escalation)")
6569
5613
  pan.set_defaults(func=cmd_autonomy, _opt_positionals=("a1", "a2"))
6570
5614
 
5615
+ pto = sub.add_parser("todo", help="capture / list / close a lightweight backlog todo (jot an idea)")
5616
+ pto.add_argument("text", nargs="?", default=None,
5617
+ help="todo text to capture; omit to LIST open todos")
5618
+ pto.add_argument("--done", type=int, default=None, metavar="ID",
5619
+ help="close an open todo by id")
5620
+ pto.set_defaults(func=cmd_todo)
5621
+
6571
5622
  pr = sub.add_parser("reopen", help="return a done task to an earlier phase with a recorded reason")
6572
5623
  pr.add_argument("slug", nargs="?", default=None)
6573
5624
  # --to / --reason are validated in-body (not argparse choices) so the named reject
@@ -6614,6 +5665,8 @@ def build_parser() -> argparse.ArgumentParser:
6614
5665
  pmine.add_argument("--actor", default=None, metavar="\"Name <email>\"",
6615
5666
  help="inspect another actor's queue instead of your own")
6616
5667
  pmine.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
5668
+ pmine.add_argument("--all", action="store_true",
5669
+ help="widen past the active SET: every milestone + loose tasks, not just active")
6617
5670
  pmine.set_defaults(func=cmd_mine)
6618
5671
 
6619
5672
  pwv = sub.add_parser("wave-verify",
@@ -6739,16 +5792,6 @@ def _rebind_optional_positionals(parser: argparse.ArgumentParser,
6739
5792
  # silently does nothing and nothing is lost. It NEVER changes a command's stdout or exit.
6740
5793
  _UPDATE_CACHE = ".update-cache.json"
6741
5794
  _UPDATE_TTL = timedelta(hours=24) # hit the registry at most once / day
6742
- _REGISTRY_LATEST = "https://registry.npmjs.org/@pilotspace/add/latest"
6743
-
6744
-
6745
- def _read_json_safe(path: Path):
6746
- try:
6747
- return json.loads(path.read_text(encoding="utf-8"))
6748
- except (OSError, json.JSONDecodeError):
6749
- return None
6750
-
6751
-
6752
5795
  def _write_json_safe(path: Path, obj) -> None:
6753
5796
  try:
6754
5797
  path.write_text(json.dumps(obj, indent=2) + "\n", encoding="utf-8")
@@ -6756,33 +5799,6 @@ def _write_json_safe(path: Path, obj) -> None:
6756
5799
  pass
6757
5800
 
6758
5801
 
6759
- def _version_gt(a: str, b: str) -> bool:
6760
- """True if version a is newer than b (dotted numeric; prerelease suffix dropped)."""
6761
- def key(v: str):
6762
- out = []
6763
- for part in str(v).split("."):
6764
- part = part.split("-", 1)[0]
6765
- out.append((0, int(part)) if part.isdigit() else (1, part))
6766
- return out
6767
- try:
6768
- return key(a) > key(b)
6769
- except Exception:
6770
- return False
6771
-
6772
-
6773
- def _fetch_latest_version(timeout: float = 1.5):
6774
- """GET the registry's latest version. Returns a string, or None on ANY failure
6775
- (offline, timeout, bad payload) — the caller treats None as 'unknown, skip'."""
6776
- try:
6777
- req = urllib.request.Request(_REGISTRY_LATEST, headers={"Accept": "application/json"})
6778
- with urllib.request.urlopen(req, timeout=timeout) as resp:
6779
- data = json.loads(resp.read().decode("utf-8"))
6780
- v = data.get("version")
6781
- return v if isinstance(v, str) and v else None
6782
- except Exception:
6783
- return None
6784
-
6785
-
6786
5802
  def _cached_latest(add_dir: Path):
6787
5803
  """The registry's latest version, throttled: served from .update-cache.json within
6788
5804
  the TTL, else refreshed over the network (fail-open). None when unknown."""