@pilotspace/add 2.2.0 → 2.3.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
@@ -34,7 +34,7 @@ except ModuleNotFoundError: # < 3.11: the registry is unsupported → degrade
34
34
  from add_engine.constants import * # noqa: F401,F403 (public constants via __all__)
35
35
  from add_engine.constants import ( # the _-prefixed names (import * skips them)
36
36
  _GITIGNORE_BODY, _GUIDE_BEGIN, _GUIDE_END,
37
- _RULE_REF_LINE, _FALLBACK_TASK,
37
+ _FALLBACK_TASK,
38
38
  _DEFAULT_WIDTH,
39
39
  _DELTA_RE, _PERSONA_TAG_RE, _EVIDENCE_RE, _SPEC_DELTA_RE, # shared delta regexes (taskdoc + deltas-web lint)
40
40
  _SEED_POINTER_RE, # shared (delta-task-backlink) — reads the `[→ slug]` seed stamp back
@@ -301,6 +301,177 @@ def _seeded_delta_pointers(text: str) -> list[str]:
301
301
  return out
302
302
 
303
303
 
304
+ def _signals(root: Path) -> list[dict]:
305
+ """signal-model: project the three split observation primitives — todos
306
+ (state["todos"]), SPEC deltas and competency deltas (each task's §7) — into ONE
307
+ unified signal node list. A signal is {id, kind, text, status, edges}: status
308
+ rides the closed lifecycle {advisory, captured, evidenced, resolving, resolved,
309
+ dropped}; edges are (rel, target_slug) with rel in {observed-by, resolves-into,
310
+ blocks}. PURE projection — reads only, adds NO store, rewrites nothing (the graph
311
+ is a VIEW, not a table). Backward-reading: every pre-existing todo/delta maps to a
312
+ status; a malformed line or corrupt entry is SKIPPED, never raised."""
313
+ try:
314
+ state = load_state(root)
315
+ except Exception:
316
+ return []
317
+ out: list[dict] = []
318
+ # todos — state["todos"] {id, text, status: open|done}
319
+ for t in (state.get("todos") or []):
320
+ if not isinstance(t, dict) or "id" not in t:
321
+ continue
322
+ status = {"open": "captured", "done": "resolved"}.get(t.get("status"))
323
+ if status is None:
324
+ continue
325
+ out.append({"id": f"t{t['id']}", "kind": "todo", "text": t.get("text") or "",
326
+ "status": status, "edges": []})
327
+ # §7 deltas per task — SPEC (open|seeded|dropped|carried) + competency (open|folded|rejected)
328
+ for slug in sorted(state.get("tasks") or {}):
329
+ body = _raw_phase_bodies(root, slug).get(7, "")
330
+ s_n = c_n = 0
331
+ for raw in body.splitlines():
332
+ line = raw.rstrip("\n")
333
+ ms = _SPEC_DELTA_RE.match(line)
334
+ if ms:
335
+ st, text = ms.group(2), ms.group(3)
336
+ edges: list = [("observed-by", slug)]
337
+ if st == "open":
338
+ status = "evidenced" if _EVIDENCE_RE.match(text) else "captured"
339
+ elif st == "carried": # still open, carried forward
340
+ status = "captured"
341
+ elif st == "seeded":
342
+ status = "resolving"
343
+ p = _SEED_POINTER_RE.search(text)
344
+ if p:
345
+ edges.append(("resolves-into", p.group(1)))
346
+ elif st == "dropped":
347
+ status = "dropped"
348
+ else:
349
+ continue
350
+ s_n += 1
351
+ out.append({"id": f"s:{slug}:{s_n}", "kind": "spec-delta",
352
+ "text": text, "status": status, "edges": edges})
353
+ continue
354
+ mc = _DELTA_RE.match(line)
355
+ if mc:
356
+ status = {"open": "evidenced", "folded": "resolved",
357
+ "rejected": "dropped"}.get(mc.group(2))
358
+ if status is None:
359
+ continue
360
+ c_n += 1
361
+ out.append({"id": f"c:{slug}:{c_n}", "kind": "competency-delta",
362
+ "text": mc.group(3), "status": status,
363
+ "edges": [("observed-by", slug)]})
364
+ return out
365
+
366
+
367
+ _EXIT_CRITERION_RE = re.compile(r"^\s*- \[([ x])\]\s+(.*)$")
368
+ _DELIVERED_BY_RE = re.compile(r"\(←\s*([A-Za-z0-9][A-Za-z0-9_-]*)\s*\)")
369
+
370
+
371
+ def _exit_criterion_nodes(root: Path) -> list[dict]:
372
+ """exit-criterion-nodes: project every milestone's `## Exit criteria` section into
373
+ delivered-by signal nodes — one dict per criterion {ms, idx, text, met, delivered_by}.
374
+ `met` is the `[x]` box; `delivered_by` is the `(← <slug>)` pointer (None if absent).
375
+ PURE read of each MILESTONE.md (never state, never a store); a missing file/section
376
+ contributes nothing (fail-soft) — mirrors _exit_criteria, ADDITIVE beside it."""
377
+ try:
378
+ state = load_state(root)
379
+ except Exception:
380
+ return []
381
+ out: list[dict] = []
382
+ for mslug in sorted(state.get("milestones") or {}):
383
+ f = root / "milestones" / mslug / MILESTONE_FILE
384
+ if not f.exists():
385
+ continue
386
+ try:
387
+ text = f.read_text(encoding="utf-8")
388
+ except Exception:
389
+ continue
390
+ m = re.search(r"## Exit criteria.*?(?=\n## |\Z)", text, re.S)
391
+ if not m:
392
+ continue
393
+ idx = 0
394
+ for line in m.group(0).splitlines():
395
+ cm = _EXIT_CRITERION_RE.match(line)
396
+ if not cm:
397
+ continue
398
+ idx += 1
399
+ body = cm.group(2)
400
+ p = _DELIVERED_BY_RE.search(body)
401
+ out.append({"ms": mslug, "idx": idx, "text": body,
402
+ "met": cm.group(1) == "x",
403
+ "delivered_by": p.group(1) if p else None})
404
+ return out
405
+
406
+
407
+ _NUMBERED_BOLD_RE = re.compile(r"(?m)^\s*\d+\.\s+\*\*(.+?)\*\*")
408
+ _PARTS_MARKER_RE = re.compile(r"\(\s*(\d+)\s+parts?\s*\)|(\d+)-part", re.I)
409
+ _CATCHALL_KW_RE = re.compile(r"longtail|drain|sweep|catch-all|grab-bag", re.I)
410
+
411
+
412
+ def _scope_parts(root: Path, slug: str) -> list[str]:
413
+ """atomicity-signal: PURE read of a task's §1/§3 body — return the ordered
414
+ independent-Part labels a scope enumerates. A junk-drawer / longtail / drain
415
+ catch-all reads as N>1 Parts; a normal atomic task reads as [] (silence = pass).
416
+ Signals (union, order-preserving, deduped): a numbered-bold list `N. **label**` ·
417
+ a `(N parts)` / `N-part` marker (N>=2) · a catch-all keyword in the slug or title.
418
+ Returns [] when fewer than 2 Parts — never raises (fail-soft on a missing task)."""
419
+ try:
420
+ bodies = _raw_phase_bodies(root, slug)
421
+ except Exception:
422
+ return []
423
+ body = (bodies.get(1, "") + "\n" + bodies.get(3, ""))
424
+ try:
425
+ state = load_state(root)
426
+ title = ((state.get("tasks") or {}).get(slug) or {}).get("title", "") or ""
427
+ except Exception:
428
+ title = ""
429
+ parts: list[str] = []
430
+ for m in _NUMBERED_BOLD_RE.finditer(body):
431
+ label = m.group(1).strip()
432
+ if label and label not in parts:
433
+ parts.append(label)
434
+ if len(parts) < 2: # no explicit bold list — try the marker
435
+ mk = _PARTS_MARKER_RE.search(body)
436
+ if mk:
437
+ n = int(mk.group(1) or mk.group(2) or 0)
438
+ if n >= 2:
439
+ parts = [f"part {i}" for i in range(1, n + 1)]
440
+ if len(parts) < 2 and _CATCHALL_KW_RE.search(f"{slug} {title}"):
441
+ parts = ["catch-all", "drain"] # keyword fires the nudge (recall over a named list)
442
+ return parts if len(parts) >= 2 else []
443
+
444
+
445
+ def _atomicity_signal_seed(root: Path, slug: str):
446
+ """atomicity-signal: when a task's scope reads as >1 independent Part, SEED a
447
+ persistent `captured` signal (a todo in state["todos"], the store _signals already
448
+ projects) instead of an ephemeral print — so the atomicity concern survives after
449
+ the freeze scrolls away and appears in `graph --signals`. Idempotent per slug (a
450
+ re-freeze adds no duplicate); returns the new todo id, or None when <2 Parts /
451
+ already seeded. Writes ONLY the existing todo store — no new store (thin-engine floor).
452
+ Measure-not-block: the freeze caller wraps this fail-open; it never gates a freeze."""
453
+ parts = _scope_parts(root, slug)
454
+ if len(parts) < 2:
455
+ return None
456
+ state = load_state(root)
457
+ todos = state.get("todos")
458
+ if not isinstance(todos, list):
459
+ todos = state["todos"] = []
460
+ tag = f"atomicity: {slug} —"
461
+ for t in todos:
462
+ if isinstance(t, dict) and t.get("status") == "open" \
463
+ and str(t.get("text", "")).startswith(tag):
464
+ return None # idempotent — already seeded for this slug
465
+ new_id = max((t.get("id", 0) for t in todos if isinstance(t, dict)), default=0) + 1
466
+ text = (f"{tag} §3 scope reads as {len(parts)} Parts ({', '.join(parts)}); "
467
+ f"consider new-milestone + one task per Part.")
468
+ todos.append({"id": new_id, "text": text, "created": _now(), "status": "open"})
469
+ save_state(root, state)
470
+ print(f"note: seeded atomicity signal #{new_id} — §3 scope reads as {len(parts)} "
471
+ f"Parts (addressable after this freeze)")
472
+ return new_id
473
+
474
+
304
475
  # --- tidy a closed PLAN.md (strip-scaffold-at-done) --------------------------
305
476
  # A live PLAN.md carries `<!-- … -->` instruction comments that guide the active phase; once the
306
477
  # task is `done` they are dead weight (PR40 audit). cmd_gate strips them on a COMPLETING gate.
@@ -545,8 +716,7 @@ def _stamp_adr_record(root: Path, state: dict, slug: str) -> None:
545
716
 
546
717
  # --- guidelines / CLAUDE.md-injection subsystem (moved to add_engine/guidelines.py) -
547
718
  from add_engine.guidelines import (
548
- _guideline_block, _inject_block, _rule_file_mode, _strip_inline_block,
549
- _insert_rule_reference, _ensure_claude_reference, _inject_guidelines, _is_brownfield,
719
+ _guideline_block, _inject_block, _inject_guidelines, _inject_specs_pointers, _is_brownfield,
550
720
  )
551
721
  def cmd_init(args: argparse.Namespace) -> None:
552
722
  base = Path(args.dir).resolve()
@@ -578,7 +748,11 @@ def cmd_init(args: argparse.Namespace) -> None:
578
748
  today = date.today().isoformat()
579
749
  proj_name = args.name or base.name
580
750
 
581
- # survivor-layer files — never clobber an existing one, never write a blank one
751
+ # survivor-layer files — never clobber an existing one, never write a blank one.
752
+ # Remember whether PROJECT.md pre-existed: a --force reinit resets state but must NOT
753
+ # touch a hand-edited survivor, so the specs-pointer wiring below fires ONLY when this
754
+ # init actually scaffolds PROJECT.md fresh (a pre-existing one is retrofitted via `migrate`).
755
+ _project_md_existed = (root / "PROJECT.md").exists()
582
756
  for fname in SETUP_FILES:
583
757
  dest = root / fname
584
758
  if dest.exists():
@@ -603,6 +777,12 @@ def cmd_init(args: argparse.Namespace) -> None:
603
777
  for dd in SPEC_DDS:
604
778
  _seed_spec_file(root, dd, project=proj_name, stage=args.stage, date_str=today)
605
779
 
780
+ # foundation-specs-refs: wire the freshly-scaffolded PROJECT.md's thin index to the five
781
+ # specs just seeded (a managed, SPEC_DDS-driven ADD:SPECS block). Guarded on freshness so a
782
+ # --force reinit never mutates a hand-edited survivor — that retrofit is `migrate`'s job.
783
+ if not _project_md_existed:
784
+ _inject_specs_pointers(root / "PROJECT.md")
785
+
606
786
  # --run-mode: seed the autonomy dial into PROJECT.md. Run mode IS the autonomy posture;
607
787
  # concurrency is a per-task subagent (doc-level), never an engine-managed streams line.
608
788
  # ONLY when the flag is explicitly set — absent flag leaves PROJECT.md byte-identical.
@@ -633,7 +813,7 @@ def cmd_init(args: argparse.Namespace) -> None:
633
813
  state["setup"] = {"locked": False, "locked_at": None, "locked_by": None, "layers": []}
634
814
  save_state(root, state)
635
815
  # zero-config: give any agent a stable pointer into the ADD runtime.
636
- for name, action in _inject_guidelines(base, getattr(args, "rule_file", False)):
816
+ for name, action in _inject_guidelines(base):
637
817
  if action != "unchanged":
638
818
  print(f"{action:>9} {name}")
639
819
  print(f"initialised ADD project '{state['project']}' (stage: {state['stage']}) at {root}")
@@ -673,7 +853,7 @@ def cmd_init(args: argparse.Namespace) -> None:
673
853
 
674
854
  def cmd_sync_guidelines(args: argparse.Namespace) -> None:
675
855
  project_root = _require_root().parent
676
- for name, action in _inject_guidelines(project_root, getattr(args, "rule_file", False)):
856
+ for name, action in _inject_guidelines(project_root):
677
857
  print(f"{action:>9} {name}")
678
858
 
679
859
 
@@ -761,7 +941,7 @@ def cmd_new_task(args: argparse.Namespace) -> None:
761
941
  # later re-words the template itself (this sub is then a no-op on an updated template).
762
942
  rendered = re.sub(r"(?m)^phase:\s*\S+(\s*<!--.*?-->)?\s*$",
763
943
  "phase: direction <!-- direction→build→verify→done; direction drafts "
764
- "§1–§4 (rules · scenarios · change plan · red suite) to the ONE freeze -->",
944
+ "§1–§4 (rules · change plan · red suite) to the ONE freeze -->",
765
945
  rendered, count=1)
766
946
  if from_delta: # delta-task-backlink: §0 reverse link
767
947
  # pre-fill the §0 Related-intent PLACEHOLDER only (the `<…>` line a fresh full template
@@ -824,8 +1004,9 @@ def cmd_new_task(args: argparse.Namespace) -> None:
824
1004
  print(f"seeded from '{from_delta}' — its open SPEC delta is now "
825
1005
  f"[SPEC · seeded] … [→ {slug}]; §1 Feature pre-filled.")
826
1006
  print("active task set. phase: direction. Draft the whole Direction bundle top-to-bottom — "
827
- "§1 rules · §2 scenarios · §3 the change PLAN (ground + contract + what this task "
828
- "will do) · §4 red suite then ONE freeze approval crosses it into build.")
1007
+ "§1 rules · §3 the change PLAN (ground + contract + what this task "
1008
+ "will do) · §4 red suite (cases live here, in TESTS & SCENARIOS) then ONE "
1009
+ "freeze approval crosses it into build.")
829
1010
  print(_next_footer(root, state)) # converges the old "then: add.py advance" hint
830
1011
  # kickoff-truth M2: the remaining engine-call recipe at task birth — the transcript
831
1012
  # audit measured 6-11 status/guide/--help re-orientation calls per run that this
@@ -839,7 +1020,7 @@ def cmd_new_task(args: argparse.Namespace) -> None:
839
1020
  if len(state.get("tasks") or {}) <= 1:
840
1021
  print("recipe — this task's remaining engine calls:")
841
1022
  print(" add.py freeze --by <name> --cross [approval — freezes the Direction "
842
- "bundle (§1–§4: rules · scenarios · change plan · red suite) and crosses "
1023
+ "bundle (§1–§4: rules · change plan · red suite) and crosses "
843
1024
  "straight to build]")
844
1025
  print(" add.py gate PASS (from build — crosses to verify and records the outcome)")
845
1026
  else:
@@ -899,6 +1080,12 @@ def _scope_echo(root: Path, slug: str) -> None:
899
1080
  marks = [(rel, (rootp / rel).exists()) for rel in resolved]
900
1081
  for rel, ok in marks:
901
1082
  print(f"scope: {rel} [{'ok' if ok else 'MISSING'}]")
1083
+ # scope-first-freeze teach note: a MISSING token that resolved UNDER the task
1084
+ # dir is almost always the `./…`-grammar trap (2026-07-23 WM1 census, rep1/2:
1085
+ # declared `./app/`, built root app/) — name the rule at the freeze read.
1086
+ if not ok and rel.startswith(".add/tasks/"):
1087
+ print(f"note: {rel} resolves under THIS TASK's dir (the `./…` token rule) — "
1088
+ "a project file wants a root-relative token (e.g. `app/`)")
902
1089
  missing_all = not any(ok for _, ok in marks)
903
1090
  # scope-coverage-hint: the too-narrow class behind the measured re-cross
904
1091
  # repairs — tokens resolve [ok] yet the build's real targets sit outside them.
@@ -1012,6 +1199,18 @@ def cmd_freeze(args: argparse.Namespace) -> None:
1012
1199
  _die(f"boundary_unfilled: {slug}'s §1 Boundary: line still carries the template "
1013
1200
  f"placeholder — declare >=1 format-variant per external input shape "
1014
1201
  f"(or an explicit \"none — ...\"), then re-freeze")
1202
+ # scope-first-freeze (wm1-lean-to-twelve): a DECLARED §3 Scope resolving to the EMPTY
1203
+ # allowlist would freeze a guaranteed scope_violation — the Scope line lives INSIDE the
1204
+ # frozen §3, so every post-freeze fix costs a re-cross (2026-07-23 WM1 census: 3/3 reps
1205
+ # paid 2-3 calls to this class). Fail-closed at the cheap seam, validate-then-write:
1206
+ # nothing is written on this path. UNDECLARED (None) stays grandfathered; resolvable
1207
+ # tokens — [ok] or greenfield [MISSING] — freeze exactly as today.
1208
+ if _declared_scope(root, slug) == []:
1209
+ _die(f"scope_unresolved: {slug} declares a §3 Scope but every token dropped — "
1210
+ "backtick each token (`name/` = project root · `./…` = THIS task's dir · a "
1211
+ "directory covers its whole subtree); unbackticked or outside-root tokens "
1212
+ "grant NO cover, and the gate would refuse scope_violation after the build. "
1213
+ "Fix the Scope line, then freeze again")
1015
1214
  # the human declares the risk-CLASS at freeze (risk-sensitivity-taxonomy): a present-but-
1016
1215
  # unknown sensitivity token is refused here (validate-then-write — nothing is written);
1017
1216
  # an absent token is grandfathered (allowed), a valid member proceeds. The engine never
@@ -1101,6 +1300,10 @@ def cmd_freeze(args: argparse.Namespace) -> None:
1101
1300
  _scope_echo(root, slug)
1102
1301
  except Exception:
1103
1302
  pass
1303
+ try: # atomicity-signal: SEED a signal when §3 reads as >1 Part
1304
+ _atomicity_signal_seed(root, slug)
1305
+ except Exception:
1306
+ pass
1104
1307
  # compound-ticks: `--cross` compresses the freeze->build crossing into this same
1105
1308
  # call — OPT-IN (the bare freeze is byte-identical). phase-collapse-3 (W2): W1's
1106
1309
  # Direction-span cross is now THE cross, every lane. The bundle (§1–§4) is drafted
@@ -1389,6 +1592,72 @@ def cmd_locate(args: argparse.Namespace) -> None:
1389
1592
  f"floor (never weaken it to pass)")
1390
1593
 
1391
1594
 
1595
+ _MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"
1596
+
1597
+
1598
+ def _graph_html_page(title: str, mermaid: str, done: int, total: int,
1599
+ met: int, ectot: int, show_ec: bool) -> str:
1600
+ """graph-html: wrap the mermaid diagram in a self-rendering, theme-aware HTML page —
1601
+ engine-authored chrome (title + done/met status chips + a legend) plus a `<pre
1602
+ class="mermaid">` (HTML-escaped so no `<`/`>`/`&` breaks parsing) and a PINNED mermaid
1603
+ CDN `<script>`. The 3 MB library rides the CDN, never the 4-way byte-twinned add.py;
1604
+ the diagram source is fully embedded (readable offline, renders online)."""
1605
+ esc = mermaid.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
1606
+ t = title.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
1607
+ ec_chip = (f'<span class="chip ok">{met}/{ectot} exit-criteria met</span>'
1608
+ if show_ec else "")
1609
+ return f"""<!doctype html>
1610
+ <html lang="en"><head><meta charset="utf-8">
1611
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1612
+ <title>{t} · add graph</title>
1613
+ <style>
1614
+ :root {{ --bg:#f6f7f9; --panel:#fff; --plate:#fbfcfd; --ink:#1a1f27; --soft:#5a6472;
1615
+ --hair:#e3e7ec; --accent:#1971c2; --met:#2b8a3e;
1616
+ --mono:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;
1617
+ --sans:system-ui,-apple-system,"Segoe UI",sans-serif; }}
1618
+ @media (prefers-color-scheme:dark) {{ :root {{ --bg:#0e1116; --panel:#161b22;
1619
+ --ink:#e6edf3; --soft:#8b949e; --hair:#262c34; --accent:#4a9eea; }} }}
1620
+ :root[data-theme="light"] {{ --bg:#f6f7f9; --panel:#fff; --ink:#1a1f27; --soft:#5a6472; --hair:#e3e7ec; --accent:#1971c2; }}
1621
+ :root[data-theme="dark"] {{ --bg:#0e1116; --panel:#161b22; --ink:#e6edf3; --soft:#8b949e; --hair:#262c34; --accent:#4a9eea; }}
1622
+ * {{ box-sizing:border-box; }}
1623
+ body {{ margin:0; background:var(--bg); color:var(--ink); font-family:var(--sans); line-height:1.5; }}
1624
+ .wrap {{ max-width:1100px; margin:0 auto; padding:36px 24px 56px; }}
1625
+ .cmd {{ font-family:var(--mono); font-size:13px; color:var(--soft); }}
1626
+ h1 {{ font-family:var(--mono); font-size:clamp(24px,4vw,34px); font-weight:600; margin:8px 0 14px; }}
1627
+ .chips {{ display:flex; flex-wrap:wrap; gap:8px; margin-bottom:24px; }}
1628
+ .chip {{ font-family:var(--mono); font-size:12px; padding:4px 10px; border-radius:999px;
1629
+ border:1px solid var(--hair); background:var(--panel); color:var(--soft); }}
1630
+ .chip.ok {{ color:var(--met); border-color:color-mix(in srgb,var(--met) 35%,var(--hair)); }}
1631
+ .plate {{ background:var(--plate); border:1px solid var(--hair); border-radius:14px;
1632
+ padding:20px; overflow-x:auto; box-shadow:0 8px 28px rgba(20,30,50,.06); }}
1633
+ .mermaid {{ display:flex; justify-content:center; min-width:560px; }}
1634
+ .legend {{ display:flex; flex-wrap:wrap; gap:18px; margin-top:20px; font-family:var(--mono);
1635
+ font-size:12px; color:var(--soft); }}
1636
+ .legend b {{ color:var(--accent); font-weight:600; }}
1637
+ </style></head><body>
1638
+ <div class="wrap">
1639
+ <div class="cmd">$ add.py graph</div>
1640
+ <h1>{t}</h1>
1641
+ <div class="chips">
1642
+ <span class="chip ok">{done}/{total} tasks done</span>
1643
+ {ec_chip}
1644
+ </div>
1645
+ <div class="plate"><pre class="mermaid">
1646
+ {esc}
1647
+ </pre></div>
1648
+ <div class="legend">
1649
+ <span><b>--&gt;</b> depends-on</span>
1650
+ <span><b>-.-&gt;</b> observed-by / delivered-by</span>
1651
+ <span><b>==&gt;</b> blocks</span>
1652
+ <span>green = done / met · blue = signal · grey = planned / unmet</span>
1653
+ </div>
1654
+ </div>
1655
+ <script src="{_MERMAID_CDN}"></script>
1656
+ <script>mermaid.initialize({{ startOnLoad: true, securityLevel: "loose" }});</script>
1657
+ </body></html>
1658
+ """
1659
+
1660
+
1392
1661
  def cmd_graph(args: argparse.Namespace) -> None:
1393
1662
  """graph-views W4: the live board as a mermaid flowchart — deterministic,
1394
1663
  read-only, print-only (paste into any mermaid renderer / GitHub fence).
@@ -1462,7 +1731,88 @@ def cmd_graph(args: argparse.Namespace) -> None:
1462
1731
  cls = "done" if tasks[slug].get("phase") == "done" else "live"
1463
1732
  lines.append(f" class t_{slug} {cls}")
1464
1733
  lines.extend(f" class p_{slug} planned" for slug in sorted(planned_shown))
1465
- print("\n".join(lines))
1734
+ # signal overlay (graph-view-signals): opt-in `--signals` layer — LIVE signals
1735
+ # (todos + open §7 deltas via _signals) as nodes edged to their task nodes. Pure
1736
+ # read; the default (no flag) path above is byte-unchanged. Resolved/dropped omit.
1737
+ if getattr(args, "signals", False):
1738
+ live = [s for s in _signals(root) if s["status"] not in ("resolved", "dropped")]
1739
+ sig_missing: dict[str, str] = {}
1740
+ node_lines: list[str] = []
1741
+ edge_lines: list[str] = []
1742
+ class_lines: list[str] = []
1743
+ for s in live:
1744
+ obs = [t for r, t in s["edges"] if r == "observed-by"]
1745
+ if obs and not any(t in shown for t in obs):
1746
+ continue # observed-by task filtered out by --milestone
1747
+ sid = "sig_" + re.sub(r"[^0-9A-Za-z]", "_", s["id"])
1748
+ text = re.sub(r'["\[\]|\n]', " ", s["text"]).strip()[:40]
1749
+ node_lines.append(f' {sid}["{s["kind"]} · {s["status"]}: {text}"]')
1750
+ class_lines.append(f" class {sid} signal")
1751
+ for rel, target in s["edges"]:
1752
+ arrow = {"observed-by": "-.->", "resolves-into": "-->",
1753
+ "blocks": "==>"}.get(rel)
1754
+ if not arrow:
1755
+ continue
1756
+ if target in tasks:
1757
+ tid = node_id(target)
1758
+ else: # missing/archived target -> x_ fallback (never dangling)
1759
+ tid = "x_" + target
1760
+ note = "archived" if target in archived else "missing"
1761
+ sig_missing[tid] = f' {tid}["{target} · {note}"]'
1762
+ edge_lines.append(f" {sid} {arrow}|{rel}| {tid}")
1763
+ if node_lines:
1764
+ lines.extend(sorted(sig_missing.values()))
1765
+ lines.extend(node_lines)
1766
+ lines.extend(edge_lines)
1767
+ lines.append(" classDef signal fill:#e7f5ff,stroke:#1971c2")
1768
+ lines.extend(class_lines)
1769
+ # exit-criterion overlay (exit-criterion-nodes): each milestone exit criterion
1770
+ # as a delivered-by node — met/unmet classed, edged to the task that satisfies it
1771
+ # (x_ fallback for an unknown slug, no edge when unpointed). Same `--signals` gate.
1772
+ ec_nodes = [n for n in _exit_criterion_nodes(root) if not only or n["ms"] == only]
1773
+ if ec_nodes:
1774
+ ec_missing: dict[str, str] = {}
1775
+ ec_node_lines: list[str] = []
1776
+ ec_edge_lines: list[str] = []
1777
+ ec_class_lines: list[str] = []
1778
+ for n in ec_nodes:
1779
+ nid = f"ec_{n['ms']}_{n['idx']}"
1780
+ glyph = "✓" if n["met"] else "○"
1781
+ text = re.sub(r'["\[\]|\n]', " ", n["text"]).strip()[:40]
1782
+ ec_node_lines.append(f' {nid}["{glyph} {text}"]')
1783
+ ec_class_lines.append(f" class {nid} {'ec_met' if n['met'] else 'ec_unmet'}")
1784
+ slug = n["delivered_by"]
1785
+ if slug:
1786
+ if slug in tasks:
1787
+ tid = node_id(slug)
1788
+ else: # unknown slug -> x_ fallback (never dangling)
1789
+ tid = "x_" + slug
1790
+ note = "archived" if slug in archived else "missing"
1791
+ ec_missing[tid] = f' {tid}["{slug} · {note}"]'
1792
+ ec_edge_lines.append(f" {nid} -.->|delivered-by| {tid}")
1793
+ lines.extend(sorted(ec_missing.values()))
1794
+ lines.extend(ec_node_lines)
1795
+ lines.extend(ec_edge_lines)
1796
+ lines.append(" classDef ec_met fill:#d3f9d8,stroke:#2b8a3e")
1797
+ lines.append(" classDef ec_unmet fill:#f1f3f5,stroke:#868e96")
1798
+ lines.extend(ec_class_lines)
1799
+ mermaid = "\n".join(lines)
1800
+ # graph-html: opt-in `--html` wraps the SAME mermaid in a self-rendering page written
1801
+ # to a temp file (the board stays read-only; the only write is the requested output).
1802
+ if getattr(args, "html", False):
1803
+ done = sum(1 for s in shown if tasks[s].get("phase") == "done")
1804
+ ecs = [n for n in _exit_criterion_nodes(root) if not only or n["ms"] == only]
1805
+ met = sum(1 for n in ecs if n["met"])
1806
+ page = _graph_html_page(only or "ADD graph", mermaid, done, len(shown),
1807
+ met, len(ecs), bool(only))
1808
+ out = (Path(args.out) if getattr(args, "out", None)
1809
+ else Path(tempfile.gettempdir()) / f"add-graph{'-' + only if only else ''}.html")
1810
+ out.parent.mkdir(parents=True, exist_ok=True)
1811
+ out.write_text(page, encoding="utf-8")
1812
+ print(f"wrote {out}")
1813
+ print("open it in a browser to view the rendered graph")
1814
+ return
1815
+ print(mermaid)
1466
1816
 
1467
1817
 
1468
1818
  def _relations_health(root: Path, state: dict) -> list[dict]:
@@ -1640,12 +1990,16 @@ def _build_entry(root: Path, state: dict, slug: str, skip_freeze: bool = False,
1640
1990
  _rb5 = _raw_phase_bodies(root, slug)
1641
1991
  m5 = (re.search(r"^\s*Scope \(may touch\):.*$", _rb5.get(3, ""), re.M)
1642
1992
  or re.search(r"^\s*Scope \(may touch\):.*$", _rb5.get(5, ""), re.M))
1643
- if m5 and "<fill before the §3 freeze" in m5.group(0):
1644
- print(f"warning: task '{slug}' §5 Scope is still the template default"
1645
- "`./src/` resolves to THIS TASK's dir (.add/tasks/"
1646
- f"{slug}/src/), not your project files. Edit the §5 Scope line to "
1647
- "the real paths the build may touch, then re-snapshot: "
1648
- "add.py re-cross --by <name>")
1993
+ # scope-first-freeze: detection accepts BOTH hint eras the original
1994
+ # "<fill before the §3 freeze" wording AND the #38-relabeled "<HARDfill
1995
+ # before the freeze" (the relabel silently killed the original match).
1996
+ if m5 and ("<fill before the §3 freeze" in m5.group(0)
1997
+ or "<HARD fill before the freeze" in m5.group(0)):
1998
+ print(f"warning: task '{slug}' §3 Scope is still the template default — "
1999
+ "edit it to the REAL write-set (the default `src/` covers only the "
2000
+ "project-root src/; `./…` = this task's dir) (a note, not a blocker — "
2001
+ "it clears only when the Scope line itself is edited; re-cross does "
2002
+ "not clear it)")
1649
2003
  else:
1650
2004
  state["tasks"][slug].pop("scope", None)
1651
2005
  try:
@@ -3004,7 +3358,7 @@ def cmd_status(args: argparse.Namespace) -> None:
3004
3358
  print(f"milestone-relations: {' · '.join(_ms_parts)} — run add.py check")
3005
3359
  # foundation pointer — read the cross-milestone context first (anti-rot)
3006
3360
  if (root / "PROJECT.md").exists():
3007
- print("context : .add/PROJECT.md (foundation: domain · spec · UI/UX read first)")
3361
+ print("context : .add/PROJECT.md (read-first foundation: goal · invariants · pointers to .add/specs/)")
3008
3362
  # voice pointer — the AI's SOUL (tone · style · trust); read each session, edit freely.
3009
3363
  # Existence-only: no open/parse, so the pointer adds no IO failure path (a non-file is no voice).
3010
3364
  if (root / "SOUL.md").exists():
@@ -3891,10 +4245,11 @@ def cmd_check(args: argparse.Namespace) -> None:
3891
4245
  # R:code convention is silently grandfathered — never retro-flagged.
3892
4246
  if _task_text is not None:
3893
4247
  _spans = _phase_spans(_task_text)
4248
+ # sec2 still read for legacy §2-bearing boards (fold-scenarios-tests retired the
4249
+ # standalone §2; cases now live in §4 — a new doc's §2 span is simply absent/empty).
3894
4250
  for _rid, _kind in _rule_coverage_gaps(_spans.get(1, ""), _spans.get(2, ""), _spans.get(4, "")):
3895
- warnings.append((f"task '{slug}'", f"rule '{_rid}' ({_kind}) has no §2 scenario tag "
3896
- "and no §4 test covering it (coverage gap) — add a scenario tag "
3897
- "or a covers: line"))
4251
+ warnings.append((f"task '{slug}'", f"rule '{_rid}' ({_kind}) has no §4 test "
4252
+ "covering it (coverage gap) — add a covers: line to the §4 test_plan"))
3898
4253
  # autonomy level (task explicit-autonomy-dial): a REAL out-of-set token is a hard
3899
4254
  # unknown_autonomy_level; a LIVE task (phase before done) with no `autonomy:`
3900
4255
  # line is implicit_autonomy — a WARN, never red. Done predecessors are SKIPPED
@@ -4759,7 +5114,11 @@ def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
4759
5114
  # task's declared source; without it, the walk descends into `.claude/worktrees/<wt>/` (linked
4760
5115
  # git worktrees: full branch checkouts) and their churn produces false `scope_violation`s.
4761
5116
  _SCOPE_EXCLUDE_DIRS = (".git", ".add", ".claude", "__pycache__", "node_modules", ".serena",
4762
- ".next", "coverage", "test-results", ".pytest_cache")
5117
+ ".next", "coverage", "test-results", ".pytest_cache",
5118
+ # tool-owned python dirs (scope-walk-prune): an in-workspace
5119
+ # virtualenv read as out-of-scope writes in 3/3 re-measure reps.
5120
+ # dist/build stay WATCHED — they can be a project's real write-set.
5121
+ ".venv", "venv", ".tox", ".mypy_cache", ".ruff_cache", ".eggs")
4763
5122
  _SCOPE_EXCLUDE_FILES = (".DS_Store", ".coverage") # plus *.pyc / *.tsbuildinfo by suffix
4764
5123
  _SCOPE_EXCLUDE_SUFFIXES = (".pyc", ".tsbuildinfo")
4765
5124
 
@@ -4855,7 +5214,10 @@ def _scope_walk(rootp: Path) -> dict[str, str]:
4855
5214
  reads as a touch (fail-closed at the biting end). Bytes only — no git."""
4856
5215
  files: dict[str, str] = {}
4857
5216
  for dirpath, dirnames, filenames in os.walk(rootp):
4858
- dirnames[:] = [d for d in dirnames if d not in _SCOPE_EXCLUDE_DIRS]
5217
+ # *.egg-info is PROJECT-DERIVED (app.egg-info) no literal covers it; suffix-prune
5218
+ # (egg-info-prune: 3/3 run-3 reps tripped scope_violation on pip install -e .'s output).
5219
+ dirnames[:] = [d for d in dirnames
5220
+ if d not in _SCOPE_EXCLUDE_DIRS and not d.endswith(".egg-info")]
4859
5221
  for name in filenames:
4860
5222
  if name in _SCOPE_EXCLUDE_FILES or name.endswith(_SCOPE_EXCLUDE_SUFFIXES):
4861
5223
  continue
@@ -5479,13 +5841,15 @@ def _flag_well_formed(raw3: str) -> bool:
5479
5841
  # is a template placeholder (leading "<", or the bare "./src/" Scope default) is skipped; a
5480
5842
  # trailing " <hint>" on a real value is stripped. PURE.
5481
5843
  _PLAN_FIELDS = ("Scope (may touch)", "Strategy (ordered batches)", "Approach (domain strategy)",
5482
- "Persona (required)", "Spawn isolation (default)", "Known-problem fixes")
5844
+ "Persona (optional)", "Spawn isolation (default)", "Known-problem fixes")
5483
5845
 
5484
5846
 
5485
5847
  def _build_plan(raw3: str) -> list[dict]:
5486
5848
  out: list[dict] = []
5487
5849
  for label in _PLAN_FIELDS:
5488
5850
  m = re.search(rf"(?m)^{re.escape(label)}:[ \t]*(.*)$", raw3) # this label's line ONLY
5851
+ if not m and label == "Persona (optional)": # legacy tasks froze it "(required)"
5852
+ m = re.search(r"(?m)^Persona \(required\):[ \t]*(.*)$", raw3)
5489
5853
  if not m:
5490
5854
  continue
5491
5855
  val = m.group(1).strip()
@@ -5494,7 +5858,8 @@ def _build_plan(raw3: str) -> list[dict]:
5494
5858
  val = val[:hint.start()].strip()
5495
5859
  if not val or val.startswith("<"): # a bare placeholder is not a plan
5496
5860
  continue
5497
- if val.strip("`").strip().startswith("./src/"): # the untouched Scope default
5861
+ core = val.strip("`").strip()
5862
+ if core.startswith("./src/") or core == "src/": # the untouched Scope defaults (legacy `./src/` · current `src/`)
5498
5863
  continue
5499
5864
  out.append({"label": label, "value": val})
5500
5865
  return out
@@ -6366,13 +6731,19 @@ def cmd_migrate(args: argparse.Namespace) -> None:
6366
6731
  stage=state.get("stage") or "mvp", date_str=today)
6367
6732
  if p.exists():
6368
6733
  seeded.append(SPEC_DDS[dd][0])
6369
- if not renames and not seeded:
6370
- print("already 2.0 nothing to migrate (task docs are PLAN.md; the 5 living specs exist)")
6734
+ # foundation-specs-refs: wire PROJECT.md's thin index to the five specs (idempotent —
6735
+ # a pre-pointer PROJECT.md gets the managed ADD:SPECS block; an up-to-date one is a no-op).
6736
+ pointer_action = _inject_specs_pointers(root / "PROJECT.md")
6737
+ if not renames and not seeded and pointer_action in ("unchanged", "absent"):
6738
+ print("already 2.0 — nothing to migrate (task docs are PLAN.md; the 5 living specs exist; "
6739
+ "PROJECT.md points at them)")
6371
6740
  return
6372
6741
  if renames:
6373
6742
  print(f"migrated {len(renames)} task doc(s) TASK.md -> PLAN.md")
6374
6743
  if seeded:
6375
6744
  print(f"seeded {len(seeded)} living spec(s): {', '.join(seeded)}")
6745
+ if pointer_action in ("created", "updated"):
6746
+ print(f"{pointer_action} PROJECT.md → .add/specs/ pointer block (the 5-DD standing picture)")
6376
6747
  print("next: add.py status — re-orient on the 2.0 board")
6377
6748
 
6378
6749
 
@@ -6564,9 +6935,6 @@ def build_parser() -> argparse.ArgumentParser:
6564
6935
  pi.add_argument("--force", action="store_true", help="reset state.json if present")
6565
6936
  pi.add_argument("--await-lock", dest="await_lock", action="store_true",
6566
6937
  help="seed an unlocked setup; gates new-task/advance/gate until `add.py lock`")
6567
- pi.add_argument("--rule-file", dest="rule_file", action="store_true",
6568
- help="write the ADD block to .claude/rules/add-workflows.md and reference it "
6569
- "from CLAUDE.md (auto-on for ccsk projects with a .ccsk/ dir)")
6570
6938
  pi.add_argument("--run-mode", dest="run_mode", default=None,
6571
6939
  choices=["auto", "conservative"],
6572
6940
  help="seed autonomy+streams posture: auto→parallel, conservative→sequential "
@@ -6644,6 +7012,15 @@ def build_parser() -> argparse.ArgumentParser:
6644
7012
  "(milestone subgraphs; edge style = edge type; dashed = "
6645
7013
  "planned-but-never-created)")
6646
7014
  pgr.add_argument("--milestone", help="limit to one milestone's subgraph")
7015
+ pgr.add_argument("--signals", action="store_true",
7016
+ help="overlay LIVE signals (todos + open §7 deltas) as nodes edged "
7017
+ "to their tasks (observed-by/resolves-into/blocks)")
7018
+ pgr.add_argument("--html", action="store_true",
7019
+ help="write a self-rendering HTML page (chrome + pinned-CDN mermaid) "
7020
+ "to a temp file and print its path, instead of raw mermaid")
7021
+ pgr.add_argument("--out", default=None,
7022
+ help="with --html, the output path (default: a stable file under the "
7023
+ "system temp dir; a missing parent dir is created)")
6647
7024
  pgr.set_defaults(func=cmd_graph)
6648
7025
 
6649
7026
  pdap = sub.add_parser("delta-append",
@@ -6832,9 +7209,6 @@ def build_parser() -> argparse.ArgumentParser:
6832
7209
 
6833
7210
  psg = sub.add_parser("sync-guidelines",
6834
7211
  help="(re)write the ADD guideline block into AGENTS.md + CLAUDE.md")
6835
- psg.add_argument("--rule-file", dest="rule_file", action="store_true",
6836
- help="relocate CLAUDE.md's block to .claude/rules/add-workflows.md + reference "
6837
- "it (auto-on for ccsk projects)")
6838
7212
  psg.set_defaults(func=cmd_sync_guidelines)
6839
7213
 
6840
7214
  pgd = sub.add_parser("guide", help="print the one concrete next step for the active task")