@pilotspace/add 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tooling/add.py CHANGED
@@ -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
+ )
70
+
71
+ # --- TASK.md structural readers (moved to add_engine/taskdoc.py) ------------
72
+ from add_engine.taskdoc import (
73
+ _task_header, _count_test_defs, _primary_test_files, _tests_count,
74
+ _declared_test_files, _declared_tests_count, _tests_info, _task_prose,
75
+ _phase_spans, _raw_phase_bodies, _spec_delta_entries,
76
+ )
77
+
78
+ # --- autonomy-level resolvers (moved to add_engine/autonomy.py) -------------
79
+ from add_engine.autonomy import (
80
+ _autonomy_level, _effective_autonomy, _project_autonomy, _project_autonomy_token,
81
+ )
169
82
 
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
- """
209
83
 
84
+ def _phase_index(name: str) -> int:
85
+ """Ordinal of a phase in PHASES; used to enforce forward-skip rules."""
86
+ return PHASES.index(name)
210
87
 
211
- # --- low-level IO (designed for failure: atomic, no silent clobber) ----------
88
+ # --- low-level IO (moved to add_engine/io_state.py engine-modularization) -
89
+ from add_engine.io_state import ( # re-exported as module globals: callers use bare
90
+ _now, _atomic_write, _atomic_write_bytes, _atomic_write_many, # names so patches
91
+ find_root, _require_root, _migrate_state, _state_text_or_die, # on add.<name>
92
+ _die, # still resolve;
93
+ _CONFLICT_MARKER_RE, # conflict-marker re
94
+ _load_state_for_json, # --json state loader
95
+ _md5_text, _md5_file, # md5 hashing helpers
96
+ )
212
97
 
213
- def _now() -> str:
214
- return datetime.now(timezone.utc).isoformat(timespec="seconds")
215
98
 
99
+ # --- active milestone/task accessors (moved to add_engine/accessors.py) -------
100
+ from add_engine.accessors import (
101
+ _active_milestone, _active_task, _set_active_milestone,
102
+ _set_active_task, _activate_milestone, _deactivate_milestone,
103
+ )
216
104
 
217
- def _atomic_write(path: Path, text: str) -> None:
218
- """Write via a temp file in the same dir, then atomically replace.
105
+ # --- state load/save (KEPT in add.py: write-path pinned by add._atomic_write tests) -
219
106
 
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")
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,206 +150,18 @@ 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
167
  def _my_work(state: dict, me: dict) -> list[dict]:
@@ -535,8 +175,8 @@ def _my_work(state: dict, me: dict) -> list[dict]:
535
175
  for slug, t in tasks.items():
536
176
  if not isinstance(t, dict) or t.get("milestone") not in active_set or _task_done(t):
537
177
  continue
538
- owns = _actor_matches(t.get("owner"), me)
539
- assigned = _actor_matches(t.get("assignee"), me)
178
+ owns = identity._actor_matches(t.get("owner"), me)
179
+ assigned = identity._actor_matches(t.get("assignee"), me)
540
180
  if not (owns or assigned):
541
181
  continue
542
182
  role = "both" if owns and assigned else ("owner" if owns else "assignee")
@@ -550,106 +190,6 @@ def _my_work(state: dict, me: dict) -> list[dict]:
550
190
  # A git conflict marker BEGINS a line with 7 of `<`, `=`, or `>` (`(?m)^…`). An unresolved
551
191
  # merge writes these into state.json, making it invalid JSON; the line-anchor keeps a
552
192
  # 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
193
 
654
194
 
655
195
  def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None:
@@ -668,7 +208,7 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
668
208
  return # unreadable -> no-op (never blocks the gate)
669
209
  if "### GATE RECORD" not in text:
670
210
  return # nothing to mirror into
671
- actor = _actor_stamp(state)
211
+ actor = identity._actor_stamp(state)
672
212
  today = date.today().isoformat()
673
213
  # each rule matches ONLY a line still carrying a `<…>` placeholder -> grandfather a resolved line.
674
214
  rules = [
@@ -695,251 +235,11 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
695
235
  _atomic_write(f, new)
696
236
 
697
237
 
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
-
238
+ # --- guidelines / CLAUDE.md-injection subsystem (moved to add_engine/guidelines.py) -
239
+ from add_engine.guidelines import (
240
+ _guideline_block, _inject_block, _rule_file_mode, _strip_inline_block,
241
+ _insert_rule_reference, _ensure_claude_reference, _inject_guidelines, _is_brownfield,
242
+ )
943
243
  def cmd_init(args: argparse.Namespace) -> None:
944
244
  base = Path(args.dir).resolve()
945
245
  root = base / ROOT_DIRNAME
@@ -1003,6 +303,10 @@ def cmd_init(args: argparse.Namespace) -> None:
1003
303
  else:
1004
304
  print("next: open Claude Code, run `/add`, and say what you want to build —")
1005
305
  print(" the `add` skill sizes it into a milestone and drives the build with you.")
306
+ # setup hygiene (both branches): the .add/ folder IS the shared project state — commit it
307
+ # so the team shares one source of truth; its transient working files are already gitignored.
308
+ print("tip: commit the .add/ folder to git so your team shares the ADD state "
309
+ "(its transient files are already .gitignored).")
1006
310
 
1007
311
 
1008
312
  def cmd_sync_guidelines(args: argparse.Namespace) -> None:
@@ -1088,6 +392,11 @@ def cmd_new_task(args: argparse.Namespace) -> None:
1088
392
  f"autonomy token; new task seeded fail-safe '{autonomy}' "
1089
393
  "(fix it with `add.py autonomy set <level> --project`)", file=sys.stderr)
1090
394
 
395
+ # F8 (force-preserve-heal): a --force overwrite RE-CREATES the record; capture the prior
396
+ # MONOTONIC heal counter first so it survives. Else a task that accrued heal attempts (or
397
+ # was HARD-STOP escalated) could launder the cap (HEAL_CAP) to zero by re-creating itself —
398
+ # a zero-human cap bypass (the same invariant _heal_or_escalate guards: "never auto-resets").
399
+ prior_heal = state["tasks"].get(slug, {}).get("heal") if args.force else None
1091
400
  state["tasks"][slug] = {
1092
401
  "title": title,
1093
402
  "phase": "ground",
@@ -1097,6 +406,8 @@ def cmd_new_task(args: argparse.Namespace) -> None:
1097
406
  "created": _now(),
1098
407
  "updated": _now(),
1099
408
  }
409
+ if prior_heal is not None:
410
+ state["tasks"][slug]["heal"] = prior_heal # monotonic — survives the --force re-create
1100
411
  if from_delta:
1101
412
  state["tasks"][slug]["from_delta"] = from_delta # lineage: seeded from <prior>
1102
413
  if fast:
@@ -1107,6 +418,11 @@ def cmd_new_task(args: argparse.Namespace) -> None:
1107
418
  if milestone:
1108
419
  print(f"linked to milestone '{milestone}'" +
1109
420
  (f", depends-on {depends_on}" if depends_on else ""))
421
+ elif fast:
422
+ # blessed milestone-free fast lane (standalone-fast-task): a --fast task with no owning
423
+ # milestone is a DELIBERATE low-ceremony lane, not an orphan to nag — AFFIRM it.
424
+ print(f"standalone fast task '{slug}' — milestone-free by design (low-ceremony lane); "
425
+ f"attach later with `add.py set-milestone {slug} --milestone <id>` if it grows")
1110
426
  else:
1111
427
  # warn-never-block: the task is created (escape hatch), but nudge back toward the
1112
428
  # intake -> milestone flow. Speaks of STRUCTURE (not attached), never the act.
@@ -1151,16 +467,6 @@ def _parse_deps(raw: str | None) -> list[str]:
1151
467
  return [d.strip() for d in raw.split(",") if d.strip()]
1152
468
 
1153
469
 
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
470
  def _archived_task_slugs(state: dict) -> set[str]:
1165
471
  """Slugs of tasks that left active state via archive — all were PASS-done at
1166
472
  archive time, so a dep on one of them counts as satisfied (not dangling).
@@ -1184,16 +490,94 @@ def _resolve_task(state: dict, slug: str | None) -> str:
1184
490
  return slug
1185
491
 
1186
492
 
493
+ def _build_entry(root: Path, state: dict, slug: str) -> None:
494
+ """The shared tests->build entry guards + snapshots (task phase-build-guard, F4).
495
+
496
+ Extracted VERBATIM from cmd_advance's `nxt == "build"` block so BOTH `advance` and the
497
+ `phase build` admin override run the identical gate stack — the freeze gate, the
498
+ build-expectations gate, the unflagged-freeze check + flag stamp, the tamper tripwire, and
499
+ the §5 scope snapshot. validate-then-write: every `_die` precedes the first state mutation,
500
+ so a refused entry leaves the task byte-unchanged. The heal loop sets phase=build DIRECTLY
501
+ and never routes here, so it stays exempt.
502
+ """
503
+ # the OPTED-IN crossing guards (fast-lane + flow-enforcement): a task whose PARENT
504
+ # milestone opted into --await-confirm is held to the trust floor at tests->build. A
505
+ # task under a plain / no milestone is never gated (every existing advance-to-build flow
506
+ # stays green). validate-then-write — every refusal runs BEFORE the tripwire/scope
507
+ # snapshots below, writing nothing; the task stays at `tests`.
508
+ _ms = state["tasks"][slug].get("milestone")
509
+ _optin = bool(_ms) and (state.get("milestones") or {}).get(_ms, {}).get("await_confirm") is True
510
+ raw3 = _raw_phase_bodies(root, slug).get(3, "")
511
+ # freeze-before-build gate (fast-lane): "collapse-never-skip" made REAL — the freeze is
512
+ # engine-enforced for an opted-in milestone OR any --fast task (the fast arm, fast-new-task-flag:
513
+ # a fast task is held to the floor under ANY milestone, so the lighter lane never drops the
514
+ # trust seam). PRECEDES build-expectations: you freeze §3 before pre-declaring §6's outcomes.
515
+ _freeze_gated = _optin or state["tasks"][slug].get("fast") is True
516
+ if _freeze_gated and not _contract_frozen(raw3):
517
+ _die("contract_not_frozen: freeze §3 before crossing into build — approve "
518
+ f"the contract in {slug}'s TASK.md (Status: FROZEN @ vN)")
519
+ # build-expectations gate (flow-enforcement): an opted-in task may not enter build until
520
+ # its §6 `### Build expectations` are pre-declared — so verify checks the build is RIGHT,
521
+ # not just green. Same opt-in switch as the contract-fill gate, one level out.
522
+ if _optin:
523
+ if _section_unfilled(_raw_phase_bodies(root, slug).get(6, ""), "### Build expectations"):
524
+ _die("build_expectations_unfilled: fill the §6 '### Build expectations' block "
525
+ f"of {slug}'s TASK.md before crossing into build")
526
+ if _contract_frozen(raw3):
527
+ if not _flag_well_formed(raw3):
528
+ _die("unflagged_freeze: a frozen §3 must surface a well-formed "
529
+ "'Least-sure flag surfaced at freeze:' unit (>=1 [part] tag "
530
+ "+ substantive content; bare 'none' only as 'none material — "
531
+ "biggest risk: X') before crossing into build")
532
+ state["tasks"][slug]["flag_verified"] = True
533
+ # tamper tripwire (verify-integrity): snapshot the red test files + the frozen
534
+ # §3 md5s so the verify gate can prove the green was EARNED, not edited into
535
+ # place. UNCONDITIONAL overwrite — a legit change-request that re-crosses
536
+ # tests->build re-snapshots cleanly. Co-witnessed by flag_verified (above).
537
+ state["tasks"][slug]["tripwire"] = _tripwire_snapshot(root, slug, raw3)
538
+ # §5 scope gate (build-scope-lock): when the task declares its Scope, freeze
539
+ # the project tree into a sidecar (payload) + a state.json anchor (md5 of the
540
+ # sidecar bytes). Same UNCONDITIONAL-overwrite semantics as the tripwire.
541
+ # UNDECLARED (no Scope line) takes no snapshot — grandfathered, never retro-red
542
+ # — and CLEANS UP a previous declaration's leftovers (v3): a declared->
543
+ # undeclared re-cross pops the stale anchor + unlinks the stale sidecar, so
544
+ # "UNDECLARED is never refused" holds on every path.
545
+ declared = _declared_scope(root, slug)
546
+ side = root / "tasks" / slug / "scope-snapshot.json"
547
+ if declared is not None:
548
+ payload = json.dumps({"version": 1,
549
+ "files": _scope_walk(root.parent.resolve())},
550
+ sort_keys=True)
551
+ _atomic_write(side, payload) # temp+replace, like save_state — a crash can't leave a
552
+ # torn sidecar (payload verbatim, no newline → md5 anchor holds)
553
+ state["tasks"][slug]["scope"] = {"declared": declared,
554
+ "snapshot_md5": _md5_text(payload)}
555
+ else:
556
+ state["tasks"][slug].pop("scope", None)
557
+ try:
558
+ side.unlink()
559
+ except OSError:
560
+ pass
561
+
562
+
1187
563
  def cmd_phase(args: argparse.Namespace) -> None:
1188
564
  root = _require_root()
1189
565
  state = load_state(root)
1190
566
  slug = _resolve_task(state, args.slug)
1191
567
  if args.phase not in PHASES:
1192
568
  _die(f"phase must be one of: {', '.join(PHASES)}")
569
+ # phase-build-guard (F4): the admin override is NOT a backdoor around the tests->build gate
570
+ # stack — setting a task to build runs the SAME _build_entry guards `advance` runs (freeze
571
+ # gate · build-expectations · flag check · tamper tripwire · scope snapshot), so verify's
572
+ # _tamper_guard is armed and a freeze-gated DRAFT §3 is refused. Other targets are unchanged.
573
+ # validate-then-write: a refusal raises BEFORE the phase is set, so nothing moves. The heal
574
+ # loop sets phase=build directly (never via cmd_phase) and so stays exempt.
575
+ if args.phase == "build":
576
+ _build_entry(root, state, slug)
1193
577
  state["tasks"][slug]["phase"] = args.phase
1194
578
  state["tasks"][slug]["updated"] = _now()
1195
- _sync_task_marker(root, slug, args.phase)
1196
- save_state(root, state)
579
+ save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
580
+ _sync_task_marker(root, slug, args.phase) # then mirror into TASK.md (best-effort) — no split-brain
1197
581
  print(f"task '{slug}' phase -> {args.phase}")
1198
582
  print(_next_footer(root, state))
1199
583
 
@@ -1230,63 +614,10 @@ def cmd_advance(args: argparse.Namespace) -> None:
1230
614
  # freezes — the unmarked predecessors are never retro-redded). REFUSE writes
1231
615
  # nothing (fail-closed); below the build boundary the flag is never checked.
1232
616
  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
617
+ # the tests->build entry guards + snapshots now live in the shared _build_entry helper
618
+ # (task phase-build-guard, F4) so `advance` and the `phase build` admin override run the
619
+ # IDENTICAL gate stack. Behavior here is byte-unchanged this is a pure extraction.
620
+ _build_entry(root, state, slug)
1290
621
  # cross-component contract artifact (cross-component-contract): the contract->tests crossing
1291
622
  # is the producer's freeze-approval moment. A `produces:` task WRITES the immutable snapshot;
1292
623
  # a `consumes:` task PINS the live hash — a missing/unreadable snapshot HARD-STOPS here (the
@@ -1327,8 +658,8 @@ def cmd_advance(args: argparse.Namespace) -> None:
1327
658
  state["tasks"][slug]["contract_pin"] = {"id": _cons, "hash": pinned}
1328
659
  state["tasks"][slug]["phase"] = nxt
1329
660
  state["tasks"][slug]["updated"] = _now()
1330
- _sync_task_marker(root, slug, nxt)
1331
- save_state(root, state)
661
+ save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
662
+ _sync_task_marker(root, slug, nxt) # then mirror into TASK.md (best-effort) — no split-brain
1332
663
  print(f"task '{slug}' phase {cur} -> {nxt}")
1333
664
  if nxt == "observe":
1334
665
  # OBSERVE is where this loop's lessons get captured (TASK.md §7) — suggest routing
@@ -1356,12 +687,10 @@ _RISK_HIGH_RE = re.compile(r"(?:^|·)[ \t]*risk:[ \t]*high\b", re.MULTILINE)
1356
687
 
1357
688
  # the explicit 3-mode autonomy dial (task explicit-autonomy-dial): an ordered ladder
1358
689
  # manual < conservative < auto, declared as a per-task `autonomy:` header token.
1359
- _AUTONOMY_LEVELS = ("manual", "conservative", "auto")
1360
690
  # anchored to a DECLARATION position — line-start `autonomy:` OR the inline slug-line form
1361
691
  # `… · autonomy: conservative` (the `·`-preceded shape) — never a title/prose substring; the
1362
692
  # value stops at space/`<`/`#`/`|` so an unfilled `<manual | … >` placeholder captures nothing
1363
693
  # and reads as UNSET.
1364
- _AUTONOMY_LINE_RE = re.compile(r"(?:^|·)[ \t]*autonomy:[ \t]*([^\s<#|]+)", re.MULTILINE)
1365
694
 
1366
695
  # component-aware-add: a task binds to a registered component via a `component: <name>`
1367
696
  # header token — the SAME anchored grammar as autonomy (line-start or the `·`-inline
@@ -1372,45 +701,12 @@ _PRODUCES_LINE_RE = re.compile(r"(?:^|·)[ \t]*produces:[ \t]*([^\s<#|]+)", re.M
1372
701
  _CONSUMES_LINE_RE = re.compile(r"(?:^|·)[ \t]*consumes:[ \t]*([^\s<#|]+)", re.MULTILINE)
1373
702
 
1374
703
 
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
704
  def _autonomy_lowered(hdr: str) -> bool:
1389
705
  """True iff the declared rung is high-risk-safe (manual or conservative). A
1390
706
  high-risk scope must be lowered to one of these; `auto` and UNSET are not."""
1391
707
  return _autonomy_level(hdr) in ("manual", "conservative")
1392
708
 
1393
709
 
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
710
  def _driver_stop(root: Path, state: dict, slug: str, phase: str) -> bool:
1415
711
  """True iff a HUMAN owns the next step for `phase` under the effective autonomy — the
1416
712
  SINGLE source the footer marker and the guide TEXT marker both render (task
@@ -1469,6 +765,10 @@ def cmd_gate(args: argparse.Namespace) -> None:
1469
765
  # §5 scope gate (build-scope-lock): touched ⊆ declared, or a named refusal —
1470
766
  # same placement discipline as the tripwire (before the waiver, never on HARD-STOP).
1471
767
  _scope_guard(root, state, slug)
768
+ # consumer-stale gate (consumer-stale-gate): a `consumes:` task whose pinned producer
769
+ # contract hash has drifted from the live snapshot built against an out-of-date shape —
770
+ # refuse the completing outcome (same before-the-waiver discipline). Re-pin to recover.
771
+ _consumer_stale_guard(root, state, slug)
1472
772
  # per-component verify (component-aware-add): a component-bound task with a declared
1473
773
  # green_bar must CITE that bar in its §6 evidence before a completing outcome — the
1474
774
  # engine never runs the suite, it checks the right bar was recorded. Unbound / no
@@ -1493,11 +793,12 @@ def cmd_gate(args: argparse.Namespace) -> None:
1493
793
  }
1494
794
  if completing:
1495
795
  state["tasks"][slug]["phase"] = "done"
1496
- _sync_task_marker(root, slug, "done")
1497
796
  state["tasks"][slug]["gate"] = args.outcome
1498
- state["tasks"][slug]["gate_actor"] = _actor_stamp(state) # WHO recorded the verdict (every outcome)
797
+ state["tasks"][slug]["gate_actor"] = identity._actor_stamp(state) # WHO recorded the verdict (every outcome)
1499
798
  state["tasks"][slug]["updated"] = _now()
1500
- save_state(root, state)
799
+ save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
800
+ if completing:
801
+ _sync_task_marker(root, slug, "done") # then mirror the phase into TASK.md — no split-brain
1501
802
  _stamp_gate_record(root, state, slug, args.outcome) # mirror the verdict into §6 (Finding C)
1502
803
  print(f"task '{slug}' gate -> {args.outcome}")
1503
804
  _gbar = _task_green_bar(root, slug) # per-component-verify: surface the bound bar
@@ -1582,6 +883,45 @@ def cmd_autonomy(args: argparse.Namespace) -> None:
1582
883
  _print_autonomy(root, state, slug)
1583
884
 
1584
885
 
886
+ def cmd_todo(args: argparse.Namespace) -> None:
887
+ """Capture / list / close a lightweight backlog todo (task: todo-capture).
888
+
889
+ A todo is a JOTTED IDEA, not a task — it carries no spec/contract/gate. It lets you
890
+ record an intent without sizing it. Promote one to a real task with
891
+ `add.py new-task <slug> --fast` when you decide to build it. Stored in state["todos"]
892
+ as {id (1-based = max+1), text, created, status:"open"|"done"}.
893
+ """
894
+ root = _require_root() # reused -> "no .add/ project found …"
895
+ state = load_state(root)
896
+ todos = state.get("todos")
897
+ if not isinstance(todos, list): # absent / corrupt -> fresh list (drift-safe)
898
+ todos = state["todos"] = []
899
+ done_id = getattr(args, "done", None)
900
+ if done_id is not None: # --done <id> : close an OPEN todo
901
+ for t in todos:
902
+ if isinstance(t, dict) and t.get("id") == done_id and t.get("status") == "open":
903
+ t["status"] = "done"
904
+ save_state(root, state)
905
+ print(f"todo #{done_id} done")
906
+ return
907
+ _die(f"todo_unknown: no open todo #{done_id}")
908
+ if args.text is not None: # capture attempt (text positional present)
909
+ text = args.text.strip()
910
+ if not text:
911
+ _die("todo_empty: a todo needs text")
912
+ new_id = max((t.get("id", 0) for t in todos if isinstance(t, dict)), default=0) + 1
913
+ todos.append({"id": new_id, "text": text, "created": _now(), "status": "open"})
914
+ save_state(root, state)
915
+ print(f"captured todo #{new_id}: {text}")
916
+ return
917
+ open_todos = [t for t in todos if isinstance(t, dict) and t.get("status") == "open"]
918
+ if not open_todos: # bare `todo` -> list OPEN todos
919
+ print("no open todos")
920
+ return
921
+ for t in open_todos:
922
+ print(f"#{t.get('id')} {t.get('text')}")
923
+
924
+
1585
925
  def cmd_reopen(args: argparse.Namespace) -> None:
1586
926
  """Return an already-`done` task to an earlier phase with a never-silent record.
1587
927
 
@@ -1616,8 +956,8 @@ def cmd_reopen(args: argparse.Namespace) -> None:
1616
956
  t["phase"] = target
1617
957
  t["gate"] = "none"
1618
958
  t["updated"] = now
1619
- _sync_task_marker(root, slug, target)
1620
- save_state(root, state)
959
+ save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
960
+ _sync_task_marker(root, slug, target) # then mirror into TASK.md (best-effort) — no split-brain
1621
961
  print(f"task '{slug}' reopened: done -> {target} (reason recorded); gate reset to none")
1622
962
  print(_next_footer(root, state))
1623
963
 
@@ -1668,7 +1008,7 @@ def cmd_lock(args: argparse.Namespace) -> None:
1668
1008
  when = _now()
1669
1009
  # ONE atomic write — no partial lock state.
1670
1010
  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
1011
+ "actor": identity._actor_stamp(state)} # structured actor alongside the free-text locked_by
1672
1012
  save_state(root, state)
1673
1013
  if getattr(args, "json", False):
1674
1014
  print(json.dumps(
@@ -1695,7 +1035,7 @@ def cmd_whoami(args: argparse.Namespace) -> None:
1695
1035
  _die("actor_name_blank")
1696
1036
  state["actor_override"] = {"name": args.name, "email": args.email or None}
1697
1037
  save_state(root, state)
1698
- who = _whoami(state)
1038
+ who = identity._whoami(state)
1699
1039
  if getattr(args, "json", False):
1700
1040
  print(json.dumps(who, separators=(",", ":")))
1701
1041
  return
@@ -1724,14 +1064,14 @@ def cmd_assign(args: argparse.Namespace) -> None:
1724
1064
  _die("unknown_slug")
1725
1065
  # parse + validate ALL flags BEFORE the first write — a blank name is rejected on the
1726
1066
  # 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
1067
+ parsed_owner = identity._parse_actor_arg(args.owner) if args.owner is not None else None
1068
+ parsed_assignee = identity._parse_actor_arg(args.assignee) if args.assignee is not None else None
1729
1069
  if parsed_owner is not None and not parsed_owner["name"].strip():
1730
1070
  _die("owner_name_blank")
1731
1071
  if parsed_assignee is not None and not parsed_assignee["name"].strip():
1732
1072
  _die("assignee_name_blank")
1733
1073
  if parsed_owner is None and parsed_assignee is None:
1734
- who = _whoami(state)
1074
+ who = identity._whoami(state)
1735
1075
  rec["owner"] = dict(who)
1736
1076
  rec["assignee"] = dict(who)
1737
1077
  else:
@@ -1765,15 +1105,6 @@ def cmd_unassign(args: argparse.Namespace) -> None:
1765
1105
  print(f"unassigned {args.slug} ({', '.join(roles)})")
1766
1106
 
1767
1107
 
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
1108
  def cmd_stage(args: argparse.Namespace) -> None:
1778
1109
  root = _require_root()
1779
1110
  state = load_state(root)
@@ -1846,7 +1177,7 @@ def cmd_status(args: argparse.Namespace) -> None:
1846
1177
  grad_ready, grad_met, grad_total = _graduation_ready(root, state)
1847
1178
  print(json.dumps({
1848
1179
  "project": state.get("project"), "stage": state.get("stage"),
1849
- "actor": _whoami(state),
1180
+ "actor": identity._whoami(state),
1850
1181
  "active_task": _active_task(state),
1851
1182
  "active_milestones": list(state.get("active_milestones") or []),
1852
1183
  "active_tasks": dict(state.get("active_tasks") or {}),
@@ -1871,7 +1202,7 @@ def cmd_status(args: argparse.Namespace) -> None:
1871
1202
  print(f"project autonomy: {_project_autonomy(root)} (default — new tasks inherit)")
1872
1203
  # git-native actor (user-identity): who ADD sees you as this session — the identity every
1873
1204
  # human-owned stamp records. Always present (the resolver is TOTAL). Read-only, no write.
1874
- _who = _whoami(state)
1205
+ _who = identity._whoami(state)
1875
1206
  _who_email = f" <{_who['email']}>" if _who.get("email") else ""
1876
1207
  print(f"actor : {_who['name']}{_who_email} (source: {_who['source']})")
1877
1208
  print(f"stage : {state.get('stage', '(unknown)')}")
@@ -1938,6 +1269,12 @@ def cmd_status(args: argparse.Namespace) -> None:
1938
1269
  _rel = _releasable(root, state)
1939
1270
  if _rel:
1940
1271
  print(f" → {RELEASABLE_CUE.format(n=len(_rel))}")
1272
+ # loose-task release cue (loose-task-release): a SEPARATE additive line — done milestone-free
1273
+ # tasks not yet attributed to a RELEASES.md `loose tasks:` row. Peer to the milestone cue (its
1274
+ # constant is untouched); fires even with zero releasable milestones. Fail-open ledger read.
1275
+ _loose = _releasable_loose_tasks(root, state)
1276
+ if _loose:
1277
+ print(f" → releasable: {len(_loose)} loose task(s) since last release")
1941
1278
 
1942
1279
  # fast-lane marker (fast-new-task-flag): tag an ACTIVE fast task so the lane is visible at a
1943
1280
  # glance. Presentation-only, existence-gated — a plain/absent active task is byte-unchanged.
@@ -2624,6 +1961,7 @@ def cmd_check(args: argparse.Namespace) -> None:
2624
1961
  milestones = state.get("milestones") if isinstance(state.get("milestones"), dict) else {}
2625
1962
  archived_slugs = _archived_task_slugs(state) # archived deps still resolve
2626
1963
  warnings: list[tuple[str, str]] = [] # (name, reason) — nudges that NEVER feed `failed`
1964
+ infos: list[tuple[str, str]] = [] # (name, reason) — affirmations; NEVER feed `warned`/`failed`
2627
1965
  for slug, t in tasks.items():
2628
1966
  task_md = root / "tasks" / slug / "TASK.md"
2629
1967
  checks.append((task_md.exists(), f"task '{slug}' has TASK.md", "file missing"))
@@ -2635,6 +1973,10 @@ def cmd_check(args: argparse.Namespace) -> None:
2635
1973
  if ms is not None:
2636
1974
  checks.append((ms in milestones, f"task '{slug}' milestone resolves",
2637
1975
  f"unknown milestone {ms!r}"))
1976
+ elif t.get("fast"):
1977
+ # blessed milestone-free fast lane (standalone-fast-task): a --fast task with no
1978
+ # milestone is DELIBERATE — a soft INFO affirmation, never a WARN/orphan nudge.
1979
+ infos.append((f"task '{slug}'", "— standalone fast lane (milestone-free by design)"))
2638
1980
  else:
2639
1981
  # warn-never-block: a task outside a milestone is a structural nudge back toward
2640
1982
  # the intake flow — NOT a failure. Names structure, never the act of intake.
@@ -2824,10 +2166,15 @@ def cmd_check(args: argparse.Namespace) -> None:
2824
2166
  passed = sum(1 for ok, _, _ in checks if ok)
2825
2167
  failed = len(checks) - passed
2826
2168
  if as_json:
2169
+ # `infos`/`informed` are ADDITIVE (standalone-fast-task) — affirmations that never feed
2170
+ # `warned`/`failed`; existing keys are untouched so prior consumers keep working.
2827
2171
  print(json.dumps({"passed": passed, "failed": failed,
2828
2172
  "warned": len(warnings),
2829
2173
  "warnings": [{"name": name, "reason": reason}
2830
2174
  for name, reason in warnings],
2175
+ "informed": len(infos),
2176
+ "infos": [{"name": name, "reason": reason}
2177
+ for name, reason in infos],
2831
2178
  "checks": [{"ok": ok, "name": desc,
2832
2179
  "reason": reason if not ok else ""}
2833
2180
  for ok, desc, reason in checks]}))
@@ -2836,6 +2183,8 @@ def cmd_check(args: argparse.Namespace) -> None:
2836
2183
  print(f"PASS {desc}" if ok else f"FAIL {desc}: {reason}")
2837
2184
  for name, reason in warnings:
2838
2185
  print(f"WARN {name} {reason}")
2186
+ for name, reason in infos:
2187
+ print(f"INFO {name} {reason}")
2839
2188
  summary = f"check: {passed} passed, {failed} failed"
2840
2189
  if warnings:
2841
2190
  summary += f" ({len(warnings)} warnings)" # frozen §3: summary gains "(N warnings)"
@@ -2917,7 +2266,7 @@ def cmd_mine(args: argparse.Namespace) -> None:
2917
2266
  if root is None:
2918
2267
  _die("no_project")
2919
2268
  state = load_state(root)
2920
- me = _parse_actor_arg(args.actor) if getattr(args, "actor", None) else _whoami(state)
2269
+ me = identity._parse_actor_arg(args.actor) if getattr(args, "actor", None) else identity._whoami(state)
2921
2270
  rows = _my_work(state, me)
2922
2271
  if getattr(args, "json", False):
2923
2272
  print(json.dumps({"actor": me, "tasks": rows}))
@@ -3136,7 +2485,7 @@ def cmd_milestone_confirm(args: argparse.Namespace) -> None:
3136
2485
  m["confirmed"] = True
3137
2486
  m["confirmed_at"] = _now()
3138
2487
  m["confirmed_by"] = who
3139
- m["actor"] = _actor_stamp(state) # structured actor alongside the free-text confirmed_by
2488
+ m["actor"] = identity._actor_stamp(state) # structured actor alongside the free-text confirmed_by
3140
2489
  m["updated"] = _now()
3141
2490
  save_state(root, state)
3142
2491
  print(f"confirmed milestone '{slug}' (by {who}) — new-task is now open for it.")
@@ -3389,7 +2738,7 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
3389
2738
  # Stamp WHO closed it BEFORE rendering the retro, so the persisted exit report records
3390
2739
  # the closer (identity-in-status: the retro IS the report `report <ms>` re-renders, so both
3391
2740
  # must reflect the same final state). In-memory only here — save_state below commits it.
3392
- state["milestones"][slug]["done_actor"] = _actor_stamp(state)
2741
+ state["milestones"][slug]["done_actor"] = identity._actor_stamp(state)
3393
2742
  # Fail-closed: render+persist the exit report (RETRO.md) BEFORE committing the
3394
2743
  # status flip, so a write failure rolls back naturally (status never commits ->
3395
2744
  # no done-without-retro state). The retro step is read-only on state.json.
@@ -3692,165 +3041,14 @@ def _sync_task_marker(root: Path, slug: str, phase: str) -> None:
3692
3041
  # state.json; prose (observe delta, deltas) is parsed from each TASK.md and
3693
3042
  # fails CLOSED to `(unknown)` rather than omitting silently.
3694
3043
 
3695
- _DEFAULT_WIDTH = 72 # fixed width for the persisted/canonical render (RETRO.md)
3696
3044
  # Two glyph tiers. Alignment is correct only with ASCII in column-positioned
3697
3045
  # cells (every ASCII char is 1 display cell); Unicode glyphs sit at line-END
3698
3046
  # (the PROGRESS track) or in non-aligned rows, where width can't break columns.
3699
3047
  _UNICODE = {"reached": "●", "current": "◉", "pending": "○", "h": "═", "rule": "─", "bullet": "•"}
3700
3048
  _ASCII = {"reached": "#", "current": ">", "pending": ".", "h": "=", "rule": "-", "bullet": "*"}
3701
3049
  _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
3050
  # A non-empty `(verify: <citation>)` on an exit-criterion line — at least one non-whitespace
3829
3051
  # 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
3052
  def _goal_auto_ready(root: Path, mslug: str) -> bool:
3855
3053
  """True iff the milestone goal is AUTO-READY: its Exit criteria has >= 1 criterion
3856
3054
  AND every one cites a verifier (cited == total) — so the engine can self-verify the
@@ -3860,32 +3058,6 @@ def _goal_auto_ready(root: Path, mslug: str) -> bool:
3860
3058
  return total >= 1 and cited == total
3861
3059
 
3862
3060
 
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
3061
  def _graduation_ready(root: Path, state: dict) -> tuple[bool, int, int]:
3890
3062
  """(ready, met, total) for the stage-graduation cue (v22): every milestone done AND the
3891
3063
  human's stage-goal-criteria all checked (total>0 and met==total). The SINGLE source the
@@ -3895,93 +3067,6 @@ def _graduation_ready(root: Path, state: dict) -> tuple[bool, int, int]:
3895
3067
  return ready, met, total
3896
3068
 
3897
3069
 
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
3070
  def _resolved_test_files(root: Path, slug: str) -> list[Path]:
3986
3071
  """The file set the engine treats as this task's tests — the PRIMARY set wins
3987
3072
  when it yields any test defs, else the §4-declared set (mirrors _tests_info's
@@ -3992,19 +3077,6 @@ def _resolved_test_files(root: Path, slug: str) -> list[Path]:
3992
3077
  return _declared_test_files(root, slug)
3993
3078
 
3994
3079
 
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
3080
  def _tripwire_snapshot(root: Path, slug: str, raw3: str) -> dict:
4009
3081
  """Freeze the md5 of the resolved red test files + the frozen §3 contract — the
4010
3082
  tamper baseline (verify-integrity). Keys are project-root-relative paths (stable
@@ -4058,31 +3130,6 @@ _SCOPE_EXCLUDE_SUFFIXES = (".pyc", ".tsbuildinfo")
4058
3130
  # OPT-IN + DEGRADE-SAFE: with no .add/components.toml every reader is byte-identical to
4059
3131
  # pre-component ADD. A read NEVER raises (absent/unreadable/malformed → {} / dropped
4060
3132
  # 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
3133
  def _component_root(root: Path, name: str) -> str | None:
4087
3134
  """Project-root-relative path (trailing '/') of component `name`'s root, or None
4088
3135
  when the name is absent OR the root escapes the project (fail-closed — grants no
@@ -4120,22 +3167,6 @@ def _task_green_bar(root: Path, slug: str) -> str | None:
4120
3167
  return (_components(root).get(comp) or {}).get("green_bar") or None
4121
3168
 
4122
3169
 
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
3170
  def _component_findings(root: Path) -> list[tuple[str, str]]:
4140
3171
  """The loud gate surface for the registry — the codes a degrade-safe read passes
4141
3172
  over silently. Consumed by cmd_check (the scope_violation surface). [] when clean."""
@@ -4178,46 +3209,6 @@ def _component_findings(root: Path) -> list[tuple[str, str]]:
4178
3209
  # ── cross-component contracts (cross-component-contract) ──────────────────────────────────
4179
3210
  # OPT-IN + DEGRADE-SAFE, like the component readers: no [contract.*] / no produces|consumes
4180
3211
  # 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
3212
  def _task_produces(root: Path, slug: str) -> str | None:
4222
3213
  m = _PRODUCES_LINE_RE.search(_task_header(root, slug))
4223
3214
  return m.group(1).strip() if m else None
@@ -4228,10 +3219,6 @@ def _task_consumes(root: Path, slug: str) -> str | None:
4228
3219
  return m.group(1).strip() if m else None
4229
3220
 
4230
3221
 
4231
- def _contract_snapshot(root: Path, cid: str) -> Path:
4232
- return root / "contracts" / f"{cid}.json"
4233
-
4234
-
4235
3222
  def _contract_body_hash(raw3: str) -> str:
4236
3223
  """md5 of the §3 contract SHAPE — the first ```fenced``` block, whitespace-normalized. The
4237
3224
  version stamp + freeze flags are excluded (fallback strips Status:/flag/change-request lines)
@@ -4304,18 +3291,6 @@ def _declared_scope(root: Path, slug: str) -> list[str] | None:
4304
3291
  return out
4305
3292
 
4306
3293
 
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
3294
  def _scope_walk(rootp: Path) -> dict[str, str]:
4320
3295
  """{project-root-relative path: md5} over the project tree, pruning
4321
3296
  _SCOPE_EXCLUDE_DIRS at any depth and skipping bytecode/OS junk +
@@ -4446,6 +3421,28 @@ def _heal_or_escalate(root: Path, state: dict, slug: str, *, reason: str, source
4446
3421
  raise SystemExit(3) # redo signal (distinct from _die's 1, argparse's 2)
4447
3422
 
4448
3423
 
3424
+ def _consumer_stale_guard(root: Path, state: dict, slug: str) -> None:
3425
+ """Refuse a COMPLETING gate when a `consumes:` task's pinned producer contract hash is STALE
3426
+ (the producer re-froze a CHANGED shape since the pin) — the consumer built against an
3427
+ out-of-date contract (consumer-stale-gate, the gate twin of cmd_check's contract_consumer_stale
3428
+ warning). Recoverable, not a cheat: re-pin by re-crossing contract->tests after reviewing the
3429
+ new frozen shape. Degrade-safe — an unreadable/missing live snapshot is NOT decided here (it
3430
+ stays a cmd_check warning + the advance-time contract_snapshot_missing HARD-STOP); only a
3431
+ CONFIRMED hash drift blocks. Placed with the other completing guards, BEFORE the waiver write,
3432
+ so a stale pin is never launderable through RISK-ACCEPTED; HARD-STOP never reaches here."""
3433
+ pin = state["tasks"][slug].get("contract_pin")
3434
+ if not pin:
3435
+ return
3436
+ try:
3437
+ live = json.loads(_contract_snapshot(root, pin["id"]).read_text(encoding="utf-8")).get("hash")
3438
+ except (OSError, ValueError, KeyError, TypeError, AttributeError):
3439
+ return # unreadable -> surfaced by cmd_check, not confirmable as stale here
3440
+ if live is not None and live != pin.get("hash"):
3441
+ _die(f"contract_consumer_stale: task '{slug}' pinned contract '{pin['id']}' changed shape "
3442
+ "since the pin (the producer re-froze) — re-pin by re-crossing contract->tests after "
3443
+ "reviewing the producer's new frozen shape; never complete against a stale contract")
3444
+
3445
+
4449
3446
  def _tamper_guard(root: Path, state: dict, slug: str) -> None:
4450
3447
  """HARD-STOP a COMPLETING gate when the tripwire shows tampering — the method's
4451
3448
  first mechanical cheat block (verify-integrity). Tri-state, co-witnessed by
@@ -4472,89 +3469,6 @@ def _tamper_guard(root: Path, state: dict, slug: str) -> None:
4472
3469
  reason="tamper_detected:" + ",".join(diffs), source="tamper")
4473
3470
 
4474
3471
 
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
3472
  def report_data(root: Path, state: dict, mslug: str) -> dict:
4559
3473
  """The single source of FACTS for a milestone report — pure, NO writes.
4560
3474
  Both the text dashboard (render_report) and `report --json` render from this,
@@ -4636,43 +3550,6 @@ def _clean_phase_body(body: str) -> str:
4636
3550
  return "\n".join(lines) if meaningful else "(empty)"
4637
3551
 
4638
3552
 
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
3553
  def task_phases(root: Path, slug: str) -> list[dict]:
4677
3554
  """The frozen per-task PHASE-DETAIL shape (v9-1): parse TASK.md §0–§7 into eight
4678
3555
  blocks ground→observe. PURE — NO writes. Each entry is
@@ -5259,10 +4136,6 @@ _DELTA_STATUSES = ("open", "folded", "rejected")
5259
4136
  # un-stripped lines directly; callers that pre-strip their input
5260
4137
  # (e.g. _collect_open_deltas, _lint_task_deltas) match the same way (\s*
5261
4138
  # 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
4139
 
5267
4140
  # SPEC-delta track — a SEPARATE resolution lifecycle from the competency deltas
5268
4141
  # above. SPEC shares the "- [TAG · status]" LINE shape but its statuses are
@@ -5271,9 +4144,6 @@ _EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
5271
4144
  # tag to its legal status set so the ONE lint can reject a cross-set pairing
5272
4145
  # ([SPEC · folded], [SDD · seeded]) without a parallel grammar.
5273
4146
  _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
4147
  _STATUS_SETS = {**{c: _DELTA_STATUSES for c in _COMPETENCY_ORDER}, "SPEC": _SPEC_STATUSES}
5278
4148
 
5279
4149
  # Broad structural tag detector: finds ANY "- [tok · tok]" line (valid OR malformed).
@@ -5429,32 +4299,6 @@ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
5429
4299
  return by_comp
5430
4300
 
5431
4301
 
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
4302
  def _collect_open_spec_deltas(root: Path) -> list[dict]:
5459
4303
  """Scan every .add/tasks/*/TASK.md "### Spec delta" block for OPEN SPEC deltas.
5460
4304
 
@@ -5782,6 +4626,15 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
5782
4626
  elif gate != "none" and outcomes[0] != gate:
5783
4627
  f(slug, "gate_record_mismatch",
5784
4628
  f"§6 records {outcomes[0]} but state.json records {gate}")
4629
+ elif gate == "none":
4630
+ # F13 ungated_verdict: §6 carries a verdict the engine never gated.
4631
+ # cmd_gate is the ONLY writer of `gate` (it also marks done), so a
4632
+ # done/observe task at gate=="none" reached its verdict without the
4633
+ # engine — a hand-stamped §6 or an `advance` past verify. Constraint 4
4634
+ # requires a RECORDED outcome; a §6 verdict without one is not trusted.
4635
+ f(slug, "ungated_verdict",
4636
+ f"§6 records {outcomes[0]} but state.json recorded no gate (gate=none) — "
4637
+ f"the verdict was written without the engine gate")
5785
4638
  sec = _AUDIT_SECURITY_RE.search(s6)
5786
4639
  marked = bool(sec and ("NOTE" in sec.group(0) or "⚠" in sec.group(0)))
5787
4640
  rev = _AUDIT_REVIEWED_RE.search(s6)
@@ -5967,12 +4820,6 @@ def cmd_graduation_report(args: argparse.Namespace) -> None:
5967
4820
  print("\n".join(L))
5968
4821
 
5969
4822
 
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
4823
  def _released_milestones(root: Path) -> set[str]:
5977
4824
  """Slugs already attributed to a release — the union of every `milestones:` row in
5978
4825
  RELEASES.md. Fail-OPEN: a missing/unreadable/malformed ledger yields the empty set, so
@@ -5993,21 +4840,6 @@ def _released_milestones(root: Path) -> set[str]:
5993
4840
  return out
5994
4841
 
5995
4842
 
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
4843
  def _releasable(root: Path, state: dict) -> list[dict]:
6012
4844
  """Closed milestones NOT yet attributed to any RELEASES.md row — the cut's candidate
6013
4845
  bundle. Drives BOTH the `→ releasable: N` status cue and release-report. READ-ONLY."""
@@ -6015,19 +4847,38 @@ def _releasable(root: Path, state: dict) -> list[dict]:
6015
4847
  return [m for m in _closed_milestones(state) if m["slug"] not in released]
6016
4848
 
6017
4849
 
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."""
4850
+ def _released_loose_tasks(root: Path) -> set[str]:
4851
+ """Slugs already attributed to a release as LOOSE tasks — the union of every
4852
+ `loose tasks:` row in RELEASES.md. The exact parallel of _released_milestones:
4853
+ fail-OPEN (a missing/unreadable/malformed ledger yields the empty set, so every
4854
+ done loose task reads as still-releasable). READ-ONLY."""
6022
4855
  try:
6023
- text = (root / "PROJECT.md").read_text(encoding="utf-8")
4856
+ text = _releases_path(root).read_text(encoding="utf-8")
6024
4857
  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]
4858
+ return set() # no ledger -> nothing released yet
4859
+ out: set[str] = set()
4860
+ for line in text.splitlines():
4861
+ st = line.strip()
4862
+ if st.lower().startswith("loose tasks:"):
4863
+ for tok in re.split(r"[,\s]+", st.split(":", 1)[1]):
4864
+ tok = tok.strip()
4865
+ if tok and tok.lower() != "none":
4866
+ out.add(tok)
4867
+ return out
4868
+
4869
+
4870
+ def _releasable_loose_tasks(root: Path, state: dict) -> list[dict]:
4871
+ """Done milestone-free tasks NOT yet attributed to any RELEASES.md `loose tasks:` row —
4872
+ the loose half of the cut's candidate bundle, peer to _releasable. A loose task is a
4873
+ first-class releasable item: milestone-free (a standalone, fast OR full) AND done (a
4874
+ completing gate). Drives the loose status cue + release_data["loose"]. READ-ONLY."""
4875
+ released = _released_loose_tasks(root)
4876
+ out: list[dict] = []
4877
+ for slug, t in (state.get("tasks") or {}).items():
4878
+ if (isinstance(t, dict) and t.get("milestone") is None and _task_done(t)
4879
+ and slug not in released):
4880
+ out.append({"slug": slug, "title": t.get("title", slug)})
4881
+ return out
6031
4882
 
6032
4883
 
6033
4884
  def release_data(root: Path, state: dict) -> dict:
@@ -6092,15 +4943,19 @@ def release_data(root: Path, state: dict) -> dict:
6092
4943
  monitors.append({"slug": slug, "watch": st})
6093
4944
  break
6094
4945
 
4946
+ # loose — done milestone-free tasks not yet attributed (the cut's loose bundle, peer to releasable)
4947
+ loose = _releasable_loose_tasks(root, state)
4948
+
6095
4949
  return {
6096
4950
  "releasable": releasable,
6097
4951
  "changed": changed,
6098
4952
  "waivers": waivers,
6099
4953
  "blockers": blockers,
6100
4954
  "monitors": monitors,
4955
+ "loose": loose,
6101
4956
  "summary": {
6102
4957
  "releasable": len(releasable), "changed": len(changed), "waivers": len(waivers),
6103
- "blockers": len(blockers), "monitors": len(monitors),
4958
+ "blockers": len(blockers), "monitors": len(monitors), "loose": len(loose),
6104
4959
  },
6105
4960
  }
6106
4961
 
@@ -6144,14 +4999,6 @@ def cmd_release_report(args: argparse.Namespace) -> None:
6144
4999
  print("\n".join(L))
6145
5000
 
6146
5001
 
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
5002
  def _prepend_block(existing: str, header: str, block: str) -> str:
6156
5003
  """Newest-first prepend: insert `block` directly under the top H1 `header`, creating the
6157
5004
  header when `existing` is empty / headerless. Existing content is preserved VERBATIM
@@ -6164,37 +5011,6 @@ def _prepend_block(existing: str, header: str, block: str) -> str:
6164
5011
  return f"{block}{existing}" # no recognized header -> block goes on top, verbatim tail
6165
5012
 
6166
5013
 
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
5014
  def cmd_release(args: argparse.Namespace) -> None:
6199
5015
  """GUARDED, record-only: cut a version. Enforce the 4-code readiness floor, then RECORD by
6200
5016
  prepending CHANGELOG.md + an append-only RELEASES.md row (whose `milestones:` line attributes
@@ -6218,9 +5034,11 @@ def cmd_release(args: argparse.Namespace) -> None:
6218
5034
  _die("release_tests_red: a build is in flight without a recorded green gate — finish and "
6219
5035
  "gate it first, or pass --force to override.")
6220
5036
  bundle = _releasable(root, state)
6221
- if not forced and not bundle:
5037
+ loose_bundle = _releasable_loose_tasks(root, state)
5038
+ if not forced and not bundle and not loose_bundle:
6222
5039
  _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.")
5040
+ "would be a no-op. Close a milestone (or a standalone task) first, or pass --force "
5041
+ "to override.")
6224
5042
  if not forced and d["waivers"] and not disclosed:
6225
5043
  _die("release_undisclosed_waiver: a RISK-ACCEPTED waiver rides into this release — pass "
6226
5044
  "--with-waivers to disclose it in the notes, or --force to override.")
@@ -6238,7 +5056,7 @@ def cmd_release(args: argparse.Namespace) -> None:
6238
5056
  new_rel = _prepend_block(rel_before, "# Releases",
6239
5057
  _render_releases_row(args.version, day, bundle, waiver_slugs,
6240
5058
  getattr(args, "evidence", None),
6241
- _render_actor_line(state)))
5059
+ identity._render_actor_line(state), loose_bundle))
6242
5060
  try: # CHANGELOG + RELEASES as one all-or-nothing commit
6243
5061
  _atomic_write_many([(changelog_path, new_cl), (releases_path, new_rel)])
6244
5062
  except OSError as e:
@@ -6247,7 +5065,9 @@ def cmd_release(args: argparse.Namespace) -> None:
6247
5065
 
6248
5066
  # NO save_state — attribution lives in RELEASES.md (the cue re-reads it), never state.json
6249
5067
  ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
6250
- print(f"released {args.version} recorded {len(bundle)} milestone(s): {ms}")
5068
+ lt = ", ".join(t["slug"] for t in loose_bundle) if loose_bundle else "none"
5069
+ print(f"released {args.version} — recorded {len(bundle)} milestone(s): {ms} "
5070
+ f"+ {len(loose_bundle)} loose task(s): {lt}")
6251
5071
  print(" CHANGELOG.md + RELEASES.md updated (project root). The engine records; "
6252
5072
  "you run the tag / publish / deploy.")
6253
5073
  if forced:
@@ -6568,6 +5388,13 @@ def build_parser() -> argparse.ArgumentParser:
6568
5388
  help="confirm a RAISE toward auto (a human-owned trust escalation)")
6569
5389
  pan.set_defaults(func=cmd_autonomy, _opt_positionals=("a1", "a2"))
6570
5390
 
5391
+ pto = sub.add_parser("todo", help="capture / list / close a lightweight backlog todo (jot an idea)")
5392
+ pto.add_argument("text", nargs="?", default=None,
5393
+ help="todo text to capture; omit to LIST open todos")
5394
+ pto.add_argument("--done", type=int, default=None, metavar="ID",
5395
+ help="close an open todo by id")
5396
+ pto.set_defaults(func=cmd_todo)
5397
+
6571
5398
  pr = sub.add_parser("reopen", help="return a done task to an earlier phase with a recorded reason")
6572
5399
  pr.add_argument("slug", nargs="?", default=None)
6573
5400
  # --to / --reason are validated in-body (not argparse choices) so the named reject
@@ -6739,16 +5566,6 @@ def _rebind_optional_positionals(parser: argparse.ArgumentParser,
6739
5566
  # silently does nothing and nothing is lost. It NEVER changes a command's stdout or exit.
6740
5567
  _UPDATE_CACHE = ".update-cache.json"
6741
5568
  _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
5569
  def _write_json_safe(path: Path, obj) -> None:
6753
5570
  try:
6754
5571
  path.write_text(json.dumps(obj, indent=2) + "\n", encoding="utf-8")
@@ -6756,33 +5573,6 @@ def _write_json_safe(path: Path, obj) -> None:
6756
5573
  pass
6757
5574
 
6758
5575
 
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
5576
  def _cached_latest(add_dir: Path):
6787
5577
  """The registry's latest version, throttled: served from .update-cache.json within
6788
5578
  the TTL, else refreshed over the network (fail-open). None when unknown."""