@pilotspace/add 1.16.1 → 1.17.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 +83 -0
- package/agents/add-advisor.md +1 -1
- package/agents/add-build.md +1 -1
- package/agents/add-design.md +1 -1
- package/agents/add-persona.md +6 -4
- package/agents/add-verify.md +3 -1
- package/bin/cli.js +143 -8
- package/docs/10-setup-and-stages.md +1 -1
- package/docs/18-personas.md +2 -2
- package/package.json +1 -1
- package/skill/add/advisor.md +2 -1
- package/skill/add/deltas.md +1 -1
- package/skill/add/design.md +2 -2
- package/skill/add/fold.md +7 -7
- package/skill/add/intake.md +17 -12
- package/skill/add/loop.md +6 -7
- package/skill/add/phases/0-setup.md +3 -3
- package/skill/add/phases/4-tests.md +19 -17
- package/skill/add/phases/7-observe.md +8 -10
- package/skill/add/phases/fast-lane.md +3 -0
- package/skill/add/report-template.md +7 -6
- package/skill/add/run.md +7 -17
- package/skill/add/scope.md +1 -1
- package/skill/add/self-improve.md +20 -0
- package/skill/add/streams.md +14 -13
- package/tooling/add.py +502 -37
- package/tooling/add_engine/constants.py +12 -0
- package/tooling/add_engine/io_state.py +13 -0
- package/tooling/templates/TASK.fast.md.tmpl +2 -1
- package/tooling/templates/TASK.md.tmpl +16 -51
- package/tooling/templates/personas/_template.md.tmpl +2 -0
package/tooling/add.py
CHANGED
|
@@ -101,6 +101,7 @@ from add_engine.io_state import ( # re-exported as module globals: callers use
|
|
|
101
101
|
_load_state_for_json, # --json state loader
|
|
102
102
|
_md5_text, _md5_file, # md5 hashing helpers
|
|
103
103
|
_personas_unseeded, # persona-seed-nudge predicate
|
|
104
|
+
_real_persona_slugs, # persona-fit-nudge slug listing
|
|
104
105
|
)
|
|
105
106
|
|
|
106
107
|
|
|
@@ -280,10 +281,14 @@ _BLANK_RUN_RE = re.compile(r"\n{3,}")
|
|
|
280
281
|
def _strip_live_scaffold(text: str) -> str:
|
|
281
282
|
"""Remove `<!-- … -->` instruction comments from a TASK.md — fences untouched, idempotent.
|
|
282
283
|
|
|
283
|
-
Splits on fenced code blocks
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
284
|
+
Splits on fenced code blocks AND an inline single-backtick span that IS itself a whole
|
|
285
|
+
`` `<!--...-->` `` (literal comment syntax quoted as an example in prose) so neither is
|
|
286
|
+
touched; a live comment that merely CONTAINS unrelated backtick-quoted code (e.g. this very
|
|
287
|
+
template's own `` `add.py autonomy set` ``-style asides) is untouched by this exception and
|
|
288
|
+
still stripped whole, exactly as before. In the remaining segments it drops comment spans,
|
|
289
|
+
trims the trailing whitespace a removal leaves on a line, and collapses 3+ consecutive
|
|
290
|
+
newlines to one blank line."""
|
|
291
|
+
segs = re.split(r"(```.*?```|`<!--.*?-->`)", text, flags=re.DOTALL)
|
|
287
292
|
for i in range(0, len(segs), 2): # even indices = OUTSIDE any fence
|
|
288
293
|
s = _HTML_COMMENT_RE.sub("", segs[i])
|
|
289
294
|
s = _TRAILING_WS_RE.sub("", s)
|
|
@@ -395,6 +400,28 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
|
|
|
395
400
|
_atomic_write(f, new)
|
|
396
401
|
|
|
397
402
|
|
|
403
|
+
def _capture_wrapped(label: str, body: str):
|
|
404
|
+
"""Capture a `<label>: value` field that may WRAP onto continuation lines (a human writing
|
|
405
|
+
prose in a TASK.md field routinely wraps past one line). Matches the label's first line, then
|
|
406
|
+
consumes subsequent physical lines while each is non-blank AND does not itself start a new
|
|
407
|
+
field label — `Word Word:` or `Word Word (parenthetical):` (the real template places labels
|
|
408
|
+
like `Safety rule (feature-specific):`/`Persona (optional):` immediately after a wrapped field
|
|
409
|
+
with no blank line; a parenthetical-blind boundary would silently swallow them) — so a wrapped
|
|
410
|
+
value is captured in full without ever bleeding into the next field or past a blank-line
|
|
411
|
+
paragraph break. Returns None if the label is absent, matching the single-line behavior it
|
|
412
|
+
replaces."""
|
|
413
|
+
m = re.search(rf"(?m)^{re.escape(label)}:[ \t]*(.+)$", body)
|
|
414
|
+
if not m:
|
|
415
|
+
return None
|
|
416
|
+
lines = [m.group(1).strip()]
|
|
417
|
+
rest = body[m.end():].split("\n")[1:]
|
|
418
|
+
for line in rest:
|
|
419
|
+
if not line.strip() or re.match(r"^[A-Z][A-Za-z ]*(\([^)]*\))?[ \t]*:", line):
|
|
420
|
+
break
|
|
421
|
+
lines.append(line.strip())
|
|
422
|
+
return " ".join(lines)
|
|
423
|
+
|
|
424
|
+
|
|
398
425
|
def _stamp_adr_record(root: Path, state: dict, slug: str) -> None:
|
|
399
426
|
"""Write-back (adr-at-observe): HARVEST a §7 `### Decisions (ADR)` block from the actor-stamps
|
|
400
427
|
ALREADY in the task — §1 framing (AI) · §3 freeze (human) · §5 strategy-actually-used (AI) · §6
|
|
@@ -427,11 +454,11 @@ def _stamp_adr_record(root: Path, state: dict, slug: str) -> None:
|
|
|
427
454
|
|
|
428
455
|
def _framing(): # §1 -> [AI]: chosen + rejected
|
|
429
456
|
try:
|
|
430
|
-
|
|
431
|
-
if
|
|
457
|
+
val = _capture_wrapped("Framings weighed", bodies.get(1, ""))
|
|
458
|
+
if val is None:
|
|
432
459
|
return UN, ""
|
|
433
460
|
chosen, rejected = UN, []
|
|
434
|
-
for p in (s.strip() for s in
|
|
461
|
+
for p in (s.strip() for s in val.split("·") if s.strip()):
|
|
435
462
|
cm = re.match(r"(.*?)\s*\(chosen\b.*\)\s*$", p) # "(chosen)" OR "(chosen — rationale)"
|
|
436
463
|
if cm:
|
|
437
464
|
chosen = cm.group(1).strip() or UN
|
|
@@ -459,12 +486,11 @@ def _stamp_adr_record(root: Path, state: dict, slug: str) -> None:
|
|
|
459
486
|
|
|
460
487
|
def _strategy(): # §5 -> [AI]: the value, default "as planned"
|
|
461
488
|
try:
|
|
462
|
-
|
|
463
|
-
if
|
|
489
|
+
val = _capture_wrapped("Strategy actually used", bodies.get(5, ""))
|
|
490
|
+
if val:
|
|
464
491
|
# UNFILLED is the "<fill at …>" template token; a real value may legitimately
|
|
465
492
|
# contain "<" (quoting `<tag>`, "x < y") and must NOT degrade to the default
|
|
466
|
-
|
|
467
|
-
if val and not val.startswith("<fill"):
|
|
493
|
+
if not val.startswith("<fill"):
|
|
468
494
|
return val
|
|
469
495
|
except Exception:
|
|
470
496
|
pass
|
|
@@ -594,6 +620,26 @@ def cmd_sync_guidelines(args: argparse.Namespace) -> None:
|
|
|
594
620
|
print(f"{action:>9} {name}")
|
|
595
621
|
|
|
596
622
|
|
|
623
|
+
# fastlane-intake-nudge: a frozen, blunt lexical heuristic — substring match only, no semantic
|
|
624
|
+
# read. Advisory-only (see _fastlane_nudge); never blocks, never selects the lane itself.
|
|
625
|
+
RISK_KEYWORDS = frozenset({
|
|
626
|
+
"milestone", "release", "security", "auth", "architecture", "migration",
|
|
627
|
+
"schema", "protocol", "engine", "breaking", "concurrency", "compliance", "payment",
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _fastlane_nudge(title: str, slug: str) -> str | None:
|
|
632
|
+
"""PURE. None if any RISK_KEYWORDS token appears in title.lower() or slug.lower();
|
|
633
|
+
else the one-line advisory recommending the fast lane or a direct edit."""
|
|
634
|
+
haystack = f"{title} {slug}".lower()
|
|
635
|
+
if any(word in haystack for word in RISK_KEYWORDS):
|
|
636
|
+
return None
|
|
637
|
+
return ("heuristic: this looks like a fast-lane or direct-edit candidate (no --fast, "
|
|
638
|
+
"no risk keyword in title/slug) — consider `add.py new-task <slug> --fast`, or "
|
|
639
|
+
"just edit directly for a single-file change. Recommendation only — the lane "
|
|
640
|
+
"is yours to pick.")
|
|
641
|
+
|
|
642
|
+
|
|
597
643
|
def cmd_new_task(args: argparse.Namespace) -> None:
|
|
598
644
|
root = _require_root()
|
|
599
645
|
state = load_state(root)
|
|
@@ -718,10 +764,31 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
718
764
|
if from_delta:
|
|
719
765
|
print(f"seeded from '{from_delta}' — its open SPEC delta is now "
|
|
720
766
|
f"[SPEC · seeded] … [→ {slug}]; §1 Feature pre-filled.")
|
|
767
|
+
if not fast:
|
|
768
|
+
note = _fastlane_nudge(title, slug)
|
|
769
|
+
if note:
|
|
770
|
+
print(note)
|
|
721
771
|
print("active task set. phase: ground. Gather the real codebase (section 0 GROUND).")
|
|
722
772
|
print(_next_footer(root, state)) # converges the old "then: add.py advance" hint
|
|
723
773
|
|
|
724
774
|
|
|
775
|
+
def _delta_task_md(root: Path, state: dict, raw_slug: str | None) -> tuple[str, Path, bool]:
|
|
776
|
+
"""Resolve a delta verb's target to its on-disk TASK.md — ACTIVE (state-tracked) or
|
|
777
|
+
light-ARCHIVED (state entry dropped at archive-milestone, file kept). The SPEC-delta
|
|
778
|
+
lifecycle lives in the FILE and `deltas` already lists archived ones, so the write verbs
|
|
779
|
+
reach them too (delta-drain reach-back) — previously that needed a hand edit. An archived
|
|
780
|
+
target must be named EXPLICITLY: the no-slug active-task fallback never resolves to one.
|
|
781
|
+
A slug neither in state nor on disk still dies `unknown task` (a compacted bundle under
|
|
782
|
+
.add/archive/ stays out of reach — recover it first). Returns (slug, task_md, archived)."""
|
|
783
|
+
if raw_slug and raw_slug not in state.get("tasks", {}):
|
|
784
|
+
task_md = root / "tasks" / raw_slug / "TASK.md"
|
|
785
|
+
if task_md.exists():
|
|
786
|
+
return raw_slug, task_md, True
|
|
787
|
+
_die(f"unknown task '{raw_slug}'")
|
|
788
|
+
slug = _resolve_task(state, raw_slug) # active path, unchanged semantics
|
|
789
|
+
return slug, root / "tasks" / slug / "TASK.md", False
|
|
790
|
+
|
|
791
|
+
|
|
725
792
|
def cmd_drop_delta(args: argparse.Namespace) -> None:
|
|
726
793
|
"""DISMISS a task's first open SPEC delta — `[SPEC · open]` -> `[SPEC · dropped]`.
|
|
727
794
|
|
|
@@ -730,8 +797,7 @@ def cmd_drop_delta(args: argparse.Namespace) -> None:
|
|
|
730
797
|
text + `(evidence: …)` are byte-preserved by the pure `_resolve_spec_delta`."""
|
|
731
798
|
root = _require_root()
|
|
732
799
|
state = load_state(root)
|
|
733
|
-
slug =
|
|
734
|
-
task_md = root / "tasks" / slug / "TASK.md"
|
|
800
|
+
slug, task_md, _arch = _delta_task_md(root, state, args.slug)
|
|
735
801
|
text = task_md.read_text(encoding="utf-8")
|
|
736
802
|
match = getattr(args, "match", None)
|
|
737
803
|
status, idx, _disp = _select_spec_delta(text, match)
|
|
@@ -744,7 +810,8 @@ def cmd_drop_delta(args: argparse.Namespace) -> None:
|
|
|
744
810
|
f"'{slug}' — narrow it")
|
|
745
811
|
new_text = _resolve_spec_delta(text, "dropped", line_index=idx)
|
|
746
812
|
_atomic_write(task_md, new_text)
|
|
747
|
-
print(f"dropped the {'matched' if match else 'first'} open SPEC delta in
|
|
813
|
+
print(f"dropped the {'matched' if match else 'first'} open SPEC delta in "
|
|
814
|
+
f"'{slug}'{' (archived — on-disk record)' if _arch else ''} -> [SPEC · dropped]")
|
|
748
815
|
print(_next_footer(root, state))
|
|
749
816
|
|
|
750
817
|
|
|
@@ -764,12 +831,12 @@ def cmd_carry_delta(args: argparse.Namespace) -> None:
|
|
|
764
831
|
every open delta in the task; `--match` targets the unique one. Validate-then-write."""
|
|
765
832
|
root = _require_root()
|
|
766
833
|
state = load_state(root)
|
|
767
|
-
slug =
|
|
834
|
+
slug, task_md, _arch = _delta_task_md(root, state, args.slug)
|
|
835
|
+
_arch_note = " (archived — on-disk record)" if _arch else ""
|
|
768
836
|
reason = (getattr(args, "reason", None) or "").strip()
|
|
769
837
|
if not reason:
|
|
770
838
|
_die("carry_reason_required: carry-delta needs a --reason — a deferral must say why "
|
|
771
839
|
"(it is the breadcrumb a future loop reads)")
|
|
772
|
-
task_md = root / "tasks" / slug / "TASK.md"
|
|
773
840
|
text = task_md.read_text(encoding="utf-8")
|
|
774
841
|
stamp = f"[carried: {reason}]"
|
|
775
842
|
if getattr(args, "all", False):
|
|
@@ -779,7 +846,7 @@ def cmd_carry_delta(args: argparse.Namespace) -> None:
|
|
|
779
846
|
for idx in idxs: # indices stay valid (flip is in-place)
|
|
780
847
|
text = _resolve_spec_delta(text, "carried", line_index=idx, stamp=stamp)
|
|
781
848
|
_atomic_write(task_md, text)
|
|
782
|
-
print(f"carried {len(idxs)} open SPEC delta(s) in '{slug}' -> [SPEC · carried] ({reason})")
|
|
849
|
+
print(f"carried {len(idxs)} open SPEC delta(s) in '{slug}'{_arch_note} -> [SPEC · carried] ({reason})")
|
|
783
850
|
print(_next_footer(root, state))
|
|
784
851
|
return
|
|
785
852
|
match = getattr(args, "match", None)
|
|
@@ -793,7 +860,7 @@ def cmd_carry_delta(args: argparse.Namespace) -> None:
|
|
|
793
860
|
f"'{slug}' — narrow it, or use --all")
|
|
794
861
|
new_text = _resolve_spec_delta(text, "carried", line_index=idx, stamp=stamp)
|
|
795
862
|
_atomic_write(task_md, new_text)
|
|
796
|
-
print(f"carried the {'matched' if match else 'first'} open SPEC delta in '{slug}' -> "
|
|
863
|
+
print(f"carried the {'matched' if match else 'first'} open SPEC delta in '{slug}'{_arch_note} -> "
|
|
797
864
|
f"[SPEC · carried] ({reason})")
|
|
798
865
|
print(_next_footer(root, state))
|
|
799
866
|
|
|
@@ -805,8 +872,7 @@ def cmd_reopen_delta(args: argparse.Namespace) -> None:
|
|
|
805
872
|
targets the unique carried delta. Validate-then-write; refuse `no_carried_spec_delta`."""
|
|
806
873
|
root = _require_root()
|
|
807
874
|
state = load_state(root)
|
|
808
|
-
slug =
|
|
809
|
-
task_md = root / "tasks" / slug / "TASK.md"
|
|
875
|
+
slug, task_md, _arch = _delta_task_md(root, state, args.slug)
|
|
810
876
|
text = task_md.read_text(encoding="utf-8")
|
|
811
877
|
match = getattr(args, "match", None)
|
|
812
878
|
status, idx, _disp = _select_spec_delta(text, match, status="carried")
|
|
@@ -821,7 +887,8 @@ def cmd_reopen_delta(args: argparse.Namespace) -> None:
|
|
|
821
887
|
eol = lines[idx][len(lines[idx].rstrip("\n")):]
|
|
822
888
|
lines[idx] = re.sub(r"\s*\[carried:[^\]]*\]\s*$", "", lines[idx].rstrip("\n")) + eol
|
|
823
889
|
_atomic_write(task_md, "".join(lines))
|
|
824
|
-
print(f"reopened the {'matched' if match else 'first'} carried SPEC delta in
|
|
890
|
+
print(f"reopened the {'matched' if match else 'first'} carried SPEC delta in "
|
|
891
|
+
f"'{slug}'{' (archived — on-disk record)' if _arch else ''} -> [SPEC · open]")
|
|
825
892
|
print(_next_footer(root, state))
|
|
826
893
|
|
|
827
894
|
|
|
@@ -1033,6 +1100,35 @@ def cmd_phase(args: argparse.Namespace) -> None:
|
|
|
1033
1100
|
print(_next_footer(root, state))
|
|
1034
1101
|
|
|
1035
1102
|
|
|
1103
|
+
def cmd_recross(args: argparse.Namespace) -> None:
|
|
1104
|
+
"""The RECORDED post-freeze re-cross (bundle-advance): a HUMAN-APPROVED test change after
|
|
1105
|
+
the tests->build crossing (e.g. a test added at review) re-arms the tamper tripwire + §5
|
|
1106
|
+
scope snapshot by re-running the IDENTICAL _build_entry gate stack — never a freeze bypass
|
|
1107
|
+
(a DRAFT §3 still refuses contract_not_frozen). The approver is recorded in state
|
|
1108
|
+
(tasks[slug]["recross"] = {by, at, from_phase}) so an audit can tell a signed re-cross
|
|
1109
|
+
from silent tampering. Replaces the undocumented `phase tests` + `advance` dance."""
|
|
1110
|
+
root = _require_root()
|
|
1111
|
+
state = load_state(root)
|
|
1112
|
+
slug = _resolve_task(state, args.slug)
|
|
1113
|
+
cur = state["tasks"][slug]["phase"]
|
|
1114
|
+
if cur not in ("build", "verify"):
|
|
1115
|
+
_die(f"recross_wrong_phase: re-cross re-arms the tests->build snapshots — only a "
|
|
1116
|
+
f"task at build or verify can re-cross (task '{slug}' is at {cur})")
|
|
1117
|
+
if not (getattr(args, "by", "") or "").strip():
|
|
1118
|
+
_die("recross_unsigned: a post-freeze test change is human-approved — record the "
|
|
1119
|
+
"approver with --by <name>")
|
|
1120
|
+
_build_entry(root, state, slug) # full gate stack; validate-then-write
|
|
1121
|
+
state["tasks"][slug]["recross"] = {"by": args.by.strip(), "at": _now(),
|
|
1122
|
+
"from_phase": cur}
|
|
1123
|
+
state["tasks"][slug]["phase"] = "build"
|
|
1124
|
+
state["tasks"][slug]["updated"] = _now()
|
|
1125
|
+
save_state(root, state) # durable state FIRST (source of truth)
|
|
1126
|
+
_sync_task_marker(root, slug, "build") # then mirror into TASK.md — no split-brain
|
|
1127
|
+
print(f"task '{slug}' re-crossed tests->build — tripwire + scope re-snapshotted "
|
|
1128
|
+
f"(approved by {args.by.strip()})")
|
|
1129
|
+
print(_next_footer(root, state))
|
|
1130
|
+
|
|
1131
|
+
|
|
1036
1132
|
def cmd_advance(args: argparse.Namespace) -> None:
|
|
1037
1133
|
root = _require_root()
|
|
1038
1134
|
state = load_state(root)
|
|
@@ -1041,6 +1137,18 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
1041
1137
|
idx = PHASES.index(cur)
|
|
1042
1138
|
if idx >= len(PHASES) - 1:
|
|
1043
1139
|
_die(f"task '{slug}' already at final phase ({cur})")
|
|
1140
|
+
# bundle fast-forward (bundle-advance): --to repeats the SINGLE-STEP advance — every
|
|
1141
|
+
# crossing guard below runs per step — and stops hard at `tests`: the tests->build
|
|
1142
|
+
# crossing carries the gate stack (_build_entry) and is never fast-forwarded.
|
|
1143
|
+
_to = getattr(args, "to", None)
|
|
1144
|
+
if _to is not None:
|
|
1145
|
+
if _to not in PHASES:
|
|
1146
|
+
_die(f"advance_to_invalid: --to must be one of: {', '.join(PHASES)}")
|
|
1147
|
+
if PHASES.index(_to) > PHASES.index("tests"):
|
|
1148
|
+
_die("advance_to_stops_at_tests: --to fast-forwards the bundle bookkeeping only — "
|
|
1149
|
+
"the tests->build crossing carries the gate stack; cross it with a plain advance")
|
|
1150
|
+
if PHASES.index(_to) <= idx:
|
|
1151
|
+
_die(f"advance_to_not_forward: task '{slug}' is already at {cur}")
|
|
1044
1152
|
nxt = PHASES[idx + 1]
|
|
1045
1153
|
# build-boundary gate: pre-lock the front (specify..tests) is allowed, but crossing
|
|
1046
1154
|
# into build/verify/observe/done is refused until `add.py lock`.
|
|
@@ -1120,6 +1228,11 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
1120
1228
|
print(" note: record the lessons this loop taught the foundation in §7 "
|
|
1121
1229
|
"OBSERVE, then update PROJECT.md when ready:")
|
|
1122
1230
|
print(f" add.py {_FOLD_VERB} --task {slug} (review first: add.py deltas)")
|
|
1231
|
+
# bundle fast-forward: keep stepping (each pass re-loads state and re-runs every
|
|
1232
|
+
# crossing guard) until the validated --to target is reached.
|
|
1233
|
+
if _to is not None and PHASES.index(nxt) < PHASES.index(_to):
|
|
1234
|
+
cmd_advance(args)
|
|
1235
|
+
return
|
|
1123
1236
|
print(_next_footer(root, state))
|
|
1124
1237
|
|
|
1125
1238
|
|
|
@@ -1303,9 +1416,47 @@ def _driver_marker(stop: bool) -> str:
|
|
|
1303
1416
|
return " [human gate]" if stop else " [you drive]"
|
|
1304
1417
|
|
|
1305
1418
|
|
|
1419
|
+
def _gate_explain(root: Path, state: dict, slug: str) -> None:
|
|
1420
|
+
"""gate-explain (method-ergonomics): compose the gate decision from the SAME predicates
|
|
1421
|
+
cmd_gate enforces, as a READ-ONLY answer — the agent asks instead of recalling run.md.
|
|
1422
|
+
PURE: prints, writes nothing."""
|
|
1423
|
+
hdr = _task_header(root, slug)
|
|
1424
|
+
body6 = _raw_phase_bodies(root, slug).get(6, "")
|
|
1425
|
+
from add_engine.autonomy import _autonomy_level
|
|
1426
|
+
level = _autonomy_level(hdr) or "auto"
|
|
1427
|
+
high = bool(_RISK_HIGH_RE.search(hdr))
|
|
1428
|
+
sens = _task_sensitivity(hdr, valid=_project_sensitivity_values(root)) or "unset"
|
|
1429
|
+
adv_pass = _advisor_verdict_is_pass(body6)
|
|
1430
|
+
adv_clean = adv_pass and _advisor_no_residue(body6)
|
|
1431
|
+
relaxed = sens == "mechanical" and adv_clean
|
|
1432
|
+
print(f"gate-explain {slug}")
|
|
1433
|
+
print(f" phase: {state['tasks'][slug].get('phase', '?')}")
|
|
1434
|
+
print(f" autonomy: {level} · risk: {'high' if high else 'unset/low'} · sensitivity: {sens}")
|
|
1435
|
+
print(f" advisor 3-lens: {'PASS, residue none' if adv_clean else ('PASS with residue' if adv_pass else 'unrecorded or non-PASS')}")
|
|
1436
|
+
print(f" advisor-gate-relax: {'applies (mechanical + clean advisor verdict)' if relaxed else 'not applicable'}")
|
|
1437
|
+
if _autonomy_lowered(hdr):
|
|
1438
|
+
print(" path: HUMAN — the lowered autonomy level puts a person at this verify gate")
|
|
1439
|
+
elif high and not relaxed:
|
|
1440
|
+
print(" path: REFUSED at completion (unguarded_high_risk_auto) — lower the autonomy "
|
|
1441
|
+
"level (`add.py autonomy set conservative`), or for a mechanical task record a "
|
|
1442
|
+
"clean Advisor 3-lens verdict")
|
|
1443
|
+
elif relaxed:
|
|
1444
|
+
print(" path: RELAX — mechanical sensitivity + advisor PASS/none may complete "
|
|
1445
|
+
"without a lowered level (advisor-gate-relax)")
|
|
1446
|
+
else:
|
|
1447
|
+
print(" path: AUTO — may auto-PASS on complete evidence (tests green · no tamper · "
|
|
1448
|
+
"loops dry · deep check + refute-read + 3-lens recorded) with no residue")
|
|
1449
|
+
print(" floor: a security finding is always HARD-STOP — never auto-passed, on every path")
|
|
1450
|
+
|
|
1451
|
+
|
|
1306
1452
|
def cmd_gate(args: argparse.Namespace) -> None:
|
|
1307
1453
|
root = _require_root()
|
|
1308
1454
|
state = load_state(root)
|
|
1455
|
+
if getattr(args, "explain", False):
|
|
1456
|
+
# slug may have landed in the outcome slot (`gate --explain <slug>`)
|
|
1457
|
+
cand = args.slug or (args.outcome if args.outcome not in GATES else None)
|
|
1458
|
+
_gate_explain(root, state, _resolve_task(state, cand))
|
|
1459
|
+
return
|
|
1309
1460
|
slug = _resolve_task(state, args.slug)
|
|
1310
1461
|
# build-boundary gate: no verdict may be recorded before the setup is locked.
|
|
1311
1462
|
if not _setup_locked(state):
|
|
@@ -1493,6 +1644,55 @@ def cmd_autonomy(args: argparse.Namespace) -> None:
|
|
|
1493
1644
|
_print_autonomy(root, state, slug)
|
|
1494
1645
|
|
|
1495
1646
|
|
|
1647
|
+
def cmd_worktree_prep(args: argparse.Namespace) -> None:
|
|
1648
|
+
"""Mechanize streams.md's manual worktree recipe (worktree-prep): cut an isolated git
|
|
1649
|
+
worktree at HEAD for a spawned worker, materialize the gitignored engine content a
|
|
1650
|
+
tracked-only checkout lacks (.add/tooling · .add/docs — confirmed absent 3-for-3 when
|
|
1651
|
+
done by hand), and echo the fork base for the WAVE.md ledger. Workspace-only: never
|
|
1652
|
+
writes state.json (prep is not a state transition). validate-then-act — every refusal
|
|
1653
|
+
precedes the first filesystem write; a dirty tree WARNS (streams.md: cut AFTER the
|
|
1654
|
+
bundle commit) but proceeds. Runs git only (identity.py precedent) — the NO-EXEC floor
|
|
1655
|
+
(never run a verify suite) is untouched."""
|
|
1656
|
+
root = _require_root()
|
|
1657
|
+
state = load_state(root)
|
|
1658
|
+
slug = _resolve_task(state, args.slug) # unknown task -> _die
|
|
1659
|
+
project = root.parent
|
|
1660
|
+
try:
|
|
1661
|
+
r = subprocess.run(["git", "-C", str(project), "rev-parse", "--short", "HEAD"],
|
|
1662
|
+
capture_output=True, text=True, timeout=30)
|
|
1663
|
+
except (OSError, subprocess.SubprocessError):
|
|
1664
|
+
r = None
|
|
1665
|
+
if r is None or r.returncode != 0:
|
|
1666
|
+
_die("worktree_prep_no_git: the project root is not a git repository with a commit — "
|
|
1667
|
+
"worktree isolation needs git (commit the frozen bundle first)")
|
|
1668
|
+
base = r.stdout.strip()
|
|
1669
|
+
dest = (Path(args.dir).expanduser().resolve() if getattr(args, "dir", None)
|
|
1670
|
+
else project.parent / f"{project.name}-wt-{slug}")
|
|
1671
|
+
if dest.exists():
|
|
1672
|
+
_die(f"worktree_prep_exists: {dest} already exists — remove it or pass --dir <path>")
|
|
1673
|
+
dirty = subprocess.run(["git", "-C", str(project), "status", "--porcelain"],
|
|
1674
|
+
capture_output=True, text=True, timeout=30).stdout.strip()
|
|
1675
|
+
if dirty:
|
|
1676
|
+
print("warning: worktree_prep_dirty_tree — uncommitted changes will NOT ride into the "
|
|
1677
|
+
"worktree; streams.md cuts AFTER committing the frozen bundle", file=sys.stderr)
|
|
1678
|
+
r = subprocess.run(["git", "-C", str(project), "worktree", "add", str(dest), "HEAD"],
|
|
1679
|
+
capture_output=True, text=True, timeout=120)
|
|
1680
|
+
if r.returncode != 0:
|
|
1681
|
+
_die(f"worktree_prep_git_failed: git worktree add refused — {r.stderr.strip()[:300]}")
|
|
1682
|
+
copied = [] # tracked-only checkout lacks these
|
|
1683
|
+
for name in ("tooling", "docs"):
|
|
1684
|
+
src = root / name
|
|
1685
|
+
if src.is_dir():
|
|
1686
|
+
shutil.copytree(src, dest / ".add" / name, dirs_exist_ok=True)
|
|
1687
|
+
copied.append(f".add/{name}")
|
|
1688
|
+
print(f"worktree ready: {dest}")
|
|
1689
|
+
print(f"fork base: {base} (record it in the WAVE.md ledger; the worker's step-0 "
|
|
1690
|
+
"re-echoes `git rev-parse HEAD` and wave-verify checks the match)")
|
|
1691
|
+
print(f"materialized (gitignored, absent from a bare checkout): "
|
|
1692
|
+
f"{', '.join(copied) if copied else 'none found'}")
|
|
1693
|
+
print(f"cleanup when merged: git -C {project} worktree remove {dest}")
|
|
1694
|
+
|
|
1695
|
+
|
|
1496
1696
|
def cmd_streams(args: argparse.Namespace) -> None:
|
|
1497
1697
|
"""show / set the project STREAMS posture — the parallel-vs-sequential half of the run mode
|
|
1498
1698
|
(persist-run-mode). Project-scoped (parallelism is ACROSS tasks, so there is no per-task posture)
|
|
@@ -1945,6 +2145,20 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1945
2145
|
if _loose:
|
|
1946
2146
|
print(f" → releasable: {len(_loose)} loose task(s) since last release")
|
|
1947
2147
|
|
|
2148
|
+
# loop-surfacing-nudges: two ADDITIVE cues so the observe loop surfaces its own accumulation
|
|
2149
|
+
# (cues COUNT, never judge; a clean project's output is byte-identical). carried = the deferred
|
|
2150
|
+
# spec-delta backlog (write-only memory otherwise); compaction = the folded tail the
|
|
2151
|
+
# compact-foundation.md ritual exists to roll (threshold 25 keeps young projects quiet).
|
|
2152
|
+
_carried_n = len(_collect_carried_spec_deltas(root))
|
|
2153
|
+
if _carried_n:
|
|
2154
|
+
print(f" → carried: {_carried_n} deferred spec delta(s) — add.py deltas --carried")
|
|
2155
|
+
_tail = _foundation_tail(root)
|
|
2156
|
+
if _tail["bullets"] >= 25:
|
|
2157
|
+
_rolled = f"fv{_tail['last_settled_fv']}" if _tail["last_settled_fv"] else "never"
|
|
2158
|
+
_now = f"fv{_tail['fv']}" if _tail["fv"] else "fv?"
|
|
2159
|
+
print(f" → compaction: {_tail['bullets']} consolidated lesson(s) above the settled line "
|
|
2160
|
+
f"(last rolled {_rolled}, now {_now}) — compact-foundation.md")
|
|
2161
|
+
|
|
1948
2162
|
# fast-lane marker (fast-new-task-flag): tag an ACTIVE fast task so the lane is visible at a
|
|
1949
2163
|
# glance. Presentation-only, existence-gated — a plain/absent active task is byte-unchanged.
|
|
1950
2164
|
_fast_mark = " · fast" if active and tasks.get(active, {}).get("fast") is True else ""
|
|
@@ -3448,6 +3662,13 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
|
|
|
3448
3662
|
# (a queued milestone isn't yet in flight, so the nudge would be premature there).
|
|
3449
3663
|
if _personas_unseeded(root):
|
|
3450
3664
|
print(f"note: {PERSONA_HINT}")
|
|
3665
|
+
else:
|
|
3666
|
+
# persona-fit-nudge: the opposite branch — ≥1 real persona already exists, so nudge
|
|
3667
|
+
# the AI to confirm domain fit (or draft a new one) rather than silently assuming an
|
|
3668
|
+
# existing persona covers this brand-new milestone. Existence-only, mutually
|
|
3669
|
+
# exclusive with the note above (same predicate, opposite branch — never both).
|
|
3670
|
+
slugs = ", ".join(_real_persona_slugs(root))
|
|
3671
|
+
print(f"persona-fit: {PERSONA_FIT_HINT_TEMPLATE.format(slugs=slugs)}")
|
|
3451
3672
|
print(_next_footer(root, state)) # converges the old "Decompose it into tasks: …" hint
|
|
3452
3673
|
|
|
3453
3674
|
|
|
@@ -5640,6 +5861,32 @@ def _collect_open_spec_deltas(root: Path) -> list[dict]:
|
|
|
5640
5861
|
return _collect_spec_deltas(root, "open")
|
|
5641
5862
|
|
|
5642
5863
|
|
|
5864
|
+
def _foundation_tail(root: Path) -> dict:
|
|
5865
|
+
"""Count the un-compacted foundation tail — READ-ONLY facts for the status compaction cue.
|
|
5866
|
+
|
|
5867
|
+
Returns {bullets, last_settled_fv, fv}: live `[folded foundation-version N]` stamps across
|
|
5868
|
+
PROJECT.md + CONVENTIONS.md, the highest fv a `settled …fvK–fvM` rolled line reaches (None
|
|
5869
|
+
when never rolled), and the current header fv (None when unparseable). Judges nothing."""
|
|
5870
|
+
bullets, settled, fv = 0, None, None
|
|
5871
|
+
for name in ("PROJECT.md", "CONVENTIONS.md"):
|
|
5872
|
+
f = root / name
|
|
5873
|
+
if not f.is_file():
|
|
5874
|
+
continue
|
|
5875
|
+
try:
|
|
5876
|
+
s = f.read_text(encoding="utf-8")
|
|
5877
|
+
except OSError:
|
|
5878
|
+
continue
|
|
5879
|
+
bullets += s.count("[folded foundation-version")
|
|
5880
|
+
for m in re.finditer(r"settled[^\n]*?fv\d+[–-]fv(\d+)", s):
|
|
5881
|
+
n = int(m.group(1))
|
|
5882
|
+
settled = n if settled is None else max(settled, n)
|
|
5883
|
+
if fv is None:
|
|
5884
|
+
hm = re.search(r"foundation-version:\s*(\d+)", s)
|
|
5885
|
+
if hm:
|
|
5886
|
+
fv = int(hm.group(1))
|
|
5887
|
+
return {"bullets": bullets, "last_settled_fv": settled, "fv": fv}
|
|
5888
|
+
|
|
5889
|
+
|
|
5643
5890
|
def _collect_carried_spec_deltas(root: Path) -> list[dict]:
|
|
5644
5891
|
"""Carried (deferred, non-lossy) SPEC deltas — the `deltas --carried` retrieval surface."""
|
|
5645
5892
|
return _collect_spec_deltas(root, "carried")
|
|
@@ -5745,12 +5992,136 @@ _KEY_DECISIONS_HEADING = "## Key Decisions" # the universal audit-trail sectio
|
|
|
5745
5992
|
_TABLE_SEP_RE = re.compile(r"\s*\|[-\s|]+\|\s*$")
|
|
5746
5993
|
|
|
5747
5994
|
# persona-self-improve: a `persona:<slug>` lesson routes into `.add/personas/<slug>.md` instead of a
|
|
5748
|
-
# foundation file. The section HINT picks the growable section; only these
|
|
5995
|
+
# foundation file. The section HINT picks the growable section; only these four are routable
|
|
5996
|
+
# (fold-persona-sections widened the pair to the 1.16.1 schema's behavioral sections).
|
|
5749
5997
|
_PERSONA_FOLD_SECTIONS = {
|
|
5750
5998
|
"critical-rule": "## Critical Rules",
|
|
5751
5999
|
"success-metric": "## Success Metrics",
|
|
6000
|
+
"anti-pattern": "## Anti-patterns",
|
|
6001
|
+
"ability": "## Abilities",
|
|
5752
6002
|
}
|
|
5753
6003
|
|
|
6004
|
+
# fold-glossary-deltas: a 6th pseudo-competency, `GLOSSARY`, folding a DONE task's own §3
|
|
6005
|
+
# `Glossary deltas: <term>: <definition>` line into `.add/GLOSSARY.md` — unstamped, matching
|
|
6006
|
+
# that file's own existing convention (provenance lives in the task's OWN stamped line instead).
|
|
6007
|
+
_GLOSSARY_LINE_RE = re.compile(r"^Glossary deltas:\s*(.*)$")
|
|
6008
|
+
_GLOSSARY_STAMP_RE = re.compile(r"\[" + re.escape(_FOLDED) + r" foundation-version \d+\]\s*$")
|
|
6009
|
+
_GLOSSARY_EMBEDDED_TERM_RE = re.compile(r"`[^`\n]{1,80}`\s*:") # a 2nd `term`: span -> multi-term, reject
|
|
6010
|
+
_TASK_ATTR_LINE_RE = re.compile(r"^[A-Z][A-Za-z0-9 /()'-]*:\s") # a new top-level "Key: value" line ends a wrap
|
|
6011
|
+
# (real corpus has hyphenated/apostrophe'd labels too, e.g. "Least-sure flag surfaced at freeze:",
|
|
6012
|
+
# "BIND-DON'T-BREAK:" — a narrower class silently over-joins the NEXT field into a Glossary delta)
|
|
6013
|
+
|
|
6014
|
+
|
|
6015
|
+
def _parse_glossary_delta(full_text: str) -> tuple[str, str] | None:
|
|
6016
|
+
"""Parse a joined Glossary-deltas value into (term, definition), PURE, or None when it does
|
|
6017
|
+
NOT cleanly parse as a single `<term>: <definition>` pair — "none", already resolved (the
|
|
6018
|
+
`[<resolved> foundation-version N]` stamp), no top-level colon, an unmatched backtick before
|
|
6019
|
+
the split (the `roster-portable-shape`-shaped hazard), or a second embedded `` `term`: ``
|
|
6020
|
+
span further in the definition (the `search-index`-shaped two-terms-in-one-line hazard) all
|
|
6021
|
+
reject."""
|
|
6022
|
+
text = full_text.strip()
|
|
6023
|
+
if not text or text.lower().startswith("none"):
|
|
6024
|
+
return None
|
|
6025
|
+
if _GLOSSARY_STAMP_RE.search(text):
|
|
6026
|
+
return None
|
|
6027
|
+
colon = text.find(":")
|
|
6028
|
+
if colon == -1:
|
|
6029
|
+
return None
|
|
6030
|
+
term_candidate, definition = text[:colon], text[colon + 1:].strip()
|
|
6031
|
+
if not definition or term_candidate.count("`") % 2 != 0:
|
|
6032
|
+
return None
|
|
6033
|
+
term = term_candidate.strip()
|
|
6034
|
+
if term.startswith("`") and term.endswith("`") and len(term) >= 2:
|
|
6035
|
+
term = term[1:-1].strip()
|
|
6036
|
+
if not term or _GLOSSARY_EMBEDDED_TERM_RE.search(definition):
|
|
6037
|
+
return None
|
|
6038
|
+
return term, definition
|
|
6039
|
+
|
|
6040
|
+
|
|
6041
|
+
def _fold_glossary_delta(text: str, version: int) -> str | None:
|
|
6042
|
+
"""Append the resolved-stamp (` [<resolved> foundation-version N]`) to the LAST physical line
|
|
6043
|
+
of the FIRST clean, not-yet-resolved `Glossary deltas:` entry in `text` (joining any wrapped
|
|
6044
|
+
continuation lines the same way `_collect_open_deltas` groups a multi-line delta). PURE — no
|
|
6045
|
+
IO. Returns None when there is no clean, not-yet-resolved candidate (the caller then refuses
|
|
6046
|
+
— validate-all-then-write)."""
|
|
6047
|
+
lines = text.splitlines(keepends=True)
|
|
6048
|
+
start = next((i for i, ln in enumerate(lines) if _GLOSSARY_LINE_RE.match(ln.rstrip("\n"))), None)
|
|
6049
|
+
if start is None:
|
|
6050
|
+
return None
|
|
6051
|
+
end = start
|
|
6052
|
+
unit = [_GLOSSARY_LINE_RE.match(lines[start].rstrip("\n")).group(1).strip()]
|
|
6053
|
+
for j in range(start + 1, len(lines)):
|
|
6054
|
+
stripped = lines[j].strip()
|
|
6055
|
+
if not stripped or _TASK_ATTR_LINE_RE.match(stripped) or stripped.startswith("<!--"):
|
|
6056
|
+
break
|
|
6057
|
+
unit.append(stripped)
|
|
6058
|
+
end = j
|
|
6059
|
+
if _parse_glossary_delta(" ".join(unit)) is None:
|
|
6060
|
+
return None
|
|
6061
|
+
last = lines[end]
|
|
6062
|
+
eol = last[len(last.rstrip("\n")):]
|
|
6063
|
+
lines[end] = last.rstrip("\n") + f" [{_FOLDED} foundation-version {version}]" + eol
|
|
6064
|
+
return "".join(lines)
|
|
6065
|
+
|
|
6066
|
+
|
|
6067
|
+
def _collect_glossary_deltas(root: Path) -> tuple[list[dict], int]:
|
|
6068
|
+
"""Scan every DONE task's own `Glossary deltas:` line for a clean, not-yet-resolved
|
|
6069
|
+
consolidation candidate.
|
|
6070
|
+
|
|
6071
|
+
Returns (candidates, skipped_count): candidates are {task, term, definition} dicts, READ-ONLY.
|
|
6072
|
+
skipped_count tallies DONE tasks whose line is non-"none", not already resolved-stamped, but
|
|
6073
|
+
does NOT cleanly parse — measured and reported (never silently invisible), per this
|
|
6074
|
+
mechanism's own freeze decision: "count and report skipped/unparseable lines"."""
|
|
6075
|
+
candidates: list[dict] = []
|
|
6076
|
+
skipped = 0
|
|
6077
|
+
tasks_dir = root / "tasks"
|
|
6078
|
+
if not tasks_dir.is_dir():
|
|
6079
|
+
return candidates, skipped
|
|
6080
|
+
for task_md in sorted(tasks_dir.glob("*/TASK.md")):
|
|
6081
|
+
slug = task_md.parent.name
|
|
6082
|
+
if _read_task_phase(root, slug) != "done":
|
|
6083
|
+
continue
|
|
6084
|
+
try:
|
|
6085
|
+
text = task_md.read_text(encoding="utf-8")
|
|
6086
|
+
except OSError:
|
|
6087
|
+
continue
|
|
6088
|
+
lines = text.splitlines()
|
|
6089
|
+
collected = None
|
|
6090
|
+
for i, line in enumerate(lines):
|
|
6091
|
+
m = _GLOSSARY_LINE_RE.match(line)
|
|
6092
|
+
if not m:
|
|
6093
|
+
continue
|
|
6094
|
+
unit = [m.group(1).strip()]
|
|
6095
|
+
for cont in lines[i + 1:]:
|
|
6096
|
+
stripped = cont.strip()
|
|
6097
|
+
if not stripped or _TASK_ATTR_LINE_RE.match(stripped) or stripped.startswith("<!--"):
|
|
6098
|
+
break
|
|
6099
|
+
unit.append(stripped)
|
|
6100
|
+
collected = " ".join(unit).strip()
|
|
6101
|
+
break
|
|
6102
|
+
if collected is None:
|
|
6103
|
+
continue
|
|
6104
|
+
if not collected or collected.lower().startswith("none") or _GLOSSARY_STAMP_RE.search(collected):
|
|
6105
|
+
continue # not a candidate — already handled or none
|
|
6106
|
+
parsed = _parse_glossary_delta(collected)
|
|
6107
|
+
if parsed is None:
|
|
6108
|
+
skipped += 1
|
|
6109
|
+
continue
|
|
6110
|
+
term, definition = parsed
|
|
6111
|
+
candidates.append({"task": slug, "term": term, "definition": definition})
|
|
6112
|
+
return candidates, skipped
|
|
6113
|
+
|
|
6114
|
+
|
|
6115
|
+
def _glossary_has_term(glossary_text: str, term: str) -> bool:
|
|
6116
|
+
"""Case-insensitive match of `term` against the text before GLOSSARY.md's own first ': '
|
|
6117
|
+
on each line — the existing-term dup check (`_FOLD_ROUTES`-style, GLOSSARY.md-specific)."""
|
|
6118
|
+
needle = term.strip().lower()
|
|
6119
|
+
for line in glossary_text.splitlines():
|
|
6120
|
+
idx = line.find(": ")
|
|
6121
|
+
if idx != -1 and line[:idx].strip().lower() == needle:
|
|
6122
|
+
return True
|
|
6123
|
+
return False
|
|
6124
|
+
|
|
5754
6125
|
|
|
5755
6126
|
def _fold_competency_delta(text: str, version: int, comps=None) -> str | None:
|
|
5756
6127
|
"""Flip EVERY open competency lesson in `text` to resolved + append ` [<resolved> foundation-version N]`.
|
|
@@ -5831,7 +6202,16 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
5831
6202
|
if want_task and it["task"] != want_task:
|
|
5832
6203
|
continue
|
|
5833
6204
|
selected.append({**it, "comp": comp})
|
|
5834
|
-
|
|
6205
|
+
|
|
6206
|
+
# fold-glossary-deltas: --comp GLOSSARY | (no --comp) ALSO folds glossary terms; a specific
|
|
6207
|
+
# competency (--comp DDD etc.) narrows AWAY from glossary, matching the frozen contract.
|
|
6208
|
+
glossary_selected: list[dict] = []
|
|
6209
|
+
glossary_skipped = 0
|
|
6210
|
+
if want_comp is None or want_comp == "GLOSSARY":
|
|
6211
|
+
glossary_candidates, glossary_skipped = _collect_glossary_deltas(root)
|
|
6212
|
+
glossary_selected = [it for it in glossary_candidates if not want_task or it["task"] == want_task]
|
|
6213
|
+
|
|
6214
|
+
if not selected and not glossary_selected:
|
|
5835
6215
|
scope = (f"task '{want_task}'" if want_task else "the project") + \
|
|
5836
6216
|
(f", competency {want_comp}" if want_comp else "")
|
|
5837
6217
|
_die(f"no_open_deltas: no open lesson to consolidate in {scope} (see `add.py deltas`)")
|
|
@@ -5878,15 +6258,45 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
5878
6258
|
"seed the persona first (setup) or fix the slug")
|
|
5879
6259
|
persona_paths[slug] = ppath
|
|
5880
6260
|
|
|
6261
|
+
# glossary routing — GLOSSARY.md must exist to fold into (this mechanism never creates it).
|
|
6262
|
+
glossary_path = root / "GLOSSARY.md"
|
|
6263
|
+
if glossary_selected and not glossary_path.exists():
|
|
6264
|
+
_die("missing_glossary_file: no .add/GLOSSARY.md to consolidate a glossary term into — "
|
|
6265
|
+
"create it first (or narrow away from --comp GLOSSARY) and re-run")
|
|
6266
|
+
|
|
5881
6267
|
# ── build EVERY edit in memory before writing anything ──────────────────────────────────────
|
|
5882
6268
|
comps_filter = {want_comp} if want_comp else None
|
|
6269
|
+
comp_task_set = {it["task"] for it in selected}
|
|
6270
|
+
glossary_task_set = {it["task"] for it in glossary_selected}
|
|
5883
6271
|
task_new: dict[str, str] = {}
|
|
5884
|
-
for slug in dict.fromkeys(it["task"] for it in selected)
|
|
6272
|
+
for slug in list(dict.fromkeys(it["task"] for it in selected)) + [
|
|
6273
|
+
s for s in dict.fromkeys(it["task"] for it in glossary_selected) if s not in comp_task_set]:
|
|
5885
6274
|
tmd = root / "tasks" / slug / "TASK.md"
|
|
5886
|
-
|
|
5887
|
-
if
|
|
5888
|
-
|
|
5889
|
-
|
|
6275
|
+
body = tmd.read_text(encoding="utf-8")
|
|
6276
|
+
if slug in comp_task_set:
|
|
6277
|
+
flipped = _fold_competency_delta(body, new_v, comps_filter)
|
|
6278
|
+
if flipped is None: # defensive: selected ⇒ ≥1 open here
|
|
6279
|
+
_die(f"no_open_deltas: task '{slug}' lost its open lesson mid-session")
|
|
6280
|
+
body = flipped
|
|
6281
|
+
if slug in glossary_task_set:
|
|
6282
|
+
gflipped = _fold_glossary_delta(body, new_v)
|
|
6283
|
+
if gflipped is None: # defensive: selected ⇒ ≥1 clean candidate here
|
|
6284
|
+
_die(f"no_open_deltas: task '{slug}' lost its glossary delta mid-session")
|
|
6285
|
+
body = gflipped
|
|
6286
|
+
task_new[slug] = body
|
|
6287
|
+
|
|
6288
|
+
# glossary transcription — append clean, UNSTAMPED "Term: definition" lines (GLOSSARY.md's own
|
|
6289
|
+
# existing convention); a case-insensitive-existing term is skipped, never duplicated.
|
|
6290
|
+
glossary_text = glossary_path.read_text(encoding="utf-8") if glossary_selected else ""
|
|
6291
|
+
glossary_new_lines: list[str] = []
|
|
6292
|
+
for it in glossary_selected:
|
|
6293
|
+
if _glossary_has_term(glossary_text, it["term"]):
|
|
6294
|
+
continue
|
|
6295
|
+
line = f"{it['term']}: {it['definition']}"
|
|
6296
|
+
if glossary_text and not glossary_text.endswith("\n"):
|
|
6297
|
+
glossary_text += "\n"
|
|
6298
|
+
glossary_text += line + "\n"
|
|
6299
|
+
glossary_new_lines.append(line)
|
|
5890
6300
|
|
|
5891
6301
|
def _bullet(it):
|
|
5892
6302
|
ev = f" (evidence: {it['evidence']})" if it["evidence"] else ""
|
|
@@ -5928,13 +6338,17 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
5928
6338
|
persona_new[slug] = after
|
|
5929
6339
|
|
|
5930
6340
|
counts = {c: sum(1 for it in selected if it["comp"] == c) for c in _COMPETENCY_ORDER}
|
|
5931
|
-
|
|
6341
|
+
count_parts = [f"{c} {counts[c]}" for c in _COMPETENCY_ORDER if counts[c]]
|
|
6342
|
+
if glossary_selected:
|
|
6343
|
+
count_parts.append(f"GLOSSARY {len(glossary_selected)}")
|
|
6344
|
+
count_str = " · ".join(count_parts)
|
|
5932
6345
|
scope = "all" if not (want_task or want_comp) else " ".join(
|
|
5933
6346
|
filter(None, [f"--task {want_task}" if want_task else "",
|
|
5934
6347
|
f"--comp {want_comp}" if want_comp else ""]))
|
|
6348
|
+
glossary_row_note = f"; {len(glossary_new_lines)} glossary term(s) added" if glossary_selected else ""
|
|
5935
6349
|
row = (f"| {date.today().isoformat()} | {_FOLD_VERB} {scope} → foundation-version {new_v} "
|
|
5936
6350
|
f"({count_str}) | consolidate captured OBSERVE lessons into the versioned foundation "
|
|
5937
|
-
f"| {len(selected)} lessons open→{_FOLDED}; +{len(selected)} routed bullets; {prev_v}→{new_v} |")
|
|
6351
|
+
f"| {len(selected)} lessons open→{_FOLDED}; +{len(selected)} routed bullets{glossary_row_note}; {prev_v}→{new_v} |")
|
|
5938
6352
|
proj_text = _prepend_key_decision_row(proj_text, row)
|
|
5939
6353
|
proj_text = re.sub(r"foundation-version:\s*\d+", f"foundation-version: {new_v}", proj_text, count=1)
|
|
5940
6354
|
|
|
@@ -5953,11 +6367,22 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
5953
6367
|
writes.append((persona_paths[slug], body))
|
|
5954
6368
|
if persona_new:
|
|
5955
6369
|
touched.append(f"{len(persona_new)} persona")
|
|
6370
|
+
if glossary_selected and glossary_new_lines:
|
|
6371
|
+
writes.append((glossary_path, glossary_text))
|
|
6372
|
+
touched.append("GLOSSARY.md")
|
|
5956
6373
|
_atomic_write_many(writes)
|
|
5957
6374
|
|
|
5958
|
-
print(f"{_FOLDED} {len(selected)}
|
|
5959
|
-
|
|
6375
|
+
print(f"{_FOLDED} {len(selected)} lesson(s) -> foundation-version {new_v}")
|
|
6376
|
+
if count_str:
|
|
6377
|
+
print(f" {count_str}")
|
|
5960
6378
|
print(f" bumped PROJECT.md {prev_v} -> {new_v}")
|
|
6379
|
+
if glossary_selected:
|
|
6380
|
+
added = len(glossary_new_lines)
|
|
6381
|
+
dup = len(glossary_selected) - added
|
|
6382
|
+
dup_note = f"; {dup} duplicate term(s) skipped" if dup else ""
|
|
6383
|
+
print(f" glossary: {added} term(s) added{dup_note}")
|
|
6384
|
+
if glossary_skipped:
|
|
6385
|
+
print(f" glossary: {glossary_skipped} line(s) unparseable, skipped")
|
|
5961
6386
|
print(f" files: {', '.join(touched)}")
|
|
5962
6387
|
print(_next_footer(root, state))
|
|
5963
6388
|
|
|
@@ -6045,7 +6470,9 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
|
|
|
6045
6470
|
if marked:
|
|
6046
6471
|
f(slug, "risk_accepted_security",
|
|
6047
6472
|
"a waiver on a marked security item is never allowed")
|
|
6048
|
-
|
|
6473
|
+
# case-insensitive: human-signed records write Owner:/Ticket:/Expires: —
|
|
6474
|
+
# the seam is the field EXISTING, casing is presentation (waiver-field-case)
|
|
6475
|
+
if not all(re.search(rf"{k}:\s*(?!<)\S", s6, re.IGNORECASE)
|
|
6049
6476
|
for k in ("owner", "ticket", "expires")):
|
|
6050
6477
|
f(slug, "waiver_incomplete",
|
|
6051
6478
|
"RISK-ACCEPTED needs owner · ticket · expires")
|
|
@@ -6188,7 +6615,12 @@ def _guarantee_lint_notices(root: Path, state: dict) -> dict:
|
|
|
6188
6615
|
"advisor_residue_on_mechanical_mis_tier": advisor_residue_on_mechanical_mis_tier,
|
|
6189
6616
|
"rule_coverage_gap": rule_coverage_gap,
|
|
6190
6617
|
"contract_report_unrecorded": contract_report_unrecorded,
|
|
6191
|
-
"verify_report_unrecorded": verify_report_unrecorded
|
|
6618
|
+
"verify_report_unrecorded": verify_report_unrecorded,
|
|
6619
|
+
# DERIVED rollup (verify-record-rollup): one summary list over the four §6-record
|
|
6620
|
+
# lists — additive; the per-code lists above stay the source of truth.
|
|
6621
|
+
"verify_record_incomplete": sorted(
|
|
6622
|
+
set(shallow) | set(refute_unrecorded)
|
|
6623
|
+
| set(advisor_verdict_unrecorded) | set(verify_report_unrecorded))}
|
|
6192
6624
|
|
|
6193
6625
|
|
|
6194
6626
|
def cmd_audit(args: argparse.Namespace) -> None:
|
|
@@ -6251,6 +6683,11 @@ def cmd_audit(args: argparse.Namespace) -> None:
|
|
|
6251
6683
|
vr = glints["verify_report_unrecorded"]
|
|
6252
6684
|
print(f"audit: verify_report_unrecorded — {len(vr)} task(s): {', '.join(vr)} "
|
|
6253
6685
|
f"— record the rendered gate report (§6 `Reported: yes`); a spot-audit is the backstop")
|
|
6686
|
+
if glints["verify_record_incomplete"]:
|
|
6687
|
+
vi = glints["verify_record_incomplete"]
|
|
6688
|
+
print(f"audit: verify_record_incomplete — {len(vi)} task(s): {', '.join(vi)} "
|
|
6689
|
+
f"— fill the §6 verify record (rollup of the four detail lines above: "
|
|
6690
|
+
f"deep-check · refute-read · 3-lens · gate-report)")
|
|
6254
6691
|
if not findings and not skips and not glints["shallow"] and not glints["risk_unset"] \
|
|
6255
6692
|
and not glints["refute_unrecorded"] and not glints["advisor_verdict_unrecorded"] \
|
|
6256
6693
|
and not glints["sensitivity_unset"] \
|
|
@@ -6580,6 +7017,13 @@ def cmd_release_report(args: argparse.Namespace) -> None:
|
|
|
6580
7017
|
L.append(f" - {c['milestone']}: {c['retro'] or '(no RETRO record)'} "
|
|
6581
7018
|
f"({c['carried_deltas']} carried · {len(c['key_decisions'])} key decision(s))")
|
|
6582
7019
|
L.append("")
|
|
7020
|
+
_carried = _collect_carried_spec_deltas(root)
|
|
7021
|
+
L.append(f"Carried ({len(_carried)}) — deferred spec deltas riding across releases (re-triage each cut):")
|
|
7022
|
+
for cd in _carried[:10]:
|
|
7023
|
+
L.append(f" - [{cd['task']}] {cd['text'][:100]}")
|
|
7024
|
+
if len(_carried) > 10:
|
|
7025
|
+
L.append(f" … and {len(_carried) - 10} more — add.py deltas --carried")
|
|
7026
|
+
L.append("")
|
|
6583
7027
|
L.append(f"Waivers ({s['waivers']}) — open RISK-ACCEPTED riding into the cut, soonest expiry first:")
|
|
6584
7028
|
for w in d["waivers"]:
|
|
6585
7029
|
L.append(f" - {w['slug']}: {w['owner']} · {w['ticket']} · expires {w['expires']}")
|
|
@@ -7056,11 +7500,24 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
7056
7500
|
pa.add_argument("--skip-freeze", action="store_true",
|
|
7057
7501
|
help="cross tests->build on a DRAFT §3, recording an auditable freeze_skipped "
|
|
7058
7502
|
"marker (the universal freeze gate's only bypass; never auto-freezes §3)")
|
|
7503
|
+
pa.add_argument("--to", default=None,
|
|
7504
|
+
help="fast-forward the bundle bookkeeping to this phase (at most `tests`); "
|
|
7505
|
+
"every crossing guard still runs per step")
|
|
7059
7506
|
pa.set_defaults(func=cmd_advance, _opt_positionals=("slug",))
|
|
7060
7507
|
|
|
7508
|
+
prx = sub.add_parser("re-cross", help="re-arm the tests->build snapshots after a "
|
|
7509
|
+
"HUMAN-APPROVED post-freeze test change")
|
|
7510
|
+
prx.add_argument("slug", nargs="?", default=None)
|
|
7511
|
+
prx.add_argument("--by", default="",
|
|
7512
|
+
help="the human approver (required — a post-freeze test change is human-approved)")
|
|
7513
|
+
prx.set_defaults(func=cmd_recross, _opt_positionals=("slug",))
|
|
7514
|
+
|
|
7061
7515
|
pg = sub.add_parser("gate", help="record a verify gate outcome")
|
|
7062
|
-
pg.add_argument("outcome",
|
|
7063
|
-
pg.add_argument("slug", nargs="?", default=None)
|
|
7516
|
+
pg.add_argument("outcome", nargs="?", default=None) # validated in cmd_gate (gate-explain
|
|
7517
|
+
pg.add_argument("slug", nargs="?", default=None) # made it optional under --explain)
|
|
7518
|
+
pg.add_argument("--explain", action="store_true",
|
|
7519
|
+
help="READ-ONLY: print the composed auto-pass/escalation path for the task "
|
|
7520
|
+
"(autonomy · risk · sensitivity · advisor verdict), then exit")
|
|
7064
7521
|
pg.add_argument("--owner", help="RISK-ACCEPTED waiver: accountable owner")
|
|
7065
7522
|
pg.add_argument("--ticket", help="RISK-ACCEPTED waiver: tracking ticket/link")
|
|
7066
7523
|
pg.add_argument("--expires", help="RISK-ACCEPTED waiver: expiry date")
|
|
@@ -7081,6 +7538,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
7081
7538
|
pst.add_argument("posture", nargs="?", default=None, help="set: <parallel|sequential>")
|
|
7082
7539
|
pst.set_defaults(func=cmd_streams, _opt_positionals=("posture",))
|
|
7083
7540
|
|
|
7541
|
+
pwt = sub.add_parser("worktree-prep",
|
|
7542
|
+
help="cut a worker worktree at HEAD + materialize gitignored "
|
|
7543
|
+
".add/tooling + .add/docs + echo the fork base (streams.md recipe)")
|
|
7544
|
+
pwt.add_argument("slug", nargs="?", default=None, help="task the worktree is for (default: active)")
|
|
7545
|
+
pwt.add_argument("--dir", default=None, help="worktree path (default: ../<project>-wt-<slug>)")
|
|
7546
|
+
pwt.set_defaults(func=cmd_worktree_prep)
|
|
7547
|
+
|
|
7084
7548
|
pto = sub.add_parser("todo", help="capture / list / close a lightweight backlog todo (jot an idea)")
|
|
7085
7549
|
pto.add_argument("text", nargs="?", default=None,
|
|
7086
7550
|
help="todo text to capture; omit to LIST open todos")
|
|
@@ -7208,7 +7672,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
7208
7672
|
help="record one retrospective consolidation of open lessons into the "
|
|
7209
7673
|
"versioned foundation (stamp + route + version-bump, atomic)")
|
|
7210
7674
|
pfo.add_argument("--task", help="narrow to one task's open lessons")
|
|
7211
|
-
pfo.add_argument("--comp", choices=_COMPETENCY_ORDER,
|
|
7675
|
+
pfo.add_argument("--comp", choices=[*_COMPETENCY_ORDER, "GLOSSARY"],
|
|
7676
|
+
help="narrow to one competency's open lessons, or GLOSSARY for glossary-only")
|
|
7212
7677
|
pfo.set_defaults(func=cmd_fold)
|
|
7213
7678
|
|
|
7214
7679
|
pgr = sub.add_parser("graduation-report",
|