@pilotspace/add 1.1.0 → 1.2.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 +40 -0
- package/GETTING-STARTED.md +165 -139
- package/README.md +13 -7
- package/bin/cli.js +13 -4
- package/docs/01-principles.md +3 -3
- package/docs/02-the-flow.md +15 -11
- package/docs/03-step-1-specify.md +13 -13
- package/docs/04-step-2-scenarios.md +2 -2
- package/docs/05-step-3-contract.md +3 -3
- package/docs/06-step-4-tests.md +2 -2
- package/docs/07-step-5-build.md +1 -1
- package/docs/08-step-6-verify.md +14 -5
- package/docs/09-the-loop.md +12 -6
- package/docs/10-setup-and-stages.md +27 -13
- package/docs/11-governance.md +2 -2
- package/docs/12-roles.md +3 -3
- package/docs/13-adoption.md +1 -1
- package/docs/14-foundation.md +15 -15
- package/docs/15-foundations-and-lineage.md +106 -0
- package/docs/README.md +4 -0
- package/docs/appendix-a-templates.md +3 -3
- package/docs/appendix-b-prompts.md +40 -5
- package/docs/appendix-c-glossary.md +42 -12
- package/docs/appendix-d-worked-example.md +2 -2
- package/docs/appendix-e-checklists.md +2 -2
- package/docs/appendix-f-requirements-matrix.md +8 -8
- package/docs/appendix-g-references.md +106 -0
- package/package.json +1 -1
- package/skill/add/SKILL.md +39 -37
- package/skill/add/adopt.md +13 -11
- package/skill/add/deltas.md +8 -6
- package/skill/add/fold.md +19 -17
- package/skill/add/graduate.md +74 -0
- package/skill/add/intake.md +22 -7
- package/skill/add/loop.md +59 -0
- package/skill/add/phases/0-setup.md +29 -24
- package/skill/add/phases/1-specify.md +23 -13
- package/skill/add/phases/2-scenarios.md +14 -4
- package/skill/add/phases/3-contract.md +24 -11
- package/skill/add/phases/4-tests.md +15 -5
- package/skill/add/phases/5-build.md +11 -4
- package/skill/add/phases/6-verify.md +24 -2
- package/skill/add/phases/7-observe.md +13 -5
- package/skill/add/report-template.md +65 -7
- package/skill/add/run.md +45 -34
- package/skill/add/scope.md +10 -6
- package/skill/add/setup-review.md +13 -10
- package/skill/add/streams.md +69 -19
- package/tooling/add.py +476 -34
- package/tooling/templates/CONVENTIONS.md.tmpl +1 -1
- package/tooling/templates/GLOSSARY.md.tmpl +23 -0
- package/tooling/templates/MILESTONE.md.tmpl +1 -0
- package/tooling/templates/PROJECT.md.tmpl +4 -3
- package/tooling/templates/TASK.md.tmpl +33 -12
package/tooling/add.py
CHANGED
|
@@ -26,7 +26,14 @@ from pathlib import Path
|
|
|
26
26
|
ROOT_DIRNAME = ".add"
|
|
27
27
|
STATE_FILE = "state.json"
|
|
28
28
|
MILESTONE_FILE = "MILESTONE.md"
|
|
29
|
+
# The project GOAL (v20) is read live from PROJECT.md — never copied into state.json
|
|
30
|
+
# (single-source; the foundation is the truth). A missing/blank source degrades to
|
|
31
|
+
# this sentinel so the read-only orientation surfaces never blank or crash.
|
|
32
|
+
GOAL_UNSET = "(unset — add a 'goal:' line to PROJECT.md)"
|
|
29
33
|
STAGES = ("prototype", "poc", "mvp", "production")
|
|
34
|
+
# v22 stage-graduation: the read-only cue `status` shows when the MVP is covered.
|
|
35
|
+
# Worded as the ACTION (never a file) so it stands before graduate.md exists.
|
|
36
|
+
GRADUATION_CUE = "MVP covered → propose graduation"
|
|
30
37
|
PHASES = ("specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done")
|
|
31
38
|
GATES = ("none", "PASS", "RISK-ACCEPTED", "HARD-STOP")
|
|
32
39
|
|
|
@@ -38,7 +45,7 @@ def _phase_index(name: str) -> int:
|
|
|
38
45
|
# `add.py guide` copy: per-phase (concrete next action, book chapter to read).
|
|
39
46
|
# Keep the action wording aligned with each phase's EXIT line in the TASK template.
|
|
40
47
|
PHASE_GUIDE = {
|
|
41
|
-
"specify": ("state every rule — Must / Reject (+ named code) / After; rank assumptions
|
|
48
|
+
"specify": ("state every rule — Must / Reject (+ named code) / After; rank assumptions lowest-confidence first and flag the biggest risk",
|
|
42
49
|
"03-step-1-specify.md"),
|
|
43
50
|
"scenarios": ("write one Given/When/Then per Must AND per Reject; every result observable",
|
|
44
51
|
"04-step-2-scenarios.md"),
|
|
@@ -48,7 +55,7 @@ PHASE_GUIDE = {
|
|
|
48
55
|
"06-step-4-tests.md"),
|
|
49
56
|
"build": ("write the minimum code to pass the tests; change no test and no contract",
|
|
50
57
|
"07-step-5-build.md"),
|
|
51
|
-
"verify": ("run the suite +
|
|
58
|
+
"verify": ("run the suite + non-functional checks, then record the gate",
|
|
52
59
|
"08-step-6-verify.md"),
|
|
53
60
|
"observe": ("note what to watch + the spec delta for the next loop",
|
|
54
61
|
"09-the-loop.md"),
|
|
@@ -85,8 +92,8 @@ Framings weighed:
|
|
|
85
92
|
Must:
|
|
86
93
|
Reject:
|
|
87
94
|
After:
|
|
88
|
-
Assumptions —
|
|
89
|
-
⚠ <most likely wrong> —
|
|
95
|
+
Assumptions — lowest-confidence first:
|
|
96
|
+
⚠ <most likely wrong> — lowest confidence because <why>; if wrong: <cost>
|
|
90
97
|
|
|
91
98
|
## 2 · SCENARIOS
|
|
92
99
|
## 3 · CONTRACT
|
|
@@ -244,7 +251,7 @@ def _guideline_block() -> str:
|
|
|
244
251
|
" guide ends with its exit gate and the command to move on.\n"
|
|
245
252
|
"\n"
|
|
246
253
|
"The flow: INTAKE sizes a request into a milestone; each task runs the\n"
|
|
247
|
-
"**
|
|
254
|
+
"**specification bundle** — Spec+Scenarios+Contract+Tests as one bundle,\n"
|
|
248
255
|
"ONE human approval at the frozen contract — then a self-driving build→verify\n"
|
|
249
256
|
"run. Non-negotiable for every agent:\n"
|
|
250
257
|
"Never weaken a test or edit a frozen contract to make a build pass; a security\n"
|
|
@@ -332,7 +339,7 @@ def _is_brownfield(base: Path) -> bool:
|
|
|
332
339
|
"""True when `base` already holds project content beyond the tool's own scaffolding.
|
|
333
340
|
|
|
334
341
|
Judgment-free: a mechanical fact (does the dir hold a non-excluded entry?), so the
|
|
335
|
-
autonomous-onboarding flow knows to map existing code into the
|
|
342
|
+
autonomous-onboarding flow knows to map existing code into the living documentation. INTERPRETING
|
|
336
343
|
that code stays with the AI (skill/add/adopt.md) — the engine only detects + signals."""
|
|
337
344
|
if not base.is_dir():
|
|
338
345
|
return False
|
|
@@ -515,6 +522,20 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
515
522
|
# into build/verify/observe/done is refused until `add.py lock`.
|
|
516
523
|
if not _setup_locked(state) and nxt in ("build", "verify", "observe", "done"):
|
|
517
524
|
_die("setup_unlocked: lock the foundation first — add.py lock")
|
|
525
|
+
# flag-first freeze guard (task unflagged-freeze): a FROZEN §3 may not cross
|
|
526
|
+
# into build without a WELL-FORMED lowest-confidence flag. On pass, stamp the
|
|
527
|
+
# verified marker so `audit` enforces the flag on THIS record only (open/new
|
|
528
|
+
# freezes — the unmarked predecessors are never retro-redded). REFUSE writes
|
|
529
|
+
# nothing (fail-closed); below the build boundary the flag is never checked.
|
|
530
|
+
if nxt == "build":
|
|
531
|
+
raw3 = _raw_phase_bodies(root, slug).get(3, "")
|
|
532
|
+
if _contract_frozen(raw3):
|
|
533
|
+
if not _flag_well_formed(raw3):
|
|
534
|
+
_die("unflagged_freeze: a frozen §3 must surface a well-formed "
|
|
535
|
+
"'Least-sure flag surfaced at freeze:' unit (>=1 [part] tag "
|
|
536
|
+
"+ substantive content; bare 'none' only as 'none material — "
|
|
537
|
+
"biggest risk: X') before crossing into build")
|
|
538
|
+
state["tasks"][slug]["flag_verified"] = True
|
|
518
539
|
state["tasks"][slug]["phase"] = nxt
|
|
519
540
|
state["tasks"][slug]["updated"] = _now()
|
|
520
541
|
_sync_task_marker(root, slug, nxt)
|
|
@@ -569,7 +590,7 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
569
590
|
hdr = _task_header(root, slug)
|
|
570
591
|
if _RISK_HIGH_RE.search(hdr) and not _AUTONOMY_CONSERVATIVE_RE.search(hdr):
|
|
571
592
|
_die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high "
|
|
572
|
-
"without autonomy: conservative — lower the
|
|
593
|
+
"without autonomy: conservative — lower the autonomy level in the TASK.md "
|
|
573
594
|
"header; a human must own a high-risk gate (run.md guard)")
|
|
574
595
|
if args.outcome == "RISK-ACCEPTED":
|
|
575
596
|
# A waiver must be SIGNED: owner, ticket, expiry (glossary). Stored in state
|
|
@@ -592,10 +613,49 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
592
613
|
print("HARD-STOP recorded: return to BUILD; nothing ships on a failing/security gate.")
|
|
593
614
|
|
|
594
615
|
|
|
616
|
+
def cmd_reopen(args: argparse.Namespace) -> None:
|
|
617
|
+
"""Return an already-`done` task to an earlier phase with a never-silent record.
|
|
618
|
+
|
|
619
|
+
The flow already permits backward correction (book ch02: "any phase may return
|
|
620
|
+
to an earlier one"); `done` is terminal EXCEPT via this recorded action. reopen
|
|
621
|
+
sets the phase back, resets the gate to "none" (the task must re-earn its
|
|
622
|
+
verdict), and appends an append-only `reopens` entry recording WHY. A done task
|
|
623
|
+
done via RISK-ACCEPTED carries a live `waiver`; reopen records it inside the entry
|
|
624
|
+
(prior_gate / prior_waiver) and drops the live key, so no signed waiver lingers
|
|
625
|
+
without a verdict. Judgement of WHEN to reopen stays the resolver's; the engine
|
|
626
|
+
only enforces the recorded, coherent transition.
|
|
627
|
+
"""
|
|
628
|
+
root = _require_root()
|
|
629
|
+
state = load_state(root)
|
|
630
|
+
slug = _resolve_task(state, args.slug)
|
|
631
|
+
t = state["tasks"][slug]
|
|
632
|
+
if t.get("phase") != "done":
|
|
633
|
+
_die(f"reopen_not_done: task '{slug}' is at '{t.get('phase')}', not done — "
|
|
634
|
+
"backward correction inside a live run is `add.py phase` / HARD-STOP, not reopen")
|
|
635
|
+
reason = (args.reason or "").strip()
|
|
636
|
+
if not reason:
|
|
637
|
+
_die("reopen_reason_required: reopen records WHY — supply a non-empty --reason")
|
|
638
|
+
target = args.to
|
|
639
|
+
if target not in PHASES[:7]: # specify..observe; never "done", never an unknown name
|
|
640
|
+
_die(f"reopen_target_invalid: --to must be one of {', '.join(PHASES[:7])} (got {target!r})")
|
|
641
|
+
now = _now()
|
|
642
|
+
entry = {"from": "done", "to": target, "reason": reason, "at": now,
|
|
643
|
+
"prior_gate": t.get("gate", "none")}
|
|
644
|
+
if t.get("waiver"): # void verdict's waiver -> history, drop the live key
|
|
645
|
+
entry["prior_waiver"] = t.pop("waiver")
|
|
646
|
+
t.setdefault("reopens", []).append(entry)
|
|
647
|
+
t["phase"] = target
|
|
648
|
+
t["gate"] = "none"
|
|
649
|
+
t["updated"] = now
|
|
650
|
+
_sync_task_marker(root, slug, target)
|
|
651
|
+
save_state(root, state)
|
|
652
|
+
print(f"task '{slug}' reopened: done -> {target} (reason recorded); gate reset to none")
|
|
653
|
+
|
|
654
|
+
|
|
595
655
|
def cmd_lock(args: argparse.Namespace) -> None:
|
|
596
|
-
"""The human
|
|
656
|
+
"""The human baseline approval: freeze the autonomously-drafted setup in ONE atomic write.
|
|
597
657
|
|
|
598
|
-
Setup-
|
|
658
|
+
Setup-level analog of the contract freeze — the only new human action onboarding
|
|
599
659
|
needs. `add.py lock` is judgment-free (it records the signature; it does NOT inspect
|
|
600
660
|
the artifacts): the human's signature IS the gate."""
|
|
601
661
|
root = _require_root()
|
|
@@ -622,19 +682,42 @@ def cmd_lock(args: argparse.Namespace) -> None:
|
|
|
622
682
|
print(f"locked setup ({','.join(layers)}) by {who} @ {when}")
|
|
623
683
|
|
|
624
684
|
|
|
685
|
+
def _has_production_roadmap(state: dict) -> bool:
|
|
686
|
+
"""True iff ≥1 milestone in state has stage == "production" (STATUS-AGNOSTIC).
|
|
687
|
+
The single source of the stage-graduation floor (v22 graduate-guide): the guard counts
|
|
688
|
+
that a production-roadmap RECORD exists — it never judges whether those milestones are
|
|
689
|
+
done/good/sufficient (gather-not-judge). An archived-out-of-state roadmap falls to --force."""
|
|
690
|
+
return any(m.get("stage") == "production"
|
|
691
|
+
for m in state.get("milestones", {}).values())
|
|
692
|
+
|
|
693
|
+
|
|
625
694
|
def cmd_stage(args: argparse.Namespace) -> None:
|
|
626
695
|
root = _require_root()
|
|
627
696
|
state = load_state(root)
|
|
628
697
|
if args.stage not in STAGES:
|
|
629
698
|
_die(f"stage must be one of: {', '.join(STAGES)}")
|
|
699
|
+
# v22 stage-graduation guard: the →production TRANSITION refuses without a roadmap — a tally
|
|
700
|
+
# check (≥1 production milestone exists), never a readiness judgment. Scoped to production
|
|
701
|
+
# ONLY; every other flip is the existing bare flip, byte-unchanged. --force overrides
|
|
702
|
+
# (precedent: lock --force). The flip is graduate.md's FINAL, confirmed-roadmap step.
|
|
703
|
+
forced = getattr(args, "force", False)
|
|
704
|
+
bypassing = False
|
|
705
|
+
if args.stage == "production":
|
|
706
|
+
roadmap = _has_production_roadmap(state)
|
|
707
|
+
if not roadmap and not forced:
|
|
708
|
+
_die("stage_no_roadmap: no production milestone drafted. Draft ≥1 via "
|
|
709
|
+
"graduate.md (new-milestone --stage production), or use --force to override.")
|
|
710
|
+
bypassing = forced and not roadmap
|
|
630
711
|
state["stage"] = args.stage
|
|
631
712
|
save_state(root, state)
|
|
632
713
|
print(f"project stage -> {args.stage}")
|
|
714
|
+
if bypassing:
|
|
715
|
+
print("(--force: bypassed roadmap check — no production milestone drafted)")
|
|
633
716
|
|
|
634
717
|
|
|
635
718
|
def cmd_status(args: argparse.Namespace) -> None:
|
|
636
719
|
if getattr(args, "json", False):
|
|
637
|
-
|
|
720
|
+
root, state = _load_state_for_json()
|
|
638
721
|
tasks = state.get("tasks") or {}
|
|
639
722
|
milestones = state.get("milestones") or {}
|
|
640
723
|
ms_list = []
|
|
@@ -643,12 +726,15 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
643
726
|
ms_list.append({"slug": mslug, "status": m.get("status", "active"),
|
|
644
727
|
"done": sum(1 for t in members if _task_done(t)),
|
|
645
728
|
"total": len(members)})
|
|
729
|
+
grad_ready, grad_met, grad_total = _graduation_ready(root, state)
|
|
646
730
|
print(json.dumps({
|
|
647
731
|
"project": state.get("project"), "stage": state.get("stage"),
|
|
648
732
|
"active_task": state.get("active_task"),
|
|
649
733
|
"milestones": ms_list,
|
|
650
734
|
"tasks": [{"slug": s, "phase": t.get("phase"), "gate": t.get("gate"),
|
|
651
|
-
"milestone": t.get("milestone")} for s, t in tasks.items()]
|
|
735
|
+
"milestone": t.get("milestone")} for s, t in tasks.items()],
|
|
736
|
+
"graduation_ready": grad_ready,
|
|
737
|
+
"stage_criteria": {"met": grad_met, "total": grad_total}}))
|
|
652
738
|
return
|
|
653
739
|
root = _require_root()
|
|
654
740
|
state = load_state(root)
|
|
@@ -659,9 +745,24 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
659
745
|
unlocked = not _setup_locked(state)
|
|
660
746
|
print(f"project : {state.get('project', '(unknown)')}")
|
|
661
747
|
print(f"stage : {state.get('stage', '(unknown)')}")
|
|
748
|
+
# project GOAL + active-milestone goal (v20) — the loop's orientation anchor, read
|
|
749
|
+
# LIVE from PROJECT.md / MILESTONE.md (never state.json). Additive: every existing
|
|
750
|
+
# line stays put. A missing source degrades to a sentinel — one never blanks the other.
|
|
751
|
+
print(f"goal : {_project_goal(root)}")
|
|
752
|
+
_active_ms = state.get("active_milestone")
|
|
753
|
+
if _active_ms:
|
|
754
|
+
print(f"m-goal : {_milestone_doc(root, _active_ms)[1]} (← {_active_ms})")
|
|
662
755
|
# foundation pointer — read the cross-milestone context first (anti-rot)
|
|
663
756
|
if (root / "PROJECT.md").exists():
|
|
664
757
|
print("context : .add/PROJECT.md (foundation: domain · spec · UI/UX — read first)")
|
|
758
|
+
# wave resume hint — a live ledger outranks memory (streams.md "Wave ledger").
|
|
759
|
+
# Existence-only: no open/read/parse, so the hint adds no IO failure path; a
|
|
760
|
+
# non-file at the path is not a ledger. One line PER live ledger — more than
|
|
761
|
+
# one live wave is an anomaly the orchestrator must see, never a line we hide.
|
|
762
|
+
for _wave in sorted((root / "milestones").glob("*/WAVE.md")):
|
|
763
|
+
if _wave.is_file():
|
|
764
|
+
print(f"wave : LIVE — .add/milestones/{_wave.parent.name}/WAVE.md"
|
|
765
|
+
" (wave resume point — re-orient from the ledger first)")
|
|
665
766
|
|
|
666
767
|
# milestone rollup (only when milestones are in use)
|
|
667
768
|
milestones = state.get("milestones") or {}
|
|
@@ -674,6 +775,12 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
674
775
|
mark = "*" if mslug == active_ms else " "
|
|
675
776
|
print(f" {mark} {mslug:<20} {done}/{len(members)} tasks done"
|
|
676
777
|
f" status={m.get('status', 'active')}")
|
|
778
|
+
# graduation cue (v22): project-global + read-only. Fires only when every milestone
|
|
779
|
+
# is done AND the human's PROJECT.md stage-goal-criteria are all checked — additive
|
|
780
|
+
# (a new line solely when ready; the non-ready output is byte-identical to before).
|
|
781
|
+
grad_ready, _gm, _gt = _graduation_ready(root, state)
|
|
782
|
+
if grad_ready:
|
|
783
|
+
print(f" → {GRADUATION_CUE}")
|
|
677
784
|
|
|
678
785
|
# archived rollup — one line keeps state visible without re-bloating status
|
|
679
786
|
archived = state.get("archived") or []
|
|
@@ -691,7 +798,7 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
691
798
|
print("tasks : (none yet)")
|
|
692
799
|
print()
|
|
693
800
|
if unlocked:
|
|
694
|
-
print("setup : UNLOCKED — review .add/SETUP-REVIEW.md (
|
|
801
|
+
print("setup : UNLOCKED — review .add/SETUP-REVIEW.md (lowest-confidence first),"
|
|
695
802
|
" then sign: add.py lock")
|
|
696
803
|
print(" (the build-boundary gate is closed until the foundation is locked)")
|
|
697
804
|
else:
|
|
@@ -710,11 +817,11 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
710
817
|
# silently outrun the human fold (read-only; v11). Silent when none are open.
|
|
711
818
|
open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
|
|
712
819
|
if open_deltas:
|
|
713
|
-
print(f"deltas : {open_deltas} open —
|
|
820
|
+
print(f"deltas : {open_deltas} open — consolidate at milestone close (add.py deltas)")
|
|
714
821
|
# When the setup is unlocked, the only terminal guidance that matters is
|
|
715
822
|
# review+lock; suppress the generic resume block so it does not compete.
|
|
716
823
|
if unlocked:
|
|
717
|
-
print("\nsetup : UNLOCKED — review .add/SETUP-REVIEW.md (
|
|
824
|
+
print("\nsetup : UNLOCKED — review .add/SETUP-REVIEW.md (lowest-confidence first),"
|
|
718
825
|
" then sign: add.py lock")
|
|
719
826
|
print(" (the build-boundary gate is closed until the foundation is locked)")
|
|
720
827
|
elif active and active in tasks:
|
|
@@ -791,6 +898,7 @@ def cmd_guide(args: argparse.Namespace) -> None:
|
|
|
791
898
|
_die(f"task '{slug}' has unknown phase '{phase}' (state.json corrupted?)")
|
|
792
899
|
action, chapter = entry
|
|
793
900
|
print(f"active : {slug} (phase: {phase})")
|
|
901
|
+
print(f"goal : {_project_goal(root)}") # v20 — the next-step surface still shows what the work is FOR
|
|
794
902
|
print(f"next : {action}")
|
|
795
903
|
print(f"read : .add/docs/{chapter}")
|
|
796
904
|
gp = _phase_guide_path(root.parent, phase)
|
|
@@ -1001,6 +1109,17 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
|
1001
1109
|
t = members[s]
|
|
1002
1110
|
print(f" - {s} (phase={t.get('phase')}, gate={t.get('gate')})", file=sys.stderr)
|
|
1003
1111
|
_die("milestone_incomplete")
|
|
1112
|
+
# Goal-gate (v20 dynamic-task-loop): a milestone holds until its exit criteria are
|
|
1113
|
+
# met. The engine READS the checkbox tally (the human's goal-met affirmation, like a
|
|
1114
|
+
# gate=PASS) — it never judges the goal. Fires ONLY when criteria exist, so a
|
|
1115
|
+
# criteria-less milestone and every pre-v20 close path stay valid. milestone-done is
|
|
1116
|
+
# the SOLE status->done transition; archive-milestone/compact already refuse a
|
|
1117
|
+
# non-done milestone, so this single gate has no back door. Refuse BEFORE any write.
|
|
1118
|
+
met, total = _exit_criteria(root, slug)
|
|
1119
|
+
if total > 0 and met < total:
|
|
1120
|
+
_die(f"milestone_goal_unmet: milestone '{slug}' has {met}/{total} exit criteria met "
|
|
1121
|
+
f"— check the remaining boxes in MILESTONE.md (the goal-gate holds the loop "
|
|
1122
|
+
f"open) or propose the next tasks (add.py deltas)")
|
|
1004
1123
|
# Fail-closed: render+persist the exit report (RETRO.md) BEFORE committing the
|
|
1005
1124
|
# status flip, so a write failure rolls back naturally (status never commits ->
|
|
1006
1125
|
# no done-without-retro state). The retro step is read-only on state.json.
|
|
@@ -1020,7 +1139,7 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
|
1020
1139
|
open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
|
|
1021
1140
|
if open_deltas:
|
|
1022
1141
|
noun = "delta" if open_deltas == 1 else "deltas"
|
|
1023
|
-
print(f"note: {open_deltas} open
|
|
1142
|
+
print(f"note: {open_deltas} open {noun} to consolidate into the foundation "
|
|
1024
1143
|
f"— review with: add.py deltas")
|
|
1025
1144
|
|
|
1026
1145
|
|
|
@@ -1076,6 +1195,70 @@ def cmd_archive_milestone(args: argparse.Namespace) -> None:
|
|
|
1076
1195
|
print("files on disk are untouched; see `add.py status` for the archived rollup.")
|
|
1077
1196
|
|
|
1078
1197
|
|
|
1198
|
+
def cmd_compact(args: argparse.Namespace) -> None:
|
|
1199
|
+
"""Heavy archive (step two, after `archive-milestone`): move a light-archived
|
|
1200
|
+
milestone's files — MILESTONE.md + siblings + every rollup-member task dir — into
|
|
1201
|
+
one recovery bundle `.add/archive/<slug>/`. Validate-all-then-move: any reject
|
|
1202
|
+
leaves the tree AND state.json byte-for-byte unchanged. Compact never deletes,
|
|
1203
|
+
only renames; recovery = reverse move, no state edit (state already dropped these
|
|
1204
|
+
at light archive). Preserves the _archived_task_slugs invariant: `task_slugs` is
|
|
1205
|
+
never touched — archived ⇒ was PASS-done keeps resolving cross-milestone deps."""
|
|
1206
|
+
root = _require_root()
|
|
1207
|
+
state = load_state(root)
|
|
1208
|
+
slug = args.slug
|
|
1209
|
+
# validate before any mutation — a reject must leave tree + state byte-for-byte unchanged
|
|
1210
|
+
if slug in state.get("milestones", {}):
|
|
1211
|
+
_die(f"milestone_not_archived: '{slug}' is still active — "
|
|
1212
|
+
f"run `add.py archive-milestone {slug}` first (light archive is step one)")
|
|
1213
|
+
entry = next((e for e in state.get("archived", []) if e.get("slug") == slug), None)
|
|
1214
|
+
if entry is None:
|
|
1215
|
+
_die("unknown_milestone")
|
|
1216
|
+
if entry.get("compacted"):
|
|
1217
|
+
_die(f"already_compacted: '{slug}' was compacted {entry['compacted']} — "
|
|
1218
|
+
f"see .add/archive/{slug}/")
|
|
1219
|
+
dest = root / "archive" / slug
|
|
1220
|
+
if dest.exists():
|
|
1221
|
+
_die(f"archive_destination_exists: .add/archive/{slug}/ exists without a "
|
|
1222
|
+
"compacted stamp — resolve the collision by hand before compacting")
|
|
1223
|
+
ms_dir = root / "milestones" / slug
|
|
1224
|
+
members = list(entry.get("task_slugs") or [])
|
|
1225
|
+
missing = [str(p.relative_to(root)) for p in
|
|
1226
|
+
[ms_dir, *(root / "tasks" / t for t in members)] if not p.is_dir()]
|
|
1227
|
+
if missing:
|
|
1228
|
+
_die("source_files_missing: " + " · ".join(missing))
|
|
1229
|
+
# deltas folded first: an `open` lesson inside the bundle would silently vanish
|
|
1230
|
+
# from `add.py deltas` (_collect_open_deltas globs tasks/*/TASK.md) once moved.
|
|
1231
|
+
member_set = set(members)
|
|
1232
|
+
offenders = sorted({e["task"] for v in _collect_open_deltas(root).values()
|
|
1233
|
+
for e in v if e["task"] in member_set})
|
|
1234
|
+
if offenders:
|
|
1235
|
+
_die("open_deltas_unfolded: consolidate the open lessons first (`add.py deltas`) — "
|
|
1236
|
+
"open in: " + " · ".join(offenders))
|
|
1237
|
+
# every precondition passed — move (same-filesystem renames, never a delete)
|
|
1238
|
+
def _files(d: Path) -> int:
|
|
1239
|
+
return sum(1 for f in d.rglob("*") if f.is_file())
|
|
1240
|
+
moved: list[tuple[str, int]] = []
|
|
1241
|
+
(root / "archive").mkdir(exist_ok=True)
|
|
1242
|
+
n = _files(ms_dir)
|
|
1243
|
+
ms_dir.rename(dest) # the milestone dir becomes the bundle root
|
|
1244
|
+
moved.append((f"milestones/{slug}/", n))
|
|
1245
|
+
(dest / "tasks").mkdir(exist_ok=True)
|
|
1246
|
+
for t in members:
|
|
1247
|
+
src = root / "tasks" / t
|
|
1248
|
+
n = _files(src)
|
|
1249
|
+
src.rename(dest / "tasks" / t)
|
|
1250
|
+
moved.append((f"tasks/{t}/", n))
|
|
1251
|
+
# state write is the LAST step: additive stamp only — task_slugs untouched
|
|
1252
|
+
entry["compacted"] = date.today().isoformat()
|
|
1253
|
+
save_state(root, state)
|
|
1254
|
+
total = sum(n for _, n in moved)
|
|
1255
|
+
print(f"compacted milestone '{slug}' -> .add/archive/{slug}/ "
|
|
1256
|
+
f"({len(members)} task dirs, {total} files moved)")
|
|
1257
|
+
for path, n in moved:
|
|
1258
|
+
print(f" moved {path} ({n} files)")
|
|
1259
|
+
print("recovery: reverse the moves (mv the bundle's parts back) — state needs no edit.")
|
|
1260
|
+
|
|
1261
|
+
|
|
1079
1262
|
def cmd_set_milestone(args: argparse.Namespace) -> None:
|
|
1080
1263
|
root = _require_root()
|
|
1081
1264
|
state = load_state(root)
|
|
@@ -1236,6 +1419,21 @@ def _colorize(s: str) -> str:
|
|
|
1236
1419
|
return s
|
|
1237
1420
|
|
|
1238
1421
|
|
|
1422
|
+
def _project_goal(root: Path) -> str:
|
|
1423
|
+
"""The project GOAL — the value of the first `goal:` line in PROJECT.md, else
|
|
1424
|
+
GOAL_UNSET. Read-only and fail-closed: a missing/unreadable foundation or a
|
|
1425
|
+
blank value degrades to the sentinel (orientation never raises). Mirrors how
|
|
1426
|
+
_milestone_doc reads the milestone goal — the foundation is the single source."""
|
|
1427
|
+
f = root / "PROJECT.md"
|
|
1428
|
+
try:
|
|
1429
|
+
for line in f.read_text(encoding="utf-8").splitlines():
|
|
1430
|
+
if line.startswith("goal:"):
|
|
1431
|
+
return line.split(":", 1)[1].strip() or GOAL_UNSET
|
|
1432
|
+
except OSError:
|
|
1433
|
+
pass
|
|
1434
|
+
return GOAL_UNSET
|
|
1435
|
+
|
|
1436
|
+
|
|
1239
1437
|
def _milestone_doc(root: Path, mslug: str) -> tuple[str, str]:
|
|
1240
1438
|
"""(title, goal) from MILESTONE.md; ('(unknown)','(unknown)') if the doc is gone."""
|
|
1241
1439
|
f = root / "milestones" / mslug / MILESTONE_FILE
|
|
@@ -1265,6 +1463,41 @@ def _exit_criteria(root: Path, mslug: str) -> tuple[int, int]:
|
|
|
1265
1463
|
return met, total
|
|
1266
1464
|
|
|
1267
1465
|
|
|
1466
|
+
def _stage_criteria(root: Path) -> tuple[int, int]:
|
|
1467
|
+
"""(met, total) checkbox tally inside PROJECT.md's 'Stage goal criteria' section — the
|
|
1468
|
+
PROJECT.md analog of _exit_criteria (v22): the human's stage-covered affirmation. Read-only
|
|
1469
|
+
and fail-closed to (0, 0): a missing file, a missing section, or any read error never raises
|
|
1470
|
+
and never fabricates a cue (so an unreadable foundation withholds graduation, design-for-failure)."""
|
|
1471
|
+
try:
|
|
1472
|
+
text = (root / "PROJECT.md").read_text(encoding="utf-8")
|
|
1473
|
+
except OSError:
|
|
1474
|
+
return 0, 0
|
|
1475
|
+
m = re.search(r"## Stage goal criteria.*?(?=\n## |\Z)", text, re.S)
|
|
1476
|
+
if not m:
|
|
1477
|
+
return 0, 0
|
|
1478
|
+
sec = m.group(0)
|
|
1479
|
+
met = len(re.findall(r"- \[x\]", sec))
|
|
1480
|
+
total = met + len(re.findall(r"- \[ \]", sec))
|
|
1481
|
+
return met, total
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def _all_milestones_done(state: dict) -> bool:
|
|
1485
|
+
"""True when the project HAS milestones and EVERY one is status=done (v22). Archived
|
|
1486
|
+
milestones are absent from state['milestones'] (removed by the archive lifecycle), so they
|
|
1487
|
+
do not count; a project with zero milestones is not 'covered' and returns False."""
|
|
1488
|
+
ms = state.get("milestones") or {}
|
|
1489
|
+
return bool(ms) and all(m.get("status") == "done" for m in ms.values())
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
def _graduation_ready(root: Path, state: dict) -> tuple[bool, int, int]:
|
|
1493
|
+
"""(ready, met, total) for the stage-graduation cue (v22): every milestone done AND the
|
|
1494
|
+
human's stage-goal-criteria all checked (total>0 and met==total). The SINGLE source the
|
|
1495
|
+
text and --json status branches share, so the cue and the json signal can never disagree."""
|
|
1496
|
+
met, total = _stage_criteria(root)
|
|
1497
|
+
ready = _all_milestones_done(state) and total > 0 and met == total
|
|
1498
|
+
return ready, met, total
|
|
1499
|
+
|
|
1500
|
+
|
|
1268
1501
|
def _count_test_defs(f: Path) -> int:
|
|
1269
1502
|
"""`def test_` occurrences in one file — the ONE counting regex (primary and
|
|
1270
1503
|
§4-declared fallback share it by construction). OSError -> 0, fail-closed."""
|
|
@@ -1748,8 +1981,39 @@ def _contract_frozen(raw3: str) -> bool:
|
|
|
1748
1981
|
return any(re.match(r"\s*Status:\s*FROZEN", ln) for ln in raw3.splitlines())
|
|
1749
1982
|
|
|
1750
1983
|
|
|
1984
|
+
_FLAG_LABEL_RE = re.compile(r"Least-sure flag surfaced at freeze\s*:", re.I)
|
|
1985
|
+
_FLAG_PART_RE = re.compile(
|
|
1986
|
+
r"\[(?:spec|scenario|contract|test)(?:/(?:spec|scenario|contract|test))*\]")
|
|
1987
|
+
_FLAG_NONE_ESCAPE_RE = re.compile(
|
|
1988
|
+
r"none material\s*[—-]+\s*biggest risk\s*:\s*\S", re.I)
|
|
1989
|
+
|
|
1990
|
+
|
|
1991
|
+
def _flag_well_formed(raw3: str) -> bool:
|
|
1992
|
+
"""A FROZEN §3 must surface a WELL-FORMED lowest-confidence flag — the unit
|
|
1993
|
+
that NAMES which part of the bundle is least certain. Well-formed := the label
|
|
1994
|
+
phrase + a unit carrying >=1 [part] tag (part in spec/scenario/contract/test,
|
|
1995
|
+
slash-joinable like [spec/contract]) + substantive content. A bare 'none' is
|
|
1996
|
+
refused unless it takes the honest escape 'none material — biggest risk: X'.
|
|
1997
|
+
why/cost stay a human-read convention, never machine keywords (evidence: the
|
|
1998
|
+
lived flags use em-dash/prose, never literal because/if-wrong). HTML comments
|
|
1999
|
+
(template hints) never count. PURE — fail-closed on a missing label."""
|
|
2000
|
+
body = re.sub(r"<!--.*?-->", "", raw3, flags=re.S)
|
|
2001
|
+
m = _FLAG_LABEL_RE.search(body)
|
|
2002
|
+
if not m:
|
|
2003
|
+
return False
|
|
2004
|
+
unit = body[m.end():].strip()
|
|
2005
|
+
if not unit:
|
|
2006
|
+
return False
|
|
2007
|
+
if _FLAG_NONE_ESCAPE_RE.search(unit): # the honest-none escape — no tag needed
|
|
2008
|
+
return True
|
|
2009
|
+
if not _FLAG_PART_RE.search(unit): # must name WHICH part is uncertain
|
|
2010
|
+
return False
|
|
2011
|
+
residue = _FLAG_PART_RE.sub("", unit).replace("⚠", "").strip(" -—·\n\t")
|
|
2012
|
+
return len(residue) >= 3 # substantive content beyond the tag(s)
|
|
2013
|
+
|
|
2014
|
+
|
|
1751
2015
|
def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict:
|
|
1752
|
-
"""FACTS for the task-level decision-
|
|
2016
|
+
"""FACTS for the task-level decision-point digest (frozen shape). The decision comes
|
|
1753
2017
|
from STATE ONLY: recorded (gate set / observe / done) · front (specify→tests) ·
|
|
1754
2018
|
gate (build/verify). judgment = extracted markers, byte-verbatim. PURE."""
|
|
1755
2019
|
tasks = state.get("tasks") or {}
|
|
@@ -1786,7 +2050,7 @@ def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict:
|
|
|
1786
2050
|
decide = "approve -> freeze §3 (Status: FROZEN @ v1) -> auto run"
|
|
1787
2051
|
elif seam == "front":
|
|
1788
2052
|
unlocks = "none"
|
|
1789
|
-
decide = "no decision pending — frozen; the run owns it. next
|
|
2053
|
+
decide = "no decision pending — frozen; the run owns it. next decision point: verify gate"
|
|
1790
2054
|
else:
|
|
1791
2055
|
unlocks = "none"
|
|
1792
2056
|
decide = f"no decision pending — recorded gate: {gate}"
|
|
@@ -1797,7 +2061,7 @@ def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict:
|
|
|
1797
2061
|
|
|
1798
2062
|
def render_decide(root: Path, state: dict, mslug: str, slug: str, *,
|
|
1799
2063
|
width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str:
|
|
1800
|
-
"""Text view of the decision-
|
|
2064
|
+
"""Text view of the decision-point digest — decisive facts FIRST: NEEDS YOUR
|
|
1801
2065
|
JUDGMENT (markers byte-verbatim, section-tagged) -> [front: §3 verbatim] ->
|
|
1802
2066
|
ENGINE FACTS -> UNLOCKS -> DECIDE. PURE — no writes; plain text (color is a
|
|
1803
2067
|
tty-only skin in cmd_report, like every report view)."""
|
|
@@ -1806,7 +2070,7 @@ def render_decide(root: Path, state: dict, mslug: str, slug: str, *,
|
|
|
1806
2070
|
banner = g["h"] * width
|
|
1807
2071
|
seam_label = {"gate": "VERIFY GATE", "front": "CONTRACT APPROVAL",
|
|
1808
2072
|
"recorded": "RECORDED"}[d["seam"]]
|
|
1809
|
-
L = [banner, f" DECIDE · {mslug or '—'} · {slug} ·
|
|
2073
|
+
L = [banner, f" DECIDE · {mslug or '—'} · {slug} · decision point: {seam_label}", banner]
|
|
1810
2074
|
if d["decide"].startswith("no decision pending"):
|
|
1811
2075
|
L.append(f" {d['decide']}")
|
|
1812
2076
|
L.append(f" GATE {d['gate']}")
|
|
@@ -1853,8 +2117,8 @@ def _planned_unscaffolded(root: Path, mslug: str) -> list[str]:
|
|
|
1853
2117
|
|
|
1854
2118
|
|
|
1855
2119
|
def _decide_next(state: dict, d: dict) -> str:
|
|
1856
|
-
"""The rollup's DECIDE NEXT line (frozen precedence): HARD-STOP ->
|
|
1857
|
-
-> first
|
|
2120
|
+
"""The rollup's DECIDE NEXT line (frozen precedence): HARD-STOP -> consolidate+archive
|
|
2121
|
+
-> first decision-blocked task (ACTIVE task first, then state order) -> run-in-
|
|
1858
2122
|
progress. v2: when d carries planned_unscaffolded, the line gains a
|
|
1859
2123
|
plan-vs-state suffix — precedence itself stays state-only."""
|
|
1860
2124
|
return _decide_next_base(state, d) + _planned_hint(d)
|
|
@@ -1880,7 +2144,15 @@ def _decide_next_base(state: dict, d: dict) -> str:
|
|
|
1880
2144
|
return f"resolve HARD-STOP on {stopped[0]['slug']}"
|
|
1881
2145
|
s = d["summary"]
|
|
1882
2146
|
if s["tasks_done"] == s["tasks_total"]:
|
|
1883
|
-
|
|
2147
|
+
# tasks complete — but the milestone holds while the goal (exit criteria) is
|
|
2148
|
+
# unmet (v20). Point at the feed-forward inventory the loop draws from, instead
|
|
2149
|
+
# of "archive". Fires only when criteria exist; else the prompt is unchanged.
|
|
2150
|
+
ec = s.get("exit_criteria") or {}
|
|
2151
|
+
met, total = ec.get("met", 0), ec.get("total", 0)
|
|
2152
|
+
if total > 0 and met < total:
|
|
2153
|
+
return (f"goal not met ({met}/{total} exit criteria) — propose next tasks "
|
|
2154
|
+
f"from open deltas / the unscaffolded plan (add.py deltas)")
|
|
2155
|
+
return f"consolidate learnings + archive-milestone {ms}"
|
|
1884
2156
|
active = state.get("active_task")
|
|
1885
2157
|
order = sorted(rows, key=lambda r: 0 if r["slug"] == active else 1) # stable
|
|
1886
2158
|
for r in order:
|
|
@@ -2037,7 +2309,7 @@ def _lint_task_deltas(root: Path, slug: str) -> tuple[bool, str] | None:
|
|
|
2037
2309
|
|
|
2038
2310
|
|
|
2039
2311
|
def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
|
|
2040
|
-
"""Scan every .add/tasks/*/TASK.md for open
|
|
2312
|
+
"""Scan every .add/tasks/*/TASK.md for open lessons learned.
|
|
2041
2313
|
|
|
2042
2314
|
Returns a dict keyed by competency in canonical order; each value is a list
|
|
2043
2315
|
of {task, text, evidence} dicts. READ-ONLY — never mutates any file."""
|
|
@@ -2099,7 +2371,7 @@ _AUDIT_REVIEWED_RE = re.compile(r"^Reviewed by:(.*)$", re.M)
|
|
|
2099
2371
|
|
|
2100
2372
|
|
|
2101
2373
|
def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
|
|
2102
|
-
"""The gate-audit core: verify that human
|
|
2374
|
+
"""The gate-audit core: verify that human decision points left WELL-FORMED records.
|
|
2103
2375
|
Judgment-free — checks record SHAPE (a named human at the freeze, exactly one
|
|
2104
2376
|
gate outcome, prose ≡ state, a marked security note never auto-reviewed),
|
|
2105
2377
|
never re-decides an outcome. Scope: active tasks done/observe or gated; open
|
|
@@ -2122,6 +2394,15 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
|
|
|
2122
2394
|
if not _AUDIT_STAMP_RE.search(s3):
|
|
2123
2395
|
f(slug, "unstamped_freeze",
|
|
2124
2396
|
"§3 lacks 'Status: FROZEN @ vN — approved by <name>'")
|
|
2397
|
+
# verified-marker discriminator (task unflagged-freeze): enforce the
|
|
2398
|
+
# lowest-confidence flag ONLY on records that crossed the guard (flag_verified).
|
|
2399
|
+
# A marked record whose flag was deleted/corrupted post-freeze is
|
|
2400
|
+
# tampering; unmarked predecessors are skipped — the board is never
|
|
2401
|
+
# retro-redded.
|
|
2402
|
+
if t.get("flag_verified") and not _flag_well_formed(s3):
|
|
2403
|
+
f(slug, "unflagged_freeze",
|
|
2404
|
+
"flag_verified record lost its well-formed "
|
|
2405
|
+
"'Least-sure flag surfaced at freeze:' unit")
|
|
2125
2406
|
outcomes = _AUDIT_OUTCOME_RE.findall(s6)
|
|
2126
2407
|
if len(outcomes) != 1:
|
|
2127
2408
|
f(slug, "malformed_gate_record",
|
|
@@ -2158,8 +2439,8 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
|
|
|
2158
2439
|
|
|
2159
2440
|
|
|
2160
2441
|
def cmd_audit(args: argparse.Namespace) -> None:
|
|
2161
|
-
"""Read-only: audit recorded human
|
|
2162
|
-
exit 1 with findings — the enforcement
|
|
2442
|
+
"""Read-only: audit recorded human decision points for well-formedness. Exit 0 clean,
|
|
2443
|
+
exit 1 with findings — the enforcement gate CI consumes (audit-ci). Writes
|
|
2163
2444
|
NOTHING; every other command is byte-identical."""
|
|
2164
2445
|
root = _require_root()
|
|
2165
2446
|
checked, findings = _audit_findings(root, load_state(root))
|
|
@@ -2176,8 +2457,146 @@ def cmd_audit(args: argparse.Namespace) -> None:
|
|
|
2176
2457
|
sys.exit(1)
|
|
2177
2458
|
|
|
2178
2459
|
|
|
2460
|
+
def _retro_carried(path: Path) -> int:
|
|
2461
|
+
"""Parse the 'LEARNINGS (N carried)' count from a RETRO.md; absent/unreadable -> 0.
|
|
2462
|
+
READ-ONLY (the graduation harvest's carried-delta facet for the consolidated tier)."""
|
|
2463
|
+
try:
|
|
2464
|
+
text = path.read_text(encoding="utf-8")
|
|
2465
|
+
except OSError:
|
|
2466
|
+
return 0
|
|
2467
|
+
m = re.search(r"LEARNINGS \((\d+) carried\)", text)
|
|
2468
|
+
return int(m.group(1)) if m else 0
|
|
2469
|
+
|
|
2470
|
+
|
|
2471
|
+
def graduation_data(root: Path, state: dict) -> dict:
|
|
2472
|
+
"""The single source of FACTS for the graduation harvest — PURE, NO writes (mirrors
|
|
2473
|
+
report_data). Both the `graduation-report` text dashboard and `--json` render from this
|
|
2474
|
+
one dict, so the human view and the machine view can never disagree.
|
|
2475
|
+
|
|
2476
|
+
GATHER, never JUDGE: every value is a RECORD the human verifies by looking; there is no
|
|
2477
|
+
readiness/score/ranking field by construction (would_be_judging is structurally impossible).
|
|
2478
|
+
Two tiers: LIVE = in-state (state + on-disk TASK.md); CONSOLIDATED = compacted milestones,
|
|
2479
|
+
a RETRO record only. A missing/unreadable source is SKIPPED, never a crash (fail-closed)."""
|
|
2480
|
+
tasks = state.get("tasks") or {}
|
|
2481
|
+
milestones = state.get("milestones") or {}
|
|
2482
|
+
archived = state.get("archived") or []
|
|
2483
|
+
|
|
2484
|
+
# a — open deltas by competency (reuse the project-wide harvester; compacted folded out)
|
|
2485
|
+
by_comp = _collect_open_deltas(root)
|
|
2486
|
+
open_deltas = {"total": sum(len(v) for v in by_comp.values()),
|
|
2487
|
+
"by_competency": {c: v for c, v in by_comp.items() if v}}
|
|
2488
|
+
|
|
2489
|
+
# b — open RISK-ACCEPTED waivers, soonest expiry first (missing/unparseable expiry sorts LAST)
|
|
2490
|
+
waivers = []
|
|
2491
|
+
for slug, t in tasks.items():
|
|
2492
|
+
if t.get("gate") == "RISK-ACCEPTED" and t.get("waiver"):
|
|
2493
|
+
w = t["waiver"]
|
|
2494
|
+
waivers.append({"slug": slug, "owner": w.get("owner", "?"),
|
|
2495
|
+
"ticket": w.get("ticket", "?"), "expires": w.get("expires", "?")})
|
|
2496
|
+
|
|
2497
|
+
def _exp_key(wv):
|
|
2498
|
+
try:
|
|
2499
|
+
return (0, date.fromisoformat(wv["expires"]).isoformat())
|
|
2500
|
+
except (ValueError, TypeError):
|
|
2501
|
+
return (1, "") # unparseable/missing -> after every real date
|
|
2502
|
+
waivers.sort(key=_exp_key)
|
|
2503
|
+
|
|
2504
|
+
# c — RETRO records: LIVE under milestones/, CONSOLIDATED under archive/ (the compacted backbone)
|
|
2505
|
+
retros = []
|
|
2506
|
+
for sub_dir, tier in ((root / "milestones", "live"), (root / "archive", "consolidated")):
|
|
2507
|
+
if sub_dir.is_dir():
|
|
2508
|
+
for retro in sorted(sub_dir.glob("*/RETRO.md")):
|
|
2509
|
+
if retro.is_file(): # a directory at the path is not a ledger (fail-closed)
|
|
2510
|
+
retros.append({"milestone": retro.parent.name,
|
|
2511
|
+
"path": str(retro.relative_to(root)),
|
|
2512
|
+
"carried_deltas": _retro_carried(retro), "tier": tier})
|
|
2513
|
+
|
|
2514
|
+
# d-i — residue gate records: the residue-class facet (RISK-ACCEPTED shares the waivers[] record)
|
|
2515
|
+
residue_gates = [{"slug": s, "gate": t.get("gate")} for s, t in tasks.items()
|
|
2516
|
+
if t.get("gate") in ("RISK-ACCEPTED", "HARD-STOP")]
|
|
2517
|
+
|
|
2518
|
+
# d-ii — §6 disclosed residue: in-state tasks' '- [⚠]' VERIFY list items (the pinned rule)
|
|
2519
|
+
# e — coverage-gaps proxy: in-state §7 Watch still the '<error rate' placeholder head
|
|
2520
|
+
residue_disclosed, coverage_gaps = [], []
|
|
2521
|
+
for slug in tasks:
|
|
2522
|
+
try:
|
|
2523
|
+
text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8")
|
|
2524
|
+
except OSError:
|
|
2525
|
+
continue # unreadable TASK.md -> skip this task's prose records
|
|
2526
|
+
m = re.search(r"##\s*6\b.*?(?=\n##\s*\d|\Z)", text, re.S) # the VERIFY section only
|
|
2527
|
+
for line in (m.group(0) if m else "").splitlines():
|
|
2528
|
+
st = line.strip()
|
|
2529
|
+
if st.startswith("- [⚠]"):
|
|
2530
|
+
residue_disclosed.append({"slug": slug, "line": st[len("- [⚠]"):].strip()})
|
|
2531
|
+
for line in text.splitlines():
|
|
2532
|
+
if line.startswith("Watch") and "<error rate" in line: # unfilled <…> template head
|
|
2533
|
+
coverage_gaps.append({"slug": slug})
|
|
2534
|
+
break
|
|
2535
|
+
|
|
2536
|
+
return {
|
|
2537
|
+
"open_deltas": open_deltas,
|
|
2538
|
+
"waivers": waivers,
|
|
2539
|
+
"retros": retros,
|
|
2540
|
+
"residue_gates": residue_gates,
|
|
2541
|
+
"residue_disclosed": residue_disclosed,
|
|
2542
|
+
"coverage_gaps": coverage_gaps,
|
|
2543
|
+
"summary": {
|
|
2544
|
+
"open_deltas": open_deltas["total"], "waivers": len(waivers), "retros": len(retros),
|
|
2545
|
+
"residue_gates": len(residue_gates), "residue_disclosed": len(residue_disclosed),
|
|
2546
|
+
"coverage_gaps": len(coverage_gaps),
|
|
2547
|
+
"milestones_live": len(milestones), "milestones_consolidated": len(archived),
|
|
2548
|
+
},
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
|
|
2552
|
+
def cmd_graduation_report(args: argparse.Namespace) -> None:
|
|
2553
|
+
"""Read-only: GATHER the MVP loop's evidence into five labeled record-sets for the
|
|
2554
|
+
graduate.md interview. text (default) or --json (the frozen JSON facts interface). Exit 0 ALWAYS —
|
|
2555
|
+
a gather, not a gate; the ONLY non-zero exit is no_project. Judges nothing. NO writes."""
|
|
2556
|
+
root = find_root()
|
|
2557
|
+
if root is None: # frozen contract: fail-closed with a no_project signal
|
|
2558
|
+
_die("no_project: no .add/ project found. Run `add.py init` first.")
|
|
2559
|
+
state = load_state(root)
|
|
2560
|
+
d = graduation_data(root, state)
|
|
2561
|
+
|
|
2562
|
+
if getattr(args, "json", False):
|
|
2563
|
+
print(json.dumps(d, ensure_ascii=False, indent=2))
|
|
2564
|
+
return
|
|
2565
|
+
|
|
2566
|
+
s = d["summary"]
|
|
2567
|
+
L = ["GRADUATION REPORT — MVP-loop evidence (gather, not judge)", ""]
|
|
2568
|
+
L.append(f"Open deltas ({s['open_deltas']}) — unfolded lessons by competency:")
|
|
2569
|
+
for comp, entries in d["open_deltas"]["by_competency"].items():
|
|
2570
|
+
for e in entries:
|
|
2571
|
+
L.append(f" - [{comp}] {e['text']} [{e['task']}]")
|
|
2572
|
+
L.append("")
|
|
2573
|
+
L.append(f"Waivers ({s['waivers']}) — open RISK-ACCEPTED, soonest expiry first:")
|
|
2574
|
+
for w in d["waivers"]:
|
|
2575
|
+
L.append(f" - {w['slug']}: {w['owner']} · {w['ticket']} · expires {w['expires']}")
|
|
2576
|
+
L.append("")
|
|
2577
|
+
_live_retros = sum(1 for r in d["retros"] if r["tier"] == "live")
|
|
2578
|
+
_cons_retros = s["retros"] - _live_retros
|
|
2579
|
+
L.append(f"RETRO records ({s['retros']}: {_live_retros} live · {_cons_retros} consolidated) — "
|
|
2580
|
+
f"milestones: {s['milestones_live']} live · "
|
|
2581
|
+
f"{s['milestones_consolidated']} represented by RETRO record:")
|
|
2582
|
+
for r in d["retros"]:
|
|
2583
|
+
L.append(f" - {r['milestone']} [{r['tier']}]: {r['path']} ({r['carried_deltas']} carried)")
|
|
2584
|
+
L.append("")
|
|
2585
|
+
L.append(f"Verify residue — gate records ({s['residue_gates']}, RISK-ACCEPTED/HARD-STOP):")
|
|
2586
|
+
for g in d["residue_gates"]:
|
|
2587
|
+
L.append(f" - {g['slug']}: {g['gate']}")
|
|
2588
|
+
L.append(f"Verify residue — disclosed §6 lines ({s['residue_disclosed']}):")
|
|
2589
|
+
for r in d["residue_disclosed"]:
|
|
2590
|
+
L.append(f" - {r['slug']}: {r['line']}")
|
|
2591
|
+
L.append("")
|
|
2592
|
+
L.append(f"Coverage gaps ({s['coverage_gaps']}) — PROXY (monitor not declared; §7 Watch unfilled):")
|
|
2593
|
+
for c in d["coverage_gaps"]:
|
|
2594
|
+
L.append(f" - {c['slug']}")
|
|
2595
|
+
print("\n".join(L))
|
|
2596
|
+
|
|
2597
|
+
|
|
2179
2598
|
def cmd_deltas(args: argparse.Namespace) -> None:
|
|
2180
|
-
"""Read-only: report all open
|
|
2599
|
+
"""Read-only: report all open lessons learned grouped by competency.
|
|
2181
2600
|
|
|
2182
2601
|
Scans every .add/tasks/*/TASK.md '### Competency deltas' block for lines
|
|
2183
2602
|
matching the delta grammar; shows only `open` entries in canonical competency
|
|
@@ -2199,7 +2618,7 @@ def cmd_deltas(args: argparse.Namespace) -> None:
|
|
|
2199
2618
|
print("no open deltas.")
|
|
2200
2619
|
return
|
|
2201
2620
|
|
|
2202
|
-
print(f"open
|
|
2621
|
+
print(f"open lessons learned ({total} total):")
|
|
2203
2622
|
for comp in _COMPETENCY_ORDER:
|
|
2204
2623
|
entries = by_comp[comp]
|
|
2205
2624
|
if not entries:
|
|
@@ -2324,7 +2743,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2324
2743
|
pi.set_defaults(func=cmd_init)
|
|
2325
2744
|
|
|
2326
2745
|
pl = sub.add_parser("lock",
|
|
2327
|
-
help="freeze the autonomous setup (the human
|
|
2746
|
+
help="freeze the autonomous setup (the human baseline approval) and open the build")
|
|
2328
2747
|
pl.add_argument("--by", default=None, help="who is locking (default: current OS user)")
|
|
2329
2748
|
pl.add_argument("--layers", default=None,
|
|
2330
2749
|
help="comma-separated lock layers (default: foundation,scope,contract)")
|
|
@@ -2371,6 +2790,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2371
2790
|
pam.add_argument("slug")
|
|
2372
2791
|
pam.set_defaults(func=cmd_archive_milestone)
|
|
2373
2792
|
|
|
2793
|
+
pco = sub.add_parser("compact",
|
|
2794
|
+
help="heavy archive: move an archived milestone's files into "
|
|
2795
|
+
".add/archive/<slug>/ (recoverable reverse move)")
|
|
2796
|
+
pco.add_argument("slug")
|
|
2797
|
+
pco.set_defaults(func=cmd_compact)
|
|
2798
|
+
|
|
2374
2799
|
pp = sub.add_parser("phase", help="set a task's phase explicitly")
|
|
2375
2800
|
pp.add_argument("phase", choices=PHASES)
|
|
2376
2801
|
pp.add_argument("slug", nargs="?", default=None)
|
|
@@ -2388,8 +2813,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2388
2813
|
pg.add_argument("--expires", help="RISK-ACCEPTED waiver: expiry date")
|
|
2389
2814
|
pg.set_defaults(func=cmd_gate)
|
|
2390
2815
|
|
|
2816
|
+
pr = sub.add_parser("reopen", help="return a done task to an earlier phase with a recorded reason")
|
|
2817
|
+
pr.add_argument("slug", nargs="?", default=None)
|
|
2818
|
+
# --to / --reason are validated in-body (not argparse choices) so the named reject
|
|
2819
|
+
# codes fire (reopen_target_invalid / reopen_reason_required), not a bare exit-2.
|
|
2820
|
+
pr.add_argument("--to", default=None, help="target phase (specify..observe)")
|
|
2821
|
+
pr.add_argument("--reason", default="", help="why the task is reopened (required, non-empty)")
|
|
2822
|
+
pr.set_defaults(func=cmd_reopen)
|
|
2823
|
+
|
|
2391
2824
|
ps = sub.add_parser("stage", help="set the project stage")
|
|
2392
2825
|
ps.add_argument("stage", choices=STAGES)
|
|
2826
|
+
ps.add_argument("--force", action="store_true",
|
|
2827
|
+
help="override the →production roadmap guard (stage_no_roadmap)")
|
|
2393
2828
|
ps.set_defaults(func=cmd_stage)
|
|
2394
2829
|
|
|
2395
2830
|
pst = sub.add_parser("status", help="print where the project is (resume point)")
|
|
@@ -2424,19 +2859,26 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2424
2859
|
prp.add_argument("--plain", action="store_true",
|
|
2425
2860
|
help="ASCII, no color, fixed width (pipe / CI / screen-reader safe)")
|
|
2426
2861
|
prp.add_argument("--decide", action="store_true",
|
|
2427
|
-
help="decision-
|
|
2428
|
-
"(task ->
|
|
2862
|
+
help="decision-point digest: what needs the human's judgment NOW "
|
|
2863
|
+
"(task -> decision digest; milestone -> DECIDE NEXT only; "
|
|
2429
2864
|
"bare -> the active task)")
|
|
2430
2865
|
prp.set_defaults(func=cmd_report)
|
|
2431
2866
|
|
|
2432
2867
|
pdt = sub.add_parser("deltas",
|
|
2433
|
-
help="read-only report: open
|
|
2868
|
+
help="read-only report: open lessons learned grouped by competency")
|
|
2434
2869
|
pdt.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
2435
2870
|
pdt.set_defaults(func=cmd_deltas)
|
|
2436
2871
|
|
|
2872
|
+
pgr = sub.add_parser("graduation-report",
|
|
2873
|
+
help="read-only: gather the MVP loop's evidence (deltas · waivers · RETROs · "
|
|
2874
|
+
"residue · coverage gaps) for a graduation interview — gathers, never judges")
|
|
2875
|
+
pgr.add_argument("--json", action="store_true", help="emit the frozen JSON facts interface")
|
|
2876
|
+
pgr.add_argument("--plain", action="store_true", help="ASCII/pipe-safe text (output is plain by default)")
|
|
2877
|
+
pgr.set_defaults(func=cmd_graduation_report)
|
|
2878
|
+
|
|
2437
2879
|
pau = sub.add_parser("audit",
|
|
2438
|
-
help="read-only: verify human
|
|
2439
|
-
"(exit 1 on findings — the CI enforcement
|
|
2880
|
+
help="read-only: verify recorded human decision points left well-formed records "
|
|
2881
|
+
"(exit 1 on findings — the CI enforcement gate)")
|
|
2440
2882
|
pau.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
2441
2883
|
pau.set_defaults(func=cmd_audit)
|
|
2442
2884
|
|