@pilotspace/add 1.0.0 → 1.1.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 +48 -0
- package/GETTING-STARTED.md +66 -4
- package/README.md +2 -2
- package/bin/cli.js +27 -35
- package/docs/02-the-flow.md +9 -6
- package/docs/04-step-2-scenarios.md +2 -0
- package/docs/05-step-3-contract.md +2 -0
- package/docs/06-step-4-tests.md +2 -0
- package/docs/08-step-6-verify.md +11 -2
- package/docs/09-the-loop.md +18 -0
- package/docs/10-setup-and-stages.md +36 -7
- package/docs/13-adoption.md +2 -2
- package/docs/14-foundation.md +12 -4
- package/docs/appendix-f-requirements-matrix.md +5 -4
- package/package.json +5 -3
- package/skill/add/SKILL.md +40 -13
- package/skill/add/adopt.md +65 -0
- package/skill/add/deltas.md +12 -2
- package/skill/add/phases/0-setup.md +87 -24
- package/skill/add/phases/3-contract.md +16 -0
- package/skill/add/phases/4-tests.md +14 -0
- package/skill/add/phases/5-build.md +3 -0
- package/skill/add/phases/6-verify.md +15 -3
- package/skill/add/report-template.md +48 -0
- package/skill/add/run.md +11 -3
- package/skill/add/scope.md +18 -0
- package/skill/add/setup-review.md +62 -0
- package/skill/add/streams.md +206 -0
- package/tooling/add.py +940 -56
- package/tooling/templates/TASK.md.tmpl +7 -0
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
|
|
@@ -162,7 +163,14 @@ def _require_root() -> Path:
|
|
|
162
163
|
|
|
163
164
|
|
|
164
165
|
def load_state(root: Path) -> dict:
|
|
165
|
-
|
|
166
|
+
"""Load + parse state.json, failing CLOSED. A corrupt or unreadable state file
|
|
167
|
+
dies with a clean 'state_invalid' message (never a raw traceback), so every
|
|
168
|
+
command that loads state degrades gracefully (design-for-failure)."""
|
|
169
|
+
try:
|
|
170
|
+
return json.loads((root / STATE_FILE).read_text(encoding="utf-8"))
|
|
171
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
172
|
+
_die(f"state_invalid: {root / STATE_FILE} is corrupt or unreadable "
|
|
173
|
+
f"({e.__class__.__name__}) — restore it from git or a backup")
|
|
166
174
|
|
|
167
175
|
|
|
168
176
|
def _load_state_for_json() -> tuple[Path, dict]:
|
|
@@ -191,6 +199,15 @@ def save_state(root: Path, state: dict) -> None:
|
|
|
191
199
|
_atomic_write(root / STATE_FILE, json.dumps(state, indent=2) + "\n")
|
|
192
200
|
|
|
193
201
|
|
|
202
|
+
def _setup_locked(state: dict) -> bool:
|
|
203
|
+
"""True when the project's setup is locked — i.e. the build-boundary gate is OPEN.
|
|
204
|
+
|
|
205
|
+
A state with NO "setup" key is GRANDFATHERED-locked: plain `init` and every legacy
|
|
206
|
+
project are never gated (the lock is opt-in via `init --await-lock`). The gate is
|
|
207
|
+
therefore active in exactly one case: "setup" present AND locked is False."""
|
|
208
|
+
return ("setup" not in state) or (state["setup"].get("locked") is True)
|
|
209
|
+
|
|
210
|
+
|
|
194
211
|
def _die(msg: str, code: int = 1) -> None:
|
|
195
212
|
print(f"add: error: {msg}", file=sys.stderr)
|
|
196
213
|
raise SystemExit(code)
|
|
@@ -205,27 +222,37 @@ def _die(msg: str, code: int = 1) -> None:
|
|
|
205
222
|
# 20%+ more cost), so the stable pointer is the whole point.
|
|
206
223
|
|
|
207
224
|
def _guideline_block() -> str:
|
|
208
|
-
"""The canonical ADD block (markers + body, no trailing newline).
|
|
225
|
+
"""The canonical ADD block (markers + body, no trailing newline).
|
|
226
|
+
|
|
227
|
+
Agent-agnostic by design (v14 agent-portability): the routing steps depend
|
|
228
|
+
only on the CLI and plain files, so any agent — Claude, Cursor, Copilot,
|
|
229
|
+
Codex — can follow them. Claude additionally gets the `add` skill."""
|
|
209
230
|
return (
|
|
210
231
|
f"{_GUIDE_BEGIN}\n"
|
|
211
232
|
"## ADD — how to work in this repo\n"
|
|
212
233
|
"\n"
|
|
213
234
|
"This project uses **ADD (AI-Driven Development)**: you, the AI, drive the build;\n"
|
|
214
|
-
"the human owns direction and verification.
|
|
235
|
+
"the human owns direction and verification. The loop below works for any agent —\n"
|
|
236
|
+
"Claude, Cursor, Copilot, Codex — through the CLI alone. Before you change code:\n"
|
|
215
237
|
"\n"
|
|
216
238
|
"1. Run `python3 .add/tooling/add.py status` — where the project is and what's\n"
|
|
217
239
|
" next (the resume point; read it first every session).\n"
|
|
218
240
|
"2. Read `.add/PROJECT.md` — the foundation (domain · spec · UI/UX) every task\n"
|
|
219
241
|
" builds on.\n"
|
|
220
|
-
"3.
|
|
221
|
-
"
|
|
222
|
-
"
|
|
223
|
-
"
|
|
224
|
-
"
|
|
242
|
+
"3. Run `python3 .add/tooling/add.py guide` — it names the phase and the exact\n"
|
|
243
|
+
" phase-guide file to read (the `guide :` line). Work ONLY that phase — each\n"
|
|
244
|
+
" guide ends with its exit gate and the command to move on.\n"
|
|
245
|
+
"\n"
|
|
246
|
+
"The flow: INTAKE sizes a request into a milestone; each task runs the\n"
|
|
247
|
+
"**one-approval front** — Spec+Scenarios+Contract+Tests as one bundle,\n"
|
|
248
|
+
"ONE human approval at the frozen contract — then a self-driving build→verify\n"
|
|
249
|
+
"run. Non-negotiable for every agent:\n"
|
|
250
|
+
"Never weaken a test or edit a frozen contract to make a build pass; a security\n"
|
|
251
|
+
"finding is always HARD-STOP — never auto-passed.\n"
|
|
225
252
|
"\n"
|
|
226
|
-
"
|
|
227
|
-
"
|
|
228
|
-
"outside the markers, not inside.\n"
|
|
253
|
+
"On Claude Code the `add` skill drives this loop automatically; other agents\n"
|
|
254
|
+
"follow the three steps. The book is in `.add/docs/`. This block is generated\n"
|
|
255
|
+
"by `add.py sync-guidelines`; edit outside the markers, not inside.\n"
|
|
229
256
|
f"{_GUIDE_END}"
|
|
230
257
|
)
|
|
231
258
|
|
|
@@ -294,6 +321,24 @@ def _inject_guidelines(project_root: Path) -> list[tuple[str, str]]:
|
|
|
294
321
|
|
|
295
322
|
# --- commands ----------------------------------------------------------------
|
|
296
323
|
|
|
324
|
+
_INIT_EXCLUDE = {
|
|
325
|
+
".add", "AGENTS.md", "CLAUDE.md", ".git",
|
|
326
|
+
".gitignore", ".gitattributes", ".github", ".editorconfig", # VCS/CI/editor scaffolding — no domain signal
|
|
327
|
+
"LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", # legal boilerplate — no domain signal
|
|
328
|
+
} # README/docs/source are NOT excluded: they carry domain content adopt.md maps -> brownfield
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _is_brownfield(base: Path) -> bool:
|
|
332
|
+
"""True when `base` already holds project content beyond the tool's own scaffolding.
|
|
333
|
+
|
|
334
|
+
Judgment-free: a mechanical fact (does the dir hold a non-excluded entry?), so the
|
|
335
|
+
autonomous-onboarding flow knows to map existing code into the survivors. INTERPRETING
|
|
336
|
+
that code stays with the AI (skill/add/adopt.md) — the engine only detects + signals."""
|
|
337
|
+
if not base.is_dir():
|
|
338
|
+
return False
|
|
339
|
+
return any(child.name not in _INIT_EXCLUDE for child in base.iterdir())
|
|
340
|
+
|
|
341
|
+
|
|
297
342
|
def cmd_init(args: argparse.Namespace) -> None:
|
|
298
343
|
base = Path(args.dir).resolve()
|
|
299
344
|
root = base / ROOT_DIRNAME
|
|
@@ -330,14 +375,24 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
330
375
|
"created": _now(),
|
|
331
376
|
"updated": _now(),
|
|
332
377
|
}
|
|
378
|
+
if getattr(args, "await_lock", False):
|
|
379
|
+
# opt-in: seed an UNLOCKED setup so the build-boundary gate is active until
|
|
380
|
+
# `add.py lock`. Plain init omits this key entirely (grandfathered-locked).
|
|
381
|
+
state["setup"] = {"locked": False, "locked_at": None, "locked_by": None, "layers": []}
|
|
333
382
|
save_state(root, state)
|
|
334
383
|
# zero-config: give any agent a stable pointer into the ADD runtime.
|
|
335
384
|
for name, action in _inject_guidelines(base):
|
|
336
385
|
if action != "unchanged":
|
|
337
386
|
print(f"{action:>9} {name}")
|
|
338
387
|
print(f"initialised ADD project '{state['project']}' (stage: {state['stage']}) at {root}")
|
|
339
|
-
|
|
340
|
-
|
|
388
|
+
if _is_brownfield(base):
|
|
389
|
+
# Existing code present — the AI maps it SILENTLY into the survivors (skill/add/adopt.md),
|
|
390
|
+
# then the human locks it down. The engine only flags it; it never reads or fills the code.
|
|
391
|
+
print("brownfield: existing code detected — the `add` skill maps it into your")
|
|
392
|
+
print(" foundation (silent), then you lock it down: add.py lock")
|
|
393
|
+
else:
|
|
394
|
+
print("next: open Claude Code, run `/add`, and say what you want to build —")
|
|
395
|
+
print(" the `add` skill sizes it into a milestone and drives the build with you.")
|
|
341
396
|
|
|
342
397
|
|
|
343
398
|
def cmd_sync_guidelines(args: argparse.Namespace) -> None:
|
|
@@ -349,6 +404,9 @@ def cmd_sync_guidelines(args: argparse.Namespace) -> None:
|
|
|
349
404
|
def cmd_new_task(args: argparse.Namespace) -> None:
|
|
350
405
|
root = _require_root()
|
|
351
406
|
state = load_state(root)
|
|
407
|
+
# build-boundary gate: pre-lock, EXACTLY one first task may be drafted; refuse a 2nd.
|
|
408
|
+
if not _setup_locked(state) and state.get("tasks"):
|
|
409
|
+
_die("setup_unlocked: lock the foundation first — add.py lock")
|
|
352
410
|
slug = args.slug
|
|
353
411
|
if not slug.replace("-", "").replace("_", "").isalnum():
|
|
354
412
|
_die("slug must be alphanumeric with - or _ only")
|
|
@@ -453,6 +511,10 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
453
511
|
if idx >= len(PHASES) - 1:
|
|
454
512
|
_die(f"task '{slug}' already at final phase ({cur})")
|
|
455
513
|
nxt = PHASES[idx + 1]
|
|
514
|
+
# build-boundary gate: pre-lock the front (specify..tests) is allowed, but crossing
|
|
515
|
+
# into build/verify/observe/done is refused until `add.py lock`.
|
|
516
|
+
if not _setup_locked(state) and nxt in ("build", "verify", "observe", "done"):
|
|
517
|
+
_die("setup_unlocked: lock the foundation first — add.py lock")
|
|
456
518
|
state["tasks"][slug]["phase"] = nxt
|
|
457
519
|
state["tasks"][slug]["updated"] = _now()
|
|
458
520
|
_sync_task_marker(root, slug, nxt)
|
|
@@ -460,10 +522,33 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
460
522
|
print(f"task '{slug}' phase {cur} -> {nxt}")
|
|
461
523
|
|
|
462
524
|
|
|
525
|
+
# The mechanized high-risk guard (run.md, v14): judging WHAT is high-risk stays
|
|
526
|
+
# human — a scope declares `risk: high` in its TASK.md header at the freeze. The
|
|
527
|
+
# engine then enforces the pure token contradiction: risk: high WITHOUT
|
|
528
|
+
# autonomy: conservative is unguarded, and completion is refused. Tokens are
|
|
529
|
+
# read from the header region (text before the first section heading) with HTML
|
|
530
|
+
# comments stripped — a documentation comment is never a declaration.
|
|
531
|
+
_RISK_HIGH_RE = re.compile(r"\brisk:\s*high\b")
|
|
532
|
+
_AUTONOMY_CONSERVATIVE_RE = re.compile(r"\bautonomy:\s*conservative\b")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _task_header(root: Path, slug: str) -> str:
|
|
536
|
+
"""The TASK.md header region — where declared tokens (risk · autonomy)
|
|
537
|
+
live — with HTML comments stripped. Missing file -> '' (no tokens)."""
|
|
538
|
+
try:
|
|
539
|
+
text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8")
|
|
540
|
+
except OSError:
|
|
541
|
+
return ""
|
|
542
|
+
return re.sub(r"<!--.*?-->", "", text.split("\n## ", 1)[0], flags=re.S)
|
|
543
|
+
|
|
544
|
+
|
|
463
545
|
def cmd_gate(args: argparse.Namespace) -> None:
|
|
464
546
|
root = _require_root()
|
|
465
547
|
state = load_state(root)
|
|
466
548
|
slug = _resolve_task(state, args.slug)
|
|
549
|
+
# build-boundary gate: no verdict may be recorded before the setup is locked.
|
|
550
|
+
if not _setup_locked(state):
|
|
551
|
+
_die("setup_unlocked: lock the foundation first — add.py lock")
|
|
467
552
|
if args.outcome not in GATES:
|
|
468
553
|
_die(f"outcome must be one of: {', '.join(GATES)}")
|
|
469
554
|
# Completing outcomes (PASS, RISK-ACCEPTED) are the VERIFY step's verdict, so they
|
|
@@ -478,6 +563,14 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
478
563
|
else "gate_risk_accepted_before_verify")
|
|
479
564
|
_die(f"{code}: task '{slug}' is at '{current}'; reach the verify phase "
|
|
480
565
|
f"first (or `add.py phase verify {slug}` to override)")
|
|
566
|
+
# the mechanized high-risk guard: an unguarded high-risk header refuses
|
|
567
|
+
# COMPLETION (PASS / RISK-ACCEPTED) until the dial is lowered and a human
|
|
568
|
+
# owns the gate. HARD-STOP is never blocked — stopping is always allowed.
|
|
569
|
+
hdr = _task_header(root, slug)
|
|
570
|
+
if _RISK_HIGH_RE.search(hdr) and not _AUTONOMY_CONSERVATIVE_RE.search(hdr):
|
|
571
|
+
_die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high "
|
|
572
|
+
"without autonomy: conservative — lower the dial in the TASK.md "
|
|
573
|
+
"header; a human must own a high-risk gate (run.md guard)")
|
|
481
574
|
if args.outcome == "RISK-ACCEPTED":
|
|
482
575
|
# A waiver must be SIGNED: owner, ticket, expiry (glossary). Stored in state
|
|
483
576
|
# so a later `check` can read/expire it. Refuse a partial waiver outright.
|
|
@@ -499,6 +592,36 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
499
592
|
print("HARD-STOP recorded: return to BUILD; nothing ships on a failing/security gate.")
|
|
500
593
|
|
|
501
594
|
|
|
595
|
+
def cmd_lock(args: argparse.Namespace) -> None:
|
|
596
|
+
"""The human lock-down: freeze the autonomously-drafted setup in ONE atomic write.
|
|
597
|
+
|
|
598
|
+
Setup-altitude analog of the contract freeze — the only new human action onboarding
|
|
599
|
+
needs. `add.py lock` is judgment-free (it records the signature; it does NOT inspect
|
|
600
|
+
the artifacts): the human's signature IS the gate."""
|
|
601
|
+
root = _require_root()
|
|
602
|
+
state = load_state(root)
|
|
603
|
+
# idempotent-guarded: the predicate also treats a grandfathered (no "setup" key)
|
|
604
|
+
# project as already locked, so a bare re-lock there refuses too.
|
|
605
|
+
if _setup_locked(state) and not args.force:
|
|
606
|
+
_die("already_locked: setup is already locked (use --force to re-lock)")
|
|
607
|
+
# parse layers BEFORE any write so an invalid request never half-locks (design-for-failure).
|
|
608
|
+
raw = args.layers if args.layers is not None else "foundation,scope,contract"
|
|
609
|
+
layers = [s.strip() for s in raw.split(",") if s.strip()]
|
|
610
|
+
if not layers:
|
|
611
|
+
_die("layers_invalid: --layers must name at least one lock layer")
|
|
612
|
+
who = args.by or getpass.getuser()
|
|
613
|
+
when = _now()
|
|
614
|
+
# ONE atomic write — no partial lock state.
|
|
615
|
+
state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers}
|
|
616
|
+
save_state(root, state)
|
|
617
|
+
if getattr(args, "json", False):
|
|
618
|
+
print(json.dumps(
|
|
619
|
+
{"locked": True, "locked_at": when, "locked_by": who, "layers": layers},
|
|
620
|
+
separators=(",", ":")))
|
|
621
|
+
else:
|
|
622
|
+
print(f"locked setup ({','.join(layers)}) by {who} @ {when}")
|
|
623
|
+
|
|
624
|
+
|
|
502
625
|
def cmd_stage(args: argparse.Namespace) -> None:
|
|
503
626
|
root = _require_root()
|
|
504
627
|
state = load_state(root)
|
|
@@ -531,8 +654,11 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
531
654
|
state = load_state(root)
|
|
532
655
|
active = state.get("active_task")
|
|
533
656
|
tasks = state.get("tasks", {})
|
|
534
|
-
|
|
535
|
-
|
|
657
|
+
# Compute once: True when setup is present AND locked is False (the lock-gate window).
|
|
658
|
+
# Reuses the canonical helper — do NOT write a parallel predicate.
|
|
659
|
+
unlocked = not _setup_locked(state)
|
|
660
|
+
print(f"project : {state.get('project', '(unknown)')}")
|
|
661
|
+
print(f"stage : {state.get('stage', '(unknown)')}")
|
|
536
662
|
# foundation pointer — read the cross-milestone context first (anti-rot)
|
|
537
663
|
if (root / "PROJECT.md").exists():
|
|
538
664
|
print("context : .add/PROJECT.md (foundation: domain · spec · UI/UX — read first)")
|
|
@@ -560,13 +686,18 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
560
686
|
print(f"active : {active or '(none)'}")
|
|
561
687
|
if not tasks:
|
|
562
688
|
# First-run panel: a brand-new project's status is the moment a user is most
|
|
563
|
-
# lost.
|
|
564
|
-
#
|
|
689
|
+
# lost. When the setup is unlocked, the only correct next move is review+lock —
|
|
690
|
+
# suppress the generic /add hint and name the two steps that matter.
|
|
565
691
|
print("tasks : (none yet)")
|
|
566
692
|
print()
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
693
|
+
if unlocked:
|
|
694
|
+
print("setup : UNLOCKED — review .add/SETUP-REVIEW.md (least-sure first),"
|
|
695
|
+
" then sign: add.py lock")
|
|
696
|
+
print(" (the build-boundary gate is closed until the foundation is locked)")
|
|
697
|
+
else:
|
|
698
|
+
print("next : you're set up. In Claude Code, run /add and say what you want to")
|
|
699
|
+
print(" build — the `add` skill sizes it into a milestone and drives the")
|
|
700
|
+
print(' build with you. Escape hatch: add.py new-task <slug> --title "..."')
|
|
570
701
|
return
|
|
571
702
|
print("tasks :")
|
|
572
703
|
for slug, t in tasks.items():
|
|
@@ -575,7 +706,18 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
575
706
|
dep_s = f" deps={','.join(deps)}" if deps else ""
|
|
576
707
|
ms_s = f" [{t['milestone']}]" if t.get("milestone") else ""
|
|
577
708
|
print(f" {mark} {slug:<24} phase={t['phase']:<10} gate={t['gate']}{ms_s}{dep_s}")
|
|
578
|
-
|
|
709
|
+
# fold-pressure nudge: surface unfolded competency deltas so emission can't
|
|
710
|
+
# silently outrun the human fold (read-only; v11). Silent when none are open.
|
|
711
|
+
open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
|
|
712
|
+
if open_deltas:
|
|
713
|
+
print(f"deltas : {open_deltas} open — fold at milestone close (add.py deltas)")
|
|
714
|
+
# When the setup is unlocked, the only terminal guidance that matters is
|
|
715
|
+
# review+lock; suppress the generic resume block so it does not compete.
|
|
716
|
+
if unlocked:
|
|
717
|
+
print("\nsetup : UNLOCKED — review .add/SETUP-REVIEW.md (least-sure first),"
|
|
718
|
+
" then sign: add.py lock")
|
|
719
|
+
print(" (the build-boundary gate is closed until the foundation is locked)")
|
|
720
|
+
elif active and active in tasks:
|
|
579
721
|
ph = tasks[active]["phase"]
|
|
580
722
|
if ph == "done":
|
|
581
723
|
print(f"\nresume : task '{active}' is done ({tasks[active]['gate']}).")
|
|
@@ -585,18 +727,42 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
585
727
|
print(f" read .add/tasks/{active}/TASK.md and continue that phase.")
|
|
586
728
|
|
|
587
729
|
|
|
730
|
+
# Agent-portability (v14): `guide` names the PHASE PLAYBOOK file — the same
|
|
731
|
+
# guides the Claude skill loads, installed as plain markdown by every channel
|
|
732
|
+
# at .claude/skills/add/phases/ — so ANY agent (Cursor, Copilot, Codex) can be
|
|
733
|
+
# routed there through the CLI alone. Never a dead pointer: the path is printed
|
|
734
|
+
# only if the file exists; a missing tree gets an install hint instead.
|
|
735
|
+
_PHASE_GUIDE_FILES = {
|
|
736
|
+
"specify": "1-specify.md", "scenarios": "2-scenarios.md",
|
|
737
|
+
"contract": "3-contract.md", "tests": "4-tests.md",
|
|
738
|
+
"build": "5-build.md", "verify": "6-verify.md", "observe": "7-observe.md",
|
|
739
|
+
}
|
|
740
|
+
_SKILL_PHASES_DIR = Path(".claude") / "skills" / "add" / "phases"
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _phase_guide_path(project_root: Path, phase: str) -> str | None:
|
|
744
|
+
"""Relative path to the phase playbook if it exists, else None.
|
|
745
|
+
done/unknown phases have no playbook (the `then:` line routes onward)."""
|
|
746
|
+
fname = _PHASE_GUIDE_FILES.get(phase)
|
|
747
|
+
if fname is None:
|
|
748
|
+
return None
|
|
749
|
+
rel = _SKILL_PHASES_DIR / fname
|
|
750
|
+
return str(rel) if (project_root / rel).is_file() else None
|
|
751
|
+
|
|
752
|
+
|
|
588
753
|
def cmd_guide(args: argparse.Namespace) -> None:
|
|
589
754
|
"""Answer "what do I do next?" for the active (or named) task.
|
|
590
755
|
|
|
591
756
|
Strictly read-only: load_state only — never save_state, never writes a TASK.md.
|
|
592
757
|
"""
|
|
593
758
|
if getattr(args, "json", False):
|
|
594
|
-
|
|
759
|
+
json_root, state = _load_state_for_json()
|
|
595
760
|
slug = args.slug or state.get("active_task")
|
|
596
761
|
if not slug:
|
|
597
762
|
print(json.dumps({"task": None, "phase": None, "owner": "human", "stop": True,
|
|
598
763
|
"next_step": "start your first feature -> add.py new-task <slug>",
|
|
599
|
-
"chapter": ".add/docs/02-the-flow.md", "gate": None
|
|
764
|
+
"chapter": ".add/docs/02-the-flow.md", "gate": None,
|
|
765
|
+
"guide": None}))
|
|
600
766
|
return
|
|
601
767
|
t = (state.get("tasks") or {}).get(slug)
|
|
602
768
|
if t is None:
|
|
@@ -606,7 +772,8 @@ def cmd_guide(args: argparse.Namespace) -> None:
|
|
|
606
772
|
action, chapter = PHASE_GUIDE[phase] # phase is mapped, so PHASE_GUIDE has it too
|
|
607
773
|
print(json.dumps({"task": slug, "phase": phase, "owner": owner,
|
|
608
774
|
"stop": owner != "ai", "next_step": action,
|
|
609
|
-
"chapter": f".add/docs/{chapter}", "gate": t.get("gate")
|
|
775
|
+
"chapter": f".add/docs/{chapter}", "gate": t.get("gate"),
|
|
776
|
+
"guide": _phase_guide_path(json_root.parent, phase)}))
|
|
610
777
|
return
|
|
611
778
|
root = _require_root()
|
|
612
779
|
state = load_state(root)
|
|
@@ -626,6 +793,11 @@ def cmd_guide(args: argparse.Namespace) -> None:
|
|
|
626
793
|
print(f"active : {slug} (phase: {phase})")
|
|
627
794
|
print(f"next : {action}")
|
|
628
795
|
print(f"read : .add/docs/{chapter}")
|
|
796
|
+
gp = _phase_guide_path(root.parent, phase)
|
|
797
|
+
if gp is not None:
|
|
798
|
+
print(f"guide : {gp}")
|
|
799
|
+
elif phase in _PHASE_GUIDE_FILES:
|
|
800
|
+
print("guide : (phase guides not installed — npx @pilotspace/add init)")
|
|
629
801
|
if phase == "verify":
|
|
630
802
|
print("then : add.py gate PASS | RISK-ACCEPTED | HARD-STOP")
|
|
631
803
|
elif phase == "done":
|
|
@@ -698,6 +870,13 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
698
870
|
except (ValueError, TypeError):
|
|
699
871
|
ok, reason = False, f"waiver_expired (unparseable expires={exp!r})"
|
|
700
872
|
checks.append((ok, f"task '{slug}' waiver not expired", reason))
|
|
873
|
+
# delta-lint: validate all OPEN entries in the "### Competency deltas" block.
|
|
874
|
+
# Fail-closed; folded/rejected entries are skipped (open-only). Only emits a
|
|
875
|
+
# check when at least one delta-attempt is present in the block.
|
|
876
|
+
lint_result = _lint_task_deltas(root, slug)
|
|
877
|
+
if lint_result is not None:
|
|
878
|
+
ok, reason = lint_result
|
|
879
|
+
checks.append((ok, f"task '{slug}' deltas well-formed", reason))
|
|
701
880
|
|
|
702
881
|
# drift: a done milestone must have no unfinished tasks
|
|
703
882
|
for mslug, m in milestones.items():
|
|
@@ -837,6 +1016,12 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
|
837
1016
|
print(f"milestone '{slug}' -> done ({len(members)} tasks complete{tail}).")
|
|
838
1017
|
print(f"wrote {retro_path.relative_to(root.parent)} (milestone exit report)")
|
|
839
1018
|
print("Confirm the MILESTONE.md exit criteria are checked, then archive/start the next.")
|
|
1019
|
+
# fold-pressure nudge: milestone close is the natural fold point for open deltas (v11)
|
|
1020
|
+
open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
|
|
1021
|
+
if open_deltas:
|
|
1022
|
+
noun = "delta" if open_deltas == 1 else "deltas"
|
|
1023
|
+
print(f"note: {open_deltas} open competency {noun} to fold into the foundation "
|
|
1024
|
+
f"— review with: add.py deltas")
|
|
840
1025
|
|
|
841
1026
|
|
|
842
1027
|
def cmd_archive_milestone(args: argparse.Namespace) -> None:
|
|
@@ -861,6 +1046,15 @@ def cmd_archive_milestone(args: argparse.Namespace) -> None:
|
|
|
861
1046
|
t = tasks[s]
|
|
862
1047
|
print(f" - {s} (phase={t.get('phase')}, gate={t.get('gate')})", file=sys.stderr)
|
|
863
1048
|
_die("milestone_has_incomplete_tasks")
|
|
1049
|
+
# pre-archive snapshot (design-for-failure): the archived record below keeps only a
|
|
1050
|
+
# slug-list, so capture the full milestone + member task records to a .bak BEFORE the
|
|
1051
|
+
# destructive deletes — an accidental archive stays recoverable (phase/gate/waiver/deps
|
|
1052
|
+
# the record drops). Mirrors the .bak the guideline injector writes before mutating.
|
|
1053
|
+
_atomic_write(
|
|
1054
|
+
root / "milestones" / slug / "pre-archive-state.bak.json",
|
|
1055
|
+
json.dumps({"milestone": ms, "tasks": {s: tasks[s] for s in members},
|
|
1056
|
+
"archived_at": _now()}, indent=2) + "\n",
|
|
1057
|
+
)
|
|
864
1058
|
# a slug-list summary (never task bodies) so the active state can't regrow,
|
|
865
1059
|
# yet cross-milestone deps on these tasks still resolve (see _archived_task_slugs)
|
|
866
1060
|
state.setdefault("archived", []).append({
|
|
@@ -900,6 +1094,20 @@ def cmd_set_milestone(args: argparse.Namespace) -> None:
|
|
|
900
1094
|
print(f"task '{task}' -> milestone '{new}'" if new else f"task '{task}' -> milestone (none)")
|
|
901
1095
|
|
|
902
1096
|
|
|
1097
|
+
def cmd_use(args: argparse.Namespace) -> None:
|
|
1098
|
+
"""Set the active task to an EXISTING task (switch focus) without scaffolding a new
|
|
1099
|
+
one or hand-editing state.json. advance/gate/phase still take an explicit slug; `use`
|
|
1100
|
+
just moves the default focus, closing the only gap that forced manual state edits."""
|
|
1101
|
+
root = _require_root()
|
|
1102
|
+
state = load_state(root)
|
|
1103
|
+
slug = args.slug
|
|
1104
|
+
if slug not in state.get("tasks", {}):
|
|
1105
|
+
_die("unknown_task")
|
|
1106
|
+
state["active_task"] = slug
|
|
1107
|
+
save_state(root, state)
|
|
1108
|
+
print(f"active task -> '{slug}' (phase={state['tasks'][slug]['phase']})")
|
|
1109
|
+
|
|
1110
|
+
|
|
903
1111
|
def _find_cycle(tasks: dict) -> list[str] | None:
|
|
904
1112
|
"""Return a cycle path in the depends_on graph, or None. Ignores unknown deps."""
|
|
905
1113
|
WHITE, GRAY, BLACK = 0, 1, 2
|
|
@@ -1057,12 +1265,80 @@ def _exit_criteria(root: Path, mslug: str) -> tuple[int, int]:
|
|
|
1057
1265
|
return met, total
|
|
1058
1266
|
|
|
1059
1267
|
|
|
1268
|
+
def _count_test_defs(f: Path) -> int:
|
|
1269
|
+
"""`def test_` occurrences in one file — the ONE counting regex (primary and
|
|
1270
|
+
§4-declared fallback share it by construction). OSError -> 0, fail-closed."""
|
|
1271
|
+
try:
|
|
1272
|
+
return len(re.findall(r"^\s*def test_", f.read_text(encoding="utf-8"), re.M))
|
|
1273
|
+
except OSError:
|
|
1274
|
+
return 0
|
|
1275
|
+
|
|
1276
|
+
|
|
1060
1277
|
def _tests_count(root: Path, slug: str) -> int:
|
|
1061
1278
|
d = root / "tasks" / slug / "tests"
|
|
1062
1279
|
if not d.is_dir():
|
|
1063
1280
|
return 0
|
|
1064
|
-
return sum(
|
|
1065
|
-
|
|
1281
|
+
return sum(_count_test_defs(f) for f in d.glob("*.py"))
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _confined(p: Path, rootp: Path) -> bool:
|
|
1285
|
+
"""True only if p resolves (symlinks followed) inside rootp; errors -> False.
|
|
1286
|
+
The v2 confinement check — no read is attempted on a path that fails it."""
|
|
1287
|
+
try:
|
|
1288
|
+
return p.resolve().is_relative_to(rootp)
|
|
1289
|
+
except OSError:
|
|
1290
|
+
return False
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def _declared_tests_count(root: Path, slug: str) -> int:
|
|
1294
|
+
"""Count tests at the §4 'Tests live in:' declared path(s). PURE, fail-closed 0.
|
|
1295
|
+
Tokens are the backticked spans on the FIRST declaring line of the raw §4 body.
|
|
1296
|
+
Resolution: './…' -> task dir · contains '/' -> project root (parent of .add) ·
|
|
1297
|
+
bare name -> sibling of the previous resolved token (else task dir). A directory
|
|
1298
|
+
token counts the *.py files directly inside it; resolved files are deduped.
|
|
1299
|
+
v2 confinement: every file read must resolve inside the project root — '..'
|
|
1300
|
+
traversal, absolute tokens, and symlink escapes all contribute 0, fail-closed."""
|
|
1301
|
+
body = _raw_phase_bodies(root, slug).get(4, "")
|
|
1302
|
+
m = re.search(r"^\s*Tests live in:.*$", body, re.M)
|
|
1303
|
+
if not m:
|
|
1304
|
+
return 0
|
|
1305
|
+
tdir = root / "tasks" / slug
|
|
1306
|
+
rootp = root.parent.resolve()
|
|
1307
|
+
files: list[Path] = []
|
|
1308
|
+
prev_dir = None
|
|
1309
|
+
for tok in re.findall(r"`([^`]+)`", m.group(0)):
|
|
1310
|
+
tok = tok.strip()
|
|
1311
|
+
if tok.startswith("./"):
|
|
1312
|
+
p = tdir / tok[2:]
|
|
1313
|
+
elif "/" in tok:
|
|
1314
|
+
p = root.parent / tok
|
|
1315
|
+
else:
|
|
1316
|
+
p = (prev_dir or tdir) / tok
|
|
1317
|
+
try:
|
|
1318
|
+
if not _confined(p, rootp):
|
|
1319
|
+
continue
|
|
1320
|
+
if p.is_dir():
|
|
1321
|
+
cand, prev_dir = sorted(f for f in p.glob("*.py")
|
|
1322
|
+
if _confined(f, rootp)), p
|
|
1323
|
+
elif p.is_file() and p.suffix == ".py":
|
|
1324
|
+
cand, prev_dir = [p], p.parent
|
|
1325
|
+
else:
|
|
1326
|
+
continue
|
|
1327
|
+
except OSError:
|
|
1328
|
+
continue
|
|
1329
|
+
files.extend(f for f in cand if f not in files)
|
|
1330
|
+
return sum(_count_test_defs(f) for f in files)
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def _tests_info(root: Path, slug: str) -> tuple[int, bool]:
|
|
1334
|
+
"""(count, declared). The tests/ dir count ALWAYS wins when > 0; otherwise the
|
|
1335
|
+
§4-declared fallback — flagged True only when it supplied a non-zero count, so
|
|
1336
|
+
a true zero stays a bare, honest 0."""
|
|
1337
|
+
primary = _tests_count(root, slug)
|
|
1338
|
+
if primary > 0:
|
|
1339
|
+
return primary, False
|
|
1340
|
+
declared = _declared_tests_count(root, slug)
|
|
1341
|
+
return (declared, True) if declared > 0 else (0, False)
|
|
1066
1342
|
|
|
1067
1343
|
|
|
1068
1344
|
def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
|
|
@@ -1076,8 +1352,6 @@ def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
|
|
|
1076
1352
|
text = f.read_text(encoding="utf-8")
|
|
1077
1353
|
m7 = re.search(r"##\s*7\s*·\s*OBSERVE.*\Z", text, re.S)
|
|
1078
1354
|
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
1355
|
# observe: the field value + continuation lines until a blank line / heading / list
|
|
1082
1356
|
observe = "(unknown)"
|
|
1083
1357
|
for i, ln in enumerate(lines):
|
|
@@ -1098,14 +1372,14 @@ def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
|
|
|
1098
1372
|
# deltas: each "- [COMP · status] ..." plus its indented continuation lines
|
|
1099
1373
|
deltas, i = [], 0
|
|
1100
1374
|
while i < len(lines):
|
|
1101
|
-
m =
|
|
1375
|
+
m = _DELTA_RE.match(lines[i])
|
|
1102
1376
|
if not m:
|
|
1103
1377
|
i += 1
|
|
1104
1378
|
continue
|
|
1105
1379
|
parts, j = [m.group(3).strip()], i + 1
|
|
1106
1380
|
while j < len(lines):
|
|
1107
1381
|
t = lines[j].strip()
|
|
1108
|
-
if not t or t.startswith("#") or
|
|
1382
|
+
if not t or t.startswith("#") or _DELTA_RE.match(lines[j]):
|
|
1109
1383
|
break
|
|
1110
1384
|
parts.append(t)
|
|
1111
1385
|
j += 1
|
|
@@ -1152,6 +1426,7 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
|
|
|
1152
1426
|
observe, deltas = _task_prose(root, slug)
|
|
1153
1427
|
phase = t.get("phase", "specify")
|
|
1154
1428
|
gate = t.get("gate", "none")
|
|
1429
|
+
n_tests, t_declared = _tests_info(root, slug)
|
|
1155
1430
|
row = {
|
|
1156
1431
|
"slug": slug,
|
|
1157
1432
|
"title": t.get("title", slug),
|
|
@@ -1159,7 +1434,8 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
|
|
|
1159
1434
|
"phase_index": PHASES.index(phase) if phase in PHASES else 0,
|
|
1160
1435
|
"done": _task_done(t),
|
|
1161
1436
|
"gate": gate,
|
|
1162
|
-
"tests":
|
|
1437
|
+
"tests": n_tests,
|
|
1438
|
+
"tests_declared": t_declared,
|
|
1163
1439
|
"observe": observe,
|
|
1164
1440
|
"deltas": deltas,
|
|
1165
1441
|
"waiver": t.get("waiver"),
|
|
@@ -1185,6 +1461,9 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
|
|
|
1185
1461
|
"tasks": task_rows,
|
|
1186
1462
|
"waivers": waivers,
|
|
1187
1463
|
"deltas": all_deltas,
|
|
1464
|
+
# additive (v13-1): MILESTONE.md-planned slugs with no TASK.md yet —
|
|
1465
|
+
# the plan-vs-state diff DECIDE NEXT was blind to; [] when none
|
|
1466
|
+
"planned_unscaffolded": _planned_unscaffolded(root, mslug),
|
|
1188
1467
|
}
|
|
1189
1468
|
|
|
1190
1469
|
|
|
@@ -1204,22 +1483,13 @@ def _clean_phase_body(body: str) -> str:
|
|
|
1204
1483
|
return "\n".join(lines) if meaningful else "(empty)"
|
|
1205
1484
|
|
|
1206
1485
|
|
|
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).
|
|
1486
|
+
def _phase_spans(text: str) -> dict[int, str]:
|
|
1487
|
+
"""Split a TASK.md into RAW §1–§7 bodies keyed by section number — the ONE
|
|
1488
|
+
canonical heading scan (`^##\\s*<n>\\s*·`, case/locale-proof); a body runs from
|
|
1489
|
+
its heading to the next `## `/`---`/EOF. RAW = byte-faithful lines, no cleaning:
|
|
1490
|
+
the decision-marker extractor (decide-digest) depends on byte-verbatim text.
|
|
1215
1491
|
KNOWN LIMIT: a §body containing a line-start `## ` or bare `---` truncates early —
|
|
1216
1492
|
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
1493
|
lines = text.splitlines()
|
|
1224
1494
|
head = re.compile(r"^##\s*(\d+)\s*·")
|
|
1225
1495
|
starts: dict[int, int] = {}
|
|
@@ -1229,21 +1499,47 @@ def task_phases(root: Path, slug: str) -> list[dict]:
|
|
|
1229
1499
|
n = int(m.group(1))
|
|
1230
1500
|
if 1 <= n <= 7 and n not in starts:
|
|
1231
1501
|
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
|
|
1502
|
+
out: dict[int, str] = {}
|
|
1503
|
+
for n, idx in starts.items():
|
|
1237
1504
|
body_lines = []
|
|
1238
|
-
for ln in lines[
|
|
1505
|
+
for ln in lines[idx + 1:]:
|
|
1239
1506
|
if re.match(r"^##\s", ln) or re.match(r"^---\s*$", ln):
|
|
1240
1507
|
break
|
|
1241
1508
|
body_lines.append(ln)
|
|
1242
|
-
out
|
|
1243
|
-
"body": _clean_phase_body("\n".join(body_lines))})
|
|
1509
|
+
out[n] = "\n".join(body_lines)
|
|
1244
1510
|
return out
|
|
1245
1511
|
|
|
1246
1512
|
|
|
1513
|
+
def _raw_phase_bodies(root: Path, slug: str) -> dict[int, str]:
|
|
1514
|
+
"""RAW §bodies for one task (byte-faithful, for marker extraction). PURE.
|
|
1515
|
+
Missing/unreadable TASK.md -> {} (fail-closed, like task_phases)."""
|
|
1516
|
+
f = root / "tasks" / slug / "TASK.md"
|
|
1517
|
+
try:
|
|
1518
|
+
return _phase_spans(f.read_text(encoding="utf-8"))
|
|
1519
|
+
except OSError:
|
|
1520
|
+
return {}
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def task_phases(root: Path, slug: str) -> list[dict]:
|
|
1524
|
+
"""The frozen per-task PHASE-DETAIL shape (v9-1): parse TASK.md §1–§7 into seven
|
|
1525
|
+
blocks specify→observe. PURE — NO writes. Each entry is
|
|
1526
|
+
{ "phase": <name>, "n": <1..7>, "body": <cleaned text | "(empty)"> }.
|
|
1527
|
+
|
|
1528
|
+
The heading scan lives in _phase_spans (shared with the decide digest); this view
|
|
1529
|
+
CLEANS each body. Missing file / missing section / placeholder-only body ->
|
|
1530
|
+
"(empty)" (fail-closed)."""
|
|
1531
|
+
names = PHASES[:7] # specify..observe; "done" is a terminal STATE, not a section
|
|
1532
|
+
f = root / "tasks" / slug / "TASK.md"
|
|
1533
|
+
try:
|
|
1534
|
+
text = f.read_text(encoding="utf-8")
|
|
1535
|
+
except OSError: # missing OR unreadable -> every phase fail-closed to "(empty)"
|
|
1536
|
+
return [{"phase": names[n - 1], "n": n, "body": "(empty)"} for n in range(1, 8)]
|
|
1537
|
+
spans = _phase_spans(text)
|
|
1538
|
+
return [{"phase": names[n - 1], "n": n,
|
|
1539
|
+
"body": _clean_phase_body(spans[n]) if n in spans else "(empty)"}
|
|
1540
|
+
for n in range(1, 8)]
|
|
1541
|
+
|
|
1542
|
+
|
|
1247
1543
|
def _task_title(root: Path, slug: str) -> str:
|
|
1248
1544
|
"""The task's display title from TASK.md line 1 `# TASK: <title>` (fail-soft: the
|
|
1249
1545
|
slug if the file or the header line is missing)."""
|
|
@@ -1262,10 +1558,20 @@ def _task_title(root: Path, slug: str) -> str:
|
|
|
1262
1558
|
def _detail_body(body: str, width: int) -> list[str]:
|
|
1263
1559
|
"""Indent a phase body under its block, soft-wrapping over-long physical lines on
|
|
1264
1560
|
spaces while preserving blank lines + each line's leading indent (so scenarios and
|
|
1265
|
-
contract code keep their shape).
|
|
1561
|
+
contract code keep their shape). Fenced ``` blocks are exempt: delimiter lines and
|
|
1562
|
+
everything inside an open fence emit BYTE-VERBATIM (indent + raw — no wrap, no
|
|
1563
|
+
whitespace collapse, even past width) so a copied contract round-trips after
|
|
1564
|
+
stripping the uniform indent; an unclosed fence runs verbatim to the §body end
|
|
1565
|
+
(fail-open). Drill-down = reading is the point, never clipped."""
|
|
1266
1566
|
indent = " "
|
|
1267
1567
|
out: list[str] = []
|
|
1568
|
+
fenced = False
|
|
1268
1569
|
for raw in body.split("\n"):
|
|
1570
|
+
is_delim = raw.lstrip().startswith("```")
|
|
1571
|
+
if fenced or is_delim:
|
|
1572
|
+
fenced = fenced != is_delim # delimiter toggles; content keeps state
|
|
1573
|
+
out.append(indent + raw if raw.strip() else "")
|
|
1574
|
+
continue
|
|
1269
1575
|
if not raw.strip():
|
|
1270
1576
|
out.append("")
|
|
1271
1577
|
continue
|
|
@@ -1364,10 +1670,13 @@ def render_report(root: Path, state: dict, mslug: str, *,
|
|
|
1364
1670
|
for r in d["tasks"]:
|
|
1365
1671
|
slug = _clip(r["slug"], 27)
|
|
1366
1672
|
gate = _GATE_SHORT.get(r["gate"], r["gate"])
|
|
1673
|
+
tests = f"{r['tests']}†" if r.get("tests_declared") else str(r["tests"])
|
|
1367
1674
|
L.append(f" {slug:<27} {r['phase']:<9} {gate:<4} "
|
|
1368
|
-
f"{
|
|
1675
|
+
f"{tests:<5} {_phase_track(r['phase'], g)}")
|
|
1369
1676
|
L.append(f" legend {g['reached']} reached {g['current']} current "
|
|
1370
1677
|
f"{g['pending']} pending spec→…→done")
|
|
1678
|
+
if any(r.get("tests_declared") for r in d["tasks"]):
|
|
1679
|
+
L.append(" † counted at the §4-declared path")
|
|
1371
1680
|
else:
|
|
1372
1681
|
L.append(" (no tasks yet)")
|
|
1373
1682
|
L.append("")
|
|
@@ -1385,6 +1694,217 @@ def render_report(root: Path, state: dict, mslug: str, *,
|
|
|
1385
1694
|
L.extend(_wrap(x, W - 5, f" {g['bullet']} "))
|
|
1386
1695
|
else:
|
|
1387
1696
|
L.append(" LEARNINGS none")
|
|
1697
|
+
L.append("") # DECIDE NEXT footer (v13): always present, APPEND-ONLY
|
|
1698
|
+
L.extend(_wrap(_decide_next_base(state, d), W - 15, " DECIDE NEXT "))
|
|
1699
|
+
if _planned_hint(d): # own segment so the phrase never splits mid-token
|
|
1700
|
+
L.extend(_wrap(_planned_hint(d).removeprefix(" — "), W - 15, " " * 14))
|
|
1701
|
+
L.append(banner)
|
|
1702
|
+
return "\n".join(L)
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
# ---- decide digest (v13 decide-digest, frozen §3) ---------------------------
|
|
1706
|
+
# Decision markers: prose conventions surfaced VERBATIM. The engine EXTRACTS; it
|
|
1707
|
+
# never interprets, scores, or filters — add.py stays judgment-free, the human
|
|
1708
|
+
# signature is the gate.
|
|
1709
|
+
_MARKER_PREFIXES = (("⚠", "⚠"), ("- [~]", "[~]"), ("- [ ]", "[ ]"))
|
|
1710
|
+
_FRONT_PHASES = ("specify", "scenarios", "contract", "tests")
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
def _decision_markers(body: str, section: int) -> list[dict]:
|
|
1714
|
+
"""Extract decision markers from a RAW §body: a line whose first non-space chars
|
|
1715
|
+
are `⚠` / `- [~]` / `- [ ]`, PLUS its continuation lines (immediately following
|
|
1716
|
+
non-blank lines indented deeper than the marker). text is BYTE-VERBATIM — never
|
|
1717
|
+
re-wrapped, never clipped. Fail-open by design (a differently-worded item is
|
|
1718
|
+
missed); the always-printed count keeps that visible."""
|
|
1719
|
+
items: list[dict] = []
|
|
1720
|
+
lines = body.split("\n")
|
|
1721
|
+
i = 0
|
|
1722
|
+
while i < len(lines):
|
|
1723
|
+
ln = lines[i]
|
|
1724
|
+
stripped = ln.lstrip()
|
|
1725
|
+
tag = next((t for p, t in _MARKER_PREFIXES if stripped.startswith(p)), None)
|
|
1726
|
+
if tag is None:
|
|
1727
|
+
i += 1
|
|
1728
|
+
continue
|
|
1729
|
+
indent = len(ln) - len(stripped)
|
|
1730
|
+
block = [ln]
|
|
1731
|
+
j = i + 1
|
|
1732
|
+
while j < len(lines):
|
|
1733
|
+
nxt = lines[j]
|
|
1734
|
+
ns = nxt.lstrip()
|
|
1735
|
+
if ns and (len(nxt) - len(ns)) > indent:
|
|
1736
|
+
block.append(nxt)
|
|
1737
|
+
j += 1
|
|
1738
|
+
else:
|
|
1739
|
+
break
|
|
1740
|
+
items.append({"marker": tag, "section": section, "text": "\n".join(block)})
|
|
1741
|
+
i = j
|
|
1742
|
+
return items
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def _contract_frozen(raw3: str) -> bool:
|
|
1746
|
+
"""§3's `Status:` line is the freeze signal (v12 precedent: the freeze is
|
|
1747
|
+
artifact-observable; no engine flag). Missing Status -> DRAFT (fail-closed)."""
|
|
1748
|
+
return any(re.match(r"\s*Status:\s*FROZEN", ln) for ln in raw3.splitlines())
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict:
|
|
1752
|
+
"""FACTS for the task-level decision-seam digest (frozen shape). The seam comes
|
|
1753
|
+
from STATE ONLY: recorded (gate set / observe / done) · front (specify→tests) ·
|
|
1754
|
+
gate (build/verify). judgment = extracted markers, byte-verbatim. PURE."""
|
|
1755
|
+
tasks = state.get("tasks") or {}
|
|
1756
|
+
t = tasks.get(slug, {})
|
|
1757
|
+
phase = t.get("phase", "specify")
|
|
1758
|
+
gate = t.get("gate", "none")
|
|
1759
|
+
if gate != "none" or phase in ("observe", "done"):
|
|
1760
|
+
seam = "recorded"
|
|
1761
|
+
elif phase in _FRONT_PHASES:
|
|
1762
|
+
seam = "front"
|
|
1763
|
+
else:
|
|
1764
|
+
seam = "gate"
|
|
1765
|
+
raw = _raw_phase_bodies(root, slug)
|
|
1766
|
+
frozen = _contract_frozen(raw.get(3, ""))
|
|
1767
|
+
if seam == "gate": # the items closest to the gate lead: §6 first, then §1
|
|
1768
|
+
judgment = _decision_markers(raw.get(6, ""), 6) + _decision_markers(raw.get(1, ""), 1)
|
|
1769
|
+
elif seam == "front" and not frozen:
|
|
1770
|
+
judgment = _decision_markers(raw.get(1, ""), 1) + _decision_markers(raw.get(3, ""), 3)
|
|
1771
|
+
else:
|
|
1772
|
+
judgment = []
|
|
1773
|
+
|
|
1774
|
+
members = [x for x in tasks.values() if x.get("milestone") == mslug]
|
|
1775
|
+
done, total = sum(1 for x in members if _task_done(x)), len(members)
|
|
1776
|
+
facts = {"phase": phase, "gate": gate,
|
|
1777
|
+
"deps": [{"slug": d, "gate": tasks.get(d, {}).get("gate", "none")}
|
|
1778
|
+
for d in t.get("depends_on", [])],
|
|
1779
|
+
"tests": _tests_info(root, slug)[0]}
|
|
1780
|
+
|
|
1781
|
+
if seam == "gate":
|
|
1782
|
+
unlocks = f"gate PASS -> task done -> milestone {min(done + 1, total)}/{total}"
|
|
1783
|
+
decide = "add.py gate PASS | RISK-ACCEPTED | HARD-STOP"
|
|
1784
|
+
elif seam == "front" and not frozen:
|
|
1785
|
+
unlocks = "freeze §3 -> the auto run takes build -> verify (autonomy: auto by default)"
|
|
1786
|
+
decide = "approve -> freeze §3 (Status: FROZEN @ v1) -> auto run"
|
|
1787
|
+
elif seam == "front":
|
|
1788
|
+
unlocks = "none"
|
|
1789
|
+
decide = "no decision pending — frozen; the run owns it. next seam: verify gate"
|
|
1790
|
+
else:
|
|
1791
|
+
unlocks = "none"
|
|
1792
|
+
decide = f"no decision pending — recorded gate: {gate}"
|
|
1793
|
+
return {"seam": seam, "milestone": mslug, "task": slug, "phase": phase,
|
|
1794
|
+
"gate": gate, "judgment": judgment, "facts": facts,
|
|
1795
|
+
"unlocks": unlocks, "decide": decide}
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
def render_decide(root: Path, state: dict, mslug: str, slug: str, *,
|
|
1799
|
+
width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str:
|
|
1800
|
+
"""Text view of the decision-seam digest — decisive facts FIRST: NEEDS YOUR
|
|
1801
|
+
JUDGMENT (markers byte-verbatim, section-tagged) -> [front: §3 verbatim] ->
|
|
1802
|
+
ENGINE FACTS -> UNLOCKS -> DECIDE. PURE — no writes; plain text (color is a
|
|
1803
|
+
tty-only skin in cmd_report, like every report view)."""
|
|
1804
|
+
d = decide_data(root, state, mslug, slug)
|
|
1805
|
+
g = _ASCII if ascii else _UNICODE
|
|
1806
|
+
banner = g["h"] * width
|
|
1807
|
+
seam_label = {"gate": "VERIFY GATE", "front": "CONTRACT APPROVAL",
|
|
1808
|
+
"recorded": "RECORDED"}[d["seam"]]
|
|
1809
|
+
L = [banner, f" DECIDE · {mslug or '—'} · {slug} · seam: {seam_label}", banner]
|
|
1810
|
+
if d["decide"].startswith("no decision pending"):
|
|
1811
|
+
L.append(f" {d['decide']}")
|
|
1812
|
+
L.append(f" GATE {d['gate']}")
|
|
1813
|
+
L.append(banner)
|
|
1814
|
+
return "\n".join(L)
|
|
1815
|
+
L.append(f" NEEDS YOUR JUDGMENT ({len(d['judgment'])})")
|
|
1816
|
+
for item in d["judgment"]:
|
|
1817
|
+
L.append(f" [§{item['section']}]")
|
|
1818
|
+
L.extend(item["text"].split("\n")) # byte-verbatim — never wrapped/clipped
|
|
1819
|
+
if d["seam"] == "front":
|
|
1820
|
+
L.append("")
|
|
1821
|
+
L.append(" CONTRACT (§3 verbatim)")
|
|
1822
|
+
L.extend(_raw_phase_bodies(root, slug).get(3, "").split("\n"))
|
|
1823
|
+
L.append(" STATUS DRAFT")
|
|
1824
|
+
f = d["facts"]
|
|
1825
|
+
deps_txt = " ".join(f"{x['slug']}:{x['gate']}" for x in f["deps"]) or "none"
|
|
1826
|
+
L.append("")
|
|
1827
|
+
L.append(f" ENGINE FACTS phase {f['phase']} · gate {f['gate']} · "
|
|
1828
|
+
f"deps {deps_txt} · tests {f['tests']}")
|
|
1829
|
+
L.append(f" UNLOCKS {d['unlocks']}")
|
|
1830
|
+
L.append(f" DECIDE {d['decide']}")
|
|
1831
|
+
L.append(banner)
|
|
1832
|
+
return "\n".join(L)
|
|
1833
|
+
|
|
1834
|
+
|
|
1835
|
+
def _planned_unscaffolded(root: Path, mslug: str) -> list[str]:
|
|
1836
|
+
"""Slugs MILESTONE.md plans (rows `- [ ] <slug> …`) that have no TASK.md yet —
|
|
1837
|
+
the plan-vs-state diff. Only valid-slug first-tokens match (a template
|
|
1838
|
+
placeholder like <slug> never does); file order, deduped; fail-closed []."""
|
|
1839
|
+
md = root / "milestones" / mslug / "MILESTONE.md"
|
|
1840
|
+
try:
|
|
1841
|
+
text = md.read_text(encoding="utf-8")
|
|
1842
|
+
except OSError:
|
|
1843
|
+
return []
|
|
1844
|
+
out: list[str] = []
|
|
1845
|
+
for sec in re.split(r"^## ", text, flags=re.M)[1:]:
|
|
1846
|
+
if not sec.startswith("Tasks"): # only the Tasks list — never exit criteria
|
|
1847
|
+
continue
|
|
1848
|
+
for m in re.finditer(r"^- \[[ x~]\] ([A-Za-z0-9_-]+)\b", sec, re.M):
|
|
1849
|
+
slug = m.group(1)
|
|
1850
|
+
if slug not in out and not (root / "tasks" / slug / "TASK.md").is_file():
|
|
1851
|
+
out.append(slug)
|
|
1852
|
+
return out
|
|
1853
|
+
|
|
1854
|
+
|
|
1855
|
+
def _decide_next(state: dict, d: dict) -> str:
|
|
1856
|
+
"""The rollup's DECIDE NEXT line (frozen precedence): HARD-STOP -> fold+archive
|
|
1857
|
+
-> first seam-blocked task (ACTIVE task first, then state order) -> run-in-
|
|
1858
|
+
progress. v2: when d carries planned_unscaffolded, the line gains a
|
|
1859
|
+
plan-vs-state suffix — precedence itself stays state-only."""
|
|
1860
|
+
return _decide_next_base(state, d) + _planned_hint(d)
|
|
1861
|
+
|
|
1862
|
+
|
|
1863
|
+
def _planned_hint(d: dict) -> str:
|
|
1864
|
+
"""The plan-vs-state suffix ('' when nothing is missing). Text renders emit it
|
|
1865
|
+
as its OWN wrapped segment so the phrase never splits mid-token; the JSON
|
|
1866
|
+
'decide' string carries it inline via _decide_next."""
|
|
1867
|
+
planned = d.get("planned_unscaffolded") or []
|
|
1868
|
+
if not planned:
|
|
1869
|
+
return ""
|
|
1870
|
+
return f" — {len(planned)} planned not yet scaffolded: " + " · ".join(planned)
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
def _decide_next_base(state: dict, d: dict) -> str:
|
|
1874
|
+
ms = d["milestone"]["slug"]
|
|
1875
|
+
rows = d["tasks"]
|
|
1876
|
+
if not rows:
|
|
1877
|
+
return "none — no tasks yet"
|
|
1878
|
+
stopped = [r for r in rows if r["gate"] == "HARD-STOP"]
|
|
1879
|
+
if stopped:
|
|
1880
|
+
return f"resolve HARD-STOP on {stopped[0]['slug']}"
|
|
1881
|
+
s = d["summary"]
|
|
1882
|
+
if s["tasks_done"] == s["tasks_total"]:
|
|
1883
|
+
return f"fold learnings + archive-milestone {ms}"
|
|
1884
|
+
active = state.get("active_task")
|
|
1885
|
+
order = sorted(rows, key=lambda r: 0 if r["slug"] == active else 1) # stable
|
|
1886
|
+
for r in order:
|
|
1887
|
+
if r["done"]:
|
|
1888
|
+
continue
|
|
1889
|
+
if r["phase"] in _FRONT_PHASES:
|
|
1890
|
+
return (f"approve the contract of {r['slug']} — "
|
|
1891
|
+
f"add.py report {ms} {r['slug']} --decide")
|
|
1892
|
+
if r["phase"] == "verify" and r["gate"] == "none":
|
|
1893
|
+
return f"gate {r['slug']} — add.py report {ms} {r['slug']} --decide"
|
|
1894
|
+
r = next(x for x in order if not x["done"])
|
|
1895
|
+
return f"none — run in progress ({r['slug']} at {r['phase']})"
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
def render_decide_next(root: Path, state: dict, mslug: str, *,
|
|
1899
|
+
width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str:
|
|
1900
|
+
"""`report <ms> --decide`: ONLY the DECIDE NEXT block (no rollup table). PURE."""
|
|
1901
|
+
g = _ASCII if ascii else _UNICODE
|
|
1902
|
+
banner = g["h"] * width
|
|
1903
|
+
d = report_data(root, state, mslug)
|
|
1904
|
+
L = [banner, f" {mslug} · DECIDE NEXT", banner]
|
|
1905
|
+
L.extend(_wrap(_decide_next_base(state, d), width - 4, " "))
|
|
1906
|
+
if _planned_hint(d): # own segment so the phrase never splits mid-token
|
|
1907
|
+
L.extend(_wrap(_planned_hint(d).removeprefix(" — "), width - 4, " "))
|
|
1388
1908
|
L.append(banner)
|
|
1389
1909
|
return "\n".join(L)
|
|
1390
1910
|
|
|
@@ -1398,10 +1918,309 @@ def _write_retro(root: Path, state: dict, mslug: str) -> Path:
|
|
|
1398
1918
|
trust the locale default), never mutates state.json."""
|
|
1399
1919
|
content = render_report(root, state, mslug, width=_DEFAULT_WIDTH, ascii=False)
|
|
1400
1920
|
path = root / "milestones" / mslug / "RETRO.md"
|
|
1401
|
-
path
|
|
1921
|
+
_atomic_write(path, content) # honor the module's atomic-write contract (no half-write)
|
|
1402
1922
|
return path
|
|
1403
1923
|
|
|
1404
1924
|
|
|
1925
|
+
_COMPETENCY_ORDER = ("DDD", "SDD", "UDD", "TDD", "ADD")
|
|
1926
|
+
_DELTA_STATUSES = ("open", "folded", "rejected")
|
|
1927
|
+
|
|
1928
|
+
# Canonical delta grammar — the single compiled source for the enumerated
|
|
1929
|
+
# competency · status shape. Leading \s* is PERMISSIVE so _task_prose can feed
|
|
1930
|
+
# un-stripped lines directly; callers that pre-strip their input
|
|
1931
|
+
# (e.g. _collect_open_deltas, _lint_task_deltas) match the same way (\s*
|
|
1932
|
+
# matches zero). Anchored at line-start via re.match.
|
|
1933
|
+
_DELTA_RE = re.compile(
|
|
1934
|
+
r"\s*-\s*\[\s*(DDD|SDD|UDD|TDD|ADD)\s*·\s*(open|folded|rejected)\s*\]\s*(.+)$"
|
|
1935
|
+
)
|
|
1936
|
+
_EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
|
|
1937
|
+
|
|
1938
|
+
# Broad structural tag detector: finds ANY "- [tok · tok]" line (valid OR malformed).
|
|
1939
|
+
# A line with a `· ` bracket separator is a delta-attempt. Does NOT enumerate
|
|
1940
|
+
# competencies or statuses — a different abstraction from _DELTA_RE (no DRY violation).
|
|
1941
|
+
_TAG_BROAD_RE = re.compile(r"^\s*-\s*\[\s*([^\]·]+?)\s*·\s*([^\]·]+?)\s*\]\s*(.*)$")
|
|
1942
|
+
|
|
1943
|
+
|
|
1944
|
+
def _lint_task_deltas(root: Path, slug: str) -> tuple[bool, str] | None:
|
|
1945
|
+
"""Lint all open delta entries in a task's '### Competency deltas' block.
|
|
1946
|
+
|
|
1947
|
+
Returns:
|
|
1948
|
+
None — no delta-attempts found; no check emitted.
|
|
1949
|
+
(True, "") — all open entries pass.
|
|
1950
|
+
(False, "<code> -> <tag line>") — first failing entry with its failure code.
|
|
1951
|
+
|
|
1952
|
+
Contract rules (frozen §3, v1):
|
|
1953
|
+
- SKIP HTML-comment lines and blank lines (they are never tag lines).
|
|
1954
|
+
- Group lines into ENTRIES: a broad tag line starts an entry; following lines
|
|
1955
|
+
until next tag / blank / end-of-block are its continuation.
|
|
1956
|
+
- A line without a '· ' separator inside brackets (e.g. '- [x]') is NOT a tag.
|
|
1957
|
+
- For each entry, skip folded/rejected (open-only — history not retrofitted).
|
|
1958
|
+
- Validate the remaining (open) entries: COMP in _COMPETENCY_ORDER,
|
|
1959
|
+
status in _DELTA_STATUSES, and '(evidence:' present SOMEWHERE in the unit.
|
|
1960
|
+
- Fail-closed: an unparseable attempt FAILS (never silently passes).
|
|
1961
|
+
"""
|
|
1962
|
+
task_md = root / "tasks" / slug / "TASK.md"
|
|
1963
|
+
if not task_md.exists():
|
|
1964
|
+
return None
|
|
1965
|
+
try:
|
|
1966
|
+
text = task_md.read_text(encoding="utf-8")
|
|
1967
|
+
except OSError:
|
|
1968
|
+
return None
|
|
1969
|
+
|
|
1970
|
+
# Locate the "### Competency deltas" block.
|
|
1971
|
+
block_match = re.search(r"###\s*Competency deltas\s*\n(.*?)(?=\n##|\Z)", text, re.S)
|
|
1972
|
+
if not block_match:
|
|
1973
|
+
return None
|
|
1974
|
+
|
|
1975
|
+
block = block_match.group(1)
|
|
1976
|
+
raw_lines = block.splitlines()
|
|
1977
|
+
|
|
1978
|
+
# First pass: collect entries (tag line + continuations).
|
|
1979
|
+
# HTML-comment lines are skipped entirely (invisible to the guard).
|
|
1980
|
+
# Blank lines terminate the current entry, but are not tags themselves.
|
|
1981
|
+
entries: list[tuple[str, list[str]]] = [] # (tag_line, [tag_line, *continuations])
|
|
1982
|
+
current: list[str] | None = None
|
|
1983
|
+
for raw_line in raw_lines:
|
|
1984
|
+
stripped = raw_line.strip()
|
|
1985
|
+
# Skip HTML-comment lines.
|
|
1986
|
+
if stripped.startswith("<!--"):
|
|
1987
|
+
continue
|
|
1988
|
+
# Blank line terminates the current entry.
|
|
1989
|
+
if not stripped:
|
|
1990
|
+
current = None
|
|
1991
|
+
continue
|
|
1992
|
+
# Broad tag detection: any "- [tok · tok]" line starts a new entry.
|
|
1993
|
+
m = _TAG_BROAD_RE.match(raw_line)
|
|
1994
|
+
if m:
|
|
1995
|
+
current = [stripped]
|
|
1996
|
+
entries.append((stripped, current))
|
|
1997
|
+
elif current is not None:
|
|
1998
|
+
# Continuation line of the current entry.
|
|
1999
|
+
current.append(stripped)
|
|
2000
|
+
# else: non-blank, non-comment, non-tag line with no prior entry — ignore.
|
|
2001
|
+
|
|
2002
|
+
if not entries:
|
|
2003
|
+
return None # no delta-attempts → no check emitted
|
|
2004
|
+
|
|
2005
|
+
# Second pass: validate each entry.
|
|
2006
|
+
for tag_line, unit_lines in entries:
|
|
2007
|
+
m = _TAG_BROAD_RE.match(tag_line)
|
|
2008
|
+
if not m:
|
|
2009
|
+
# Should not happen, but fail-closed.
|
|
2010
|
+
return False, f"malformed_delta -> {tag_line}"
|
|
2011
|
+
raw_comp = m.group(1).strip()
|
|
2012
|
+
raw_status = m.group(2).strip()
|
|
2013
|
+
|
|
2014
|
+
# Step 1: skip historical entries (folded/rejected) — open-only enforcement.
|
|
2015
|
+
# MUST happen before competency/status validation per §3: "history not retrofitted".
|
|
2016
|
+
if raw_status in ("folded", "rejected"):
|
|
2017
|
+
continue
|
|
2018
|
+
|
|
2019
|
+
# Step 2: use _DELTA_RE (the canonical grammar, single source of truth) to test
|
|
2020
|
+
# whether the tag line is a fully-valid delta shape. If it matches, check evidence
|
|
2021
|
+
# only. If it fails, classify the failure via the raw tokens (never a parallel grammar).
|
|
2022
|
+
unit_text = " ".join(unit_lines)
|
|
2023
|
+
if _DELTA_RE.match(tag_line):
|
|
2024
|
+
# Valid comp + status + non-empty tail — check evidence in the joined unit.
|
|
2025
|
+
if "(evidence:" not in unit_text:
|
|
2026
|
+
return False, f"no_evidence -> {tag_line}"
|
|
2027
|
+
else:
|
|
2028
|
+
# Classify why _DELTA_RE rejected it (open entries only — folded/rejected skipped).
|
|
2029
|
+
if raw_comp not in _COMPETENCY_ORDER:
|
|
2030
|
+
return False, f"unknown_competency -> {tag_line}"
|
|
2031
|
+
if raw_status not in _DELTA_STATUSES:
|
|
2032
|
+
return False, f"unknown_status -> {tag_line}"
|
|
2033
|
+
# Comp and status are valid but the line still failed _DELTA_RE (e.g. empty tail).
|
|
2034
|
+
return False, f"malformed_delta -> {tag_line}"
|
|
2035
|
+
|
|
2036
|
+
return True, ""
|
|
2037
|
+
|
|
2038
|
+
|
|
2039
|
+
def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
|
|
2040
|
+
"""Scan every .add/tasks/*/TASK.md for open competency deltas.
|
|
2041
|
+
|
|
2042
|
+
Returns a dict keyed by competency in canonical order; each value is a list
|
|
2043
|
+
of {task, text, evidence} dicts. READ-ONLY — never mutates any file."""
|
|
2044
|
+
by_comp: dict[str, list[dict]] = {c: [] for c in _COMPETENCY_ORDER}
|
|
2045
|
+
tasks_dir = root / "tasks"
|
|
2046
|
+
if not tasks_dir.is_dir():
|
|
2047
|
+
return by_comp
|
|
2048
|
+
for task_md in sorted(tasks_dir.glob("*/TASK.md")):
|
|
2049
|
+
slug = task_md.parent.name
|
|
2050
|
+
try:
|
|
2051
|
+
text = task_md.read_text(encoding="utf-8")
|
|
2052
|
+
except OSError:
|
|
2053
|
+
continue
|
|
2054
|
+
# Locate the "### Competency deltas" block (may appear anywhere in the file).
|
|
2055
|
+
block_match = re.search(r"###\s*Competency deltas\s*\n(.*?)(?=\n##|\Z)", text, re.S)
|
|
2056
|
+
if not block_match:
|
|
2057
|
+
continue
|
|
2058
|
+
block = block_match.group(1)
|
|
2059
|
+
# Group lines into entries (tag line + continuations) so a multi-line delta —
|
|
2060
|
+
# whose learning wraps and whose (evidence: …) may land on a later line — is read
|
|
2061
|
+
# in FULL, not truncated to its first line. A tag line starts an entry; a line
|
|
2062
|
+
# that does not begin a new "- " list item continues it; a blank/comment or a
|
|
2063
|
+
# new "- " item ends it (a trailing malformed item can't pollute a delta's text).
|
|
2064
|
+
entries: list[list[str]] = []
|
|
2065
|
+
current: list[str] | None = None
|
|
2066
|
+
for line in block.splitlines():
|
|
2067
|
+
stripped = line.strip()
|
|
2068
|
+
if not stripped or stripped.startswith("<!--"):
|
|
2069
|
+
current = None
|
|
2070
|
+
continue
|
|
2071
|
+
if _DELTA_RE.match(stripped):
|
|
2072
|
+
current = [stripped]
|
|
2073
|
+
entries.append(current)
|
|
2074
|
+
elif current is not None and not stripped.startswith("-"):
|
|
2075
|
+
current.append(stripped) # genuine wrap of the current learning
|
|
2076
|
+
else:
|
|
2077
|
+
current = None # a new / malformed list item ends the run
|
|
2078
|
+
for unit in entries:
|
|
2079
|
+
m = _DELTA_RE.match(unit[0])
|
|
2080
|
+
comp, status = m.group(1), m.group(2)
|
|
2081
|
+
if status != "open":
|
|
2082
|
+
continue
|
|
2083
|
+
# Join the tag line's tail with any continuation lines, then split evidence.
|
|
2084
|
+
tail = " ".join([m.group(3).strip(), *unit[1:]]).strip()
|
|
2085
|
+
em = _EVIDENCE_RE.match(tail)
|
|
2086
|
+
if em:
|
|
2087
|
+
delta_text, evidence = em.group(1).strip(), em.group(2).strip()
|
|
2088
|
+
else:
|
|
2089
|
+
delta_text, evidence = tail, ""
|
|
2090
|
+
by_comp[comp].append({"task": slug, "text": delta_text, "evidence": evidence})
|
|
2091
|
+
return by_comp
|
|
2092
|
+
|
|
2093
|
+
|
|
2094
|
+
_AUDIT_STAMP_RE = re.compile(r"Status:\s*FROZEN @ v\d+\s*[—-]+\s*approved by\s+\S+")
|
|
2095
|
+
_AUDIT_OUTCOME_RE = re.compile(r"^Outcome:\s*(PASS|RISK-ACCEPTED|HARD-STOP)\b", re.M)
|
|
2096
|
+
_AUDIT_SECURITY_RE = re.compile(
|
|
2097
|
+
r"^\s*- \[[ x~]\] no exposed secrets.*(?:\n(?!\s*- \[|#).*)*", re.M)
|
|
2098
|
+
_AUDIT_REVIEWED_RE = re.compile(r"^Reviewed by:(.*)$", re.M)
|
|
2099
|
+
|
|
2100
|
+
|
|
2101
|
+
def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
|
|
2102
|
+
"""The gate-audit core: verify that human seams left WELL-FORMED records.
|
|
2103
|
+
Judgment-free — checks record SHAPE (a named human at the freeze, exactly one
|
|
2104
|
+
gate outcome, prose ≡ state, a marked security note never auto-reviewed),
|
|
2105
|
+
never re-decides an outcome. Scope: active tasks done/observe or gated; open
|
|
2106
|
+
fronts skipped. PURE — reads only. Honest limit: shape, not engagement — a
|
|
2107
|
+
forged name passes; CI wiring makes forgery explicit and attributable."""
|
|
2108
|
+
tasks = state.get("tasks") or {}
|
|
2109
|
+
checked, findings = 0, []
|
|
2110
|
+
|
|
2111
|
+
def f(slug: str, code: str, detail: str) -> None:
|
|
2112
|
+
findings.append({"task": slug, "code": code, "detail": detail})
|
|
2113
|
+
|
|
2114
|
+
for slug in sorted(tasks):
|
|
2115
|
+
t = tasks[slug]
|
|
2116
|
+
phase, gate = t.get("phase", "specify"), t.get("gate", "none")
|
|
2117
|
+
if phase not in ("done", "observe") and gate == "none":
|
|
2118
|
+
continue # the front is still open — nothing recorded to audit
|
|
2119
|
+
checked += 1
|
|
2120
|
+
raw = _raw_phase_bodies(root, slug)
|
|
2121
|
+
s3, s6 = raw.get(3, ""), raw.get(6, "")
|
|
2122
|
+
if not _AUDIT_STAMP_RE.search(s3):
|
|
2123
|
+
f(slug, "unstamped_freeze",
|
|
2124
|
+
"§3 lacks 'Status: FROZEN @ vN — approved by <name>'")
|
|
2125
|
+
outcomes = _AUDIT_OUTCOME_RE.findall(s6)
|
|
2126
|
+
if len(outcomes) != 1:
|
|
2127
|
+
f(slug, "malformed_gate_record",
|
|
2128
|
+
f"{len(outcomes)} Outcome lines in §6 (need exactly 1)")
|
|
2129
|
+
elif gate != "none" and outcomes[0] != gate:
|
|
2130
|
+
f(slug, "gate_record_mismatch",
|
|
2131
|
+
f"§6 records {outcomes[0]} but state.json records {gate}")
|
|
2132
|
+
sec = _AUDIT_SECURITY_RE.search(s6)
|
|
2133
|
+
marked = bool(sec and ("NOTE" in sec.group(0) or "⚠" in sec.group(0)))
|
|
2134
|
+
rev = _AUDIT_REVIEWED_RE.search(s6)
|
|
2135
|
+
if marked and rev and "auto-gate" in rev.group(1):
|
|
2136
|
+
f(slug, "unescalated_security_note",
|
|
2137
|
+
"security-line note (NOTE/⚠) with an auto-gate reviewer")
|
|
2138
|
+
# F7 unguarded_high_risk_auto (task high-risk-signal, v14): a declared
|
|
2139
|
+
# high-risk record must show a guarded dial AND a human at the gate —
|
|
2140
|
+
# catches post-gate header tampering and auto-resolved high-risk gates.
|
|
2141
|
+
hdr = _task_header(root, slug)
|
|
2142
|
+
if _RISK_HIGH_RE.search(hdr):
|
|
2143
|
+
if not _AUTONOMY_CONSERVATIVE_RE.search(hdr):
|
|
2144
|
+
f(slug, "unguarded_high_risk_auto",
|
|
2145
|
+
"risk: high declared but autonomy is not 'conservative'")
|
|
2146
|
+
elif rev and "auto-gate" in rev.group(1):
|
|
2147
|
+
f(slug, "unguarded_high_risk_auto",
|
|
2148
|
+
"risk: high task whose GATE RECORD reviewer is the auto-gate")
|
|
2149
|
+
if outcomes == ["RISK-ACCEPTED"]:
|
|
2150
|
+
if marked:
|
|
2151
|
+
f(slug, "risk_accepted_security",
|
|
2152
|
+
"a waiver on a marked security item is never allowed")
|
|
2153
|
+
if not all(re.search(rf"{k}:\s*(?!<)\S", s6)
|
|
2154
|
+
for k in ("owner", "ticket", "expires")):
|
|
2155
|
+
f(slug, "waiver_incomplete",
|
|
2156
|
+
"RISK-ACCEPTED needs owner · ticket · expires")
|
|
2157
|
+
return checked, findings
|
|
2158
|
+
|
|
2159
|
+
|
|
2160
|
+
def cmd_audit(args: argparse.Namespace) -> None:
|
|
2161
|
+
"""Read-only: audit recorded human seams for well-formedness. Exit 0 clean,
|
|
2162
|
+
exit 1 with findings — the enforcement seam CI consumes (audit-ci). Writes
|
|
2163
|
+
NOTHING; every other command is byte-identical."""
|
|
2164
|
+
root = _require_root()
|
|
2165
|
+
checked, findings = _audit_findings(root, load_state(root))
|
|
2166
|
+
if getattr(args, "json", False):
|
|
2167
|
+
print(json.dumps({"checked": checked, "findings": findings},
|
|
2168
|
+
ensure_ascii=False, indent=2))
|
|
2169
|
+
else:
|
|
2170
|
+
if findings:
|
|
2171
|
+
for x in findings:
|
|
2172
|
+
print(f"audit: {x['code']} {x['task']} — {x['detail']}")
|
|
2173
|
+
else:
|
|
2174
|
+
print(f"audit: clean ({checked} tasks checked)")
|
|
2175
|
+
if findings:
|
|
2176
|
+
sys.exit(1)
|
|
2177
|
+
|
|
2178
|
+
|
|
2179
|
+
def cmd_deltas(args: argparse.Namespace) -> None:
|
|
2180
|
+
"""Read-only: report all open competency deltas grouped by competency.
|
|
2181
|
+
|
|
2182
|
+
Scans every .add/tasks/*/TASK.md '### Competency deltas' block for lines
|
|
2183
|
+
matching the delta grammar; shows only `open` entries in canonical competency
|
|
2184
|
+
order (DDD·SDD·UDD·TDD·ADD). --json emits one JSON object. Exit 0 ALWAYS.
|
|
2185
|
+
Writes NOTHING."""
|
|
2186
|
+
root = _require_root()
|
|
2187
|
+
by_comp = _collect_open_deltas(root)
|
|
2188
|
+
total = sum(len(v) for v in by_comp.values())
|
|
2189
|
+
|
|
2190
|
+
if getattr(args, "json", False):
|
|
2191
|
+
payload: dict = {
|
|
2192
|
+
"total": total,
|
|
2193
|
+
"by_competency": {c: v for c, v in by_comp.items() if v},
|
|
2194
|
+
}
|
|
2195
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
2196
|
+
return
|
|
2197
|
+
|
|
2198
|
+
if total == 0:
|
|
2199
|
+
print("no open deltas.")
|
|
2200
|
+
return
|
|
2201
|
+
|
|
2202
|
+
print(f"open competency deltas ({total} total):")
|
|
2203
|
+
for comp in _COMPETENCY_ORDER:
|
|
2204
|
+
entries = by_comp[comp]
|
|
2205
|
+
if not entries:
|
|
2206
|
+
continue
|
|
2207
|
+
print(f" {comp} ({len(entries)}):")
|
|
2208
|
+
for e in entries:
|
|
2209
|
+
print(f" - {e['text']} [{e['task']}]")
|
|
2210
|
+
|
|
2211
|
+
|
|
2212
|
+
def cmd_project(args: argparse.Namespace) -> None:
|
|
2213
|
+
"""Read-only: print .add/PROJECT.md (the read-first foundation) in one command.
|
|
2214
|
+
|
|
2215
|
+
Fail-closed: a missing foundation dies with a clear stderr message + a non-zero
|
|
2216
|
+
exit, never a silent empty print. Writes NOTHING."""
|
|
2217
|
+
root = _require_root()
|
|
2218
|
+
foundation = root / "PROJECT.md"
|
|
2219
|
+
if not foundation.exists():
|
|
2220
|
+
_die("missing foundation: .add/PROJECT.md (run `add.py init` to scaffold it)")
|
|
2221
|
+
print(foundation.read_text(encoding="utf-8"), end="")
|
|
2222
|
+
|
|
2223
|
+
|
|
1405
2224
|
def cmd_report(args: argparse.Namespace) -> None:
|
|
1406
2225
|
"""Read-only: capture a milestone's raw data (--json) or render the text
|
|
1407
2226
|
dashboard (color on a tty, ASCII when the terminal can't do Unicode, --plain
|
|
@@ -1433,6 +2252,12 @@ def cmd_report(args: argparse.Namespace) -> None:
|
|
|
1433
2252
|
_die(f"unknown_milestone: task '{name}' is not attached to a milestone")
|
|
1434
2253
|
else:
|
|
1435
2254
|
_die(f"unknown_milestone: '{name}' is not a milestone")
|
|
2255
|
+
elif getattr(args, "decide", False): # bare --decide -> the ACTIVE TASK
|
|
2256
|
+
slug = state.get("active_task")
|
|
2257
|
+
if not slug or slug not in tasks:
|
|
2258
|
+
_die("no_active_task — name one: add.py report <milestone> <task> --decide")
|
|
2259
|
+
drill_task = slug
|
|
2260
|
+
mslug = tasks[slug].get("milestone") or ""
|
|
1436
2261
|
else: # no positional -> active milestone
|
|
1437
2262
|
mslug = state.get("active_milestone")
|
|
1438
2263
|
if not mslug:
|
|
@@ -1441,6 +2266,32 @@ def cmd_report(args: argparse.Namespace) -> None:
|
|
|
1441
2266
|
if mslug not in milestones:
|
|
1442
2267
|
_die(f"unknown_milestone: '{mslug}' is not a milestone")
|
|
1443
2268
|
|
|
2269
|
+
if getattr(args, "decide", False):
|
|
2270
|
+
# Decision-seam digest (v13): task -> seam digest; milestone -> DECIDE NEXT
|
|
2271
|
+
# block only. PURE, like every report path.
|
|
2272
|
+
if getattr(args, "json", False):
|
|
2273
|
+
if drill_task:
|
|
2274
|
+
payload = decide_data(root, state, mslug, drill_task)
|
|
2275
|
+
else: # milestone altitude: same frozen key set, task null
|
|
2276
|
+
d = report_data(root, state, mslug)
|
|
2277
|
+
payload = {"seam": "milestone", "milestone": mslug, "task": None,
|
|
2278
|
+
"phase": "", "gate": "none", "judgment": [],
|
|
2279
|
+
"facts": {"phase": "", "gate": "none", "deps": [], "tests": 0},
|
|
2280
|
+
"unlocks": "", "decide": _decide_next(state, d)}
|
|
2281
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
2282
|
+
return
|
|
2283
|
+
plain = getattr(args, "plain", False)
|
|
2284
|
+
interactive = sys.stdout.isatty() and not plain
|
|
2285
|
+
width = _term_width() if interactive else _DEFAULT_WIDTH
|
|
2286
|
+
use_ascii = plain or _use_ascii()
|
|
2287
|
+
out = (render_decide(root, state, mslug, drill_task, width=width, ascii=use_ascii)
|
|
2288
|
+
if drill_task else
|
|
2289
|
+
render_decide_next(root, state, mslug, width=width, ascii=use_ascii))
|
|
2290
|
+
if not plain and _color_enabled():
|
|
2291
|
+
out = _colorize(out)
|
|
2292
|
+
print(out)
|
|
2293
|
+
return
|
|
2294
|
+
|
|
1444
2295
|
if getattr(args, "json", False):
|
|
1445
2296
|
# POLYMORPHIC by path: drill -> task_phases list; rollup -> report_data dict.
|
|
1446
2297
|
payload = task_phases(root, drill_task) if drill_task \
|
|
@@ -1468,8 +2319,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1468
2319
|
pi.add_argument("--name", default=None, help="project name (default: dir name)")
|
|
1469
2320
|
pi.add_argument("--stage", default="prototype", choices=STAGES)
|
|
1470
2321
|
pi.add_argument("--force", action="store_true", help="reset state.json if present")
|
|
2322
|
+
pi.add_argument("--await-lock", dest="await_lock", action="store_true",
|
|
2323
|
+
help="seed an unlocked setup; gates new-task/advance/gate until `add.py lock`")
|
|
1471
2324
|
pi.set_defaults(func=cmd_init)
|
|
1472
2325
|
|
|
2326
|
+
pl = sub.add_parser("lock",
|
|
2327
|
+
help="freeze the autonomous setup (the human lock-down) and open the build")
|
|
2328
|
+
pl.add_argument("--by", default=None, help="who is locking (default: current OS user)")
|
|
2329
|
+
pl.add_argument("--layers", default=None,
|
|
2330
|
+
help="comma-separated lock layers (default: foundation,scope,contract)")
|
|
2331
|
+
pl.add_argument("--force", action="store_true", help="re-lock an already-locked project")
|
|
2332
|
+
pl.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
|
|
2333
|
+
pl.set_defaults(func=cmd_lock)
|
|
2334
|
+
|
|
1473
2335
|
pn = sub.add_parser("new-task", help="scaffold a new task (TASK.md + tests/ + src/)")
|
|
1474
2336
|
pn.add_argument("slug")
|
|
1475
2337
|
pn.add_argument("--title", default=None)
|
|
@@ -1500,6 +2362,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1500
2362
|
psm.add_argument("milestone", help="milestone slug, or 'none' to detach")
|
|
1501
2363
|
psm.set_defaults(func=cmd_set_milestone)
|
|
1502
2364
|
|
|
2365
|
+
pu = sub.add_parser("use", help="set the active task to an existing one (switch focus)")
|
|
2366
|
+
pu.add_argument("slug")
|
|
2367
|
+
pu.set_defaults(func=cmd_use)
|
|
2368
|
+
|
|
1503
2369
|
pam = sub.add_parser("archive-milestone",
|
|
1504
2370
|
help="collapse a done milestone out of active state (files stay on disk)")
|
|
1505
2371
|
pam.add_argument("slug")
|
|
@@ -1557,8 +2423,26 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1557
2423
|
"drill -> task_phases list of 7 phase dicts)")
|
|
1558
2424
|
prp.add_argument("--plain", action="store_true",
|
|
1559
2425
|
help="ASCII, no color, fixed width (pipe / CI / screen-reader safe)")
|
|
2426
|
+
prp.add_argument("--decide", action="store_true",
|
|
2427
|
+
help="decision-seam digest: what needs the human's judgment NOW "
|
|
2428
|
+
"(task -> seam digest; milestone -> DECIDE NEXT only; "
|
|
2429
|
+
"bare -> the active task)")
|
|
1560
2430
|
prp.set_defaults(func=cmd_report)
|
|
1561
2431
|
|
|
2432
|
+
pdt = sub.add_parser("deltas",
|
|
2433
|
+
help="read-only report: open competency deltas grouped by competency")
|
|
2434
|
+
pdt.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
2435
|
+
pdt.set_defaults(func=cmd_deltas)
|
|
2436
|
+
|
|
2437
|
+
pau = sub.add_parser("audit",
|
|
2438
|
+
help="read-only: verify human seams left well-formed records "
|
|
2439
|
+
"(exit 1 on findings — the CI enforcement seam)")
|
|
2440
|
+
pau.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
2441
|
+
pau.set_defaults(func=cmd_audit)
|
|
2442
|
+
|
|
2443
|
+
ppj = sub.add_parser("project", help="print .add/PROJECT.md (the read-first foundation)")
|
|
2444
|
+
ppj.set_defaults(func=cmd_project)
|
|
2445
|
+
|
|
1562
2446
|
return p
|
|
1563
2447
|
|
|
1564
2448
|
|