@pilotspace/add 2.2.0 → 2.4.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/CHANGELOG.md +104 -0
- package/GETTING-STARTED.md +19 -19
- package/agents/add-advisor.md +3 -2
- package/bin/cli.js +45 -149
- package/package.json +1 -1
- package/skill/add/SKILL.md +6 -5
- package/skill/add/beyond.md +4 -0
- package/skill/add/deltas.md +1 -1
- package/skill/add/design.md +1 -1
- package/skill/add/intake.md +17 -0
- package/skill/add/phases/build.md +22 -0
- package/skill/add/phases/direction.md +13 -10
- package/skill/add/phases/verify.md +23 -2
- package/skill/add/strategy.md +54 -0
- package/tooling/add.py +407 -33
- package/tooling/add_engine/constants.py +11 -16
- package/tooling/add_engine/guidelines.py +86 -136
- package/tooling/templates/GLOSSARY.md.tmpl +1 -1
- package/tooling/templates/MILESTONE.md.tmpl +9 -0
- package/tooling/templates/PLAN.md.tmpl +13 -15
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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 ·
|
|
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 · §
|
|
828
|
-
"will do) · §4 red suite
|
|
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 ·
|
|
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("&", "&").replace("<", "<").replace(">", ">")
|
|
1606
|
+
t = title.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
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>--></b> depends-on</span>
|
|
1650
|
+
<span><b>-.-></b> observed-by / delivered-by</span>
|
|
1651
|
+
<span><b>==></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
|
-
|
|
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
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1993
|
+
# scope-first-freeze: detection accepts BOTH hint eras — the original
|
|
1994
|
+
# "<fill before the §3 freeze" wording AND the #38-relabeled "<HARD — fill
|
|
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:
|
|
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 §
|
|
3896
|
-
"
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
6370
|
-
|
|
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")
|