@pilotspace/add 1.7.2 → 1.8.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
@@ -173,29 +175,59 @@ def _atomic_write(path: Path, text: str) -> None:
173
175
 
174
176
 
175
177
  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.
178
+ """True all-or-nothing commit across N files — design-for-failure for a multi-file write.
179
+
180
+ Phase 1 STAGES every (path, text) to a sibling `.tmp`, flushing + fsync-ing each, so the
181
+ realistic IO failures (disk full, permission denied) surface HERE, before any target changes —
182
+ and on any stage failure every staged temp is removed, so NOTHING is committed. Phase 2 then
183
+ COMMITS each file by renaming any existing target ASIDE to a sibling `.bak`, then `os.replace`-ing
184
+ the staged `.tmp` into place. If ANY commit rename raises, every file already committed is rolled
185
+ back IN REVERSE (remove the landed new file, rename its `.bak` back, or leave it absent) and the
186
+ original error re-raised — so the whole set is all-or-nothing: either every file holds its new
187
+ text, or every file holds its prior content. Restoring is an atomic rename of an already-written
188
+ `.bak` (no content held in memory, no re-write), the cheapest recovery under failing IO. Leftover
189
+ `.tmp`/`.bak` siblings are removed on every exit path.
184
190
  """
185
191
  staged: list[tuple[str, Path]] = []
192
+ committed: list[list] = [] # [path, bak_or_None, new_landed] per committed file
186
193
  try:
187
- for path, text in writes:
194
+ for path, text in writes: # phase 1: stage every temp (fsync'd before any commit)
188
195
  path.parent.mkdir(parents=True, exist_ok=True)
189
196
  fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
197
+ staged.append((tmp, path)) # track BEFORE write so a write/fsync failure still cleans it
190
198
  with os.fdopen(fd, "w", encoding="utf-8") as fh:
191
199
  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)
200
+ fh.flush()
201
+ os.fsync(fh.fileno())
202
+ try:
203
+ for tmp, path in staged: # phase 2: commit via rename-aside
204
+ bak = None
205
+ existed = path.exists()
206
+ if existed:
207
+ fd2, bak = tempfile.mkstemp(dir=str(path.parent), suffix=".bak")
208
+ os.close(fd2)
209
+ committed.append([path, bak, False]) # track .bak NOW so cleanup never leaks it
210
+ if existed:
211
+ os.replace(path, bak) # move the existing target aside
212
+ os.replace(tmp, path) # move the new file in
213
+ committed[-1][2] = True
214
+ except OSError:
215
+ for path, bak, landed in reversed(committed): # roll back, newest-first
216
+ try:
217
+ if landed and path.exists():
218
+ os.unlink(path) # drop the new file we put in
219
+ if bak is not None and not path.exists():
220
+ os.replace(bak, path) # restore the prior target (atomic rename)
221
+ except OSError:
222
+ pass # best-effort restore under already-failing IO
223
+ raise
195
224
  finally:
196
- for tmp, _ in staged: # leftover temps (a failed/aborted stage) never persist
225
+ for tmp, _ in staged: # leftover .tmp (failed/aborted stage) never persists
197
226
  if os.path.exists(tmp):
198
227
  os.unlink(tmp)
228
+ for path, bak, landed in committed: # leftover .bak (success, or orphaned post-rollback)
229
+ if bak is not None and os.path.exists(bak):
230
+ os.unlink(bak)
199
231
 
200
232
 
201
233
  def _templates_dir() -> Path:
@@ -238,12 +270,238 @@ def _require_root() -> Path:
238
270
  return root
239
271
 
240
272
 
273
+ def _migrate_state(state: dict) -> dict:
274
+ """Forward-migrate a single-active state to the multi-active schema (team-collaboration
275
+ foundation). PURE · idempotent · TOTAL · never raises · no I/O.
276
+
277
+ A state lacking the `active_milestones` key gains it — DERIVED from the scalar
278
+ `active_milestone` (grandfather-by-missing-key, mirroring `_setup_locked`): None -> [],
279
+ "x" -> ["x"]. A per-milestone active-task map `active_tasks` is added; the old global
280
+ `active_task` is placed under its owning active milestone only when it genuinely belongs
281
+ there, else it stays as the top-level scalar fallback (orphan rule, FROZEN decision (a)).
282
+ The scalar `active_milestone` / `active_task` keys are KEPT as the N<=1 mirror so the
283
+ not-yet-routed readers keep working. An already-migrated state (key present) is returned
284
+ unchanged — never re-derived, never clobbered. Corrupt parsing stays the loader's job.
285
+
286
+ PURE in the observable sense: the caller's dict is NEVER mutated — a state that needs
287
+ migrating is upgraded on a fresh top-level copy (nested objects are shared but only read)."""
288
+ if not isinstance(state, dict) or "active_milestones" in state:
289
+ return state
290
+ migrated = dict(state)
291
+ active_ms = migrated.get("active_milestone")
292
+ migrated["active_milestones"] = [] if active_ms is None else [active_ms]
293
+ active_task = migrated.get("active_task")
294
+ tasks = migrated.get("tasks") or {}
295
+ owns = (active_ms is not None and active_task is not None
296
+ and isinstance(tasks.get(active_task), dict)
297
+ and tasks[active_task].get("milestone") == active_ms)
298
+ migrated["active_tasks"] = {active_ms: active_task} if owns else {}
299
+ return migrated
300
+
301
+
302
+ # --- active milestone/task accessor seam (multi-active foundation) -----------
303
+ #
304
+ # Every engine call site reads & writes the active milestone(s)/task through these
305
+ # four helpers, so multi-active SEMANTICS can land in one place later. Today they
306
+ # preserve single-active behavior exactly: reads return the scalar mirror, writes
307
+ # keep the scalar AND the new active_milestones/active_tasks structures in sync.
308
+
309
+ def _active_milestone(state: dict) -> str | None:
310
+ """The primary active milestone — the N<=1 scalar mirror (== active_milestones[0])."""
311
+ return state.get("active_milestone")
312
+
313
+
314
+ def _active_task(state: dict, milestone: str | None = None) -> str | None:
315
+ """The active task: per-milestone when `milestone` is given (partial-state -> None),
316
+ else the global/primary scalar active task. Total — never raises."""
317
+ if milestone is None:
318
+ return state.get("active_task")
319
+ return (state.get("active_tasks") or {}).get(milestone)
320
+
321
+
322
+ def _set_active_milestone(state: dict, slug: str | None) -> None:
323
+ """Set the primary active milestone, keeping `active_milestones` consistent (N<=1 sync)."""
324
+ state["active_milestone"] = slug
325
+ state["active_milestones"] = [] if slug is None else [slug]
326
+
327
+
328
+ def _set_active_task(state: dict, slug: str | None, milestone: str | None = None) -> None:
329
+ """Set the active task, keeping the scalar mirror AND the per-milestone map in sync.
330
+ With no owning active milestone the active task is scalar-only (the migration's orphan
331
+ rule); clearing (slug is None) pops the milestone's entry."""
332
+ state["active_task"] = slug
333
+ ms = milestone if milestone is not None else _active_milestone(state)
334
+ tasks_map = state.setdefault("active_tasks", {})
335
+ if ms is None:
336
+ return
337
+ if slug is None:
338
+ tasks_map.pop(ms, None)
339
+ else:
340
+ tasks_map[ms] = slug
341
+
342
+
343
+ def _activate_milestone(state: dict, slug: str) -> None:
344
+ """Add a milestone to the active SET (idempotent) and make it the primary focus,
345
+ syncing the scalar active_task to that milestone's entry. Does NOT remove other members
346
+ (this is how a user reaches N>=2 active milestones)."""
347
+ ms_list = state.setdefault("active_milestones", [])
348
+ if slug not in ms_list:
349
+ ms_list.append(slug)
350
+ state["active_milestone"] = slug
351
+ state["active_task"] = (state.get("active_tasks") or {}).get(slug)
352
+
353
+
354
+ def _deactivate_milestone(state: dict, slug: str) -> None:
355
+ """Remove a milestone from the active SET, pop its active-task entry, and (if it was the
356
+ primary) repoint the primary to the most-recent remaining member (or None when empty)."""
357
+ ms_list = state.setdefault("active_milestones", [])
358
+ if slug in ms_list:
359
+ ms_list.remove(slug)
360
+ (state.setdefault("active_tasks", {})).pop(slug, None)
361
+ if state.get("active_milestone") == slug:
362
+ new = ms_list[-1] if ms_list else None
363
+ state["active_milestone"] = new
364
+ state["active_task"] = (state.get("active_tasks") or {}).get(new) if new else None
365
+
366
+
367
+ def _git_config(key: str) -> str | None:
368
+ """Read one `git config --get <key>`, STRICTLY fail-soft: the engine's FIRST git call,
369
+ so it never raises, never hangs, never shells. Returns the trimmed value, or None when
370
+ git is absent / errors / times out / the value is empty."""
371
+ if shutil.which("git") is None:
372
+ return None
373
+ try:
374
+ out = subprocess.run(
375
+ ["git", "config", "--get", key],
376
+ capture_output=True, text=True, timeout=2,
377
+ ).stdout.strip()
378
+ except (OSError, subprocess.SubprocessError, ValueError):
379
+ # OSError: git vanished between which() and run() / spawn error · SubprocessError:
380
+ # TimeoutExpired · ValueError: a non-UTF-8 config value (latin-1 legacy name) makes
381
+ # text=True decoding raise UnicodeDecodeError (a ValueError) — all fail soft to None.
382
+ return None
383
+ return out or None
384
+
385
+
386
+ def _os_user() -> str:
387
+ """The guaranteed non-empty OS floor. getpass.getuser() reads LOGNAME/USER/... then
388
+ falls back to the passwd database — but in a bare container (no env var AND no passwd
389
+ entry) CPython raises KeyError (OSError only on 3.13+). Catch broadly and return a
390
+ sentinel so _whoami stays TOTAL: it always yields a non-empty name, never crashes."""
391
+ try:
392
+ return getpass.getuser() or "unknown"
393
+ except (KeyError, OSError):
394
+ return "unknown"
395
+
396
+
397
+ def _whoami(state: dict) -> dict:
398
+ """Resolve the current git-native ACTOR -> {name, email, source}. Priority:
399
+ (1) an `actor_override` (whoami --set) with a non-blank name -> source 'override';
400
+ (2) `git config user.name`/`user.email` -> source 'git';
401
+ (3) the OS user (_os_user) -> source 'os', the guaranteed non-empty floor.
402
+ Total: always returns a dict with a non-empty name; `email` may be None."""
403
+ ov = state.get("actor_override")
404
+ if ov and (ov.get("name") or "").strip():
405
+ return {"name": ov["name"], "email": ov.get("email"), "source": "override"}
406
+ name = _git_config("user.name")
407
+ if name:
408
+ return {"name": name, "email": _git_config("user.email"), "source": "git"}
409
+ return {"name": _os_user(), "email": None, "source": "os"}
410
+
411
+
412
+ def _actor_stamp(state: dict) -> dict:
413
+ """The SINGLE source of the structured-actor stamp every engine-WRITTEN human action
414
+ records — lock · gate · milestone-done · release (user-identity actor-stamping). It IS
415
+ `_whoami(state)`: a TOTAL {name,email,source} (always a non-empty name), so a stamp can
416
+ never fail or block a write. Descriptive only — no command's decision reads it."""
417
+ return _whoami(state)
418
+
419
+
420
+ def _render_actor_line(state: dict) -> str:
421
+ """Render the actor stamp as one human-readable line: name, an optional angle-bracketed
422
+ email, then the source in parens — used on the RELEASES.md row (no state.json write)."""
423
+ a = _actor_stamp(state)
424
+ email = f" <{a['email']}>" if a.get("email") else ""
425
+ return f"{a['name']}{email} ({a['source']})"
426
+
427
+
428
+ def _parse_actor_arg(s: str) -> dict:
429
+ """Parse an `assign --owner`/`--assignee` value into a {name, email, source: "assigned"}
430
+ actor (ownership-assignment). "Name <email>" -> both; a bare "Name" -> email None. TOTAL:
431
+ a malformed value (no closing bracket) never raises — the whole stripped string is the name.
432
+ `source` is "assigned" — a human typed this name (not git-resolved nor an ADD override)."""
433
+ m = re.match(r"^\s*(.*?)\s*<([^>]*)>\s*$", s)
434
+ if m:
435
+ return {"name": m.group(1), "email": m.group(2) or None, "source": "assigned"}
436
+ return {"name": s.strip(), "email": None, "source": "assigned"}
437
+
438
+
439
+ def _actor_matches(rec_actor: dict | None, me: dict) -> bool:
440
+ """Does a recorded owner/assignee actor identify the SAME person as `me` (multi-active-UX)?
441
+ Email-first (the stabler key): when BOTH carry a non-empty email, emails decide; otherwise
442
+ fall back to name-equality. Both comparisons are stripped + case-insensitive. TOTAL — a None,
443
+ non-dict, or blank-name record returns False (an unowned/garbage slot is no one's)."""
444
+ if not isinstance(rec_actor, dict):
445
+ return False
446
+ rec_name = (rec_actor.get("name") or "").strip()
447
+ if not rec_name:
448
+ return False
449
+ rec_email = (rec_actor.get("email") or "").strip()
450
+ me_email = (me.get("email") or "").strip()
451
+ if rec_email and me_email:
452
+ return rec_email.lower() == me_email.lower()
453
+ return rec_name.lower() == (me.get("name") or "").strip().lower()
454
+
455
+
456
+ def _my_work(state: dict, me: dict) -> list[dict]:
457
+ """The "my work" lens (multi-active-UX): across ALL active milestones, the NOT-done tasks
458
+ whose owner OR assignee is `me`. Returns ordered rows {slug, milestone, phase, role} with
459
+ role in {owner, assignee, both}, sorted by active-milestone order then slug. PURE · no I/O."""
460
+ active = list(state.get("active_milestones") or [])
461
+ active_set = set(active)
462
+ tasks = state.get("tasks") if isinstance(state.get("tasks"), dict) else {}
463
+ rows: list[dict] = []
464
+ for slug, t in tasks.items():
465
+ if not isinstance(t, dict) or t.get("milestone") not in active_set or _task_done(t):
466
+ continue
467
+ owns = _actor_matches(t.get("owner"), me)
468
+ assigned = _actor_matches(t.get("assignee"), me)
469
+ if not (owns or assigned):
470
+ continue
471
+ role = "both" if owns and assigned else ("owner" if owns else "assignee")
472
+ rows.append({"slug": slug, "milestone": t.get("milestone"),
473
+ "phase": t.get("phase"), "role": role})
474
+ order = {m: i for i, m in enumerate(active)}
475
+ rows.sort(key=lambda r: (order.get(r["milestone"], len(order)), r["slug"]))
476
+ return rows
477
+
478
+
479
+ # A git conflict marker BEGINS a line with 7 of `<`, `=`, or `>` (`(?m)^…`). An unresolved
480
+ # merge writes these into state.json, making it invalid JSON; the line-anchor keeps a
481
+ # legitimate value (always on an INDENTED JSON line) from false-tripping the guard.
482
+ _CONFLICT_MARKER_RE = re.compile(r"(?m)^(<{7}|={7}|>{7})")
483
+
484
+
485
+ def _state_text_or_die(root: Path) -> str:
486
+ """Read state.json's raw text, failing CLOSED with a merge-SPECIFIC `state_conflicted`
487
+ message when it carries git conflict markers (an unresolved merge — the major's #1 failure
488
+ mode). A genuine read OSError is NOT swallowed: it propagates to the caller, which maps it
489
+ to its own existing code (state_invalid / no_state). The guard only READS — never writes."""
490
+ text = (root / STATE_FILE).read_text(encoding="utf-8")
491
+ if _CONFLICT_MARKER_RE.search(text):
492
+ _die(f"state_conflicted: {root / STATE_FILE} has unresolved git merge markers "
493
+ f"(<<<<<<< / ======= / >>>>>>>) — resolve them (or "
494
+ f"`git checkout --ours/--theirs {STATE_FILE}`), then run `add.py doctor` to verify")
495
+ return text
496
+
497
+
241
498
  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)."""
499
+ """Load + parse state.json, failing CLOSED. A git-conflicted file dies with a merge-specific
500
+ 'state_conflicted'; any other corrupt/unreadable file dies with a clean 'state_invalid'
501
+ message (never a raw traceback), so every command that loads state degrades gracefully
502
+ (design-for-failure). The parsed state is forward-migrated to the multi-active schema."""
245
503
  try:
246
- return json.loads((root / STATE_FILE).read_text(encoding="utf-8"))
504
+ return _migrate_state(json.loads(_state_text_or_die(root)))
247
505
  except (json.JSONDecodeError, OSError) as e:
248
506
  _die(f"state_invalid: {root / STATE_FILE} is corrupt or unreadable "
249
507
  f"({e.__class__.__name__}) — restore it from git or a backup")
@@ -252,12 +510,13 @@ def load_state(root: Path) -> dict:
252
510
  def _load_state_for_json() -> tuple[Path, dict]:
253
511
  """Fail-closed state load for `--json` paths: a missing project or unparseable
254
512
  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."""
513
+ JSON object a harness might parse). Built from State only — reads no docs/ chapter.
514
+ The parsed state is forward-migrated to the multi-active schema before it is returned."""
256
515
  root = find_root()
257
516
  if root is None:
258
517
  _die("no_state")
259
518
  try:
260
- return root, json.loads((root / STATE_FILE).read_text(encoding="utf-8"))
519
+ return root, _migrate_state(json.loads(_state_text_or_die(root)))
261
520
  except (json.JSONDecodeError, OSError):
262
521
  _die("no_state")
263
522
 
@@ -453,6 +712,8 @@ def cmd_init(args: argparse.Namespace) -> None:
453
712
  "stage": args.stage,
454
713
  "active_task": None,
455
714
  "active_milestone": None,
715
+ "active_milestones": [],
716
+ "active_tasks": {},
456
717
  "tasks": {},
457
718
  "milestones": {},
458
719
  "created": _now(),
@@ -499,7 +760,7 @@ def cmd_new_task(args: argparse.Namespace) -> None:
499
760
  _die(f"task '{slug}' already exists (use --force to overwrite TASK.md)")
500
761
 
501
762
  # link to a milestone (explicit, or the active one) — validate before any write
502
- milestone = getattr(args, "milestone", None) or state.get("active_milestone")
763
+ milestone = getattr(args, "milestone", None) or _active_milestone(state)
503
764
  if milestone and milestone not in state.get("milestones", {}):
504
765
  _die("unknown_milestone")
505
766
  depends_on = _parse_deps(getattr(args, "depends_on", None))
@@ -509,16 +770,25 @@ def cmd_new_task(args: argparse.Namespace) -> None:
509
770
  # seeded flip NOW (before any write); the slug-free check above has already passed, so
510
771
  # the only writes below are the new TASK.md, then the prior flip, then state.
511
772
  from_delta = getattr(args, "from_delta", None)
773
+ match = getattr(args, "match", None)
774
+ if match and not from_delta: # --match targets the PRIOR's delta
775
+ _die("match_requires_from_delta: --match needs --from-delta <prior> (it selects the "
776
+ "prior task's open SPEC delta to seed)")
512
777
  feature_override = prior_md = flipped_prior = None
513
778
  if from_delta:
514
779
  prior = _resolve_task(state, from_delta) # unknown prior -> _die
515
780
  prior_md = root / "tasks" / prior / "TASK.md"
516
781
  prior_text = prior_md.read_text(encoding="utf-8")
517
- delta_text = _first_open_spec_text(prior_text)
518
- if delta_text is None:
782
+ status, idx, delta_text = _select_spec_delta(prior_text, match)
783
+ if status == "no_open":
519
784
  _die(f"no_open_spec_delta: task '{prior}' has no open SPEC delta to seed")
785
+ if status == "no_match":
786
+ _die(f"no_matching_spec_delta: no open SPEC delta in '{prior}' matches --match '{match}'")
787
+ if status == "ambiguous":
788
+ _die(f"ambiguous_spec_match: --match '{match}' matches multiple open SPEC deltas in "
789
+ f"'{prior}' — narrow it")
520
790
  feature_override = f"{delta_text} (from {prior} spec-delta)"
521
- flipped_prior = _resolve_spec_delta(prior_text, "seeded", pointer=slug)
791
+ flipped_prior = _resolve_spec_delta(prior_text, "seeded", pointer=slug, line_index=idx)
522
792
 
523
793
  (tdir / "tests").mkdir(parents=True, exist_ok=True)
524
794
  (tdir / "src").mkdir(parents=True, exist_ok=True)
@@ -532,9 +802,10 @@ def cmd_new_task(args: argparse.Namespace) -> None:
532
802
  if feature_override: # pre-fill §1 from the seeded delta
533
803
  rendered = re.sub(r"(?m)^Feature:.*$",
534
804
  lambda _m: f"Feature: {feature_override}", rendered, count=1)
535
- _atomic_write(task_md, rendered)
805
+ seed_writes: list[tuple[Path, str]] = [(task_md, rendered)]
536
806
  if flipped_prior is not None: # consume the source delta -> seeded
537
- _atomic_write(prior_md, flipped_prior)
807
+ seed_writes.append((prior_md, flipped_prior))
808
+ _atomic_write_many(seed_writes) # new TASK.md + consumed source as one commit
538
809
  if _project_autonomy_token(root) == "?":
539
810
  print("warning: garbled_project_autonomy — PROJECT.md declares an unrecognized "
540
811
  f"autonomy token; new task seeded fail-safe '{autonomy}' "
@@ -551,7 +822,7 @@ def cmd_new_task(args: argparse.Namespace) -> None:
551
822
  }
552
823
  if from_delta:
553
824
  state["tasks"][slug]["from_delta"] = from_delta # lineage: seeded from <prior>
554
- state["active_task"] = slug
825
+ _set_active_task(state, slug, milestone)
555
826
  save_state(root, state)
556
827
  print(f"created task '{slug}' -> {task_md}")
557
828
  if milestone:
@@ -579,11 +850,19 @@ def cmd_drop_delta(args: argparse.Namespace) -> None:
579
850
  state = load_state(root)
580
851
  slug = _resolve_task(state, args.slug) # unknown task -> _die
581
852
  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:
853
+ text = task_md.read_text(encoding="utf-8")
854
+ match = getattr(args, "match", None)
855
+ status, idx, _disp = _select_spec_delta(text, match)
856
+ if status == "no_open":
584
857
  _die(f"no_open_spec_delta: task '{slug}' has no open SPEC delta to drop")
858
+ if status == "no_match":
859
+ _die(f"no_matching_spec_delta: no open SPEC delta in '{slug}' matches --match '{match}'")
860
+ if status == "ambiguous":
861
+ _die(f"ambiguous_spec_match: --match '{match}' matches multiple open SPEC deltas in "
862
+ f"'{slug}' — narrow it")
863
+ new_text = _resolve_spec_delta(text, "dropped", line_index=idx)
585
864
  _atomic_write(task_md, new_text)
586
- print(f"dropped the first open SPEC delta in '{slug}' -> [SPEC · dropped]")
865
+ print(f"dropped the {'matched' if match else 'first'} open SPEC delta in '{slug}' -> [SPEC · dropped]")
587
866
  print(_next_footer(root, state))
588
867
 
589
868
 
@@ -618,7 +897,7 @@ def _archived_task_slugs(state: dict) -> set[str]:
618
897
 
619
898
 
620
899
  def _resolve_task(state: dict, slug: str | None) -> str:
621
- slug = slug or state.get("active_task")
900
+ slug = slug or _active_task(state)
622
901
  if not slug:
623
902
  _die("no task specified and no active task set")
624
903
  if slug not in state["tasks"]:
@@ -699,6 +978,15 @@ def cmd_advance(args: argparse.Namespace) -> None:
699
978
  _sync_task_marker(root, slug, nxt)
700
979
  save_state(root, state)
701
980
  print(f"task '{slug}' phase {cur} -> {nxt}")
981
+ if nxt == "observe":
982
+ # OBSERVE is where this loop's lessons get captured (TASK.md §7) — suggest routing
983
+ # them into PROJECT.md right away (a per-task fold is engine-legal; otherwise the
984
+ # lessons sit unconsolidated until milestone close). Additive: fires only at this
985
+ # one transition, and the human still decides whether/when to run it. The verb word
986
+ # is interpolated via _FOLD_VERB so the domain wording-lint sees no bare slang.
987
+ print(" note: record the lessons this loop taught the foundation in §7 "
988
+ "OBSERVE, then update PROJECT.md when ready:")
989
+ print(f" add.py {_FOLD_VERB} --task {slug} (review first: add.py deltas)")
702
990
  print(_next_footer(root, state))
703
991
 
704
992
 
@@ -835,6 +1123,7 @@ def cmd_gate(args: argparse.Namespace) -> None:
835
1123
  state["tasks"][slug]["phase"] = "done"
836
1124
  _sync_task_marker(root, slug, "done")
837
1125
  state["tasks"][slug]["gate"] = args.outcome
1126
+ state["tasks"][slug]["gate_actor"] = _actor_stamp(state) # WHO recorded the verdict (every outcome)
838
1127
  state["tasks"][slug]["updated"] = _now()
839
1128
  save_state(root, state)
840
1129
  print(f"task '{slug}' gate -> {args.outcome}")
@@ -1002,7 +1291,8 @@ def cmd_lock(args: argparse.Namespace) -> None:
1002
1291
  who = args.by or getpass.getuser()
1003
1292
  when = _now()
1004
1293
  # ONE atomic write — no partial lock state.
1005
- state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers}
1294
+ state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers,
1295
+ "actor": _actor_stamp(state)} # structured actor alongside the free-text locked_by
1006
1296
  save_state(root, state)
1007
1297
  if getattr(args, "json", False):
1008
1298
  print(json.dumps(
@@ -1013,6 +1303,92 @@ def cmd_lock(args: argparse.Namespace) -> None:
1013
1303
  print(_next_footer(root, state))
1014
1304
 
1015
1305
 
1306
+ def cmd_whoami(args: argparse.Namespace) -> None:
1307
+ """Show / set / unset the git-native ACTOR — the identity every human-owned stamp reads.
1308
+ No flags: print the resolved actor (override -> git -> os). --name/--email: store an
1309
+ override. --unset: clear it. Validates before mutating (a reject leaves state unchanged)."""
1310
+ root = _require_root()
1311
+ state = load_state(root)
1312
+ if args.unset:
1313
+ if "actor_override" not in state:
1314
+ _die("no_actor_override")
1315
+ del state["actor_override"]
1316
+ save_state(root, state)
1317
+ elif args.name is not None:
1318
+ if not args.name.strip():
1319
+ _die("actor_name_blank")
1320
+ state["actor_override"] = {"name": args.name, "email": args.email or None}
1321
+ save_state(root, state)
1322
+ who = _whoami(state)
1323
+ if getattr(args, "json", False):
1324
+ print(json.dumps(who, separators=(",", ":")))
1325
+ return
1326
+ email = f" <{who['email']}>" if who.get("email") else ""
1327
+ print(f"actor : {who['name']}{email} (source: {who['source']})")
1328
+
1329
+
1330
+ def _ownership_record(state: dict, slug: str) -> dict | None:
1331
+ """Resolve the record a slug names for ownership — a TASK first, else a MILESTONE
1332
+ (tasks win, matching cmd_use/report precedent). None if neither exists."""
1333
+ rec = state.get("tasks", {}).get(slug)
1334
+ if rec is not None:
1335
+ return rec
1336
+ return state.get("milestones", {}).get(slug)
1337
+
1338
+
1339
+ def cmd_assign(args: argparse.Namespace) -> None:
1340
+ """Assign an OWNER (accountable) and/or ASSIGNEE (working it) to a task or milestone
1341
+ (ownership-assignment). No role flag -> set BOTH to the resolved self (_whoami); --owner/
1342
+ --assignee "Name <email>" -> set that role only (partial update). Descriptive, never a gate.
1343
+ Validate-before-mutate: a reject leaves state.json byte-identical (no partial write)."""
1344
+ root = _require_root()
1345
+ state = load_state(root)
1346
+ rec = _ownership_record(state, args.slug)
1347
+ if rec is None:
1348
+ _die("unknown_slug")
1349
+ # parse + validate ALL flags BEFORE the first write — a blank name is rejected on the
1350
+ # PARSED name (so "<>" or " <a@x.io>", whose name parses empty, is caught like " ").
1351
+ parsed_owner = _parse_actor_arg(args.owner) if args.owner is not None else None
1352
+ parsed_assignee = _parse_actor_arg(args.assignee) if args.assignee is not None else None
1353
+ if parsed_owner is not None and not parsed_owner["name"].strip():
1354
+ _die("owner_name_blank")
1355
+ if parsed_assignee is not None and not parsed_assignee["name"].strip():
1356
+ _die("assignee_name_blank")
1357
+ if parsed_owner is None and parsed_assignee is None:
1358
+ who = _whoami(state)
1359
+ rec["owner"] = dict(who)
1360
+ rec["assignee"] = dict(who)
1361
+ else:
1362
+ if parsed_owner is not None:
1363
+ rec["owner"] = parsed_owner
1364
+ if parsed_assignee is not None:
1365
+ rec["assignee"] = parsed_assignee
1366
+ save_state(root, state)
1367
+ parts = [f"{role}: {rec[role]['name']}" for role in ("owner", "assignee") if role in rec]
1368
+ print(f"assigned {args.slug} -> " + " · ".join(parts))
1369
+
1370
+
1371
+ def cmd_unassign(args: argparse.Namespace) -> None:
1372
+ """Clear the OWNER and/or ASSIGNEE of a task or milestone (ownership-assignment). No role
1373
+ flag -> clear BOTH; --owner/--assignee -> clear that role only. Reject not_assigned if the
1374
+ targeted role(s) are already absent. Validate-before-mutate (a reject changes nothing)."""
1375
+ root = _require_root()
1376
+ state = load_state(root)
1377
+ rec = _ownership_record(state, args.slug)
1378
+ if rec is None:
1379
+ _die("unknown_slug")
1380
+ if args.owner or args.assignee:
1381
+ roles = [r for r in ("owner", "assignee") if getattr(args, r)]
1382
+ else:
1383
+ roles = ["owner", "assignee"]
1384
+ if not all(r in rec for r in roles): # every targeted role must exist (frozen: "both must exist")
1385
+ _die("not_assigned")
1386
+ for r in roles:
1387
+ rec.pop(r, None)
1388
+ save_state(root, state)
1389
+ print(f"unassigned {args.slug} ({', '.join(roles)})")
1390
+
1391
+
1016
1392
  def _has_production_roadmap(state: dict) -> bool:
1017
1393
  """True iff ≥1 milestone in state has stage == "production" (STATUS-AGNOSTIC).
1018
1394
  The single source of the stage-graduation floor (v22 graduate-guide): the guard counts
@@ -1057,20 +1433,26 @@ def cmd_status(args: argparse.Namespace) -> None:
1057
1433
  members = [t for t in tasks.values() if t.get("milestone") == mslug]
1058
1434
  ms_list.append({"slug": mslug, "status": m.get("status", "active"),
1059
1435
  "done": sum(1 for t in members if _task_done(t)),
1060
- "total": len(members)})
1436
+ "total": len(members),
1437
+ "owner": m.get("owner"), "assignee": m.get("assignee")})
1061
1438
  grad_ready, grad_met, grad_total = _graduation_ready(root, state)
1062
1439
  print(json.dumps({
1063
1440
  "project": state.get("project"), "stage": state.get("stage"),
1064
- "active_task": state.get("active_task"),
1441
+ "actor": _whoami(state),
1442
+ "active_task": _active_task(state),
1443
+ "active_milestones": list(state.get("active_milestones") or []),
1444
+ "active_tasks": dict(state.get("active_tasks") or {}),
1065
1445
  "milestones": ms_list,
1066
1446
  "tasks": [{"slug": s, "phase": t.get("phase"), "gate": t.get("gate"),
1067
- "milestone": t.get("milestone")} for s, t in tasks.items()],
1447
+ "milestone": t.get("milestone"),
1448
+ "owner": t.get("owner"), "assignee": t.get("assignee")}
1449
+ for s, t in tasks.items()],
1068
1450
  "graduation_ready": grad_ready,
1069
1451
  "stage_criteria": {"met": grad_met, "total": grad_total}}))
1070
1452
  return
1071
1453
  root = _require_root()
1072
1454
  state = load_state(root)
1073
- active = state.get("active_task")
1455
+ active = _active_task(state)
1074
1456
  tasks = state.get("tasks", {})
1075
1457
  # Compute once: True when setup is present AND locked is False (the lock-gate window).
1076
1458
  # Reuses the canonical helper — do NOT write a parallel predicate.
@@ -1079,12 +1461,17 @@ def cmd_status(args: argparse.Namespace) -> None:
1079
1461
  # project autonomy default (task init-auto-default): the posture new tasks INHERIT,
1080
1462
  # read LIVE from PROJECT.md so the human sees the project-wide throttle every session.
1081
1463
  print(f"project autonomy: {_project_autonomy(root)} (default — new tasks inherit)")
1464
+ # git-native actor (user-identity): who ADD sees you as this session — the identity every
1465
+ # human-owned stamp records. Always present (the resolver is TOTAL). Read-only, no write.
1466
+ _who = _whoami(state)
1467
+ _who_email = f" <{_who['email']}>" if _who.get("email") else ""
1468
+ print(f"actor : {_who['name']}{_who_email} (source: {_who['source']})")
1082
1469
  print(f"stage : {state.get('stage', '(unknown)')}")
1083
1470
  # project GOAL + active-milestone goal (v20) — the loop's orientation anchor, read
1084
1471
  # LIVE from PROJECT.md / MILESTONE.md (never state.json). Additive: every existing
1085
1472
  # line stays put. A missing source degrades to a sentinel — one never blanks the other.
1086
1473
  print(f"goal : {_project_goal(root)}")
1087
- _active_ms = state.get("active_milestone")
1474
+ _active_ms = _active_milestone(state)
1088
1475
  if _active_ms:
1089
1476
  print(f"m-goal : {_milestone_doc(root, _active_ms)[1]} (← {_active_ms})")
1090
1477
  # goal-ready (task goal-auto-ready-gate): is the active milestone's goal AUTO-READY
@@ -1112,13 +1499,13 @@ def cmd_status(args: argparse.Namespace) -> None:
1112
1499
 
1113
1500
  # milestone rollup (only when milestones are in use)
1114
1501
  milestones = state.get("milestones") or {}
1115
- active_ms = state.get("active_milestone")
1502
+ active_ms = _active_milestone(state)
1116
1503
  if milestones:
1117
1504
  print("milestones:")
1118
1505
  for mslug, m in milestones.items():
1119
1506
  members = [t for t in tasks.values() if t.get("milestone") == mslug]
1120
1507
  done = sum(1 for t in members if _task_done(t))
1121
- mark = "*" if mslug == active_ms else " "
1508
+ mark = "*" if mslug in (state.get("active_milestones") or []) else " "
1122
1509
  print(f" {mark} {mslug:<20} {done}/{len(members)} tasks done"
1123
1510
  f" status={m.get('status', 'active')}")
1124
1511
  # graduation cue (v22): project-global + read-only. Fires only when every milestone
@@ -1145,10 +1532,38 @@ def cmd_status(args: argparse.Namespace) -> None:
1145
1532
  print(f" → {RELEASABLE_CUE.format(n=len(_rel))}")
1146
1533
 
1147
1534
  print(f"active : {active or '(none)'}")
1535
+ # parallel streams (parallel-status-view): when >=2 milestones are active, render each as its
1536
+ # own stream (active task + phase) so a user working N fronts reads them all at once. ADDITIVE —
1537
+ # the N<=1 output above is byte-identical (the standing additive-cue convention); presentation
1538
+ # only, no command DECISION changes. Reads the SET/map via the task-2 seam shape.
1539
+ _ams = state.get("active_milestones") or []
1540
+ if len(_ams) >= 2:
1541
+ _primary = _active_milestone(state)
1542
+ _order = ([_primary] if _primary in _ams else []) + [m for m in _ams if m != _primary]
1543
+ _atasks = state.get("active_tasks") or {}
1544
+ print(f"streams : {len(_ams)} active milestones")
1545
+ for _m in _order:
1546
+ _tk = _atasks.get(_m)
1547
+ _ph = (tasks.get(_tk) or {}).get("phase", "-") if _tk else "-"
1548
+ _mk = "▸" if _m == _primary else " "
1549
+ _tag = " (primary)" if _m == _primary else ""
1550
+ # per-stream owner (per-stream-owner): the milestone's lead, present-only — a stream
1551
+ # whose milestone has no owner (or a blank-name owner) renders byte-identically
1552
+ # (additive-cue convention). Guard on the name like `_fmt_ownership`, so a hand-edited
1553
+ # blank-name record never emits an `owner:` fragment.
1554
+ _owner_rec = (milestones.get(_m) or {}).get("owner") or {}
1555
+ _so = _fmt_actor(_owner_rec) if _owner_rec.get("name") else ""
1556
+ _own_frag = f" · owner: {_so}" if _so else ""
1557
+ print(f" {_mk} {_m:<20} task={_tk or '(none)'} phase={_ph}{_tag}{_own_frag}")
1148
1558
  # surface the active task's autonomy level (task explicit-autonomy-dial) so the human
1149
1559
  # reads the throttle every session; "unset" when no explicit `autonomy:` line is present.
1150
1560
  if active and active in tasks:
1151
1561
  print(f"autonomy: {_autonomy_level(_task_header(root, active)) or 'unset'}")
1562
+ # owner/assignee of the active task (ownership-assignment) — present-only, never a
1563
+ # placeholder; an unassigned active task adds no line (additive-cue convention).
1564
+ _own = _fmt_ownership(tasks[active])
1565
+ if _own:
1566
+ print(f"owned : {_own}")
1152
1567
  # grounded (task ground-bundle-wiring): does the active task's §0 GROUND map cite the
1153
1568
  # anchors §3 names? measure-not-block, human-readable only (never the JSON surface). A
1154
1569
  # pre-ground / legacy task (no §0) -> _task_grounded None -> NO line, so the surface is
@@ -1237,7 +1652,7 @@ def cmd_guide(args: argparse.Namespace) -> None:
1237
1652
  """
1238
1653
  if getattr(args, "json", False):
1239
1654
  json_root, state = _load_state_for_json()
1240
- slug = args.slug or state.get("active_task")
1655
+ slug = args.slug or _active_task(state)
1241
1656
  if not slug:
1242
1657
  print(json.dumps({"task": None, "phase": None, "owner": "human", "stop": True,
1243
1658
  "next_step": "start your first feature -> add.py new-task <slug>",
@@ -1257,7 +1672,7 @@ def cmd_guide(args: argparse.Namespace) -> None:
1257
1672
  return
1258
1673
  root = _require_root()
1259
1674
  state = load_state(root)
1260
- slug = args.slug or state.get("active_task")
1675
+ slug = args.slug or _active_task(state)
1261
1676
  if not slug:
1262
1677
  print("active : (none)")
1263
1678
  print('next : start your first feature -> add.py new-task <slug> --title "..."')
@@ -1732,7 +2147,7 @@ def cmd_check(args: argparse.Namespace) -> None:
1732
2147
  if root is None:
1733
2148
  _die("no_project")
1734
2149
  try:
1735
- state = json.loads((root / STATE_FILE).read_text(encoding="utf-8"))
2150
+ state = json.loads(_state_text_or_die(root))
1736
2151
  except (json.JSONDecodeError, OSError):
1737
2152
  _die("state_invalid")
1738
2153
 
@@ -1832,7 +2247,7 @@ def cmd_check(args: argparse.Namespace) -> None:
1832
2247
  # zero-criteria milestone is shaping's nudge, not this one's. LIVE-ONLY: the OPEN active
1833
2248
  # milestone only — a done-but-not-yet-archived one (still the active pointer until
1834
2249
  # archive clears it) and closed/archived predecessors are never retro-flagged (Must #4).
1835
- _active_ms = state.get("active_milestone")
2250
+ _active_ms = _active_milestone(state)
1836
2251
  if _active_ms in milestones and milestones[_active_ms].get("status") != "done":
1837
2252
  _cited, _total = _exit_criteria_cited(root, _active_ms)
1838
2253
  if _total >= 1 and _cited < _total:
@@ -1847,7 +2262,7 @@ def cmd_check(args: argparse.Namespace) -> None:
1847
2262
  # FROZEN AND its §0 GROUND map is ungrounded (the precise "froze without grounding" gap, so
1848
2263
  # no nag during pre-freeze drafting). A pre-ground / legacy task (no §0 -> _grounded_state
1849
2264
  # None) is EXEMPT, never retro-flagged. Rides the existing `warnings` array — no new key.
1850
- _at = state.get("active_task")
2265
+ _at = _active_task(state)
1851
2266
  if _at in tasks:
1852
2267
  _raw = _raw_phase_bodies(root, _at)
1853
2268
  if _contract_frozen(_raw.get(3, "")) and _grounded_state(_raw) is False:
@@ -1921,6 +2336,93 @@ def cmd_check(args: argparse.Namespace) -> None:
1921
2336
  raise SystemExit(1)
1922
2337
 
1923
2338
 
2339
+ def _doctor_findings(root: Path) -> list[str]:
2340
+ """Read-only diagnosis of state.json: each item is "<problem> — fix: <fix>".
2341
+
2342
+ Reads the RAW text with its OWN try/except — NEVER through the dying load_state — so a
2343
+ conflicted/corrupt state is REPORTED, not aborted on (the proactive counterpart to the
2344
+ merge-guard load guard, which fails fast at the first problem). Reports the FIRST blocking
2345
+ class then stops (can't parse deeper): missing/unreadable file -> conflict markers -> bad
2346
+ JSON. On a PARSEABLE state (normalized through `_migrate_state` so the canonical multi-active
2347
+ shape is judged) it appends EVERY referential violation. PURE: reads only, returns the list."""
2348
+ try:
2349
+ text = (root / STATE_FILE).read_text(encoding="utf-8")
2350
+ except OSError:
2351
+ return ["state.json missing/unreadable — fix: restore it from git/backup"]
2352
+ if _CONFLICT_MARKER_RE.search(text):
2353
+ return ["state.json has unresolved git merge markers — fix: resolve "
2354
+ "<<<<<<< / ======= / >>>>>>> (or git checkout --ours/--theirs), then re-run doctor"]
2355
+ try:
2356
+ parsed = json.loads(text)
2357
+ except json.JSONDecodeError:
2358
+ return ["state.json is not valid JSON — fix: restore it from git/backup"]
2359
+
2360
+ state = _migrate_state(parsed)
2361
+ findings: list[str] = []
2362
+ milestones = state.get("milestones") if isinstance(state.get("milestones"), dict) else {}
2363
+ tasks = state.get("tasks") if isinstance(state.get("tasks"), dict) else {}
2364
+
2365
+ active_ms = state.get("active_milestones") if isinstance(state.get("active_milestones"), list) else []
2366
+ active_tasks = state.get("active_tasks") if isinstance(state.get("active_tasks"), dict) else {}
2367
+ for am in active_ms:
2368
+ if am not in milestones:
2369
+ findings.append(f"active milestone '{am}' has no record — fix: deactivate it or "
2370
+ "recreate the milestone")
2371
+ for ms, t in active_tasks.items():
2372
+ if not t:
2373
+ continue
2374
+ if t not in tasks:
2375
+ findings.append(f"active task '{t}' (milestone '{ms}') has no record — fix: use a "
2376
+ "real task or clear the active pointer")
2377
+ elif (tasks[t].get("milestone") if isinstance(tasks[t], dict) else None) != ms:
2378
+ findings.append(f"active task '{t}' is mislabeled under '{ms}' — fix: re-use it "
2379
+ "under its own milestone")
2380
+ for slug, t in tasks.items():
2381
+ m = t.get("milestone") if isinstance(t, dict) else None
2382
+ if m is not None and m not in milestones:
2383
+ findings.append(f"task '{slug}' references missing milestone '{m}' — fix: set its "
2384
+ "milestone to a real one (or none)")
2385
+ return findings
2386
+
2387
+
2388
+ def cmd_doctor(args: argparse.Namespace) -> None:
2389
+ """Read-only `add.py doctor`: PASS + exit 0 on a healthy state, else report each problem +
2390
+ fix to stdout and exit non-zero. NEVER mutates state (detect, never auto-resolve)."""
2391
+ root = find_root()
2392
+ if root is None:
2393
+ _die("no_project")
2394
+ findings = _doctor_findings(root)
2395
+ if not findings:
2396
+ print("doctor: PASS — state.json is healthy (parseable · conflict-free · references intact)")
2397
+ return
2398
+ print(f"doctor: {len(findings)} problem(s):")
2399
+ for f in findings:
2400
+ print(f" ✗ {f}")
2401
+ raise SystemExit(1)
2402
+
2403
+
2404
+ def cmd_mine(args: argparse.Namespace) -> None:
2405
+ """Read-only `add.py mine`: across all active milestones, the not-done tasks owned-by or
2406
+ assigned-to the resolved actor (`_whoami`, or `--actor "Name <email>"`). Text or `--json`.
2407
+ An empty queue is a plain exit-0 line, not an error. NEVER writes state."""
2408
+ root = find_root()
2409
+ if root is None:
2410
+ _die("no_project")
2411
+ state = load_state(root)
2412
+ me = _parse_actor_arg(args.actor) if getattr(args, "actor", None) else _whoami(state)
2413
+ rows = _my_work(state, me)
2414
+ if getattr(args, "json", False):
2415
+ print(json.dumps({"actor": me, "tasks": rows}))
2416
+ return
2417
+ who = _fmt_actor(me) or me.get("name", "you")
2418
+ if not rows:
2419
+ print(f"mine: no open tasks for {who} across active milestones")
2420
+ return
2421
+ print(f"mine: {who} — {len(rows)} open task(s) across active milestones:")
2422
+ for r in rows:
2423
+ print(f" {r['slug']:<24} [{r['milestone']}] phase={r['phase']} ({r['role']})")
2424
+
2425
+
1924
2426
  # ---------------------------------------------------------------------------
1925
2427
  # wave-ledger fork-base enforcement (engine-merge-base-enforcement)
1926
2428
  #
@@ -2079,7 +2581,7 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
2079
2581
  "title": title, "goal": args.goal or "", "stage": args.stage,
2080
2582
  "status": "active", "created": _now(), "updated": _now(),
2081
2583
  }
2082
- state["active_milestone"] = slug
2584
+ _set_active_milestone(state, slug)
2083
2585
  save_state(root, state)
2084
2586
  print(f"created milestone '{slug}' -> {mfile}")
2085
2587
  print("active milestone set.")
@@ -2128,7 +2630,11 @@ def cmd_ready(args: argparse.Namespace) -> None:
2128
2630
  for slug in ready:
2129
2631
  deps = tasks[slug].get("depends_on") or []
2130
2632
  suffix = f" (after {', '.join(deps)})" if deps else ""
2131
- print(f" {slug}{suffix}")
2633
+ # cross-active legibility (cross-active-waves): name the stream each ready task belongs
2634
+ # to, present-only — a milestone-less task gets no bracket (byte-identical).
2635
+ _ms = tasks[slug].get("milestone")
2636
+ ms_frag = f" [{_ms}]" if _ms else ""
2637
+ print(f" {slug}{ms_frag}{suffix}")
2132
2638
 
2133
2639
 
2134
2640
  def _wave_schedule(state: dict, mslug: str) -> dict:
@@ -2222,36 +2728,17 @@ def _wave_schedule(state: dict, mslug: str) -> dict:
2222
2728
  "tiers": tiers, "blocked": blocked_sorted}
2223
2729
 
2224
2730
 
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}")
2731
+ def _wave_block_lines(state: dict, mslug: str, sched: dict) -> list[str]:
2732
+ """The exact text lines `waves` renders for ONE milestone's schedule (cross-active-waves
2733
+ extracts this so a single target stays byte-identical and N targets each get a block)."""
2734
+ lines = [f"milestone: {mslug}"]
2248
2735
  if not sched["waves"]:
2249
2736
  if sched["blocked"]:
2250
2737
  for s in sched["blocked"]:
2251
- print(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2738
+ lines.append(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2252
2739
  else:
2253
- print("all tasks done — nothing to schedule")
2254
- return
2740
+ lines.append("all tasks done — nothing to schedule")
2741
+ return lines
2255
2742
  scheduled_set = {x for w in sched["waves"] for x in w}
2256
2743
  for i, wave in enumerate(sched["waves"], start=1):
2257
2744
  parts = []
@@ -2259,14 +2746,62 @@ def cmd_waves(args: argparse.Namespace) -> None:
2259
2746
  md = sorted(d for d in (state["tasks"][s].get("depends_on") or [])
2260
2747
  if d in scheduled_set)
2261
2748
  parts.append(f"{s} (deps: {', '.join(md)})" if md else s)
2262
- print(f"wave {i}: {', '.join(parts)}")
2749
+ lines.append(f"wave {i}: {', '.join(parts)}")
2263
2750
  crit = sched["critical_path"]
2264
- print(f"critical path: {' → '.join(crit)} ({sched['critical_path_len']} tasks)")
2751
+ lines.append(f"critical path: {' → '.join(crit)} ({sched['critical_path_len']} tasks)")
2265
2752
  tops = [s for s, tier in sched["tiers"].items() if tier == "top"]
2266
2753
  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)'}")
2754
+ lines.append(f"tier hint: top → {', '.join(tops)}; mid → {', '.join(mids) or '(none)'}")
2268
2755
  for s in sched["blocked"]:
2269
- print(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2756
+ lines.append(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2757
+ return lines
2758
+
2759
+
2760
+ def cmd_waves(args: argparse.Namespace) -> None:
2761
+ """READ-ONLY DAG scheduler: print the topological waves, critical path, advisory tier hint,
2762
+ and blocked set. With no --milestone it spans EVERY active milestone (cross-active-waves);
2763
+ a single target / --milestone renders byte-identically. Writes nothing; no `next:` footer."""
2764
+ is_json = getattr(args, "json", False)
2765
+ if is_json:
2766
+ _, state = _load_state_for_json()
2767
+ else:
2768
+ state = load_state(_require_root())
2769
+ mslug_arg = getattr(args, "milestone", None)
2770
+ if mslug_arg:
2771
+ targets = [mslug_arg] # explicit single target — unchanged
2772
+ else:
2773
+ primary = _active_milestone(state) # the SCALAR is the gate (test relies on it)
2774
+ if not primary:
2775
+ _die("no_active_milestone: no active milestone and no --milestone given")
2776
+ # additively widen to the other active milestones (cross-active); primary first
2777
+ targets = [primary] + [m for m in (state.get("active_milestones") or [])
2778
+ if m != primary]
2779
+ scheds = []
2780
+ for t in targets:
2781
+ if t not in (state.get("milestones") or {}):
2782
+ _die(f"unknown_milestone: '{t}' is not a milestone in this project")
2783
+ sched = _wave_schedule(state, t)
2784
+ if "cycle" in sched:
2785
+ _die(f"dependency_cycle: not-done deps form a cycle "
2786
+ f"({' -> '.join(sched['cycle'])}) — no valid schedule")
2787
+ scheds.append(sched)
2788
+
2789
+ if is_json:
2790
+ if len(targets) == 1:
2791
+ print(json.dumps({"milestone": targets[0], **scheds[0]})) # unchanged shape
2792
+ else:
2793
+ print(json.dumps({"streams": [{"milestone": t, **s}
2794
+ for t, s in zip(targets, scheds)]}))
2795
+ return
2796
+
2797
+ if len(targets) == 1:
2798
+ print("\n".join(_wave_block_lines(state, targets[0], scheds[0]))) # byte-identical
2799
+ return
2800
+ print(f"active streams: {len(targets)}")
2801
+ for i, (t, s) in enumerate(zip(targets, scheds)):
2802
+ if i:
2803
+ print()
2804
+ print("\n".join(_wave_block_lines(state, t, s)))
2270
2805
 
2271
2806
 
2272
2807
  def cmd_milestone_done(args: argparse.Namespace) -> None:
@@ -2296,6 +2831,10 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
2296
2831
  _die(f"milestone_goal_unmet: milestone '{slug}' has {met}/{total} exit criteria met "
2297
2832
  f"— check the remaining boxes in MILESTONE.md (the goal-gate holds the loop "
2298
2833
  f"open) or propose the next tasks (add.py deltas)")
2834
+ # Stamp WHO closed it BEFORE rendering the retro, so the persisted exit report records
2835
+ # the closer (identity-in-status: the retro IS the report `report <ms>` re-renders, so both
2836
+ # must reflect the same final state). In-memory only here — save_state below commits it.
2837
+ state["milestones"][slug]["done_actor"] = _actor_stamp(state)
2299
2838
  # Fail-closed: render+persist the exit report (RETRO.md) BEFORE committing the
2300
2839
  # status flip, so a write failure rolls back naturally (status never commits ->
2301
2840
  # no done-without-retro state). The retro step is read-only on state.json.
@@ -2311,11 +2850,15 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
2311
2850
  print(f"milestone '{slug}' -> done ({len(members)} tasks complete{tail}).")
2312
2851
  print(f"wrote {retro_path.relative_to(root.parent)} (milestone exit report)")
2313
2852
  # 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())
2853
+ by_comp = _collect_open_deltas(root)
2854
+ open_deltas = sum(len(v) for v in by_comp.values())
2315
2855
  if open_deltas:
2316
2856
  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")
2857
+ print(f"note: {open_deltas} open {noun} ready to {_FOLD_VERB} into the foundation:")
2858
+ for comp in _COMPETENCY_ORDER:
2859
+ for e in by_comp[comp]:
2860
+ print(f" ({comp}) {e['text']} [{e['task']}]")
2861
+ print(f" run: add.py {_FOLD_VERB} (review first: add.py deltas)")
2319
2862
  # SPEC-delta nudge (project-wide): the close is also a natural prompt to RESOLVE the
2320
2863
  # forward hand-offs (seed/drop) so none is orphaned at the eventual compaction.
2321
2864
  open_spec = len(_collect_open_spec_deltas(root))
@@ -2369,10 +2912,9 @@ def cmd_archive_milestone(args: argparse.Namespace) -> None:
2369
2912
  del state["milestones"][slug]
2370
2913
  for s in members:
2371
2914
  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
2915
+ _deactivate_milestone(state, slug) # drop from the active SET + pop its task entry, repointing the primary focus
2916
+ if _active_task(state) in members: # N<=1 oracle: a NON-primary archive (new-milestone replace-to-focus leaves
2917
+ state["active_task"] = None # active_task pointing at m1's task while primary is m2) would dangle at a deleted task
2376
2918
  save_state(root, state)
2377
2919
  print(f"archived milestone '{slug}' ({len(members)} tasks) — removed from active state.")
2378
2920
  print("files on disk are untouched; see `add.py status` for the archived rollup.")
@@ -2422,10 +2964,15 @@ def cmd_compact(args: argparse.Namespace) -> None:
2422
2964
  # hand-off that resolves into a task, not a foundation lesson — an open one ANYWHERE would
2423
2965
  # be orphaned at the next compaction. Deliberately broader than the member-scoped competency
2424
2966
  # guard above. Still validate-before-move: refuses BEFORE the first rename.
2967
+ # --force overrides THIS guard ONLY (never a structural reject above) — the escape hatch
2968
+ # for a settled milestone blocked by an unrelated open SPEC delta elsewhere. Bypass is
2969
+ # loud (warns + records `force_bypassed_spec_deltas`), never silent.
2970
+ forced = getattr(args, "force", False)
2425
2971
  spec_offenders = sorted({d["task"] for d in _collect_open_spec_deltas(root)})
2426
- if spec_offenders:
2972
+ if spec_offenders and not forced:
2427
2973
  _die("open_spec_deltas_unresolved: resolve every open SPEC delta first "
2428
- "(`add.py deltas`; seed with `new-task --from-delta`, or `drop-delta`) "
2974
+ "(`add.py deltas`; seed with `new-task --from-delta`, or `drop-delta`; "
2975
+ "or re-run with --force to compact past them) — "
2429
2976
  "open in: " + " · ".join(spec_offenders))
2430
2977
  # every precondition passed — move (same-filesystem renames, never a delete)
2431
2978
  def _files(d: Path) -> int:
@@ -2443,7 +2990,12 @@ def cmd_compact(args: argparse.Namespace) -> None:
2443
2990
  moved.append((f"tasks/{t}/", n))
2444
2991
  # state write is the LAST step: additive stamp only — task_slugs untouched
2445
2992
  entry["compacted"] = date.today().isoformat()
2993
+ if spec_offenders: # forced is implied (un-forced would have _die'd)
2994
+ entry["force_bypassed_spec_deltas"] = spec_offenders
2446
2995
  save_state(root, state)
2996
+ if spec_offenders:
2997
+ print("⚠ --force bypassed open SPEC delta(s) in: " + " · ".join(spec_offenders) +
2998
+ " — recorded as force_bypassed_spec_deltas; resolve them before the next release.")
2447
2999
  total = sum(n for _, n in moved)
2448
3000
  print(f"compacted milestone '{slug}' -> .add/archive/{slug}/ "
2449
3001
  f"({len(members)} task dirs, {total} files moved)")
@@ -2472,16 +3024,54 @@ def cmd_set_milestone(args: argparse.Namespace) -> None:
2472
3024
  print(_next_footer(root, state))
2473
3025
 
2474
3026
 
3027
+ def cmd_activate(args: argparse.Namespace) -> None:
3028
+ """Add a milestone to the active working SET and focus it — how a user works N milestones
3029
+ in parallel. Idempotent (re-activating just refocuses). Validates before mutating."""
3030
+ root = _require_root()
3031
+ state = load_state(root)
3032
+ slug = args.slug
3033
+ if slug not in state.get("milestones", {}):
3034
+ _die("unknown_milestone")
3035
+ if state["milestones"][slug].get("status") == "done":
3036
+ _die("milestone_done")
3037
+ _activate_milestone(state, slug)
3038
+ save_state(root, state)
3039
+ print(f"activated '{slug}' — active: {', '.join(state['active_milestones'])}")
3040
+ print(_next_footer(root, state))
3041
+
3042
+
3043
+ def cmd_deactivate(args: argparse.Namespace) -> None:
3044
+ """Remove a milestone from the active working SET (its files + status are untouched);
3045
+ repoints the primary focus to a remaining member. Validates before mutating."""
3046
+ root = _require_root()
3047
+ state = load_state(root)
3048
+ slug = args.slug
3049
+ if slug not in (state.get("active_milestones") or []):
3050
+ _die("milestone_not_active")
3051
+ _deactivate_milestone(state, slug)
3052
+ save_state(root, state)
3053
+ remaining = state.get("active_milestones") or []
3054
+ print(f"deactivated '{slug}' — active: {', '.join(remaining) if remaining else '(none)'}")
3055
+ print(_next_footer(root, state))
3056
+
3057
+
2475
3058
  def cmd_use(args: argparse.Namespace) -> None:
2476
3059
  """Set the active task to an EXISTING task (switch focus) without scaffolding a new
2477
3060
  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."""
3061
+ just moves the default focus, closing the only gap that forced manual state edits.
3062
+ Milestone-aware: focuses the task's OWN milestone (activating it into the set) so the
3063
+ active task is switched WITHIN that milestone, not mislabeled under a stale primary."""
2479
3064
  root = _require_root()
2480
3065
  state = load_state(root)
2481
3066
  slug = args.slug
2482
3067
  if slug not in state.get("tasks", {}):
2483
3068
  _die("unknown_task")
2484
- state["active_task"] = slug
3069
+ ms = state["tasks"][slug].get("milestone")
3070
+ if ms is not None and ms in state.get("milestones", {}):
3071
+ _activate_milestone(state, ms) # focus the task's milestone (adds to the set if needed)
3072
+ _set_active_task(state, slug, ms)
3073
+ else:
3074
+ _set_active_task(state, slug) # milestone-less task: scalar only (back-compat)
2485
3075
  save_state(root, state)
2486
3076
  print(f"active task -> '{slug}' (phase={state['tasks'][slug]['phase']})")
2487
3077
  print(_next_footer(root, state))
@@ -3226,6 +3816,9 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
3226
3816
  "phase_index": PHASES.index(phase) if phase in PHASES else 0,
3227
3817
  "done": _task_done(t),
3228
3818
  "gate": gate,
3819
+ "gate_actor": t.get("gate_actor"), # WHO recorded the verdict (None when unstamped)
3820
+ "owner": t.get("owner"), # WHO is accountable (None when unassigned)
3821
+ "assignee": t.get("assignee"), # WHO is working it (None when unassigned)
3229
3822
  "tests": n_tests,
3230
3823
  "tests_declared": t_declared,
3231
3824
  "observe": observe,
@@ -3241,7 +3834,10 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
3241
3834
 
3242
3835
  return {
3243
3836
  "milestone": {"slug": mslug, "title": title, "goal": goal,
3244
- "status": ms.get("status", "active")},
3837
+ "status": ms.get("status", "active"),
3838
+ "done_actor": ms.get("done_actor"), # WHO closed it (None when unstamped/open)
3839
+ "owner": ms.get("owner"), # WHO is accountable for the milestone
3840
+ "assignee": ms.get("assignee")}, # WHO is working it (None when unassigned)
3245
3841
  "summary": {
3246
3842
  "tasks_done": sum(1 for r in task_rows if r["done"]),
3247
3843
  "tasks_total": len(task_rows),
@@ -3423,6 +4019,24 @@ def render_task_detail(root: Path, state: dict, mslug: str, slug: str, *,
3423
4019
  return "\n".join(L)
3424
4020
 
3425
4021
 
4022
+ def _fmt_actor(actor: dict | None) -> str:
4023
+ """Format a recorded actor stamp `{name,email,source}` as `name [<email>]` for the
4024
+ report surface — "" when absent (user-identity: present-only render, no placeholder)."""
4025
+ if not actor:
4026
+ return ""
4027
+ email = f" <{actor['email']}>" if actor.get("email") else ""
4028
+ return f"{actor.get('name', '')}{email}"
4029
+
4030
+
4031
+ def _fmt_ownership(rec: dict) -> str:
4032
+ """Format a record's owner/assignee as `owner: <name> · assignee: <name>` for the
4033
+ surface (ownership-assignment) — present-only: each role appears only when set, and
4034
+ "" when neither is. Reuses _fmt_actor to render each `{name,email,source}` actor."""
4035
+ bits = [f"{role}: {_fmt_actor(rec[role])}" for role in ("owner", "assignee")
4036
+ if rec.get(role) and rec[role].get("name")] # skip a hand-edited blank-name record
4037
+ return " · ".join(bits)
4038
+
4039
+
3426
4040
  def render_report(root: Path, state: dict, mslug: str, *,
3427
4041
  width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str:
3428
4042
  """Format the FACTS (report_data) as the text DASHBOARD — verdict-first header,
@@ -3457,6 +4071,13 @@ def render_report(root: Path, state: dict, mslug: str, *,
3457
4071
  L.append(f" {'GATES':<9} {gate_txt:<18} {'WAIVERS':<9} {waiver_txt}")
3458
4072
  L.append("")
3459
4073
  L.extend(_wrap(m["goal"], W - 7, " goal "))
4074
+ # who closed the milestone (user-identity) — present-only, never a placeholder
4075
+ if m.get("done_actor"):
4076
+ L.append(f" closed by {_fmt_actor(m['done_actor'])}")
4077
+ # who owns/works the milestone (ownership-assignment) — present-only
4078
+ _ms_own = _fmt_ownership(m)
4079
+ if _ms_own:
4080
+ L.append(f" owned by {_ms_own}")
3460
4081
  L.append("")
3461
4082
  if d["tasks"]:
3462
4083
  L.append(f" {'TASK':<27} {'PHASE':<9} {'GATE':<4} {'TESTS':<5} PROGRESS")
@@ -3471,6 +4092,21 @@ def render_report(root: Path, state: dict, mslug: str, *,
3471
4092
  f"{g['pending']} pending spec→…→done")
3472
4093
  if any(r.get("tests_declared") for r in d["tasks"]):
3473
4094
  L.append(" † counted at the §4-declared path")
4095
+ # who recorded each verdict (user-identity) — present-only audit trail
4096
+ gated = [r for r in d["tasks"] if r.get("gate_actor")]
4097
+ if gated:
4098
+ L.append("")
4099
+ L.append(" GATED BY")
4100
+ for r in gated:
4101
+ short = _GATE_SHORT.get(r["gate"], r["gate"])
4102
+ L.append(f" {_clip(r['slug'], 24):<24} {short:<4} {_fmt_actor(r['gate_actor'])}")
4103
+ # who owns/works each task (ownership-assignment) — present-only, mirror of GATED BY
4104
+ owned = [r for r in d["tasks"] if r.get("owner") or r.get("assignee")]
4105
+ if owned:
4106
+ L.append("")
4107
+ L.append(" OWNED BY")
4108
+ for r in owned:
4109
+ L.append(f" {_clip(r['slug'], 24):<24} {_fmt_ownership(r)}")
3474
4110
  else:
3475
4111
  L.append(" (no tasks yet)")
3476
4112
  L.append("")
@@ -3764,7 +4400,7 @@ def _decide_next_pair(state: dict, d: dict) -> tuple[str, bool]:
3764
4400
  return (f"goal not met ({met}/{total} exit criteria) — propose next tasks "
3765
4401
  f"from open deltas / the unscaffolded plan (add.py deltas)"), True
3766
4402
  return f"consolidate learnings + archive-milestone {ms}", True
3767
- active = state.get("active_task")
4403
+ active = _active_task(state)
3768
4404
  order = sorted(rows, key=lambda r: 0 if r["slug"] == active else 1) # stable
3769
4405
  for r in order:
3770
4406
  if r["done"]:
@@ -3806,7 +4442,7 @@ def _next_footer(root: Path, state: dict) -> str:
3806
4442
  The fail-soft line carries NO marker — never assert a driver that could not be computed.
3807
4443
  """
3808
4444
  try:
3809
- slug = state.get("active_task")
4445
+ slug = _active_task(state)
3810
4446
  t = (state.get("tasks") or {}).get(slug) if slug else None
3811
4447
  if t and t.get("gate", "none") == "none" and t.get("phase") != "done":
3812
4448
  phase = t.get("phase")
@@ -3815,7 +4451,7 @@ def _next_footer(root: Path, state: dict) -> str:
3815
4451
  if phase == "verify" else "add.py advance")
3816
4452
  marker = _driver_marker(_driver_stop(root, state, slug, phase))
3817
4453
  return f"next: {command} — {why}{marker}"
3818
- mslug = state.get("active_milestone")
4454
+ mslug = _active_milestone(state)
3819
4455
  if mslug:
3820
4456
  d = report_data(root, state, mslug)
3821
4457
  text, human_stop = _decide_next_pair(state, d)
@@ -4092,40 +4728,61 @@ def _collect_open_spec_deltas(root: Path) -> list[dict]:
4092
4728
  _SPEC_OPEN_TOKEN_RE = re.compile(r"(\[\s*SPEC\s*·\s*)open(\s*\])")
4093
4729
 
4094
4730
 
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.
4731
+ def _resolve_spec_delta(text: str, new_status: str, pointer: str | None = None,
4732
+ line_index: int | None = None) -> str | None:
4733
+ """Flip ONE `[SPEC · open]` line in `text` to `new_status`; return the new text.
4097
4734
 
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."""
4735
+ PURE — no IO. With `line_index` (a splitlines(keepends=True) index, as `_select_spec_delta`
4736
+ returns) flip THAT line; without it, flip the FIRST open delta (back-compat). Only the status
4737
+ token changes (+ a trailing ` [→ <pointer>]` provenance stamp when seeding); the entry's text
4738
+ and `(evidence: …)` are byte-preserved. Returns None when there is NO open SPEC delta to flip —
4739
+ the caller then refuses and writes nothing. Mirrors the `_autonomy_decl_line` pure-transform."""
4102
4740
  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
4741
+ target = line_index
4742
+ if target is None: # back-compat: the FIRST open delta
4743
+ for i, ln in enumerate(lines):
4744
+ m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
4745
+ if m and m.group(2) == "open":
4746
+ target = i
4747
+ break
4748
+ if target is None:
4749
+ return None
4750
+ ln = lines[target]
4751
+ eol = ln[len(ln.rstrip("\n")):] # preserve the exact line ending
4752
+ body = _SPEC_OPEN_TOKEN_RE.sub(rf"\g<1>{new_status}\g<2>", ln.rstrip("\n"), count=1)
4753
+ if pointer:
4754
+ body = f"{body} [→ {pointer}]"
4755
+ lines[target] = body + eol
4756
+ return "".join(lines)
4114
4757
 
4115
4758
 
4116
- def _first_open_spec_text(text: str) -> str | None:
4117
- """The first OPEN SPEC delta's text (evidence stripped) in `text`, or None.
4759
+ def _select_spec_delta(text: str, match: str | None = None) -> tuple[str, int | None, str | None]:
4760
+ """Pick the open SPEC delta to resolve (delta-match-selector). PURE no IO.
4118
4761
 
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":
4762
+ `match=None` -> the FIRST open delta. `match=<substr>` -> the UNIQUE open delta whose display
4763
+ text (status token + `(evidence: …)` excluded) contains <substr>, case-insensitive. Returns
4764
+ (status, line_index, display_text) where status is one of: "ok" (line_index/display set),
4765
+ "no_open" (no open delta at all), "no_match" (--match hit zero), "ambiguous" (--match hit >1).
4766
+ line_index is a splitlines(keepends=True) index, the same `_resolve_spec_delta` flips."""
4767
+ opens: list[tuple[int, str]] = []
4768
+ for i, ln in enumerate(text.splitlines(keepends=True)):
4769
+ m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
4770
+ if not m or m.group(2) != "open":
4124
4771
  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
4772
+ tail = m.group(3).strip()
4773
+ cut = tail.find("(evidence:") # exclude the evidence tail even if its paren is unclosed
4774
+ opens.append((i, (tail[:cut].strip() if cut != -1 else tail)))
4775
+ if not opens:
4776
+ return ("no_open", None, None)
4777
+ if match is None:
4778
+ return ("ok", opens[0][0], opens[0][1])
4779
+ needle = match.lower()
4780
+ hits = [(i, d) for i, d in opens if needle in d.lower()]
4781
+ if not hits:
4782
+ return ("no_match", None, None)
4783
+ if len(hits) > 1:
4784
+ return ("ambiguous", None, None)
4785
+ return ("ok", hits[0][0], hits[0][1])
4129
4786
 
4130
4787
 
4131
4788
  # ── add.py fold — mechanized competency-lesson consolidation ────────────────────────────────
@@ -4295,13 +4952,9 @@ def cmd_fold(args: argparse.Namespace) -> None:
4295
4952
  proj_text = _prepend_key_decision_row(proj_text, row)
4296
4953
  proj_text = re.sub(r"foundation-version:\s*\d+", f"foundation-version: {new_v}", proj_text, count=1)
4297
4954
 
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. ────────────────────
4955
+ # ── all bodies built; commit ALL via the all-or-nothing multi-file primitive: a stage failure
4956
+ # (disk-full / permission) writes nothing, and a mid-commit rename failure rolls back every
4957
+ # already-committed file so the foundation never advances while a TASK.md stays unflipped. ─
4305
4958
  writes: list[tuple[Path, str]] = [(project_md, proj_text)]
4306
4959
  touched = ["PROJECT.md"]
4307
4960
  if conv_text != conventions_text:
@@ -4764,13 +5417,18 @@ def _render_changelog_block(version: str, day: str, bundle: list[dict],
4764
5417
 
4765
5418
 
4766
5419
  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)."""
5420
+ waiver_slugs: list[str], evidence: str | None,
5421
+ actor: str | None = None) -> str:
5422
+ """One append-only RELEASES.md row — the attribution source (`milestones:` membership).
5423
+ The `actor:` line records WHO cut the release (structured-actor stamping); absent on a
5424
+ legacy row (back-compat) when no actor is supplied."""
4769
5425
  ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
4770
5426
  wv = ", ".join(waiver_slugs) if waiver_slugs else "none"
5427
+ actor_line = f"actor: {actor}\n" if actor else ""
4771
5428
  return (f"## {version} — {day}\n"
4772
5429
  f"milestones: {ms}\n"
4773
5430
  f"waivers: {wv}\n"
5431
+ f"{actor_line}"
4774
5432
  f"evidence: {evidence or 'recorded by add.py release'}\n\n")
4775
5433
 
4776
5434
 
@@ -4816,20 +5474,13 @@ def cmd_release(args: argparse.Namespace) -> None:
4816
5474
  _render_changelog_block(args.version, day, bundle, changed_by_slug))
4817
5475
  new_rel = _prepend_block(rel_before, "# Releases",
4818
5476
  _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
5477
+ getattr(args, "evidence", None),
5478
+ _render_actor_line(state)))
5479
+ try: # CHANGELOG + RELEASES as one all-or-nothing commit
5480
+ _atomic_write_many([(changelog_path, new_cl), (releases_path, new_rel)])
4823
5481
  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.")
5482
+ _die(f"release_write_failed: the ledger write failed ({e}); nothing was recorded — both "
5483
+ "files were rolled back to their prior content. Retry the release.")
4833
5484
 
4834
5485
  # NO save_state — attribution lives in RELEASES.md (the cue re-reads it), never state.json
4835
5486
  ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
@@ -4926,13 +5577,13 @@ def cmd_report(args: argparse.Namespace) -> None:
4926
5577
  else:
4927
5578
  _die(f"unknown_milestone: '{name}' is not a milestone")
4928
5579
  elif getattr(args, "decide", False): # bare --decide -> the ACTIVE TASK
4929
- slug = state.get("active_task")
5580
+ slug = _active_task(state)
4930
5581
  if not slug or slug not in tasks:
4931
5582
  _die("no_active_task — name one: add.py report <milestone> <task> --decide")
4932
5583
  drill_task = slug
4933
5584
  mslug = tasks[slug].get("milestone") or ""
4934
5585
  else: # no positional -> active milestone
4935
- mslug = state.get("active_milestone")
5586
+ mslug = _active_milestone(state)
4936
5587
  if not mslug:
4937
5588
  _die("no_active_milestone: no milestone given and none is active; "
4938
5589
  "try `add.py report <milestone>`")
@@ -5005,6 +5656,33 @@ def build_parser() -> argparse.ArgumentParser:
5005
5656
  pl.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
5006
5657
  pl.set_defaults(func=cmd_lock)
5007
5658
 
5659
+ pwho = sub.add_parser("whoami",
5660
+ help="show / set the git-native actor (git config -> OS user; --name to override)")
5661
+ # --name (set) and --unset (clear) are mutually exclusive — argparse rejects the
5662
+ # contradiction (exit 2) before any state read, so neither silently wins.
5663
+ pwho_mut = pwho.add_mutually_exclusive_group()
5664
+ pwho_mut.add_argument("--name", default=None, help="set an actor override (name)")
5665
+ pwho_mut.add_argument("--unset", action="store_true", help="clear the actor override")
5666
+ pwho.add_argument("--email", default=None, help="set the override email (with --name)")
5667
+ pwho.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
5668
+ pwho.set_defaults(func=cmd_whoami)
5669
+
5670
+ pas = sub.add_parser("assign",
5671
+ help="assign an owner/assignee to a task or milestone (no flag = self)")
5672
+ pas.add_argument("slug")
5673
+ pas.add_argument("--owner", default=None, metavar="\"Name <email>\"",
5674
+ help="set the accountable owner (default with no flag: self)")
5675
+ pas.add_argument("--assignee", default=None, metavar="\"Name <email>\"",
5676
+ help="set the working assignee (default with no flag: self)")
5677
+ pas.set_defaults(func=cmd_assign)
5678
+
5679
+ pun = sub.add_parser("unassign",
5680
+ help="clear the owner/assignee of a task or milestone (no flag = both)")
5681
+ pun.add_argument("slug")
5682
+ pun.add_argument("--owner", action="store_true", help="clear only the owner")
5683
+ pun.add_argument("--assignee", action="store_true", help="clear only the assignee")
5684
+ pun.set_defaults(func=cmd_unassign)
5685
+
5008
5686
  pn = sub.add_parser("new-task", help="scaffold a new task (TASK.md + tests/ + src/)")
5009
5687
  pn.add_argument("slug")
5010
5688
  pn.add_argument("--title", default=None)
@@ -5012,14 +5690,20 @@ def build_parser() -> argparse.ArgumentParser:
5012
5690
  pn.add_argument("--depends-on", dest="depends_on", default=None,
5013
5691
  help="comma-separated task slugs this task depends on")
5014
5692
  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 "
5693
+ help="SEED PRIOR's open SPEC delta into this task (pre-fills §1 "
5016
5694
  "Feature, flips the source -> [SPEC · seeded] [→ this])")
5695
+ pn.add_argument("--match", default=None, metavar="SUBSTR",
5696
+ help="with --from-delta: target the UNIQUE open SPEC delta whose text "
5697
+ "contains SUBSTR (case-insensitive) instead of the first")
5017
5698
  pn.add_argument("--force", action="store_true", help="overwrite TASK.md if present")
5018
5699
  pn.set_defaults(func=cmd_new_task)
5019
5700
 
5020
5701
  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")
5702
+ help="dismiss a task's open SPEC delta -> [SPEC · dropped]")
5703
+ pdd.add_argument("slug", help="task whose open SPEC delta to drop")
5704
+ pdd.add_argument("--match", default=None, metavar="SUBSTR",
5705
+ help="target the UNIQUE open SPEC delta whose text contains SUBSTR "
5706
+ "(case-insensitive) instead of the first")
5023
5707
  pdd.set_defaults(func=cmd_drop_delta)
5024
5708
 
5025
5709
  pm = sub.add_parser("new-milestone", help="scaffold a milestone (SDD living doc)")
@@ -5054,6 +5738,16 @@ def build_parser() -> argparse.ArgumentParser:
5054
5738
  pu.add_argument("slug")
5055
5739
  pu.set_defaults(func=cmd_use)
5056
5740
 
5741
+ pac = sub.add_parser("activate",
5742
+ help="add a milestone to the active working SET and focus it (parallel milestones)")
5743
+ pac.add_argument("slug")
5744
+ pac.set_defaults(func=cmd_activate)
5745
+
5746
+ pdac = sub.add_parser("deactivate",
5747
+ help="remove a milestone from the active working SET (its files stay on disk)")
5748
+ pdac.add_argument("slug")
5749
+ pdac.set_defaults(func=cmd_deactivate)
5750
+
5057
5751
  pam = sub.add_parser("archive-milestone",
5058
5752
  help="collapse a done milestone out of active state (files stay on disk)")
5059
5753
  pam.add_argument("slug")
@@ -5063,6 +5757,9 @@ def build_parser() -> argparse.ArgumentParser:
5063
5757
  help="heavy archive: move an archived milestone's files into "
5064
5758
  ".add/archive/<slug>/ (recoverable reverse move)")
5065
5759
  pco.add_argument("slug")
5760
+ pco.add_argument("--force", action="store_true",
5761
+ help="compact past an unrelated open SPEC delta (the open_spec_deltas_unresolved "
5762
+ "guard ONLY; never a structural reject) — bypass is warned + recorded")
5066
5763
  pco.set_defaults(func=cmd_compact)
5067
5764
 
5068
5765
  pp = sub.add_parser("phase", help="set a task's phase explicitly")
@@ -5121,6 +5818,17 @@ def build_parser() -> argparse.ArgumentParser:
5121
5818
  pck.add_argument("--json", action="store_true", help="machine-readable JSON output")
5122
5819
  pck.set_defaults(func=cmd_check)
5123
5820
 
5821
+ pdoc = sub.add_parser("doctor", help="read-only diagnosis of state.json integrity + "
5822
+ "referential consistency (run after a git merge)")
5823
+ pdoc.set_defaults(func=cmd_doctor)
5824
+
5825
+ pmine = sub.add_parser("mine", help="read-only: my not-done tasks (owner or assignee) "
5826
+ "across all active milestones")
5827
+ pmine.add_argument("--actor", default=None, metavar="\"Name <email>\"",
5828
+ help="inspect another actor's queue instead of your own")
5829
+ pmine.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
5830
+ pmine.set_defaults(func=cmd_mine)
5831
+
5124
5832
  pwv = sub.add_parser("wave-verify",
5125
5833
  help="read-only merge-time gate: every WAVE.md roster echo must match "
5126
5834
  "base (refuses unverified_fork_base) — run before the first merge-back")