@pilotspace/add 1.7.3 → 1.9.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
@@ -17,6 +17,8 @@ import hashlib
17
17
  import json
18
18
  import os
19
19
  import re
20
+ import shutil
21
+ import subprocess
20
22
  import sys
21
23
  import tempfile
22
24
  import urllib.request
@@ -150,6 +152,47 @@ Outcome:
150
152
  """
151
153
 
152
154
 
155
+ # Fast-lane fallback: the minimal TASK.md variant (sections {0,1,3,4,5,6}; §2 + §7 dropped).
156
+ # Mirrors templates/TASK.fast.md.tmpl's section set (circuit-breaker parity); a deleted
157
+ # templates/ never hard-fails the fast lane. Keeps the trust floor: §3 freeze-flag + Status,
158
+ # §6 GATE RECORD/Outcome, §0 Anchors, §4 red-before-build, §5 Scope.
159
+ _FALLBACK_TASK_FAST = """# TASK: {title}
160
+
161
+ slug: {slug} · created: {date} · stage: {stage}
162
+ autonomy: auto
163
+ phase: ground
164
+ fast: true
165
+
166
+ ## 0 · GROUND
167
+ Touches (files · symbols):
168
+ Anchors the contract cites:
169
+
170
+ ## 1 · SPECIFY
171
+ Feature:
172
+ Must:
173
+ Reject:
174
+ Accept:
175
+ Assumptions: ⚠ <most likely wrong> — why; if wrong: <cost>
176
+
177
+ ## 3 · CONTRACT
178
+ Least-sure flag surfaced at freeze:
179
+ Status: DRAFT
180
+
181
+ ## 4 · TESTS
182
+ Plan:
183
+ Tests live in: `./tests/` · MUST run red before Build.
184
+
185
+ ## 5 · BUILD
186
+ Scope (may touch): `./src/`
187
+
188
+ ## 6 · VERIFY
189
+ Build expectations (from §1 Accept + §3 CONTRACT):
190
+ ### GATE RECORD
191
+ Outcome:
192
+ Reviewed by:
193
+ """
194
+
195
+
153
196
  # --- low-level IO (designed for failure: atomic, no silent clobber) ----------
154
197
 
155
198
  def _now() -> str:
@@ -173,29 +216,59 @@ def _atomic_write(path: Path, text: str) -> None:
173
216
 
174
217
 
175
218
  def _atomic_write_many(writes: list[tuple[Path, str]]) -> None:
176
- """Two-phase commit across several files — design-for-failure for a multi-file write.
177
-
178
- Phase 1 STAGES every (path, text) to a sibling temp file; the realistic IO failures
179
- (disk full, permission denied) surface HERE, before any visible file changes — and on any
180
- failure every staged temp is removed, so NOTHING is committed. Phase 2 then `os.replace`s
181
- each staged temp into place (same-dir renames are atomic and effectively never fail once the
182
- temp is written). This narrows the partial-write window of N independent `_atomic_write`s to
183
- the rename loop, honouring a caller's "any failure -> write nothing" across the whole set.
219
+ """True all-or-nothing commit across N files — design-for-failure for a multi-file write.
220
+
221
+ Phase 1 STAGES every (path, text) to a sibling `.tmp`, flushing + fsync-ing each, so the
222
+ realistic IO failures (disk full, permission denied) surface HERE, before any target changes —
223
+ and on any stage failure every staged temp is removed, so NOTHING is committed. Phase 2 then
224
+ COMMITS each file by renaming any existing target ASIDE to a sibling `.bak`, then `os.replace`-ing
225
+ the staged `.tmp` into place. If ANY commit rename raises, every file already committed is rolled
226
+ back IN REVERSE (remove the landed new file, rename its `.bak` back, or leave it absent) and the
227
+ original error re-raised — so the whole set is all-or-nothing: either every file holds its new
228
+ text, or every file holds its prior content. Restoring is an atomic rename of an already-written
229
+ `.bak` (no content held in memory, no re-write), the cheapest recovery under failing IO. Leftover
230
+ `.tmp`/`.bak` siblings are removed on every exit path.
184
231
  """
185
232
  staged: list[tuple[str, Path]] = []
233
+ committed: list[list] = [] # [path, bak_or_None, new_landed] per committed file
186
234
  try:
187
- for path, text in writes:
235
+ for path, text in writes: # phase 1: stage every temp (fsync'd before any commit)
188
236
  path.parent.mkdir(parents=True, exist_ok=True)
189
237
  fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
238
+ staged.append((tmp, path)) # track BEFORE write so a write/fsync failure still cleans it
190
239
  with os.fdopen(fd, "w", encoding="utf-8") as fh:
191
240
  fh.write(text)
192
- staged.append((tmp, path))
193
- for tmp, path in staged: # phase 2: commit via atomic renames
194
- os.replace(tmp, path)
241
+ fh.flush()
242
+ os.fsync(fh.fileno())
243
+ try:
244
+ for tmp, path in staged: # phase 2: commit via rename-aside
245
+ bak = None
246
+ existed = path.exists()
247
+ if existed:
248
+ fd2, bak = tempfile.mkstemp(dir=str(path.parent), suffix=".bak")
249
+ os.close(fd2)
250
+ committed.append([path, bak, False]) # track .bak NOW so cleanup never leaks it
251
+ if existed:
252
+ os.replace(path, bak) # move the existing target aside
253
+ os.replace(tmp, path) # move the new file in
254
+ committed[-1][2] = True
255
+ except OSError:
256
+ for path, bak, landed in reversed(committed): # roll back, newest-first
257
+ try:
258
+ if landed and path.exists():
259
+ os.unlink(path) # drop the new file we put in
260
+ if bak is not None and not path.exists():
261
+ os.replace(bak, path) # restore the prior target (atomic rename)
262
+ except OSError:
263
+ pass # best-effort restore under already-failing IO
264
+ raise
195
265
  finally:
196
- for tmp, _ in staged: # leftover temps (a failed/aborted stage) never persist
266
+ for tmp, _ in staged: # leftover .tmp (failed/aborted stage) never persists
197
267
  if os.path.exists(tmp):
198
268
  os.unlink(tmp)
269
+ for path, bak, landed in committed: # leftover .bak (success, or orphaned post-rollback)
270
+ if bak is not None and os.path.exists(bak):
271
+ os.unlink(bak)
199
272
 
200
273
 
201
274
  def _templates_dir() -> Path:
@@ -205,13 +278,14 @@ def _templates_dir() -> Path:
205
278
  def _render_template(name: str, **subs: str) -> str:
206
279
  """Load templates/<name>.tmpl and substitute {{key}} tokens.
207
280
 
208
- Falls back to a built-in minimal template only for TASK.md.
281
+ Falls back to a built-in minimal template for TASK.md and the fast-lane TASK.fast.md.
209
282
  """
210
283
  tmpl = _templates_dir() / f"{name}.tmpl"
284
+ _fallbacks = {"TASK.md": _FALLBACK_TASK, "TASK.fast.md": _FALLBACK_TASK_FAST}
211
285
  if tmpl.exists():
212
286
  text = tmpl.read_text(encoding="utf-8")
213
- elif name == "TASK.md":
214
- text = _FALLBACK_TASK.replace("{title}", "{{title}}").replace(
287
+ elif name in _fallbacks:
288
+ text = _fallbacks[name].replace("{title}", "{{title}}").replace(
215
289
  "{slug}", "{{slug}}").replace("{date}", "{{date}}").replace("{stage}", "{{stage}}")
216
290
  else:
217
291
  text = ""
@@ -238,12 +312,238 @@ def _require_root() -> Path:
238
312
  return root
239
313
 
240
314
 
315
+ def _migrate_state(state: dict) -> dict:
316
+ """Forward-migrate a single-active state to the multi-active schema (team-collaboration
317
+ foundation). PURE · idempotent · TOTAL · never raises · no I/O.
318
+
319
+ A state lacking the `active_milestones` key gains it — DERIVED from the scalar
320
+ `active_milestone` (grandfather-by-missing-key, mirroring `_setup_locked`): None -> [],
321
+ "x" -> ["x"]. A per-milestone active-task map `active_tasks` is added; the old global
322
+ `active_task` is placed under its owning active milestone only when it genuinely belongs
323
+ there, else it stays as the top-level scalar fallback (orphan rule, FROZEN decision (a)).
324
+ The scalar `active_milestone` / `active_task` keys are KEPT as the N<=1 mirror so the
325
+ not-yet-routed readers keep working. An already-migrated state (key present) is returned
326
+ unchanged — never re-derived, never clobbered. Corrupt parsing stays the loader's job.
327
+
328
+ PURE in the observable sense: the caller's dict is NEVER mutated — a state that needs
329
+ migrating is upgraded on a fresh top-level copy (nested objects are shared but only read)."""
330
+ if not isinstance(state, dict) or "active_milestones" in state:
331
+ return state
332
+ migrated = dict(state)
333
+ active_ms = migrated.get("active_milestone")
334
+ migrated["active_milestones"] = [] if active_ms is None else [active_ms]
335
+ active_task = migrated.get("active_task")
336
+ tasks = migrated.get("tasks") or {}
337
+ owns = (active_ms is not None and active_task is not None
338
+ and isinstance(tasks.get(active_task), dict)
339
+ and tasks[active_task].get("milestone") == active_ms)
340
+ migrated["active_tasks"] = {active_ms: active_task} if owns else {}
341
+ return migrated
342
+
343
+
344
+ # --- active milestone/task accessor seam (multi-active foundation) -----------
345
+ #
346
+ # Every engine call site reads & writes the active milestone(s)/task through these
347
+ # four helpers, so multi-active SEMANTICS can land in one place later. Today they
348
+ # preserve single-active behavior exactly: reads return the scalar mirror, writes
349
+ # keep the scalar AND the new active_milestones/active_tasks structures in sync.
350
+
351
+ def _active_milestone(state: dict) -> str | None:
352
+ """The primary active milestone — the N<=1 scalar mirror (== active_milestones[0])."""
353
+ return state.get("active_milestone")
354
+
355
+
356
+ def _active_task(state: dict, milestone: str | None = None) -> str | None:
357
+ """The active task: per-milestone when `milestone` is given (partial-state -> None),
358
+ else the global/primary scalar active task. Total — never raises."""
359
+ if milestone is None:
360
+ return state.get("active_task")
361
+ return (state.get("active_tasks") or {}).get(milestone)
362
+
363
+
364
+ def _set_active_milestone(state: dict, slug: str | None) -> None:
365
+ """Set the primary active milestone, keeping `active_milestones` consistent (N<=1 sync)."""
366
+ state["active_milestone"] = slug
367
+ state["active_milestones"] = [] if slug is None else [slug]
368
+
369
+
370
+ def _set_active_task(state: dict, slug: str | None, milestone: str | None = None) -> None:
371
+ """Set the active task, keeping the scalar mirror AND the per-milestone map in sync.
372
+ With no owning active milestone the active task is scalar-only (the migration's orphan
373
+ rule); clearing (slug is None) pops the milestone's entry."""
374
+ state["active_task"] = slug
375
+ ms = milestone if milestone is not None else _active_milestone(state)
376
+ tasks_map = state.setdefault("active_tasks", {})
377
+ if ms is None:
378
+ return
379
+ if slug is None:
380
+ tasks_map.pop(ms, None)
381
+ else:
382
+ tasks_map[ms] = slug
383
+
384
+
385
+ def _activate_milestone(state: dict, slug: str) -> None:
386
+ """Add a milestone to the active SET (idempotent) and make it the primary focus,
387
+ syncing the scalar active_task to that milestone's entry. Does NOT remove other members
388
+ (this is how a user reaches N>=2 active milestones)."""
389
+ ms_list = state.setdefault("active_milestones", [])
390
+ if slug not in ms_list:
391
+ ms_list.append(slug)
392
+ state["active_milestone"] = slug
393
+ state["active_task"] = (state.get("active_tasks") or {}).get(slug)
394
+
395
+
396
+ def _deactivate_milestone(state: dict, slug: str) -> None:
397
+ """Remove a milestone from the active SET, pop its active-task entry, and (if it was the
398
+ primary) repoint the primary to the most-recent remaining member (or None when empty)."""
399
+ ms_list = state.setdefault("active_milestones", [])
400
+ if slug in ms_list:
401
+ ms_list.remove(slug)
402
+ (state.setdefault("active_tasks", {})).pop(slug, None)
403
+ if state.get("active_milestone") == slug:
404
+ new = ms_list[-1] if ms_list else None
405
+ state["active_milestone"] = new
406
+ state["active_task"] = (state.get("active_tasks") or {}).get(new) if new else None
407
+
408
+
409
+ def _git_config(key: str) -> str | None:
410
+ """Read one `git config --get <key>`, STRICTLY fail-soft: the engine's FIRST git call,
411
+ so it never raises, never hangs, never shells. Returns the trimmed value, or None when
412
+ git is absent / errors / times out / the value is empty."""
413
+ if shutil.which("git") is None:
414
+ return None
415
+ try:
416
+ out = subprocess.run(
417
+ ["git", "config", "--get", key],
418
+ capture_output=True, text=True, timeout=2,
419
+ ).stdout.strip()
420
+ except (OSError, subprocess.SubprocessError, ValueError):
421
+ # OSError: git vanished between which() and run() / spawn error · SubprocessError:
422
+ # TimeoutExpired · ValueError: a non-UTF-8 config value (latin-1 legacy name) makes
423
+ # text=True decoding raise UnicodeDecodeError (a ValueError) — all fail soft to None.
424
+ return None
425
+ return out or None
426
+
427
+
428
+ def _os_user() -> str:
429
+ """The guaranteed non-empty OS floor. getpass.getuser() reads LOGNAME/USER/... then
430
+ falls back to the passwd database — but in a bare container (no env var AND no passwd
431
+ entry) CPython raises KeyError (OSError only on 3.13+). Catch broadly and return a
432
+ sentinel so _whoami stays TOTAL: it always yields a non-empty name, never crashes."""
433
+ try:
434
+ return getpass.getuser() or "unknown"
435
+ except (KeyError, OSError):
436
+ return "unknown"
437
+
438
+
439
+ def _whoami(state: dict) -> dict:
440
+ """Resolve the current git-native ACTOR -> {name, email, source}. Priority:
441
+ (1) an `actor_override` (whoami --set) with a non-blank name -> source 'override';
442
+ (2) `git config user.name`/`user.email` -> source 'git';
443
+ (3) the OS user (_os_user) -> source 'os', the guaranteed non-empty floor.
444
+ Total: always returns a dict with a non-empty name; `email` may be None."""
445
+ ov = state.get("actor_override")
446
+ if ov and (ov.get("name") or "").strip():
447
+ return {"name": ov["name"], "email": ov.get("email"), "source": "override"}
448
+ name = _git_config("user.name")
449
+ if name:
450
+ return {"name": name, "email": _git_config("user.email"), "source": "git"}
451
+ return {"name": _os_user(), "email": None, "source": "os"}
452
+
453
+
454
+ def _actor_stamp(state: dict) -> dict:
455
+ """The SINGLE source of the structured-actor stamp every engine-WRITTEN human action
456
+ records — lock · gate · milestone-done · release (user-identity actor-stamping). It IS
457
+ `_whoami(state)`: a TOTAL {name,email,source} (always a non-empty name), so a stamp can
458
+ never fail or block a write. Descriptive only — no command's decision reads it."""
459
+ return _whoami(state)
460
+
461
+
462
+ def _render_actor_line(state: dict) -> str:
463
+ """Render the actor stamp as one human-readable line: name, an optional angle-bracketed
464
+ email, then the source in parens — used on the RELEASES.md row (no state.json write)."""
465
+ a = _actor_stamp(state)
466
+ email = f" <{a['email']}>" if a.get("email") else ""
467
+ return f"{a['name']}{email} ({a['source']})"
468
+
469
+
470
+ def _parse_actor_arg(s: str) -> dict:
471
+ """Parse an `assign --owner`/`--assignee` value into a {name, email, source: "assigned"}
472
+ actor (ownership-assignment). "Name <email>" -> both; a bare "Name" -> email None. TOTAL:
473
+ a malformed value (no closing bracket) never raises — the whole stripped string is the name.
474
+ `source` is "assigned" — a human typed this name (not git-resolved nor an ADD override)."""
475
+ m = re.match(r"^\s*(.*?)\s*<([^>]*)>\s*$", s)
476
+ if m:
477
+ return {"name": m.group(1), "email": m.group(2) or None, "source": "assigned"}
478
+ return {"name": s.strip(), "email": None, "source": "assigned"}
479
+
480
+
481
+ def _actor_matches(rec_actor: dict | None, me: dict) -> bool:
482
+ """Does a recorded owner/assignee actor identify the SAME person as `me` (multi-active-UX)?
483
+ Email-first (the stabler key): when BOTH carry a non-empty email, emails decide; otherwise
484
+ fall back to name-equality. Both comparisons are stripped + case-insensitive. TOTAL — a None,
485
+ non-dict, or blank-name record returns False (an unowned/garbage slot is no one's)."""
486
+ if not isinstance(rec_actor, dict):
487
+ return False
488
+ rec_name = (rec_actor.get("name") or "").strip()
489
+ if not rec_name:
490
+ return False
491
+ rec_email = (rec_actor.get("email") or "").strip()
492
+ me_email = (me.get("email") or "").strip()
493
+ if rec_email and me_email:
494
+ return rec_email.lower() == me_email.lower()
495
+ return rec_name.lower() == (me.get("name") or "").strip().lower()
496
+
497
+
498
+ def _my_work(state: dict, me: dict) -> list[dict]:
499
+ """The "my work" lens (multi-active-UX): across ALL active milestones, the NOT-done tasks
500
+ whose owner OR assignee is `me`. Returns ordered rows {slug, milestone, phase, role} with
501
+ role in {owner, assignee, both}, sorted by active-milestone order then slug. PURE · no I/O."""
502
+ active = list(state.get("active_milestones") or [])
503
+ active_set = set(active)
504
+ tasks = state.get("tasks") if isinstance(state.get("tasks"), dict) else {}
505
+ rows: list[dict] = []
506
+ for slug, t in tasks.items():
507
+ if not isinstance(t, dict) or t.get("milestone") not in active_set or _task_done(t):
508
+ continue
509
+ owns = _actor_matches(t.get("owner"), me)
510
+ assigned = _actor_matches(t.get("assignee"), me)
511
+ if not (owns or assigned):
512
+ continue
513
+ role = "both" if owns and assigned else ("owner" if owns else "assignee")
514
+ rows.append({"slug": slug, "milestone": t.get("milestone"),
515
+ "phase": t.get("phase"), "role": role})
516
+ order = {m: i for i, m in enumerate(active)}
517
+ rows.sort(key=lambda r: (order.get(r["milestone"], len(order)), r["slug"]))
518
+ return rows
519
+
520
+
521
+ # A git conflict marker BEGINS a line with 7 of `<`, `=`, or `>` (`(?m)^…`). An unresolved
522
+ # merge writes these into state.json, making it invalid JSON; the line-anchor keeps a
523
+ # legitimate value (always on an INDENTED JSON line) from false-tripping the guard.
524
+ _CONFLICT_MARKER_RE = re.compile(r"(?m)^(<{7}|={7}|>{7})")
525
+
526
+
527
+ def _state_text_or_die(root: Path) -> str:
528
+ """Read state.json's raw text, failing CLOSED with a merge-SPECIFIC `state_conflicted`
529
+ message when it carries git conflict markers (an unresolved merge — the major's #1 failure
530
+ mode). A genuine read OSError is NOT swallowed: it propagates to the caller, which maps it
531
+ to its own existing code (state_invalid / no_state). The guard only READS — never writes."""
532
+ text = (root / STATE_FILE).read_text(encoding="utf-8")
533
+ if _CONFLICT_MARKER_RE.search(text):
534
+ _die(f"state_conflicted: {root / STATE_FILE} has unresolved git merge markers "
535
+ f"(<<<<<<< / ======= / >>>>>>>) — resolve them (or "
536
+ f"`git checkout --ours/--theirs {STATE_FILE}`), then run `add.py doctor` to verify")
537
+ return text
538
+
539
+
241
540
  def load_state(root: Path) -> dict:
242
- """Load + parse state.json, failing CLOSED. A corrupt or unreadable state file
243
- dies with a clean 'state_invalid' message (never a raw traceback), so every
244
- command that loads state degrades gracefully (design-for-failure)."""
541
+ """Load + parse state.json, failing CLOSED. A git-conflicted file dies with a merge-specific
542
+ 'state_conflicted'; any other corrupt/unreadable file dies with a clean 'state_invalid'
543
+ message (never a raw traceback), so every command that loads state degrades gracefully
544
+ (design-for-failure). The parsed state is forward-migrated to the multi-active schema."""
245
545
  try:
246
- return json.loads((root / STATE_FILE).read_text(encoding="utf-8"))
546
+ return _migrate_state(json.loads(_state_text_or_die(root)))
247
547
  except (json.JSONDecodeError, OSError) as e:
248
548
  _die(f"state_invalid: {root / STATE_FILE} is corrupt or unreadable "
249
549
  f"({e.__class__.__name__}) — restore it from git or a backup")
@@ -252,12 +552,13 @@ def load_state(root: Path) -> dict:
252
552
  def _load_state_for_json() -> tuple[Path, dict]:
253
553
  """Fail-closed state load for `--json` paths: a missing project or unparseable
254
554
  state.json -> `no_state` on stderr + exit 1, with EMPTY stdout (never a partial
255
- JSON object a harness might parse). Built from State only — reads no docs/ chapter."""
555
+ JSON object a harness might parse). Built from State only — reads no docs/ chapter.
556
+ The parsed state is forward-migrated to the multi-active schema before it is returned."""
256
557
  root = find_root()
257
558
  if root is None:
258
559
  _die("no_state")
259
560
  try:
260
- return root, json.loads((root / STATE_FILE).read_text(encoding="utf-8"))
561
+ return root, _migrate_state(json.loads(_state_text_or_die(root)))
261
562
  except (json.JSONDecodeError, OSError):
262
563
  _die("no_state")
263
564
 
@@ -284,6 +585,80 @@ def _setup_locked(state: dict) -> bool:
284
585
  return ("setup" not in state) or (state["setup"].get("locked") is True)
285
586
 
286
587
 
588
+ def _milestone_confirmed(state: dict, mslug: str) -> bool:
589
+ """True when milestone `mslug` is confirmed — i.e. the new-task gate is OPEN.
590
+
591
+ Mirrors `_setup_locked` one level down. A milestone record with NO "confirmed" key is
592
+ GRANDFATHERED-confirmed: every milestone created WITHOUT `--await-confirm` (and every
593
+ pre-existing one) is never gated. Opt-in: `new-milestone --await-confirm` seeds confirmed:false,
594
+ so the gate is active in exactly one case: the record is present AND confirmed is False. An
595
+ unknown milestone is treated as confirmed here (existence is cmd_new_task's separate check)."""
596
+ m = (state.get("milestones") or {}).get(mslug)
597
+ if not isinstance(m, dict) or "confirmed" not in m:
598
+ return True
599
+ return m["confirmed"] is True
600
+
601
+
602
+ def _section_unfilled(md_text: str, header: str) -> bool:
603
+ """True iff the `header` section is PRESENT but UNFILLED — empty (no real bullet) or
604
+ still a `<…>` template placeholder. ABSENT section -> False (grandfathered legacy);
605
+ a filled section (>=1 real bullet, no `<…>`) -> False. Pure predicate — the shared
606
+ placeholder test the fill gates use (contract-fill at confirm; build-expectations at build)."""
607
+ body, in_sec, present = [], False, False
608
+ for ln in md_text.splitlines():
609
+ if ln.startswith(header):
610
+ in_sec, present = True, True
611
+ continue
612
+ if in_sec:
613
+ if ln.startswith("#"): # ANY next header (## or ###) ends our section
614
+ break
615
+ if ln.lstrip().startswith(">"): # skip blockquote GUIDANCE — it is not content
616
+ continue
617
+ body.append(ln)
618
+ if not present:
619
+ return False # absent -> grandfather
620
+ text = "\n".join(body).strip()
621
+ if not text:
622
+ return True # present but empty
623
+ return bool(re.search(r"<[^>\n]+>", text)) # a <…> placeholder remains
624
+
625
+
626
+ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None:
627
+ """Write-back (gate-record-writeback): mirror the resolved gate verdict into the task's
628
+ §6 `### GATE RECORD`, so the file and state.json never silently diverge (Finding C). Runs
629
+ for EVERY task — the write is ADDITIVE and never refuses, so (unlike the two refusal gates)
630
+ it needs no `--await-confirm` opt-in to protect the census. GRANDFATHER is the safety: a
631
+ GATE RECORD line is rewritten ONLY while it still holds a `<…>` placeholder; a resolved
632
+ (hand-filled) line is byte-untouched. No GATE RECORD block / no placeholder line / an
633
+ unreadable file -> silent no-op, the file stays byte-identical. Called AFTER save_state —
634
+ state is the source of truth; the file only mirrors it, so a write fault never loses a verdict."""
635
+ f = root / "tasks" / slug / "TASK.md"
636
+ try:
637
+ text = f.read_text(encoding="utf-8")
638
+ except OSError:
639
+ return # unreadable -> no-op (never blocks the gate)
640
+ if "### GATE RECORD" not in text:
641
+ return # nothing to mirror into
642
+ actor = _actor_stamp(state)
643
+ today = date.today().isoformat()
644
+ # each rule matches ONLY a line still carrying a `<…>` placeholder -> grandfather a resolved line.
645
+ rules = [
646
+ (r"(?m)^(Outcome:[ \t]*)<[^>\n]*>.*$", f"Outcome: {outcome}"),
647
+ (r"(?m)^Reviewed by:[ \t]*.*<[^>\n]*>.*$",
648
+ f"Reviewed by: {actor['name']} · date: {today}"),
649
+ ]
650
+ if outcome == "RISK-ACCEPTED":
651
+ w = ((state.get("tasks") or {}).get(slug) or {}).get("waiver") or {}
652
+ rules.append((r"(?m)^If RISK-ACCEPTED ->.*<[^>\n]*>.*$",
653
+ f"If RISK-ACCEPTED -> owner: {w.get('owner', '?')} · "
654
+ f"ticket: {w.get('ticket', '?')} · expires: {w.get('expires', '?')}"))
655
+ new = text
656
+ for pat, repl in rules:
657
+ new = re.sub(pat, repl, new, count=1)
658
+ if new != text: # no-op = no write (mtime stable)
659
+ _atomic_write(f, new)
660
+
661
+
287
662
  def _die(msg: str, code: int = 1) -> None:
288
663
  print(f"add: error: {msg}", file=sys.stderr)
289
664
  raise SystemExit(code)
@@ -453,6 +828,8 @@ def cmd_init(args: argparse.Namespace) -> None:
453
828
  "stage": args.stage,
454
829
  "active_task": None,
455
830
  "active_milestone": None,
831
+ "active_milestones": [],
832
+ "active_tasks": {},
456
833
  "tasks": {},
457
834
  "milestones": {},
458
835
  "created": _now(),
@@ -499,9 +876,15 @@ def cmd_new_task(args: argparse.Namespace) -> None:
499
876
  _die(f"task '{slug}' already exists (use --force to overwrite TASK.md)")
500
877
 
501
878
  # link to a milestone (explicit, or the active one) — validate before any write
502
- milestone = getattr(args, "milestone", None) or state.get("active_milestone")
879
+ milestone = getattr(args, "milestone", None) or _active_milestone(state)
503
880
  if milestone and milestone not in state.get("milestones", {}):
504
881
  _die("unknown_milestone")
882
+ # confirm-parent gate (OPT-IN): a task may not be detailed before its parent milestone is
883
+ # human-confirmed — but ONLY when the milestone opted in via `new-milestone --await-confirm`.
884
+ # validate-then-write — refuse BEFORE any scaffold/state mutation. A milestone with no
885
+ # `confirmed` key (non-flag + pre-existing) is grandfathered (mirrors the setup-lock).
886
+ if milestone and not _milestone_confirmed(state, milestone):
887
+ _die(f"milestone_unconfirmed: confirm it first — add.py milestone-confirm {milestone}")
505
888
  depends_on = _parse_deps(getattr(args, "depends_on", None))
506
889
 
507
890
  # SEED (--from-delta): resolve a prior task's FIRST open SPEC delta into THIS task.
@@ -509,16 +892,25 @@ def cmd_new_task(args: argparse.Namespace) -> None:
509
892
  # seeded flip NOW (before any write); the slug-free check above has already passed, so
510
893
  # the only writes below are the new TASK.md, then the prior flip, then state.
511
894
  from_delta = getattr(args, "from_delta", None)
895
+ match = getattr(args, "match", None)
896
+ if match and not from_delta: # --match targets the PRIOR's delta
897
+ _die("match_requires_from_delta: --match needs --from-delta <prior> (it selects the "
898
+ "prior task's open SPEC delta to seed)")
512
899
  feature_override = prior_md = flipped_prior = None
513
900
  if from_delta:
514
901
  prior = _resolve_task(state, from_delta) # unknown prior -> _die
515
902
  prior_md = root / "tasks" / prior / "TASK.md"
516
903
  prior_text = prior_md.read_text(encoding="utf-8")
517
- delta_text = _first_open_spec_text(prior_text)
518
- if delta_text is None:
904
+ status, idx, delta_text = _select_spec_delta(prior_text, match)
905
+ if status == "no_open":
519
906
  _die(f"no_open_spec_delta: task '{prior}' has no open SPEC delta to seed")
907
+ if status == "no_match":
908
+ _die(f"no_matching_spec_delta: no open SPEC delta in '{prior}' matches --match '{match}'")
909
+ if status == "ambiguous":
910
+ _die(f"ambiguous_spec_match: --match '{match}' matches multiple open SPEC deltas in "
911
+ f"'{prior}' — narrow it")
520
912
  feature_override = f"{delta_text} (from {prior} spec-delta)"
521
- flipped_prior = _resolve_spec_delta(prior_text, "seeded", pointer=slug)
913
+ flipped_prior = _resolve_spec_delta(prior_text, "seeded", pointer=slug, line_index=idx)
522
914
 
523
915
  (tdir / "tests").mkdir(parents=True, exist_ok=True)
524
916
  (tdir / "src").mkdir(parents=True, exist_ok=True)
@@ -526,15 +918,21 @@ def cmd_new_task(args: argparse.Namespace) -> None:
526
918
  # inherit the project's DECLARED autonomy default (task init-auto-default) — fail-SAFE:
527
919
  # absent -> auto, garbled -> conservative; the posture is project-scoped, not hardcoded.
528
920
  autonomy = _project_autonomy(root)
921
+ # fast lane (fast-new-task-flag): --fast scaffolds the MINIMAL template instead of the full one.
922
+ # The human opts in explicitly (the engine never guesses ceremony); the freeze floor is held by
923
+ # the freeze-before-build gate's fast arm (cmd_advance), so the lighter shape never drops the trust seam.
924
+ fast = bool(getattr(args, "fast", False))
529
925
  rendered = _render_template(
530
- "TASK.md", title=title, slug=slug, date=date.today().isoformat(),
926
+ "TASK.fast.md" if fast else "TASK.md",
927
+ title=title, slug=slug, date=date.today().isoformat(),
531
928
  stage=state["stage"], autonomy=autonomy)
532
929
  if feature_override: # pre-fill §1 from the seeded delta
533
930
  rendered = re.sub(r"(?m)^Feature:.*$",
534
931
  lambda _m: f"Feature: {feature_override}", rendered, count=1)
535
- _atomic_write(task_md, rendered)
932
+ seed_writes: list[tuple[Path, str]] = [(task_md, rendered)]
536
933
  if flipped_prior is not None: # consume the source delta -> seeded
537
- _atomic_write(prior_md, flipped_prior)
934
+ seed_writes.append((prior_md, flipped_prior))
935
+ _atomic_write_many(seed_writes) # new TASK.md + consumed source as one commit
538
936
  if _project_autonomy_token(root) == "?":
539
937
  print("warning: garbled_project_autonomy — PROJECT.md declares an unrecognized "
540
938
  f"autonomy token; new task seeded fail-safe '{autonomy}' "
@@ -551,7 +949,9 @@ def cmd_new_task(args: argparse.Namespace) -> None:
551
949
  }
552
950
  if from_delta:
553
951
  state["tasks"][slug]["from_delta"] = from_delta # lineage: seeded from <prior>
554
- state["active_task"] = slug
952
+ if fast:
953
+ state["tasks"][slug]["fast"] = True # durable lane marker (absent == not-fast)
954
+ _set_active_task(state, slug, milestone)
555
955
  save_state(root, state)
556
956
  print(f"created task '{slug}' -> {task_md}")
557
957
  if milestone:
@@ -579,11 +979,19 @@ def cmd_drop_delta(args: argparse.Namespace) -> None:
579
979
  state = load_state(root)
580
980
  slug = _resolve_task(state, args.slug) # unknown task -> _die
581
981
  task_md = root / "tasks" / slug / "TASK.md"
582
- new_text = _resolve_spec_delta(task_md.read_text(encoding="utf-8"), "dropped")
583
- if new_text is None:
982
+ text = task_md.read_text(encoding="utf-8")
983
+ match = getattr(args, "match", None)
984
+ status, idx, _disp = _select_spec_delta(text, match)
985
+ if status == "no_open":
584
986
  _die(f"no_open_spec_delta: task '{slug}' has no open SPEC delta to drop")
987
+ if status == "no_match":
988
+ _die(f"no_matching_spec_delta: no open SPEC delta in '{slug}' matches --match '{match}'")
989
+ if status == "ambiguous":
990
+ _die(f"ambiguous_spec_match: --match '{match}' matches multiple open SPEC deltas in "
991
+ f"'{slug}' — narrow it")
992
+ new_text = _resolve_spec_delta(text, "dropped", line_index=idx)
585
993
  _atomic_write(task_md, new_text)
586
- print(f"dropped the first open SPEC delta in '{slug}' -> [SPEC · dropped]")
994
+ print(f"dropped the {'matched' if match else 'first'} open SPEC delta in '{slug}' -> [SPEC · dropped]")
587
995
  print(_next_footer(root, state))
588
996
 
589
997
 
@@ -618,7 +1026,7 @@ def _archived_task_slugs(state: dict) -> set[str]:
618
1026
 
619
1027
 
620
1028
  def _resolve_task(state: dict, slug: str | None) -> str:
621
- slug = slug or state.get("active_task")
1029
+ slug = slug or _active_task(state)
622
1030
  if not slug:
623
1031
  _die("no task specified and no active task set")
624
1032
  if slug not in state["tasks"]:
@@ -659,7 +1067,29 @@ def cmd_advance(args: argparse.Namespace) -> None:
659
1067
  # freezes — the unmarked predecessors are never retro-redded). REFUSE writes
660
1068
  # nothing (fail-closed); below the build boundary the flag is never checked.
661
1069
  if nxt == "build":
1070
+ # the OPTED-IN crossing guards (fast-lane + flow-enforcement): a task whose PARENT
1071
+ # milestone opted into --await-confirm is held to the trust floor at tests->build. A
1072
+ # task under a plain / no milestone is never gated (every existing advance-to-build flow
1073
+ # stays green). validate-then-write — every refusal runs BEFORE the tripwire/scope
1074
+ # snapshots below, writing nothing; the task stays at `tests`.
1075
+ _ms = state["tasks"][slug].get("milestone")
1076
+ _optin = bool(_ms) and (state.get("milestones") or {}).get(_ms, {}).get("await_confirm") is True
662
1077
  raw3 = _raw_phase_bodies(root, slug).get(3, "")
1078
+ # freeze-before-build gate (fast-lane): "collapse-never-skip" made REAL — the freeze is
1079
+ # engine-enforced for an opted-in milestone OR any --fast task (the fast arm, fast-new-task-flag:
1080
+ # a fast task is held to the floor under ANY milestone, so the lighter lane never drops the
1081
+ # trust seam). PRECEDES build-expectations: you freeze §3 before pre-declaring §6's outcomes.
1082
+ _freeze_gated = _optin or state["tasks"][slug].get("fast") is True
1083
+ if _freeze_gated and not _contract_frozen(raw3):
1084
+ _die("contract_not_frozen: freeze §3 before crossing into build — approve "
1085
+ f"the contract in {slug}'s TASK.md (Status: FROZEN @ vN)")
1086
+ # build-expectations gate (flow-enforcement): an opted-in task may not enter build until
1087
+ # its §6 `### Build expectations` are pre-declared — so verify checks the build is RIGHT,
1088
+ # not just green. Same opt-in switch as the contract-fill gate, one level out.
1089
+ if _optin:
1090
+ if _section_unfilled(_raw_phase_bodies(root, slug).get(6, ""), "### Build expectations"):
1091
+ _die("build_expectations_unfilled: fill the §6 '### Build expectations' block "
1092
+ f"of {slug}'s TASK.md before crossing into build")
663
1093
  if _contract_frozen(raw3):
664
1094
  if not _flag_well_formed(raw3):
665
1095
  _die("unflagged_freeze: a frozen §3 must surface a well-formed "
@@ -699,6 +1129,15 @@ def cmd_advance(args: argparse.Namespace) -> None:
699
1129
  _sync_task_marker(root, slug, nxt)
700
1130
  save_state(root, state)
701
1131
  print(f"task '{slug}' phase {cur} -> {nxt}")
1132
+ if nxt == "observe":
1133
+ # OBSERVE is where this loop's lessons get captured (TASK.md §7) — suggest routing
1134
+ # them into PROJECT.md right away (a per-task fold is engine-legal; otherwise the
1135
+ # lessons sit unconsolidated until milestone close). Additive: fires only at this
1136
+ # one transition, and the human still decides whether/when to run it. The verb word
1137
+ # is interpolated via _FOLD_VERB so the domain wording-lint sees no bare slang.
1138
+ print(" note: record the lessons this loop taught the foundation in §7 "
1139
+ "OBSERVE, then update PROJECT.md when ready:")
1140
+ print(f" add.py {_FOLD_VERB} --task {slug} (review first: add.py deltas)")
702
1141
  print(_next_footer(root, state))
703
1142
 
704
1143
 
@@ -835,8 +1274,10 @@ def cmd_gate(args: argparse.Namespace) -> None:
835
1274
  state["tasks"][slug]["phase"] = "done"
836
1275
  _sync_task_marker(root, slug, "done")
837
1276
  state["tasks"][slug]["gate"] = args.outcome
1277
+ state["tasks"][slug]["gate_actor"] = _actor_stamp(state) # WHO recorded the verdict (every outcome)
838
1278
  state["tasks"][slug]["updated"] = _now()
839
1279
  save_state(root, state)
1280
+ _stamp_gate_record(root, state, slug, args.outcome) # mirror the verdict into §6 (Finding C)
840
1281
  print(f"task '{slug}' gate -> {args.outcome}")
841
1282
  # the engine-sourced next step (next-footer-engine): a completing gate hands off to the
842
1283
  # state arm; HARD-STOP routes to "resolve HARD-STOP …" — converging the old bespoke line.
@@ -1002,7 +1443,8 @@ def cmd_lock(args: argparse.Namespace) -> None:
1002
1443
  who = args.by or getpass.getuser()
1003
1444
  when = _now()
1004
1445
  # ONE atomic write — no partial lock state.
1005
- state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers}
1446
+ state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers,
1447
+ "actor": _actor_stamp(state)} # structured actor alongside the free-text locked_by
1006
1448
  save_state(root, state)
1007
1449
  if getattr(args, "json", False):
1008
1450
  print(json.dumps(
@@ -1013,6 +1455,92 @@ def cmd_lock(args: argparse.Namespace) -> None:
1013
1455
  print(_next_footer(root, state))
1014
1456
 
1015
1457
 
1458
+ def cmd_whoami(args: argparse.Namespace) -> None:
1459
+ """Show / set / unset the git-native ACTOR — the identity every human-owned stamp reads.
1460
+ No flags: print the resolved actor (override -> git -> os). --name/--email: store an
1461
+ override. --unset: clear it. Validates before mutating (a reject leaves state unchanged)."""
1462
+ root = _require_root()
1463
+ state = load_state(root)
1464
+ if args.unset:
1465
+ if "actor_override" not in state:
1466
+ _die("no_actor_override")
1467
+ del state["actor_override"]
1468
+ save_state(root, state)
1469
+ elif args.name is not None:
1470
+ if not args.name.strip():
1471
+ _die("actor_name_blank")
1472
+ state["actor_override"] = {"name": args.name, "email": args.email or None}
1473
+ save_state(root, state)
1474
+ who = _whoami(state)
1475
+ if getattr(args, "json", False):
1476
+ print(json.dumps(who, separators=(",", ":")))
1477
+ return
1478
+ email = f" <{who['email']}>" if who.get("email") else ""
1479
+ print(f"actor : {who['name']}{email} (source: {who['source']})")
1480
+
1481
+
1482
+ def _ownership_record(state: dict, slug: str) -> dict | None:
1483
+ """Resolve the record a slug names for ownership — a TASK first, else a MILESTONE
1484
+ (tasks win, matching cmd_use/report precedent). None if neither exists."""
1485
+ rec = state.get("tasks", {}).get(slug)
1486
+ if rec is not None:
1487
+ return rec
1488
+ return state.get("milestones", {}).get(slug)
1489
+
1490
+
1491
+ def cmd_assign(args: argparse.Namespace) -> None:
1492
+ """Assign an OWNER (accountable) and/or ASSIGNEE (working it) to a task or milestone
1493
+ (ownership-assignment). No role flag -> set BOTH to the resolved self (_whoami); --owner/
1494
+ --assignee "Name <email>" -> set that role only (partial update). Descriptive, never a gate.
1495
+ Validate-before-mutate: a reject leaves state.json byte-identical (no partial write)."""
1496
+ root = _require_root()
1497
+ state = load_state(root)
1498
+ rec = _ownership_record(state, args.slug)
1499
+ if rec is None:
1500
+ _die("unknown_slug")
1501
+ # parse + validate ALL flags BEFORE the first write — a blank name is rejected on the
1502
+ # PARSED name (so "<>" or " <a@x.io>", whose name parses empty, is caught like " ").
1503
+ parsed_owner = _parse_actor_arg(args.owner) if args.owner is not None else None
1504
+ parsed_assignee = _parse_actor_arg(args.assignee) if args.assignee is not None else None
1505
+ if parsed_owner is not None and not parsed_owner["name"].strip():
1506
+ _die("owner_name_blank")
1507
+ if parsed_assignee is not None and not parsed_assignee["name"].strip():
1508
+ _die("assignee_name_blank")
1509
+ if parsed_owner is None and parsed_assignee is None:
1510
+ who = _whoami(state)
1511
+ rec["owner"] = dict(who)
1512
+ rec["assignee"] = dict(who)
1513
+ else:
1514
+ if parsed_owner is not None:
1515
+ rec["owner"] = parsed_owner
1516
+ if parsed_assignee is not None:
1517
+ rec["assignee"] = parsed_assignee
1518
+ save_state(root, state)
1519
+ parts = [f"{role}: {rec[role]['name']}" for role in ("owner", "assignee") if role in rec]
1520
+ print(f"assigned {args.slug} -> " + " · ".join(parts))
1521
+
1522
+
1523
+ def cmd_unassign(args: argparse.Namespace) -> None:
1524
+ """Clear the OWNER and/or ASSIGNEE of a task or milestone (ownership-assignment). No role
1525
+ flag -> clear BOTH; --owner/--assignee -> clear that role only. Reject not_assigned if the
1526
+ targeted role(s) are already absent. Validate-before-mutate (a reject changes nothing)."""
1527
+ root = _require_root()
1528
+ state = load_state(root)
1529
+ rec = _ownership_record(state, args.slug)
1530
+ if rec is None:
1531
+ _die("unknown_slug")
1532
+ if args.owner or args.assignee:
1533
+ roles = [r for r in ("owner", "assignee") if getattr(args, r)]
1534
+ else:
1535
+ roles = ["owner", "assignee"]
1536
+ if not all(r in rec for r in roles): # every targeted role must exist (frozen: "both must exist")
1537
+ _die("not_assigned")
1538
+ for r in roles:
1539
+ rec.pop(r, None)
1540
+ save_state(root, state)
1541
+ print(f"unassigned {args.slug} ({', '.join(roles)})")
1542
+
1543
+
1016
1544
  def _has_production_roadmap(state: dict) -> bool:
1017
1545
  """True iff ≥1 milestone in state has stage == "production" (STATUS-AGNOSTIC).
1018
1546
  The single source of the stage-graduation floor (v22 graduate-guide): the guard counts
@@ -1057,20 +1585,26 @@ def cmd_status(args: argparse.Namespace) -> None:
1057
1585
  members = [t for t in tasks.values() if t.get("milestone") == mslug]
1058
1586
  ms_list.append({"slug": mslug, "status": m.get("status", "active"),
1059
1587
  "done": sum(1 for t in members if _task_done(t)),
1060
- "total": len(members)})
1588
+ "total": len(members),
1589
+ "owner": m.get("owner"), "assignee": m.get("assignee")})
1061
1590
  grad_ready, grad_met, grad_total = _graduation_ready(root, state)
1062
1591
  print(json.dumps({
1063
1592
  "project": state.get("project"), "stage": state.get("stage"),
1064
- "active_task": state.get("active_task"),
1593
+ "actor": _whoami(state),
1594
+ "active_task": _active_task(state),
1595
+ "active_milestones": list(state.get("active_milestones") or []),
1596
+ "active_tasks": dict(state.get("active_tasks") or {}),
1065
1597
  "milestones": ms_list,
1066
1598
  "tasks": [{"slug": s, "phase": t.get("phase"), "gate": t.get("gate"),
1067
- "milestone": t.get("milestone")} for s, t in tasks.items()],
1599
+ "milestone": t.get("milestone"),
1600
+ "owner": t.get("owner"), "assignee": t.get("assignee")}
1601
+ for s, t in tasks.items()],
1068
1602
  "graduation_ready": grad_ready,
1069
1603
  "stage_criteria": {"met": grad_met, "total": grad_total}}))
1070
1604
  return
1071
1605
  root = _require_root()
1072
1606
  state = load_state(root)
1073
- active = state.get("active_task")
1607
+ active = _active_task(state)
1074
1608
  tasks = state.get("tasks", {})
1075
1609
  # Compute once: True when setup is present AND locked is False (the lock-gate window).
1076
1610
  # Reuses the canonical helper — do NOT write a parallel predicate.
@@ -1079,12 +1613,17 @@ def cmd_status(args: argparse.Namespace) -> None:
1079
1613
  # project autonomy default (task init-auto-default): the posture new tasks INHERIT,
1080
1614
  # read LIVE from PROJECT.md so the human sees the project-wide throttle every session.
1081
1615
  print(f"project autonomy: {_project_autonomy(root)} (default — new tasks inherit)")
1616
+ # git-native actor (user-identity): who ADD sees you as this session — the identity every
1617
+ # human-owned stamp records. Always present (the resolver is TOTAL). Read-only, no write.
1618
+ _who = _whoami(state)
1619
+ _who_email = f" <{_who['email']}>" if _who.get("email") else ""
1620
+ print(f"actor : {_who['name']}{_who_email} (source: {_who['source']})")
1082
1621
  print(f"stage : {state.get('stage', '(unknown)')}")
1083
1622
  # project GOAL + active-milestone goal (v20) — the loop's orientation anchor, read
1084
1623
  # LIVE from PROJECT.md / MILESTONE.md (never state.json). Additive: every existing
1085
1624
  # line stays put. A missing source degrades to a sentinel — one never blanks the other.
1086
1625
  print(f"goal : {_project_goal(root)}")
1087
- _active_ms = state.get("active_milestone")
1626
+ _active_ms = _active_milestone(state)
1088
1627
  if _active_ms:
1089
1628
  print(f"m-goal : {_milestone_doc(root, _active_ms)[1]} (← {_active_ms})")
1090
1629
  # goal-ready (task goal-auto-ready-gate): is the active milestone's goal AUTO-READY
@@ -1112,13 +1651,13 @@ def cmd_status(args: argparse.Namespace) -> None:
1112
1651
 
1113
1652
  # milestone rollup (only when milestones are in use)
1114
1653
  milestones = state.get("milestones") or {}
1115
- active_ms = state.get("active_milestone")
1654
+ active_ms = _active_milestone(state)
1116
1655
  if milestones:
1117
1656
  print("milestones:")
1118
1657
  for mslug, m in milestones.items():
1119
1658
  members = [t for t in tasks.values() if t.get("milestone") == mslug]
1120
1659
  done = sum(1 for t in members if _task_done(t))
1121
- mark = "*" if mslug == active_ms else " "
1660
+ mark = "*" if mslug in (state.get("active_milestones") or []) else " "
1122
1661
  print(f" {mark} {mslug:<20} {done}/{len(members)} tasks done"
1123
1662
  f" status={m.get('status', 'active')}")
1124
1663
  # graduation cue (v22): project-global + read-only. Fires only when every milestone
@@ -1144,11 +1683,42 @@ def cmd_status(args: argparse.Namespace) -> None:
1144
1683
  if _rel:
1145
1684
  print(f" → {RELEASABLE_CUE.format(n=len(_rel))}")
1146
1685
 
1147
- print(f"active : {active or '(none)'}")
1686
+ # fast-lane marker (fast-new-task-flag): tag an ACTIVE fast task so the lane is visible at a
1687
+ # glance. Presentation-only, existence-gated — a plain/absent active task is byte-unchanged.
1688
+ _fast_mark = " · fast" if active and tasks.get(active, {}).get("fast") is True else ""
1689
+ print(f"active : {active or '(none)'}{_fast_mark}")
1690
+ # parallel streams (parallel-status-view): when >=2 milestones are active, render each as its
1691
+ # own stream (active task + phase) so a user working N fronts reads them all at once. ADDITIVE —
1692
+ # the N<=1 output above is byte-identical (the standing additive-cue convention); presentation
1693
+ # only, no command DECISION changes. Reads the SET/map via the task-2 seam shape.
1694
+ _ams = state.get("active_milestones") or []
1695
+ if len(_ams) >= 2:
1696
+ _primary = _active_milestone(state)
1697
+ _order = ([_primary] if _primary in _ams else []) + [m for m in _ams if m != _primary]
1698
+ _atasks = state.get("active_tasks") or {}
1699
+ print(f"streams : {len(_ams)} active milestones")
1700
+ for _m in _order:
1701
+ _tk = _atasks.get(_m)
1702
+ _ph = (tasks.get(_tk) or {}).get("phase", "-") if _tk else "-"
1703
+ _mk = "▸" if _m == _primary else " "
1704
+ _tag = " (primary)" if _m == _primary else ""
1705
+ # per-stream owner (per-stream-owner): the milestone's lead, present-only — a stream
1706
+ # whose milestone has no owner (or a blank-name owner) renders byte-identically
1707
+ # (additive-cue convention). Guard on the name like `_fmt_ownership`, so a hand-edited
1708
+ # blank-name record never emits an `owner:` fragment.
1709
+ _owner_rec = (milestones.get(_m) or {}).get("owner") or {}
1710
+ _so = _fmt_actor(_owner_rec) if _owner_rec.get("name") else ""
1711
+ _own_frag = f" · owner: {_so}" if _so else ""
1712
+ print(f" {_mk} {_m:<20} task={_tk or '(none)'} phase={_ph}{_tag}{_own_frag}")
1148
1713
  # surface the active task's autonomy level (task explicit-autonomy-dial) so the human
1149
1714
  # reads the throttle every session; "unset" when no explicit `autonomy:` line is present.
1150
1715
  if active and active in tasks:
1151
1716
  print(f"autonomy: {_autonomy_level(_task_header(root, active)) or 'unset'}")
1717
+ # owner/assignee of the active task (ownership-assignment) — present-only, never a
1718
+ # placeholder; an unassigned active task adds no line (additive-cue convention).
1719
+ _own = _fmt_ownership(tasks[active])
1720
+ if _own:
1721
+ print(f"owned : {_own}")
1152
1722
  # grounded (task ground-bundle-wiring): does the active task's §0 GROUND map cite the
1153
1723
  # anchors §3 names? measure-not-block, human-readable only (never the JSON surface). A
1154
1724
  # pre-ground / legacy task (no §0) -> _task_grounded None -> NO line, so the surface is
@@ -1237,7 +1807,7 @@ def cmd_guide(args: argparse.Namespace) -> None:
1237
1807
  """
1238
1808
  if getattr(args, "json", False):
1239
1809
  json_root, state = _load_state_for_json()
1240
- slug = args.slug or state.get("active_task")
1810
+ slug = args.slug or _active_task(state)
1241
1811
  if not slug:
1242
1812
  print(json.dumps({"task": None, "phase": None, "owner": "human", "stop": True,
1243
1813
  "next_step": "start your first feature -> add.py new-task <slug>",
@@ -1257,7 +1827,7 @@ def cmd_guide(args: argparse.Namespace) -> None:
1257
1827
  return
1258
1828
  root = _require_root()
1259
1829
  state = load_state(root)
1260
- slug = args.slug or state.get("active_task")
1830
+ slug = args.slug or _active_task(state)
1261
1831
  if not slug:
1262
1832
  print("active : (none)")
1263
1833
  print('next : start your first feature -> add.py new-task <slug> --title "..."')
@@ -1732,7 +2302,7 @@ def cmd_check(args: argparse.Namespace) -> None:
1732
2302
  if root is None:
1733
2303
  _die("no_project")
1734
2304
  try:
1735
- state = json.loads((root / STATE_FILE).read_text(encoding="utf-8"))
2305
+ state = json.loads(_state_text_or_die(root))
1736
2306
  except (json.JSONDecodeError, OSError):
1737
2307
  _die("state_invalid")
1738
2308
 
@@ -1832,7 +2402,7 @@ def cmd_check(args: argparse.Namespace) -> None:
1832
2402
  # zero-criteria milestone is shaping's nudge, not this one's. LIVE-ONLY: the OPEN active
1833
2403
  # milestone only — a done-but-not-yet-archived one (still the active pointer until
1834
2404
  # archive clears it) and closed/archived predecessors are never retro-flagged (Must #4).
1835
- _active_ms = state.get("active_milestone")
2405
+ _active_ms = _active_milestone(state)
1836
2406
  if _active_ms in milestones and milestones[_active_ms].get("status") != "done":
1837
2407
  _cited, _total = _exit_criteria_cited(root, _active_ms)
1838
2408
  if _total >= 1 and _cited < _total:
@@ -1847,7 +2417,7 @@ def cmd_check(args: argparse.Namespace) -> None:
1847
2417
  # FROZEN AND its §0 GROUND map is ungrounded (the precise "froze without grounding" gap, so
1848
2418
  # no nag during pre-freeze drafting). A pre-ground / legacy task (no §0 -> _grounded_state
1849
2419
  # None) is EXEMPT, never retro-flagged. Rides the existing `warnings` array — no new key.
1850
- _at = state.get("active_task")
2420
+ _at = _active_task(state)
1851
2421
  if _at in tasks:
1852
2422
  _raw = _raw_phase_bodies(root, _at)
1853
2423
  if _contract_frozen(_raw.get(3, "")) and _grounded_state(_raw) is False:
@@ -1921,6 +2491,93 @@ def cmd_check(args: argparse.Namespace) -> None:
1921
2491
  raise SystemExit(1)
1922
2492
 
1923
2493
 
2494
+ def _doctor_findings(root: Path) -> list[str]:
2495
+ """Read-only diagnosis of state.json: each item is "<problem> — fix: <fix>".
2496
+
2497
+ Reads the RAW text with its OWN try/except — NEVER through the dying load_state — so a
2498
+ conflicted/corrupt state is REPORTED, not aborted on (the proactive counterpart to the
2499
+ merge-guard load guard, which fails fast at the first problem). Reports the FIRST blocking
2500
+ class then stops (can't parse deeper): missing/unreadable file -> conflict markers -> bad
2501
+ JSON. On a PARSEABLE state (normalized through `_migrate_state` so the canonical multi-active
2502
+ shape is judged) it appends EVERY referential violation. PURE: reads only, returns the list."""
2503
+ try:
2504
+ text = (root / STATE_FILE).read_text(encoding="utf-8")
2505
+ except OSError:
2506
+ return ["state.json missing/unreadable — fix: restore it from git/backup"]
2507
+ if _CONFLICT_MARKER_RE.search(text):
2508
+ return ["state.json has unresolved git merge markers — fix: resolve "
2509
+ "<<<<<<< / ======= / >>>>>>> (or git checkout --ours/--theirs), then re-run doctor"]
2510
+ try:
2511
+ parsed = json.loads(text)
2512
+ except json.JSONDecodeError:
2513
+ return ["state.json is not valid JSON — fix: restore it from git/backup"]
2514
+
2515
+ state = _migrate_state(parsed)
2516
+ findings: list[str] = []
2517
+ milestones = state.get("milestones") if isinstance(state.get("milestones"), dict) else {}
2518
+ tasks = state.get("tasks") if isinstance(state.get("tasks"), dict) else {}
2519
+
2520
+ active_ms = state.get("active_milestones") if isinstance(state.get("active_milestones"), list) else []
2521
+ active_tasks = state.get("active_tasks") if isinstance(state.get("active_tasks"), dict) else {}
2522
+ for am in active_ms:
2523
+ if am not in milestones:
2524
+ findings.append(f"active milestone '{am}' has no record — fix: deactivate it or "
2525
+ "recreate the milestone")
2526
+ for ms, t in active_tasks.items():
2527
+ if not t:
2528
+ continue
2529
+ if t not in tasks:
2530
+ findings.append(f"active task '{t}' (milestone '{ms}') has no record — fix: use a "
2531
+ "real task or clear the active pointer")
2532
+ elif (tasks[t].get("milestone") if isinstance(tasks[t], dict) else None) != ms:
2533
+ findings.append(f"active task '{t}' is mislabeled under '{ms}' — fix: re-use it "
2534
+ "under its own milestone")
2535
+ for slug, t in tasks.items():
2536
+ m = t.get("milestone") if isinstance(t, dict) else None
2537
+ if m is not None and m not in milestones:
2538
+ findings.append(f"task '{slug}' references missing milestone '{m}' — fix: set its "
2539
+ "milestone to a real one (or none)")
2540
+ return findings
2541
+
2542
+
2543
+ def cmd_doctor(args: argparse.Namespace) -> None:
2544
+ """Read-only `add.py doctor`: PASS + exit 0 on a healthy state, else report each problem +
2545
+ fix to stdout and exit non-zero. NEVER mutates state (detect, never auto-resolve)."""
2546
+ root = find_root()
2547
+ if root is None:
2548
+ _die("no_project")
2549
+ findings = _doctor_findings(root)
2550
+ if not findings:
2551
+ print("doctor: PASS — state.json is healthy (parseable · conflict-free · references intact)")
2552
+ return
2553
+ print(f"doctor: {len(findings)} problem(s):")
2554
+ for f in findings:
2555
+ print(f" ✗ {f}")
2556
+ raise SystemExit(1)
2557
+
2558
+
2559
+ def cmd_mine(args: argparse.Namespace) -> None:
2560
+ """Read-only `add.py mine`: across all active milestones, the not-done tasks owned-by or
2561
+ assigned-to the resolved actor (`_whoami`, or `--actor "Name <email>"`). Text or `--json`.
2562
+ An empty queue is a plain exit-0 line, not an error. NEVER writes state."""
2563
+ root = find_root()
2564
+ if root is None:
2565
+ _die("no_project")
2566
+ state = load_state(root)
2567
+ me = _parse_actor_arg(args.actor) if getattr(args, "actor", None) else _whoami(state)
2568
+ rows = _my_work(state, me)
2569
+ if getattr(args, "json", False):
2570
+ print(json.dumps({"actor": me, "tasks": rows}))
2571
+ return
2572
+ who = _fmt_actor(me) or me.get("name", "you")
2573
+ if not rows:
2574
+ print(f"mine: no open tasks for {who} across active milestones")
2575
+ return
2576
+ print(f"mine: {who} — {len(rows)} open task(s) across active milestones:")
2577
+ for r in rows:
2578
+ print(f" {r['slug']:<24} [{r['milestone']}] phase={r['phase']} ({r['role']})")
2579
+
2580
+
1924
2581
  # ---------------------------------------------------------------------------
1925
2582
  # wave-ledger fork-base enforcement (engine-merge-base-enforcement)
1926
2583
  #
@@ -2075,17 +2732,64 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
2075
2732
  _atomic_write(mfile, _render_template(
2076
2733
  "MILESTONE.md", title=title, goal=args.goal or "<goal>",
2077
2734
  stage=args.stage, date=date.today().isoformat()))
2078
- state["milestones"][slug] = {
2735
+ # confirm-parent gate (OPT-IN, mirrors `init --await-lock`): `--await-confirm` seeds the
2736
+ # milestone UNCONFIRMED so new-task is held until `add.py milestone-confirm`. WITHOUT the flag
2737
+ # NO `confirmed` key is written → grandfathered-confirmed → no gate (so the existing engine
2738
+ # tests stay byte-green). The guided skill flow passes the flag at the human-review point.
2739
+ await_confirm = bool(getattr(args, "await_confirm", False))
2740
+ record = {
2079
2741
  "title": title, "goal": args.goal or "", "stage": args.stage,
2080
2742
  "status": "active", "created": _now(), "updated": _now(),
2081
2743
  }
2082
- state["active_milestone"] = slug
2744
+ if await_confirm:
2745
+ # `await_confirm` is the STABLE opt-in marker (set ONLY here, at creation). `confirmed`
2746
+ # alone is NOT a reliable opt-in signal: milestone-confirm stamps confirmed:true on a plain
2747
+ # milestone too, so a later build-entry gate must key on `await_confirm`, not `confirmed`.
2748
+ record.update(confirmed=False, confirmed_at=None, confirmed_by=None, await_confirm=True)
2749
+ state["milestones"][slug] = record
2750
+ _set_active_milestone(state, slug)
2083
2751
  save_state(root, state)
2084
2752
  print(f"created milestone '{slug}' -> {mfile}")
2085
- print("active milestone set.")
2753
+ print("active milestone set." + ("" if not await_confirm else
2754
+ " (unconfirmed — show the MILESTONE.md, then: add.py milestone-confirm " + slug + ")"))
2086
2755
  print(_next_footer(root, state)) # converges the old "Decompose it into tasks: …" hint
2087
2756
 
2088
2757
 
2758
+ def cmd_milestone_confirm(args: argparse.Namespace) -> None:
2759
+ """The human gate that opens new-task for a milestone (confirm-parent). Mirrors `cmd_lock`
2760
+ one level down: the human reviews the filled MILESTONE.md, then RECORDS confirmation here.
2761
+ The engine never self-confirms. Validate-then-write; re-confirm is an idempotent note."""
2762
+ root = _require_root()
2763
+ state = load_state(root)
2764
+ slug = args.slug
2765
+ if slug not in state.get("milestones", {}):
2766
+ _die("unknown_milestone")
2767
+ m = state["milestones"][slug]
2768
+ if m.get("confirmed") is True:
2769
+ print(f"milestone '{slug}' already confirmed (by {m.get('confirmed_by', '?')}).")
2770
+ return
2771
+ # contract-fill gate (flow-enforcement, OPTED-IN only): a milestone that opted into
2772
+ # --await-confirm (carries a `confirmed` key) may not be confirmed until its cross-task
2773
+ # `## Shared / risky contracts` section is filled — so "confirmed" MEANS the contracts
2774
+ # were present at confirm time. A grandfathered no-key milestone keeps the plain stamp
2775
+ # (gate skipped — keeps the census + existing flows green). Validate-then-write.
2776
+ if "confirmed" in m:
2777
+ mfile = root / "milestones" / slug / MILESTONE_FILE
2778
+ md = mfile.read_text(encoding="utf-8") if mfile.exists() else ""
2779
+ if _section_unfilled(md, "## Shared / risky contracts"):
2780
+ _die("milestone_contracts_unfilled: fill the '## Shared / risky contracts' "
2781
+ f"section of {slug}'s MILESTONE.md before confirming")
2782
+ who = getattr(args, "by", None) or getpass.getuser()
2783
+ m["confirmed"] = True
2784
+ m["confirmed_at"] = _now()
2785
+ m["confirmed_by"] = who
2786
+ m["actor"] = _actor_stamp(state) # structured actor alongside the free-text confirmed_by
2787
+ m["updated"] = _now()
2788
+ save_state(root, state)
2789
+ print(f"confirmed milestone '{slug}' (by {who}) — new-task is now open for it.")
2790
+ print(_next_footer(root, state))
2791
+
2792
+
2089
2793
  def cmd_ready(args: argparse.Namespace) -> None:
2090
2794
  if getattr(args, "json", False):
2091
2795
  _, state = _load_state_for_json()
@@ -2128,7 +2832,11 @@ def cmd_ready(args: argparse.Namespace) -> None:
2128
2832
  for slug in ready:
2129
2833
  deps = tasks[slug].get("depends_on") or []
2130
2834
  suffix = f" (after {', '.join(deps)})" if deps else ""
2131
- print(f" {slug}{suffix}")
2835
+ # cross-active legibility (cross-active-waves): name the stream each ready task belongs
2836
+ # to, present-only — a milestone-less task gets no bracket (byte-identical).
2837
+ _ms = tasks[slug].get("milestone")
2838
+ ms_frag = f" [{_ms}]" if _ms else ""
2839
+ print(f" {slug}{ms_frag}{suffix}")
2132
2840
 
2133
2841
 
2134
2842
  def _wave_schedule(state: dict, mslug: str) -> dict:
@@ -2222,36 +2930,17 @@ def _wave_schedule(state: dict, mslug: str) -> dict:
2222
2930
  "tiers": tiers, "blocked": blocked_sorted}
2223
2931
 
2224
2932
 
2225
- def cmd_waves(args: argparse.Namespace) -> None:
2226
- """READ-ONLY DAG scheduler: print the active milestone's topological waves, critical
2227
- path, advisory tier hint, and blocked set. Writes nothing; emits no `next:` footer."""
2228
- is_json = getattr(args, "json", False)
2229
- if is_json:
2230
- _, state = _load_state_for_json()
2231
- else:
2232
- state = load_state(_require_root())
2233
- mslug = getattr(args, "milestone", None) or state.get("active_milestone")
2234
- if not mslug:
2235
- _die("no_active_milestone: no active milestone and no --milestone given")
2236
- if mslug not in (state.get("milestones") or {}):
2237
- _die(f"unknown_milestone: '{mslug}' is not a milestone in this project")
2238
- sched = _wave_schedule(state, mslug)
2239
- if "cycle" in sched:
2240
- _die(f"dependency_cycle: not-done deps form a cycle "
2241
- f"({' -> '.join(sched['cycle'])}) — no valid schedule")
2242
-
2243
- if is_json:
2244
- print(json.dumps({"milestone": mslug, **sched}))
2245
- return
2246
-
2247
- print(f"milestone: {mslug}")
2933
+ def _wave_block_lines(state: dict, mslug: str, sched: dict) -> list[str]:
2934
+ """The exact text lines `waves` renders for ONE milestone's schedule (cross-active-waves
2935
+ extracts this so a single target stays byte-identical and N targets each get a block)."""
2936
+ lines = [f"milestone: {mslug}"]
2248
2937
  if not sched["waves"]:
2249
2938
  if sched["blocked"]:
2250
2939
  for s in sched["blocked"]:
2251
- print(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2940
+ lines.append(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2252
2941
  else:
2253
- print("all tasks done — nothing to schedule")
2254
- return
2942
+ lines.append("all tasks done — nothing to schedule")
2943
+ return lines
2255
2944
  scheduled_set = {x for w in sched["waves"] for x in w}
2256
2945
  for i, wave in enumerate(sched["waves"], start=1):
2257
2946
  parts = []
@@ -2259,14 +2948,62 @@ def cmd_waves(args: argparse.Namespace) -> None:
2259
2948
  md = sorted(d for d in (state["tasks"][s].get("depends_on") or [])
2260
2949
  if d in scheduled_set)
2261
2950
  parts.append(f"{s} (deps: {', '.join(md)})" if md else s)
2262
- print(f"wave {i}: {', '.join(parts)}")
2951
+ lines.append(f"wave {i}: {', '.join(parts)}")
2263
2952
  crit = sched["critical_path"]
2264
- print(f"critical path: {' → '.join(crit)} ({sched['critical_path_len']} tasks)")
2953
+ lines.append(f"critical path: {' → '.join(crit)} ({sched['critical_path_len']} tasks)")
2265
2954
  tops = [s for s, tier in sched["tiers"].items() if tier == "top"]
2266
2955
  mids = [s for s, tier in sched["tiers"].items() if tier == "mid"]
2267
- print(f"tier hint: top → {', '.join(tops)}; mid → {', '.join(mids) or '(none)'}")
2956
+ lines.append(f"tier hint: top → {', '.join(tops)}; mid → {', '.join(mids) or '(none)'}")
2268
2957
  for s in sched["blocked"]:
2269
- print(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2958
+ lines.append(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2959
+ return lines
2960
+
2961
+
2962
+ def cmd_waves(args: argparse.Namespace) -> None:
2963
+ """READ-ONLY DAG scheduler: print the topological waves, critical path, advisory tier hint,
2964
+ and blocked set. With no --milestone it spans EVERY active milestone (cross-active-waves);
2965
+ a single target / --milestone renders byte-identically. Writes nothing; no `next:` footer."""
2966
+ is_json = getattr(args, "json", False)
2967
+ if is_json:
2968
+ _, state = _load_state_for_json()
2969
+ else:
2970
+ state = load_state(_require_root())
2971
+ mslug_arg = getattr(args, "milestone", None)
2972
+ if mslug_arg:
2973
+ targets = [mslug_arg] # explicit single target — unchanged
2974
+ else:
2975
+ primary = _active_milestone(state) # the SCALAR is the gate (test relies on it)
2976
+ if not primary:
2977
+ _die("no_active_milestone: no active milestone and no --milestone given")
2978
+ # additively widen to the other active milestones (cross-active); primary first
2979
+ targets = [primary] + [m for m in (state.get("active_milestones") or [])
2980
+ if m != primary]
2981
+ scheds = []
2982
+ for t in targets:
2983
+ if t not in (state.get("milestones") or {}):
2984
+ _die(f"unknown_milestone: '{t}' is not a milestone in this project")
2985
+ sched = _wave_schedule(state, t)
2986
+ if "cycle" in sched:
2987
+ _die(f"dependency_cycle: not-done deps form a cycle "
2988
+ f"({' -> '.join(sched['cycle'])}) — no valid schedule")
2989
+ scheds.append(sched)
2990
+
2991
+ if is_json:
2992
+ if len(targets) == 1:
2993
+ print(json.dumps({"milestone": targets[0], **scheds[0]})) # unchanged shape
2994
+ else:
2995
+ print(json.dumps({"streams": [{"milestone": t, **s}
2996
+ for t, s in zip(targets, scheds)]}))
2997
+ return
2998
+
2999
+ if len(targets) == 1:
3000
+ print("\n".join(_wave_block_lines(state, targets[0], scheds[0]))) # byte-identical
3001
+ return
3002
+ print(f"active streams: {len(targets)}")
3003
+ for i, (t, s) in enumerate(zip(targets, scheds)):
3004
+ if i:
3005
+ print()
3006
+ print("\n".join(_wave_block_lines(state, t, s)))
2270
3007
 
2271
3008
 
2272
3009
  def cmd_milestone_done(args: argparse.Namespace) -> None:
@@ -2296,6 +3033,10 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
2296
3033
  _die(f"milestone_goal_unmet: milestone '{slug}' has {met}/{total} exit criteria met "
2297
3034
  f"— check the remaining boxes in MILESTONE.md (the goal-gate holds the loop "
2298
3035
  f"open) or propose the next tasks (add.py deltas)")
3036
+ # Stamp WHO closed it BEFORE rendering the retro, so the persisted exit report records
3037
+ # the closer (identity-in-status: the retro IS the report `report <ms>` re-renders, so both
3038
+ # must reflect the same final state). In-memory only here — save_state below commits it.
3039
+ state["milestones"][slug]["done_actor"] = _actor_stamp(state)
2299
3040
  # Fail-closed: render+persist the exit report (RETRO.md) BEFORE committing the
2300
3041
  # status flip, so a write failure rolls back naturally (status never commits ->
2301
3042
  # no done-without-retro state). The retro step is read-only on state.json.
@@ -2311,11 +3052,15 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
2311
3052
  print(f"milestone '{slug}' -> done ({len(members)} tasks complete{tail}).")
2312
3053
  print(f"wrote {retro_path.relative_to(root.parent)} (milestone exit report)")
2313
3054
  # fold-pressure nudge: milestone close is the natural fold point for open deltas (v11)
2314
- open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
3055
+ by_comp = _collect_open_deltas(root)
3056
+ open_deltas = sum(len(v) for v in by_comp.values())
2315
3057
  if open_deltas:
2316
3058
  noun = "delta" if open_deltas == 1 else "deltas"
2317
- print(f"note: {open_deltas} open {noun} to consolidate into the foundation "
2318
- f"— review with: add.py deltas")
3059
+ print(f"note: {open_deltas} open {noun} ready to {_FOLD_VERB} into the foundation:")
3060
+ for comp in _COMPETENCY_ORDER:
3061
+ for e in by_comp[comp]:
3062
+ print(f" ({comp}) {e['text']} [{e['task']}]")
3063
+ print(f" run: add.py {_FOLD_VERB} (review first: add.py deltas)")
2319
3064
  # SPEC-delta nudge (project-wide): the close is also a natural prompt to RESOLVE the
2320
3065
  # forward hand-offs (seed/drop) so none is orphaned at the eventual compaction.
2321
3066
  open_spec = len(_collect_open_spec_deltas(root))
@@ -2369,10 +3114,9 @@ def cmd_archive_milestone(args: argparse.Namespace) -> None:
2369
3114
  del state["milestones"][slug]
2370
3115
  for s in members:
2371
3116
  del tasks[s]
2372
- if state.get("active_milestone") == slug:
2373
- state["active_milestone"] = None
2374
- if state.get("active_task") in members:
2375
- state["active_task"] = None
3117
+ _deactivate_milestone(state, slug) # drop from the active SET + pop its task entry, repointing the primary focus
3118
+ if _active_task(state) in members: # N<=1 oracle: a NON-primary archive (new-milestone replace-to-focus leaves
3119
+ state["active_task"] = None # active_task pointing at m1's task while primary is m2) would dangle at a deleted task
2376
3120
  save_state(root, state)
2377
3121
  print(f"archived milestone '{slug}' ({len(members)} tasks) — removed from active state.")
2378
3122
  print("files on disk are untouched; see `add.py status` for the archived rollup.")
@@ -2422,10 +3166,15 @@ def cmd_compact(args: argparse.Namespace) -> None:
2422
3166
  # hand-off that resolves into a task, not a foundation lesson — an open one ANYWHERE would
2423
3167
  # be orphaned at the next compaction. Deliberately broader than the member-scoped competency
2424
3168
  # guard above. Still validate-before-move: refuses BEFORE the first rename.
3169
+ # --force overrides THIS guard ONLY (never a structural reject above) — the escape hatch
3170
+ # for a settled milestone blocked by an unrelated open SPEC delta elsewhere. Bypass is
3171
+ # loud (warns + records `force_bypassed_spec_deltas`), never silent.
3172
+ forced = getattr(args, "force", False)
2425
3173
  spec_offenders = sorted({d["task"] for d in _collect_open_spec_deltas(root)})
2426
- if spec_offenders:
3174
+ if spec_offenders and not forced:
2427
3175
  _die("open_spec_deltas_unresolved: resolve every open SPEC delta first "
2428
- "(`add.py deltas`; seed with `new-task --from-delta`, or `drop-delta`) "
3176
+ "(`add.py deltas`; seed with `new-task --from-delta`, or `drop-delta`; "
3177
+ "or re-run with --force to compact past them) — "
2429
3178
  "open in: " + " · ".join(spec_offenders))
2430
3179
  # every precondition passed — move (same-filesystem renames, never a delete)
2431
3180
  def _files(d: Path) -> int:
@@ -2443,7 +3192,12 @@ def cmd_compact(args: argparse.Namespace) -> None:
2443
3192
  moved.append((f"tasks/{t}/", n))
2444
3193
  # state write is the LAST step: additive stamp only — task_slugs untouched
2445
3194
  entry["compacted"] = date.today().isoformat()
3195
+ if spec_offenders: # forced is implied (un-forced would have _die'd)
3196
+ entry["force_bypassed_spec_deltas"] = spec_offenders
2446
3197
  save_state(root, state)
3198
+ if spec_offenders:
3199
+ print("⚠ --force bypassed open SPEC delta(s) in: " + " · ".join(spec_offenders) +
3200
+ " — recorded as force_bypassed_spec_deltas; resolve them before the next release.")
2447
3201
  total = sum(n for _, n in moved)
2448
3202
  print(f"compacted milestone '{slug}' -> .add/archive/{slug}/ "
2449
3203
  f"({len(members)} task dirs, {total} files moved)")
@@ -2472,16 +3226,54 @@ def cmd_set_milestone(args: argparse.Namespace) -> None:
2472
3226
  print(_next_footer(root, state))
2473
3227
 
2474
3228
 
3229
+ def cmd_activate(args: argparse.Namespace) -> None:
3230
+ """Add a milestone to the active working SET and focus it — how a user works N milestones
3231
+ in parallel. Idempotent (re-activating just refocuses). Validates before mutating."""
3232
+ root = _require_root()
3233
+ state = load_state(root)
3234
+ slug = args.slug
3235
+ if slug not in state.get("milestones", {}):
3236
+ _die("unknown_milestone")
3237
+ if state["milestones"][slug].get("status") == "done":
3238
+ _die("milestone_done")
3239
+ _activate_milestone(state, slug)
3240
+ save_state(root, state)
3241
+ print(f"activated '{slug}' — active: {', '.join(state['active_milestones'])}")
3242
+ print(_next_footer(root, state))
3243
+
3244
+
3245
+ def cmd_deactivate(args: argparse.Namespace) -> None:
3246
+ """Remove a milestone from the active working SET (its files + status are untouched);
3247
+ repoints the primary focus to a remaining member. Validates before mutating."""
3248
+ root = _require_root()
3249
+ state = load_state(root)
3250
+ slug = args.slug
3251
+ if slug not in (state.get("active_milestones") or []):
3252
+ _die("milestone_not_active")
3253
+ _deactivate_milestone(state, slug)
3254
+ save_state(root, state)
3255
+ remaining = state.get("active_milestones") or []
3256
+ print(f"deactivated '{slug}' — active: {', '.join(remaining) if remaining else '(none)'}")
3257
+ print(_next_footer(root, state))
3258
+
3259
+
2475
3260
  def cmd_use(args: argparse.Namespace) -> None:
2476
3261
  """Set the active task to an EXISTING task (switch focus) without scaffolding a new
2477
3262
  one or hand-editing state.json. advance/gate/phase still take an explicit slug; `use`
2478
- just moves the default focus, closing the only gap that forced manual state edits."""
3263
+ just moves the default focus, closing the only gap that forced manual state edits.
3264
+ Milestone-aware: focuses the task's OWN milestone (activating it into the set) so the
3265
+ active task is switched WITHIN that milestone, not mislabeled under a stale primary."""
2479
3266
  root = _require_root()
2480
3267
  state = load_state(root)
2481
3268
  slug = args.slug
2482
3269
  if slug not in state.get("tasks", {}):
2483
3270
  _die("unknown_task")
2484
- state["active_task"] = slug
3271
+ ms = state["tasks"][slug].get("milestone")
3272
+ if ms is not None and ms in state.get("milestones", {}):
3273
+ _activate_milestone(state, ms) # focus the task's milestone (adds to the set if needed)
3274
+ _set_active_task(state, slug, ms)
3275
+ else:
3276
+ _set_active_task(state, slug) # milestone-less task: scalar only (back-compat)
2485
3277
  save_state(root, state)
2486
3278
  print(f"active task -> '{slug}' (phase={state['tasks'][slug]['phase']})")
2487
3279
  print(_next_footer(root, state))
@@ -3226,6 +4018,9 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
3226
4018
  "phase_index": PHASES.index(phase) if phase in PHASES else 0,
3227
4019
  "done": _task_done(t),
3228
4020
  "gate": gate,
4021
+ "gate_actor": t.get("gate_actor"), # WHO recorded the verdict (None when unstamped)
4022
+ "owner": t.get("owner"), # WHO is accountable (None when unassigned)
4023
+ "assignee": t.get("assignee"), # WHO is working it (None when unassigned)
3229
4024
  "tests": n_tests,
3230
4025
  "tests_declared": t_declared,
3231
4026
  "observe": observe,
@@ -3241,7 +4036,10 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
3241
4036
 
3242
4037
  return {
3243
4038
  "milestone": {"slug": mslug, "title": title, "goal": goal,
3244
- "status": ms.get("status", "active")},
4039
+ "status": ms.get("status", "active"),
4040
+ "done_actor": ms.get("done_actor"), # WHO closed it (None when unstamped/open)
4041
+ "owner": ms.get("owner"), # WHO is accountable for the milestone
4042
+ "assignee": ms.get("assignee")}, # WHO is working it (None when unassigned)
3245
4043
  "summary": {
3246
4044
  "tasks_done": sum(1 for r in task_rows if r["done"]),
3247
4045
  "tasks_total": len(task_rows),
@@ -3423,6 +4221,24 @@ def render_task_detail(root: Path, state: dict, mslug: str, slug: str, *,
3423
4221
  return "\n".join(L)
3424
4222
 
3425
4223
 
4224
+ def _fmt_actor(actor: dict | None) -> str:
4225
+ """Format a recorded actor stamp `{name,email,source}` as `name [<email>]` for the
4226
+ report surface — "" when absent (user-identity: present-only render, no placeholder)."""
4227
+ if not actor:
4228
+ return ""
4229
+ email = f" <{actor['email']}>" if actor.get("email") else ""
4230
+ return f"{actor.get('name', '')}{email}"
4231
+
4232
+
4233
+ def _fmt_ownership(rec: dict) -> str:
4234
+ """Format a record's owner/assignee as `owner: <name> · assignee: <name>` for the
4235
+ surface (ownership-assignment) — present-only: each role appears only when set, and
4236
+ "" when neither is. Reuses _fmt_actor to render each `{name,email,source}` actor."""
4237
+ bits = [f"{role}: {_fmt_actor(rec[role])}" for role in ("owner", "assignee")
4238
+ if rec.get(role) and rec[role].get("name")] # skip a hand-edited blank-name record
4239
+ return " · ".join(bits)
4240
+
4241
+
3426
4242
  def render_report(root: Path, state: dict, mslug: str, *,
3427
4243
  width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str:
3428
4244
  """Format the FACTS (report_data) as the text DASHBOARD — verdict-first header,
@@ -3457,6 +4273,13 @@ def render_report(root: Path, state: dict, mslug: str, *,
3457
4273
  L.append(f" {'GATES':<9} {gate_txt:<18} {'WAIVERS':<9} {waiver_txt}")
3458
4274
  L.append("")
3459
4275
  L.extend(_wrap(m["goal"], W - 7, " goal "))
4276
+ # who closed the milestone (user-identity) — present-only, never a placeholder
4277
+ if m.get("done_actor"):
4278
+ L.append(f" closed by {_fmt_actor(m['done_actor'])}")
4279
+ # who owns/works the milestone (ownership-assignment) — present-only
4280
+ _ms_own = _fmt_ownership(m)
4281
+ if _ms_own:
4282
+ L.append(f" owned by {_ms_own}")
3460
4283
  L.append("")
3461
4284
  if d["tasks"]:
3462
4285
  L.append(f" {'TASK':<27} {'PHASE':<9} {'GATE':<4} {'TESTS':<5} PROGRESS")
@@ -3471,6 +4294,21 @@ def render_report(root: Path, state: dict, mslug: str, *,
3471
4294
  f"{g['pending']} pending spec→…→done")
3472
4295
  if any(r.get("tests_declared") for r in d["tasks"]):
3473
4296
  L.append(" † counted at the §4-declared path")
4297
+ # who recorded each verdict (user-identity) — present-only audit trail
4298
+ gated = [r for r in d["tasks"] if r.get("gate_actor")]
4299
+ if gated:
4300
+ L.append("")
4301
+ L.append(" GATED BY")
4302
+ for r in gated:
4303
+ short = _GATE_SHORT.get(r["gate"], r["gate"])
4304
+ L.append(f" {_clip(r['slug'], 24):<24} {short:<4} {_fmt_actor(r['gate_actor'])}")
4305
+ # who owns/works each task (ownership-assignment) — present-only, mirror of GATED BY
4306
+ owned = [r for r in d["tasks"] if r.get("owner") or r.get("assignee")]
4307
+ if owned:
4308
+ L.append("")
4309
+ L.append(" OWNED BY")
4310
+ for r in owned:
4311
+ L.append(f" {_clip(r['slug'], 24):<24} {_fmt_ownership(r)}")
3474
4312
  else:
3475
4313
  L.append(" (no tasks yet)")
3476
4314
  L.append("")
@@ -3764,7 +4602,7 @@ def _decide_next_pair(state: dict, d: dict) -> tuple[str, bool]:
3764
4602
  return (f"goal not met ({met}/{total} exit criteria) — propose next tasks "
3765
4603
  f"from open deltas / the unscaffolded plan (add.py deltas)"), True
3766
4604
  return f"consolidate learnings + archive-milestone {ms}", True
3767
- active = state.get("active_task")
4605
+ active = _active_task(state)
3768
4606
  order = sorted(rows, key=lambda r: 0 if r["slug"] == active else 1) # stable
3769
4607
  for r in order:
3770
4608
  if r["done"]:
@@ -3806,7 +4644,7 @@ def _next_footer(root: Path, state: dict) -> str:
3806
4644
  The fail-soft line carries NO marker — never assert a driver that could not be computed.
3807
4645
  """
3808
4646
  try:
3809
- slug = state.get("active_task")
4647
+ slug = _active_task(state)
3810
4648
  t = (state.get("tasks") or {}).get(slug) if slug else None
3811
4649
  if t and t.get("gate", "none") == "none" and t.get("phase") != "done":
3812
4650
  phase = t.get("phase")
@@ -3815,7 +4653,7 @@ def _next_footer(root: Path, state: dict) -> str:
3815
4653
  if phase == "verify" else "add.py advance")
3816
4654
  marker = _driver_marker(_driver_stop(root, state, slug, phase))
3817
4655
  return f"next: {command} — {why}{marker}"
3818
- mslug = state.get("active_milestone")
4656
+ mslug = _active_milestone(state)
3819
4657
  if mslug:
3820
4658
  d = report_data(root, state, mslug)
3821
4659
  text, human_stop = _decide_next_pair(state, d)
@@ -4092,40 +4930,61 @@ def _collect_open_spec_deltas(root: Path) -> list[dict]:
4092
4930
  _SPEC_OPEN_TOKEN_RE = re.compile(r"(\[\s*SPEC\s*·\s*)open(\s*\])")
4093
4931
 
4094
4932
 
4095
- def _resolve_spec_delta(text: str, new_status: str, pointer: str | None = None) -> str | None:
4096
- """Flip the FIRST `[SPEC · open]` line in `text` to `new_status`; return the new text.
4933
+ def _resolve_spec_delta(text: str, new_status: str, pointer: str | None = None,
4934
+ line_index: int | None = None) -> str | None:
4935
+ """Flip ONE `[SPEC · open]` line in `text` to `new_status`; return the new text.
4097
4936
 
4098
- PURE — no IO. Only the status token changes (+ a trailing ` [→ <pointer>]` provenance
4099
- stamp when seeding); the entry's text and `(evidence: …)` are byte-preserved. Returns
4100
- None when there is NO open SPEC delta the caller then refuses and writes nothing
4101
- (validate-all-then-write). Mirrors the `_autonomy_decl_line` pure-transform pattern."""
4937
+ PURE — no IO. With `line_index` (a splitlines(keepends=True) index, as `_select_spec_delta`
4938
+ returns) flip THAT line; without it, flip the FIRST open delta (back-compat). Only the status
4939
+ token changes (+ a trailing ` [→ <pointer>]` provenance stamp when seeding); the entry's text
4940
+ and `(evidence: …)` are byte-preserved. Returns None when there is NO open SPEC delta to flip —
4941
+ the caller then refuses and writes nothing. Mirrors the `_autonomy_decl_line` pure-transform."""
4102
4942
  lines = text.splitlines(keepends=True)
4103
- for i, ln in enumerate(lines):
4104
- m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
4105
- if not m or m.group(2) != "open":
4106
- continue
4107
- eol = ln[len(ln.rstrip("\n")):] # preserve the exact line ending
4108
- body = _SPEC_OPEN_TOKEN_RE.sub(rf"\g<1>{new_status}\g<2>", ln.rstrip("\n"), count=1)
4109
- if pointer:
4110
- body = f"{body} [→ {pointer}]"
4111
- lines[i] = body + eol
4112
- return "".join(lines)
4113
- return None
4943
+ target = line_index
4944
+ if target is None: # back-compat: the FIRST open delta
4945
+ for i, ln in enumerate(lines):
4946
+ m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
4947
+ if m and m.group(2) == "open":
4948
+ target = i
4949
+ break
4950
+ if target is None:
4951
+ return None
4952
+ ln = lines[target]
4953
+ eol = ln[len(ln.rstrip("\n")):] # preserve the exact line ending
4954
+ body = _SPEC_OPEN_TOKEN_RE.sub(rf"\g<1>{new_status}\g<2>", ln.rstrip("\n"), count=1)
4955
+ if pointer:
4956
+ body = f"{body} [→ {pointer}]"
4957
+ lines[target] = body + eol
4958
+ return "".join(lines)
4114
4959
 
4115
4960
 
4116
- def _first_open_spec_text(text: str) -> str | None:
4117
- """The first OPEN SPEC delta's text (evidence stripped) in `text`, or None.
4961
+ def _select_spec_delta(text: str, match: str | None = None) -> tuple[str, int | None, str | None]:
4962
+ """Pick the open SPEC delta to resolve (delta-match-selector). PURE no IO.
4118
4963
 
4119
- Used to pre-fill a seeded task's §1 Feature line from the SAME in-memory text the
4120
- flip operates on (one read, consistent selection)."""
4121
- for unit in _spec_delta_entries(text):
4122
- m = _SPEC_DELTA_RE.match(unit[0])
4123
- if m.group(2) != "open":
4964
+ `match=None` -> the FIRST open delta. `match=<substr>` -> the UNIQUE open delta whose display
4965
+ text (status token + `(evidence: …)` excluded) contains <substr>, case-insensitive. Returns
4966
+ (status, line_index, display_text) where status is one of: "ok" (line_index/display set),
4967
+ "no_open" (no open delta at all), "no_match" (--match hit zero), "ambiguous" (--match hit >1).
4968
+ line_index is a splitlines(keepends=True) index, the same `_resolve_spec_delta` flips."""
4969
+ opens: list[tuple[int, str]] = []
4970
+ for i, ln in enumerate(text.splitlines(keepends=True)):
4971
+ m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
4972
+ if not m or m.group(2) != "open":
4124
4973
  continue
4125
- tail = " ".join([m.group(3).strip(), *unit[1:]]).strip()
4126
- em = _EVIDENCE_RE.match(tail)
4127
- return em.group(1).strip() if em else tail
4128
- return None
4974
+ tail = m.group(3).strip()
4975
+ cut = tail.find("(evidence:") # exclude the evidence tail even if its paren is unclosed
4976
+ opens.append((i, (tail[:cut].strip() if cut != -1 else tail)))
4977
+ if not opens:
4978
+ return ("no_open", None, None)
4979
+ if match is None:
4980
+ return ("ok", opens[0][0], opens[0][1])
4981
+ needle = match.lower()
4982
+ hits = [(i, d) for i, d in opens if needle in d.lower()]
4983
+ if not hits:
4984
+ return ("no_match", None, None)
4985
+ if len(hits) > 1:
4986
+ return ("ambiguous", None, None)
4987
+ return ("ok", hits[0][0], hits[0][1])
4129
4988
 
4130
4989
 
4131
4990
  # ── add.py fold — mechanized competency-lesson consolidation ────────────────────────────────
@@ -4295,13 +5154,9 @@ def cmd_fold(args: argparse.Namespace) -> None:
4295
5154
  proj_text = _prepend_key_decision_row(proj_text, row)
4296
5155
  proj_text = re.sub(r"foundation-version:\s*\d+", f"foundation-version: {new_v}", proj_text, count=1)
4297
5156
 
4298
- # ── all bodies built; commit via a two-phase write (stage every temp, then rename-all). A
4299
- # phase-1 temp-write failure the REALISTIC one (disk-full / permission) leaves NOTHING
4300
- # written. A phase-2 mid-rename failure (near-impossible on same-dir renames) can leave the
4301
- # foundation advanced while a TASK.md stays unflipped; files are ordered foundation-FIRST so
4302
- # the lesson then stays visibly `open` and a re-run re-transcribes (DUPLICATING, never
4303
- # losing — manual fixup), rather than a silently-flipped-but-untranscribed loss. A true
4304
- # all-or-nothing N-file commit is the multi-file-commit follow-up task. ────────────────────
5157
+ # ── all bodies built; commit ALL via the all-or-nothing multi-file primitive: a stage failure
5158
+ # (disk-full / permission) writes nothing, and a mid-commit rename failure rolls back every
5159
+ # already-committed file so the foundation never advances while a TASK.md stays unflipped. ─
4305
5160
  writes: list[tuple[Path, str]] = [(project_md, proj_text)]
4306
5161
  touched = ["PROJECT.md"]
4307
5162
  if conv_text != conventions_text:
@@ -4764,13 +5619,18 @@ def _render_changelog_block(version: str, day: str, bundle: list[dict],
4764
5619
 
4765
5620
 
4766
5621
  def _render_releases_row(version: str, day: str, bundle: list[dict],
4767
- waiver_slugs: list[str], evidence: str | None) -> str:
4768
- """One append-only RELEASES.md row the attribution source (`milestones:` membership)."""
5622
+ waiver_slugs: list[str], evidence: str | None,
5623
+ actor: str | None = None) -> str:
5624
+ """One append-only RELEASES.md row — the attribution source (`milestones:` membership).
5625
+ The `actor:` line records WHO cut the release (structured-actor stamping); absent on a
5626
+ legacy row (back-compat) when no actor is supplied."""
4769
5627
  ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
4770
5628
  wv = ", ".join(waiver_slugs) if waiver_slugs else "none"
5629
+ actor_line = f"actor: {actor}\n" if actor else ""
4771
5630
  return (f"## {version} — {day}\n"
4772
5631
  f"milestones: {ms}\n"
4773
5632
  f"waivers: {wv}\n"
5633
+ f"{actor_line}"
4774
5634
  f"evidence: {evidence or 'recorded by add.py release'}\n\n")
4775
5635
 
4776
5636
 
@@ -4816,20 +5676,13 @@ def cmd_release(args: argparse.Namespace) -> None:
4816
5676
  _render_changelog_block(args.version, day, bundle, changed_by_slug))
4817
5677
  new_rel = _prepend_block(rel_before, "# Releases",
4818
5678
  _render_releases_row(args.version, day, bundle, waiver_slugs,
4819
- getattr(args, "evidence", None)))
4820
- _atomic_write(changelog_path, new_cl)
4821
- try:
4822
- _atomic_write(releases_path, new_rel) # the attribution commit point
5679
+ getattr(args, "evidence", None),
5680
+ _render_actor_line(state)))
5681
+ try: # CHANGELOG + RELEASES as one all-or-nothing commit
5682
+ _atomic_write_many([(changelog_path, new_cl), (releases_path, new_rel)])
4823
5683
  except OSError as e:
4824
- if cl_before is not None: # ROLLBACK (design-for-failure)
4825
- _atomic_write(changelog_path, cl_before)
4826
- else:
4827
- try:
4828
- changelog_path.unlink()
4829
- except OSError:
4830
- pass
4831
- _die(f"release_write_failed: the ledger write failed ({e}); CHANGELOG was rolled back — "
4832
- "nothing was recorded. Retry the release.")
5684
+ _die(f"release_write_failed: the ledger write failed ({e}); nothing was recorded — both "
5685
+ "files were rolled back to their prior content. Retry the release.")
4833
5686
 
4834
5687
  # NO save_state — attribution lives in RELEASES.md (the cue re-reads it), never state.json
4835
5688
  ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
@@ -4926,13 +5779,13 @@ def cmd_report(args: argparse.Namespace) -> None:
4926
5779
  else:
4927
5780
  _die(f"unknown_milestone: '{name}' is not a milestone")
4928
5781
  elif getattr(args, "decide", False): # bare --decide -> the ACTIVE TASK
4929
- slug = state.get("active_task")
5782
+ slug = _active_task(state)
4930
5783
  if not slug or slug not in tasks:
4931
5784
  _die("no_active_task — name one: add.py report <milestone> <task> --decide")
4932
5785
  drill_task = slug
4933
5786
  mslug = tasks[slug].get("milestone") or ""
4934
5787
  else: # no positional -> active milestone
4935
- mslug = state.get("active_milestone")
5788
+ mslug = _active_milestone(state)
4936
5789
  if not mslug:
4937
5790
  _die("no_active_milestone: no milestone given and none is active; "
4938
5791
  "try `add.py report <milestone>`")
@@ -5005,6 +5858,33 @@ def build_parser() -> argparse.ArgumentParser:
5005
5858
  pl.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
5006
5859
  pl.set_defaults(func=cmd_lock)
5007
5860
 
5861
+ pwho = sub.add_parser("whoami",
5862
+ help="show / set the git-native actor (git config -> OS user; --name to override)")
5863
+ # --name (set) and --unset (clear) are mutually exclusive — argparse rejects the
5864
+ # contradiction (exit 2) before any state read, so neither silently wins.
5865
+ pwho_mut = pwho.add_mutually_exclusive_group()
5866
+ pwho_mut.add_argument("--name", default=None, help="set an actor override (name)")
5867
+ pwho_mut.add_argument("--unset", action="store_true", help="clear the actor override")
5868
+ pwho.add_argument("--email", default=None, help="set the override email (with --name)")
5869
+ pwho.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
5870
+ pwho.set_defaults(func=cmd_whoami)
5871
+
5872
+ pas = sub.add_parser("assign",
5873
+ help="assign an owner/assignee to a task or milestone (no flag = self)")
5874
+ pas.add_argument("slug")
5875
+ pas.add_argument("--owner", default=None, metavar="\"Name <email>\"",
5876
+ help="set the accountable owner (default with no flag: self)")
5877
+ pas.add_argument("--assignee", default=None, metavar="\"Name <email>\"",
5878
+ help="set the working assignee (default with no flag: self)")
5879
+ pas.set_defaults(func=cmd_assign)
5880
+
5881
+ pun = sub.add_parser("unassign",
5882
+ help="clear the owner/assignee of a task or milestone (no flag = both)")
5883
+ pun.add_argument("slug")
5884
+ pun.add_argument("--owner", action="store_true", help="clear only the owner")
5885
+ pun.add_argument("--assignee", action="store_true", help="clear only the assignee")
5886
+ pun.set_defaults(func=cmd_unassign)
5887
+
5008
5888
  pn = sub.add_parser("new-task", help="scaffold a new task (TASK.md + tests/ + src/)")
5009
5889
  pn.add_argument("slug")
5010
5890
  pn.add_argument("--title", default=None)
@@ -5012,14 +5892,23 @@ def build_parser() -> argparse.ArgumentParser:
5012
5892
  pn.add_argument("--depends-on", dest="depends_on", default=None,
5013
5893
  help="comma-separated task slugs this task depends on")
5014
5894
  pn.add_argument("--from-delta", dest="from_delta", default=None, metavar="PRIOR",
5015
- help="SEED PRIOR's first open SPEC delta into this task (pre-fills §1 "
5895
+ help="SEED PRIOR's open SPEC delta into this task (pre-fills §1 "
5016
5896
  "Feature, flips the source -> [SPEC · seeded] [→ this])")
5897
+ pn.add_argument("--match", default=None, metavar="SUBSTR",
5898
+ help="with --from-delta: target the UNIQUE open SPEC delta whose text "
5899
+ "contains SUBSTR (case-insensitive) instead of the first")
5017
5900
  pn.add_argument("--force", action="store_true", help="overwrite TASK.md if present")
5901
+ pn.add_argument("--fast", action="store_true",
5902
+ help="opt into the fast lane: scaffold the minimal TASK.fast.md template + "
5903
+ "hold the task to the freeze floor under any milestone")
5018
5904
  pn.set_defaults(func=cmd_new_task)
5019
5905
 
5020
5906
  pdd = sub.add_parser("drop-delta",
5021
- help="dismiss a task's first open SPEC delta -> [SPEC · dropped]")
5022
- pdd.add_argument("slug", help="task whose first open SPEC delta to drop")
5907
+ help="dismiss a task's open SPEC delta -> [SPEC · dropped]")
5908
+ pdd.add_argument("slug", help="task whose open SPEC delta to drop")
5909
+ pdd.add_argument("--match", default=None, metavar="SUBSTR",
5910
+ help="target the UNIQUE open SPEC delta whose text contains SUBSTR "
5911
+ "(case-insensitive) instead of the first")
5023
5912
  pdd.set_defaults(func=cmd_drop_delta)
5024
5913
 
5025
5914
  pm = sub.add_parser("new-milestone", help="scaffold a milestone (SDD living doc)")
@@ -5028,8 +5917,18 @@ def build_parser() -> argparse.ArgumentParser:
5028
5917
  pm.add_argument("--goal", default=None, help="one-sentence outcome")
5029
5918
  pm.add_argument("--stage", default="mvp", choices=STAGES)
5030
5919
  pm.add_argument("--force", action="store_true", help="overwrite MILESTONE.md if present")
5920
+ pm.add_argument("--await-confirm", action="store_true",
5921
+ help="opt into the confirm-parent gate: seed the milestone unconfirmed so "
5922
+ "new-task is held until `milestone-confirm` (mirrors `init --await-lock`); "
5923
+ "the guided skill flow passes this at the human-review point")
5031
5924
  pm.set_defaults(func=cmd_new_milestone)
5032
5925
 
5926
+ pmc = sub.add_parser("milestone-confirm",
5927
+ help="confirm a milestone (the human gate that opens new-task for it)")
5928
+ pmc.add_argument("slug")
5929
+ pmc.add_argument("--by", default=None, help="free-text confirmer name (defaults to the OS user)")
5930
+ pmc.set_defaults(func=cmd_milestone_confirm)
5931
+
5033
5932
  pr = sub.add_parser("ready", help="list tasks whose dependencies are satisfied")
5034
5933
  pr.add_argument("--json", action="store_true", help="machine-readable JSON output")
5035
5934
  pr.set_defaults(func=cmd_ready)
@@ -5054,6 +5953,16 @@ def build_parser() -> argparse.ArgumentParser:
5054
5953
  pu.add_argument("slug")
5055
5954
  pu.set_defaults(func=cmd_use)
5056
5955
 
5956
+ pac = sub.add_parser("activate",
5957
+ help="add a milestone to the active working SET and focus it (parallel milestones)")
5958
+ pac.add_argument("slug")
5959
+ pac.set_defaults(func=cmd_activate)
5960
+
5961
+ pdac = sub.add_parser("deactivate",
5962
+ help="remove a milestone from the active working SET (its files stay on disk)")
5963
+ pdac.add_argument("slug")
5964
+ pdac.set_defaults(func=cmd_deactivate)
5965
+
5057
5966
  pam = sub.add_parser("archive-milestone",
5058
5967
  help="collapse a done milestone out of active state (files stay on disk)")
5059
5968
  pam.add_argument("slug")
@@ -5063,6 +5972,9 @@ def build_parser() -> argparse.ArgumentParser:
5063
5972
  help="heavy archive: move an archived milestone's files into "
5064
5973
  ".add/archive/<slug>/ (recoverable reverse move)")
5065
5974
  pco.add_argument("slug")
5975
+ pco.add_argument("--force", action="store_true",
5976
+ help="compact past an unrelated open SPEC delta (the open_spec_deltas_unresolved "
5977
+ "guard ONLY; never a structural reject) — bypass is warned + recorded")
5066
5978
  pco.set_defaults(func=cmd_compact)
5067
5979
 
5068
5980
  pp = sub.add_parser("phase", help="set a task's phase explicitly")
@@ -5121,6 +6033,17 @@ def build_parser() -> argparse.ArgumentParser:
5121
6033
  pck.add_argument("--json", action="store_true", help="machine-readable JSON output")
5122
6034
  pck.set_defaults(func=cmd_check)
5123
6035
 
6036
+ pdoc = sub.add_parser("doctor", help="read-only diagnosis of state.json integrity + "
6037
+ "referential consistency (run after a git merge)")
6038
+ pdoc.set_defaults(func=cmd_doctor)
6039
+
6040
+ pmine = sub.add_parser("mine", help="read-only: my not-done tasks (owner or assignee) "
6041
+ "across all active milestones")
6042
+ pmine.add_argument("--actor", default=None, metavar="\"Name <email>\"",
6043
+ help="inspect another actor's queue instead of your own")
6044
+ pmine.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
6045
+ pmine.set_defaults(func=cmd_mine)
6046
+
5124
6047
  pwv = sub.add_parser("wave-verify",
5125
6048
  help="read-only merge-time gate: every WAVE.md roster echo must match "
5126
6049
  "base (refuses unverified_fork_base) — run before the first merge-back")