@pilotspace/add 1.16.0 → 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 +101 -0
- package/README.md +2 -2
- 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 +4 -4
- 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 +519 -37
- package/tooling/add_engine/constants.py +21 -0
- package/tooling/add_engine/io_state.py +27 -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 +24 -2
package/tooling/add.py
CHANGED
|
@@ -100,6 +100,8 @@ from add_engine.io_state import ( # re-exported as module globals: callers use
|
|
|
100
100
|
_CONFLICT_MARKER_RE, # conflict-marker re
|
|
101
101
|
_load_state_for_json, # --json state loader
|
|
102
102
|
_md5_text, _md5_file, # md5 hashing helpers
|
|
103
|
+
_personas_unseeded, # persona-seed-nudge predicate
|
|
104
|
+
_real_persona_slugs, # persona-fit-nudge slug listing
|
|
103
105
|
)
|
|
104
106
|
|
|
105
107
|
|
|
@@ -279,10 +281,14 @@ _BLANK_RUN_RE = re.compile(r"\n{3,}")
|
|
|
279
281
|
def _strip_live_scaffold(text: str) -> str:
|
|
280
282
|
"""Remove `<!-- … -->` instruction comments from a TASK.md — fences untouched, idempotent.
|
|
281
283
|
|
|
282
|
-
Splits on fenced code blocks
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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)
|
|
286
292
|
for i in range(0, len(segs), 2): # even indices = OUTSIDE any fence
|
|
287
293
|
s = _HTML_COMMENT_RE.sub("", segs[i])
|
|
288
294
|
s = _TRAILING_WS_RE.sub("", s)
|
|
@@ -394,6 +400,28 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
|
|
|
394
400
|
_atomic_write(f, new)
|
|
395
401
|
|
|
396
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
|
+
|
|
397
425
|
def _stamp_adr_record(root: Path, state: dict, slug: str) -> None:
|
|
398
426
|
"""Write-back (adr-at-observe): HARVEST a §7 `### Decisions (ADR)` block from the actor-stamps
|
|
399
427
|
ALREADY in the task — §1 framing (AI) · §3 freeze (human) · §5 strategy-actually-used (AI) · §6
|
|
@@ -426,11 +454,11 @@ def _stamp_adr_record(root: Path, state: dict, slug: str) -> None:
|
|
|
426
454
|
|
|
427
455
|
def _framing(): # §1 -> [AI]: chosen + rejected
|
|
428
456
|
try:
|
|
429
|
-
|
|
430
|
-
if
|
|
457
|
+
val = _capture_wrapped("Framings weighed", bodies.get(1, ""))
|
|
458
|
+
if val is None:
|
|
431
459
|
return UN, ""
|
|
432
460
|
chosen, rejected = UN, []
|
|
433
|
-
for p in (s.strip() for s in
|
|
461
|
+
for p in (s.strip() for s in val.split("·") if s.strip()):
|
|
434
462
|
cm = re.match(r"(.*?)\s*\(chosen\b.*\)\s*$", p) # "(chosen)" OR "(chosen — rationale)"
|
|
435
463
|
if cm:
|
|
436
464
|
chosen = cm.group(1).strip() or UN
|
|
@@ -458,12 +486,11 @@ def _stamp_adr_record(root: Path, state: dict, slug: str) -> None:
|
|
|
458
486
|
|
|
459
487
|
def _strategy(): # §5 -> [AI]: the value, default "as planned"
|
|
460
488
|
try:
|
|
461
|
-
|
|
462
|
-
if
|
|
489
|
+
val = _capture_wrapped("Strategy actually used", bodies.get(5, ""))
|
|
490
|
+
if val:
|
|
463
491
|
# UNFILLED is the "<fill at …>" template token; a real value may legitimately
|
|
464
492
|
# contain "<" (quoting `<tag>`, "x < y") and must NOT degrade to the default
|
|
465
|
-
|
|
466
|
-
if val and not val.startswith("<fill"):
|
|
493
|
+
if not val.startswith("<fill"):
|
|
467
494
|
return val
|
|
468
495
|
except Exception:
|
|
469
496
|
pass
|
|
@@ -593,6 +620,26 @@ def cmd_sync_guidelines(args: argparse.Namespace) -> None:
|
|
|
593
620
|
print(f"{action:>9} {name}")
|
|
594
621
|
|
|
595
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
|
+
|
|
596
643
|
def cmd_new_task(args: argparse.Namespace) -> None:
|
|
597
644
|
root = _require_root()
|
|
598
645
|
state = load_state(root)
|
|
@@ -717,10 +764,31 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
717
764
|
if from_delta:
|
|
718
765
|
print(f"seeded from '{from_delta}' — its open SPEC delta is now "
|
|
719
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)
|
|
720
771
|
print("active task set. phase: ground. Gather the real codebase (section 0 GROUND).")
|
|
721
772
|
print(_next_footer(root, state)) # converges the old "then: add.py advance" hint
|
|
722
773
|
|
|
723
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
|
+
|
|
724
792
|
def cmd_drop_delta(args: argparse.Namespace) -> None:
|
|
725
793
|
"""DISMISS a task's first open SPEC delta — `[SPEC · open]` -> `[SPEC · dropped]`.
|
|
726
794
|
|
|
@@ -729,8 +797,7 @@ def cmd_drop_delta(args: argparse.Namespace) -> None:
|
|
|
729
797
|
text + `(evidence: …)` are byte-preserved by the pure `_resolve_spec_delta`."""
|
|
730
798
|
root = _require_root()
|
|
731
799
|
state = load_state(root)
|
|
732
|
-
slug =
|
|
733
|
-
task_md = root / "tasks" / slug / "TASK.md"
|
|
800
|
+
slug, task_md, _arch = _delta_task_md(root, state, args.slug)
|
|
734
801
|
text = task_md.read_text(encoding="utf-8")
|
|
735
802
|
match = getattr(args, "match", None)
|
|
736
803
|
status, idx, _disp = _select_spec_delta(text, match)
|
|
@@ -743,7 +810,8 @@ def cmd_drop_delta(args: argparse.Namespace) -> None:
|
|
|
743
810
|
f"'{slug}' — narrow it")
|
|
744
811
|
new_text = _resolve_spec_delta(text, "dropped", line_index=idx)
|
|
745
812
|
_atomic_write(task_md, new_text)
|
|
746
|
-
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]")
|
|
747
815
|
print(_next_footer(root, state))
|
|
748
816
|
|
|
749
817
|
|
|
@@ -763,12 +831,12 @@ def cmd_carry_delta(args: argparse.Namespace) -> None:
|
|
|
763
831
|
every open delta in the task; `--match` targets the unique one. Validate-then-write."""
|
|
764
832
|
root = _require_root()
|
|
765
833
|
state = load_state(root)
|
|
766
|
-
slug =
|
|
834
|
+
slug, task_md, _arch = _delta_task_md(root, state, args.slug)
|
|
835
|
+
_arch_note = " (archived — on-disk record)" if _arch else ""
|
|
767
836
|
reason = (getattr(args, "reason", None) or "").strip()
|
|
768
837
|
if not reason:
|
|
769
838
|
_die("carry_reason_required: carry-delta needs a --reason — a deferral must say why "
|
|
770
839
|
"(it is the breadcrumb a future loop reads)")
|
|
771
|
-
task_md = root / "tasks" / slug / "TASK.md"
|
|
772
840
|
text = task_md.read_text(encoding="utf-8")
|
|
773
841
|
stamp = f"[carried: {reason}]"
|
|
774
842
|
if getattr(args, "all", False):
|
|
@@ -778,7 +846,7 @@ def cmd_carry_delta(args: argparse.Namespace) -> None:
|
|
|
778
846
|
for idx in idxs: # indices stay valid (flip is in-place)
|
|
779
847
|
text = _resolve_spec_delta(text, "carried", line_index=idx, stamp=stamp)
|
|
780
848
|
_atomic_write(task_md, text)
|
|
781
|
-
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})")
|
|
782
850
|
print(_next_footer(root, state))
|
|
783
851
|
return
|
|
784
852
|
match = getattr(args, "match", None)
|
|
@@ -792,7 +860,7 @@ def cmd_carry_delta(args: argparse.Namespace) -> None:
|
|
|
792
860
|
f"'{slug}' — narrow it, or use --all")
|
|
793
861
|
new_text = _resolve_spec_delta(text, "carried", line_index=idx, stamp=stamp)
|
|
794
862
|
_atomic_write(task_md, new_text)
|
|
795
|
-
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} -> "
|
|
796
864
|
f"[SPEC · carried] ({reason})")
|
|
797
865
|
print(_next_footer(root, state))
|
|
798
866
|
|
|
@@ -804,8 +872,7 @@ def cmd_reopen_delta(args: argparse.Namespace) -> None:
|
|
|
804
872
|
targets the unique carried delta. Validate-then-write; refuse `no_carried_spec_delta`."""
|
|
805
873
|
root = _require_root()
|
|
806
874
|
state = load_state(root)
|
|
807
|
-
slug =
|
|
808
|
-
task_md = root / "tasks" / slug / "TASK.md"
|
|
875
|
+
slug, task_md, _arch = _delta_task_md(root, state, args.slug)
|
|
809
876
|
text = task_md.read_text(encoding="utf-8")
|
|
810
877
|
match = getattr(args, "match", None)
|
|
811
878
|
status, idx, _disp = _select_spec_delta(text, match, status="carried")
|
|
@@ -820,7 +887,8 @@ def cmd_reopen_delta(args: argparse.Namespace) -> None:
|
|
|
820
887
|
eol = lines[idx][len(lines[idx].rstrip("\n")):]
|
|
821
888
|
lines[idx] = re.sub(r"\s*\[carried:[^\]]*\]\s*$", "", lines[idx].rstrip("\n")) + eol
|
|
822
889
|
_atomic_write(task_md, "".join(lines))
|
|
823
|
-
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]")
|
|
824
892
|
print(_next_footer(root, state))
|
|
825
893
|
|
|
826
894
|
|
|
@@ -1032,6 +1100,35 @@ def cmd_phase(args: argparse.Namespace) -> None:
|
|
|
1032
1100
|
print(_next_footer(root, state))
|
|
1033
1101
|
|
|
1034
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
|
+
|
|
1035
1132
|
def cmd_advance(args: argparse.Namespace) -> None:
|
|
1036
1133
|
root = _require_root()
|
|
1037
1134
|
state = load_state(root)
|
|
@@ -1040,6 +1137,18 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
1040
1137
|
idx = PHASES.index(cur)
|
|
1041
1138
|
if idx >= len(PHASES) - 1:
|
|
1042
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}")
|
|
1043
1152
|
nxt = PHASES[idx + 1]
|
|
1044
1153
|
# build-boundary gate: pre-lock the front (specify..tests) is allowed, but crossing
|
|
1045
1154
|
# into build/verify/observe/done is refused until `add.py lock`.
|
|
@@ -1119,6 +1228,11 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
1119
1228
|
print(" note: record the lessons this loop taught the foundation in §7 "
|
|
1120
1229
|
"OBSERVE, then update PROJECT.md when ready:")
|
|
1121
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
|
|
1122
1236
|
print(_next_footer(root, state))
|
|
1123
1237
|
|
|
1124
1238
|
|
|
@@ -1302,9 +1416,47 @@ def _driver_marker(stop: bool) -> str:
|
|
|
1302
1416
|
return " [human gate]" if stop else " [you drive]"
|
|
1303
1417
|
|
|
1304
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
|
+
|
|
1305
1452
|
def cmd_gate(args: argparse.Namespace) -> None:
|
|
1306
1453
|
root = _require_root()
|
|
1307
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
|
|
1308
1460
|
slug = _resolve_task(state, args.slug)
|
|
1309
1461
|
# build-boundary gate: no verdict may be recorded before the setup is locked.
|
|
1310
1462
|
if not _setup_locked(state):
|
|
@@ -1492,6 +1644,55 @@ def cmd_autonomy(args: argparse.Namespace) -> None:
|
|
|
1492
1644
|
_print_autonomy(root, state, slug)
|
|
1493
1645
|
|
|
1494
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
|
+
|
|
1495
1696
|
def cmd_streams(args: argparse.Namespace) -> None:
|
|
1496
1697
|
"""show / set the project STREAMS posture — the parallel-vs-sequential half of the run mode
|
|
1497
1698
|
(persist-run-mode). Project-scoped (parallelism is ACROSS tasks, so there is no per-task posture)
|
|
@@ -1886,6 +2087,11 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1886
2087
|
# Existence-only: no open/parse, so the pointer adds no IO failure path (a non-file is no voice).
|
|
1887
2088
|
if (root / "SOUL.md").exists():
|
|
1888
2089
|
print("voice : .add/SOUL.md (how I sound & what keeps your trust — read each session)")
|
|
2090
|
+
# persona pointer (persona-seed-nudge v2): project-wide, read every session like context/voice
|
|
2091
|
+
# above — fires until >=1 REAL persona is seeded, self-clears once one lands. Advisory only;
|
|
2092
|
+
# never gates, never touches the --json branch (human-readable orientation surface only).
|
|
2093
|
+
if _personas_unseeded(root):
|
|
2094
|
+
print(f"persona : {PERSONA_HINT}")
|
|
1889
2095
|
# wave resume hint — a live ledger outranks memory (streams.md "Wave ledger").
|
|
1890
2096
|
# Existence-only: no open/read/parse, so the hint adds no IO failure path; a
|
|
1891
2097
|
# non-file at the path is not a ledger. One line PER live ledger — more than
|
|
@@ -1939,6 +2145,20 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1939
2145
|
if _loose:
|
|
1940
2146
|
print(f" → releasable: {len(_loose)} loose task(s) since last release")
|
|
1941
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
|
+
|
|
1942
2162
|
# fast-lane marker (fast-new-task-flag): tag an ACTIVE fast task so the lane is visible at a
|
|
1943
2163
|
# glance. Presentation-only, existence-gated — a plain/absent active task is byte-unchanged.
|
|
1944
2164
|
_fast_mark = " · fast" if active and tasks.get(active, {}).get("fast") is True else ""
|
|
@@ -2930,6 +3150,11 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2930
3150
|
"persona_schema_incomplete: missing " + ", ".join(missing)))
|
|
2931
3151
|
else:
|
|
2932
3152
|
infos.append((f"persona '{slug}'", "schema-conformant"))
|
|
3153
|
+
# persona-seed-nudge: surface the SAME "no real persona" gap `new-milestone` nudges on, so
|
|
3154
|
+
# it is also visible on a plain `check`/`status` sweep — an INFO affirmation-of-absence,
|
|
3155
|
+
# never a WARN (measure-not-block; a project with no personas behaves exactly as before).
|
|
3156
|
+
if _personas_unseeded(root):
|
|
3157
|
+
infos.append(("personas", f"unseeded — {PERSONA_HINT}"))
|
|
2933
3158
|
|
|
2934
3159
|
# drift: a done milestone must have no unfinished tasks
|
|
2935
3160
|
for mslug, m in milestones.items():
|
|
@@ -3431,6 +3656,19 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
|
|
|
3431
3656
|
else:
|
|
3432
3657
|
print("active milestone set." + ("" if not await_confirm else
|
|
3433
3658
|
" (unconfirmed — show the MILESTONE.md, then: add.py milestone-confirm " + slug + ")"))
|
|
3659
|
+
# persona-seed-nudge: a non-blocking hint (never a gate) when this project has no
|
|
3660
|
+
# REAL project-fit persona yet — points at the cross-cutting selection/drafting
|
|
3661
|
+
# service rather than inventing a second mechanism. Fires only on the ACTIVE arm
|
|
3662
|
+
# (a queued milestone isn't yet in flight, so the nudge would be premature there).
|
|
3663
|
+
if _personas_unseeded(root):
|
|
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)}")
|
|
3434
3672
|
print(_next_footer(root, state)) # converges the old "Decompose it into tasks: …" hint
|
|
3435
3673
|
|
|
3436
3674
|
|
|
@@ -5623,6 +5861,32 @@ def _collect_open_spec_deltas(root: Path) -> list[dict]:
|
|
|
5623
5861
|
return _collect_spec_deltas(root, "open")
|
|
5624
5862
|
|
|
5625
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
|
+
|
|
5626
5890
|
def _collect_carried_spec_deltas(root: Path) -> list[dict]:
|
|
5627
5891
|
"""Carried (deferred, non-lossy) SPEC deltas — the `deltas --carried` retrieval surface."""
|
|
5628
5892
|
return _collect_spec_deltas(root, "carried")
|
|
@@ -5728,12 +5992,136 @@ _KEY_DECISIONS_HEADING = "## Key Decisions" # the universal audit-trail sectio
|
|
|
5728
5992
|
_TABLE_SEP_RE = re.compile(r"\s*\|[-\s|]+\|\s*$")
|
|
5729
5993
|
|
|
5730
5994
|
# persona-self-improve: a `persona:<slug>` lesson routes into `.add/personas/<slug>.md` instead of a
|
|
5731
|
-
# 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).
|
|
5732
5997
|
_PERSONA_FOLD_SECTIONS = {
|
|
5733
5998
|
"critical-rule": "## Critical Rules",
|
|
5734
5999
|
"success-metric": "## Success Metrics",
|
|
6000
|
+
"anti-pattern": "## Anti-patterns",
|
|
6001
|
+
"ability": "## Abilities",
|
|
5735
6002
|
}
|
|
5736
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
|
+
|
|
5737
6125
|
|
|
5738
6126
|
def _fold_competency_delta(text: str, version: int, comps=None) -> str | None:
|
|
5739
6127
|
"""Flip EVERY open competency lesson in `text` to resolved + append ` [<resolved> foundation-version N]`.
|
|
@@ -5814,7 +6202,16 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
5814
6202
|
if want_task and it["task"] != want_task:
|
|
5815
6203
|
continue
|
|
5816
6204
|
selected.append({**it, "comp": comp})
|
|
5817
|
-
|
|
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:
|
|
5818
6215
|
scope = (f"task '{want_task}'" if want_task else "the project") + \
|
|
5819
6216
|
(f", competency {want_comp}" if want_comp else "")
|
|
5820
6217
|
_die(f"no_open_deltas: no open lesson to consolidate in {scope} (see `add.py deltas`)")
|
|
@@ -5861,15 +6258,45 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
5861
6258
|
"seed the persona first (setup) or fix the slug")
|
|
5862
6259
|
persona_paths[slug] = ppath
|
|
5863
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
|
+
|
|
5864
6267
|
# ── build EVERY edit in memory before writing anything ──────────────────────────────────────
|
|
5865
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}
|
|
5866
6271
|
task_new: dict[str, str] = {}
|
|
5867
|
-
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]:
|
|
5868
6274
|
tmd = root / "tasks" / slug / "TASK.md"
|
|
5869
|
-
|
|
5870
|
-
if
|
|
5871
|
-
|
|
5872
|
-
|
|
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)
|
|
5873
6300
|
|
|
5874
6301
|
def _bullet(it):
|
|
5875
6302
|
ev = f" (evidence: {it['evidence']})" if it["evidence"] else ""
|
|
@@ -5911,13 +6338,17 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
5911
6338
|
persona_new[slug] = after
|
|
5912
6339
|
|
|
5913
6340
|
counts = {c: sum(1 for it in selected if it["comp"] == c) for c in _COMPETENCY_ORDER}
|
|
5914
|
-
|
|
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)
|
|
5915
6345
|
scope = "all" if not (want_task or want_comp) else " ".join(
|
|
5916
6346
|
filter(None, [f"--task {want_task}" if want_task else "",
|
|
5917
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 ""
|
|
5918
6349
|
row = (f"| {date.today().isoformat()} | {_FOLD_VERB} {scope} → foundation-version {new_v} "
|
|
5919
6350
|
f"({count_str}) | consolidate captured OBSERVE lessons into the versioned foundation "
|
|
5920
|
-
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} |")
|
|
5921
6352
|
proj_text = _prepend_key_decision_row(proj_text, row)
|
|
5922
6353
|
proj_text = re.sub(r"foundation-version:\s*\d+", f"foundation-version: {new_v}", proj_text, count=1)
|
|
5923
6354
|
|
|
@@ -5936,11 +6367,22 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
5936
6367
|
writes.append((persona_paths[slug], body))
|
|
5937
6368
|
if persona_new:
|
|
5938
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")
|
|
5939
6373
|
_atomic_write_many(writes)
|
|
5940
6374
|
|
|
5941
|
-
print(f"{_FOLDED} {len(selected)}
|
|
5942
|
-
|
|
6375
|
+
print(f"{_FOLDED} {len(selected)} lesson(s) -> foundation-version {new_v}")
|
|
6376
|
+
if count_str:
|
|
6377
|
+
print(f" {count_str}")
|
|
5943
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")
|
|
5944
6386
|
print(f" files: {', '.join(touched)}")
|
|
5945
6387
|
print(_next_footer(root, state))
|
|
5946
6388
|
|
|
@@ -6028,7 +6470,9 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
|
|
|
6028
6470
|
if marked:
|
|
6029
6471
|
f(slug, "risk_accepted_security",
|
|
6030
6472
|
"a waiver on a marked security item is never allowed")
|
|
6031
|
-
|
|
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)
|
|
6032
6476
|
for k in ("owner", "ticket", "expires")):
|
|
6033
6477
|
f(slug, "waiver_incomplete",
|
|
6034
6478
|
"RISK-ACCEPTED needs owner · ticket · expires")
|
|
@@ -6171,7 +6615,12 @@ def _guarantee_lint_notices(root: Path, state: dict) -> dict:
|
|
|
6171
6615
|
"advisor_residue_on_mechanical_mis_tier": advisor_residue_on_mechanical_mis_tier,
|
|
6172
6616
|
"rule_coverage_gap": rule_coverage_gap,
|
|
6173
6617
|
"contract_report_unrecorded": contract_report_unrecorded,
|
|
6174
|
-
"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))}
|
|
6175
6624
|
|
|
6176
6625
|
|
|
6177
6626
|
def cmd_audit(args: argparse.Namespace) -> None:
|
|
@@ -6234,6 +6683,11 @@ def cmd_audit(args: argparse.Namespace) -> None:
|
|
|
6234
6683
|
vr = glints["verify_report_unrecorded"]
|
|
6235
6684
|
print(f"audit: verify_report_unrecorded — {len(vr)} task(s): {', '.join(vr)} "
|
|
6236
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)")
|
|
6237
6691
|
if not findings and not skips and not glints["shallow"] and not glints["risk_unset"] \
|
|
6238
6692
|
and not glints["refute_unrecorded"] and not glints["advisor_verdict_unrecorded"] \
|
|
6239
6693
|
and not glints["sensitivity_unset"] \
|
|
@@ -6563,6 +7017,13 @@ def cmd_release_report(args: argparse.Namespace) -> None:
|
|
|
6563
7017
|
L.append(f" - {c['milestone']}: {c['retro'] or '(no RETRO record)'} "
|
|
6564
7018
|
f"({c['carried_deltas']} carried · {len(c['key_decisions'])} key decision(s))")
|
|
6565
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("")
|
|
6566
7027
|
L.append(f"Waivers ({s['waivers']}) — open RISK-ACCEPTED riding into the cut, soonest expiry first:")
|
|
6567
7028
|
for w in d["waivers"]:
|
|
6568
7029
|
L.append(f" - {w['slug']}: {w['owner']} · {w['ticket']} · expires {w['expires']}")
|
|
@@ -7039,11 +7500,24 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
7039
7500
|
pa.add_argument("--skip-freeze", action="store_true",
|
|
7040
7501
|
help="cross tests->build on a DRAFT §3, recording an auditable freeze_skipped "
|
|
7041
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")
|
|
7042
7506
|
pa.set_defaults(func=cmd_advance, _opt_positionals=("slug",))
|
|
7043
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
|
+
|
|
7044
7515
|
pg = sub.add_parser("gate", help="record a verify gate outcome")
|
|
7045
|
-
pg.add_argument("outcome",
|
|
7046
|
-
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")
|
|
7047
7521
|
pg.add_argument("--owner", help="RISK-ACCEPTED waiver: accountable owner")
|
|
7048
7522
|
pg.add_argument("--ticket", help="RISK-ACCEPTED waiver: tracking ticket/link")
|
|
7049
7523
|
pg.add_argument("--expires", help="RISK-ACCEPTED waiver: expiry date")
|
|
@@ -7064,6 +7538,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
7064
7538
|
pst.add_argument("posture", nargs="?", default=None, help="set: <parallel|sequential>")
|
|
7065
7539
|
pst.set_defaults(func=cmd_streams, _opt_positionals=("posture",))
|
|
7066
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
|
+
|
|
7067
7548
|
pto = sub.add_parser("todo", help="capture / list / close a lightweight backlog todo (jot an idea)")
|
|
7068
7549
|
pto.add_argument("text", nargs="?", default=None,
|
|
7069
7550
|
help="todo text to capture; omit to LIST open todos")
|
|
@@ -7191,7 +7672,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
7191
7672
|
help="record one retrospective consolidation of open lessons into the "
|
|
7192
7673
|
"versioned foundation (stamp + route + version-bump, atomic)")
|
|
7193
7674
|
pfo.add_argument("--task", help="narrow to one task's open lessons")
|
|
7194
|
-
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")
|
|
7195
7677
|
pfo.set_defaults(func=cmd_fold)
|
|
7196
7678
|
|
|
7197
7679
|
pgr = sub.add_parser("graduation-report",
|