@pilotspace/add 1.8.0 → 1.10.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/bin/cli.js +137 -2
  3. package/docs/16-releasing.md +2 -2
  4. package/docs/17-components.md +125 -0
  5. package/docs/appendix-c-glossary.md +8 -0
  6. package/package.json +2 -2
  7. package/skill/add/SKILL.md +90 -116
  8. package/skill/add/adopt.md +13 -34
  9. package/skill/add/advisor.md +9 -10
  10. package/skill/add/compact-foundation.md +21 -34
  11. package/skill/add/components.md +54 -0
  12. package/skill/add/confidence.md +9 -25
  13. package/skill/add/deltas.md +12 -28
  14. package/skill/add/design.md +26 -32
  15. package/skill/add/fold.md +26 -51
  16. package/skill/add/graduate.md +18 -54
  17. package/skill/add/intake.md +30 -34
  18. package/skill/add/loop.md +30 -34
  19. package/skill/add/phases/0-ground.md +6 -14
  20. package/skill/add/phases/0-setup.md +30 -81
  21. package/skill/add/phases/1-specify.md +7 -22
  22. package/skill/add/phases/2-scenarios.md +1 -3
  23. package/skill/add/phases/3-contract.md +8 -22
  24. package/skill/add/phases/4-tests.md +3 -6
  25. package/skill/add/phases/5-build.md +8 -22
  26. package/skill/add/phases/6-verify.md +14 -49
  27. package/skill/add/phases/7-observe.md +1 -3
  28. package/skill/add/phases/fast-lane.md +31 -0
  29. package/skill/add/release.md +36 -90
  30. package/skill/add/report-template.md +41 -126
  31. package/skill/add/run.md +89 -159
  32. package/skill/add/scope.md +40 -89
  33. package/skill/add/setup-review.md +10 -27
  34. package/skill/add/soul.md +17 -36
  35. package/skill/add/streams.md +90 -149
  36. package/tooling/add.py +804 -14
  37. package/tooling/templates/TASK.fast.md.tmpl +72 -0
  38. package/tooling/templates/TASK.md.tmpl +1 -1
package/tooling/add.py CHANGED
@@ -25,6 +25,11 @@ import urllib.request
25
25
  from datetime import date, datetime, timedelta, timezone
26
26
  from pathlib import Path
27
27
 
28
+ try: # component-aware-add registry parse (Python 3.11+ stdlib)
29
+ import tomllib
30
+ except ModuleNotFoundError: # < 3.11: the registry is unsupported → degrade to opt-out
31
+ tomllib = None
32
+
28
33
  # --- constants ---------------------------------------------------------------
29
34
 
30
35
  ROOT_DIRNAME = ".add"
@@ -116,6 +121,16 @@ GUIDELINE_FILES = ("AGENTS.md", "CLAUDE.md")
116
121
  _GUIDE_BEGIN = "<!-- ADD:BEGIN — managed by `add.py sync-guidelines`; do not edit inside -->"
117
122
  _GUIDE_END = "<!-- ADD:END -->"
118
123
 
124
+ # Rule-file mode (ccsk-style projects): instead of inlining the block into CLAUDE.md,
125
+ # write it to a dedicated rule file under .claude/rules/ and leave a one-line reference
126
+ # in CLAUDE.md's Workflows section. .claude/rules/ is a CLAUDE-only convention, so this
127
+ # mode only ever relocates CLAUDE.md — AGENTS.md/.clinerules keep the inline block.
128
+ RULES_FILE_REL = Path(".claude") / "rules" / "add-workflows.md"
129
+ # Headings (most→least specific) a project may already use to group rule/workflow links.
130
+ # Match is case-insensitive on the heading TEXT, at any `#` level.
131
+ WORKFLOW_HEADINGS = ("Rules & Workflows", "Workflows", "Rules")
132
+ _RULE_REF_LINE = "- ADD (AI-Driven Development) Workflows rules: ./.claude/rules/add-workflows.md"
133
+
119
134
  # Minimal embedded fallback so the tool still works if templates/ is missing
120
135
  # (circuit breaker: never hard-fail just because a template file was deleted).
121
136
  _FALLBACK_TASK = """# TASK: {title}
@@ -152,6 +167,47 @@ Outcome:
152
167
  """
153
168
 
154
169
 
170
+ # Fast-lane fallback: the minimal TASK.md variant (sections {0,1,3,4,5,6}; §2 + §7 dropped).
171
+ # Mirrors templates/TASK.fast.md.tmpl's section set (circuit-breaker parity); a deleted
172
+ # templates/ never hard-fails the fast lane. Keeps the trust floor: §3 freeze-flag + Status,
173
+ # §6 GATE RECORD/Outcome, §0 Anchors, §4 red-before-build, §5 Scope.
174
+ _FALLBACK_TASK_FAST = """# TASK: {title}
175
+
176
+ slug: {slug} · created: {date} · stage: {stage}
177
+ autonomy: auto
178
+ phase: ground
179
+ fast: true
180
+
181
+ ## 0 · GROUND
182
+ Touches (files · symbols):
183
+ Anchors the contract cites:
184
+
185
+ ## 1 · SPECIFY
186
+ Feature:
187
+ Must:
188
+ Reject:
189
+ Accept:
190
+ Assumptions: ⚠ <most likely wrong> — why; if wrong: <cost>
191
+
192
+ ## 3 · CONTRACT
193
+ Least-sure flag surfaced at freeze:
194
+ Status: DRAFT
195
+
196
+ ## 4 · TESTS
197
+ Plan:
198
+ Tests live in: `./tests/` · MUST run red before Build.
199
+
200
+ ## 5 · BUILD
201
+ Scope (may touch): `./src/`
202
+
203
+ ## 6 · VERIFY
204
+ Build expectations (from §1 Accept + §3 CONTRACT):
205
+ ### GATE RECORD
206
+ Outcome:
207
+ Reviewed by:
208
+ """
209
+
210
+
155
211
  # --- low-level IO (designed for failure: atomic, no silent clobber) ----------
156
212
 
157
213
  def _now() -> str:
@@ -174,6 +230,20 @@ def _atomic_write(path: Path, text: str) -> None:
174
230
  os.unlink(tmp)
175
231
 
176
232
 
233
+ def _atomic_write_bytes(path: Path, data: bytes) -> None:
234
+ """Binary sibling of `_atomic_write` — lands `data` UNCHANGED (no newline translation), so a
235
+ byte-for-byte copy stays exact. Same temp-then-replace crash-safety."""
236
+ path.parent.mkdir(parents=True, exist_ok=True)
237
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
238
+ try:
239
+ with os.fdopen(fd, "wb") as fh:
240
+ fh.write(data)
241
+ os.replace(tmp, path)
242
+ finally:
243
+ if os.path.exists(tmp):
244
+ os.unlink(tmp)
245
+
246
+
177
247
  def _atomic_write_many(writes: list[tuple[Path, str]]) -> None:
178
248
  """True all-or-nothing commit across N files — design-for-failure for a multi-file write.
179
249
 
@@ -237,13 +307,14 @@ def _templates_dir() -> Path:
237
307
  def _render_template(name: str, **subs: str) -> str:
238
308
  """Load templates/<name>.tmpl and substitute {{key}} tokens.
239
309
 
240
- Falls back to a built-in minimal template only for TASK.md.
310
+ Falls back to a built-in minimal template for TASK.md and the fast-lane TASK.fast.md.
241
311
  """
242
312
  tmpl = _templates_dir() / f"{name}.tmpl"
313
+ _fallbacks = {"TASK.md": _FALLBACK_TASK, "TASK.fast.md": _FALLBACK_TASK_FAST}
243
314
  if tmpl.exists():
244
315
  text = tmpl.read_text(encoding="utf-8")
245
- elif name == "TASK.md":
246
- text = _FALLBACK_TASK.replace("{title}", "{{title}}").replace(
316
+ elif name in _fallbacks:
317
+ text = _fallbacks[name].replace("{title}", "{{title}}").replace(
247
318
  "{slug}", "{{slug}}").replace("{date}", "{{date}}").replace("{stage}", "{{stage}}")
248
319
  else:
249
320
  text = ""
@@ -543,6 +614,87 @@ def _setup_locked(state: dict) -> bool:
543
614
  return ("setup" not in state) or (state["setup"].get("locked") is True)
544
615
 
545
616
 
617
+ def _milestone_confirmed(state: dict, mslug: str) -> bool:
618
+ """True when milestone `mslug` is confirmed — i.e. the new-task gate is OPEN.
619
+
620
+ Mirrors `_setup_locked` one level down. A milestone record with NO "confirmed" key is
621
+ GRANDFATHERED-confirmed: every milestone created WITHOUT `--await-confirm` (and every
622
+ pre-existing one) is never gated. Opt-in: `new-milestone --await-confirm` seeds confirmed:false,
623
+ so the gate is active in exactly one case: the record is present AND confirmed is False. An
624
+ unknown milestone is treated as confirmed here (existence is cmd_new_task's separate check)."""
625
+ m = (state.get("milestones") or {}).get(mslug)
626
+ if not isinstance(m, dict) or "confirmed" not in m:
627
+ return True
628
+ return m["confirmed"] is True
629
+
630
+
631
+ def _section_unfilled(md_text: str, header: str) -> bool:
632
+ """True iff the `header` section is PRESENT but UNFILLED — empty (no real bullet) or
633
+ still a `<…>` template placeholder. ABSENT section -> False (grandfathered legacy);
634
+ a filled section (>=1 real bullet, no `<…>`) -> False. Pure predicate — the shared
635
+ placeholder test the fill gates use (contract-fill at confirm; build-expectations at build)."""
636
+ body, in_sec, present = [], False, False
637
+ for ln in md_text.splitlines():
638
+ if ln.startswith(header):
639
+ in_sec, present = True, True
640
+ continue
641
+ if in_sec:
642
+ if ln.startswith("#"): # ANY next header (## or ###) ends our section
643
+ break
644
+ if ln.lstrip().startswith(">"): # skip blockquote GUIDANCE — it is not content
645
+ continue
646
+ body.append(ln)
647
+ if not present:
648
+ return False # absent -> grandfather
649
+ text = "\n".join(body).strip()
650
+ if not text:
651
+ return True # present but empty
652
+ return bool(re.search(r"<[^>\n]+>", text)) # a <…> placeholder remains
653
+
654
+
655
+ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None:
656
+ """Write-back (gate-record-writeback): mirror the resolved gate verdict into the task's
657
+ §6 `### GATE RECORD`, so the file and state.json never silently diverge (Finding C). Runs
658
+ for EVERY task — the write is ADDITIVE and never refuses, so (unlike the two refusal gates)
659
+ it needs no `--await-confirm` opt-in to protect the census. GRANDFATHER is the safety: a
660
+ GATE RECORD line is rewritten ONLY while it still holds a `<…>` placeholder; a resolved
661
+ (hand-filled) line is byte-untouched. No GATE RECORD block / no placeholder line / an
662
+ unreadable file -> silent no-op, the file stays byte-identical. Called AFTER save_state —
663
+ state is the source of truth; the file only mirrors it, so a write fault never loses a verdict."""
664
+ f = root / "tasks" / slug / "TASK.md"
665
+ try:
666
+ text = f.read_text(encoding="utf-8")
667
+ except OSError:
668
+ return # unreadable -> no-op (never blocks the gate)
669
+ if "### GATE RECORD" not in text:
670
+ return # nothing to mirror into
671
+ actor = _actor_stamp(state)
672
+ today = date.today().isoformat()
673
+ # each rule matches ONLY a line still carrying a `<…>` placeholder -> grandfather a resolved line.
674
+ rules = [
675
+ (r"(?m)^(Outcome:[ \t]*)<[^>\n]*>.*$", f"Outcome: {outcome}"),
676
+ (r"(?m)^Reviewed by:[ \t]*.*<[^>\n]*>.*$",
677
+ f"Reviewed by: {actor['name']} · date: {today}"),
678
+ ]
679
+ if outcome == "RISK-ACCEPTED":
680
+ w = ((state.get("tasks") or {}).get(slug) or {}).get("waiver") or {}
681
+ rules.append((r"(?m)^If RISK-ACCEPTED ->.*<[^>\n]*>.*$",
682
+ f"If RISK-ACCEPTED -> owner: {w.get('owner', '?')} · "
683
+ f"ticket: {w.get('ticket', '?')} · expires: {w.get('expires', '?')}"))
684
+ new = text
685
+ for pat, repl in rules:
686
+ new = re.sub(pat, repl, new, count=1)
687
+ # component-aware-add (per-component-verify): record WHICH green-bar a bound task gated
688
+ # against, right after the Outcome line. Unbound / no green_bar -> no line (byte-identical).
689
+ _bar = _task_green_bar(root, slug)
690
+ if _bar:
691
+ _line = f"component: {_task_component(root, slug)} · expected green-bar: {_bar}"
692
+ if _line not in new:
693
+ new = re.sub(r"(?m)^(Outcome:.*$)", lambda m: m.group(1) + "\n" + _line, new, count=1)
694
+ if new != text: # no-op = no write (mtime stable)
695
+ _atomic_write(f, new)
696
+
697
+
546
698
  def _die(msg: str, code: int = 1) -> None:
547
699
  print(f"add: error: {msg}", file=sys.stderr)
548
700
  raise SystemExit(code)
@@ -624,17 +776,131 @@ def _inject_block(path: Path) -> str:
624
776
  return "created"
625
777
 
626
778
 
627
- def _inject_guidelines(project_root: Path) -> list[tuple[str, str]]:
779
+ def _rule_file_mode(project_root: Path, flag: bool = False) -> bool:
780
+ """True when the ADD block should live in .claude/rules/add-workflows.md (referenced
781
+ from CLAUDE.md) instead of inline. Re-derived from disk EACH phase — no persisted
782
+ state — so an explicit `--rule-file` at install carries into init via the rule file it
783
+ leaves behind. Three triggers: the explicit flag, a ccsk project (.ccsk/ sibling to
784
+ .claude/), or a rule file already written by a prior run. Pure + fail-soft."""
785
+ if flag:
786
+ return True
787
+ try:
788
+ if (project_root / ".ccsk").is_dir():
789
+ return True
790
+ if (project_root / RULES_FILE_REL).exists():
791
+ return True
792
+ except OSError:
793
+ pass
794
+ return False
795
+
796
+
797
+ def _strip_inline_block(text: str) -> str:
798
+ """Remove an inline ADD:BEGIN..ADD:END region (migration to rule-file mode), collapsing
799
+ the blank-line gap it leaves behind. An unterminated BEGIN (no END) is left as-is rather
800
+ than eating the rest of the file (design-for-failure)."""
801
+ begin = text.find(_GUIDE_BEGIN)
802
+ if begin == -1:
803
+ return text
804
+ end = text.find(_GUIDE_END, begin)
805
+ if end == -1:
806
+ return text
807
+ end += len(_GUIDE_END)
808
+ head = text[:begin].rstrip("\n")
809
+ tail = text[end:].lstrip("\n")
810
+ if head and tail:
811
+ return head + "\n\n" + tail
812
+ return head or tail
813
+
814
+
815
+ def _insert_rule_reference(text: str) -> str:
816
+ """Insert the ADD rule-file bullet under an existing Workflows/Rules heading, or append a
817
+ fresh '## Workflows' section when none is found. Caller guarantees the bullet is absent."""
818
+ lines = text.split("\n")
819
+ heading_idx = -1
820
+ for i, line in enumerate(lines):
821
+ m = re.match(r"^(#{1,6})\s+(.*?)\s*$", line)
822
+ if m and any(m.group(2).strip().lower() == h.lower() for h in WORKFLOW_HEADINGS):
823
+ heading_idx = i
824
+ break
825
+ if heading_idx == -1: # no section — append a fresh one
826
+ body = text.rstrip("\n")
827
+ sep = "\n\n" if body else ""
828
+ return f"{body}{sep}## Workflows\n\n{_RULE_REF_LINE}\n"
829
+ level = len(re.match(r"^(#{1,6})", lines[heading_idx]).group(1))
830
+ end = len(lines) # section ends at next same/higher heading or EOF
831
+ for j in range(heading_idx + 1, len(lines)):
832
+ m = re.match(r"^(#{1,6})\s+", lines[j])
833
+ if m and len(m.group(1)) <= level:
834
+ end = j
835
+ break
836
+ insert_at = heading_idx + 1 # after the last non-blank line in the section
837
+ for j in range(heading_idx + 1, end):
838
+ if lines[j].strip():
839
+ insert_at = j + 1
840
+ lines.insert(insert_at, _RULE_REF_LINE)
841
+ return "\n".join(lines)
842
+
843
+
844
+ def _ensure_claude_reference(claude_md: Path) -> str:
845
+ """Make CLAUDE.md reference the ADD rule file under a Workflows/Rules heading, migrating
846
+ any prior inline ADD block out. Returns created|updated|unchanged.
847
+
848
+ - Strips a prior inline ADD:BEGIN..END block (rule-file mode supersedes inline).
849
+ - If a reference to add-workflows.md already exists -> no bullet change.
850
+ - Else inserts the bullet into the first matching section, or appends '## Workflows'.
851
+ .bak on change; idempotent. User content outside the touched region is preserved.
852
+ """
853
+ existed = claude_md.exists()
854
+ current = claude_md.read_text(encoding="utf-8") if existed else ""
855
+ new = _strip_inline_block(current)
856
+ if "add-workflows.md" not in new:
857
+ new = _insert_rule_reference(new)
858
+ if not new.endswith("\n"):
859
+ new += "\n"
860
+ if new == current:
861
+ return "unchanged"
862
+ if existed:
863
+ _atomic_write(Path(str(claude_md) + ".bak"), current) # rollback path before mutate
864
+ _atomic_write(claude_md, new)
865
+ return "updated" if existed else "created"
866
+
867
+
868
+ def _inject_guidelines(project_root: Path, rule_file: bool = False) -> list[tuple[str, str]]:
628
869
  """Inject the block into each guideline file under `project_root`.
629
870
 
630
871
  Symlink-dedup: targets resolving (os.path.realpath) to the same inode are
631
872
  written once, against the REAL file (never replacing the symlink with a
632
873
  regular file). Per-target OSError is isolated (warn+skip) so one unwritable
633
874
  file never aborts the run or `init`.
875
+
876
+ Rule-file mode (ccsk projects / `--rule-file`): CLAUDE.md's full block is relocated
877
+ to .claude/rules/add-workflows.md and CLAUDE.md keeps only a reference bullet. This is
878
+ CLAUDE-only — AGENTS.md (and any other guideline file) keeps the inline block.
634
879
  """
635
880
  results: list[tuple[str, str]] = []
636
881
  seen: set[str] = set()
882
+ mode = _rule_file_mode(project_root, rule_file)
637
883
  for name in GUIDELINE_FILES:
884
+ if name == "CLAUDE.md" and mode:
885
+ rules_path = project_root / RULES_FILE_REL
886
+ real = os.path.realpath(rules_path)
887
+ if real not in seen:
888
+ seen.add(real)
889
+ try:
890
+ action = _inject_block(rules_path)
891
+ except (OSError, UnicodeDecodeError) as exc:
892
+ print(f"add: warning: could not sync {RULES_FILE_REL} — {exc}; skipped",
893
+ file=sys.stderr)
894
+ action = "skipped"
895
+ results.append((str(RULES_FILE_REL), action))
896
+ try:
897
+ ref_action = _ensure_claude_reference(project_root / "CLAUDE.md")
898
+ except (OSError, UnicodeDecodeError) as exc:
899
+ print(f"add: warning: could not sync CLAUDE.md — {exc}; skipped",
900
+ file=sys.stderr)
901
+ ref_action = "skipped"
902
+ results.append(("CLAUDE.md", ref_action))
903
+ continue
638
904
  target = project_root / name
639
905
  real = os.path.realpath(target)
640
906
  if real in seen:
@@ -725,7 +991,7 @@ def cmd_init(args: argparse.Namespace) -> None:
725
991
  state["setup"] = {"locked": False, "locked_at": None, "locked_by": None, "layers": []}
726
992
  save_state(root, state)
727
993
  # zero-config: give any agent a stable pointer into the ADD runtime.
728
- for name, action in _inject_guidelines(base):
994
+ for name, action in _inject_guidelines(base, getattr(args, "rule_file", False)):
729
995
  if action != "unchanged":
730
996
  print(f"{action:>9} {name}")
731
997
  print(f"initialised ADD project '{state['project']}' (stage: {state['stage']}) at {root}")
@@ -741,7 +1007,7 @@ def cmd_init(args: argparse.Namespace) -> None:
741
1007
 
742
1008
  def cmd_sync_guidelines(args: argparse.Namespace) -> None:
743
1009
  project_root = _require_root().parent
744
- for name, action in _inject_guidelines(project_root):
1010
+ for name, action in _inject_guidelines(project_root, getattr(args, "rule_file", False)):
745
1011
  print(f"{action:>9} {name}")
746
1012
 
747
1013
 
@@ -763,6 +1029,12 @@ def cmd_new_task(args: argparse.Namespace) -> None:
763
1029
  milestone = getattr(args, "milestone", None) or _active_milestone(state)
764
1030
  if milestone and milestone not in state.get("milestones", {}):
765
1031
  _die("unknown_milestone")
1032
+ # confirm-parent gate (OPT-IN): a task may not be detailed before its parent milestone is
1033
+ # human-confirmed — but ONLY when the milestone opted in via `new-milestone --await-confirm`.
1034
+ # validate-then-write — refuse BEFORE any scaffold/state mutation. A milestone with no
1035
+ # `confirmed` key (non-flag + pre-existing) is grandfathered (mirrors the setup-lock).
1036
+ if milestone and not _milestone_confirmed(state, milestone):
1037
+ _die(f"milestone_unconfirmed: confirm it first — add.py milestone-confirm {milestone}")
766
1038
  depends_on = _parse_deps(getattr(args, "depends_on", None))
767
1039
 
768
1040
  # SEED (--from-delta): resolve a prior task's FIRST open SPEC delta into THIS task.
@@ -796,8 +1068,13 @@ def cmd_new_task(args: argparse.Namespace) -> None:
796
1068
  # inherit the project's DECLARED autonomy default (task init-auto-default) — fail-SAFE:
797
1069
  # absent -> auto, garbled -> conservative; the posture is project-scoped, not hardcoded.
798
1070
  autonomy = _project_autonomy(root)
1071
+ # fast lane (fast-new-task-flag): --fast scaffolds the MINIMAL template instead of the full one.
1072
+ # The human opts in explicitly (the engine never guesses ceremony); the freeze floor is held by
1073
+ # the freeze-before-build gate's fast arm (cmd_advance), so the lighter shape never drops the trust seam.
1074
+ fast = bool(getattr(args, "fast", False))
799
1075
  rendered = _render_template(
800
- "TASK.md", title=title, slug=slug, date=date.today().isoformat(),
1076
+ "TASK.fast.md" if fast else "TASK.md",
1077
+ title=title, slug=slug, date=date.today().isoformat(),
801
1078
  stage=state["stage"], autonomy=autonomy)
802
1079
  if feature_override: # pre-fill §1 from the seeded delta
803
1080
  rendered = re.sub(r"(?m)^Feature:.*$",
@@ -822,6 +1099,8 @@ def cmd_new_task(args: argparse.Namespace) -> None:
822
1099
  }
823
1100
  if from_delta:
824
1101
  state["tasks"][slug]["from_delta"] = from_delta # lineage: seeded from <prior>
1102
+ if fast:
1103
+ state["tasks"][slug]["fast"] = True # durable lane marker (absent == not-fast)
825
1104
  _set_active_task(state, slug, milestone)
826
1105
  save_state(root, state)
827
1106
  print(f"created task '{slug}' -> {task_md}")
@@ -932,13 +1211,48 @@ def cmd_advance(args: argparse.Namespace) -> None:
932
1211
  # into build/verify/observe/done is refused until `add.py lock`.
933
1212
  if not _setup_locked(state) and nxt in ("build", "verify", "observe", "done"):
934
1213
  _die("setup_unlocked: lock the foundation first — add.py lock")
1214
+ # intra-milestone cross-component HOLD (cross-component-milestone): a consumer of a DECLARED
1215
+ # contract may not enter §3 (scenarios->contract) until its producer froze — proven by the
1216
+ # snapshot the producer's contract->tests crossing writes (task 3). This orders a BE→FE slice
1217
+ # inside ONE milestone (the FE stays downstream of the frozen endpoint). Validate-before-write:
1218
+ # the HARD-STOP precedes the phase bump, so the task stays at `scenarios`. Undeclared id / no
1219
+ # `consumes:` header -> no hold (byte-identical; a typo'd id is a cmd_check registry finding).
1220
+ if nxt == "contract":
1221
+ _cid = _task_consumes(root, slug)
1222
+ _cmap = _contracts(root)
1223
+ if _cid and _cid in _cmap and not _contract_snapshot(root, _cid).exists():
1224
+ _die(f"producer_contract_unfrozen: the producer '{_cmap[_cid].get('producer', '?')}' of "
1225
+ f"contract '{_cid}' must freeze its contract before you write §3 — wait for "
1226
+ f".add/contracts/{_cid}.json")
935
1227
  # flag-first freeze guard (task unflagged-freeze): a FROZEN §3 may not cross
936
1228
  # into build without a WELL-FORMED lowest-confidence flag. On pass, stamp the
937
1229
  # verified marker so `audit` enforces the flag on THIS record only (open/new
938
1230
  # freezes — the unmarked predecessors are never retro-redded). REFUSE writes
939
1231
  # nothing (fail-closed); below the build boundary the flag is never checked.
940
1232
  if nxt == "build":
1233
+ # the OPTED-IN crossing guards (fast-lane + flow-enforcement): a task whose PARENT
1234
+ # milestone opted into --await-confirm is held to the trust floor at tests->build. A
1235
+ # task under a plain / no milestone is never gated (every existing advance-to-build flow
1236
+ # stays green). validate-then-write — every refusal runs BEFORE the tripwire/scope
1237
+ # snapshots below, writing nothing; the task stays at `tests`.
1238
+ _ms = state["tasks"][slug].get("milestone")
1239
+ _optin = bool(_ms) and (state.get("milestones") or {}).get(_ms, {}).get("await_confirm") is True
941
1240
  raw3 = _raw_phase_bodies(root, slug).get(3, "")
1241
+ # freeze-before-build gate (fast-lane): "collapse-never-skip" made REAL — the freeze is
1242
+ # engine-enforced for an opted-in milestone OR any --fast task (the fast arm, fast-new-task-flag:
1243
+ # a fast task is held to the floor under ANY milestone, so the lighter lane never drops the
1244
+ # trust seam). PRECEDES build-expectations: you freeze §3 before pre-declaring §6's outcomes.
1245
+ _freeze_gated = _optin or state["tasks"][slug].get("fast") is True
1246
+ if _freeze_gated and not _contract_frozen(raw3):
1247
+ _die("contract_not_frozen: freeze §3 before crossing into build — approve "
1248
+ f"the contract in {slug}'s TASK.md (Status: FROZEN @ vN)")
1249
+ # build-expectations gate (flow-enforcement): an opted-in task may not enter build until
1250
+ # its §6 `### Build expectations` are pre-declared — so verify checks the build is RIGHT,
1251
+ # not just green. Same opt-in switch as the contract-fill gate, one level out.
1252
+ if _optin:
1253
+ if _section_unfilled(_raw_phase_bodies(root, slug).get(6, ""), "### Build expectations"):
1254
+ _die("build_expectations_unfilled: fill the §6 '### Build expectations' block "
1255
+ f"of {slug}'s TASK.md before crossing into build")
942
1256
  if _contract_frozen(raw3):
943
1257
  if not _flag_well_formed(raw3):
944
1258
  _die("unflagged_freeze: a frozen §3 must surface a well-formed "
@@ -973,6 +1287,44 @@ def cmd_advance(args: argparse.Namespace) -> None:
973
1287
  side.unlink()
974
1288
  except OSError:
975
1289
  pass
1290
+ # cross-component contract artifact (cross-component-contract): the contract->tests crossing
1291
+ # is the producer's freeze-approval moment. A `produces:` task WRITES the immutable snapshot;
1292
+ # a `consumes:` task PINS the live hash — a missing/unreadable snapshot HARD-STOPS here (the
1293
+ # phase stays at `contract`, nothing pinned), so a consumer never builds against a guessed
1294
+ # shape. No role / no contracts ⇒ neither branch is taken (byte-identical).
1295
+ if nxt == "tests":
1296
+ _prod = _task_produces(root, slug)
1297
+ if _prod:
1298
+ raw3c = _raw_phase_bodies(root, slug).get(3, "")
1299
+ vm = re.search(r"FROZEN @ (v\d+)", raw3c)
1300
+ snap = {"id": _prod, "producer": (_contracts(root).get(_prod) or {}).get("producer", "?"),
1301
+ "task": slug, "version": vm.group(1) if vm else "?",
1302
+ "frozen": date.today().isoformat(), "hash": _contract_body_hash(raw3c)}
1303
+ sp = _contract_snapshot(root, _prod)
1304
+ cur_snap = None
1305
+ if sp.exists():
1306
+ try:
1307
+ cur_snap = json.loads(sp.read_text(encoding="utf-8"))
1308
+ except (OSError, ValueError):
1309
+ cur_snap = None
1310
+ # idempotent: re-write only when the shape-hash or version actually changed (so a
1311
+ # no-op re-cross leaves the file — and its `frozen` date — byte-identical).
1312
+ if not (cur_snap and cur_snap.get("hash") == snap["hash"]
1313
+ and cur_snap.get("version") == snap["version"]):
1314
+ sp.parent.mkdir(parents=True, exist_ok=True)
1315
+ _atomic_write(sp, json.dumps(snap, sort_keys=True))
1316
+ _cons = _task_consumes(root, slug)
1317
+ if _cons:
1318
+ sp = _contract_snapshot(root, _cons)
1319
+ try:
1320
+ pinned = json.loads(sp.read_text(encoding="utf-8")).get("hash")
1321
+ except (OSError, ValueError, AttributeError):
1322
+ pinned = None
1323
+ if not pinned: # absent / unreadable / valid-JSON-but-no-hash all fail loud
1324
+ _die(f"contract_snapshot_missing: no readable hashed .add/contracts/{_cons}.json — the "
1325
+ f"producer of '{_cons}' must freeze its contract first "
1326
+ "(never build against a guessed shape)")
1327
+ state["tasks"][slug]["contract_pin"] = {"id": _cons, "hash": pinned}
976
1328
  state["tasks"][slug]["phase"] = nxt
977
1329
  state["tasks"][slug]["updated"] = _now()
978
1330
  _sync_task_marker(root, slug, nxt)
@@ -1011,6 +1363,14 @@ _AUTONOMY_LEVELS = ("manual", "conservative", "auto")
1011
1363
  # and reads as UNSET.
1012
1364
  _AUTONOMY_LINE_RE = re.compile(r"(?:^|·)[ \t]*autonomy:[ \t]*([^\s<#|]+)", re.MULTILINE)
1013
1365
 
1366
+ # component-aware-add: a task binds to a registered component via a `component: <name>`
1367
+ # header token — the SAME anchored grammar as autonomy (line-start or the `·`-inline
1368
+ # slug-line form), and an unfilled `<name>` placeholder captures nothing (reads UNBOUND).
1369
+ _COMPONENT_LINE_RE = re.compile(r"(?:^|·)[ \t]*component:[ \t]*([^\s<#|]+)", re.MULTILINE)
1370
+ # cross-component-contract: a task's role in a cross-component seam — same anchored grammar.
1371
+ _PRODUCES_LINE_RE = re.compile(r"(?:^|·)[ \t]*produces:[ \t]*([^\s<#|]+)", re.MULTILINE)
1372
+ _CONSUMES_LINE_RE = re.compile(r"(?:^|·)[ \t]*consumes:[ \t]*([^\s<#|]+)", re.MULTILINE)
1373
+
1014
1374
 
1015
1375
  def _autonomy_level(hdr: str):
1016
1376
  """The declared autonomy rung from a TASK.md header region (HTML comments
@@ -1109,6 +1469,18 @@ def cmd_gate(args: argparse.Namespace) -> None:
1109
1469
  # §5 scope gate (build-scope-lock): touched ⊆ declared, or a named refusal —
1110
1470
  # same placement discipline as the tripwire (before the waiver, never on HARD-STOP).
1111
1471
  _scope_guard(root, state, slug)
1472
+ # per-component verify (component-aware-add): a component-bound task with a declared
1473
+ # green_bar must CITE that bar in its §6 evidence before a completing outcome — the
1474
+ # engine never runs the suite, it checks the right bar was recorded. Unbound / no
1475
+ # green_bar -> _bar is None -> this is skipped (byte-identical). HARD-STOP never here.
1476
+ _bar = _task_green_bar(root, slug)
1477
+ # the cite must live in the user-authored Build-expectations evidence region (_cite_region,
1478
+ # v3): excludes the §6 checklist boilerplate + the GATE RECORD template/stamp, works for both
1479
+ # the standard and fast-lane §6 shapes. Unbound / no green_bar -> _bar None -> skipped.
1480
+ if _bar and _bar not in _cite_region(_raw_phase_bodies(root, slug).get(6, "")):
1481
+ _die(f"component_green_bar_uncited: task '{slug}' is bound to component "
1482
+ f"'{_task_component(root, slug)}'; its §6 Build-expectations must cite the "
1483
+ f"green-bar '{_bar}' — record the evidence that bar was met before PASS")
1112
1484
  if args.outcome == "RISK-ACCEPTED":
1113
1485
  # A waiver must be SIGNED: owner, ticket, expiry (glossary). Stored in state
1114
1486
  # so a later `check` can read/expire it. Refuse a partial waiver outright.
@@ -1126,7 +1498,11 @@ def cmd_gate(args: argparse.Namespace) -> None:
1126
1498
  state["tasks"][slug]["gate_actor"] = _actor_stamp(state) # WHO recorded the verdict (every outcome)
1127
1499
  state["tasks"][slug]["updated"] = _now()
1128
1500
  save_state(root, state)
1501
+ _stamp_gate_record(root, state, slug, args.outcome) # mirror the verdict into §6 (Finding C)
1129
1502
  print(f"task '{slug}' gate -> {args.outcome}")
1503
+ _gbar = _task_green_bar(root, slug) # per-component-verify: surface the bound bar
1504
+ if _gbar:
1505
+ print(f"component: {_task_component(root, slug)} · expected green-bar: {_gbar}")
1130
1506
  # the engine-sourced next step (next-footer-engine): a completing gate hands off to the
1131
1507
  # state arm; HARD-STOP routes to "resolve HARD-STOP …" — converging the old bespoke line.
1132
1508
  print(_next_footer(root, state))
@@ -1423,6 +1799,38 @@ def cmd_stage(args: argparse.Namespace) -> None:
1423
1799
  print(_next_footer(root, state))
1424
1800
 
1425
1801
 
1802
+ def _done_resume(root: Path, state: dict, slug: str) -> tuple[str, str, str]:
1803
+ """At a DONE task, what should the agent do NEXT? Classify from the task's
1804
+ milestone exit-criteria tally (_exit_criteria) so the orient surfaces (status,
1805
+ guide) STEER into the loop instead of always saying "start the next feature".
1806
+
1807
+ Returns (headline, next_step, chapter) where chapter is a docs/ filename:
1808
+ LOOP-JUNCTURE total>0 and met<total -> name the unmet goal, route to the loop
1809
+ GOAL-MET total>0 and met==total -> point at milestone-done
1810
+ PLAIN no criteria / no milestone / any read error -> today's "next feature"
1811
+ PURE and fail-closed (design-for-failure): a missing milestone or unreadable
1812
+ MILESTONE.md degrades to PLAIN — it never raises into a status/guide print path."""
1813
+ PLAIN = ("this task is done",
1814
+ "start the next feature -> add.py new-task <slug>",
1815
+ "02-the-flow.md")
1816
+ try:
1817
+ ms = ((state.get("tasks") or {}).get(slug) or {}).get("milestone")
1818
+ if not ms:
1819
+ return PLAIN
1820
+ met, total = _exit_criteria(root, ms)
1821
+ except Exception: # noqa: BLE001 — never break orient output
1822
+ return PLAIN
1823
+ if total > 0 and met < total:
1824
+ return (f"milestone '{ms}' goal not met ({met}/{total} exit criteria)",
1825
+ "propose the next tasks from open deltas / the unscaffolded plan -> add.py deltas",
1826
+ "09-the-loop.md")
1827
+ if total > 0 and met == total:
1828
+ return (f"milestone '{ms}' goal met ({met}/{total})",
1829
+ f"close it -> add.py milestone-done {ms}",
1830
+ "09-the-loop.md")
1831
+ return PLAIN
1832
+
1833
+
1426
1834
  def cmd_status(args: argparse.Namespace) -> None:
1427
1835
  if getattr(args, "json", False):
1428
1836
  root, state = _load_state_for_json()
@@ -1531,7 +1939,10 @@ def cmd_status(args: argparse.Namespace) -> None:
1531
1939
  if _rel:
1532
1940
  print(f" → {RELEASABLE_CUE.format(n=len(_rel))}")
1533
1941
 
1534
- print(f"active : {active or '(none)'}")
1942
+ # fast-lane marker (fast-new-task-flag): tag an ACTIVE fast task so the lane is visible at a
1943
+ # glance. Presentation-only, existence-gated — a plain/absent active task is byte-unchanged.
1944
+ _fast_mark = " · fast" if active and tasks.get(active, {}).get("fast") is True else ""
1945
+ print(f"active : {active or '(none)'}{_fast_mark}")
1535
1946
  # parallel streams (parallel-status-view): when >=2 milestones are active, render each as its
1536
1947
  # own stream (active task + phase) so a user working N fronts reads them all at once. ADDITIVE —
1537
1948
  # the N<=1 output above is byte-identical (the standing additive-cue convention); presentation
@@ -1614,8 +2025,16 @@ def cmd_status(args: argparse.Namespace) -> None:
1614
2025
  elif active and active in tasks:
1615
2026
  ph = tasks[active]["phase"]
1616
2027
  if ph == "done":
2028
+ # loop-aware resume (loop-aware-orient): a done task is NOT always "start the
2029
+ # next feature" — if its milestone goal is unmet we are at the loop juncture, so
2030
+ # STEER into the loop; if met, point at the close. PLAIN stays byte-identical.
2031
+ _hl, _nxt, _chap = _done_resume(root, state, active)
1617
2032
  print(f"\nresume : task '{active}' is done ({tasks[active]['gate']}).")
1618
- print(" start the next feature: add.py new-task <slug>")
2033
+ if _chap == "02-the-flow.md":
2034
+ print(" start the next feature: add.py new-task <slug>")
2035
+ else:
2036
+ print(f" {_hl} — {_nxt}")
2037
+ print(f" (the loop: .add/docs/{_chap})")
1619
2038
  else:
1620
2039
  print(f"\nresume : task '{active}' is at phase '{ph}'.")
1621
2040
  print(f" read .add/tasks/{active}/TASK.md and continue that phase.")
@@ -1665,6 +2084,10 @@ def cmd_guide(args: argparse.Namespace) -> None:
1665
2084
  phase = t.get("phase")
1666
2085
  owner = _phase_owner(phase) # _die unmapped_phase before any stdout
1667
2086
  action, chapter = PHASE_GUIDE[phase] # phase is mapped, so PHASE_GUIDE has it too
2087
+ if phase == "done": # loop-aware-orient: steer the --json surface too
2088
+ _hl, _nxt, _chap = _done_resume(json_root, state, slug)
2089
+ if _chap != "02-the-flow.md": # loop juncture / goal met; PLAIN stays unchanged
2090
+ action, chapter = _nxt, _chap
1668
2091
  print(json.dumps({"task": slug, "phase": phase, "owner": owner,
1669
2092
  "stop": owner != "ai", "next_step": action,
1670
2093
  "chapter": f".add/docs/{chapter}", "gate": t.get("gate"),
@@ -1685,6 +2108,10 @@ def cmd_guide(args: argparse.Namespace) -> None:
1685
2108
  if entry is None: # corrupted/hand-edited state.json — fail clean, not KeyError
1686
2109
  _die(f"task '{slug}' has unknown phase '{phase}' (state.json corrupted?)")
1687
2110
  action, chapter = entry
2111
+ if phase == "done": # loop-aware-orient: steer at the loop juncture
2112
+ _hl, _nxt, _chap = _done_resume(root, state, slug)
2113
+ if _chap != "02-the-flow.md": # loop juncture / goal met; PLAIN stays unchanged
2114
+ action, chapter = _nxt, _chap
1688
2115
  # the guide names the driver too (task gate-owner-marker) — the SAME _driver_stop the
1689
2116
  # footer renders, on the next-step line. Computed AFTER the unknown-phase guard above,
1690
2117
  # so a bad phase fails clean and never reaches the marker (it invents no default).
@@ -1701,7 +2128,10 @@ def cmd_guide(args: argparse.Namespace) -> None:
1701
2128
  if phase == "verify":
1702
2129
  print("then : add.py gate PASS | RISK-ACCEPTED | HARD-STOP")
1703
2130
  elif phase == "done":
1704
- print("then : start the next feature -> add.py new-task <slug>")
2131
+ if chapter != "02-the-flow.md": # loop juncture / goal met -> the steered command
2132
+ print(f"then : {action}")
2133
+ else:
2134
+ print("then : start the next feature -> add.py new-task <slug>")
1705
2135
  else:
1706
2136
  print("then : add.py advance")
1707
2137
 
@@ -2137,6 +2567,41 @@ def _missing_captures(root: Path) -> list[str]:
2137
2567
  if not any((cap_dir / f"{n}.{ext}").is_file() for ext in _CAPTURE_EXTS)]
2138
2568
 
2139
2569
 
2570
+ def cmd_federate(args: argparse.Namespace) -> None:
2571
+ """Multi-repo federation: pull a producer repo's published, immutable contract snapshot into
2572
+ this repo. Mono vs multi-repo differ ONLY in snapshot-transport — this lands the byte-copy at
2573
+ the SAME local `.add/contracts/<id>.json` the monorepo path (tasks 3/4) already reads, so a
2574
+ `consumes: <id>` task then holds/pins identically. Designed-for-failure: unknown / missing /
2575
+ invalid / version-mismatched sources HARD-STOP and land NOTHING (never build blind)."""
2576
+ root = find_root()
2577
+ if root is None:
2578
+ _die("no_project")
2579
+ fid = args.id
2580
+ fed = _federation(root)
2581
+ if fid not in fed:
2582
+ _die(f"federation_unknown: no [federation.{fid}] in components.toml — declare the producer "
2583
+ f"repo's published snapshot source before pulling")
2584
+ source = (root.parent / fed[fid]["source"])
2585
+ try:
2586
+ raw = source.read_bytes() # bytes — the landed snapshot must be a byte-for-byte copy
2587
+ except OSError:
2588
+ _die(f"federation_source_missing: cannot read the producer snapshot at '{fed[fid]['source']}' "
2589
+ f"(resolved {source}) — publish/commit it in the producer repo first")
2590
+ try:
2591
+ snap = json.loads(raw.decode("utf-8"))
2592
+ except (json.JSONDecodeError, ValueError, UnicodeDecodeError):
2593
+ snap = None
2594
+ if not isinstance(snap, dict) or snap.get("id") != fid or not snap.get("hash"):
2595
+ _die(f"federation_snapshot_invalid: the source for '{fid}' is not a valid contract snapshot "
2596
+ f"(needs JSON with matching id + a hash) — refusing to land a guessed shape")
2597
+ pin = fed[fid]["pin"]
2598
+ if pin and snap.get("version") != pin:
2599
+ _die(f"federation_version_mismatch: [federation.{fid}] pins '{pin}' but the source is "
2600
+ f"'{snap.get('version')}' — bump the pin or wait for the producer to publish {pin}")
2601
+ _atomic_write_bytes(_contract_snapshot(root, fid), raw)
2602
+ print(f"federated '{fid}' {snap.get('version', '?')} {snap['hash']} from {fed[fid]['source']}")
2603
+
2604
+
2140
2605
  def cmd_check(args: argparse.Namespace) -> None:
2141
2606
  """Read-only integrity check of the .add project. Exit 1 if anything fails."""
2142
2607
  as_json = getattr(args, "json", False)
@@ -2185,6 +2650,29 @@ def cmd_check(args: argparse.Namespace) -> None:
2185
2650
  if _alvl is None and t.get("phase") not in ("done", "observe"):
2186
2651
  warnings.append((f"task '{slug}'", "has no explicit autonomy level (implicit_autonomy) "
2187
2652
  "— run `add.py autonomy set <level>` to set it"))
2653
+ # per-component-verify: a bound task whose component declares no green_bar can't be
2654
+ # gated on a bar — surface it (WARN, never red). Unbound / "?" -> silent.
2655
+ _tc = _task_component(root, slug)
2656
+ if _tc and _tc != "?" and not (_components(root).get(_tc) or {}).get("green_bar"):
2657
+ warnings.append((f"task '{slug}'", f"component_green_bar_unset — bound component '{_tc}' "
2658
+ "declares no green_bar; the per-component gate cannot check a bar"))
2659
+ # cross-component-contract: a consumer whose pinned hash drifted from the live snapshot
2660
+ # (the producer re-froze a CHANGED shape) — the §7-stale cue. Degrade-safe (unreadable
2661
+ # snapshot ⇒ no finding here; the missing-snapshot HARD-STOP lives at the advance crossing).
2662
+ _pin = t.get("contract_pin")
2663
+ if _pin:
2664
+ try:
2665
+ _live = json.loads(_contract_snapshot(root, _pin["id"]).read_text(encoding="utf-8")).get("hash")
2666
+ except (OSError, ValueError, KeyError, TypeError, AttributeError):
2667
+ _live = None
2668
+ if _live is None: # missing / corrupt / hash-less ⇒ SURFACE, never mask
2669
+ warnings.append((f"task '{slug}'", f"contract_snapshot_unreadable — pinned contract "
2670
+ f"'{_pin.get('id')}' snapshot is missing or corrupt; re-publish the "
2671
+ "producer contract (cannot confirm the pin is current)"))
2672
+ elif _live != _pin.get("hash"):
2673
+ warnings.append((f"task '{slug}'", f"contract_consumer_stale — pinned contract "
2674
+ f"'{_pin.get('id')}' changed shape since pin; re-pin (re-cross contract→tests) "
2675
+ "after reviewing the producer's new frozen shape"))
2188
2676
  for dep in t.get("depends_on") or []:
2189
2677
  checks.append((dep in tasks or dep in archived_slugs,
2190
2678
  f"task '{slug}' dep '{dep}' resolves", "unknown task"))
@@ -2298,6 +2786,26 @@ def cmd_check(args: argparse.Namespace) -> None:
2298
2786
  checks.append((cycle is None, "task dependencies are acyclic",
2299
2787
  f"cycle: {' -> '.join(cycle)}" if cycle else ""))
2300
2788
 
2789
+ # component registry (component-aware-add): a malformed .add/components.toml, a root
2790
+ # escaping the project, or a task binding an unregistered component are integrity FAILS
2791
+ # — fail-closed RED (like wave_ledger_malformed), loud but never a crash (the readers
2792
+ # themselves degrade-safe). Silent when there is no components.toml.
2793
+ for _ccode, _cdetail in _component_findings(root):
2794
+ checks.append((False, f"component registry ({_ccode})", _cdetail))
2795
+ # cross-component-contract: a [contract.<id>] naming an unregistered producer is an
2796
+ # integrity FAIL (same fail-closed RED discipline; the readers stay degrade-safe).
2797
+ for _ccode, _cdetail in _contract_findings(root):
2798
+ checks.append((False, f"contract registry ({_ccode})", _cdetail))
2799
+ # multirepo-federation: a declared [federation.<id>] whose producer-repo source is unreadable
2800
+ # is a BROKEN JOIN — surface it EARLY as a WARN (never red alone; `federate pull` is where it
2801
+ # HARD-STOPs). Silent when no federation is declared (opt-in / byte-identical).
2802
+ for _fid, _fspec in _federation(root).items():
2803
+ if not (root.parent / _fspec["source"]).is_file():
2804
+ warnings.append((f"federation '{_fid}'",
2805
+ f"federation_source_unreadable — the producer snapshot at "
2806
+ f"'{_fspec['source']}' is missing/unreadable; `federate pull {_fid}` "
2807
+ "will hard-stop until the producer repo publishes it"))
2808
+
2301
2809
  # UDD foundation (udd-check-lint): lint a project's named set under .add/design/ —
2302
2810
  # composes the token + catalog/tree validators + the cross-file prop-token resolution.
2303
2811
  # Silent when absent; read-only; fail-closed on malformed JSON.
@@ -2577,17 +3085,64 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
2577
3085
  _atomic_write(mfile, _render_template(
2578
3086
  "MILESTONE.md", title=title, goal=args.goal or "<goal>",
2579
3087
  stage=args.stage, date=date.today().isoformat()))
2580
- state["milestones"][slug] = {
3088
+ # confirm-parent gate (OPT-IN, mirrors `init --await-lock`): `--await-confirm` seeds the
3089
+ # milestone UNCONFIRMED so new-task is held until `add.py milestone-confirm`. WITHOUT the flag
3090
+ # NO `confirmed` key is written → grandfathered-confirmed → no gate (so the existing engine
3091
+ # tests stay byte-green). The guided skill flow passes the flag at the human-review point.
3092
+ await_confirm = bool(getattr(args, "await_confirm", False))
3093
+ record = {
2581
3094
  "title": title, "goal": args.goal or "", "stage": args.stage,
2582
3095
  "status": "active", "created": _now(), "updated": _now(),
2583
3096
  }
3097
+ if await_confirm:
3098
+ # `await_confirm` is the STABLE opt-in marker (set ONLY here, at creation). `confirmed`
3099
+ # alone is NOT a reliable opt-in signal: milestone-confirm stamps confirmed:true on a plain
3100
+ # milestone too, so a later build-entry gate must key on `await_confirm`, not `confirmed`.
3101
+ record.update(confirmed=False, confirmed_at=None, confirmed_by=None, await_confirm=True)
3102
+ state["milestones"][slug] = record
2584
3103
  _set_active_milestone(state, slug)
2585
3104
  save_state(root, state)
2586
3105
  print(f"created milestone '{slug}' -> {mfile}")
2587
- print("active milestone set.")
3106
+ print("active milestone set." + ("" if not await_confirm else
3107
+ " (unconfirmed — show the MILESTONE.md, then: add.py milestone-confirm " + slug + ")"))
2588
3108
  print(_next_footer(root, state)) # converges the old "Decompose it into tasks: …" hint
2589
3109
 
2590
3110
 
3111
+ def cmd_milestone_confirm(args: argparse.Namespace) -> None:
3112
+ """The human gate that opens new-task for a milestone (confirm-parent). Mirrors `cmd_lock`
3113
+ one level down: the human reviews the filled MILESTONE.md, then RECORDS confirmation here.
3114
+ The engine never self-confirms. Validate-then-write; re-confirm is an idempotent note."""
3115
+ root = _require_root()
3116
+ state = load_state(root)
3117
+ slug = args.slug
3118
+ if slug not in state.get("milestones", {}):
3119
+ _die("unknown_milestone")
3120
+ m = state["milestones"][slug]
3121
+ if m.get("confirmed") is True:
3122
+ print(f"milestone '{slug}' already confirmed (by {m.get('confirmed_by', '?')}).")
3123
+ return
3124
+ # contract-fill gate (flow-enforcement, OPTED-IN only): a milestone that opted into
3125
+ # --await-confirm (carries a `confirmed` key) may not be confirmed until its cross-task
3126
+ # `## Shared / risky contracts` section is filled — so "confirmed" MEANS the contracts
3127
+ # were present at confirm time. A grandfathered no-key milestone keeps the plain stamp
3128
+ # (gate skipped — keeps the census + existing flows green). Validate-then-write.
3129
+ if "confirmed" in m:
3130
+ mfile = root / "milestones" / slug / MILESTONE_FILE
3131
+ md = mfile.read_text(encoding="utf-8") if mfile.exists() else ""
3132
+ if _section_unfilled(md, "## Shared / risky contracts"):
3133
+ _die("milestone_contracts_unfilled: fill the '## Shared / risky contracts' "
3134
+ f"section of {slug}'s MILESTONE.md before confirming")
3135
+ who = getattr(args, "by", None) or getpass.getuser()
3136
+ m["confirmed"] = True
3137
+ m["confirmed_at"] = _now()
3138
+ m["confirmed_by"] = who
3139
+ m["actor"] = _actor_stamp(state) # structured actor alongside the free-text confirmed_by
3140
+ m["updated"] = _now()
3141
+ save_state(root, state)
3142
+ print(f"confirmed milestone '{slug}' (by {who}) — new-task is now open for it.")
3143
+ print(_next_footer(root, state))
3144
+
3145
+
2591
3146
  def cmd_ready(args: argparse.Namespace) -> None:
2592
3147
  if getattr(args, "json", False):
2593
3148
  _, state = _load_state_for_json()
@@ -3499,6 +4054,204 @@ _SCOPE_EXCLUDE_FILES = (".DS_Store",) # plus *.pyc / *.tsbuildi
3499
4054
  _SCOPE_EXCLUDE_SUFFIXES = (".pyc", ".tsbuildinfo")
3500
4055
 
3501
4056
 
4057
+ # ── component registry (component-aware-add): declared components + task binding ─────
4058
+ # OPT-IN + DEGRADE-SAFE: with no .add/components.toml every reader is byte-identical to
4059
+ # pre-component ADD. A read NEVER raises (absent/unreadable/malformed → {} / dropped
4060
+ # cover); the loud surface is _component_findings, consumed by the scope gate (cmd_check).
4061
+ def _components(root: Path) -> dict[str, dict]:
4062
+ """The registry from .add/components.toml → {name: {root, verify, green_bar,
4063
+ language}}. `root` required per entry; an entry missing it is skipped (the finding
4064
+ surface reports it). `verify` is stored OPAQUE — parsed as data, NEVER executed. PURE."""
4065
+ if tomllib is None:
4066
+ return {}
4067
+ try:
4068
+ raw = (root / "components.toml").read_bytes()
4069
+ except OSError:
4070
+ return {}
4071
+ try:
4072
+ data = tomllib.loads(raw.decode("utf-8"))
4073
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
4074
+ return {}
4075
+ out: dict[str, dict] = {}
4076
+ for name, spec in (data.get("component") or {}).items():
4077
+ # "?" is the reserved unknown-binding sentinel (_task_component) — a component
4078
+ # named "?" would collide and silently drop cover, so it never registers.
4079
+ if name == "?" or not isinstance(spec, dict) or not isinstance(spec.get("root"), str):
4080
+ continue
4081
+ out[name] = {"root": spec["root"], "verify": spec.get("verify"),
4082
+ "green_bar": spec.get("green_bar"), "language": spec.get("language")}
4083
+ return out
4084
+
4085
+
4086
+ def _component_root(root: Path, name: str) -> str | None:
4087
+ """Project-root-relative path (trailing '/') of component `name`'s root, or None
4088
+ when the name is absent OR the root escapes the project (fail-closed — grants no
4089
+ scope cover, mirroring _declared_scope's _confined drop). PURE."""
4090
+ spec = _components(root).get(name)
4091
+ if not spec:
4092
+ return None
4093
+ rootp = root.parent.resolve()
4094
+ p = root.parent / spec["root"]
4095
+ if not _confined(p, rootp):
4096
+ return None
4097
+ try:
4098
+ return str(p.resolve().relative_to(rootp)).rstrip("/") + "/"
4099
+ except (OSError, ValueError):
4100
+ return None
4101
+
4102
+
4103
+ def _task_component(root: Path, slug: str):
4104
+ """The component a task binds to via its `component:` header token (anchored like
4105
+ autonomy). None = no line / unfilled `<…>` placeholder; "?" = a real token absent
4106
+ from the registry; otherwise the component name. PURE."""
4107
+ m = _COMPONENT_LINE_RE.search(_task_header(root, slug))
4108
+ if not m:
4109
+ return None
4110
+ tok = m.group(1).strip()
4111
+ return tok if tok in _components(root) else "?"
4112
+
4113
+
4114
+ def _task_green_bar(root: Path, slug: str) -> str | None:
4115
+ """The green_bar phrase of the task's bound component (per-component-verify), else
4116
+ None — unbound, "?", or no green_bar declared all yield None. PURE."""
4117
+ comp = _task_component(root, slug)
4118
+ if not comp or comp == "?":
4119
+ return None
4120
+ return (_components(root).get(comp) or {}).get("green_bar") or None
4121
+
4122
+
4123
+ def _cite_region(body: str) -> str:
4124
+ """The user-authored "Build expectations" evidence region of a §6 body, stamp-stripped —
4125
+ the only place a per-component green-bar cite counts (per-component-verify, v3). PURE.
4126
+
4127
+ The marker matches BOTH template shapes: the standard "### Build expectations …" heading AND
4128
+ the fast-lane bare "Build expectations (from …):" line, running up to the GATE RECORD sub-block.
4129
+ So the top-of-§6 checklist ("- [ ] all tests pass") and the "Outcome: <PASS|…>" placeholder are
4130
+ excluded, and a component-bound FAST task is still citable. The trailing strip removes the
4131
+ engine's own "component: … · expected green-bar: …" stamp wherever it landed, so a stamp that
4132
+ fell inside the region can never self-satisfy the gate. No marker -> "" (fail-closed for a bound
4133
+ task: it must declare its evidence)."""
4134
+ m = re.search(r"(?im)^#*[ \t]*Build expectations\b.*?(?=\n#+[ \t]*GATE RECORD\b|\Z)", body, re.DOTALL)
4135
+ region = m.group(0) if m else ""
4136
+ return re.sub(r"(?m)^component:.*·.*expected green-bar:.*$", "", region)
4137
+
4138
+
4139
+ def _component_findings(root: Path) -> list[tuple[str, str]]:
4140
+ """The loud gate surface for the registry — the codes a degrade-safe read passes
4141
+ over silently. Consumed by cmd_check (the scope_violation surface). [] when clean."""
4142
+ findings: list[tuple[str, str]] = []
4143
+ try:
4144
+ raw = (root / "components.toml").read_bytes()
4145
+ except OSError:
4146
+ return findings # absent/unreadable = opt-out, nothing to report
4147
+ data = None
4148
+ if tomllib is None:
4149
+ findings.append(("components_malformed", "components.toml present but tomllib unavailable (Python < 3.11)"))
4150
+ else:
4151
+ try:
4152
+ data = tomllib.loads(raw.decode("utf-8"))
4153
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError) as e:
4154
+ findings.append(("components_malformed", f"components.toml: {e}"))
4155
+ if data is not None:
4156
+ rootp = root.parent.resolve()
4157
+ for name, spec in (data.get("component") or {}).items():
4158
+ if name == "?":
4159
+ findings.append(("components_malformed", "component name '?' is reserved (the unknown-binding sentinel)"))
4160
+ continue
4161
+ if not isinstance(spec, dict) or not isinstance(spec.get("root"), str):
4162
+ findings.append(("components_malformed", f"[component.{name}] missing required `root`"))
4163
+ continue
4164
+ if not _confined(root.parent / spec["root"], rootp):
4165
+ findings.append(("component_root_outside", f"[component.{name}] root {spec['root']!r} escapes the project"))
4166
+ known = set(_components(root))
4167
+ try:
4168
+ task_dirs = sorted(p for p in (root / "tasks").iterdir() if p.is_dir())
4169
+ except OSError:
4170
+ task_dirs = [] # unreadable tasks/ degrades safe — never crash a read
4171
+ for d in task_dirs:
4172
+ tc = _task_component(root, d.name)
4173
+ if tc is not None and tc not in known: # "?" or a stale name
4174
+ findings.append(("component_unknown", f"task {d.name} binds an unregistered component"))
4175
+ return findings
4176
+
4177
+
4178
+ # ── cross-component contracts (cross-component-contract) ──────────────────────────────────
4179
+ # OPT-IN + DEGRADE-SAFE, like the component readers: no [contract.*] / no produces|consumes
4180
+ # header ⇒ every path below is byte-identical to pre-contract ADD. A read NEVER raises.
4181
+ def _contracts(root: Path) -> dict[str, dict]:
4182
+ """[contract.<id>] from .add/components.toml -> {id: {producer: str, consumers: list[str]}}.
4183
+ A malformed entry (producer not a str) is skipped (the finding surface reports it). PURE."""
4184
+ if tomllib is None:
4185
+ return {}
4186
+ try:
4187
+ data = tomllib.loads((root / "components.toml").read_bytes().decode("utf-8"))
4188
+ except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
4189
+ return {}
4190
+ out: dict[str, dict] = {}
4191
+ for cid, spec in (data.get("contract") or {}).items():
4192
+ if not isinstance(spec, dict) or not isinstance(spec.get("producer"), str):
4193
+ continue
4194
+ cons = spec.get("consumers")
4195
+ out[cid] = {"producer": spec["producer"],
4196
+ "consumers": [c for c in cons if isinstance(c, str)] if isinstance(cons, list) else []}
4197
+ return out
4198
+
4199
+
4200
+ def _federation(root: Path) -> dict[str, dict]:
4201
+ """[federation.<id>] from .add/components.toml -> {id: {source: str, pin: str|None}}.
4202
+ The cross-REPO join: a consumer repo names where a producer repo's published snapshot lives.
4203
+ A malformed entry (no string source) is skipped; a non-string `pin` degrades to None. Degrade-safe
4204
+ — never raises. PURE. On Python < 3.11 (no tomllib) this returns {} like the other component
4205
+ readers, so `federate` reports federation_unknown — components.toml needs a 3.11+ runtime."""
4206
+ if tomllib is None:
4207
+ return {}
4208
+ try:
4209
+ data = tomllib.loads((root / "components.toml").read_bytes().decode("utf-8"))
4210
+ except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
4211
+ return {}
4212
+ out: dict[str, dict] = {}
4213
+ for fid, spec in (data.get("federation") or {}).items():
4214
+ if not isinstance(spec, dict) or not isinstance(spec.get("source"), str):
4215
+ continue
4216
+ pin = spec.get("pin")
4217
+ out[fid] = {"source": spec["source"], "pin": pin if isinstance(pin, str) else None}
4218
+ return out
4219
+
4220
+
4221
+ def _task_produces(root: Path, slug: str) -> str | None:
4222
+ m = _PRODUCES_LINE_RE.search(_task_header(root, slug))
4223
+ return m.group(1).strip() if m else None
4224
+
4225
+
4226
+ def _task_consumes(root: Path, slug: str) -> str | None:
4227
+ m = _CONSUMES_LINE_RE.search(_task_header(root, slug))
4228
+ return m.group(1).strip() if m else None
4229
+
4230
+
4231
+ def _contract_snapshot(root: Path, cid: str) -> Path:
4232
+ return root / "contracts" / f"{cid}.json"
4233
+
4234
+
4235
+ def _contract_body_hash(raw3: str) -> str:
4236
+ """md5 of the §3 contract SHAPE — the first ```fenced``` block, whitespace-normalized. The
4237
+ version stamp + freeze flags are excluded (fallback strips Status:/flag/change-request lines)
4238
+ so a pure version bump does NOT churn pinned consumers stale. PURE."""
4239
+ m = re.search(r"```(.*?)```", raw3, re.DOTALL)
4240
+ body = m.group(1) if m else re.sub(r"(?m)^(Status:|.*surfaced at freeze:|v\d+ CHANGE REQUEST).*$", "", raw3)
4241
+ return _md5_text(re.sub(r"\s+", " ", body).strip())
4242
+
4243
+
4244
+ def _contract_findings(root: Path) -> list[tuple[str, str]]:
4245
+ """The loud gate surface for cross-component contracts — [] when clean / opted-out."""
4246
+ findings: list[tuple[str, str]] = []
4247
+ known = set(_components(root))
4248
+ for cid, spec in _contracts(root).items():
4249
+ if spec["producer"] not in known:
4250
+ findings.append(("contract_producer_unknown",
4251
+ f"[contract.{cid}] producer {spec['producer']!r} is not a declared component"))
4252
+ return findings
4253
+
4254
+
3502
4255
  def _declared_scope(root: Path, slug: str) -> list[str] | None:
3503
4256
  """Resolve the §5 'Scope (may touch):' declaration to project-root-relative
3504
4257
  strings (directory tokens keep a trailing '/'). The frozen scope-decl-template
@@ -3508,11 +4261,19 @@ def _declared_scope(root: Path, slug: str) -> list[str] | None:
3508
4261
  root, fail-closed — with ONE divergence: a directory token covers its WHOLE
3509
4262
  subtree (containment, judged by _in_scope). None = no Scope line (UNDECLARED,
3510
4263
  grandfathered — never retro-red); [] = a line whose every token was dropped
3511
- (a garbage declaration grants NO cover)."""
4264
+ (a garbage declaration grants NO cover).
4265
+
4266
+ component-aware-add: when the task binds a known `component:` (_task_component),
4267
+ that component's root subtree (_component_root) is APPENDED to the resolved tokens
4268
+ (dedup) — composing with the explicit declaration, never redrawing token resolution.
4269
+ A bound task with NO Scope line returns [component_root] (not None); an UNBOUND task
4270
+ is byte-identical to before."""
4271
+ comp = _task_component(root, slug)
4272
+ croot = _component_root(root, comp) if comp and comp != "?" else None
3512
4273
  body = _raw_phase_bodies(root, slug).get(5, "")
3513
4274
  m = re.search(r"^\s*Scope \(may touch\):.*$", body, re.M)
3514
4275
  if not m:
3515
- return None
4276
+ return [croot] if croot else None
3516
4277
  tdir = root / "tasks" / slug
3517
4278
  rootp = root.parent.resolve()
3518
4279
  out: list[str] = []
@@ -3538,6 +4299,8 @@ def _declared_scope(root: Path, slug: str) -> list[str] | None:
3538
4299
  continue
3539
4300
  if rel not in out:
3540
4301
  out.append(rel)
4302
+ if croot and croot not in out: # component-aware-add: compose, never redraw
4303
+ out.append(croot)
3541
4304
  return out
3542
4305
 
3543
4306
 
@@ -5645,6 +6408,9 @@ def build_parser() -> argparse.ArgumentParser:
5645
6408
  pi.add_argument("--force", action="store_true", help="reset state.json if present")
5646
6409
  pi.add_argument("--await-lock", dest="await_lock", action="store_true",
5647
6410
  help="seed an unlocked setup; gates new-task/advance/gate until `add.py lock`")
6411
+ pi.add_argument("--rule-file", dest="rule_file", action="store_true",
6412
+ help="write the ADD block to .claude/rules/add-workflows.md and reference it "
6413
+ "from CLAUDE.md (auto-on for ccsk projects with a .ccsk/ dir)")
5648
6414
  pi.set_defaults(func=cmd_init)
5649
6415
 
5650
6416
  pl = sub.add_parser("lock",
@@ -5696,6 +6462,9 @@ def build_parser() -> argparse.ArgumentParser:
5696
6462
  help="with --from-delta: target the UNIQUE open SPEC delta whose text "
5697
6463
  "contains SUBSTR (case-insensitive) instead of the first")
5698
6464
  pn.add_argument("--force", action="store_true", help="overwrite TASK.md if present")
6465
+ pn.add_argument("--fast", action="store_true",
6466
+ help="opt into the fast lane: scaffold the minimal TASK.fast.md template + "
6467
+ "hold the task to the freeze floor under any milestone")
5699
6468
  pn.set_defaults(func=cmd_new_task)
5700
6469
 
5701
6470
  pdd = sub.add_parser("drop-delta",
@@ -5712,8 +6481,18 @@ def build_parser() -> argparse.ArgumentParser:
5712
6481
  pm.add_argument("--goal", default=None, help="one-sentence outcome")
5713
6482
  pm.add_argument("--stage", default="mvp", choices=STAGES)
5714
6483
  pm.add_argument("--force", action="store_true", help="overwrite MILESTONE.md if present")
6484
+ pm.add_argument("--await-confirm", action="store_true",
6485
+ help="opt into the confirm-parent gate: seed the milestone unconfirmed so "
6486
+ "new-task is held until `milestone-confirm` (mirrors `init --await-lock`); "
6487
+ "the guided skill flow passes this at the human-review point")
5715
6488
  pm.set_defaults(func=cmd_new_milestone)
5716
6489
 
6490
+ pmc = sub.add_parser("milestone-confirm",
6491
+ help="confirm a milestone (the human gate that opens new-task for it)")
6492
+ pmc.add_argument("slug")
6493
+ pmc.add_argument("--by", default=None, help="free-text confirmer name (defaults to the OS user)")
6494
+ pmc.set_defaults(func=cmd_milestone_confirm)
6495
+
5717
6496
  pr = sub.add_parser("ready", help="list tasks whose dependencies are satisfied")
5718
6497
  pr.add_argument("--json", action="store_true", help="machine-readable JSON output")
5719
6498
  pr.set_defaults(func=cmd_ready)
@@ -5818,6 +6597,14 @@ def build_parser() -> argparse.ArgumentParser:
5818
6597
  pck.add_argument("--json", action="store_true", help="machine-readable JSON output")
5819
6598
  pck.set_defaults(func=cmd_check)
5820
6599
 
6600
+ pfed = sub.add_parser("federate", help="multi-repo: pull a producer repo's published, immutable "
6601
+ "contract snapshot into this repo (fail-loud)")
6602
+ pfedsub = pfed.add_subparsers(dest="action", required=True)
6603
+ pfedpull = pfedsub.add_parser("pull", help="land [federation.<id>].source at the local "
6604
+ ".add/contracts/<id>.json (hard-stops on a bad source)")
6605
+ pfedpull.add_argument("id", help="the contract id declared under [federation.<id>]")
6606
+ pfedpull.set_defaults(func=cmd_federate)
6607
+
5821
6608
  pdoc = sub.add_parser("doctor", help="read-only diagnosis of state.json integrity + "
5822
6609
  "referential consistency (run after a git merge)")
5823
6610
  pdoc.set_defaults(func=cmd_doctor)
@@ -5838,6 +6625,9 @@ def build_parser() -> argparse.ArgumentParser:
5838
6625
 
5839
6626
  psg = sub.add_parser("sync-guidelines",
5840
6627
  help="(re)write the ADD guideline block into AGENTS.md + CLAUDE.md")
6628
+ psg.add_argument("--rule-file", dest="rule_file", action="store_true",
6629
+ help="relocate CLAUDE.md's block to .claude/rules/add-workflows.md + reference "
6630
+ "it (auto-on for ccsk projects)")
5841
6631
  psg.set_defaults(func=cmd_sync_guidelines)
5842
6632
 
5843
6633
  pgd = sub.add_parser("guide", help="print the one concrete next step for the active task")