@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.
- package/CHANGELOG.md +109 -0
- package/bin/cli.js +137 -2
- package/docs/16-releasing.md +2 -2
- package/docs/17-components.md +125 -0
- package/docs/appendix-c-glossary.md +8 -0
- package/package.json +2 -2
- package/skill/add/SKILL.md +90 -116
- package/skill/add/adopt.md +13 -34
- package/skill/add/advisor.md +9 -10
- package/skill/add/compact-foundation.md +21 -34
- package/skill/add/components.md +54 -0
- package/skill/add/confidence.md +9 -25
- package/skill/add/deltas.md +12 -28
- package/skill/add/design.md +26 -32
- package/skill/add/fold.md +26 -51
- package/skill/add/graduate.md +18 -54
- package/skill/add/intake.md +30 -34
- package/skill/add/loop.md +30 -34
- package/skill/add/phases/0-ground.md +6 -14
- package/skill/add/phases/0-setup.md +30 -81
- package/skill/add/phases/1-specify.md +7 -22
- package/skill/add/phases/2-scenarios.md +1 -3
- package/skill/add/phases/3-contract.md +8 -22
- package/skill/add/phases/4-tests.md +3 -6
- package/skill/add/phases/5-build.md +8 -22
- package/skill/add/phases/6-verify.md +14 -49
- package/skill/add/phases/7-observe.md +1 -3
- package/skill/add/phases/fast-lane.md +31 -0
- package/skill/add/release.md +36 -90
- package/skill/add/report-template.md +41 -126
- package/skill/add/run.md +89 -159
- package/skill/add/scope.md +40 -89
- package/skill/add/setup-review.md +10 -27
- package/skill/add/soul.md +17 -36
- package/skill/add/streams.md +90 -149
- package/tooling/add.py +804 -14
- package/tooling/templates/TASK.fast.md.tmpl +72 -0
- 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
|
|
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
|
|
246
|
-
text =
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|