@pilotspace/add 1.0.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 +88 -0
- package/GETTING-STARTED.md +172 -84
- package/README.md +14 -8
- package/bin/cli.js +39 -38
- package/docs/01-principles.md +3 -3
- package/docs/02-the-flow.md +20 -13
- package/docs/03-step-1-specify.md +13 -13
- package/docs/04-step-2-scenarios.md +3 -1
- package/docs/05-step-3-contract.md +4 -2
- package/docs/06-step-4-tests.md +3 -1
- package/docs/07-step-5-build.md +1 -1
- package/docs/08-step-6-verify.md +22 -4
- package/docs/09-the-loop.md +25 -1
- package/docs/10-setup-and-stages.md +52 -9
- package/docs/11-governance.md +2 -2
- package/docs/12-roles.md +3 -3
- package/docs/13-adoption.md +3 -3
- package/docs/14-foundation.md +19 -11
- 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 +12 -11
- package/docs/appendix-g-references.md +106 -0
- package/package.json +5 -3
- package/skill/add/SKILL.md +50 -21
- package/skill/add/adopt.md +67 -0
- package/skill/add/deltas.md +20 -8
- 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 +92 -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 +38 -9
- package/skill/add/phases/4-tests.md +29 -5
- package/skill/add/phases/5-build.md +14 -4
- package/skill/add/phases/6-verify.md +38 -4
- package/skill/add/phases/7-observe.md +13 -5
- package/skill/add/report-template.md +106 -0
- package/skill/add/run.md +53 -34
- package/skill/add/scope.md +24 -2
- package/skill/add/setup-review.md +65 -0
- package/skill/add/streams.md +256 -0
- package/tooling/add.py +1388 -62
- 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 +39 -11
package/tooling/add.py
CHANGED
|
@@ -12,6 +12,7 @@ existing artifacts unless --force is given.
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
import argparse
|
|
15
|
+
import getpass
|
|
15
16
|
import json
|
|
16
17
|
import os
|
|
17
18
|
import re
|
|
@@ -25,7 +26,14 @@ from pathlib import Path
|
|
|
25
26
|
ROOT_DIRNAME = ".add"
|
|
26
27
|
STATE_FILE = "state.json"
|
|
27
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)"
|
|
28
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"
|
|
29
37
|
PHASES = ("specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done")
|
|
30
38
|
GATES = ("none", "PASS", "RISK-ACCEPTED", "HARD-STOP")
|
|
31
39
|
|
|
@@ -37,7 +45,7 @@ def _phase_index(name: str) -> int:
|
|
|
37
45
|
# `add.py guide` copy: per-phase (concrete next action, book chapter to read).
|
|
38
46
|
# Keep the action wording aligned with each phase's EXIT line in the TASK template.
|
|
39
47
|
PHASE_GUIDE = {
|
|
40
|
-
"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",
|
|
41
49
|
"03-step-1-specify.md"),
|
|
42
50
|
"scenarios": ("write one Given/When/Then per Must AND per Reject; every result observable",
|
|
43
51
|
"04-step-2-scenarios.md"),
|
|
@@ -47,7 +55,7 @@ PHASE_GUIDE = {
|
|
|
47
55
|
"06-step-4-tests.md"),
|
|
48
56
|
"build": ("write the minimum code to pass the tests; change no test and no contract",
|
|
49
57
|
"07-step-5-build.md"),
|
|
50
|
-
"verify": ("run the suite +
|
|
58
|
+
"verify": ("run the suite + non-functional checks, then record the gate",
|
|
51
59
|
"08-step-6-verify.md"),
|
|
52
60
|
"observe": ("note what to watch + the spec delta for the next loop",
|
|
53
61
|
"09-the-loop.md"),
|
|
@@ -84,8 +92,8 @@ Framings weighed:
|
|
|
84
92
|
Must:
|
|
85
93
|
Reject:
|
|
86
94
|
After:
|
|
87
|
-
Assumptions —
|
|
88
|
-
⚠ <most likely wrong> —
|
|
95
|
+
Assumptions — lowest-confidence first:
|
|
96
|
+
⚠ <most likely wrong> — lowest confidence because <why>; if wrong: <cost>
|
|
89
97
|
|
|
90
98
|
## 2 · SCENARIOS
|
|
91
99
|
## 3 · CONTRACT
|
|
@@ -162,7 +170,14 @@ def _require_root() -> Path:
|
|
|
162
170
|
|
|
163
171
|
|
|
164
172
|
def load_state(root: Path) -> dict:
|
|
165
|
-
|
|
173
|
+
"""Load + parse state.json, failing CLOSED. A corrupt or unreadable state file
|
|
174
|
+
dies with a clean 'state_invalid' message (never a raw traceback), so every
|
|
175
|
+
command that loads state degrades gracefully (design-for-failure)."""
|
|
176
|
+
try:
|
|
177
|
+
return json.loads((root / STATE_FILE).read_text(encoding="utf-8"))
|
|
178
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
179
|
+
_die(f"state_invalid: {root / STATE_FILE} is corrupt or unreadable "
|
|
180
|
+
f"({e.__class__.__name__}) — restore it from git or a backup")
|
|
166
181
|
|
|
167
182
|
|
|
168
183
|
def _load_state_for_json() -> tuple[Path, dict]:
|
|
@@ -191,6 +206,15 @@ def save_state(root: Path, state: dict) -> None:
|
|
|
191
206
|
_atomic_write(root / STATE_FILE, json.dumps(state, indent=2) + "\n")
|
|
192
207
|
|
|
193
208
|
|
|
209
|
+
def _setup_locked(state: dict) -> bool:
|
|
210
|
+
"""True when the project's setup is locked — i.e. the build-boundary gate is OPEN.
|
|
211
|
+
|
|
212
|
+
A state with NO "setup" key is GRANDFATHERED-locked: plain `init` and every legacy
|
|
213
|
+
project are never gated (the lock is opt-in via `init --await-lock`). The gate is
|
|
214
|
+
therefore active in exactly one case: "setup" present AND locked is False."""
|
|
215
|
+
return ("setup" not in state) or (state["setup"].get("locked") is True)
|
|
216
|
+
|
|
217
|
+
|
|
194
218
|
def _die(msg: str, code: int = 1) -> None:
|
|
195
219
|
print(f"add: error: {msg}", file=sys.stderr)
|
|
196
220
|
raise SystemExit(code)
|
|
@@ -205,27 +229,37 @@ def _die(msg: str, code: int = 1) -> None:
|
|
|
205
229
|
# 20%+ more cost), so the stable pointer is the whole point.
|
|
206
230
|
|
|
207
231
|
def _guideline_block() -> str:
|
|
208
|
-
"""The canonical ADD block (markers + body, no trailing newline).
|
|
232
|
+
"""The canonical ADD block (markers + body, no trailing newline).
|
|
233
|
+
|
|
234
|
+
Agent-agnostic by design (v14 agent-portability): the routing steps depend
|
|
235
|
+
only on the CLI and plain files, so any agent — Claude, Cursor, Copilot,
|
|
236
|
+
Codex — can follow them. Claude additionally gets the `add` skill."""
|
|
209
237
|
return (
|
|
210
238
|
f"{_GUIDE_BEGIN}\n"
|
|
211
239
|
"## ADD — how to work in this repo\n"
|
|
212
240
|
"\n"
|
|
213
241
|
"This project uses **ADD (AI-Driven Development)**: you, the AI, drive the build;\n"
|
|
214
|
-
"the human owns direction and verification.
|
|
242
|
+
"the human owns direction and verification. The loop below works for any agent —\n"
|
|
243
|
+
"Claude, Cursor, Copilot, Codex — through the CLI alone. Before you change code:\n"
|
|
215
244
|
"\n"
|
|
216
245
|
"1. Run `python3 .add/tooling/add.py status` — where the project is and what's\n"
|
|
217
246
|
" next (the resume point; read it first every session).\n"
|
|
218
247
|
"2. Read `.add/PROJECT.md` — the foundation (domain · spec · UI/UX) every task\n"
|
|
219
248
|
" builds on.\n"
|
|
220
|
-
"3.
|
|
221
|
-
"
|
|
222
|
-
"
|
|
223
|
-
" frozen contract — followed by a self-driving build→verify run. `add.py` is your\n"
|
|
224
|
-
" hands (scaffold + track state); the human talks to you, not the CLI.\n"
|
|
249
|
+
"3. Run `python3 .add/tooling/add.py guide` — it names the phase and the exact\n"
|
|
250
|
+
" phase-guide file to read (the `guide :` line). Work ONLY that phase — each\n"
|
|
251
|
+
" guide ends with its exit gate and the command to move on.\n"
|
|
225
252
|
"\n"
|
|
226
|
-
"The
|
|
227
|
-
"
|
|
228
|
-
"
|
|
253
|
+
"The flow: INTAKE sizes a request into a milestone; each task runs the\n"
|
|
254
|
+
"**specification bundle** — Spec+Scenarios+Contract+Tests as one bundle,\n"
|
|
255
|
+
"ONE human approval at the frozen contract — then a self-driving build→verify\n"
|
|
256
|
+
"run. Non-negotiable for every agent:\n"
|
|
257
|
+
"Never weaken a test or edit a frozen contract to make a build pass; a security\n"
|
|
258
|
+
"finding is always HARD-STOP — never auto-passed.\n"
|
|
259
|
+
"\n"
|
|
260
|
+
"On Claude Code the `add` skill drives this loop automatically; other agents\n"
|
|
261
|
+
"follow the three steps. The book is in `.add/docs/`. This block is generated\n"
|
|
262
|
+
"by `add.py sync-guidelines`; edit outside the markers, not inside.\n"
|
|
229
263
|
f"{_GUIDE_END}"
|
|
230
264
|
)
|
|
231
265
|
|
|
@@ -294,6 +328,24 @@ def _inject_guidelines(project_root: Path) -> list[tuple[str, str]]:
|
|
|
294
328
|
|
|
295
329
|
# --- commands ----------------------------------------------------------------
|
|
296
330
|
|
|
331
|
+
_INIT_EXCLUDE = {
|
|
332
|
+
".add", "AGENTS.md", "CLAUDE.md", ".git",
|
|
333
|
+
".gitignore", ".gitattributes", ".github", ".editorconfig", # VCS/CI/editor scaffolding — no domain signal
|
|
334
|
+
"LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", # legal boilerplate — no domain signal
|
|
335
|
+
} # README/docs/source are NOT excluded: they carry domain content adopt.md maps -> brownfield
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _is_brownfield(base: Path) -> bool:
|
|
339
|
+
"""True when `base` already holds project content beyond the tool's own scaffolding.
|
|
340
|
+
|
|
341
|
+
Judgment-free: a mechanical fact (does the dir hold a non-excluded entry?), so the
|
|
342
|
+
autonomous-onboarding flow knows to map existing code into the living documentation. INTERPRETING
|
|
343
|
+
that code stays with the AI (skill/add/adopt.md) — the engine only detects + signals."""
|
|
344
|
+
if not base.is_dir():
|
|
345
|
+
return False
|
|
346
|
+
return any(child.name not in _INIT_EXCLUDE for child in base.iterdir())
|
|
347
|
+
|
|
348
|
+
|
|
297
349
|
def cmd_init(args: argparse.Namespace) -> None:
|
|
298
350
|
base = Path(args.dir).resolve()
|
|
299
351
|
root = base / ROOT_DIRNAME
|
|
@@ -330,14 +382,24 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
330
382
|
"created": _now(),
|
|
331
383
|
"updated": _now(),
|
|
332
384
|
}
|
|
385
|
+
if getattr(args, "await_lock", False):
|
|
386
|
+
# opt-in: seed an UNLOCKED setup so the build-boundary gate is active until
|
|
387
|
+
# `add.py lock`. Plain init omits this key entirely (grandfathered-locked).
|
|
388
|
+
state["setup"] = {"locked": False, "locked_at": None, "locked_by": None, "layers": []}
|
|
333
389
|
save_state(root, state)
|
|
334
390
|
# zero-config: give any agent a stable pointer into the ADD runtime.
|
|
335
391
|
for name, action in _inject_guidelines(base):
|
|
336
392
|
if action != "unchanged":
|
|
337
393
|
print(f"{action:>9} {name}")
|
|
338
394
|
print(f"initialised ADD project '{state['project']}' (stage: {state['stage']}) at {root}")
|
|
339
|
-
|
|
340
|
-
|
|
395
|
+
if _is_brownfield(base):
|
|
396
|
+
# Existing code present — the AI maps it SILENTLY into the survivors (skill/add/adopt.md),
|
|
397
|
+
# then the human locks it down. The engine only flags it; it never reads or fills the code.
|
|
398
|
+
print("brownfield: existing code detected — the `add` skill maps it into your")
|
|
399
|
+
print(" foundation (silent), then you lock it down: add.py lock")
|
|
400
|
+
else:
|
|
401
|
+
print("next: open Claude Code, run `/add`, and say what you want to build —")
|
|
402
|
+
print(" the `add` skill sizes it into a milestone and drives the build with you.")
|
|
341
403
|
|
|
342
404
|
|
|
343
405
|
def cmd_sync_guidelines(args: argparse.Namespace) -> None:
|
|
@@ -349,6 +411,9 @@ def cmd_sync_guidelines(args: argparse.Namespace) -> None:
|
|
|
349
411
|
def cmd_new_task(args: argparse.Namespace) -> None:
|
|
350
412
|
root = _require_root()
|
|
351
413
|
state = load_state(root)
|
|
414
|
+
# build-boundary gate: pre-lock, EXACTLY one first task may be drafted; refuse a 2nd.
|
|
415
|
+
if not _setup_locked(state) and state.get("tasks"):
|
|
416
|
+
_die("setup_unlocked: lock the foundation first — add.py lock")
|
|
352
417
|
slug = args.slug
|
|
353
418
|
if not slug.replace("-", "").replace("_", "").isalnum():
|
|
354
419
|
_die("slug must be alphanumeric with - or _ only")
|
|
@@ -453,6 +518,24 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
453
518
|
if idx >= len(PHASES) - 1:
|
|
454
519
|
_die(f"task '{slug}' already at final phase ({cur})")
|
|
455
520
|
nxt = PHASES[idx + 1]
|
|
521
|
+
# build-boundary gate: pre-lock the front (specify..tests) is allowed, but crossing
|
|
522
|
+
# into build/verify/observe/done is refused until `add.py lock`.
|
|
523
|
+
if not _setup_locked(state) and nxt in ("build", "verify", "observe", "done"):
|
|
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
|
|
456
539
|
state["tasks"][slug]["phase"] = nxt
|
|
457
540
|
state["tasks"][slug]["updated"] = _now()
|
|
458
541
|
_sync_task_marker(root, slug, nxt)
|
|
@@ -460,10 +543,33 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
460
543
|
print(f"task '{slug}' phase {cur} -> {nxt}")
|
|
461
544
|
|
|
462
545
|
|
|
546
|
+
# The mechanized high-risk guard (run.md, v14): judging WHAT is high-risk stays
|
|
547
|
+
# human — a scope declares `risk: high` in its TASK.md header at the freeze. The
|
|
548
|
+
# engine then enforces the pure token contradiction: risk: high WITHOUT
|
|
549
|
+
# autonomy: conservative is unguarded, and completion is refused. Tokens are
|
|
550
|
+
# read from the header region (text before the first section heading) with HTML
|
|
551
|
+
# comments stripped — a documentation comment is never a declaration.
|
|
552
|
+
_RISK_HIGH_RE = re.compile(r"\brisk:\s*high\b")
|
|
553
|
+
_AUTONOMY_CONSERVATIVE_RE = re.compile(r"\bautonomy:\s*conservative\b")
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _task_header(root: Path, slug: str) -> str:
|
|
557
|
+
"""The TASK.md header region — where declared tokens (risk · autonomy)
|
|
558
|
+
live — with HTML comments stripped. Missing file -> '' (no tokens)."""
|
|
559
|
+
try:
|
|
560
|
+
text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8")
|
|
561
|
+
except OSError:
|
|
562
|
+
return ""
|
|
563
|
+
return re.sub(r"<!--.*?-->", "", text.split("\n## ", 1)[0], flags=re.S)
|
|
564
|
+
|
|
565
|
+
|
|
463
566
|
def cmd_gate(args: argparse.Namespace) -> None:
|
|
464
567
|
root = _require_root()
|
|
465
568
|
state = load_state(root)
|
|
466
569
|
slug = _resolve_task(state, args.slug)
|
|
570
|
+
# build-boundary gate: no verdict may be recorded before the setup is locked.
|
|
571
|
+
if not _setup_locked(state):
|
|
572
|
+
_die("setup_unlocked: lock the foundation first — add.py lock")
|
|
467
573
|
if args.outcome not in GATES:
|
|
468
574
|
_die(f"outcome must be one of: {', '.join(GATES)}")
|
|
469
575
|
# Completing outcomes (PASS, RISK-ACCEPTED) are the VERIFY step's verdict, so they
|
|
@@ -478,6 +584,14 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
478
584
|
else "gate_risk_accepted_before_verify")
|
|
479
585
|
_die(f"{code}: task '{slug}' is at '{current}'; reach the verify phase "
|
|
480
586
|
f"first (or `add.py phase verify {slug}` to override)")
|
|
587
|
+
# the mechanized high-risk guard: an unguarded high-risk header refuses
|
|
588
|
+
# COMPLETION (PASS / RISK-ACCEPTED) until the dial is lowered and a human
|
|
589
|
+
# owns the gate. HARD-STOP is never blocked — stopping is always allowed.
|
|
590
|
+
hdr = _task_header(root, slug)
|
|
591
|
+
if _RISK_HIGH_RE.search(hdr) and not _AUTONOMY_CONSERVATIVE_RE.search(hdr):
|
|
592
|
+
_die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high "
|
|
593
|
+
"without autonomy: conservative — lower the autonomy level in the TASK.md "
|
|
594
|
+
"header; a human must own a high-risk gate (run.md guard)")
|
|
481
595
|
if args.outcome == "RISK-ACCEPTED":
|
|
482
596
|
# A waiver must be SIGNED: owner, ticket, expiry (glossary). Stored in state
|
|
483
597
|
# so a later `check` can read/expire it. Refuse a partial waiver outright.
|
|
@@ -499,19 +613,111 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
499
613
|
print("HARD-STOP recorded: return to BUILD; nothing ships on a failing/security gate.")
|
|
500
614
|
|
|
501
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
|
+
|
|
655
|
+
def cmd_lock(args: argparse.Namespace) -> None:
|
|
656
|
+
"""The human baseline approval: freeze the autonomously-drafted setup in ONE atomic write.
|
|
657
|
+
|
|
658
|
+
Setup-level analog of the contract freeze — the only new human action onboarding
|
|
659
|
+
needs. `add.py lock` is judgment-free (it records the signature; it does NOT inspect
|
|
660
|
+
the artifacts): the human's signature IS the gate."""
|
|
661
|
+
root = _require_root()
|
|
662
|
+
state = load_state(root)
|
|
663
|
+
# idempotent-guarded: the predicate also treats a grandfathered (no "setup" key)
|
|
664
|
+
# project as already locked, so a bare re-lock there refuses too.
|
|
665
|
+
if _setup_locked(state) and not args.force:
|
|
666
|
+
_die("already_locked: setup is already locked (use --force to re-lock)")
|
|
667
|
+
# parse layers BEFORE any write so an invalid request never half-locks (design-for-failure).
|
|
668
|
+
raw = args.layers if args.layers is not None else "foundation,scope,contract"
|
|
669
|
+
layers = [s.strip() for s in raw.split(",") if s.strip()]
|
|
670
|
+
if not layers:
|
|
671
|
+
_die("layers_invalid: --layers must name at least one lock layer")
|
|
672
|
+
who = args.by or getpass.getuser()
|
|
673
|
+
when = _now()
|
|
674
|
+
# ONE atomic write — no partial lock state.
|
|
675
|
+
state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers}
|
|
676
|
+
save_state(root, state)
|
|
677
|
+
if getattr(args, "json", False):
|
|
678
|
+
print(json.dumps(
|
|
679
|
+
{"locked": True, "locked_at": when, "locked_by": who, "layers": layers},
|
|
680
|
+
separators=(",", ":")))
|
|
681
|
+
else:
|
|
682
|
+
print(f"locked setup ({','.join(layers)}) by {who} @ {when}")
|
|
683
|
+
|
|
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
|
+
|
|
502
694
|
def cmd_stage(args: argparse.Namespace) -> None:
|
|
503
695
|
root = _require_root()
|
|
504
696
|
state = load_state(root)
|
|
505
697
|
if args.stage not in STAGES:
|
|
506
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
|
|
507
711
|
state["stage"] = args.stage
|
|
508
712
|
save_state(root, state)
|
|
509
713
|
print(f"project stage -> {args.stage}")
|
|
714
|
+
if bypassing:
|
|
715
|
+
print("(--force: bypassed roadmap check — no production milestone drafted)")
|
|
510
716
|
|
|
511
717
|
|
|
512
718
|
def cmd_status(args: argparse.Namespace) -> None:
|
|
513
719
|
if getattr(args, "json", False):
|
|
514
|
-
|
|
720
|
+
root, state = _load_state_for_json()
|
|
515
721
|
tasks = state.get("tasks") or {}
|
|
516
722
|
milestones = state.get("milestones") or {}
|
|
517
723
|
ms_list = []
|
|
@@ -520,22 +726,43 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
520
726
|
ms_list.append({"slug": mslug, "status": m.get("status", "active"),
|
|
521
727
|
"done": sum(1 for t in members if _task_done(t)),
|
|
522
728
|
"total": len(members)})
|
|
729
|
+
grad_ready, grad_met, grad_total = _graduation_ready(root, state)
|
|
523
730
|
print(json.dumps({
|
|
524
731
|
"project": state.get("project"), "stage": state.get("stage"),
|
|
525
732
|
"active_task": state.get("active_task"),
|
|
526
733
|
"milestones": ms_list,
|
|
527
734
|
"tasks": [{"slug": s, "phase": t.get("phase"), "gate": t.get("gate"),
|
|
528
|
-
"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}}))
|
|
529
738
|
return
|
|
530
739
|
root = _require_root()
|
|
531
740
|
state = load_state(root)
|
|
532
741
|
active = state.get("active_task")
|
|
533
742
|
tasks = state.get("tasks", {})
|
|
534
|
-
|
|
535
|
-
|
|
743
|
+
# Compute once: True when setup is present AND locked is False (the lock-gate window).
|
|
744
|
+
# Reuses the canonical helper — do NOT write a parallel predicate.
|
|
745
|
+
unlocked = not _setup_locked(state)
|
|
746
|
+
print(f"project : {state.get('project', '(unknown)')}")
|
|
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})")
|
|
536
755
|
# foundation pointer — read the cross-milestone context first (anti-rot)
|
|
537
756
|
if (root / "PROJECT.md").exists():
|
|
538
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)")
|
|
539
766
|
|
|
540
767
|
# milestone rollup (only when milestones are in use)
|
|
541
768
|
milestones = state.get("milestones") or {}
|
|
@@ -548,6 +775,12 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
548
775
|
mark = "*" if mslug == active_ms else " "
|
|
549
776
|
print(f" {mark} {mslug:<20} {done}/{len(members)} tasks done"
|
|
550
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}")
|
|
551
784
|
|
|
552
785
|
# archived rollup — one line keeps state visible without re-bloating status
|
|
553
786
|
archived = state.get("archived") or []
|
|
@@ -560,13 +793,18 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
560
793
|
print(f"active : {active or '(none)'}")
|
|
561
794
|
if not tasks:
|
|
562
795
|
# First-run panel: a brand-new project's status is the moment a user is most
|
|
563
|
-
# lost.
|
|
564
|
-
#
|
|
796
|
+
# lost. When the setup is unlocked, the only correct next move is review+lock —
|
|
797
|
+
# suppress the generic /add hint and name the two steps that matter.
|
|
565
798
|
print("tasks : (none yet)")
|
|
566
799
|
print()
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
800
|
+
if unlocked:
|
|
801
|
+
print("setup : UNLOCKED — review .add/SETUP-REVIEW.md (lowest-confidence first),"
|
|
802
|
+
" then sign: add.py lock")
|
|
803
|
+
print(" (the build-boundary gate is closed until the foundation is locked)")
|
|
804
|
+
else:
|
|
805
|
+
print("next : you're set up. In Claude Code, run /add and say what you want to")
|
|
806
|
+
print(" build — the `add` skill sizes it into a milestone and drives the")
|
|
807
|
+
print(' build with you. Escape hatch: add.py new-task <slug> --title "..."')
|
|
570
808
|
return
|
|
571
809
|
print("tasks :")
|
|
572
810
|
for slug, t in tasks.items():
|
|
@@ -575,7 +813,18 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
575
813
|
dep_s = f" deps={','.join(deps)}" if deps else ""
|
|
576
814
|
ms_s = f" [{t['milestone']}]" if t.get("milestone") else ""
|
|
577
815
|
print(f" {mark} {slug:<24} phase={t['phase']:<10} gate={t['gate']}{ms_s}{dep_s}")
|
|
578
|
-
|
|
816
|
+
# fold-pressure nudge: surface unfolded competency deltas so emission can't
|
|
817
|
+
# silently outrun the human fold (read-only; v11). Silent when none are open.
|
|
818
|
+
open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
|
|
819
|
+
if open_deltas:
|
|
820
|
+
print(f"deltas : {open_deltas} open — consolidate at milestone close (add.py deltas)")
|
|
821
|
+
# When the setup is unlocked, the only terminal guidance that matters is
|
|
822
|
+
# review+lock; suppress the generic resume block so it does not compete.
|
|
823
|
+
if unlocked:
|
|
824
|
+
print("\nsetup : UNLOCKED — review .add/SETUP-REVIEW.md (lowest-confidence first),"
|
|
825
|
+
" then sign: add.py lock")
|
|
826
|
+
print(" (the build-boundary gate is closed until the foundation is locked)")
|
|
827
|
+
elif active and active in tasks:
|
|
579
828
|
ph = tasks[active]["phase"]
|
|
580
829
|
if ph == "done":
|
|
581
830
|
print(f"\nresume : task '{active}' is done ({tasks[active]['gate']}).")
|
|
@@ -585,18 +834,42 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
585
834
|
print(f" read .add/tasks/{active}/TASK.md and continue that phase.")
|
|
586
835
|
|
|
587
836
|
|
|
837
|
+
# Agent-portability (v14): `guide` names the PHASE PLAYBOOK file — the same
|
|
838
|
+
# guides the Claude skill loads, installed as plain markdown by every channel
|
|
839
|
+
# at .claude/skills/add/phases/ — so ANY agent (Cursor, Copilot, Codex) can be
|
|
840
|
+
# routed there through the CLI alone. Never a dead pointer: the path is printed
|
|
841
|
+
# only if the file exists; a missing tree gets an install hint instead.
|
|
842
|
+
_PHASE_GUIDE_FILES = {
|
|
843
|
+
"specify": "1-specify.md", "scenarios": "2-scenarios.md",
|
|
844
|
+
"contract": "3-contract.md", "tests": "4-tests.md",
|
|
845
|
+
"build": "5-build.md", "verify": "6-verify.md", "observe": "7-observe.md",
|
|
846
|
+
}
|
|
847
|
+
_SKILL_PHASES_DIR = Path(".claude") / "skills" / "add" / "phases"
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _phase_guide_path(project_root: Path, phase: str) -> str | None:
|
|
851
|
+
"""Relative path to the phase playbook if it exists, else None.
|
|
852
|
+
done/unknown phases have no playbook (the `then:` line routes onward)."""
|
|
853
|
+
fname = _PHASE_GUIDE_FILES.get(phase)
|
|
854
|
+
if fname is None:
|
|
855
|
+
return None
|
|
856
|
+
rel = _SKILL_PHASES_DIR / fname
|
|
857
|
+
return str(rel) if (project_root / rel).is_file() else None
|
|
858
|
+
|
|
859
|
+
|
|
588
860
|
def cmd_guide(args: argparse.Namespace) -> None:
|
|
589
861
|
"""Answer "what do I do next?" for the active (or named) task.
|
|
590
862
|
|
|
591
863
|
Strictly read-only: load_state only — never save_state, never writes a TASK.md.
|
|
592
864
|
"""
|
|
593
865
|
if getattr(args, "json", False):
|
|
594
|
-
|
|
866
|
+
json_root, state = _load_state_for_json()
|
|
595
867
|
slug = args.slug or state.get("active_task")
|
|
596
868
|
if not slug:
|
|
597
869
|
print(json.dumps({"task": None, "phase": None, "owner": "human", "stop": True,
|
|
598
870
|
"next_step": "start your first feature -> add.py new-task <slug>",
|
|
599
|
-
"chapter": ".add/docs/02-the-flow.md", "gate": None
|
|
871
|
+
"chapter": ".add/docs/02-the-flow.md", "gate": None,
|
|
872
|
+
"guide": None}))
|
|
600
873
|
return
|
|
601
874
|
t = (state.get("tasks") or {}).get(slug)
|
|
602
875
|
if t is None:
|
|
@@ -606,7 +879,8 @@ def cmd_guide(args: argparse.Namespace) -> None:
|
|
|
606
879
|
action, chapter = PHASE_GUIDE[phase] # phase is mapped, so PHASE_GUIDE has it too
|
|
607
880
|
print(json.dumps({"task": slug, "phase": phase, "owner": owner,
|
|
608
881
|
"stop": owner != "ai", "next_step": action,
|
|
609
|
-
"chapter": f".add/docs/{chapter}", "gate": t.get("gate")
|
|
882
|
+
"chapter": f".add/docs/{chapter}", "gate": t.get("gate"),
|
|
883
|
+
"guide": _phase_guide_path(json_root.parent, phase)}))
|
|
610
884
|
return
|
|
611
885
|
root = _require_root()
|
|
612
886
|
state = load_state(root)
|
|
@@ -624,8 +898,14 @@ def cmd_guide(args: argparse.Namespace) -> None:
|
|
|
624
898
|
_die(f"task '{slug}' has unknown phase '{phase}' (state.json corrupted?)")
|
|
625
899
|
action, chapter = entry
|
|
626
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
|
|
627
902
|
print(f"next : {action}")
|
|
628
903
|
print(f"read : .add/docs/{chapter}")
|
|
904
|
+
gp = _phase_guide_path(root.parent, phase)
|
|
905
|
+
if gp is not None:
|
|
906
|
+
print(f"guide : {gp}")
|
|
907
|
+
elif phase in _PHASE_GUIDE_FILES:
|
|
908
|
+
print("guide : (phase guides not installed — npx @pilotspace/add init)")
|
|
629
909
|
if phase == "verify":
|
|
630
910
|
print("then : add.py gate PASS | RISK-ACCEPTED | HARD-STOP")
|
|
631
911
|
elif phase == "done":
|
|
@@ -698,6 +978,13 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
698
978
|
except (ValueError, TypeError):
|
|
699
979
|
ok, reason = False, f"waiver_expired (unparseable expires={exp!r})"
|
|
700
980
|
checks.append((ok, f"task '{slug}' waiver not expired", reason))
|
|
981
|
+
# delta-lint: validate all OPEN entries in the "### Competency deltas" block.
|
|
982
|
+
# Fail-closed; folded/rejected entries are skipped (open-only). Only emits a
|
|
983
|
+
# check when at least one delta-attempt is present in the block.
|
|
984
|
+
lint_result = _lint_task_deltas(root, slug)
|
|
985
|
+
if lint_result is not None:
|
|
986
|
+
ok, reason = lint_result
|
|
987
|
+
checks.append((ok, f"task '{slug}' deltas well-formed", reason))
|
|
701
988
|
|
|
702
989
|
# drift: a done milestone must have no unfinished tasks
|
|
703
990
|
for mslug, m in milestones.items():
|
|
@@ -822,6 +1109,17 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
|
822
1109
|
t = members[s]
|
|
823
1110
|
print(f" - {s} (phase={t.get('phase')}, gate={t.get('gate')})", file=sys.stderr)
|
|
824
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)")
|
|
825
1123
|
# Fail-closed: render+persist the exit report (RETRO.md) BEFORE committing the
|
|
826
1124
|
# status flip, so a write failure rolls back naturally (status never commits ->
|
|
827
1125
|
# no done-without-retro state). The retro step is read-only on state.json.
|
|
@@ -837,6 +1135,12 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
|
837
1135
|
print(f"milestone '{slug}' -> done ({len(members)} tasks complete{tail}).")
|
|
838
1136
|
print(f"wrote {retro_path.relative_to(root.parent)} (milestone exit report)")
|
|
839
1137
|
print("Confirm the MILESTONE.md exit criteria are checked, then archive/start the next.")
|
|
1138
|
+
# fold-pressure nudge: milestone close is the natural fold point for open deltas (v11)
|
|
1139
|
+
open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
|
|
1140
|
+
if open_deltas:
|
|
1141
|
+
noun = "delta" if open_deltas == 1 else "deltas"
|
|
1142
|
+
print(f"note: {open_deltas} open {noun} to consolidate into the foundation "
|
|
1143
|
+
f"— review with: add.py deltas")
|
|
840
1144
|
|
|
841
1145
|
|
|
842
1146
|
def cmd_archive_milestone(args: argparse.Namespace) -> None:
|
|
@@ -861,6 +1165,15 @@ def cmd_archive_milestone(args: argparse.Namespace) -> None:
|
|
|
861
1165
|
t = tasks[s]
|
|
862
1166
|
print(f" - {s} (phase={t.get('phase')}, gate={t.get('gate')})", file=sys.stderr)
|
|
863
1167
|
_die("milestone_has_incomplete_tasks")
|
|
1168
|
+
# pre-archive snapshot (design-for-failure): the archived record below keeps only a
|
|
1169
|
+
# slug-list, so capture the full milestone + member task records to a .bak BEFORE the
|
|
1170
|
+
# destructive deletes — an accidental archive stays recoverable (phase/gate/waiver/deps
|
|
1171
|
+
# the record drops). Mirrors the .bak the guideline injector writes before mutating.
|
|
1172
|
+
_atomic_write(
|
|
1173
|
+
root / "milestones" / slug / "pre-archive-state.bak.json",
|
|
1174
|
+
json.dumps({"milestone": ms, "tasks": {s: tasks[s] for s in members},
|
|
1175
|
+
"archived_at": _now()}, indent=2) + "\n",
|
|
1176
|
+
)
|
|
864
1177
|
# a slug-list summary (never task bodies) so the active state can't regrow,
|
|
865
1178
|
# yet cross-milestone deps on these tasks still resolve (see _archived_task_slugs)
|
|
866
1179
|
state.setdefault("archived", []).append({
|
|
@@ -882,6 +1195,70 @@ def cmd_archive_milestone(args: argparse.Namespace) -> None:
|
|
|
882
1195
|
print("files on disk are untouched; see `add.py status` for the archived rollup.")
|
|
883
1196
|
|
|
884
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
|
+
|
|
885
1262
|
def cmd_set_milestone(args: argparse.Namespace) -> None:
|
|
886
1263
|
root = _require_root()
|
|
887
1264
|
state = load_state(root)
|
|
@@ -900,6 +1277,20 @@ def cmd_set_milestone(args: argparse.Namespace) -> None:
|
|
|
900
1277
|
print(f"task '{task}' -> milestone '{new}'" if new else f"task '{task}' -> milestone (none)")
|
|
901
1278
|
|
|
902
1279
|
|
|
1280
|
+
def cmd_use(args: argparse.Namespace) -> None:
|
|
1281
|
+
"""Set the active task to an EXISTING task (switch focus) without scaffolding a new
|
|
1282
|
+
one or hand-editing state.json. advance/gate/phase still take an explicit slug; `use`
|
|
1283
|
+
just moves the default focus, closing the only gap that forced manual state edits."""
|
|
1284
|
+
root = _require_root()
|
|
1285
|
+
state = load_state(root)
|
|
1286
|
+
slug = args.slug
|
|
1287
|
+
if slug not in state.get("tasks", {}):
|
|
1288
|
+
_die("unknown_task")
|
|
1289
|
+
state["active_task"] = slug
|
|
1290
|
+
save_state(root, state)
|
|
1291
|
+
print(f"active task -> '{slug}' (phase={state['tasks'][slug]['phase']})")
|
|
1292
|
+
|
|
1293
|
+
|
|
903
1294
|
def _find_cycle(tasks: dict) -> list[str] | None:
|
|
904
1295
|
"""Return a cycle path in the depends_on graph, or None. Ignores unknown deps."""
|
|
905
1296
|
WHITE, GRAY, BLACK = 0, 1, 2
|
|
@@ -1028,6 +1419,21 @@ def _colorize(s: str) -> str:
|
|
|
1028
1419
|
return s
|
|
1029
1420
|
|
|
1030
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
|
+
|
|
1031
1437
|
def _milestone_doc(root: Path, mslug: str) -> tuple[str, str]:
|
|
1032
1438
|
"""(title, goal) from MILESTONE.md; ('(unknown)','(unknown)') if the doc is gone."""
|
|
1033
1439
|
f = root / "milestones" / mslug / MILESTONE_FILE
|
|
@@ -1057,12 +1463,115 @@ def _exit_criteria(root: Path, mslug: str) -> tuple[int, int]:
|
|
|
1057
1463
|
return met, total
|
|
1058
1464
|
|
|
1059
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
|
+
|
|
1501
|
+
def _count_test_defs(f: Path) -> int:
|
|
1502
|
+
"""`def test_` occurrences in one file — the ONE counting regex (primary and
|
|
1503
|
+
§4-declared fallback share it by construction). OSError -> 0, fail-closed."""
|
|
1504
|
+
try:
|
|
1505
|
+
return len(re.findall(r"^\s*def test_", f.read_text(encoding="utf-8"), re.M))
|
|
1506
|
+
except OSError:
|
|
1507
|
+
return 0
|
|
1508
|
+
|
|
1509
|
+
|
|
1060
1510
|
def _tests_count(root: Path, slug: str) -> int:
|
|
1061
1511
|
d = root / "tasks" / slug / "tests"
|
|
1062
1512
|
if not d.is_dir():
|
|
1063
1513
|
return 0
|
|
1064
|
-
return sum(
|
|
1065
|
-
|
|
1514
|
+
return sum(_count_test_defs(f) for f in d.glob("*.py"))
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
def _confined(p: Path, rootp: Path) -> bool:
|
|
1518
|
+
"""True only if p resolves (symlinks followed) inside rootp; errors -> False.
|
|
1519
|
+
The v2 confinement check — no read is attempted on a path that fails it."""
|
|
1520
|
+
try:
|
|
1521
|
+
return p.resolve().is_relative_to(rootp)
|
|
1522
|
+
except OSError:
|
|
1523
|
+
return False
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def _declared_tests_count(root: Path, slug: str) -> int:
|
|
1527
|
+
"""Count tests at the §4 'Tests live in:' declared path(s). PURE, fail-closed 0.
|
|
1528
|
+
Tokens are the backticked spans on the FIRST declaring line of the raw §4 body.
|
|
1529
|
+
Resolution: './…' -> task dir · contains '/' -> project root (parent of .add) ·
|
|
1530
|
+
bare name -> sibling of the previous resolved token (else task dir). A directory
|
|
1531
|
+
token counts the *.py files directly inside it; resolved files are deduped.
|
|
1532
|
+
v2 confinement: every file read must resolve inside the project root — '..'
|
|
1533
|
+
traversal, absolute tokens, and symlink escapes all contribute 0, fail-closed."""
|
|
1534
|
+
body = _raw_phase_bodies(root, slug).get(4, "")
|
|
1535
|
+
m = re.search(r"^\s*Tests live in:.*$", body, re.M)
|
|
1536
|
+
if not m:
|
|
1537
|
+
return 0
|
|
1538
|
+
tdir = root / "tasks" / slug
|
|
1539
|
+
rootp = root.parent.resolve()
|
|
1540
|
+
files: list[Path] = []
|
|
1541
|
+
prev_dir = None
|
|
1542
|
+
for tok in re.findall(r"`([^`]+)`", m.group(0)):
|
|
1543
|
+
tok = tok.strip()
|
|
1544
|
+
if tok.startswith("./"):
|
|
1545
|
+
p = tdir / tok[2:]
|
|
1546
|
+
elif "/" in tok:
|
|
1547
|
+
p = root.parent / tok
|
|
1548
|
+
else:
|
|
1549
|
+
p = (prev_dir or tdir) / tok
|
|
1550
|
+
try:
|
|
1551
|
+
if not _confined(p, rootp):
|
|
1552
|
+
continue
|
|
1553
|
+
if p.is_dir():
|
|
1554
|
+
cand, prev_dir = sorted(f for f in p.glob("*.py")
|
|
1555
|
+
if _confined(f, rootp)), p
|
|
1556
|
+
elif p.is_file() and p.suffix == ".py":
|
|
1557
|
+
cand, prev_dir = [p], p.parent
|
|
1558
|
+
else:
|
|
1559
|
+
continue
|
|
1560
|
+
except OSError:
|
|
1561
|
+
continue
|
|
1562
|
+
files.extend(f for f in cand if f not in files)
|
|
1563
|
+
return sum(_count_test_defs(f) for f in files)
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
def _tests_info(root: Path, slug: str) -> tuple[int, bool]:
|
|
1567
|
+
"""(count, declared). The tests/ dir count ALWAYS wins when > 0; otherwise the
|
|
1568
|
+
§4-declared fallback — flagged True only when it supplied a non-zero count, so
|
|
1569
|
+
a true zero stays a bare, honest 0."""
|
|
1570
|
+
primary = _tests_count(root, slug)
|
|
1571
|
+
if primary > 0:
|
|
1572
|
+
return primary, False
|
|
1573
|
+
declared = _declared_tests_count(root, slug)
|
|
1574
|
+
return (declared, True) if declared > 0 else (0, False)
|
|
1066
1575
|
|
|
1067
1576
|
|
|
1068
1577
|
def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
|
|
@@ -1076,8 +1585,6 @@ def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
|
|
|
1076
1585
|
text = f.read_text(encoding="utf-8")
|
|
1077
1586
|
m7 = re.search(r"##\s*7\s*·\s*OBSERVE.*\Z", text, re.S)
|
|
1078
1587
|
lines = (m7.group(0) if m7 else text).splitlines()
|
|
1079
|
-
_delta_start = re.compile(r"\s*-\s*\[\s*(DDD|SDD|UDD|TDD|ADD)\s*·\s*(open|folded|rejected)\s*\]\s*(.+)$")
|
|
1080
|
-
|
|
1081
1588
|
# observe: the field value + continuation lines until a blank line / heading / list
|
|
1082
1589
|
observe = "(unknown)"
|
|
1083
1590
|
for i, ln in enumerate(lines):
|
|
@@ -1098,14 +1605,14 @@ def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
|
|
|
1098
1605
|
# deltas: each "- [COMP · status] ..." plus its indented continuation lines
|
|
1099
1606
|
deltas, i = [], 0
|
|
1100
1607
|
while i < len(lines):
|
|
1101
|
-
m =
|
|
1608
|
+
m = _DELTA_RE.match(lines[i])
|
|
1102
1609
|
if not m:
|
|
1103
1610
|
i += 1
|
|
1104
1611
|
continue
|
|
1105
1612
|
parts, j = [m.group(3).strip()], i + 1
|
|
1106
1613
|
while j < len(lines):
|
|
1107
1614
|
t = lines[j].strip()
|
|
1108
|
-
if not t or t.startswith("#") or
|
|
1615
|
+
if not t or t.startswith("#") or _DELTA_RE.match(lines[j]):
|
|
1109
1616
|
break
|
|
1110
1617
|
parts.append(t)
|
|
1111
1618
|
j += 1
|
|
@@ -1152,6 +1659,7 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
|
|
|
1152
1659
|
observe, deltas = _task_prose(root, slug)
|
|
1153
1660
|
phase = t.get("phase", "specify")
|
|
1154
1661
|
gate = t.get("gate", "none")
|
|
1662
|
+
n_tests, t_declared = _tests_info(root, slug)
|
|
1155
1663
|
row = {
|
|
1156
1664
|
"slug": slug,
|
|
1157
1665
|
"title": t.get("title", slug),
|
|
@@ -1159,7 +1667,8 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
|
|
|
1159
1667
|
"phase_index": PHASES.index(phase) if phase in PHASES else 0,
|
|
1160
1668
|
"done": _task_done(t),
|
|
1161
1669
|
"gate": gate,
|
|
1162
|
-
"tests":
|
|
1670
|
+
"tests": n_tests,
|
|
1671
|
+
"tests_declared": t_declared,
|
|
1163
1672
|
"observe": observe,
|
|
1164
1673
|
"deltas": deltas,
|
|
1165
1674
|
"waiver": t.get("waiver"),
|
|
@@ -1185,6 +1694,9 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
|
|
|
1185
1694
|
"tasks": task_rows,
|
|
1186
1695
|
"waivers": waivers,
|
|
1187
1696
|
"deltas": all_deltas,
|
|
1697
|
+
# additive (v13-1): MILESTONE.md-planned slugs with no TASK.md yet —
|
|
1698
|
+
# the plan-vs-state diff DECIDE NEXT was blind to; [] when none
|
|
1699
|
+
"planned_unscaffolded": _planned_unscaffolded(root, mslug),
|
|
1188
1700
|
}
|
|
1189
1701
|
|
|
1190
1702
|
|
|
@@ -1204,22 +1716,13 @@ def _clean_phase_body(body: str) -> str:
|
|
|
1204
1716
|
return "\n".join(lines) if meaningful else "(empty)"
|
|
1205
1717
|
|
|
1206
1718
|
|
|
1207
|
-
def
|
|
1208
|
-
"""
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
Sections are matched on the NUMBER (`^##\\s*<n>\\s*·`, case/locale-proof, the phase
|
|
1213
|
-
word maps n->PHASES[n-1]); a body runs from its heading to the next `## `/`---`/EOF.
|
|
1214
|
-
Missing file / missing section / placeholder-only body -> "(empty)" (fail-closed).
|
|
1719
|
+
def _phase_spans(text: str) -> dict[int, str]:
|
|
1720
|
+
"""Split a TASK.md into RAW §1–§7 bodies keyed by section number — the ONE
|
|
1721
|
+
canonical heading scan (`^##\\s*<n>\\s*·`, case/locale-proof); a body runs from
|
|
1722
|
+
its heading to the next `## `/`---`/EOF. RAW = byte-faithful lines, no cleaning:
|
|
1723
|
+
the decision-marker extractor (decide-digest) depends on byte-verbatim text.
|
|
1215
1724
|
KNOWN LIMIT: a §body containing a line-start `## ` or bare `---` truncates early —
|
|
1216
1725
|
today's TASK.md bodies don't (box-chars ─═, `### ` sub-heads)."""
|
|
1217
|
-
names = PHASES[:7] # specify..observe; "done" is a terminal STATE, not a section
|
|
1218
|
-
f = root / "tasks" / slug / "TASK.md"
|
|
1219
|
-
try:
|
|
1220
|
-
text = f.read_text(encoding="utf-8")
|
|
1221
|
-
except OSError: # missing OR unreadable -> every phase fail-closed to "(empty)"
|
|
1222
|
-
return [{"phase": names[n - 1], "n": n, "body": "(empty)"} for n in range(1, 8)]
|
|
1223
1726
|
lines = text.splitlines()
|
|
1224
1727
|
head = re.compile(r"^##\s*(\d+)\s*·")
|
|
1225
1728
|
starts: dict[int, int] = {}
|
|
@@ -1229,21 +1732,47 @@ def task_phases(root: Path, slug: str) -> list[dict]:
|
|
|
1229
1732
|
n = int(m.group(1))
|
|
1230
1733
|
if 1 <= n <= 7 and n not in starts:
|
|
1231
1734
|
starts[n] = idx
|
|
1232
|
-
out =
|
|
1233
|
-
for n in
|
|
1234
|
-
if n not in starts:
|
|
1235
|
-
out.append({"phase": names[n - 1], "n": n, "body": "(empty)"})
|
|
1236
|
-
continue
|
|
1735
|
+
out: dict[int, str] = {}
|
|
1736
|
+
for n, idx in starts.items():
|
|
1237
1737
|
body_lines = []
|
|
1238
|
-
for ln in lines[
|
|
1738
|
+
for ln in lines[idx + 1:]:
|
|
1239
1739
|
if re.match(r"^##\s", ln) or re.match(r"^---\s*$", ln):
|
|
1240
1740
|
break
|
|
1241
1741
|
body_lines.append(ln)
|
|
1242
|
-
out
|
|
1243
|
-
"body": _clean_phase_body("\n".join(body_lines))})
|
|
1742
|
+
out[n] = "\n".join(body_lines)
|
|
1244
1743
|
return out
|
|
1245
1744
|
|
|
1246
1745
|
|
|
1746
|
+
def _raw_phase_bodies(root: Path, slug: str) -> dict[int, str]:
|
|
1747
|
+
"""RAW §bodies for one task (byte-faithful, for marker extraction). PURE.
|
|
1748
|
+
Missing/unreadable TASK.md -> {} (fail-closed, like task_phases)."""
|
|
1749
|
+
f = root / "tasks" / slug / "TASK.md"
|
|
1750
|
+
try:
|
|
1751
|
+
return _phase_spans(f.read_text(encoding="utf-8"))
|
|
1752
|
+
except OSError:
|
|
1753
|
+
return {}
|
|
1754
|
+
|
|
1755
|
+
|
|
1756
|
+
def task_phases(root: Path, slug: str) -> list[dict]:
|
|
1757
|
+
"""The frozen per-task PHASE-DETAIL shape (v9-1): parse TASK.md §1–§7 into seven
|
|
1758
|
+
blocks specify→observe. PURE — NO writes. Each entry is
|
|
1759
|
+
{ "phase": <name>, "n": <1..7>, "body": <cleaned text | "(empty)"> }.
|
|
1760
|
+
|
|
1761
|
+
The heading scan lives in _phase_spans (shared with the decide digest); this view
|
|
1762
|
+
CLEANS each body. Missing file / missing section / placeholder-only body ->
|
|
1763
|
+
"(empty)" (fail-closed)."""
|
|
1764
|
+
names = PHASES[:7] # specify..observe; "done" is a terminal STATE, not a section
|
|
1765
|
+
f = root / "tasks" / slug / "TASK.md"
|
|
1766
|
+
try:
|
|
1767
|
+
text = f.read_text(encoding="utf-8")
|
|
1768
|
+
except OSError: # missing OR unreadable -> every phase fail-closed to "(empty)"
|
|
1769
|
+
return [{"phase": names[n - 1], "n": n, "body": "(empty)"} for n in range(1, 8)]
|
|
1770
|
+
spans = _phase_spans(text)
|
|
1771
|
+
return [{"phase": names[n - 1], "n": n,
|
|
1772
|
+
"body": _clean_phase_body(spans[n]) if n in spans else "(empty)"}
|
|
1773
|
+
for n in range(1, 8)]
|
|
1774
|
+
|
|
1775
|
+
|
|
1247
1776
|
def _task_title(root: Path, slug: str) -> str:
|
|
1248
1777
|
"""The task's display title from TASK.md line 1 `# TASK: <title>` (fail-soft: the
|
|
1249
1778
|
slug if the file or the header line is missing)."""
|
|
@@ -1262,10 +1791,20 @@ def _task_title(root: Path, slug: str) -> str:
|
|
|
1262
1791
|
def _detail_body(body: str, width: int) -> list[str]:
|
|
1263
1792
|
"""Indent a phase body under its block, soft-wrapping over-long physical lines on
|
|
1264
1793
|
spaces while preserving blank lines + each line's leading indent (so scenarios and
|
|
1265
|
-
contract code keep their shape).
|
|
1794
|
+
contract code keep their shape). Fenced ``` blocks are exempt: delimiter lines and
|
|
1795
|
+
everything inside an open fence emit BYTE-VERBATIM (indent + raw — no wrap, no
|
|
1796
|
+
whitespace collapse, even past width) so a copied contract round-trips after
|
|
1797
|
+
stripping the uniform indent; an unclosed fence runs verbatim to the §body end
|
|
1798
|
+
(fail-open). Drill-down = reading is the point, never clipped."""
|
|
1266
1799
|
indent = " "
|
|
1267
1800
|
out: list[str] = []
|
|
1801
|
+
fenced = False
|
|
1268
1802
|
for raw in body.split("\n"):
|
|
1803
|
+
is_delim = raw.lstrip().startswith("```")
|
|
1804
|
+
if fenced or is_delim:
|
|
1805
|
+
fenced = fenced != is_delim # delimiter toggles; content keeps state
|
|
1806
|
+
out.append(indent + raw if raw.strip() else "")
|
|
1807
|
+
continue
|
|
1269
1808
|
if not raw.strip():
|
|
1270
1809
|
out.append("")
|
|
1271
1810
|
continue
|
|
@@ -1364,10 +1903,13 @@ def render_report(root: Path, state: dict, mslug: str, *,
|
|
|
1364
1903
|
for r in d["tasks"]:
|
|
1365
1904
|
slug = _clip(r["slug"], 27)
|
|
1366
1905
|
gate = _GATE_SHORT.get(r["gate"], r["gate"])
|
|
1906
|
+
tests = f"{r['tests']}†" if r.get("tests_declared") else str(r["tests"])
|
|
1367
1907
|
L.append(f" {slug:<27} {r['phase']:<9} {gate:<4} "
|
|
1368
|
-
f"{
|
|
1908
|
+
f"{tests:<5} {_phase_track(r['phase'], g)}")
|
|
1369
1909
|
L.append(f" legend {g['reached']} reached {g['current']} current "
|
|
1370
1910
|
f"{g['pending']} pending spec→…→done")
|
|
1911
|
+
if any(r.get("tests_declared") for r in d["tasks"]):
|
|
1912
|
+
L.append(" † counted at the §4-declared path")
|
|
1371
1913
|
else:
|
|
1372
1914
|
L.append(" (no tasks yet)")
|
|
1373
1915
|
L.append("")
|
|
@@ -1385,6 +1927,256 @@ def render_report(root: Path, state: dict, mslug: str, *,
|
|
|
1385
1927
|
L.extend(_wrap(x, W - 5, f" {g['bullet']} "))
|
|
1386
1928
|
else:
|
|
1387
1929
|
L.append(" LEARNINGS none")
|
|
1930
|
+
L.append("") # DECIDE NEXT footer (v13): always present, APPEND-ONLY
|
|
1931
|
+
L.extend(_wrap(_decide_next_base(state, d), W - 15, " DECIDE NEXT "))
|
|
1932
|
+
if _planned_hint(d): # own segment so the phrase never splits mid-token
|
|
1933
|
+
L.extend(_wrap(_planned_hint(d).removeprefix(" — "), W - 15, " " * 14))
|
|
1934
|
+
L.append(banner)
|
|
1935
|
+
return "\n".join(L)
|
|
1936
|
+
|
|
1937
|
+
|
|
1938
|
+
# ---- decide digest (v13 decide-digest, frozen §3) ---------------------------
|
|
1939
|
+
# Decision markers: prose conventions surfaced VERBATIM. The engine EXTRACTS; it
|
|
1940
|
+
# never interprets, scores, or filters — add.py stays judgment-free, the human
|
|
1941
|
+
# signature is the gate.
|
|
1942
|
+
_MARKER_PREFIXES = (("⚠", "⚠"), ("- [~]", "[~]"), ("- [ ]", "[ ]"))
|
|
1943
|
+
_FRONT_PHASES = ("specify", "scenarios", "contract", "tests")
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
def _decision_markers(body: str, section: int) -> list[dict]:
|
|
1947
|
+
"""Extract decision markers from a RAW §body: a line whose first non-space chars
|
|
1948
|
+
are `⚠` / `- [~]` / `- [ ]`, PLUS its continuation lines (immediately following
|
|
1949
|
+
non-blank lines indented deeper than the marker). text is BYTE-VERBATIM — never
|
|
1950
|
+
re-wrapped, never clipped. Fail-open by design (a differently-worded item is
|
|
1951
|
+
missed); the always-printed count keeps that visible."""
|
|
1952
|
+
items: list[dict] = []
|
|
1953
|
+
lines = body.split("\n")
|
|
1954
|
+
i = 0
|
|
1955
|
+
while i < len(lines):
|
|
1956
|
+
ln = lines[i]
|
|
1957
|
+
stripped = ln.lstrip()
|
|
1958
|
+
tag = next((t for p, t in _MARKER_PREFIXES if stripped.startswith(p)), None)
|
|
1959
|
+
if tag is None:
|
|
1960
|
+
i += 1
|
|
1961
|
+
continue
|
|
1962
|
+
indent = len(ln) - len(stripped)
|
|
1963
|
+
block = [ln]
|
|
1964
|
+
j = i + 1
|
|
1965
|
+
while j < len(lines):
|
|
1966
|
+
nxt = lines[j]
|
|
1967
|
+
ns = nxt.lstrip()
|
|
1968
|
+
if ns and (len(nxt) - len(ns)) > indent:
|
|
1969
|
+
block.append(nxt)
|
|
1970
|
+
j += 1
|
|
1971
|
+
else:
|
|
1972
|
+
break
|
|
1973
|
+
items.append({"marker": tag, "section": section, "text": "\n".join(block)})
|
|
1974
|
+
i = j
|
|
1975
|
+
return items
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
def _contract_frozen(raw3: str) -> bool:
|
|
1979
|
+
"""§3's `Status:` line is the freeze signal (v12 precedent: the freeze is
|
|
1980
|
+
artifact-observable; no engine flag). Missing Status -> DRAFT (fail-closed)."""
|
|
1981
|
+
return any(re.match(r"\s*Status:\s*FROZEN", ln) for ln in raw3.splitlines())
|
|
1982
|
+
|
|
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
|
+
|
|
2015
|
+
def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict:
|
|
2016
|
+
"""FACTS for the task-level decision-point digest (frozen shape). The decision comes
|
|
2017
|
+
from STATE ONLY: recorded (gate set / observe / done) · front (specify→tests) ·
|
|
2018
|
+
gate (build/verify). judgment = extracted markers, byte-verbatim. PURE."""
|
|
2019
|
+
tasks = state.get("tasks") or {}
|
|
2020
|
+
t = tasks.get(slug, {})
|
|
2021
|
+
phase = t.get("phase", "specify")
|
|
2022
|
+
gate = t.get("gate", "none")
|
|
2023
|
+
if gate != "none" or phase in ("observe", "done"):
|
|
2024
|
+
seam = "recorded"
|
|
2025
|
+
elif phase in _FRONT_PHASES:
|
|
2026
|
+
seam = "front"
|
|
2027
|
+
else:
|
|
2028
|
+
seam = "gate"
|
|
2029
|
+
raw = _raw_phase_bodies(root, slug)
|
|
2030
|
+
frozen = _contract_frozen(raw.get(3, ""))
|
|
2031
|
+
if seam == "gate": # the items closest to the gate lead: §6 first, then §1
|
|
2032
|
+
judgment = _decision_markers(raw.get(6, ""), 6) + _decision_markers(raw.get(1, ""), 1)
|
|
2033
|
+
elif seam == "front" and not frozen:
|
|
2034
|
+
judgment = _decision_markers(raw.get(1, ""), 1) + _decision_markers(raw.get(3, ""), 3)
|
|
2035
|
+
else:
|
|
2036
|
+
judgment = []
|
|
2037
|
+
|
|
2038
|
+
members = [x for x in tasks.values() if x.get("milestone") == mslug]
|
|
2039
|
+
done, total = sum(1 for x in members if _task_done(x)), len(members)
|
|
2040
|
+
facts = {"phase": phase, "gate": gate,
|
|
2041
|
+
"deps": [{"slug": d, "gate": tasks.get(d, {}).get("gate", "none")}
|
|
2042
|
+
for d in t.get("depends_on", [])],
|
|
2043
|
+
"tests": _tests_info(root, slug)[0]}
|
|
2044
|
+
|
|
2045
|
+
if seam == "gate":
|
|
2046
|
+
unlocks = f"gate PASS -> task done -> milestone {min(done + 1, total)}/{total}"
|
|
2047
|
+
decide = "add.py gate PASS | RISK-ACCEPTED | HARD-STOP"
|
|
2048
|
+
elif seam == "front" and not frozen:
|
|
2049
|
+
unlocks = "freeze §3 -> the auto run takes build -> verify (autonomy: auto by default)"
|
|
2050
|
+
decide = "approve -> freeze §3 (Status: FROZEN @ v1) -> auto run"
|
|
2051
|
+
elif seam == "front":
|
|
2052
|
+
unlocks = "none"
|
|
2053
|
+
decide = "no decision pending — frozen; the run owns it. next decision point: verify gate"
|
|
2054
|
+
else:
|
|
2055
|
+
unlocks = "none"
|
|
2056
|
+
decide = f"no decision pending — recorded gate: {gate}"
|
|
2057
|
+
return {"seam": seam, "milestone": mslug, "task": slug, "phase": phase,
|
|
2058
|
+
"gate": gate, "judgment": judgment, "facts": facts,
|
|
2059
|
+
"unlocks": unlocks, "decide": decide}
|
|
2060
|
+
|
|
2061
|
+
|
|
2062
|
+
def render_decide(root: Path, state: dict, mslug: str, slug: str, *,
|
|
2063
|
+
width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str:
|
|
2064
|
+
"""Text view of the decision-point digest — decisive facts FIRST: NEEDS YOUR
|
|
2065
|
+
JUDGMENT (markers byte-verbatim, section-tagged) -> [front: §3 verbatim] ->
|
|
2066
|
+
ENGINE FACTS -> UNLOCKS -> DECIDE. PURE — no writes; plain text (color is a
|
|
2067
|
+
tty-only skin in cmd_report, like every report view)."""
|
|
2068
|
+
d = decide_data(root, state, mslug, slug)
|
|
2069
|
+
g = _ASCII if ascii else _UNICODE
|
|
2070
|
+
banner = g["h"] * width
|
|
2071
|
+
seam_label = {"gate": "VERIFY GATE", "front": "CONTRACT APPROVAL",
|
|
2072
|
+
"recorded": "RECORDED"}[d["seam"]]
|
|
2073
|
+
L = [banner, f" DECIDE · {mslug or '—'} · {slug} · decision point: {seam_label}", banner]
|
|
2074
|
+
if d["decide"].startswith("no decision pending"):
|
|
2075
|
+
L.append(f" {d['decide']}")
|
|
2076
|
+
L.append(f" GATE {d['gate']}")
|
|
2077
|
+
L.append(banner)
|
|
2078
|
+
return "\n".join(L)
|
|
2079
|
+
L.append(f" NEEDS YOUR JUDGMENT ({len(d['judgment'])})")
|
|
2080
|
+
for item in d["judgment"]:
|
|
2081
|
+
L.append(f" [§{item['section']}]")
|
|
2082
|
+
L.extend(item["text"].split("\n")) # byte-verbatim — never wrapped/clipped
|
|
2083
|
+
if d["seam"] == "front":
|
|
2084
|
+
L.append("")
|
|
2085
|
+
L.append(" CONTRACT (§3 verbatim)")
|
|
2086
|
+
L.extend(_raw_phase_bodies(root, slug).get(3, "").split("\n"))
|
|
2087
|
+
L.append(" STATUS DRAFT")
|
|
2088
|
+
f = d["facts"]
|
|
2089
|
+
deps_txt = " ".join(f"{x['slug']}:{x['gate']}" for x in f["deps"]) or "none"
|
|
2090
|
+
L.append("")
|
|
2091
|
+
L.append(f" ENGINE FACTS phase {f['phase']} · gate {f['gate']} · "
|
|
2092
|
+
f"deps {deps_txt} · tests {f['tests']}")
|
|
2093
|
+
L.append(f" UNLOCKS {d['unlocks']}")
|
|
2094
|
+
L.append(f" DECIDE {d['decide']}")
|
|
2095
|
+
L.append(banner)
|
|
2096
|
+
return "\n".join(L)
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
def _planned_unscaffolded(root: Path, mslug: str) -> list[str]:
|
|
2100
|
+
"""Slugs MILESTONE.md plans (rows `- [ ] <slug> …`) that have no TASK.md yet —
|
|
2101
|
+
the plan-vs-state diff. Only valid-slug first-tokens match (a template
|
|
2102
|
+
placeholder like <slug> never does); file order, deduped; fail-closed []."""
|
|
2103
|
+
md = root / "milestones" / mslug / "MILESTONE.md"
|
|
2104
|
+
try:
|
|
2105
|
+
text = md.read_text(encoding="utf-8")
|
|
2106
|
+
except OSError:
|
|
2107
|
+
return []
|
|
2108
|
+
out: list[str] = []
|
|
2109
|
+
for sec in re.split(r"^## ", text, flags=re.M)[1:]:
|
|
2110
|
+
if not sec.startswith("Tasks"): # only the Tasks list — never exit criteria
|
|
2111
|
+
continue
|
|
2112
|
+
for m in re.finditer(r"^- \[[ x~]\] ([A-Za-z0-9_-]+)\b", sec, re.M):
|
|
2113
|
+
slug = m.group(1)
|
|
2114
|
+
if slug not in out and not (root / "tasks" / slug / "TASK.md").is_file():
|
|
2115
|
+
out.append(slug)
|
|
2116
|
+
return out
|
|
2117
|
+
|
|
2118
|
+
|
|
2119
|
+
def _decide_next(state: dict, d: dict) -> str:
|
|
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-
|
|
2122
|
+
progress. v2: when d carries planned_unscaffolded, the line gains a
|
|
2123
|
+
plan-vs-state suffix — precedence itself stays state-only."""
|
|
2124
|
+
return _decide_next_base(state, d) + _planned_hint(d)
|
|
2125
|
+
|
|
2126
|
+
|
|
2127
|
+
def _planned_hint(d: dict) -> str:
|
|
2128
|
+
"""The plan-vs-state suffix ('' when nothing is missing). Text renders emit it
|
|
2129
|
+
as its OWN wrapped segment so the phrase never splits mid-token; the JSON
|
|
2130
|
+
'decide' string carries it inline via _decide_next."""
|
|
2131
|
+
planned = d.get("planned_unscaffolded") or []
|
|
2132
|
+
if not planned:
|
|
2133
|
+
return ""
|
|
2134
|
+
return f" — {len(planned)} planned not yet scaffolded: " + " · ".join(planned)
|
|
2135
|
+
|
|
2136
|
+
|
|
2137
|
+
def _decide_next_base(state: dict, d: dict) -> str:
|
|
2138
|
+
ms = d["milestone"]["slug"]
|
|
2139
|
+
rows = d["tasks"]
|
|
2140
|
+
if not rows:
|
|
2141
|
+
return "none — no tasks yet"
|
|
2142
|
+
stopped = [r for r in rows if r["gate"] == "HARD-STOP"]
|
|
2143
|
+
if stopped:
|
|
2144
|
+
return f"resolve HARD-STOP on {stopped[0]['slug']}"
|
|
2145
|
+
s = d["summary"]
|
|
2146
|
+
if s["tasks_done"] == s["tasks_total"]:
|
|
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}"
|
|
2156
|
+
active = state.get("active_task")
|
|
2157
|
+
order = sorted(rows, key=lambda r: 0 if r["slug"] == active else 1) # stable
|
|
2158
|
+
for r in order:
|
|
2159
|
+
if r["done"]:
|
|
2160
|
+
continue
|
|
2161
|
+
if r["phase"] in _FRONT_PHASES:
|
|
2162
|
+
return (f"approve the contract of {r['slug']} — "
|
|
2163
|
+
f"add.py report {ms} {r['slug']} --decide")
|
|
2164
|
+
if r["phase"] == "verify" and r["gate"] == "none":
|
|
2165
|
+
return f"gate {r['slug']} — add.py report {ms} {r['slug']} --decide"
|
|
2166
|
+
r = next(x for x in order if not x["done"])
|
|
2167
|
+
return f"none — run in progress ({r['slug']} at {r['phase']})"
|
|
2168
|
+
|
|
2169
|
+
|
|
2170
|
+
def render_decide_next(root: Path, state: dict, mslug: str, *,
|
|
2171
|
+
width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str:
|
|
2172
|
+
"""`report <ms> --decide`: ONLY the DECIDE NEXT block (no rollup table). PURE."""
|
|
2173
|
+
g = _ASCII if ascii else _UNICODE
|
|
2174
|
+
banner = g["h"] * width
|
|
2175
|
+
d = report_data(root, state, mslug)
|
|
2176
|
+
L = [banner, f" {mslug} · DECIDE NEXT", banner]
|
|
2177
|
+
L.extend(_wrap(_decide_next_base(state, d), width - 4, " "))
|
|
2178
|
+
if _planned_hint(d): # own segment so the phrase never splits mid-token
|
|
2179
|
+
L.extend(_wrap(_planned_hint(d).removeprefix(" — "), width - 4, " "))
|
|
1388
2180
|
L.append(banner)
|
|
1389
2181
|
return "\n".join(L)
|
|
1390
2182
|
|
|
@@ -1398,10 +2190,456 @@ def _write_retro(root: Path, state: dict, mslug: str) -> Path:
|
|
|
1398
2190
|
trust the locale default), never mutates state.json."""
|
|
1399
2191
|
content = render_report(root, state, mslug, width=_DEFAULT_WIDTH, ascii=False)
|
|
1400
2192
|
path = root / "milestones" / mslug / "RETRO.md"
|
|
1401
|
-
path
|
|
2193
|
+
_atomic_write(path, content) # honor the module's atomic-write contract (no half-write)
|
|
1402
2194
|
return path
|
|
1403
2195
|
|
|
1404
2196
|
|
|
2197
|
+
_COMPETENCY_ORDER = ("DDD", "SDD", "UDD", "TDD", "ADD")
|
|
2198
|
+
_DELTA_STATUSES = ("open", "folded", "rejected")
|
|
2199
|
+
|
|
2200
|
+
# Canonical delta grammar — the single compiled source for the enumerated
|
|
2201
|
+
# competency · status shape. Leading \s* is PERMISSIVE so _task_prose can feed
|
|
2202
|
+
# un-stripped lines directly; callers that pre-strip their input
|
|
2203
|
+
# (e.g. _collect_open_deltas, _lint_task_deltas) match the same way (\s*
|
|
2204
|
+
# matches zero). Anchored at line-start via re.match.
|
|
2205
|
+
_DELTA_RE = re.compile(
|
|
2206
|
+
r"\s*-\s*\[\s*(DDD|SDD|UDD|TDD|ADD)\s*·\s*(open|folded|rejected)\s*\]\s*(.+)$"
|
|
2207
|
+
)
|
|
2208
|
+
_EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
|
|
2209
|
+
|
|
2210
|
+
# Broad structural tag detector: finds ANY "- [tok · tok]" line (valid OR malformed).
|
|
2211
|
+
# A line with a `· ` bracket separator is a delta-attempt. Does NOT enumerate
|
|
2212
|
+
# competencies or statuses — a different abstraction from _DELTA_RE (no DRY violation).
|
|
2213
|
+
_TAG_BROAD_RE = re.compile(r"^\s*-\s*\[\s*([^\]·]+?)\s*·\s*([^\]·]+?)\s*\]\s*(.*)$")
|
|
2214
|
+
|
|
2215
|
+
|
|
2216
|
+
def _lint_task_deltas(root: Path, slug: str) -> tuple[bool, str] | None:
|
|
2217
|
+
"""Lint all open delta entries in a task's '### Competency deltas' block.
|
|
2218
|
+
|
|
2219
|
+
Returns:
|
|
2220
|
+
None — no delta-attempts found; no check emitted.
|
|
2221
|
+
(True, "") — all open entries pass.
|
|
2222
|
+
(False, "<code> -> <tag line>") — first failing entry with its failure code.
|
|
2223
|
+
|
|
2224
|
+
Contract rules (frozen §3, v1):
|
|
2225
|
+
- SKIP HTML-comment lines and blank lines (they are never tag lines).
|
|
2226
|
+
- Group lines into ENTRIES: a broad tag line starts an entry; following lines
|
|
2227
|
+
until next tag / blank / end-of-block are its continuation.
|
|
2228
|
+
- A line without a '· ' separator inside brackets (e.g. '- [x]') is NOT a tag.
|
|
2229
|
+
- For each entry, skip folded/rejected (open-only — history not retrofitted).
|
|
2230
|
+
- Validate the remaining (open) entries: COMP in _COMPETENCY_ORDER,
|
|
2231
|
+
status in _DELTA_STATUSES, and '(evidence:' present SOMEWHERE in the unit.
|
|
2232
|
+
- Fail-closed: an unparseable attempt FAILS (never silently passes).
|
|
2233
|
+
"""
|
|
2234
|
+
task_md = root / "tasks" / slug / "TASK.md"
|
|
2235
|
+
if not task_md.exists():
|
|
2236
|
+
return None
|
|
2237
|
+
try:
|
|
2238
|
+
text = task_md.read_text(encoding="utf-8")
|
|
2239
|
+
except OSError:
|
|
2240
|
+
return None
|
|
2241
|
+
|
|
2242
|
+
# Locate the "### Competency deltas" block.
|
|
2243
|
+
block_match = re.search(r"###\s*Competency deltas\s*\n(.*?)(?=\n##|\Z)", text, re.S)
|
|
2244
|
+
if not block_match:
|
|
2245
|
+
return None
|
|
2246
|
+
|
|
2247
|
+
block = block_match.group(1)
|
|
2248
|
+
raw_lines = block.splitlines()
|
|
2249
|
+
|
|
2250
|
+
# First pass: collect entries (tag line + continuations).
|
|
2251
|
+
# HTML-comment lines are skipped entirely (invisible to the guard).
|
|
2252
|
+
# Blank lines terminate the current entry, but are not tags themselves.
|
|
2253
|
+
entries: list[tuple[str, list[str]]] = [] # (tag_line, [tag_line, *continuations])
|
|
2254
|
+
current: list[str] | None = None
|
|
2255
|
+
for raw_line in raw_lines:
|
|
2256
|
+
stripped = raw_line.strip()
|
|
2257
|
+
# Skip HTML-comment lines.
|
|
2258
|
+
if stripped.startswith("<!--"):
|
|
2259
|
+
continue
|
|
2260
|
+
# Blank line terminates the current entry.
|
|
2261
|
+
if not stripped:
|
|
2262
|
+
current = None
|
|
2263
|
+
continue
|
|
2264
|
+
# Broad tag detection: any "- [tok · tok]" line starts a new entry.
|
|
2265
|
+
m = _TAG_BROAD_RE.match(raw_line)
|
|
2266
|
+
if m:
|
|
2267
|
+
current = [stripped]
|
|
2268
|
+
entries.append((stripped, current))
|
|
2269
|
+
elif current is not None:
|
|
2270
|
+
# Continuation line of the current entry.
|
|
2271
|
+
current.append(stripped)
|
|
2272
|
+
# else: non-blank, non-comment, non-tag line with no prior entry — ignore.
|
|
2273
|
+
|
|
2274
|
+
if not entries:
|
|
2275
|
+
return None # no delta-attempts → no check emitted
|
|
2276
|
+
|
|
2277
|
+
# Second pass: validate each entry.
|
|
2278
|
+
for tag_line, unit_lines in entries:
|
|
2279
|
+
m = _TAG_BROAD_RE.match(tag_line)
|
|
2280
|
+
if not m:
|
|
2281
|
+
# Should not happen, but fail-closed.
|
|
2282
|
+
return False, f"malformed_delta -> {tag_line}"
|
|
2283
|
+
raw_comp = m.group(1).strip()
|
|
2284
|
+
raw_status = m.group(2).strip()
|
|
2285
|
+
|
|
2286
|
+
# Step 1: skip historical entries (folded/rejected) — open-only enforcement.
|
|
2287
|
+
# MUST happen before competency/status validation per §3: "history not retrofitted".
|
|
2288
|
+
if raw_status in ("folded", "rejected"):
|
|
2289
|
+
continue
|
|
2290
|
+
|
|
2291
|
+
# Step 2: use _DELTA_RE (the canonical grammar, single source of truth) to test
|
|
2292
|
+
# whether the tag line is a fully-valid delta shape. If it matches, check evidence
|
|
2293
|
+
# only. If it fails, classify the failure via the raw tokens (never a parallel grammar).
|
|
2294
|
+
unit_text = " ".join(unit_lines)
|
|
2295
|
+
if _DELTA_RE.match(tag_line):
|
|
2296
|
+
# Valid comp + status + non-empty tail — check evidence in the joined unit.
|
|
2297
|
+
if "(evidence:" not in unit_text:
|
|
2298
|
+
return False, f"no_evidence -> {tag_line}"
|
|
2299
|
+
else:
|
|
2300
|
+
# Classify why _DELTA_RE rejected it (open entries only — folded/rejected skipped).
|
|
2301
|
+
if raw_comp not in _COMPETENCY_ORDER:
|
|
2302
|
+
return False, f"unknown_competency -> {tag_line}"
|
|
2303
|
+
if raw_status not in _DELTA_STATUSES:
|
|
2304
|
+
return False, f"unknown_status -> {tag_line}"
|
|
2305
|
+
# Comp and status are valid but the line still failed _DELTA_RE (e.g. empty tail).
|
|
2306
|
+
return False, f"malformed_delta -> {tag_line}"
|
|
2307
|
+
|
|
2308
|
+
return True, ""
|
|
2309
|
+
|
|
2310
|
+
|
|
2311
|
+
def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
|
|
2312
|
+
"""Scan every .add/tasks/*/TASK.md for open lessons learned.
|
|
2313
|
+
|
|
2314
|
+
Returns a dict keyed by competency in canonical order; each value is a list
|
|
2315
|
+
of {task, text, evidence} dicts. READ-ONLY — never mutates any file."""
|
|
2316
|
+
by_comp: dict[str, list[dict]] = {c: [] for c in _COMPETENCY_ORDER}
|
|
2317
|
+
tasks_dir = root / "tasks"
|
|
2318
|
+
if not tasks_dir.is_dir():
|
|
2319
|
+
return by_comp
|
|
2320
|
+
for task_md in sorted(tasks_dir.glob("*/TASK.md")):
|
|
2321
|
+
slug = task_md.parent.name
|
|
2322
|
+
try:
|
|
2323
|
+
text = task_md.read_text(encoding="utf-8")
|
|
2324
|
+
except OSError:
|
|
2325
|
+
continue
|
|
2326
|
+
# Locate the "### Competency deltas" block (may appear anywhere in the file).
|
|
2327
|
+
block_match = re.search(r"###\s*Competency deltas\s*\n(.*?)(?=\n##|\Z)", text, re.S)
|
|
2328
|
+
if not block_match:
|
|
2329
|
+
continue
|
|
2330
|
+
block = block_match.group(1)
|
|
2331
|
+
# Group lines into entries (tag line + continuations) so a multi-line delta —
|
|
2332
|
+
# whose learning wraps and whose (evidence: …) may land on a later line — is read
|
|
2333
|
+
# in FULL, not truncated to its first line. A tag line starts an entry; a line
|
|
2334
|
+
# that does not begin a new "- " list item continues it; a blank/comment or a
|
|
2335
|
+
# new "- " item ends it (a trailing malformed item can't pollute a delta's text).
|
|
2336
|
+
entries: list[list[str]] = []
|
|
2337
|
+
current: list[str] | None = None
|
|
2338
|
+
for line in block.splitlines():
|
|
2339
|
+
stripped = line.strip()
|
|
2340
|
+
if not stripped or stripped.startswith("<!--"):
|
|
2341
|
+
current = None
|
|
2342
|
+
continue
|
|
2343
|
+
if _DELTA_RE.match(stripped):
|
|
2344
|
+
current = [stripped]
|
|
2345
|
+
entries.append(current)
|
|
2346
|
+
elif current is not None and not stripped.startswith("-"):
|
|
2347
|
+
current.append(stripped) # genuine wrap of the current learning
|
|
2348
|
+
else:
|
|
2349
|
+
current = None # a new / malformed list item ends the run
|
|
2350
|
+
for unit in entries:
|
|
2351
|
+
m = _DELTA_RE.match(unit[0])
|
|
2352
|
+
comp, status = m.group(1), m.group(2)
|
|
2353
|
+
if status != "open":
|
|
2354
|
+
continue
|
|
2355
|
+
# Join the tag line's tail with any continuation lines, then split evidence.
|
|
2356
|
+
tail = " ".join([m.group(3).strip(), *unit[1:]]).strip()
|
|
2357
|
+
em = _EVIDENCE_RE.match(tail)
|
|
2358
|
+
if em:
|
|
2359
|
+
delta_text, evidence = em.group(1).strip(), em.group(2).strip()
|
|
2360
|
+
else:
|
|
2361
|
+
delta_text, evidence = tail, ""
|
|
2362
|
+
by_comp[comp].append({"task": slug, "text": delta_text, "evidence": evidence})
|
|
2363
|
+
return by_comp
|
|
2364
|
+
|
|
2365
|
+
|
|
2366
|
+
_AUDIT_STAMP_RE = re.compile(r"Status:\s*FROZEN @ v\d+\s*[—-]+\s*approved by\s+\S+")
|
|
2367
|
+
_AUDIT_OUTCOME_RE = re.compile(r"^Outcome:\s*(PASS|RISK-ACCEPTED|HARD-STOP)\b", re.M)
|
|
2368
|
+
_AUDIT_SECURITY_RE = re.compile(
|
|
2369
|
+
r"^\s*- \[[ x~]\] no exposed secrets.*(?:\n(?!\s*- \[|#).*)*", re.M)
|
|
2370
|
+
_AUDIT_REVIEWED_RE = re.compile(r"^Reviewed by:(.*)$", re.M)
|
|
2371
|
+
|
|
2372
|
+
|
|
2373
|
+
def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
|
|
2374
|
+
"""The gate-audit core: verify that human decision points left WELL-FORMED records.
|
|
2375
|
+
Judgment-free — checks record SHAPE (a named human at the freeze, exactly one
|
|
2376
|
+
gate outcome, prose ≡ state, a marked security note never auto-reviewed),
|
|
2377
|
+
never re-decides an outcome. Scope: active tasks done/observe or gated; open
|
|
2378
|
+
fronts skipped. PURE — reads only. Honest limit: shape, not engagement — a
|
|
2379
|
+
forged name passes; CI wiring makes forgery explicit and attributable."""
|
|
2380
|
+
tasks = state.get("tasks") or {}
|
|
2381
|
+
checked, findings = 0, []
|
|
2382
|
+
|
|
2383
|
+
def f(slug: str, code: str, detail: str) -> None:
|
|
2384
|
+
findings.append({"task": slug, "code": code, "detail": detail})
|
|
2385
|
+
|
|
2386
|
+
for slug in sorted(tasks):
|
|
2387
|
+
t = tasks[slug]
|
|
2388
|
+
phase, gate = t.get("phase", "specify"), t.get("gate", "none")
|
|
2389
|
+
if phase not in ("done", "observe") and gate == "none":
|
|
2390
|
+
continue # the front is still open — nothing recorded to audit
|
|
2391
|
+
checked += 1
|
|
2392
|
+
raw = _raw_phase_bodies(root, slug)
|
|
2393
|
+
s3, s6 = raw.get(3, ""), raw.get(6, "")
|
|
2394
|
+
if not _AUDIT_STAMP_RE.search(s3):
|
|
2395
|
+
f(slug, "unstamped_freeze",
|
|
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")
|
|
2406
|
+
outcomes = _AUDIT_OUTCOME_RE.findall(s6)
|
|
2407
|
+
if len(outcomes) != 1:
|
|
2408
|
+
f(slug, "malformed_gate_record",
|
|
2409
|
+
f"{len(outcomes)} Outcome lines in §6 (need exactly 1)")
|
|
2410
|
+
elif gate != "none" and outcomes[0] != gate:
|
|
2411
|
+
f(slug, "gate_record_mismatch",
|
|
2412
|
+
f"§6 records {outcomes[0]} but state.json records {gate}")
|
|
2413
|
+
sec = _AUDIT_SECURITY_RE.search(s6)
|
|
2414
|
+
marked = bool(sec and ("NOTE" in sec.group(0) or "⚠" in sec.group(0)))
|
|
2415
|
+
rev = _AUDIT_REVIEWED_RE.search(s6)
|
|
2416
|
+
if marked and rev and "auto-gate" in rev.group(1):
|
|
2417
|
+
f(slug, "unescalated_security_note",
|
|
2418
|
+
"security-line note (NOTE/⚠) with an auto-gate reviewer")
|
|
2419
|
+
# F7 unguarded_high_risk_auto (task high-risk-signal, v14): a declared
|
|
2420
|
+
# high-risk record must show a guarded dial AND a human at the gate —
|
|
2421
|
+
# catches post-gate header tampering and auto-resolved high-risk gates.
|
|
2422
|
+
hdr = _task_header(root, slug)
|
|
2423
|
+
if _RISK_HIGH_RE.search(hdr):
|
|
2424
|
+
if not _AUTONOMY_CONSERVATIVE_RE.search(hdr):
|
|
2425
|
+
f(slug, "unguarded_high_risk_auto",
|
|
2426
|
+
"risk: high declared but autonomy is not 'conservative'")
|
|
2427
|
+
elif rev and "auto-gate" in rev.group(1):
|
|
2428
|
+
f(slug, "unguarded_high_risk_auto",
|
|
2429
|
+
"risk: high task whose GATE RECORD reviewer is the auto-gate")
|
|
2430
|
+
if outcomes == ["RISK-ACCEPTED"]:
|
|
2431
|
+
if marked:
|
|
2432
|
+
f(slug, "risk_accepted_security",
|
|
2433
|
+
"a waiver on a marked security item is never allowed")
|
|
2434
|
+
if not all(re.search(rf"{k}:\s*(?!<)\S", s6)
|
|
2435
|
+
for k in ("owner", "ticket", "expires")):
|
|
2436
|
+
f(slug, "waiver_incomplete",
|
|
2437
|
+
"RISK-ACCEPTED needs owner · ticket · expires")
|
|
2438
|
+
return checked, findings
|
|
2439
|
+
|
|
2440
|
+
|
|
2441
|
+
def cmd_audit(args: argparse.Namespace) -> None:
|
|
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
|
|
2444
|
+
NOTHING; every other command is byte-identical."""
|
|
2445
|
+
root = _require_root()
|
|
2446
|
+
checked, findings = _audit_findings(root, load_state(root))
|
|
2447
|
+
if getattr(args, "json", False):
|
|
2448
|
+
print(json.dumps({"checked": checked, "findings": findings},
|
|
2449
|
+
ensure_ascii=False, indent=2))
|
|
2450
|
+
else:
|
|
2451
|
+
if findings:
|
|
2452
|
+
for x in findings:
|
|
2453
|
+
print(f"audit: {x['code']} {x['task']} — {x['detail']}")
|
|
2454
|
+
else:
|
|
2455
|
+
print(f"audit: clean ({checked} tasks checked)")
|
|
2456
|
+
if findings:
|
|
2457
|
+
sys.exit(1)
|
|
2458
|
+
|
|
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
|
+
|
|
2598
|
+
def cmd_deltas(args: argparse.Namespace) -> None:
|
|
2599
|
+
"""Read-only: report all open lessons learned grouped by competency.
|
|
2600
|
+
|
|
2601
|
+
Scans every .add/tasks/*/TASK.md '### Competency deltas' block for lines
|
|
2602
|
+
matching the delta grammar; shows only `open` entries in canonical competency
|
|
2603
|
+
order (DDD·SDD·UDD·TDD·ADD). --json emits one JSON object. Exit 0 ALWAYS.
|
|
2604
|
+
Writes NOTHING."""
|
|
2605
|
+
root = _require_root()
|
|
2606
|
+
by_comp = _collect_open_deltas(root)
|
|
2607
|
+
total = sum(len(v) for v in by_comp.values())
|
|
2608
|
+
|
|
2609
|
+
if getattr(args, "json", False):
|
|
2610
|
+
payload: dict = {
|
|
2611
|
+
"total": total,
|
|
2612
|
+
"by_competency": {c: v for c, v in by_comp.items() if v},
|
|
2613
|
+
}
|
|
2614
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
2615
|
+
return
|
|
2616
|
+
|
|
2617
|
+
if total == 0:
|
|
2618
|
+
print("no open deltas.")
|
|
2619
|
+
return
|
|
2620
|
+
|
|
2621
|
+
print(f"open lessons learned ({total} total):")
|
|
2622
|
+
for comp in _COMPETENCY_ORDER:
|
|
2623
|
+
entries = by_comp[comp]
|
|
2624
|
+
if not entries:
|
|
2625
|
+
continue
|
|
2626
|
+
print(f" {comp} ({len(entries)}):")
|
|
2627
|
+
for e in entries:
|
|
2628
|
+
print(f" - {e['text']} [{e['task']}]")
|
|
2629
|
+
|
|
2630
|
+
|
|
2631
|
+
def cmd_project(args: argparse.Namespace) -> None:
|
|
2632
|
+
"""Read-only: print .add/PROJECT.md (the read-first foundation) in one command.
|
|
2633
|
+
|
|
2634
|
+
Fail-closed: a missing foundation dies with a clear stderr message + a non-zero
|
|
2635
|
+
exit, never a silent empty print. Writes NOTHING."""
|
|
2636
|
+
root = _require_root()
|
|
2637
|
+
foundation = root / "PROJECT.md"
|
|
2638
|
+
if not foundation.exists():
|
|
2639
|
+
_die("missing foundation: .add/PROJECT.md (run `add.py init` to scaffold it)")
|
|
2640
|
+
print(foundation.read_text(encoding="utf-8"), end="")
|
|
2641
|
+
|
|
2642
|
+
|
|
1405
2643
|
def cmd_report(args: argparse.Namespace) -> None:
|
|
1406
2644
|
"""Read-only: capture a milestone's raw data (--json) or render the text
|
|
1407
2645
|
dashboard (color on a tty, ASCII when the terminal can't do Unicode, --plain
|
|
@@ -1433,6 +2671,12 @@ def cmd_report(args: argparse.Namespace) -> None:
|
|
|
1433
2671
|
_die(f"unknown_milestone: task '{name}' is not attached to a milestone")
|
|
1434
2672
|
else:
|
|
1435
2673
|
_die(f"unknown_milestone: '{name}' is not a milestone")
|
|
2674
|
+
elif getattr(args, "decide", False): # bare --decide -> the ACTIVE TASK
|
|
2675
|
+
slug = state.get("active_task")
|
|
2676
|
+
if not slug or slug not in tasks:
|
|
2677
|
+
_die("no_active_task — name one: add.py report <milestone> <task> --decide")
|
|
2678
|
+
drill_task = slug
|
|
2679
|
+
mslug = tasks[slug].get("milestone") or ""
|
|
1436
2680
|
else: # no positional -> active milestone
|
|
1437
2681
|
mslug = state.get("active_milestone")
|
|
1438
2682
|
if not mslug:
|
|
@@ -1441,6 +2685,32 @@ def cmd_report(args: argparse.Namespace) -> None:
|
|
|
1441
2685
|
if mslug not in milestones:
|
|
1442
2686
|
_die(f"unknown_milestone: '{mslug}' is not a milestone")
|
|
1443
2687
|
|
|
2688
|
+
if getattr(args, "decide", False):
|
|
2689
|
+
# Decision-seam digest (v13): task -> seam digest; milestone -> DECIDE NEXT
|
|
2690
|
+
# block only. PURE, like every report path.
|
|
2691
|
+
if getattr(args, "json", False):
|
|
2692
|
+
if drill_task:
|
|
2693
|
+
payload = decide_data(root, state, mslug, drill_task)
|
|
2694
|
+
else: # milestone altitude: same frozen key set, task null
|
|
2695
|
+
d = report_data(root, state, mslug)
|
|
2696
|
+
payload = {"seam": "milestone", "milestone": mslug, "task": None,
|
|
2697
|
+
"phase": "", "gate": "none", "judgment": [],
|
|
2698
|
+
"facts": {"phase": "", "gate": "none", "deps": [], "tests": 0},
|
|
2699
|
+
"unlocks": "", "decide": _decide_next(state, d)}
|
|
2700
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
2701
|
+
return
|
|
2702
|
+
plain = getattr(args, "plain", False)
|
|
2703
|
+
interactive = sys.stdout.isatty() and not plain
|
|
2704
|
+
width = _term_width() if interactive else _DEFAULT_WIDTH
|
|
2705
|
+
use_ascii = plain or _use_ascii()
|
|
2706
|
+
out = (render_decide(root, state, mslug, drill_task, width=width, ascii=use_ascii)
|
|
2707
|
+
if drill_task else
|
|
2708
|
+
render_decide_next(root, state, mslug, width=width, ascii=use_ascii))
|
|
2709
|
+
if not plain and _color_enabled():
|
|
2710
|
+
out = _colorize(out)
|
|
2711
|
+
print(out)
|
|
2712
|
+
return
|
|
2713
|
+
|
|
1444
2714
|
if getattr(args, "json", False):
|
|
1445
2715
|
# POLYMORPHIC by path: drill -> task_phases list; rollup -> report_data dict.
|
|
1446
2716
|
payload = task_phases(root, drill_task) if drill_task \
|
|
@@ -1468,8 +2738,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1468
2738
|
pi.add_argument("--name", default=None, help="project name (default: dir name)")
|
|
1469
2739
|
pi.add_argument("--stage", default="prototype", choices=STAGES)
|
|
1470
2740
|
pi.add_argument("--force", action="store_true", help="reset state.json if present")
|
|
2741
|
+
pi.add_argument("--await-lock", dest="await_lock", action="store_true",
|
|
2742
|
+
help="seed an unlocked setup; gates new-task/advance/gate until `add.py lock`")
|
|
1471
2743
|
pi.set_defaults(func=cmd_init)
|
|
1472
2744
|
|
|
2745
|
+
pl = sub.add_parser("lock",
|
|
2746
|
+
help="freeze the autonomous setup (the human baseline approval) and open the build")
|
|
2747
|
+
pl.add_argument("--by", default=None, help="who is locking (default: current OS user)")
|
|
2748
|
+
pl.add_argument("--layers", default=None,
|
|
2749
|
+
help="comma-separated lock layers (default: foundation,scope,contract)")
|
|
2750
|
+
pl.add_argument("--force", action="store_true", help="re-lock an already-locked project")
|
|
2751
|
+
pl.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
|
|
2752
|
+
pl.set_defaults(func=cmd_lock)
|
|
2753
|
+
|
|
1473
2754
|
pn = sub.add_parser("new-task", help="scaffold a new task (TASK.md + tests/ + src/)")
|
|
1474
2755
|
pn.add_argument("slug")
|
|
1475
2756
|
pn.add_argument("--title", default=None)
|
|
@@ -1500,11 +2781,21 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1500
2781
|
psm.add_argument("milestone", help="milestone slug, or 'none' to detach")
|
|
1501
2782
|
psm.set_defaults(func=cmd_set_milestone)
|
|
1502
2783
|
|
|
2784
|
+
pu = sub.add_parser("use", help="set the active task to an existing one (switch focus)")
|
|
2785
|
+
pu.add_argument("slug")
|
|
2786
|
+
pu.set_defaults(func=cmd_use)
|
|
2787
|
+
|
|
1503
2788
|
pam = sub.add_parser("archive-milestone",
|
|
1504
2789
|
help="collapse a done milestone out of active state (files stay on disk)")
|
|
1505
2790
|
pam.add_argument("slug")
|
|
1506
2791
|
pam.set_defaults(func=cmd_archive_milestone)
|
|
1507
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
|
+
|
|
1508
2799
|
pp = sub.add_parser("phase", help="set a task's phase explicitly")
|
|
1509
2800
|
pp.add_argument("phase", choices=PHASES)
|
|
1510
2801
|
pp.add_argument("slug", nargs="?", default=None)
|
|
@@ -1522,8 +2813,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1522
2813
|
pg.add_argument("--expires", help="RISK-ACCEPTED waiver: expiry date")
|
|
1523
2814
|
pg.set_defaults(func=cmd_gate)
|
|
1524
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
|
+
|
|
1525
2824
|
ps = sub.add_parser("stage", help="set the project stage")
|
|
1526
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)")
|
|
1527
2828
|
ps.set_defaults(func=cmd_stage)
|
|
1528
2829
|
|
|
1529
2830
|
pst = sub.add_parser("status", help="print where the project is (resume point)")
|
|
@@ -1557,8 +2858,33 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1557
2858
|
"drill -> task_phases list of 7 phase dicts)")
|
|
1558
2859
|
prp.add_argument("--plain", action="store_true",
|
|
1559
2860
|
help="ASCII, no color, fixed width (pipe / CI / screen-reader safe)")
|
|
2861
|
+
prp.add_argument("--decide", action="store_true",
|
|
2862
|
+
help="decision-point digest: what needs the human's judgment NOW "
|
|
2863
|
+
"(task -> decision digest; milestone -> DECIDE NEXT only; "
|
|
2864
|
+
"bare -> the active task)")
|
|
1560
2865
|
prp.set_defaults(func=cmd_report)
|
|
1561
2866
|
|
|
2867
|
+
pdt = sub.add_parser("deltas",
|
|
2868
|
+
help="read-only report: open lessons learned grouped by competency")
|
|
2869
|
+
pdt.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
2870
|
+
pdt.set_defaults(func=cmd_deltas)
|
|
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
|
+
|
|
2879
|
+
pau = sub.add_parser("audit",
|
|
2880
|
+
help="read-only: verify recorded human decision points left well-formed records "
|
|
2881
|
+
"(exit 1 on findings — the CI enforcement gate)")
|
|
2882
|
+
pau.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
2883
|
+
pau.set_defaults(func=cmd_audit)
|
|
2884
|
+
|
|
2885
|
+
ppj = sub.add_parser("project", help="print .add/PROJECT.md (the read-first foundation)")
|
|
2886
|
+
ppj.set_defaults(func=cmd_project)
|
|
2887
|
+
|
|
1562
2888
|
return p
|
|
1563
2889
|
|
|
1564
2890
|
|