@pilotspace/add 1.0.0 → 1.1.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
@@ -12,6 +12,7 @@ existing artifacts unless --force is given.
12
12
  from __future__ import annotations
13
13
 
14
14
  import argparse
15
+ import getpass
15
16
  import json
16
17
  import os
17
18
  import re
@@ -162,7 +163,14 @@ def _require_root() -> Path:
162
163
 
163
164
 
164
165
  def load_state(root: Path) -> dict:
165
- return json.loads((root / STATE_FILE).read_text(encoding="utf-8"))
166
+ """Load + parse state.json, failing CLOSED. A corrupt or unreadable state file
167
+ dies with a clean 'state_invalid' message (never a raw traceback), so every
168
+ command that loads state degrades gracefully (design-for-failure)."""
169
+ try:
170
+ return json.loads((root / STATE_FILE).read_text(encoding="utf-8"))
171
+ except (json.JSONDecodeError, OSError) as e:
172
+ _die(f"state_invalid: {root / STATE_FILE} is corrupt or unreadable "
173
+ f"({e.__class__.__name__}) — restore it from git or a backup")
166
174
 
167
175
 
168
176
  def _load_state_for_json() -> tuple[Path, dict]:
@@ -191,6 +199,15 @@ def save_state(root: Path, state: dict) -> None:
191
199
  _atomic_write(root / STATE_FILE, json.dumps(state, indent=2) + "\n")
192
200
 
193
201
 
202
+ def _setup_locked(state: dict) -> bool:
203
+ """True when the project's setup is locked — i.e. the build-boundary gate is OPEN.
204
+
205
+ A state with NO "setup" key is GRANDFATHERED-locked: plain `init` and every legacy
206
+ project are never gated (the lock is opt-in via `init --await-lock`). The gate is
207
+ therefore active in exactly one case: "setup" present AND locked is False."""
208
+ return ("setup" not in state) or (state["setup"].get("locked") is True)
209
+
210
+
194
211
  def _die(msg: str, code: int = 1) -> None:
195
212
  print(f"add: error: {msg}", file=sys.stderr)
196
213
  raise SystemExit(code)
@@ -205,27 +222,37 @@ def _die(msg: str, code: int = 1) -> None:
205
222
  # 20%+ more cost), so the stable pointer is the whole point.
206
223
 
207
224
  def _guideline_block() -> str:
208
- """The canonical ADD block (markers + body, no trailing newline)."""
225
+ """The canonical ADD block (markers + body, no trailing newline).
226
+
227
+ Agent-agnostic by design (v14 agent-portability): the routing steps depend
228
+ only on the CLI and plain files, so any agent — Claude, Cursor, Copilot,
229
+ Codex — can follow them. Claude additionally gets the `add` skill."""
209
230
  return (
210
231
  f"{_GUIDE_BEGIN}\n"
211
232
  "## ADD — how to work in this repo\n"
212
233
  "\n"
213
234
  "This project uses **ADD (AI-Driven Development)**: you, the AI, drive the build;\n"
214
- "the human owns direction and verification. Before you change code:\n"
235
+ "the human owns direction and verification. The loop below works for any agent —\n"
236
+ "Claude, Cursor, Copilot, Codex — through the CLI alone. Before you change code:\n"
215
237
  "\n"
216
238
  "1. Run `python3 .add/tooling/add.py status` — where the project is and what's\n"
217
239
  " next (the resume point; read it first every session).\n"
218
240
  "2. Read `.add/PROJECT.md` — the foundation (domain · spec · UI/UX) every task\n"
219
241
  " builds on.\n"
220
- "3. Let the **`add` skill drive the flow**: INTAKE sizes the request into a\n"
221
- " milestone, then each task runs the **one-approval front** you draft Spec +\n"
222
- " Scenarios + Contract + Tests as one bundle, the human gives ONE approval at the\n"
223
- " frozen contract — followed by a self-driving build→verify run. `add.py` is your\n"
224
- " hands (scaffold + track state); the human talks to you, not the CLI.\n"
242
+ "3. Run `python3 .add/tooling/add.py guide` it names the phase and the exact\n"
243
+ " phase-guide file to read (the `guide :` line). Work ONLY that phase — each\n"
244
+ " guide ends with its exit gate and the command to move on.\n"
245
+ "\n"
246
+ "The flow: INTAKE sizes a request into a milestone; each task runs the\n"
247
+ "**one-approval front** — Spec+Scenarios+Contract+Tests as one bundle,\n"
248
+ "ONE human approval at the frozen contract — then a self-driving build→verify\n"
249
+ "run. Non-negotiable for every agent:\n"
250
+ "Never weaken a test or edit a frozen contract to make a build pass; a security\n"
251
+ "finding is always HARD-STOP — never auto-passed.\n"
225
252
  "\n"
226
- "The full method (the book) is in `.add/docs/`; the `add` skill loads the right\n"
227
- "phase guide on demand. This block is generated by `add.py sync-guidelines`; edit\n"
228
- "outside the markers, not inside.\n"
253
+ "On Claude Code the `add` skill drives this loop automatically; other agents\n"
254
+ "follow the three steps. The book is in `.add/docs/`. This block is generated\n"
255
+ "by `add.py sync-guidelines`; edit outside the markers, not inside.\n"
229
256
  f"{_GUIDE_END}"
230
257
  )
231
258
 
@@ -294,6 +321,24 @@ def _inject_guidelines(project_root: Path) -> list[tuple[str, str]]:
294
321
 
295
322
  # --- commands ----------------------------------------------------------------
296
323
 
324
+ _INIT_EXCLUDE = {
325
+ ".add", "AGENTS.md", "CLAUDE.md", ".git",
326
+ ".gitignore", ".gitattributes", ".github", ".editorconfig", # VCS/CI/editor scaffolding — no domain signal
327
+ "LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", # legal boilerplate — no domain signal
328
+ } # README/docs/source are NOT excluded: they carry domain content adopt.md maps -> brownfield
329
+
330
+
331
+ def _is_brownfield(base: Path) -> bool:
332
+ """True when `base` already holds project content beyond the tool's own scaffolding.
333
+
334
+ Judgment-free: a mechanical fact (does the dir hold a non-excluded entry?), so the
335
+ autonomous-onboarding flow knows to map existing code into the survivors. INTERPRETING
336
+ that code stays with the AI (skill/add/adopt.md) — the engine only detects + signals."""
337
+ if not base.is_dir():
338
+ return False
339
+ return any(child.name not in _INIT_EXCLUDE for child in base.iterdir())
340
+
341
+
297
342
  def cmd_init(args: argparse.Namespace) -> None:
298
343
  base = Path(args.dir).resolve()
299
344
  root = base / ROOT_DIRNAME
@@ -330,14 +375,24 @@ def cmd_init(args: argparse.Namespace) -> None:
330
375
  "created": _now(),
331
376
  "updated": _now(),
332
377
  }
378
+ if getattr(args, "await_lock", False):
379
+ # opt-in: seed an UNLOCKED setup so the build-boundary gate is active until
380
+ # `add.py lock`. Plain init omits this key entirely (grandfathered-locked).
381
+ state["setup"] = {"locked": False, "locked_at": None, "locked_by": None, "layers": []}
333
382
  save_state(root, state)
334
383
  # zero-config: give any agent a stable pointer into the ADD runtime.
335
384
  for name, action in _inject_guidelines(base):
336
385
  if action != "unchanged":
337
386
  print(f"{action:>9} {name}")
338
387
  print(f"initialised ADD project '{state['project']}' (stage: {state['stage']}) at {root}")
339
- print("next: open Claude Code, run `/add`, and say what you want to build —")
340
- print(" the `add` skill sizes it into a milestone and drives the build with you.")
388
+ if _is_brownfield(base):
389
+ # Existing code present the AI maps it SILENTLY into the survivors (skill/add/adopt.md),
390
+ # then the human locks it down. The engine only flags it; it never reads or fills the code.
391
+ print("brownfield: existing code detected — the `add` skill maps it into your")
392
+ print(" foundation (silent), then you lock it down: add.py lock")
393
+ else:
394
+ print("next: open Claude Code, run `/add`, and say what you want to build —")
395
+ print(" the `add` skill sizes it into a milestone and drives the build with you.")
341
396
 
342
397
 
343
398
  def cmd_sync_guidelines(args: argparse.Namespace) -> None:
@@ -349,6 +404,9 @@ def cmd_sync_guidelines(args: argparse.Namespace) -> None:
349
404
  def cmd_new_task(args: argparse.Namespace) -> None:
350
405
  root = _require_root()
351
406
  state = load_state(root)
407
+ # build-boundary gate: pre-lock, EXACTLY one first task may be drafted; refuse a 2nd.
408
+ if not _setup_locked(state) and state.get("tasks"):
409
+ _die("setup_unlocked: lock the foundation first — add.py lock")
352
410
  slug = args.slug
353
411
  if not slug.replace("-", "").replace("_", "").isalnum():
354
412
  _die("slug must be alphanumeric with - or _ only")
@@ -453,6 +511,10 @@ def cmd_advance(args: argparse.Namespace) -> None:
453
511
  if idx >= len(PHASES) - 1:
454
512
  _die(f"task '{slug}' already at final phase ({cur})")
455
513
  nxt = PHASES[idx + 1]
514
+ # build-boundary gate: pre-lock the front (specify..tests) is allowed, but crossing
515
+ # into build/verify/observe/done is refused until `add.py lock`.
516
+ if not _setup_locked(state) and nxt in ("build", "verify", "observe", "done"):
517
+ _die("setup_unlocked: lock the foundation first — add.py lock")
456
518
  state["tasks"][slug]["phase"] = nxt
457
519
  state["tasks"][slug]["updated"] = _now()
458
520
  _sync_task_marker(root, slug, nxt)
@@ -460,10 +522,33 @@ def cmd_advance(args: argparse.Namespace) -> None:
460
522
  print(f"task '{slug}' phase {cur} -> {nxt}")
461
523
 
462
524
 
525
+ # The mechanized high-risk guard (run.md, v14): judging WHAT is high-risk stays
526
+ # human — a scope declares `risk: high` in its TASK.md header at the freeze. The
527
+ # engine then enforces the pure token contradiction: risk: high WITHOUT
528
+ # autonomy: conservative is unguarded, and completion is refused. Tokens are
529
+ # read from the header region (text before the first section heading) with HTML
530
+ # comments stripped — a documentation comment is never a declaration.
531
+ _RISK_HIGH_RE = re.compile(r"\brisk:\s*high\b")
532
+ _AUTONOMY_CONSERVATIVE_RE = re.compile(r"\bautonomy:\s*conservative\b")
533
+
534
+
535
+ def _task_header(root: Path, slug: str) -> str:
536
+ """The TASK.md header region — where declared tokens (risk · autonomy)
537
+ live — with HTML comments stripped. Missing file -> '' (no tokens)."""
538
+ try:
539
+ text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8")
540
+ except OSError:
541
+ return ""
542
+ return re.sub(r"<!--.*?-->", "", text.split("\n## ", 1)[0], flags=re.S)
543
+
544
+
463
545
  def cmd_gate(args: argparse.Namespace) -> None:
464
546
  root = _require_root()
465
547
  state = load_state(root)
466
548
  slug = _resolve_task(state, args.slug)
549
+ # build-boundary gate: no verdict may be recorded before the setup is locked.
550
+ if not _setup_locked(state):
551
+ _die("setup_unlocked: lock the foundation first — add.py lock")
467
552
  if args.outcome not in GATES:
468
553
  _die(f"outcome must be one of: {', '.join(GATES)}")
469
554
  # Completing outcomes (PASS, RISK-ACCEPTED) are the VERIFY step's verdict, so they
@@ -478,6 +563,14 @@ def cmd_gate(args: argparse.Namespace) -> None:
478
563
  else "gate_risk_accepted_before_verify")
479
564
  _die(f"{code}: task '{slug}' is at '{current}'; reach the verify phase "
480
565
  f"first (or `add.py phase verify {slug}` to override)")
566
+ # the mechanized high-risk guard: an unguarded high-risk header refuses
567
+ # COMPLETION (PASS / RISK-ACCEPTED) until the dial is lowered and a human
568
+ # owns the gate. HARD-STOP is never blocked — stopping is always allowed.
569
+ hdr = _task_header(root, slug)
570
+ if _RISK_HIGH_RE.search(hdr) and not _AUTONOMY_CONSERVATIVE_RE.search(hdr):
571
+ _die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high "
572
+ "without autonomy: conservative — lower the dial in the TASK.md "
573
+ "header; a human must own a high-risk gate (run.md guard)")
481
574
  if args.outcome == "RISK-ACCEPTED":
482
575
  # A waiver must be SIGNED: owner, ticket, expiry (glossary). Stored in state
483
576
  # so a later `check` can read/expire it. Refuse a partial waiver outright.
@@ -499,6 +592,36 @@ def cmd_gate(args: argparse.Namespace) -> None:
499
592
  print("HARD-STOP recorded: return to BUILD; nothing ships on a failing/security gate.")
500
593
 
501
594
 
595
+ def cmd_lock(args: argparse.Namespace) -> None:
596
+ """The human lock-down: freeze the autonomously-drafted setup in ONE atomic write.
597
+
598
+ Setup-altitude analog of the contract freeze — the only new human action onboarding
599
+ needs. `add.py lock` is judgment-free (it records the signature; it does NOT inspect
600
+ the artifacts): the human's signature IS the gate."""
601
+ root = _require_root()
602
+ state = load_state(root)
603
+ # idempotent-guarded: the predicate also treats a grandfathered (no "setup" key)
604
+ # project as already locked, so a bare re-lock there refuses too.
605
+ if _setup_locked(state) and not args.force:
606
+ _die("already_locked: setup is already locked (use --force to re-lock)")
607
+ # parse layers BEFORE any write so an invalid request never half-locks (design-for-failure).
608
+ raw = args.layers if args.layers is not None else "foundation,scope,contract"
609
+ layers = [s.strip() for s in raw.split(",") if s.strip()]
610
+ if not layers:
611
+ _die("layers_invalid: --layers must name at least one lock layer")
612
+ who = args.by or getpass.getuser()
613
+ when = _now()
614
+ # ONE atomic write — no partial lock state.
615
+ state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers}
616
+ save_state(root, state)
617
+ if getattr(args, "json", False):
618
+ print(json.dumps(
619
+ {"locked": True, "locked_at": when, "locked_by": who, "layers": layers},
620
+ separators=(",", ":")))
621
+ else:
622
+ print(f"locked setup ({','.join(layers)}) by {who} @ {when}")
623
+
624
+
502
625
  def cmd_stage(args: argparse.Namespace) -> None:
503
626
  root = _require_root()
504
627
  state = load_state(root)
@@ -531,8 +654,11 @@ def cmd_status(args: argparse.Namespace) -> None:
531
654
  state = load_state(root)
532
655
  active = state.get("active_task")
533
656
  tasks = state.get("tasks", {})
534
- print(f"project : {state['project']}")
535
- print(f"stage : {state['stage']}")
657
+ # Compute once: True when setup is present AND locked is False (the lock-gate window).
658
+ # Reuses the canonical helper — do NOT write a parallel predicate.
659
+ unlocked = not _setup_locked(state)
660
+ print(f"project : {state.get('project', '(unknown)')}")
661
+ print(f"stage : {state.get('stage', '(unknown)')}")
536
662
  # foundation pointer — read the cross-milestone context first (anti-rot)
537
663
  if (root / "PROJECT.md").exists():
538
664
  print("context : .add/PROJECT.md (foundation: domain · spec · UI/UX — read first)")
@@ -560,13 +686,18 @@ def cmd_status(args: argparse.Namespace) -> None:
560
686
  print(f"active : {active or '(none)'}")
561
687
  if not tasks:
562
688
  # First-run panel: a brand-new project's status is the moment a user is most
563
- # lost. Lead with the AI-first move (/add), keep the CLI as the escape hatch
564
- # mirrors `init`'s next-hint so the entry point is actionable, not a bare line.
689
+ # lost. When the setup is unlocked, the only correct next move is review+lock
690
+ # suppress the generic /add hint and name the two steps that matter.
565
691
  print("tasks : (none yet)")
566
692
  print()
567
- print("next : you're set up. In Claude Code, run /add and say what you want to")
568
- print(" buildthe `add` skill sizes it into a milestone and drives the")
569
- print(' build with you. Escape hatch: add.py new-task <slug> --title "..."')
693
+ if unlocked:
694
+ print("setup : UNLOCKED review .add/SETUP-REVIEW.md (least-sure first),"
695
+ " then sign: add.py lock")
696
+ print(" (the build-boundary gate is closed until the foundation is locked)")
697
+ else:
698
+ print("next : you're set up. In Claude Code, run /add and say what you want to")
699
+ print(" build — the `add` skill sizes it into a milestone and drives the")
700
+ print(' build with you. Escape hatch: add.py new-task <slug> --title "..."')
570
701
  return
571
702
  print("tasks :")
572
703
  for slug, t in tasks.items():
@@ -575,7 +706,18 @@ def cmd_status(args: argparse.Namespace) -> None:
575
706
  dep_s = f" deps={','.join(deps)}" if deps else ""
576
707
  ms_s = f" [{t['milestone']}]" if t.get("milestone") else ""
577
708
  print(f" {mark} {slug:<24} phase={t['phase']:<10} gate={t['gate']}{ms_s}{dep_s}")
578
- if active:
709
+ # fold-pressure nudge: surface unfolded competency deltas so emission can't
710
+ # silently outrun the human fold (read-only; v11). Silent when none are open.
711
+ open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
712
+ if open_deltas:
713
+ print(f"deltas : {open_deltas} open — fold at milestone close (add.py deltas)")
714
+ # When the setup is unlocked, the only terminal guidance that matters is
715
+ # review+lock; suppress the generic resume block so it does not compete.
716
+ if unlocked:
717
+ print("\nsetup : UNLOCKED — review .add/SETUP-REVIEW.md (least-sure first),"
718
+ " then sign: add.py lock")
719
+ print(" (the build-boundary gate is closed until the foundation is locked)")
720
+ elif active and active in tasks:
579
721
  ph = tasks[active]["phase"]
580
722
  if ph == "done":
581
723
  print(f"\nresume : task '{active}' is done ({tasks[active]['gate']}).")
@@ -585,18 +727,42 @@ def cmd_status(args: argparse.Namespace) -> None:
585
727
  print(f" read .add/tasks/{active}/TASK.md and continue that phase.")
586
728
 
587
729
 
730
+ # Agent-portability (v14): `guide` names the PHASE PLAYBOOK file — the same
731
+ # guides the Claude skill loads, installed as plain markdown by every channel
732
+ # at .claude/skills/add/phases/ — so ANY agent (Cursor, Copilot, Codex) can be
733
+ # routed there through the CLI alone. Never a dead pointer: the path is printed
734
+ # only if the file exists; a missing tree gets an install hint instead.
735
+ _PHASE_GUIDE_FILES = {
736
+ "specify": "1-specify.md", "scenarios": "2-scenarios.md",
737
+ "contract": "3-contract.md", "tests": "4-tests.md",
738
+ "build": "5-build.md", "verify": "6-verify.md", "observe": "7-observe.md",
739
+ }
740
+ _SKILL_PHASES_DIR = Path(".claude") / "skills" / "add" / "phases"
741
+
742
+
743
+ def _phase_guide_path(project_root: Path, phase: str) -> str | None:
744
+ """Relative path to the phase playbook if it exists, else None.
745
+ done/unknown phases have no playbook (the `then:` line routes onward)."""
746
+ fname = _PHASE_GUIDE_FILES.get(phase)
747
+ if fname is None:
748
+ return None
749
+ rel = _SKILL_PHASES_DIR / fname
750
+ return str(rel) if (project_root / rel).is_file() else None
751
+
752
+
588
753
  def cmd_guide(args: argparse.Namespace) -> None:
589
754
  """Answer "what do I do next?" for the active (or named) task.
590
755
 
591
756
  Strictly read-only: load_state only — never save_state, never writes a TASK.md.
592
757
  """
593
758
  if getattr(args, "json", False):
594
- _, state = _load_state_for_json()
759
+ json_root, state = _load_state_for_json()
595
760
  slug = args.slug or state.get("active_task")
596
761
  if not slug:
597
762
  print(json.dumps({"task": None, "phase": None, "owner": "human", "stop": True,
598
763
  "next_step": "start your first feature -> add.py new-task <slug>",
599
- "chapter": ".add/docs/02-the-flow.md", "gate": None}))
764
+ "chapter": ".add/docs/02-the-flow.md", "gate": None,
765
+ "guide": None}))
600
766
  return
601
767
  t = (state.get("tasks") or {}).get(slug)
602
768
  if t is None:
@@ -606,7 +772,8 @@ def cmd_guide(args: argparse.Namespace) -> None:
606
772
  action, chapter = PHASE_GUIDE[phase] # phase is mapped, so PHASE_GUIDE has it too
607
773
  print(json.dumps({"task": slug, "phase": phase, "owner": owner,
608
774
  "stop": owner != "ai", "next_step": action,
609
- "chapter": f".add/docs/{chapter}", "gate": t.get("gate")}))
775
+ "chapter": f".add/docs/{chapter}", "gate": t.get("gate"),
776
+ "guide": _phase_guide_path(json_root.parent, phase)}))
610
777
  return
611
778
  root = _require_root()
612
779
  state = load_state(root)
@@ -626,6 +793,11 @@ def cmd_guide(args: argparse.Namespace) -> None:
626
793
  print(f"active : {slug} (phase: {phase})")
627
794
  print(f"next : {action}")
628
795
  print(f"read : .add/docs/{chapter}")
796
+ gp = _phase_guide_path(root.parent, phase)
797
+ if gp is not None:
798
+ print(f"guide : {gp}")
799
+ elif phase in _PHASE_GUIDE_FILES:
800
+ print("guide : (phase guides not installed — npx @pilotspace/add init)")
629
801
  if phase == "verify":
630
802
  print("then : add.py gate PASS | RISK-ACCEPTED | HARD-STOP")
631
803
  elif phase == "done":
@@ -698,6 +870,13 @@ def cmd_check(args: argparse.Namespace) -> None:
698
870
  except (ValueError, TypeError):
699
871
  ok, reason = False, f"waiver_expired (unparseable expires={exp!r})"
700
872
  checks.append((ok, f"task '{slug}' waiver not expired", reason))
873
+ # delta-lint: validate all OPEN entries in the "### Competency deltas" block.
874
+ # Fail-closed; folded/rejected entries are skipped (open-only). Only emits a
875
+ # check when at least one delta-attempt is present in the block.
876
+ lint_result = _lint_task_deltas(root, slug)
877
+ if lint_result is not None:
878
+ ok, reason = lint_result
879
+ checks.append((ok, f"task '{slug}' deltas well-formed", reason))
701
880
 
702
881
  # drift: a done milestone must have no unfinished tasks
703
882
  for mslug, m in milestones.items():
@@ -837,6 +1016,12 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
837
1016
  print(f"milestone '{slug}' -> done ({len(members)} tasks complete{tail}).")
838
1017
  print(f"wrote {retro_path.relative_to(root.parent)} (milestone exit report)")
839
1018
  print("Confirm the MILESTONE.md exit criteria are checked, then archive/start the next.")
1019
+ # fold-pressure nudge: milestone close is the natural fold point for open deltas (v11)
1020
+ open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
1021
+ if open_deltas:
1022
+ noun = "delta" if open_deltas == 1 else "deltas"
1023
+ print(f"note: {open_deltas} open competency {noun} to fold into the foundation "
1024
+ f"— review with: add.py deltas")
840
1025
 
841
1026
 
842
1027
  def cmd_archive_milestone(args: argparse.Namespace) -> None:
@@ -861,6 +1046,15 @@ def cmd_archive_milestone(args: argparse.Namespace) -> None:
861
1046
  t = tasks[s]
862
1047
  print(f" - {s} (phase={t.get('phase')}, gate={t.get('gate')})", file=sys.stderr)
863
1048
  _die("milestone_has_incomplete_tasks")
1049
+ # pre-archive snapshot (design-for-failure): the archived record below keeps only a
1050
+ # slug-list, so capture the full milestone + member task records to a .bak BEFORE the
1051
+ # destructive deletes — an accidental archive stays recoverable (phase/gate/waiver/deps
1052
+ # the record drops). Mirrors the .bak the guideline injector writes before mutating.
1053
+ _atomic_write(
1054
+ root / "milestones" / slug / "pre-archive-state.bak.json",
1055
+ json.dumps({"milestone": ms, "tasks": {s: tasks[s] for s in members},
1056
+ "archived_at": _now()}, indent=2) + "\n",
1057
+ )
864
1058
  # a slug-list summary (never task bodies) so the active state can't regrow,
865
1059
  # yet cross-milestone deps on these tasks still resolve (see _archived_task_slugs)
866
1060
  state.setdefault("archived", []).append({
@@ -900,6 +1094,20 @@ def cmd_set_milestone(args: argparse.Namespace) -> None:
900
1094
  print(f"task '{task}' -> milestone '{new}'" if new else f"task '{task}' -> milestone (none)")
901
1095
 
902
1096
 
1097
+ def cmd_use(args: argparse.Namespace) -> None:
1098
+ """Set the active task to an EXISTING task (switch focus) without scaffolding a new
1099
+ one or hand-editing state.json. advance/gate/phase still take an explicit slug; `use`
1100
+ just moves the default focus, closing the only gap that forced manual state edits."""
1101
+ root = _require_root()
1102
+ state = load_state(root)
1103
+ slug = args.slug
1104
+ if slug not in state.get("tasks", {}):
1105
+ _die("unknown_task")
1106
+ state["active_task"] = slug
1107
+ save_state(root, state)
1108
+ print(f"active task -> '{slug}' (phase={state['tasks'][slug]['phase']})")
1109
+
1110
+
903
1111
  def _find_cycle(tasks: dict) -> list[str] | None:
904
1112
  """Return a cycle path in the depends_on graph, or None. Ignores unknown deps."""
905
1113
  WHITE, GRAY, BLACK = 0, 1, 2
@@ -1057,12 +1265,80 @@ def _exit_criteria(root: Path, mslug: str) -> tuple[int, int]:
1057
1265
  return met, total
1058
1266
 
1059
1267
 
1268
+ def _count_test_defs(f: Path) -> int:
1269
+ """`def test_` occurrences in one file — the ONE counting regex (primary and
1270
+ §4-declared fallback share it by construction). OSError -> 0, fail-closed."""
1271
+ try:
1272
+ return len(re.findall(r"^\s*def test_", f.read_text(encoding="utf-8"), re.M))
1273
+ except OSError:
1274
+ return 0
1275
+
1276
+
1060
1277
  def _tests_count(root: Path, slug: str) -> int:
1061
1278
  d = root / "tasks" / slug / "tests"
1062
1279
  if not d.is_dir():
1063
1280
  return 0
1064
- return sum(len(re.findall(r"^\s*def test_", f.read_text(encoding="utf-8"), re.M))
1065
- for f in d.glob("*.py"))
1281
+ return sum(_count_test_defs(f) for f in d.glob("*.py"))
1282
+
1283
+
1284
+ def _confined(p: Path, rootp: Path) -> bool:
1285
+ """True only if p resolves (symlinks followed) inside rootp; errors -> False.
1286
+ The v2 confinement check — no read is attempted on a path that fails it."""
1287
+ try:
1288
+ return p.resolve().is_relative_to(rootp)
1289
+ except OSError:
1290
+ return False
1291
+
1292
+
1293
+ def _declared_tests_count(root: Path, slug: str) -> int:
1294
+ """Count tests at the §4 'Tests live in:' declared path(s). PURE, fail-closed 0.
1295
+ Tokens are the backticked spans on the FIRST declaring line of the raw §4 body.
1296
+ Resolution: './…' -> task dir · contains '/' -> project root (parent of .add) ·
1297
+ bare name -> sibling of the previous resolved token (else task dir). A directory
1298
+ token counts the *.py files directly inside it; resolved files are deduped.
1299
+ v2 confinement: every file read must resolve inside the project root — '..'
1300
+ traversal, absolute tokens, and symlink escapes all contribute 0, fail-closed."""
1301
+ body = _raw_phase_bodies(root, slug).get(4, "")
1302
+ m = re.search(r"^\s*Tests live in:.*$", body, re.M)
1303
+ if not m:
1304
+ return 0
1305
+ tdir = root / "tasks" / slug
1306
+ rootp = root.parent.resolve()
1307
+ files: list[Path] = []
1308
+ prev_dir = None
1309
+ for tok in re.findall(r"`([^`]+)`", m.group(0)):
1310
+ tok = tok.strip()
1311
+ if tok.startswith("./"):
1312
+ p = tdir / tok[2:]
1313
+ elif "/" in tok:
1314
+ p = root.parent / tok
1315
+ else:
1316
+ p = (prev_dir or tdir) / tok
1317
+ try:
1318
+ if not _confined(p, rootp):
1319
+ continue
1320
+ if p.is_dir():
1321
+ cand, prev_dir = sorted(f for f in p.glob("*.py")
1322
+ if _confined(f, rootp)), p
1323
+ elif p.is_file() and p.suffix == ".py":
1324
+ cand, prev_dir = [p], p.parent
1325
+ else:
1326
+ continue
1327
+ except OSError:
1328
+ continue
1329
+ files.extend(f for f in cand if f not in files)
1330
+ return sum(_count_test_defs(f) for f in files)
1331
+
1332
+
1333
+ def _tests_info(root: Path, slug: str) -> tuple[int, bool]:
1334
+ """(count, declared). The tests/ dir count ALWAYS wins when > 0; otherwise the
1335
+ §4-declared fallback — flagged True only when it supplied a non-zero count, so
1336
+ a true zero stays a bare, honest 0."""
1337
+ primary = _tests_count(root, slug)
1338
+ if primary > 0:
1339
+ return primary, False
1340
+ declared = _declared_tests_count(root, slug)
1341
+ return (declared, True) if declared > 0 else (0, False)
1066
1342
 
1067
1343
 
1068
1344
  def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
@@ -1076,8 +1352,6 @@ def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
1076
1352
  text = f.read_text(encoding="utf-8")
1077
1353
  m7 = re.search(r"##\s*7\s*·\s*OBSERVE.*\Z", text, re.S)
1078
1354
  lines = (m7.group(0) if m7 else text).splitlines()
1079
- _delta_start = re.compile(r"\s*-\s*\[\s*(DDD|SDD|UDD|TDD|ADD)\s*·\s*(open|folded|rejected)\s*\]\s*(.+)$")
1080
-
1081
1355
  # observe: the field value + continuation lines until a blank line / heading / list
1082
1356
  observe = "(unknown)"
1083
1357
  for i, ln in enumerate(lines):
@@ -1098,14 +1372,14 @@ def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
1098
1372
  # deltas: each "- [COMP · status] ..." plus its indented continuation lines
1099
1373
  deltas, i = [], 0
1100
1374
  while i < len(lines):
1101
- m = _delta_start.match(lines[i])
1375
+ m = _DELTA_RE.match(lines[i])
1102
1376
  if not m:
1103
1377
  i += 1
1104
1378
  continue
1105
1379
  parts, j = [m.group(3).strip()], i + 1
1106
1380
  while j < len(lines):
1107
1381
  t = lines[j].strip()
1108
- if not t or t.startswith("#") or _delta_start.match(lines[j]):
1382
+ if not t or t.startswith("#") or _DELTA_RE.match(lines[j]):
1109
1383
  break
1110
1384
  parts.append(t)
1111
1385
  j += 1
@@ -1152,6 +1426,7 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
1152
1426
  observe, deltas = _task_prose(root, slug)
1153
1427
  phase = t.get("phase", "specify")
1154
1428
  gate = t.get("gate", "none")
1429
+ n_tests, t_declared = _tests_info(root, slug)
1155
1430
  row = {
1156
1431
  "slug": slug,
1157
1432
  "title": t.get("title", slug),
@@ -1159,7 +1434,8 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
1159
1434
  "phase_index": PHASES.index(phase) if phase in PHASES else 0,
1160
1435
  "done": _task_done(t),
1161
1436
  "gate": gate,
1162
- "tests": _tests_count(root, slug),
1437
+ "tests": n_tests,
1438
+ "tests_declared": t_declared,
1163
1439
  "observe": observe,
1164
1440
  "deltas": deltas,
1165
1441
  "waiver": t.get("waiver"),
@@ -1185,6 +1461,9 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
1185
1461
  "tasks": task_rows,
1186
1462
  "waivers": waivers,
1187
1463
  "deltas": all_deltas,
1464
+ # additive (v13-1): MILESTONE.md-planned slugs with no TASK.md yet —
1465
+ # the plan-vs-state diff DECIDE NEXT was blind to; [] when none
1466
+ "planned_unscaffolded": _planned_unscaffolded(root, mslug),
1188
1467
  }
1189
1468
 
1190
1469
 
@@ -1204,22 +1483,13 @@ def _clean_phase_body(body: str) -> str:
1204
1483
  return "\n".join(lines) if meaningful else "(empty)"
1205
1484
 
1206
1485
 
1207
- def task_phases(root: Path, slug: str) -> list[dict]:
1208
- """The frozen per-task PHASE-DETAIL shape (v9-1): parse TASK.md §1–§7 into seven
1209
- blocks specify→observe. PURE NO writes. Each entry is
1210
- { "phase": <name>, "n": <1..7>, "body": <cleaned text | "(empty)"> }.
1211
-
1212
- Sections are matched on the NUMBER (`^##\\s*<n>\\s*·`, case/locale-proof, the phase
1213
- word maps n->PHASES[n-1]); a body runs from its heading to the next `## `/`---`/EOF.
1214
- Missing file / missing section / placeholder-only body -> "(empty)" (fail-closed).
1486
+ def _phase_spans(text: str) -> dict[int, str]:
1487
+ """Split a TASK.md into RAW §1–§7 bodies keyed by section number — the ONE
1488
+ canonical heading scan (`^##\\s*<n>\\s*·`, case/locale-proof); a body runs from
1489
+ its heading to the next `## `/`---`/EOF. RAW = byte-faithful lines, no cleaning:
1490
+ the decision-marker extractor (decide-digest) depends on byte-verbatim text.
1215
1491
  KNOWN LIMIT: a §body containing a line-start `## ` or bare `---` truncates early —
1216
1492
  today's TASK.md bodies don't (box-chars ─═, `### ` sub-heads)."""
1217
- names = PHASES[:7] # specify..observe; "done" is a terminal STATE, not a section
1218
- f = root / "tasks" / slug / "TASK.md"
1219
- try:
1220
- text = f.read_text(encoding="utf-8")
1221
- except OSError: # missing OR unreadable -> every phase fail-closed to "(empty)"
1222
- return [{"phase": names[n - 1], "n": n, "body": "(empty)"} for n in range(1, 8)]
1223
1493
  lines = text.splitlines()
1224
1494
  head = re.compile(r"^##\s*(\d+)\s*·")
1225
1495
  starts: dict[int, int] = {}
@@ -1229,21 +1499,47 @@ def task_phases(root: Path, slug: str) -> list[dict]:
1229
1499
  n = int(m.group(1))
1230
1500
  if 1 <= n <= 7 and n not in starts:
1231
1501
  starts[n] = idx
1232
- out = []
1233
- for n in range(1, 8):
1234
- if n not in starts:
1235
- out.append({"phase": names[n - 1], "n": n, "body": "(empty)"})
1236
- continue
1502
+ out: dict[int, str] = {}
1503
+ for n, idx in starts.items():
1237
1504
  body_lines = []
1238
- for ln in lines[starts[n] + 1:]:
1505
+ for ln in lines[idx + 1:]:
1239
1506
  if re.match(r"^##\s", ln) or re.match(r"^---\s*$", ln):
1240
1507
  break
1241
1508
  body_lines.append(ln)
1242
- out.append({"phase": names[n - 1], "n": n,
1243
- "body": _clean_phase_body("\n".join(body_lines))})
1509
+ out[n] = "\n".join(body_lines)
1244
1510
  return out
1245
1511
 
1246
1512
 
1513
+ def _raw_phase_bodies(root: Path, slug: str) -> dict[int, str]:
1514
+ """RAW §bodies for one task (byte-faithful, for marker extraction). PURE.
1515
+ Missing/unreadable TASK.md -> {} (fail-closed, like task_phases)."""
1516
+ f = root / "tasks" / slug / "TASK.md"
1517
+ try:
1518
+ return _phase_spans(f.read_text(encoding="utf-8"))
1519
+ except OSError:
1520
+ return {}
1521
+
1522
+
1523
+ def task_phases(root: Path, slug: str) -> list[dict]:
1524
+ """The frozen per-task PHASE-DETAIL shape (v9-1): parse TASK.md §1–§7 into seven
1525
+ blocks specify→observe. PURE — NO writes. Each entry is
1526
+ { "phase": <name>, "n": <1..7>, "body": <cleaned text | "(empty)"> }.
1527
+
1528
+ The heading scan lives in _phase_spans (shared with the decide digest); this view
1529
+ CLEANS each body. Missing file / missing section / placeholder-only body ->
1530
+ "(empty)" (fail-closed)."""
1531
+ names = PHASES[:7] # specify..observe; "done" is a terminal STATE, not a section
1532
+ f = root / "tasks" / slug / "TASK.md"
1533
+ try:
1534
+ text = f.read_text(encoding="utf-8")
1535
+ except OSError: # missing OR unreadable -> every phase fail-closed to "(empty)"
1536
+ return [{"phase": names[n - 1], "n": n, "body": "(empty)"} for n in range(1, 8)]
1537
+ spans = _phase_spans(text)
1538
+ return [{"phase": names[n - 1], "n": n,
1539
+ "body": _clean_phase_body(spans[n]) if n in spans else "(empty)"}
1540
+ for n in range(1, 8)]
1541
+
1542
+
1247
1543
  def _task_title(root: Path, slug: str) -> str:
1248
1544
  """The task's display title from TASK.md line 1 `# TASK: <title>` (fail-soft: the
1249
1545
  slug if the file or the header line is missing)."""
@@ -1262,10 +1558,20 @@ def _task_title(root: Path, slug: str) -> str:
1262
1558
  def _detail_body(body: str, width: int) -> list[str]:
1263
1559
  """Indent a phase body under its block, soft-wrapping over-long physical lines on
1264
1560
  spaces while preserving blank lines + each line's leading indent (so scenarios and
1265
- contract code keep their shape). Drill-down = reading is the point, never clipped."""
1561
+ contract code keep their shape). Fenced ``` blocks are exempt: delimiter lines and
1562
+ everything inside an open fence emit BYTE-VERBATIM (indent + raw — no wrap, no
1563
+ whitespace collapse, even past width) so a copied contract round-trips after
1564
+ stripping the uniform indent; an unclosed fence runs verbatim to the §body end
1565
+ (fail-open). Drill-down = reading is the point, never clipped."""
1266
1566
  indent = " "
1267
1567
  out: list[str] = []
1568
+ fenced = False
1268
1569
  for raw in body.split("\n"):
1570
+ is_delim = raw.lstrip().startswith("```")
1571
+ if fenced or is_delim:
1572
+ fenced = fenced != is_delim # delimiter toggles; content keeps state
1573
+ out.append(indent + raw if raw.strip() else "")
1574
+ continue
1269
1575
  if not raw.strip():
1270
1576
  out.append("")
1271
1577
  continue
@@ -1364,10 +1670,13 @@ def render_report(root: Path, state: dict, mslug: str, *,
1364
1670
  for r in d["tasks"]:
1365
1671
  slug = _clip(r["slug"], 27)
1366
1672
  gate = _GATE_SHORT.get(r["gate"], r["gate"])
1673
+ tests = f"{r['tests']}†" if r.get("tests_declared") else str(r["tests"])
1367
1674
  L.append(f" {slug:<27} {r['phase']:<9} {gate:<4} "
1368
- f"{str(r['tests']):<5} {_phase_track(r['phase'], g)}")
1675
+ f"{tests:<5} {_phase_track(r['phase'], g)}")
1369
1676
  L.append(f" legend {g['reached']} reached {g['current']} current "
1370
1677
  f"{g['pending']} pending spec→…→done")
1678
+ if any(r.get("tests_declared") for r in d["tasks"]):
1679
+ L.append(" † counted at the §4-declared path")
1371
1680
  else:
1372
1681
  L.append(" (no tasks yet)")
1373
1682
  L.append("")
@@ -1385,6 +1694,217 @@ def render_report(root: Path, state: dict, mslug: str, *,
1385
1694
  L.extend(_wrap(x, W - 5, f" {g['bullet']} "))
1386
1695
  else:
1387
1696
  L.append(" LEARNINGS none")
1697
+ L.append("") # DECIDE NEXT footer (v13): always present, APPEND-ONLY
1698
+ L.extend(_wrap(_decide_next_base(state, d), W - 15, " DECIDE NEXT "))
1699
+ if _planned_hint(d): # own segment so the phrase never splits mid-token
1700
+ L.extend(_wrap(_planned_hint(d).removeprefix(" — "), W - 15, " " * 14))
1701
+ L.append(banner)
1702
+ return "\n".join(L)
1703
+
1704
+
1705
+ # ---- decide digest (v13 decide-digest, frozen §3) ---------------------------
1706
+ # Decision markers: prose conventions surfaced VERBATIM. The engine EXTRACTS; it
1707
+ # never interprets, scores, or filters — add.py stays judgment-free, the human
1708
+ # signature is the gate.
1709
+ _MARKER_PREFIXES = (("⚠", "⚠"), ("- [~]", "[~]"), ("- [ ]", "[ ]"))
1710
+ _FRONT_PHASES = ("specify", "scenarios", "contract", "tests")
1711
+
1712
+
1713
+ def _decision_markers(body: str, section: int) -> list[dict]:
1714
+ """Extract decision markers from a RAW §body: a line whose first non-space chars
1715
+ are `⚠` / `- [~]` / `- [ ]`, PLUS its continuation lines (immediately following
1716
+ non-blank lines indented deeper than the marker). text is BYTE-VERBATIM — never
1717
+ re-wrapped, never clipped. Fail-open by design (a differently-worded item is
1718
+ missed); the always-printed count keeps that visible."""
1719
+ items: list[dict] = []
1720
+ lines = body.split("\n")
1721
+ i = 0
1722
+ while i < len(lines):
1723
+ ln = lines[i]
1724
+ stripped = ln.lstrip()
1725
+ tag = next((t for p, t in _MARKER_PREFIXES if stripped.startswith(p)), None)
1726
+ if tag is None:
1727
+ i += 1
1728
+ continue
1729
+ indent = len(ln) - len(stripped)
1730
+ block = [ln]
1731
+ j = i + 1
1732
+ while j < len(lines):
1733
+ nxt = lines[j]
1734
+ ns = nxt.lstrip()
1735
+ if ns and (len(nxt) - len(ns)) > indent:
1736
+ block.append(nxt)
1737
+ j += 1
1738
+ else:
1739
+ break
1740
+ items.append({"marker": tag, "section": section, "text": "\n".join(block)})
1741
+ i = j
1742
+ return items
1743
+
1744
+
1745
+ def _contract_frozen(raw3: str) -> bool:
1746
+ """§3's `Status:` line is the freeze signal (v12 precedent: the freeze is
1747
+ artifact-observable; no engine flag). Missing Status -> DRAFT (fail-closed)."""
1748
+ return any(re.match(r"\s*Status:\s*FROZEN", ln) for ln in raw3.splitlines())
1749
+
1750
+
1751
+ def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict:
1752
+ """FACTS for the task-level decision-seam digest (frozen shape). The seam comes
1753
+ from STATE ONLY: recorded (gate set / observe / done) · front (specify→tests) ·
1754
+ gate (build/verify). judgment = extracted markers, byte-verbatim. PURE."""
1755
+ tasks = state.get("tasks") or {}
1756
+ t = tasks.get(slug, {})
1757
+ phase = t.get("phase", "specify")
1758
+ gate = t.get("gate", "none")
1759
+ if gate != "none" or phase in ("observe", "done"):
1760
+ seam = "recorded"
1761
+ elif phase in _FRONT_PHASES:
1762
+ seam = "front"
1763
+ else:
1764
+ seam = "gate"
1765
+ raw = _raw_phase_bodies(root, slug)
1766
+ frozen = _contract_frozen(raw.get(3, ""))
1767
+ if seam == "gate": # the items closest to the gate lead: §6 first, then §1
1768
+ judgment = _decision_markers(raw.get(6, ""), 6) + _decision_markers(raw.get(1, ""), 1)
1769
+ elif seam == "front" and not frozen:
1770
+ judgment = _decision_markers(raw.get(1, ""), 1) + _decision_markers(raw.get(3, ""), 3)
1771
+ else:
1772
+ judgment = []
1773
+
1774
+ members = [x for x in tasks.values() if x.get("milestone") == mslug]
1775
+ done, total = sum(1 for x in members if _task_done(x)), len(members)
1776
+ facts = {"phase": phase, "gate": gate,
1777
+ "deps": [{"slug": d, "gate": tasks.get(d, {}).get("gate", "none")}
1778
+ for d in t.get("depends_on", [])],
1779
+ "tests": _tests_info(root, slug)[0]}
1780
+
1781
+ if seam == "gate":
1782
+ unlocks = f"gate PASS -> task done -> milestone {min(done + 1, total)}/{total}"
1783
+ decide = "add.py gate PASS | RISK-ACCEPTED | HARD-STOP"
1784
+ elif seam == "front" and not frozen:
1785
+ unlocks = "freeze §3 -> the auto run takes build -> verify (autonomy: auto by default)"
1786
+ decide = "approve -> freeze §3 (Status: FROZEN @ v1) -> auto run"
1787
+ elif seam == "front":
1788
+ unlocks = "none"
1789
+ decide = "no decision pending — frozen; the run owns it. next seam: verify gate"
1790
+ else:
1791
+ unlocks = "none"
1792
+ decide = f"no decision pending — recorded gate: {gate}"
1793
+ return {"seam": seam, "milestone": mslug, "task": slug, "phase": phase,
1794
+ "gate": gate, "judgment": judgment, "facts": facts,
1795
+ "unlocks": unlocks, "decide": decide}
1796
+
1797
+
1798
+ def render_decide(root: Path, state: dict, mslug: str, slug: str, *,
1799
+ width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str:
1800
+ """Text view of the decision-seam digest — decisive facts FIRST: NEEDS YOUR
1801
+ JUDGMENT (markers byte-verbatim, section-tagged) -> [front: §3 verbatim] ->
1802
+ ENGINE FACTS -> UNLOCKS -> DECIDE. PURE — no writes; plain text (color is a
1803
+ tty-only skin in cmd_report, like every report view)."""
1804
+ d = decide_data(root, state, mslug, slug)
1805
+ g = _ASCII if ascii else _UNICODE
1806
+ banner = g["h"] * width
1807
+ seam_label = {"gate": "VERIFY GATE", "front": "CONTRACT APPROVAL",
1808
+ "recorded": "RECORDED"}[d["seam"]]
1809
+ L = [banner, f" DECIDE · {mslug or '—'} · {slug} · seam: {seam_label}", banner]
1810
+ if d["decide"].startswith("no decision pending"):
1811
+ L.append(f" {d['decide']}")
1812
+ L.append(f" GATE {d['gate']}")
1813
+ L.append(banner)
1814
+ return "\n".join(L)
1815
+ L.append(f" NEEDS YOUR JUDGMENT ({len(d['judgment'])})")
1816
+ for item in d["judgment"]:
1817
+ L.append(f" [§{item['section']}]")
1818
+ L.extend(item["text"].split("\n")) # byte-verbatim — never wrapped/clipped
1819
+ if d["seam"] == "front":
1820
+ L.append("")
1821
+ L.append(" CONTRACT (§3 verbatim)")
1822
+ L.extend(_raw_phase_bodies(root, slug).get(3, "").split("\n"))
1823
+ L.append(" STATUS DRAFT")
1824
+ f = d["facts"]
1825
+ deps_txt = " ".join(f"{x['slug']}:{x['gate']}" for x in f["deps"]) or "none"
1826
+ L.append("")
1827
+ L.append(f" ENGINE FACTS phase {f['phase']} · gate {f['gate']} · "
1828
+ f"deps {deps_txt} · tests {f['tests']}")
1829
+ L.append(f" UNLOCKS {d['unlocks']}")
1830
+ L.append(f" DECIDE {d['decide']}")
1831
+ L.append(banner)
1832
+ return "\n".join(L)
1833
+
1834
+
1835
+ def _planned_unscaffolded(root: Path, mslug: str) -> list[str]:
1836
+ """Slugs MILESTONE.md plans (rows `- [ ] <slug> …`) that have no TASK.md yet —
1837
+ the plan-vs-state diff. Only valid-slug first-tokens match (a template
1838
+ placeholder like <slug> never does); file order, deduped; fail-closed []."""
1839
+ md = root / "milestones" / mslug / "MILESTONE.md"
1840
+ try:
1841
+ text = md.read_text(encoding="utf-8")
1842
+ except OSError:
1843
+ return []
1844
+ out: list[str] = []
1845
+ for sec in re.split(r"^## ", text, flags=re.M)[1:]:
1846
+ if not sec.startswith("Tasks"): # only the Tasks list — never exit criteria
1847
+ continue
1848
+ for m in re.finditer(r"^- \[[ x~]\] ([A-Za-z0-9_-]+)\b", sec, re.M):
1849
+ slug = m.group(1)
1850
+ if slug not in out and not (root / "tasks" / slug / "TASK.md").is_file():
1851
+ out.append(slug)
1852
+ return out
1853
+
1854
+
1855
+ def _decide_next(state: dict, d: dict) -> str:
1856
+ """The rollup's DECIDE NEXT line (frozen precedence): HARD-STOP -> fold+archive
1857
+ -> first seam-blocked task (ACTIVE task first, then state order) -> run-in-
1858
+ progress. v2: when d carries planned_unscaffolded, the line gains a
1859
+ plan-vs-state suffix — precedence itself stays state-only."""
1860
+ return _decide_next_base(state, d) + _planned_hint(d)
1861
+
1862
+
1863
+ def _planned_hint(d: dict) -> str:
1864
+ """The plan-vs-state suffix ('' when nothing is missing). Text renders emit it
1865
+ as its OWN wrapped segment so the phrase never splits mid-token; the JSON
1866
+ 'decide' string carries it inline via _decide_next."""
1867
+ planned = d.get("planned_unscaffolded") or []
1868
+ if not planned:
1869
+ return ""
1870
+ return f" — {len(planned)} planned not yet scaffolded: " + " · ".join(planned)
1871
+
1872
+
1873
+ def _decide_next_base(state: dict, d: dict) -> str:
1874
+ ms = d["milestone"]["slug"]
1875
+ rows = d["tasks"]
1876
+ if not rows:
1877
+ return "none — no tasks yet"
1878
+ stopped = [r for r in rows if r["gate"] == "HARD-STOP"]
1879
+ if stopped:
1880
+ return f"resolve HARD-STOP on {stopped[0]['slug']}"
1881
+ s = d["summary"]
1882
+ if s["tasks_done"] == s["tasks_total"]:
1883
+ return f"fold learnings + archive-milestone {ms}"
1884
+ active = state.get("active_task")
1885
+ order = sorted(rows, key=lambda r: 0 if r["slug"] == active else 1) # stable
1886
+ for r in order:
1887
+ if r["done"]:
1888
+ continue
1889
+ if r["phase"] in _FRONT_PHASES:
1890
+ return (f"approve the contract of {r['slug']} — "
1891
+ f"add.py report {ms} {r['slug']} --decide")
1892
+ if r["phase"] == "verify" and r["gate"] == "none":
1893
+ return f"gate {r['slug']} — add.py report {ms} {r['slug']} --decide"
1894
+ r = next(x for x in order if not x["done"])
1895
+ return f"none — run in progress ({r['slug']} at {r['phase']})"
1896
+
1897
+
1898
+ def render_decide_next(root: Path, state: dict, mslug: str, *,
1899
+ width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str:
1900
+ """`report <ms> --decide`: ONLY the DECIDE NEXT block (no rollup table). PURE."""
1901
+ g = _ASCII if ascii else _UNICODE
1902
+ banner = g["h"] * width
1903
+ d = report_data(root, state, mslug)
1904
+ L = [banner, f" {mslug} · DECIDE NEXT", banner]
1905
+ L.extend(_wrap(_decide_next_base(state, d), width - 4, " "))
1906
+ if _planned_hint(d): # own segment so the phrase never splits mid-token
1907
+ L.extend(_wrap(_planned_hint(d).removeprefix(" — "), width - 4, " "))
1388
1908
  L.append(banner)
1389
1909
  return "\n".join(L)
1390
1910
 
@@ -1398,10 +1918,309 @@ def _write_retro(root: Path, state: dict, mslug: str) -> Path:
1398
1918
  trust the locale default), never mutates state.json."""
1399
1919
  content = render_report(root, state, mslug, width=_DEFAULT_WIDTH, ascii=False)
1400
1920
  path = root / "milestones" / mslug / "RETRO.md"
1401
- path.write_text(content, encoding="utf-8")
1921
+ _atomic_write(path, content) # honor the module's atomic-write contract (no half-write)
1402
1922
  return path
1403
1923
 
1404
1924
 
1925
+ _COMPETENCY_ORDER = ("DDD", "SDD", "UDD", "TDD", "ADD")
1926
+ _DELTA_STATUSES = ("open", "folded", "rejected")
1927
+
1928
+ # Canonical delta grammar — the single compiled source for the enumerated
1929
+ # competency · status shape. Leading \s* is PERMISSIVE so _task_prose can feed
1930
+ # un-stripped lines directly; callers that pre-strip their input
1931
+ # (e.g. _collect_open_deltas, _lint_task_deltas) match the same way (\s*
1932
+ # matches zero). Anchored at line-start via re.match.
1933
+ _DELTA_RE = re.compile(
1934
+ r"\s*-\s*\[\s*(DDD|SDD|UDD|TDD|ADD)\s*·\s*(open|folded|rejected)\s*\]\s*(.+)$"
1935
+ )
1936
+ _EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
1937
+
1938
+ # Broad structural tag detector: finds ANY "- [tok · tok]" line (valid OR malformed).
1939
+ # A line with a `· ` bracket separator is a delta-attempt. Does NOT enumerate
1940
+ # competencies or statuses — a different abstraction from _DELTA_RE (no DRY violation).
1941
+ _TAG_BROAD_RE = re.compile(r"^\s*-\s*\[\s*([^\]·]+?)\s*·\s*([^\]·]+?)\s*\]\s*(.*)$")
1942
+
1943
+
1944
+ def _lint_task_deltas(root: Path, slug: str) -> tuple[bool, str] | None:
1945
+ """Lint all open delta entries in a task's '### Competency deltas' block.
1946
+
1947
+ Returns:
1948
+ None — no delta-attempts found; no check emitted.
1949
+ (True, "") — all open entries pass.
1950
+ (False, "<code> -> <tag line>") — first failing entry with its failure code.
1951
+
1952
+ Contract rules (frozen §3, v1):
1953
+ - SKIP HTML-comment lines and blank lines (they are never tag lines).
1954
+ - Group lines into ENTRIES: a broad tag line starts an entry; following lines
1955
+ until next tag / blank / end-of-block are its continuation.
1956
+ - A line without a '· ' separator inside brackets (e.g. '- [x]') is NOT a tag.
1957
+ - For each entry, skip folded/rejected (open-only — history not retrofitted).
1958
+ - Validate the remaining (open) entries: COMP in _COMPETENCY_ORDER,
1959
+ status in _DELTA_STATUSES, and '(evidence:' present SOMEWHERE in the unit.
1960
+ - Fail-closed: an unparseable attempt FAILS (never silently passes).
1961
+ """
1962
+ task_md = root / "tasks" / slug / "TASK.md"
1963
+ if not task_md.exists():
1964
+ return None
1965
+ try:
1966
+ text = task_md.read_text(encoding="utf-8")
1967
+ except OSError:
1968
+ return None
1969
+
1970
+ # Locate the "### Competency deltas" block.
1971
+ block_match = re.search(r"###\s*Competency deltas\s*\n(.*?)(?=\n##|\Z)", text, re.S)
1972
+ if not block_match:
1973
+ return None
1974
+
1975
+ block = block_match.group(1)
1976
+ raw_lines = block.splitlines()
1977
+
1978
+ # First pass: collect entries (tag line + continuations).
1979
+ # HTML-comment lines are skipped entirely (invisible to the guard).
1980
+ # Blank lines terminate the current entry, but are not tags themselves.
1981
+ entries: list[tuple[str, list[str]]] = [] # (tag_line, [tag_line, *continuations])
1982
+ current: list[str] | None = None
1983
+ for raw_line in raw_lines:
1984
+ stripped = raw_line.strip()
1985
+ # Skip HTML-comment lines.
1986
+ if stripped.startswith("<!--"):
1987
+ continue
1988
+ # Blank line terminates the current entry.
1989
+ if not stripped:
1990
+ current = None
1991
+ continue
1992
+ # Broad tag detection: any "- [tok · tok]" line starts a new entry.
1993
+ m = _TAG_BROAD_RE.match(raw_line)
1994
+ if m:
1995
+ current = [stripped]
1996
+ entries.append((stripped, current))
1997
+ elif current is not None:
1998
+ # Continuation line of the current entry.
1999
+ current.append(stripped)
2000
+ # else: non-blank, non-comment, non-tag line with no prior entry — ignore.
2001
+
2002
+ if not entries:
2003
+ return None # no delta-attempts → no check emitted
2004
+
2005
+ # Second pass: validate each entry.
2006
+ for tag_line, unit_lines in entries:
2007
+ m = _TAG_BROAD_RE.match(tag_line)
2008
+ if not m:
2009
+ # Should not happen, but fail-closed.
2010
+ return False, f"malformed_delta -> {tag_line}"
2011
+ raw_comp = m.group(1).strip()
2012
+ raw_status = m.group(2).strip()
2013
+
2014
+ # Step 1: skip historical entries (folded/rejected) — open-only enforcement.
2015
+ # MUST happen before competency/status validation per §3: "history not retrofitted".
2016
+ if raw_status in ("folded", "rejected"):
2017
+ continue
2018
+
2019
+ # Step 2: use _DELTA_RE (the canonical grammar, single source of truth) to test
2020
+ # whether the tag line is a fully-valid delta shape. If it matches, check evidence
2021
+ # only. If it fails, classify the failure via the raw tokens (never a parallel grammar).
2022
+ unit_text = " ".join(unit_lines)
2023
+ if _DELTA_RE.match(tag_line):
2024
+ # Valid comp + status + non-empty tail — check evidence in the joined unit.
2025
+ if "(evidence:" not in unit_text:
2026
+ return False, f"no_evidence -> {tag_line}"
2027
+ else:
2028
+ # Classify why _DELTA_RE rejected it (open entries only — folded/rejected skipped).
2029
+ if raw_comp not in _COMPETENCY_ORDER:
2030
+ return False, f"unknown_competency -> {tag_line}"
2031
+ if raw_status not in _DELTA_STATUSES:
2032
+ return False, f"unknown_status -> {tag_line}"
2033
+ # Comp and status are valid but the line still failed _DELTA_RE (e.g. empty tail).
2034
+ return False, f"malformed_delta -> {tag_line}"
2035
+
2036
+ return True, ""
2037
+
2038
+
2039
+ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
2040
+ """Scan every .add/tasks/*/TASK.md for open competency deltas.
2041
+
2042
+ Returns a dict keyed by competency in canonical order; each value is a list
2043
+ of {task, text, evidence} dicts. READ-ONLY — never mutates any file."""
2044
+ by_comp: dict[str, list[dict]] = {c: [] for c in _COMPETENCY_ORDER}
2045
+ tasks_dir = root / "tasks"
2046
+ if not tasks_dir.is_dir():
2047
+ return by_comp
2048
+ for task_md in sorted(tasks_dir.glob("*/TASK.md")):
2049
+ slug = task_md.parent.name
2050
+ try:
2051
+ text = task_md.read_text(encoding="utf-8")
2052
+ except OSError:
2053
+ continue
2054
+ # Locate the "### Competency deltas" block (may appear anywhere in the file).
2055
+ block_match = re.search(r"###\s*Competency deltas\s*\n(.*?)(?=\n##|\Z)", text, re.S)
2056
+ if not block_match:
2057
+ continue
2058
+ block = block_match.group(1)
2059
+ # Group lines into entries (tag line + continuations) so a multi-line delta —
2060
+ # whose learning wraps and whose (evidence: …) may land on a later line — is read
2061
+ # in FULL, not truncated to its first line. A tag line starts an entry; a line
2062
+ # that does not begin a new "- " list item continues it; a blank/comment or a
2063
+ # new "- " item ends it (a trailing malformed item can't pollute a delta's text).
2064
+ entries: list[list[str]] = []
2065
+ current: list[str] | None = None
2066
+ for line in block.splitlines():
2067
+ stripped = line.strip()
2068
+ if not stripped or stripped.startswith("<!--"):
2069
+ current = None
2070
+ continue
2071
+ if _DELTA_RE.match(stripped):
2072
+ current = [stripped]
2073
+ entries.append(current)
2074
+ elif current is not None and not stripped.startswith("-"):
2075
+ current.append(stripped) # genuine wrap of the current learning
2076
+ else:
2077
+ current = None # a new / malformed list item ends the run
2078
+ for unit in entries:
2079
+ m = _DELTA_RE.match(unit[0])
2080
+ comp, status = m.group(1), m.group(2)
2081
+ if status != "open":
2082
+ continue
2083
+ # Join the tag line's tail with any continuation lines, then split evidence.
2084
+ tail = " ".join([m.group(3).strip(), *unit[1:]]).strip()
2085
+ em = _EVIDENCE_RE.match(tail)
2086
+ if em:
2087
+ delta_text, evidence = em.group(1).strip(), em.group(2).strip()
2088
+ else:
2089
+ delta_text, evidence = tail, ""
2090
+ by_comp[comp].append({"task": slug, "text": delta_text, "evidence": evidence})
2091
+ return by_comp
2092
+
2093
+
2094
+ _AUDIT_STAMP_RE = re.compile(r"Status:\s*FROZEN @ v\d+\s*[—-]+\s*approved by\s+\S+")
2095
+ _AUDIT_OUTCOME_RE = re.compile(r"^Outcome:\s*(PASS|RISK-ACCEPTED|HARD-STOP)\b", re.M)
2096
+ _AUDIT_SECURITY_RE = re.compile(
2097
+ r"^\s*- \[[ x~]\] no exposed secrets.*(?:\n(?!\s*- \[|#).*)*", re.M)
2098
+ _AUDIT_REVIEWED_RE = re.compile(r"^Reviewed by:(.*)$", re.M)
2099
+
2100
+
2101
+ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
2102
+ """The gate-audit core: verify that human seams left WELL-FORMED records.
2103
+ Judgment-free — checks record SHAPE (a named human at the freeze, exactly one
2104
+ gate outcome, prose ≡ state, a marked security note never auto-reviewed),
2105
+ never re-decides an outcome. Scope: active tasks done/observe or gated; open
2106
+ fronts skipped. PURE — reads only. Honest limit: shape, not engagement — a
2107
+ forged name passes; CI wiring makes forgery explicit and attributable."""
2108
+ tasks = state.get("tasks") or {}
2109
+ checked, findings = 0, []
2110
+
2111
+ def f(slug: str, code: str, detail: str) -> None:
2112
+ findings.append({"task": slug, "code": code, "detail": detail})
2113
+
2114
+ for slug in sorted(tasks):
2115
+ t = tasks[slug]
2116
+ phase, gate = t.get("phase", "specify"), t.get("gate", "none")
2117
+ if phase not in ("done", "observe") and gate == "none":
2118
+ continue # the front is still open — nothing recorded to audit
2119
+ checked += 1
2120
+ raw = _raw_phase_bodies(root, slug)
2121
+ s3, s6 = raw.get(3, ""), raw.get(6, "")
2122
+ if not _AUDIT_STAMP_RE.search(s3):
2123
+ f(slug, "unstamped_freeze",
2124
+ "§3 lacks 'Status: FROZEN @ vN — approved by <name>'")
2125
+ outcomes = _AUDIT_OUTCOME_RE.findall(s6)
2126
+ if len(outcomes) != 1:
2127
+ f(slug, "malformed_gate_record",
2128
+ f"{len(outcomes)} Outcome lines in §6 (need exactly 1)")
2129
+ elif gate != "none" and outcomes[0] != gate:
2130
+ f(slug, "gate_record_mismatch",
2131
+ f"§6 records {outcomes[0]} but state.json records {gate}")
2132
+ sec = _AUDIT_SECURITY_RE.search(s6)
2133
+ marked = bool(sec and ("NOTE" in sec.group(0) or "⚠" in sec.group(0)))
2134
+ rev = _AUDIT_REVIEWED_RE.search(s6)
2135
+ if marked and rev and "auto-gate" in rev.group(1):
2136
+ f(slug, "unescalated_security_note",
2137
+ "security-line note (NOTE/⚠) with an auto-gate reviewer")
2138
+ # F7 unguarded_high_risk_auto (task high-risk-signal, v14): a declared
2139
+ # high-risk record must show a guarded dial AND a human at the gate —
2140
+ # catches post-gate header tampering and auto-resolved high-risk gates.
2141
+ hdr = _task_header(root, slug)
2142
+ if _RISK_HIGH_RE.search(hdr):
2143
+ if not _AUTONOMY_CONSERVATIVE_RE.search(hdr):
2144
+ f(slug, "unguarded_high_risk_auto",
2145
+ "risk: high declared but autonomy is not 'conservative'")
2146
+ elif rev and "auto-gate" in rev.group(1):
2147
+ f(slug, "unguarded_high_risk_auto",
2148
+ "risk: high task whose GATE RECORD reviewer is the auto-gate")
2149
+ if outcomes == ["RISK-ACCEPTED"]:
2150
+ if marked:
2151
+ f(slug, "risk_accepted_security",
2152
+ "a waiver on a marked security item is never allowed")
2153
+ if not all(re.search(rf"{k}:\s*(?!<)\S", s6)
2154
+ for k in ("owner", "ticket", "expires")):
2155
+ f(slug, "waiver_incomplete",
2156
+ "RISK-ACCEPTED needs owner · ticket · expires")
2157
+ return checked, findings
2158
+
2159
+
2160
+ def cmd_audit(args: argparse.Namespace) -> None:
2161
+ """Read-only: audit recorded human seams for well-formedness. Exit 0 clean,
2162
+ exit 1 with findings — the enforcement seam CI consumes (audit-ci). Writes
2163
+ NOTHING; every other command is byte-identical."""
2164
+ root = _require_root()
2165
+ checked, findings = _audit_findings(root, load_state(root))
2166
+ if getattr(args, "json", False):
2167
+ print(json.dumps({"checked": checked, "findings": findings},
2168
+ ensure_ascii=False, indent=2))
2169
+ else:
2170
+ if findings:
2171
+ for x in findings:
2172
+ print(f"audit: {x['code']} {x['task']} — {x['detail']}")
2173
+ else:
2174
+ print(f"audit: clean ({checked} tasks checked)")
2175
+ if findings:
2176
+ sys.exit(1)
2177
+
2178
+
2179
+ def cmd_deltas(args: argparse.Namespace) -> None:
2180
+ """Read-only: report all open competency deltas grouped by competency.
2181
+
2182
+ Scans every .add/tasks/*/TASK.md '### Competency deltas' block for lines
2183
+ matching the delta grammar; shows only `open` entries in canonical competency
2184
+ order (DDD·SDD·UDD·TDD·ADD). --json emits one JSON object. Exit 0 ALWAYS.
2185
+ Writes NOTHING."""
2186
+ root = _require_root()
2187
+ by_comp = _collect_open_deltas(root)
2188
+ total = sum(len(v) for v in by_comp.values())
2189
+
2190
+ if getattr(args, "json", False):
2191
+ payload: dict = {
2192
+ "total": total,
2193
+ "by_competency": {c: v for c, v in by_comp.items() if v},
2194
+ }
2195
+ print(json.dumps(payload, ensure_ascii=False))
2196
+ return
2197
+
2198
+ if total == 0:
2199
+ print("no open deltas.")
2200
+ return
2201
+
2202
+ print(f"open competency deltas ({total} total):")
2203
+ for comp in _COMPETENCY_ORDER:
2204
+ entries = by_comp[comp]
2205
+ if not entries:
2206
+ continue
2207
+ print(f" {comp} ({len(entries)}):")
2208
+ for e in entries:
2209
+ print(f" - {e['text']} [{e['task']}]")
2210
+
2211
+
2212
+ def cmd_project(args: argparse.Namespace) -> None:
2213
+ """Read-only: print .add/PROJECT.md (the read-first foundation) in one command.
2214
+
2215
+ Fail-closed: a missing foundation dies with a clear stderr message + a non-zero
2216
+ exit, never a silent empty print. Writes NOTHING."""
2217
+ root = _require_root()
2218
+ foundation = root / "PROJECT.md"
2219
+ if not foundation.exists():
2220
+ _die("missing foundation: .add/PROJECT.md (run `add.py init` to scaffold it)")
2221
+ print(foundation.read_text(encoding="utf-8"), end="")
2222
+
2223
+
1405
2224
  def cmd_report(args: argparse.Namespace) -> None:
1406
2225
  """Read-only: capture a milestone's raw data (--json) or render the text
1407
2226
  dashboard (color on a tty, ASCII when the terminal can't do Unicode, --plain
@@ -1433,6 +2252,12 @@ def cmd_report(args: argparse.Namespace) -> None:
1433
2252
  _die(f"unknown_milestone: task '{name}' is not attached to a milestone")
1434
2253
  else:
1435
2254
  _die(f"unknown_milestone: '{name}' is not a milestone")
2255
+ elif getattr(args, "decide", False): # bare --decide -> the ACTIVE TASK
2256
+ slug = state.get("active_task")
2257
+ if not slug or slug not in tasks:
2258
+ _die("no_active_task — name one: add.py report <milestone> <task> --decide")
2259
+ drill_task = slug
2260
+ mslug = tasks[slug].get("milestone") or ""
1436
2261
  else: # no positional -> active milestone
1437
2262
  mslug = state.get("active_milestone")
1438
2263
  if not mslug:
@@ -1441,6 +2266,32 @@ def cmd_report(args: argparse.Namespace) -> None:
1441
2266
  if mslug not in milestones:
1442
2267
  _die(f"unknown_milestone: '{mslug}' is not a milestone")
1443
2268
 
2269
+ if getattr(args, "decide", False):
2270
+ # Decision-seam digest (v13): task -> seam digest; milestone -> DECIDE NEXT
2271
+ # block only. PURE, like every report path.
2272
+ if getattr(args, "json", False):
2273
+ if drill_task:
2274
+ payload = decide_data(root, state, mslug, drill_task)
2275
+ else: # milestone altitude: same frozen key set, task null
2276
+ d = report_data(root, state, mslug)
2277
+ payload = {"seam": "milestone", "milestone": mslug, "task": None,
2278
+ "phase": "", "gate": "none", "judgment": [],
2279
+ "facts": {"phase": "", "gate": "none", "deps": [], "tests": 0},
2280
+ "unlocks": "", "decide": _decide_next(state, d)}
2281
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
2282
+ return
2283
+ plain = getattr(args, "plain", False)
2284
+ interactive = sys.stdout.isatty() and not plain
2285
+ width = _term_width() if interactive else _DEFAULT_WIDTH
2286
+ use_ascii = plain or _use_ascii()
2287
+ out = (render_decide(root, state, mslug, drill_task, width=width, ascii=use_ascii)
2288
+ if drill_task else
2289
+ render_decide_next(root, state, mslug, width=width, ascii=use_ascii))
2290
+ if not plain and _color_enabled():
2291
+ out = _colorize(out)
2292
+ print(out)
2293
+ return
2294
+
1444
2295
  if getattr(args, "json", False):
1445
2296
  # POLYMORPHIC by path: drill -> task_phases list; rollup -> report_data dict.
1446
2297
  payload = task_phases(root, drill_task) if drill_task \
@@ -1468,8 +2319,19 @@ def build_parser() -> argparse.ArgumentParser:
1468
2319
  pi.add_argument("--name", default=None, help="project name (default: dir name)")
1469
2320
  pi.add_argument("--stage", default="prototype", choices=STAGES)
1470
2321
  pi.add_argument("--force", action="store_true", help="reset state.json if present")
2322
+ pi.add_argument("--await-lock", dest="await_lock", action="store_true",
2323
+ help="seed an unlocked setup; gates new-task/advance/gate until `add.py lock`")
1471
2324
  pi.set_defaults(func=cmd_init)
1472
2325
 
2326
+ pl = sub.add_parser("lock",
2327
+ help="freeze the autonomous setup (the human lock-down) and open the build")
2328
+ pl.add_argument("--by", default=None, help="who is locking (default: current OS user)")
2329
+ pl.add_argument("--layers", default=None,
2330
+ help="comma-separated lock layers (default: foundation,scope,contract)")
2331
+ pl.add_argument("--force", action="store_true", help="re-lock an already-locked project")
2332
+ pl.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
2333
+ pl.set_defaults(func=cmd_lock)
2334
+
1473
2335
  pn = sub.add_parser("new-task", help="scaffold a new task (TASK.md + tests/ + src/)")
1474
2336
  pn.add_argument("slug")
1475
2337
  pn.add_argument("--title", default=None)
@@ -1500,6 +2362,10 @@ def build_parser() -> argparse.ArgumentParser:
1500
2362
  psm.add_argument("milestone", help="milestone slug, or 'none' to detach")
1501
2363
  psm.set_defaults(func=cmd_set_milestone)
1502
2364
 
2365
+ pu = sub.add_parser("use", help="set the active task to an existing one (switch focus)")
2366
+ pu.add_argument("slug")
2367
+ pu.set_defaults(func=cmd_use)
2368
+
1503
2369
  pam = sub.add_parser("archive-milestone",
1504
2370
  help="collapse a done milestone out of active state (files stay on disk)")
1505
2371
  pam.add_argument("slug")
@@ -1557,8 +2423,26 @@ def build_parser() -> argparse.ArgumentParser:
1557
2423
  "drill -> task_phases list of 7 phase dicts)")
1558
2424
  prp.add_argument("--plain", action="store_true",
1559
2425
  help="ASCII, no color, fixed width (pipe / CI / screen-reader safe)")
2426
+ prp.add_argument("--decide", action="store_true",
2427
+ help="decision-seam digest: what needs the human's judgment NOW "
2428
+ "(task -> seam digest; milestone -> DECIDE NEXT only; "
2429
+ "bare -> the active task)")
1560
2430
  prp.set_defaults(func=cmd_report)
1561
2431
 
2432
+ pdt = sub.add_parser("deltas",
2433
+ help="read-only report: open competency deltas grouped by competency")
2434
+ pdt.add_argument("--json", action="store_true", help="machine-readable JSON output")
2435
+ pdt.set_defaults(func=cmd_deltas)
2436
+
2437
+ pau = sub.add_parser("audit",
2438
+ help="read-only: verify human seams left well-formed records "
2439
+ "(exit 1 on findings — the CI enforcement seam)")
2440
+ pau.add_argument("--json", action="store_true", help="machine-readable JSON output")
2441
+ pau.set_defaults(func=cmd_audit)
2442
+
2443
+ ppj = sub.add_parser("project", help="print .add/PROJECT.md (the read-first foundation)")
2444
+ ppj.set_defaults(func=cmd_project)
2445
+
1562
2446
  return p
1563
2447
 
1564
2448