@pilotspace/add 1.2.0 → 1.4.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 +122 -0
- package/GETTING-STARTED.md +26 -0
- package/README.md +16 -3
- package/bin/cli.js +85 -8
- package/docs/02-the-flow.md +4 -1
- package/docs/03-step-1-specify.md +2 -0
- package/docs/06-step-4-tests.md +8 -0
- package/docs/07-step-5-build.md +2 -0
- package/docs/08-step-6-verify.md +11 -0
- package/docs/09-the-loop.md +3 -1
- package/docs/10-setup-and-stages.md +1 -1
- package/docs/11-governance.md +4 -0
- package/docs/appendix-c-glossary.md +16 -1
- package/docs/appendix-e-checklists.md +14 -2
- package/package.json +1 -1
- package/skill/add/SKILL.md +18 -5
- package/skill/add/advisor.md +75 -0
- package/skill/add/compact-foundation.md +53 -0
- package/skill/add/confidence.md +48 -0
- package/skill/add/fold.md +4 -4
- package/skill/add/phases/0-ground.md +68 -0
- package/skill/add/phases/0-setup.md +52 -3
- package/skill/add/phases/1-specify.md +7 -0
- package/skill/add/phases/2-scenarios.md +2 -0
- package/skill/add/phases/3-contract.md +5 -1
- package/skill/add/phases/4-tests.md +2 -0
- package/skill/add/phases/5-build.md +24 -0
- package/skill/add/phases/6-verify.md +18 -0
- package/skill/add/phases/7-observe.md +7 -0
- package/skill/add/run.md +48 -5
- package/skill/add/soul.md +77 -0
- package/skill/add/streams.md +65 -9
- package/tooling/add.py +1715 -64
- package/tooling/templates/DESIGN.md.tmpl +66 -0
- package/tooling/templates/GLOSSARY.md.tmpl +11 -1
- package/tooling/templates/PROJECT.md.tmpl +3 -1
- package/tooling/templates/SOUL.md.tmpl +40 -0
- package/tooling/templates/TASK.md.tmpl +23 -4
- package/tooling/templates/catalog.sample.json +38 -0
- package/tooling/templates/prototype.sample.json +48 -0
- package/tooling/templates/tokens.sample.json +55 -0
- package/tooling/templates/udd-catalog.md +122 -0
- package/tooling/templates/udd-tokens.md +79 -0
package/tooling/add.py
CHANGED
|
@@ -13,12 +13,14 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import argparse
|
|
15
15
|
import getpass
|
|
16
|
+
import hashlib
|
|
16
17
|
import json
|
|
17
18
|
import os
|
|
18
19
|
import re
|
|
19
20
|
import sys
|
|
20
21
|
import tempfile
|
|
21
|
-
|
|
22
|
+
import urllib.request
|
|
23
|
+
from datetime import date, datetime, timedelta, timezone
|
|
22
24
|
from pathlib import Path
|
|
23
25
|
|
|
24
26
|
# --- constants ---------------------------------------------------------------
|
|
@@ -34,8 +36,14 @@ STAGES = ("prototype", "poc", "mvp", "production")
|
|
|
34
36
|
# v22 stage-graduation: the read-only cue `status` shows when the MVP is covered.
|
|
35
37
|
# Worded as the ACTION (never a file) so it stands before graduate.md exists.
|
|
36
38
|
GRADUATION_CUE = "MVP covered → propose graduation"
|
|
37
|
-
PHASES = ("specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done")
|
|
39
|
+
PHASES = ("ground", "specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done")
|
|
38
40
|
GATES = ("none", "PASS", "RISK-ACCEPTED", "HARD-STOP")
|
|
41
|
+
# heal-then-escalate (verify-integrity): the bounded self-heal loop cap. A CONFIRMED cheat
|
|
42
|
+
# (mechanical tripwire divergence, or an agent-reported semantic refute-read finding) returns
|
|
43
|
+
# the task to BUILD for an honest redo; after HEAL_CAP such attempts the next confirmed cheat
|
|
44
|
+
# forces a HARD-STOP escalation to the human. MONOTONIC — attempts never auto-resets (a gamed
|
|
45
|
+
# green is never auto-passed; the loop is never unbounded).
|
|
46
|
+
HEAL_CAP = 3
|
|
39
47
|
|
|
40
48
|
|
|
41
49
|
def _phase_index(name: str) -> int:
|
|
@@ -45,6 +53,8 @@ def _phase_index(name: str) -> int:
|
|
|
45
53
|
# `add.py guide` copy: per-phase (concrete next action, book chapter to read).
|
|
46
54
|
# Keep the action wording aligned with each phase's EXIT line in the TASK template.
|
|
47
55
|
PHASE_GUIDE = {
|
|
56
|
+
"ground": ("gather the real codebase the task touches — files, symbols, signatures, conventions, and the anchor points the contract will cite; defer to PROJECT.md/CONVENTIONS.md and gather only the task delta",
|
|
57
|
+
"02-the-flow.md"),
|
|
48
58
|
"specify": ("state every rule — Must / Reject (+ named code) / After; rank assumptions lowest-confidence first and flag the biggest risk",
|
|
49
59
|
"03-step-1-specify.md"),
|
|
50
60
|
"scenarios": ("write one Given/When/Then per Must AND per Reject; every result observable",
|
|
@@ -67,10 +77,27 @@ PHASE_GUIDE = {
|
|
|
67
77
|
# follows the book's who-does-what table (Verify is "human only"); `tests`/`build`/`observe`
|
|
68
78
|
# are AI-led. A phase missing here is `unmapped_phase` (fail closed) — never defaulted.
|
|
69
79
|
PHASE_OWNER = {
|
|
80
|
+
"ground": "ai",
|
|
70
81
|
"specify": "human", "scenarios": "human", "contract": "seam",
|
|
71
82
|
"tests": "ai", "build": "ai", "verify": "human", "observe": "ai", "done": "human",
|
|
72
83
|
}
|
|
73
|
-
SETUP_FILES = ("PROJECT.md", "CONVENTIONS.md", "GLOSSARY.md", "MODEL_REGISTRY.md", "dependencies.allowlist")
|
|
84
|
+
SETUP_FILES = ("PROJECT.md", "CONVENTIONS.md", "GLOSSARY.md", "MODEL_REGISTRY.md", "dependencies.allowlist", "DESIGN.md", "SOUL.md")
|
|
85
|
+
|
|
86
|
+
# Scaffolded into .add/.gitignore at init so the engine's transient LOCAL artifacts
|
|
87
|
+
# never reach git. Bare-filename patterns match at any depth under .add/ (tasks/,
|
|
88
|
+
# milestones/, archive/). These are working state, not records: scope-snapshot.json
|
|
89
|
+
# is the tests->build touch baseline the verify scope-gate reads from disk (the
|
|
90
|
+
# durable scope declaration is the state.json anchor); pre-archive-state.bak.json is
|
|
91
|
+
# archive-milestone's pre-delete recovery net — needed on disk, never in history;
|
|
92
|
+
# .update-cache.json is the update-nudge's once-a-day registry throttle. All stay on
|
|
93
|
+
# disk; git-ignoring them is hygiene, never deletion.
|
|
94
|
+
_GITIGNORE_BODY = """\
|
|
95
|
+
# ADD engine transient artifacts — local working state, never committed.
|
|
96
|
+
# (Scaffolded by `add.py init`; edit freely — init never clobbers an existing copy.)
|
|
97
|
+
scope-snapshot.json
|
|
98
|
+
pre-archive-state.bak.json
|
|
99
|
+
.update-cache.json
|
|
100
|
+
"""
|
|
74
101
|
|
|
75
102
|
# Guideline-injection targets + version-stable markers. NEVER change these marker
|
|
76
103
|
# strings: a re-run finds the old block by exact match, so changing them would
|
|
@@ -84,7 +111,13 @@ _GUIDE_END = "<!-- ADD:END -->"
|
|
|
84
111
|
_FALLBACK_TASK = """# TASK: {title}
|
|
85
112
|
|
|
86
113
|
slug: {slug} · created: {date} · stage: {stage}
|
|
87
|
-
|
|
114
|
+
autonomy: auto
|
|
115
|
+
phase: ground
|
|
116
|
+
|
|
117
|
+
## 0 · GROUND
|
|
118
|
+
Touches (files · symbols · signatures):
|
|
119
|
+
Honors (patterns / conventions):
|
|
120
|
+
Anchors the contract cites:
|
|
88
121
|
|
|
89
122
|
## 1 · SPECIFY
|
|
90
123
|
Feature:
|
|
@@ -354,6 +387,13 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
354
387
|
_die(f"already initialised at {root} (use --force to reset state)")
|
|
355
388
|
|
|
356
389
|
(root / "tasks").mkdir(parents=True, exist_ok=True)
|
|
390
|
+
# Keep the engine's transient local artifacts out of git. Never-clobber: a
|
|
391
|
+
# human may have customised .add/.gitignore, so an existing one is left as-is
|
|
392
|
+
# (mirrors the SETUP_FILES skip-not-clobber idiom). Writes ONLY this file — no
|
|
393
|
+
# scope-snapshot.json or .bak is created, deleted, or modified.
|
|
394
|
+
gitignore = root / ".gitignore"
|
|
395
|
+
if not gitignore.exists():
|
|
396
|
+
_atomic_write(gitignore, _GITIGNORE_BODY)
|
|
357
397
|
today = date.today().isoformat()
|
|
358
398
|
proj_name = args.name or base.name
|
|
359
399
|
|
|
@@ -431,12 +471,20 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
431
471
|
(tdir / "tests").mkdir(parents=True, exist_ok=True)
|
|
432
472
|
(tdir / "src").mkdir(parents=True, exist_ok=True)
|
|
433
473
|
title = args.title or slug.replace("-", " ").replace("_", " ").title()
|
|
474
|
+
# inherit the project's DECLARED autonomy default (task init-auto-default) — fail-SAFE:
|
|
475
|
+
# absent -> auto, garbled -> conservative; the posture is project-scoped, not hardcoded.
|
|
476
|
+
autonomy = _project_autonomy(root)
|
|
434
477
|
_atomic_write(task_md, _render_template(
|
|
435
|
-
"TASK.md", title=title, slug=slug, date=date.today().isoformat(),
|
|
478
|
+
"TASK.md", title=title, slug=slug, date=date.today().isoformat(),
|
|
479
|
+
stage=state["stage"], autonomy=autonomy))
|
|
480
|
+
if _project_autonomy_token(root) == "?":
|
|
481
|
+
print("warning: garbled_project_autonomy — PROJECT.md declares an unrecognized "
|
|
482
|
+
f"autonomy token; new task seeded fail-safe '{autonomy}' "
|
|
483
|
+
"(fix it with `add.py autonomy set <level> --project`)", file=sys.stderr)
|
|
436
484
|
|
|
437
485
|
state["tasks"][slug] = {
|
|
438
486
|
"title": title,
|
|
439
|
-
"phase": "
|
|
487
|
+
"phase": "ground",
|
|
440
488
|
"gate": "none",
|
|
441
489
|
"milestone": milestone,
|
|
442
490
|
"depends_on": depends_on,
|
|
@@ -454,7 +502,8 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
454
502
|
# intake -> milestone flow. Speaks of STRUCTURE (not attached), never the act.
|
|
455
503
|
print(f"note: '{slug}' is not attached to a milestone — size it via /add (intake), "
|
|
456
504
|
"or pass --milestone <id>")
|
|
457
|
-
print("active task set. phase:
|
|
505
|
+
print("active task set. phase: ground. Gather the real codebase (section 0 GROUND).")
|
|
506
|
+
print(_next_footer(root, state)) # converges the old "then: add.py advance" hint
|
|
458
507
|
|
|
459
508
|
|
|
460
509
|
def _parse_deps(raw: str | None) -> list[str]:
|
|
@@ -507,6 +556,7 @@ def cmd_phase(args: argparse.Namespace) -> None:
|
|
|
507
556
|
_sync_task_marker(root, slug, args.phase)
|
|
508
557
|
save_state(root, state)
|
|
509
558
|
print(f"task '{slug}' phase -> {args.phase}")
|
|
559
|
+
print(_next_footer(root, state))
|
|
510
560
|
|
|
511
561
|
|
|
512
562
|
def cmd_advance(args: argparse.Namespace) -> None:
|
|
@@ -536,21 +586,80 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
536
586
|
"+ substantive content; bare 'none' only as 'none material — "
|
|
537
587
|
"biggest risk: X') before crossing into build")
|
|
538
588
|
state["tasks"][slug]["flag_verified"] = True
|
|
589
|
+
# tamper tripwire (verify-integrity): snapshot the red test files + the frozen
|
|
590
|
+
# §3 md5s so the verify gate can prove the green was EARNED, not edited into
|
|
591
|
+
# place. UNCONDITIONAL overwrite — a legit change-request that re-crosses
|
|
592
|
+
# tests->build re-snapshots cleanly. Co-witnessed by flag_verified (above).
|
|
593
|
+
state["tasks"][slug]["tripwire"] = _tripwire_snapshot(root, slug, raw3)
|
|
594
|
+
# §5 scope gate (build-scope-lock): when the task declares its Scope, freeze
|
|
595
|
+
# the project tree into a sidecar (payload) + a state.json anchor (md5 of the
|
|
596
|
+
# sidecar bytes). Same UNCONDITIONAL-overwrite semantics as the tripwire.
|
|
597
|
+
# UNDECLARED (no Scope line) takes no snapshot — grandfathered, never retro-red
|
|
598
|
+
# — and CLEANS UP a previous declaration's leftovers (v3): a declared->
|
|
599
|
+
# undeclared re-cross pops the stale anchor + unlinks the stale sidecar, so
|
|
600
|
+
# "UNDECLARED is never refused" holds on every path.
|
|
601
|
+
declared = _declared_scope(root, slug)
|
|
602
|
+
side = root / "tasks" / slug / "scope-snapshot.json"
|
|
603
|
+
if declared is not None:
|
|
604
|
+
payload = json.dumps({"version": 1,
|
|
605
|
+
"files": _scope_walk(root.parent.resolve())},
|
|
606
|
+
sort_keys=True)
|
|
607
|
+
side.write_text(payload, encoding="utf-8")
|
|
608
|
+
state["tasks"][slug]["scope"] = {"declared": declared,
|
|
609
|
+
"snapshot_md5": _md5_text(payload)}
|
|
610
|
+
else:
|
|
611
|
+
state["tasks"][slug].pop("scope", None)
|
|
612
|
+
try:
|
|
613
|
+
side.unlink()
|
|
614
|
+
except OSError:
|
|
615
|
+
pass
|
|
539
616
|
state["tasks"][slug]["phase"] = nxt
|
|
540
617
|
state["tasks"][slug]["updated"] = _now()
|
|
541
618
|
_sync_task_marker(root, slug, nxt)
|
|
542
619
|
save_state(root, state)
|
|
543
620
|
print(f"task '{slug}' phase {cur} -> {nxt}")
|
|
621
|
+
print(_next_footer(root, state))
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# The mechanized high-risk guard (run.md, v14; widened by explicit-autonomy-dial):
|
|
625
|
+
# judging WHAT is high-risk stays human — a scope declares `risk: high` in its TASK.md
|
|
626
|
+
# header at the freeze. The engine then enforces the pure token contradiction: risk: high
|
|
627
|
+
# WITHOUT a lowered autonomy rung (manual or conservative) is unguarded, and completion is
|
|
628
|
+
# refused. Tokens are read from the header region (text before the first section heading)
|
|
629
|
+
# with HTML comments stripped — a documentation comment is never a declaration. A token
|
|
630
|
+
# counts ONLY at a DECLARATION position — line-start (optionally indented) or just after the
|
|
631
|
+
# `·` slug-line separator — so a freeform H1 title or quoted prose that happens to contain
|
|
632
|
+
# "risk: high" / "autonomy: <x>" is never mistaken for a declaration (a title substring must
|
|
633
|
+
# not be able to fool the guard either way).
|
|
634
|
+
_RISK_HIGH_RE = re.compile(r"(?:^|·)[ \t]*risk:[ \t]*high\b", re.MULTILINE)
|
|
635
|
+
|
|
636
|
+
# the explicit 3-mode autonomy dial (task explicit-autonomy-dial): an ordered ladder
|
|
637
|
+
# manual < conservative < auto, declared as a per-task `autonomy:` header token.
|
|
638
|
+
_AUTONOMY_LEVELS = ("manual", "conservative", "auto")
|
|
639
|
+
# anchored to a DECLARATION position — line-start `autonomy:` OR the inline slug-line form
|
|
640
|
+
# `… · autonomy: conservative` (the `·`-preceded shape) — never a title/prose substring; the
|
|
641
|
+
# value stops at space/`<`/`#`/`|` so an unfilled `<manual | … >` placeholder captures nothing
|
|
642
|
+
# and reads as UNSET.
|
|
643
|
+
_AUTONOMY_LINE_RE = re.compile(r"(?:^|·)[ \t]*autonomy:[ \t]*([^\s<#|]+)", re.MULTILINE)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _autonomy_level(hdr: str):
|
|
647
|
+
"""The declared autonomy rung from a TASK.md header region (HTML comments
|
|
648
|
+
already stripped by _task_header). Returns a member of _AUTONOMY_LEVELS, or
|
|
649
|
+
None when no `autonomy:` line is present (UNSET — an unfilled `<…>` placeholder,
|
|
650
|
+
whose value the regex declines, counts as unset), or "?" when a REAL token outside
|
|
651
|
+
the set was written (unknown). PURE."""
|
|
652
|
+
m = _AUTONOMY_LINE_RE.search(hdr)
|
|
653
|
+
if not m:
|
|
654
|
+
return None
|
|
655
|
+
tok = m.group(1).strip().lower()
|
|
656
|
+
return tok if tok in _AUTONOMY_LEVELS else "?"
|
|
544
657
|
|
|
545
658
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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")
|
|
659
|
+
def _autonomy_lowered(hdr: str) -> bool:
|
|
660
|
+
"""True iff the declared rung is high-risk-safe (manual or conservative). A
|
|
661
|
+
high-risk scope must be lowered to one of these; `auto` and UNSET are not."""
|
|
662
|
+
return _autonomy_level(hdr) in ("manual", "conservative")
|
|
554
663
|
|
|
555
664
|
|
|
556
665
|
def _task_header(root: Path, slug: str) -> str:
|
|
@@ -563,6 +672,37 @@ def _task_header(root: Path, slug: str) -> str:
|
|
|
563
672
|
return re.sub(r"<!--.*?-->", "", text.split("\n## ", 1)[0], flags=re.S)
|
|
564
673
|
|
|
565
674
|
|
|
675
|
+
def _effective_autonomy(root: Path, state: dict, slug: str) -> str:
|
|
676
|
+
"""The autonomy rung that governs `slug` right now: the task's own declared rung,
|
|
677
|
+
falling back to the project default when the task line is UNSET (None) or an
|
|
678
|
+
unrecognized token ("?") — the same fail-safe chain cmd_new_task seeds from
|
|
679
|
+
(_project_autonomy: absent -> auto, garbled -> conservative). PURE. `state` is unused
|
|
680
|
+
today; it is kept in the signature beside _driver_stop for symmetry."""
|
|
681
|
+
lvl = _autonomy_level(_task_header(root, slug))
|
|
682
|
+
return lvl if lvl in _AUTONOMY_LEVELS else _project_autonomy(root)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _driver_stop(root: Path, state: dict, slug: str, phase: str) -> bool:
|
|
686
|
+
"""True iff a HUMAN owns the next step for `phase` under the effective autonomy — the
|
|
687
|
+
SINGLE source the footer marker and the guide TEXT marker both render (task
|
|
688
|
+
gate-owner-marker). Refines _phase_owner with the autonomy level at exactly ONE phase,
|
|
689
|
+
verify:
|
|
690
|
+
verify -> the human gates UNLESS the run may auto-gate (effective autonomy == auto)
|
|
691
|
+
else -> the structural owner stops (owner != "ai"), independent of the level
|
|
692
|
+
The frozen machine-state-json JSON `stop` keeps its own structural value (Option F);
|
|
693
|
+
this resolver feeds ONLY the human-facing footer + guide TEXT. _phase_owner still
|
|
694
|
+
_die("unmapped_phase") on a bad phase — the marker invents no default."""
|
|
695
|
+
if phase == "verify":
|
|
696
|
+
return _effective_autonomy(root, state, slug) != "auto"
|
|
697
|
+
return _phase_owner(phase) != "ai"
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _driver_marker(stop: bool) -> str:
|
|
701
|
+
"""Render _driver_stop as the reserved-slot word (one leading space each) — the exact
|
|
702
|
+
strings next-footer-engine reserved: ` [human gate]` (a human owns it) / ` [you drive]`."""
|
|
703
|
+
return " [human gate]" if stop else " [you drive]"
|
|
704
|
+
|
|
705
|
+
|
|
566
706
|
def cmd_gate(args: argparse.Namespace) -> None:
|
|
567
707
|
root = _require_root()
|
|
568
708
|
state = load_state(root)
|
|
@@ -588,10 +728,18 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
588
728
|
# COMPLETION (PASS / RISK-ACCEPTED) until the dial is lowered and a human
|
|
589
729
|
# owns the gate. HARD-STOP is never blocked — stopping is always allowed.
|
|
590
730
|
hdr = _task_header(root, slug)
|
|
591
|
-
if _RISK_HIGH_RE.search(hdr) and not
|
|
731
|
+
if _RISK_HIGH_RE.search(hdr) and not _autonomy_lowered(hdr):
|
|
592
732
|
_die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high "
|
|
593
|
-
"without autonomy
|
|
594
|
-
"
|
|
733
|
+
"without a lowered autonomy level — run `add.py autonomy set conservative` "
|
|
734
|
+
"(or manual); a human must own a high-risk gate (run.md guard)")
|
|
735
|
+
# tamper tripwire (verify-integrity): the method's first mechanical cheat
|
|
736
|
+
# block. A completing outcome is refused if the red suite or the frozen §3
|
|
737
|
+
# changed since the tests->build snapshot. Placed BEFORE the waiver write so
|
|
738
|
+
# a tamper finding is never launderable through RISK-ACCEPTED.
|
|
739
|
+
_tamper_guard(root, state, slug)
|
|
740
|
+
# §5 scope gate (build-scope-lock): touched ⊆ declared, or a named refusal —
|
|
741
|
+
# same placement discipline as the tripwire (before the waiver, never on HARD-STOP).
|
|
742
|
+
_scope_guard(root, state, slug)
|
|
595
743
|
if args.outcome == "RISK-ACCEPTED":
|
|
596
744
|
# A waiver must be SIGNED: owner, ticket, expiry (glossary). Stored in state
|
|
597
745
|
# so a later `check` can read/expire it. Refuse a partial waiver outright.
|
|
@@ -609,8 +757,83 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
609
757
|
state["tasks"][slug]["updated"] = _now()
|
|
610
758
|
save_state(root, state)
|
|
611
759
|
print(f"task '{slug}' gate -> {args.outcome}")
|
|
612
|
-
|
|
613
|
-
|
|
760
|
+
# the engine-sourced next step (next-footer-engine): a completing gate hands off to the
|
|
761
|
+
# state arm; HARD-STOP routes to "resolve HARD-STOP …" — converging the old bespoke line.
|
|
762
|
+
print(_next_footer(root, state))
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
# the autonomy level as a first-class verb (task autonomy-command): autonomy was the ONLY mutable,
|
|
766
|
+
# security-relevant task/project token WITHOUT a CLI verb — so an agent under `auto`, applying the
|
|
767
|
+
# correct "first-class state has a command" model, hallucinated `add.py autonomy` and derailed.
|
|
768
|
+
# `show` reads the resolved level; `set` is the FIRST writer of the header token — idempotent (one
|
|
769
|
+
# declaration line, trailing comment preserved, NEVER appended), with the raise + risk:high guards
|
|
770
|
+
# enforced BEFORE the write. state.json is untouched — autonomy stays a header token.
|
|
771
|
+
_AUTONOMY_ORDER = {lvl: i for i, lvl in enumerate(_AUTONOMY_LEVELS)} # manual(0) < conservative(1) < auto(2)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _autonomy_decl_line(text: str, level: str) -> str:
|
|
775
|
+
"""Rewrite the SINGLE `autonomy:` declaration line to `level`, PRESERVING its trailing comment,
|
|
776
|
+
idempotently (replace in place, count=1 — never a second line). If absent, insert it: after the
|
|
777
|
+
`slug:` line for a task header, else after a leading `#` heading (PROJECT.md), else prepend. PURE
|
|
778
|
+
on the text; the caller does the atomic write."""
|
|
779
|
+
pat = re.compile(r"(?m)^(autonomy:[ \t]*)[^\s<#|]+(.*)$")
|
|
780
|
+
if pat.search(text):
|
|
781
|
+
return pat.sub(lambda m: f"{m.group(1)}{level}{m.group(2)}", text, count=1)
|
|
782
|
+
if re.search(r"(?m)^slug:", text):
|
|
783
|
+
return re.sub(r"(?m)^(slug:.*)$", r"\1\nautonomy: " + level, text, count=1)
|
|
784
|
+
lines = text.splitlines(keepends=True)
|
|
785
|
+
if lines and lines[0].lstrip().startswith("#"):
|
|
786
|
+
return lines[0] + f"autonomy: {level}\n" + "".join(lines[1:])
|
|
787
|
+
return f"autonomy: {level}\n" + text
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _guard_autonomy_raise(current: str, target: str, yes: bool) -> None:
|
|
791
|
+
"""RAISING the level toward `auto` is a human-owned trust escalation (run.md: the AI may LOWER
|
|
792
|
+
freely — RECOMMEND-only — but RAISING needs a human). Refuse a raise unless --yes confirms it."""
|
|
793
|
+
if _AUTONOMY_ORDER.get(target, -1) > _AUTONOMY_ORDER.get(current, -1) and not yes:
|
|
794
|
+
_die(f"autonomy_raise_unconfirmed: raising autonomy {current} -> {target} is a human-owned "
|
|
795
|
+
"trust escalation (the AI may LOWER freely; RAISING needs a human) — pass --yes to confirm")
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _print_autonomy(root: Path, state: dict, slug: str) -> None:
|
|
799
|
+
"""The read-only level view: declared · effective (fallback-resolved) · project default · the
|
|
800
|
+
verify-gate owner under it (the SAME _driver_stop the footer/guide render). Writes nothing."""
|
|
801
|
+
declared = _autonomy_level(_task_header(root, slug))
|
|
802
|
+
stop = _driver_stop(root, state, slug, "verify")
|
|
803
|
+
print(f"task : {slug}")
|
|
804
|
+
print(f"declared : {declared if declared in _AUTONOMY_LEVELS else 'unset'}")
|
|
805
|
+
print(f"effective : {_effective_autonomy(root, state, slug)}")
|
|
806
|
+
print(f"project : {_project_autonomy(root)}")
|
|
807
|
+
print(f"verify gate : {'human gate' if stop else 'you drive'}")
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def cmd_autonomy(args: argparse.Namespace) -> None:
|
|
811
|
+
"""show / set the autonomy level — the verify-gate owner (task autonomy-command)."""
|
|
812
|
+
root = _require_root() # reused -> "no .add/ project found …"
|
|
813
|
+
state = load_state(root)
|
|
814
|
+
if (getattr(args, "action", None) or "show") == "show":
|
|
815
|
+
_print_autonomy(root, state, _resolve_task(state, args.a1)) # reused -> "unknown task '<slug>'"
|
|
816
|
+
return
|
|
817
|
+
# action == "set"
|
|
818
|
+
level = args.a1
|
|
819
|
+
if level not in _AUTONOMY_LEVELS:
|
|
820
|
+
_die("autonomy_level_invalid: level must be one of "
|
|
821
|
+
f"{', '.join(_AUTONOMY_LEVELS)} (got {level!r})")
|
|
822
|
+
if getattr(args, "project", False):
|
|
823
|
+
target = root / "PROJECT.md"
|
|
824
|
+
_guard_autonomy_raise(_project_autonomy(root), level, getattr(args, "yes", False))
|
|
825
|
+
_atomic_write(target, _autonomy_decl_line(target.read_text(encoding="utf-8"), level))
|
|
826
|
+
print(f"project autonomy -> {level}")
|
|
827
|
+
return
|
|
828
|
+
slug = _resolve_task(state, args.a2) # reused -> "unknown task '<slug>'"
|
|
829
|
+
task_md = root / "tasks" / slug / "TASK.md"
|
|
830
|
+
if _RISK_HIGH_RE.search(_task_header(root, slug)) and level not in ("manual", "conservative"):
|
|
831
|
+
_die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high — autonomy must stay "
|
|
832
|
+
f"lowered (manual|conservative); refusing '{level}' (a human must own a high-risk gate)")
|
|
833
|
+
_guard_autonomy_raise(_effective_autonomy(root, state, slug), level, getattr(args, "yes", False))
|
|
834
|
+
_atomic_write(task_md, _autonomy_decl_line(task_md.read_text(encoding="utf-8"), level))
|
|
835
|
+
print(f"task '{slug}' autonomy -> {level}")
|
|
836
|
+
_print_autonomy(root, state, slug)
|
|
614
837
|
|
|
615
838
|
|
|
616
839
|
def cmd_reopen(args: argparse.Namespace) -> None:
|
|
@@ -636,8 +859,8 @@ def cmd_reopen(args: argparse.Namespace) -> None:
|
|
|
636
859
|
if not reason:
|
|
637
860
|
_die("reopen_reason_required: reopen records WHY — supply a non-empty --reason")
|
|
638
861
|
target = args.to
|
|
639
|
-
if target not in PHASES[
|
|
640
|
-
_die(f"reopen_target_invalid: --to must be one of {', '.join(PHASES[
|
|
862
|
+
if target not in PHASES[:-1]: # ground..observe; never "done", never an unknown name
|
|
863
|
+
_die(f"reopen_target_invalid: --to must be one of {', '.join(PHASES[:-1])} (got {target!r})")
|
|
641
864
|
now = _now()
|
|
642
865
|
entry = {"from": "done", "to": target, "reason": reason, "at": now,
|
|
643
866
|
"prior_gate": t.get("gate", "none")}
|
|
@@ -650,6 +873,32 @@ def cmd_reopen(args: argparse.Namespace) -> None:
|
|
|
650
873
|
_sync_task_marker(root, slug, target)
|
|
651
874
|
save_state(root, state)
|
|
652
875
|
print(f"task '{slug}' reopened: done -> {target} (reason recorded); gate reset to none")
|
|
876
|
+
print(_next_footer(root, state))
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def cmd_heal(args: argparse.Namespace) -> None:
|
|
880
|
+
"""Report a CONFIRMED semantic cheat — an earned-green failure the adversarial refute-read
|
|
881
|
+
found — and enter the bounded self-heal loop (heal-then-escalate). The judgment rubric (the
|
|
882
|
+
specific cheats and how to spot them) lives in 6-verify.md, never the engine.
|
|
883
|
+
|
|
884
|
+
The engine cannot SEE a judgment cheat — this is the agent's honest report (honor-system,
|
|
885
|
+
necessary-not-sufficient; the human verify gate stays the real backstop, and the engine
|
|
886
|
+
never spawns the refute-read). It routes through the SAME _heal_or_escalate as the
|
|
887
|
+
mechanical tripwire: return-to-build for an honest redo (≤HEAL_CAP), then a HARD-STOP
|
|
888
|
+
escalation. The refute-read is a verify-gate activity, so the task must be at verify."""
|
|
889
|
+
root = _require_root()
|
|
890
|
+
state = load_state(root)
|
|
891
|
+
slug = _resolve_task(state, args.slug)
|
|
892
|
+
reason = (args.reason or "").strip()
|
|
893
|
+
if not reason:
|
|
894
|
+
_die("heal_reason_required: heal records the refute-read finding — supply a "
|
|
895
|
+
"non-empty --reason (never a silent loop)")
|
|
896
|
+
phase = state["tasks"][slug].get("phase")
|
|
897
|
+
if phase != "verify":
|
|
898
|
+
_die(f"heal_not_at_verify: task '{slug}' is at '{phase}', not verify — the "
|
|
899
|
+
"adversarial refute-read is a verify-gate activity; build then advance to "
|
|
900
|
+
"verify before reporting a cheat")
|
|
901
|
+
_heal_or_escalate(root, state, slug, reason="refute-read:" + reason, source="refute-read")
|
|
653
902
|
|
|
654
903
|
|
|
655
904
|
def cmd_lock(args: argparse.Namespace) -> None:
|
|
@@ -680,6 +929,7 @@ def cmd_lock(args: argparse.Namespace) -> None:
|
|
|
680
929
|
separators=(",", ":")))
|
|
681
930
|
else:
|
|
682
931
|
print(f"locked setup ({','.join(layers)}) by {who} @ {when}")
|
|
932
|
+
print(_next_footer(root, state))
|
|
683
933
|
|
|
684
934
|
|
|
685
935
|
def _has_production_roadmap(state: dict) -> bool:
|
|
@@ -713,6 +963,7 @@ def cmd_stage(args: argparse.Namespace) -> None:
|
|
|
713
963
|
print(f"project stage -> {args.stage}")
|
|
714
964
|
if bypassing:
|
|
715
965
|
print("(--force: bypassed roadmap check — no production milestone drafted)")
|
|
966
|
+
print(_next_footer(root, state))
|
|
716
967
|
|
|
717
968
|
|
|
718
969
|
def cmd_status(args: argparse.Namespace) -> None:
|
|
@@ -744,6 +995,9 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
744
995
|
# Reuses the canonical helper — do NOT write a parallel predicate.
|
|
745
996
|
unlocked = not _setup_locked(state)
|
|
746
997
|
print(f"project : {state.get('project', '(unknown)')}")
|
|
998
|
+
# project autonomy default (task init-auto-default): the posture new tasks INHERIT,
|
|
999
|
+
# read LIVE from PROJECT.md so the human sees the project-wide throttle every session.
|
|
1000
|
+
print(f"project autonomy: {_project_autonomy(root)} (default — new tasks inherit)")
|
|
747
1001
|
print(f"stage : {state.get('stage', '(unknown)')}")
|
|
748
1002
|
# project GOAL + active-milestone goal (v20) — the loop's orientation anchor, read
|
|
749
1003
|
# LIVE from PROJECT.md / MILESTONE.md (never state.json). Additive: every existing
|
|
@@ -752,9 +1006,20 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
752
1006
|
_active_ms = state.get("active_milestone")
|
|
753
1007
|
if _active_ms:
|
|
754
1008
|
print(f"m-goal : {_milestone_doc(root, _active_ms)[1]} (← {_active_ms})")
|
|
1009
|
+
# goal-ready (task goal-auto-ready-gate): is the active milestone's goal AUTO-READY
|
|
1010
|
+
# — every exit criterion citing a verifier `(verify: …)` so the engine can self-verify
|
|
1011
|
+
# the result against it? Read LIVE from MILESTONE.md; surfaced every session so the
|
|
1012
|
+
# human sees the goal-clarity gap. Additive — human-readable only, never the JSON surface.
|
|
1013
|
+
_gr_cited, _gr_total = _exit_criteria_cited(root, _active_ms)
|
|
1014
|
+
_gr_state = "auto-ready ✓" if _goal_auto_ready(root, _active_ms) else "NOT auto-ready"
|
|
1015
|
+
print(f"goal-ready: {_gr_state} ({_gr_cited}/{_gr_total} exit criteria cite a verifier)")
|
|
755
1016
|
# foundation pointer — read the cross-milestone context first (anti-rot)
|
|
756
1017
|
if (root / "PROJECT.md").exists():
|
|
757
1018
|
print("context : .add/PROJECT.md (foundation: domain · spec · UI/UX — read first)")
|
|
1019
|
+
# voice pointer — the AI's SOUL (tone · style · trust); read each session, edit freely.
|
|
1020
|
+
# Existence-only: no open/parse, so the pointer adds no IO failure path (a non-file is no voice).
|
|
1021
|
+
if (root / "SOUL.md").exists():
|
|
1022
|
+
print("voice : .add/SOUL.md (how I sound & what keeps your trust — read each session)")
|
|
758
1023
|
# wave resume hint — a live ledger outranks memory (streams.md "Wave ledger").
|
|
759
1024
|
# Existence-only: no open/read/parse, so the hint adds no IO failure path; a
|
|
760
1025
|
# non-file at the path is not a ledger. One line PER live ledger — more than
|
|
@@ -791,6 +1056,18 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
791
1056
|
f"({m_tasks} task{'s' if m_tasks != 1 else ''})")
|
|
792
1057
|
|
|
793
1058
|
print(f"active : {active or '(none)'}")
|
|
1059
|
+
# surface the active task's autonomy level (task explicit-autonomy-dial) so the human
|
|
1060
|
+
# reads the throttle every session; "unset" when no explicit `autonomy:` line is present.
|
|
1061
|
+
if active and active in tasks:
|
|
1062
|
+
print(f"autonomy: {_autonomy_level(_task_header(root, active)) or 'unset'}")
|
|
1063
|
+
# grounded (task ground-bundle-wiring): does the active task's §0 GROUND map cite the
|
|
1064
|
+
# anchors §3 names? measure-not-block, human-readable only (never the JSON surface). A
|
|
1065
|
+
# pre-ground / legacy task (no §0) -> _task_grounded None -> NO line, so the surface is
|
|
1066
|
+
# purely additive: an existing task's status output is byte-unchanged.
|
|
1067
|
+
_g = _task_grounded(root, active)
|
|
1068
|
+
if _g is not None:
|
|
1069
|
+
print("grounded: " + ("grounded ✓ — §0 cites the anchors §3 names" if _g
|
|
1070
|
+
else "not yet — fill the §0 GROUND anchors (add.py guide)"))
|
|
794
1071
|
if not tasks:
|
|
795
1072
|
# First-run panel: a brand-new project's status is the moment a user is most
|
|
796
1073
|
# lost. When the setup is unlocked, the only correct next move is review+lock —
|
|
@@ -840,6 +1117,7 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
840
1117
|
# routed there through the CLI alone. Never a dead pointer: the path is printed
|
|
841
1118
|
# only if the file exists; a missing tree gets an install hint instead.
|
|
842
1119
|
_PHASE_GUIDE_FILES = {
|
|
1120
|
+
"ground": "0-ground.md",
|
|
843
1121
|
"specify": "1-specify.md", "scenarios": "2-scenarios.md",
|
|
844
1122
|
"contract": "3-contract.md", "tests": "4-tests.md",
|
|
845
1123
|
"build": "5-build.md", "verify": "6-verify.md", "observe": "7-observe.md",
|
|
@@ -897,9 +1175,13 @@ def cmd_guide(args: argparse.Namespace) -> None:
|
|
|
897
1175
|
if entry is None: # corrupted/hand-edited state.json — fail clean, not KeyError
|
|
898
1176
|
_die(f"task '{slug}' has unknown phase '{phase}' (state.json corrupted?)")
|
|
899
1177
|
action, chapter = entry
|
|
1178
|
+
# the guide names the driver too (task gate-owner-marker) — the SAME _driver_stop the
|
|
1179
|
+
# footer renders, on the next-step line. Computed AFTER the unknown-phase guard above,
|
|
1180
|
+
# so a bad phase fails clean and never reaches the marker (it invents no default).
|
|
1181
|
+
marker = _driver_marker(_driver_stop(root, state, slug, phase))
|
|
900
1182
|
print(f"active : {slug} (phase: {phase})")
|
|
901
1183
|
print(f"goal : {_project_goal(root)}") # v20 — the next-step surface still shows what the work is FOR
|
|
902
|
-
print(f"next : {action}")
|
|
1184
|
+
print(f"next : {action}{marker}")
|
|
903
1185
|
print(f"read : .add/docs/{chapter}")
|
|
904
1186
|
gp = _phase_guide_path(root.parent, phase)
|
|
905
1187
|
if gp is not None:
|
|
@@ -926,6 +1208,404 @@ def _read_task_phase(root: Path, slug: str) -> str | None:
|
|
|
926
1208
|
return None
|
|
927
1209
|
|
|
928
1210
|
|
|
1211
|
+
# --- UDD token-layer validator (udd-token-schema) -----------------------------
|
|
1212
|
+
# A pure, stdlib checker for the compact-DTCG 3-layer token dialect. Returns a
|
|
1213
|
+
# list of (code, path, detail) violations — [] means valid. NOT wired into
|
|
1214
|
+
# cmd_check here: udd-check-lint surfaces these as named reds + adds the catalog/
|
|
1215
|
+
# tree rules (the Fork-A boundary frozen in udd-token-schema §3). The dialect and
|
|
1216
|
+
# its NAMED divergences from DTCG 2025.10 live in templates/udd-tokens.md.
|
|
1217
|
+
_TOKEN_LAYERS = ("primitive", "semantic", "component")
|
|
1218
|
+
_TOKEN_LAYER_CITES = {"semantic": "primitive", "component": "semantic"}
|
|
1219
|
+
_TOKEN_TYPES = ("color", "dimension", "number", "fontFamily", "fontWeight", "duration")
|
|
1220
|
+
_TOKEN_HEX_RE = re.compile(r"^#(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")
|
|
1221
|
+
_TOKEN_DIM_RE = re.compile(r"^-?\d+(?:\.\d+)?(?:px|rem|em|%|vh|vw)$")
|
|
1222
|
+
_TOKEN_DUR_RE = re.compile(r"^\d+(?:\.\d+)?(?:ms|s)$")
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _token_value_form_ok(ttype: str, value: object) -> bool:
|
|
1226
|
+
"""True if a LITERAL value matches the compact form for its $type."""
|
|
1227
|
+
if ttype == "color":
|
|
1228
|
+
return isinstance(value, str) and bool(_TOKEN_HEX_RE.match(value))
|
|
1229
|
+
if ttype == "dimension":
|
|
1230
|
+
return isinstance(value, str) and bool(_TOKEN_DIM_RE.match(value))
|
|
1231
|
+
if ttype == "number":
|
|
1232
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
1233
|
+
if ttype == "fontWeight":
|
|
1234
|
+
return isinstance(value, str) or (
|
|
1235
|
+
isinstance(value, int) and not isinstance(value, bool) and 100 <= value <= 900)
|
|
1236
|
+
if ttype == "duration":
|
|
1237
|
+
return isinstance(value, str) and bool(_TOKEN_DUR_RE.match(value))
|
|
1238
|
+
if ttype == "fontFamily":
|
|
1239
|
+
return isinstance(value, str) or (
|
|
1240
|
+
isinstance(value, list) and bool(value) and all(isinstance(x, str) for x in value))
|
|
1241
|
+
return False
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _token_layer_violations(tokens: dict) -> list[tuple[str, str, str]]:
|
|
1245
|
+
"""Validate a compact-DTCG token dict against the 3-layer citation rules.
|
|
1246
|
+
|
|
1247
|
+
Pure (never mutates `tokens`), stdlib-only, deterministic document order.
|
|
1248
|
+
Returns [] when valid, else one (code, path, detail) per violation. The six
|
|
1249
|
+
codes are the token-layer named reds udd-check-lint surfaces. A token's LAYER
|
|
1250
|
+
is its top-level group name; value forms diverge from DTCG 2025.10 to compact
|
|
1251
|
+
scalars (color "#hex", dimension "<n><unit>") — see templates/udd-tokens.md.
|
|
1252
|
+
"""
|
|
1253
|
+
if not isinstance(tokens, dict):
|
|
1254
|
+
return [("malformed_value", "", "root is not a JSON object")]
|
|
1255
|
+
|
|
1256
|
+
# index every token (object bearing $value) by dotted path — for alias resolution
|
|
1257
|
+
index: dict[str, dict] = {}
|
|
1258
|
+
|
|
1259
|
+
def _index(node: object, path: list[str]) -> None:
|
|
1260
|
+
if not isinstance(node, dict):
|
|
1261
|
+
return
|
|
1262
|
+
if "$value" in node:
|
|
1263
|
+
index[".".join(path)] = node
|
|
1264
|
+
for key, child in node.items(): # descend even past a token — never skip a subtree
|
|
1265
|
+
if not key.startswith("$"):
|
|
1266
|
+
_index(child, path + [key])
|
|
1267
|
+
|
|
1268
|
+
for top, node in tokens.items():
|
|
1269
|
+
if top in _TOKEN_LAYERS:
|
|
1270
|
+
_index(node, [top])
|
|
1271
|
+
|
|
1272
|
+
out: list[tuple[str, str, str]] = []
|
|
1273
|
+
|
|
1274
|
+
def _walk(node: object, path: list[str], layer: str, inherited: "str | None") -> None:
|
|
1275
|
+
if not isinstance(node, dict):
|
|
1276
|
+
return
|
|
1277
|
+
if "$value" in node: # a token
|
|
1278
|
+
pathstr = ".".join(path)
|
|
1279
|
+
ttype = node.get("$type", inherited)
|
|
1280
|
+
value = node.get("$value")
|
|
1281
|
+
if ttype not in _TOKEN_TYPES:
|
|
1282
|
+
out.append(("unknown_type", pathstr, f"$type {ttype!r} not in {list(_TOKEN_TYPES)}"))
|
|
1283
|
+
elif isinstance(value, str) and value.startswith("{") and value.endswith("}"):
|
|
1284
|
+
target = value[1:-1] # an alias
|
|
1285
|
+
if layer == "primitive":
|
|
1286
|
+
out.append(("primitive_has_alias", pathstr,
|
|
1287
|
+
f"a primitive token must hold a literal, not alias {value}"))
|
|
1288
|
+
elif target not in index:
|
|
1289
|
+
out.append(("unresolved_alias", pathstr, f"{value} resolves to no token"))
|
|
1290
|
+
else:
|
|
1291
|
+
target_layer = target.split(".", 1)[0]
|
|
1292
|
+
if target_layer != _TOKEN_LAYER_CITES[layer]:
|
|
1293
|
+
out.append(("cross_layer_citation", pathstr,
|
|
1294
|
+
f"{layer} may alias only {_TOKEN_LAYER_CITES[layer]}, not {target_layer}"))
|
|
1295
|
+
elif not _token_value_form_ok(ttype, value): # a literal
|
|
1296
|
+
out.append(("malformed_value", pathstr, f"{value!r} is not a valid {ttype}"))
|
|
1297
|
+
# a token should be a leaf; if it carries non-$ children, validate them too rather
|
|
1298
|
+
# than letting them pass silently (fail-closed — never skip a subtree).
|
|
1299
|
+
for key, child in node.items():
|
|
1300
|
+
if not key.startswith("$"):
|
|
1301
|
+
_walk(child, path + [key], layer, ttype)
|
|
1302
|
+
return
|
|
1303
|
+
gtype = node.get("$type", inherited) # a group
|
|
1304
|
+
for key, child in node.items():
|
|
1305
|
+
if not key.startswith("$"):
|
|
1306
|
+
_walk(child, path + [key], layer, gtype)
|
|
1307
|
+
|
|
1308
|
+
for top, node in tokens.items():
|
|
1309
|
+
if top not in _TOKEN_LAYERS:
|
|
1310
|
+
out.append(("unknown_layer", top, f"top-level group {top!r} is not a layer"))
|
|
1311
|
+
continue
|
|
1312
|
+
_walk(node, [top], top, None)
|
|
1313
|
+
|
|
1314
|
+
return out
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
# ---- udd-catalog-content-schema (task 2/4): component catalog + content-tree validator ----
|
|
1318
|
+
_PROPSPEC_LITERALS = ("string", "number", "boolean")
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _propspec_malformed(spec: object) -> "str | None":
|
|
1322
|
+
"""Return a reason if a catalog PropSpec is malformed, else None.
|
|
1323
|
+
|
|
1324
|
+
A PropSpec is exactly one of: {type: string|number|boolean} ·
|
|
1325
|
+
{type: enum, values: [str,…]} · {type: token, token: <$type>} (a task-1 $type).
|
|
1326
|
+
"""
|
|
1327
|
+
if not isinstance(spec, dict):
|
|
1328
|
+
return "PropSpec is not an object"
|
|
1329
|
+
ptype = spec.get("type")
|
|
1330
|
+
if ptype in _PROPSPEC_LITERALS:
|
|
1331
|
+
return None
|
|
1332
|
+
if ptype == "enum":
|
|
1333
|
+
values = spec.get("values")
|
|
1334
|
+
if not isinstance(values, list) or not values or not all(isinstance(x, str) for x in values):
|
|
1335
|
+
return "enum PropSpec needs a non-empty list of string values"
|
|
1336
|
+
return None
|
|
1337
|
+
if ptype == "token":
|
|
1338
|
+
ttype = spec.get("token")
|
|
1339
|
+
if ttype not in _TOKEN_TYPES:
|
|
1340
|
+
return f"token PropSpec names unknown $type {ttype!r}"
|
|
1341
|
+
return None
|
|
1342
|
+
return f"unknown PropSpec type {ptype!r}"
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def _prop_value_code(spec: dict, value: object) -> "str | None":
|
|
1346
|
+
"""Return a violation CODE if a tree prop value mismatches its well-formed PropSpec, else None.
|
|
1347
|
+
|
|
1348
|
+
token props are LAYER-only here (frozen §3 @ v2): the value must be a
|
|
1349
|
+
`{semantic.*}` alias. A non-alias literal → prop_type_mismatch; a wrong-layer
|
|
1350
|
+
alias → non_semantic_prop_token. Target existence + $type-match defer to
|
|
1351
|
+
udd-check-lint (the composer that holds tokens.json).
|
|
1352
|
+
"""
|
|
1353
|
+
ptype = spec.get("type")
|
|
1354
|
+
if ptype == "string":
|
|
1355
|
+
return None if isinstance(value, str) else "prop_type_mismatch"
|
|
1356
|
+
if ptype == "number":
|
|
1357
|
+
ok = isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
1358
|
+
return None if ok else "prop_type_mismatch"
|
|
1359
|
+
if ptype == "boolean":
|
|
1360
|
+
return None if isinstance(value, bool) else "prop_type_mismatch"
|
|
1361
|
+
if ptype == "enum":
|
|
1362
|
+
return None if value in spec.get("values", []) else "prop_type_mismatch"
|
|
1363
|
+
if ptype == "token":
|
|
1364
|
+
if not (isinstance(value, str) and value.startswith("{") and value.endswith("}")):
|
|
1365
|
+
return "prop_type_mismatch" # a token prop must be an alias, not a literal
|
|
1366
|
+
if value[1:-1].split(".", 1)[0] != "semantic":
|
|
1367
|
+
return "non_semantic_prop_token" # v2: the alias must target the semantic layer
|
|
1368
|
+
return None
|
|
1369
|
+
return None # unreachable for well-formed specs
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def _catalog_tree_violations(catalog: dict, tree: dict) -> list[tuple[str, str, str]]:
|
|
1373
|
+
"""Validate a json-render content TREE against OUR component CATALOG.
|
|
1374
|
+
|
|
1375
|
+
Pure (never mutates `catalog`/`tree`), stdlib-only, deterministic order. Returns
|
|
1376
|
+
[] when valid, else one (code, path, detail) per violation. The eight named reds:
|
|
1377
|
+
tree_cites_uncataloged_component · unknown_prop · prop_type_mismatch ·
|
|
1378
|
+
non_semantic_prop_token · dangling_child · children_not_allowed · missing_root ·
|
|
1379
|
+
malformed_catalog. SEPARATE from _token_layer_violations; udd-check-lint composes
|
|
1380
|
+
both. non_semantic_prop_token is LAYER-only (§3 @ v2) — token existence/$type-match
|
|
1381
|
+
are udd-check-lint's job (it holds tokens.json). See templates/udd-catalog.md.
|
|
1382
|
+
"""
|
|
1383
|
+
out: list[tuple[str, str, str]] = []
|
|
1384
|
+
|
|
1385
|
+
# 1. catalog PropSpecs (malformed_catalog) — and collect the well-formed specs
|
|
1386
|
+
components = catalog.get("components") if isinstance(catalog, dict) else None
|
|
1387
|
+
if not isinstance(components, dict):
|
|
1388
|
+
out.append(("malformed_catalog", "components", "catalog has no 'components' object"))
|
|
1389
|
+
components = {}
|
|
1390
|
+
specs: dict[str, dict[str, dict]] = {} # component -> {prop: well-formed spec}
|
|
1391
|
+
declared_names: dict[str, set] = {} # component -> all declared prop names
|
|
1392
|
+
for cname, comp in components.items():
|
|
1393
|
+
if not isinstance(comp, dict): # v3: a component entry must be an object
|
|
1394
|
+
out.append(("malformed_catalog", f"components.{cname}", "component entry is not an object"))
|
|
1395
|
+
declared_names[cname] = set()
|
|
1396
|
+
specs[cname] = {}
|
|
1397
|
+
continue
|
|
1398
|
+
cprops = comp.get("props", {})
|
|
1399
|
+
cprops = cprops if isinstance(cprops, dict) else {}
|
|
1400
|
+
declared_names[cname] = set(cprops.keys())
|
|
1401
|
+
ok: dict[str, dict] = {}
|
|
1402
|
+
for pname, spec in cprops.items():
|
|
1403
|
+
reason = _propspec_malformed(spec)
|
|
1404
|
+
if reason is not None:
|
|
1405
|
+
out.append(("malformed_catalog", f"components.{cname}.props.{pname}", reason))
|
|
1406
|
+
else:
|
|
1407
|
+
ok[pname] = spec
|
|
1408
|
+
specs[cname] = ok
|
|
1409
|
+
|
|
1410
|
+
# 2. root (missing_root) — checked before the elements walk
|
|
1411
|
+
elements = tree.get("elements") if isinstance(tree, dict) else None
|
|
1412
|
+
elements = elements if isinstance(elements, dict) else {}
|
|
1413
|
+
root = tree.get("root") if isinstance(tree, dict) else None
|
|
1414
|
+
if not isinstance(root, str) or root not in elements:
|
|
1415
|
+
out.append(("missing_root", "root", f"root {root!r} is absent from elements"))
|
|
1416
|
+
|
|
1417
|
+
# 3. elements (document key order)
|
|
1418
|
+
for eid, el in elements.items():
|
|
1419
|
+
if not isinstance(el, dict): # v3: an element must be an object
|
|
1420
|
+
out.append(("malformed_element", f"elements.{eid}", "element is not an object"))
|
|
1421
|
+
continue
|
|
1422
|
+
etype = el.get("type")
|
|
1423
|
+
cataloged = isinstance(etype, str) and etype in components
|
|
1424
|
+
if not cataloged:
|
|
1425
|
+
out.append(("tree_cites_uncataloged_component", f"elements.{eid}.type",
|
|
1426
|
+
f"type {etype!r} not in catalog"))
|
|
1427
|
+
|
|
1428
|
+
props = el.get("props")
|
|
1429
|
+
if "props" in el and not isinstance(props, dict): # v3: props must be an object
|
|
1430
|
+
out.append(("malformed_element", f"elements.{eid}.props", "props is not an object"))
|
|
1431
|
+
elif cataloged and isinstance(props, dict):
|
|
1432
|
+
for pname, value in props.items():
|
|
1433
|
+
if pname not in declared_names.get(etype, set()):
|
|
1434
|
+
out.append(("unknown_prop", f"elements.{eid}.props.{pname}",
|
|
1435
|
+
f"{pname!r} not declared on {etype}"))
|
|
1436
|
+
elif pname in specs.get(etype, {}): # declared + well-formed spec → value-check
|
|
1437
|
+
code = _prop_value_code(specs[etype][pname], value)
|
|
1438
|
+
if code is not None:
|
|
1439
|
+
out.append((code, f"elements.{eid}.props.{pname}",
|
|
1440
|
+
f"{value!r} does not satisfy {specs[etype][pname]}"))
|
|
1441
|
+
# declared-but-malformed-spec prop: the catalog error is already logged; skip value-check
|
|
1442
|
+
|
|
1443
|
+
children = el.get("children")
|
|
1444
|
+
if "children" in el and not isinstance(children, list): # v3: children must be an array
|
|
1445
|
+
out.append(("malformed_element", f"elements.{eid}.children", "children is not an array"))
|
|
1446
|
+
elif isinstance(children, list) and children: # empty list == absent (no violation)
|
|
1447
|
+
comp_entry = components.get(etype)
|
|
1448
|
+
has_children = (bool(comp_entry.get("hasChildren", False))
|
|
1449
|
+
if cataloged and isinstance(comp_entry, dict) else False)
|
|
1450
|
+
if cataloged and not has_children:
|
|
1451
|
+
out.append(("children_not_allowed", f"elements.{eid}.children",
|
|
1452
|
+
f"{etype} does not declare hasChildren"))
|
|
1453
|
+
else:
|
|
1454
|
+
for cid in children:
|
|
1455
|
+
if cid not in elements:
|
|
1456
|
+
out.append(("dangling_child", f"elements.{eid}.children.{cid}",
|
|
1457
|
+
f"child id {cid!r} absent from elements"))
|
|
1458
|
+
|
|
1459
|
+
return out
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
# ---- udd-check-lint (task 4/4): the composer + cross-file token resolution ----
|
|
1463
|
+
# The single holder of tokens + catalog + tree. _catalog_tree_violations checks a
|
|
1464
|
+
# token-prop alias LAYER-only (it must target `semantic`); here we close the deferral
|
|
1465
|
+
# task 2 left — resolve that alias against tokens.json for EXISTENCE + $type-match.
|
|
1466
|
+
|
|
1467
|
+
def _semantic_token_index(tokens: dict) -> dict[str, "str | None"]:
|
|
1468
|
+
"""Map each semantic token's dotted path -> its effective $type.
|
|
1469
|
+
|
|
1470
|
+
A token is a node bearing $value; its $type is the nearest $type on its path
|
|
1471
|
+
(DTCG group inheritance — $type sits on the GROUP, the leaf carries only $value).
|
|
1472
|
+
Keys carry the layer prefix ("semantic.color.accent"), matching the alias body.
|
|
1473
|
+
"""
|
|
1474
|
+
out: dict[str, "str | None"] = {}
|
|
1475
|
+
sem = tokens.get("semantic") if isinstance(tokens, dict) else None
|
|
1476
|
+
if not isinstance(sem, dict):
|
|
1477
|
+
return out
|
|
1478
|
+
|
|
1479
|
+
def _walk(node: object, path: list[str], inherited: "str | None") -> None:
|
|
1480
|
+
if not isinstance(node, dict):
|
|
1481
|
+
return
|
|
1482
|
+
ttype = node.get("$type", inherited)
|
|
1483
|
+
if "$value" in node: # a token (a leaf bearing $value)
|
|
1484
|
+
out[".".join(path)] = ttype
|
|
1485
|
+
for key, child in node.items(): # descend even past a token — never skip a subtree
|
|
1486
|
+
if not key.startswith("$"):
|
|
1487
|
+
_walk(child, path + [key], ttype)
|
|
1488
|
+
|
|
1489
|
+
_walk(sem, ["semantic"], None)
|
|
1490
|
+
return out
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
def _prop_token_resolution_violations(tokens: dict, catalog: dict, tree: dict) -> list[tuple[str, str, str]]:
|
|
1494
|
+
"""Resolve a tree's semantic token-prop aliases against tokens.json.
|
|
1495
|
+
|
|
1496
|
+
Pure + TOTAL (never mutates inputs; stdlib only; never raises on dict inputs).
|
|
1497
|
+
Deterministic document order; [] == every token-prop alias resolves to an
|
|
1498
|
+
existing semantic token of the right $type. Acts ONLY on a prop that is BOTH a
|
|
1499
|
+
catalog PropSpec {type:token, token:<$type>} AND a tree {semantic.*} alias (the
|
|
1500
|
+
props _catalog_tree_violations passed LAYER-only); everything else is task 1/2's.
|
|
1501
|
+
Two codes: unresolved_prop_token · prop_token_type_mismatch.
|
|
1502
|
+
"""
|
|
1503
|
+
out: list[tuple[str, str, str]] = []
|
|
1504
|
+
sem_index = _semantic_token_index(tokens)
|
|
1505
|
+
components = catalog.get("components") if isinstance(catalog, dict) else None
|
|
1506
|
+
components = components if isinstance(components, dict) else {}
|
|
1507
|
+
elements = tree.get("elements") if isinstance(tree, dict) else None
|
|
1508
|
+
elements = elements if isinstance(elements, dict) else {}
|
|
1509
|
+
|
|
1510
|
+
for eid, el in elements.items():
|
|
1511
|
+
if not isinstance(el, dict):
|
|
1512
|
+
continue # malformed_element — _catalog_tree_violations' job
|
|
1513
|
+
etype = el.get("type")
|
|
1514
|
+
comp = components.get(etype) if isinstance(etype, str) else None
|
|
1515
|
+
if not isinstance(comp, dict):
|
|
1516
|
+
continue # uncataloged / malformed — already flagged there
|
|
1517
|
+
cprops = comp.get("props")
|
|
1518
|
+
cprops = cprops if isinstance(cprops, dict) else {}
|
|
1519
|
+
props = el.get("props")
|
|
1520
|
+
if not isinstance(props, dict):
|
|
1521
|
+
continue
|
|
1522
|
+
for pname, value in props.items():
|
|
1523
|
+
spec = cprops.get(pname)
|
|
1524
|
+
if not isinstance(spec, dict) or spec.get("type") != "token":
|
|
1525
|
+
continue # only catalog token-props
|
|
1526
|
+
if not (isinstance(value, str) and value.startswith("{") and value.endswith("}")):
|
|
1527
|
+
continue # non-alias literal → task-2's prop_type_mismatch
|
|
1528
|
+
target = value[1:-1]
|
|
1529
|
+
if target.split(".", 1)[0] != "semantic":
|
|
1530
|
+
continue # non-semantic alias → task-2's non_semantic_prop_token
|
|
1531
|
+
want = spec.get("token") # the declared $type
|
|
1532
|
+
if want not in _TOKEN_TYPES:
|
|
1533
|
+
continue # malformed token PropSpec → task-2's malformed_catalog owns it
|
|
1534
|
+
path = f"elements.{eid}.props.{pname}"
|
|
1535
|
+
if target not in sem_index:
|
|
1536
|
+
out.append(("unresolved_prop_token", path, f"{value} resolves to no semantic token"))
|
|
1537
|
+
continue
|
|
1538
|
+
got = sem_index[target] # the resolved token's inherited $type
|
|
1539
|
+
if got not in _TOKEN_TYPES:
|
|
1540
|
+
continue # resolved token's $type malformed → task-1's unknown_type owns it
|
|
1541
|
+
if got != want:
|
|
1542
|
+
out.append(("prop_token_type_mismatch", path,
|
|
1543
|
+
f"{value} is {got!r}, but prop wants {want!r}"))
|
|
1544
|
+
return out
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
def _udd_named_set_checks(root: Path) -> list[tuple[bool, str, str]]:
|
|
1548
|
+
"""Lint a project's UDD named set under `.add/design/` (silent when absent).
|
|
1549
|
+
|
|
1550
|
+
Composes _token_layer_violations + _catalog_tree_violations +
|
|
1551
|
+
_prop_token_resolution_violations into cmd_check's (ok, desc, reason) checks.
|
|
1552
|
+
READ-ONLY; FAIL-CLOSED on malformed JSON (a named code, never a crash). Returns
|
|
1553
|
+
[] when no named set exists — so a clean / non-UI project stays untouched.
|
|
1554
|
+
"""
|
|
1555
|
+
design = root / "design"
|
|
1556
|
+
tok_path, cat_path = design / "tokens.json", design / "catalog.json"
|
|
1557
|
+
proto_dir = design / "prototypes"
|
|
1558
|
+
trees = sorted(p for p in proto_dir.glob("*.json") if p.is_file()) if proto_dir.is_dir() else []
|
|
1559
|
+
if not (tok_path.exists() or cat_path.exists() or trees):
|
|
1560
|
+
return [] # silent-when-absent
|
|
1561
|
+
|
|
1562
|
+
def _load(p: Path) -> "tuple[object, str | None]":
|
|
1563
|
+
try:
|
|
1564
|
+
return json.loads(p.read_text(encoding="utf-8")), None
|
|
1565
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
1566
|
+
return None, str(e)
|
|
1567
|
+
|
|
1568
|
+
out: list[tuple[bool, str, str]] = []
|
|
1569
|
+
|
|
1570
|
+
tokens = None
|
|
1571
|
+
if tok_path.exists():
|
|
1572
|
+
tokens, err = _load(tok_path)
|
|
1573
|
+
if err is not None:
|
|
1574
|
+
out.append((False, "tokens.json parses", f"malformed_tokens_json: {err}"))
|
|
1575
|
+
tokens = None
|
|
1576
|
+
else:
|
|
1577
|
+
v = _token_layer_violations(tokens)
|
|
1578
|
+
if not v:
|
|
1579
|
+
out.append((True, "tokens.json layer-valid", ""))
|
|
1580
|
+
else:
|
|
1581
|
+
out += [(False, "tokens.json layer-valid", f"{c}: {p} — {d}") for c, p, d in v]
|
|
1582
|
+
|
|
1583
|
+
catalog = None
|
|
1584
|
+
if cat_path.exists():
|
|
1585
|
+
catalog, err = _load(cat_path)
|
|
1586
|
+
if err is not None:
|
|
1587
|
+
out.append((False, "catalog.json parses", f"malformed_catalog_json: {err}"))
|
|
1588
|
+
catalog = None
|
|
1589
|
+
|
|
1590
|
+
for tp in trees:
|
|
1591
|
+
name = tp.stem
|
|
1592
|
+
tree, err = _load(tp)
|
|
1593
|
+
if err is not None:
|
|
1594
|
+
out.append((False, f"prototype '{name}' parses", f"malformed_prototype_json: {err}"))
|
|
1595
|
+
continue
|
|
1596
|
+
if catalog is None:
|
|
1597
|
+
continue # no catalog to validate a tree against — skip quietly
|
|
1598
|
+
v = list(_catalog_tree_violations(catalog, tree))
|
|
1599
|
+
if tokens is not None:
|
|
1600
|
+
v += _prop_token_resolution_violations(tokens, catalog, tree)
|
|
1601
|
+
if not v:
|
|
1602
|
+
out.append((True, f"prototype '{name}' valid", ""))
|
|
1603
|
+
else:
|
|
1604
|
+
out += [(False, f"prototype '{name}' valid", f"{c}: {p} — {d}") for c, p, d in v]
|
|
1605
|
+
|
|
1606
|
+
return out
|
|
1607
|
+
|
|
1608
|
+
|
|
929
1609
|
def cmd_check(args: argparse.Namespace) -> None:
|
|
930
1610
|
"""Read-only integrity check of the .add project. Exit 1 if anything fails."""
|
|
931
1611
|
as_json = getattr(args, "json", False)
|
|
@@ -964,6 +1644,16 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
964
1644
|
# the intake flow — NOT a failure. Names structure, never the act of intake.
|
|
965
1645
|
warnings.append((f"task '{slug}'", "is outside a milestone — size it via the /add "
|
|
966
1646
|
"intake flow (or attach with --milestone)"))
|
|
1647
|
+
# autonomy level (task explicit-autonomy-dial): a REAL out-of-set token is a hard
|
|
1648
|
+
# unknown_autonomy_level; a LIVE task (phase before done/observe) with no `autonomy:`
|
|
1649
|
+
# line is implicit_autonomy — a WARN, never red. Done/observe predecessors are SKIPPED
|
|
1650
|
+
# (a fresh live-only predicate, NOT the audit open-front skip) so the board never floods.
|
|
1651
|
+
_alvl = _autonomy_level(_task_header(root, slug))
|
|
1652
|
+
checks.append((_alvl != "?", f"task '{slug}' autonomy level recognized",
|
|
1653
|
+
"unknown_autonomy_level (token outside manual|conservative|auto)"))
|
|
1654
|
+
if _alvl is None and t.get("phase") not in ("done", "observe"):
|
|
1655
|
+
warnings.append((f"task '{slug}'", "has no explicit autonomy level (implicit_autonomy) "
|
|
1656
|
+
"— run `add.py autonomy set <level>` to set it"))
|
|
967
1657
|
for dep in t.get("depends_on") or []:
|
|
968
1658
|
checks.append((dep in tasks or dep in archived_slugs,
|
|
969
1659
|
f"task '{slug}' dep '{dep}' resolves", "unknown task"))
|
|
@@ -985,6 +1675,31 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
985
1675
|
if lint_result is not None:
|
|
986
1676
|
ok, reason = lint_result
|
|
987
1677
|
checks.append((ok, f"task '{slug}' deltas well-formed", reason))
|
|
1678
|
+
# tamper tripwire standing monitor (verify-integrity): a non-done task whose
|
|
1679
|
+
# snapshot has diverged is surfaced EARLY — WARN, never red (the verify GATE
|
|
1680
|
+
# is where it bites, HARD-STOP). Fail-closed via _tripwire_divergence.
|
|
1681
|
+
if not _task_done(t):
|
|
1682
|
+
_tw = t.get("tripwire")
|
|
1683
|
+
if _tw and _tripwire_divergence(root, slug, _tw):
|
|
1684
|
+
warnings.append((f"task '{slug}'", "tampered since its tests->build "
|
|
1685
|
+
"snapshot (build_tampered) — a tracked test or the "
|
|
1686
|
+
"frozen §3 changed; the verify gate will HARD-STOP it"))
|
|
1687
|
+
# §5 scope standing monitor (build-scope-lock): a pending out-of-scope
|
|
1688
|
+
# touch (or a tampered baseline) surfaces EARLY — WARN, never red; the
|
|
1689
|
+
# verify gate is where it bites.
|
|
1690
|
+
_sc = t.get("scope")
|
|
1691
|
+
if isinstance(_sc, dict):
|
|
1692
|
+
_tamper, _out = _scope_findings(root, slug, _sc)
|
|
1693
|
+
if _tamper:
|
|
1694
|
+
warnings.append((f"task '{slug}'", "scope-snapshot.json is "
|
|
1695
|
+
f"{_tamper} against its anchor "
|
|
1696
|
+
"(scope_snapshot_tampered pending) — the verify "
|
|
1697
|
+
"gate will refuse it"))
|
|
1698
|
+
elif _out:
|
|
1699
|
+
warnings.append((f"task '{slug}'", "touched outside its declared "
|
|
1700
|
+
f"§5 Scope: {' · '.join(_out[:3])} "
|
|
1701
|
+
"(scope_violation pending) — the verify gate "
|
|
1702
|
+
"will refuse it"))
|
|
988
1703
|
|
|
989
1704
|
# drift: a done milestone must have no unfinished tasks
|
|
990
1705
|
for mslug, m in milestones.items():
|
|
@@ -994,11 +1709,69 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
994
1709
|
checks.append((not unfinished, f"done milestone '{mslug}' fully complete",
|
|
995
1710
|
f"unfinished: {unfinished}"))
|
|
996
1711
|
|
|
1712
|
+
# goal-auto-ready (task goal-auto-ready-gate): nudge the ACTIVE milestone toward a
|
|
1713
|
+
# machine-checkable goal — every exit criterion citing a verifier `(verify: …)` so the
|
|
1714
|
+
# engine can self-verify the result against it. WARN, NEVER red (measurement, not a gate);
|
|
1715
|
+
# fired IFF the goal HAS criteria but not all cite (total >= 1 AND cited < total) — a
|
|
1716
|
+
# zero-criteria milestone is shaping's nudge, not this one's. LIVE-ONLY: the OPEN active
|
|
1717
|
+
# milestone only — a done-but-not-yet-archived one (still the active pointer until
|
|
1718
|
+
# archive clears it) and closed/archived predecessors are never retro-flagged (Must #4).
|
|
1719
|
+
_active_ms = state.get("active_milestone")
|
|
1720
|
+
if _active_ms in milestones and milestones[_active_ms].get("status") != "done":
|
|
1721
|
+
_cited, _total = _exit_criteria_cited(root, _active_ms)
|
|
1722
|
+
if _total >= 1 and _cited < _total:
|
|
1723
|
+
warnings.append(("goal_not_auto_ready",
|
|
1724
|
+
f"milestone '{_active_ms}' goal not auto-ready "
|
|
1725
|
+
f"({_cited}/{_total} exit criteria cite a verifier) — add "
|
|
1726
|
+
"(verify: <test|command|metric>) to each bare criterion"))
|
|
1727
|
+
|
|
1728
|
+
# grounded (task ground-bundle-wiring): the freeze review checklist asks the human to
|
|
1729
|
+
# confirm the contract is grounded; this is the standing monitor for the gap. WARN, NEVER
|
|
1730
|
+
# red (measure-not-block, mirrors goal_not_auto_ready) — fires IFF the ACTIVE task's §3 is
|
|
1731
|
+
# FROZEN AND its §0 GROUND map is ungrounded (the precise "froze without grounding" gap, so
|
|
1732
|
+
# no nag during pre-freeze drafting). A pre-ground / legacy task (no §0 -> _grounded_state
|
|
1733
|
+
# None) is EXEMPT, never retro-flagged. Rides the existing `warnings` array — no new key.
|
|
1734
|
+
_at = state.get("active_task")
|
|
1735
|
+
if _at in tasks:
|
|
1736
|
+
_raw = _raw_phase_bodies(root, _at)
|
|
1737
|
+
if _contract_frozen(_raw.get(3, "")) and _grounded_state(_raw) is False:
|
|
1738
|
+
warnings.append(("task_not_grounded",
|
|
1739
|
+
f"task '{_at}' froze its contract without grounding — fill the "
|
|
1740
|
+
"§0 GROUND anchors the contract cites (add.py guide)"))
|
|
1741
|
+
|
|
1742
|
+
# wave-ledger fork-base (engine-merge-base-enforcement): the engine EXECUTES the
|
|
1743
|
+
# streams.md rule — every roster echo must match `base:`. A FILLED mismatch is red at
|
|
1744
|
+
# ANY status; a pending row is red at `status: merging` (merge-time strictness) but only
|
|
1745
|
+
# a WARN at `status: live` (measure-not-block: step-0 echoes land mid-wave). An
|
|
1746
|
+
# unparseable ledger is fail-closed (`wave_ledger_malformed`) — never a silent skip.
|
|
1747
|
+
for _wp in _wave_ledgers(root):
|
|
1748
|
+
_wm = _wp.parent.name
|
|
1749
|
+
_w = _parse_wave_ledger(_wp)
|
|
1750
|
+
if _w.get("error"):
|
|
1751
|
+
checks.append((False, f"wave '{_wm}' ledger parses",
|
|
1752
|
+
f"wave_ledger_malformed: {_w['error']}"))
|
|
1753
|
+
continue
|
|
1754
|
+
_bad = [r["task"] for r in _w["rows"] if r["filled"] and not r["matched"]]
|
|
1755
|
+
_pending = [r["task"] for r in _w["rows"] if not r["filled"]]
|
|
1756
|
+
if _w["status"] == "merging":
|
|
1757
|
+
_bad += _pending # merge-time strictness: pending == unverified
|
|
1758
|
+
_pending = []
|
|
1759
|
+
checks.append((not _bad, f"wave '{_wm}' fork-base echoes match base",
|
|
1760
|
+
"unverified_fork_base: " + ", ".join(_bad)))
|
|
1761
|
+
for _t in _pending:
|
|
1762
|
+
warnings.append(("fork_base_pending",
|
|
1763
|
+
f"wave '{_wm}' roster row '{_t}' awaits its step-0 echo"))
|
|
1764
|
+
|
|
997
1765
|
# dependency graph must be acyclic
|
|
998
1766
|
cycle = _find_cycle(tasks)
|
|
999
1767
|
checks.append((cycle is None, "task dependencies are acyclic",
|
|
1000
1768
|
f"cycle: {' -> '.join(cycle)}" if cycle else ""))
|
|
1001
1769
|
|
|
1770
|
+
# UDD foundation (udd-check-lint): lint a project's named set under .add/design/ —
|
|
1771
|
+
# composes the token + catalog/tree validators + the cross-file prop-token resolution.
|
|
1772
|
+
# Silent when absent; read-only; fail-closed on malformed JSON.
|
|
1773
|
+
checks.extend(_udd_named_set_checks(root))
|
|
1774
|
+
|
|
1002
1775
|
passed = sum(1 for ok, _, _ in checks if ok)
|
|
1003
1776
|
failed = len(checks) - passed
|
|
1004
1777
|
if as_json:
|
|
@@ -1022,6 +1795,144 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
1022
1795
|
raise SystemExit(1)
|
|
1023
1796
|
|
|
1024
1797
|
|
|
1798
|
+
# ---------------------------------------------------------------------------
|
|
1799
|
+
# wave-ledger fork-base enforcement (engine-merge-base-enforcement)
|
|
1800
|
+
#
|
|
1801
|
+
# streams.md states the rule; these helpers EXECUTE it (words-exist != method-works).
|
|
1802
|
+
# The ledger is the hand-written `.add/milestones/<m>/WAVE.md` per the streams.md
|
|
1803
|
+
# template: a `base: <sha>` line, a `status: live|merging` field on the header line,
|
|
1804
|
+
# and a `### Roster` table whose 3rd column holds the PASTED `rev-parse HEAD` echo.
|
|
1805
|
+
# Parsing is FAIL-CLOSED: anything off-grammar names the unparseable piece rather
|
|
1806
|
+
# than silently passing — a silent skip would un-guard the trust layer.
|
|
1807
|
+
|
|
1808
|
+
_WAVE_SHA_RE = re.compile(r"\b[0-9a-f]{7,40}\b")
|
|
1809
|
+
|
|
1810
|
+
|
|
1811
|
+
def _sha_match(a: str, b: str) -> bool:
|
|
1812
|
+
"""Exact or prefix match, both tokens >=7 hex chars (git short-sha tolerant)."""
|
|
1813
|
+
if len(a) < 7 or len(b) < 7:
|
|
1814
|
+
return False
|
|
1815
|
+
return a == b or a.startswith(b) or b.startswith(a)
|
|
1816
|
+
|
|
1817
|
+
|
|
1818
|
+
def _wave_ledgers(root: Path) -> list:
|
|
1819
|
+
"""Every live wave ledger, stable order (the same glob as the status hint)."""
|
|
1820
|
+
return sorted(p for p in (root / "milestones").glob("*/WAVE.md") if p.is_file())
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
def _parse_wave_ledger(path: Path) -> dict:
|
|
1824
|
+
"""Parse a WAVE.md against the streams.md template grammar. Fail-closed: a dict
|
|
1825
|
+
with an "error" key names exactly the piece that did not parse."""
|
|
1826
|
+
try:
|
|
1827
|
+
text = path.read_text(encoding="utf-8")
|
|
1828
|
+
except OSError as e:
|
|
1829
|
+
return {"error": f"unreadable ({e.__class__.__name__})"}
|
|
1830
|
+
# status is read ONLY from the FIRST `wave:` line — the header. Body text must
|
|
1831
|
+
# never rescue a malformed/invalid header: not free prose (heal-1 FG-2, an
|
|
1832
|
+
# unanchored search) and not a later wave:-prefixed line either (heal-2 FG-3 —
|
|
1833
|
+
# `(?m)^wave:.*?status:` happily skipped a status-less header to a body line).
|
|
1834
|
+
m_header = re.search(r"(?m)^wave:.*$", text)
|
|
1835
|
+
if not m_header:
|
|
1836
|
+
return {"error": "no 'wave:' header line"}
|
|
1837
|
+
# the status value is the EXACT token after `status:`, terminated only by
|
|
1838
|
+
# whitespace, the `·` separator, or end-of-line (v3): `\b` is not a token
|
|
1839
|
+
# terminator on hand-written input — it fires at `|` and `-`, so the unfilled
|
|
1840
|
+
# template placeholder `live|merging` (and drift like `live-ish`) parsed as
|
|
1841
|
+
# its valid prefix and greened an unfilled ledger (5th refute pass). The
|
|
1842
|
+
# `status:` label must itself START a field — start-of-line, whitespace, or
|
|
1843
|
+
# `·` before it (v4): an embedded `substatus:` is not a status field
|
|
1844
|
+
# (6th refute pass, N12).
|
|
1845
|
+
m_status = re.search(r"(?:^|[\s·])status:[ \t]*([^\s·]*)", m_header.group(0))
|
|
1846
|
+
if not m_status:
|
|
1847
|
+
return {"error": "no 'status: live|merging' on the wave: header line"}
|
|
1848
|
+
if m_status.group(1) not in ("live", "merging"):
|
|
1849
|
+
return {"error": "status token "
|
|
1850
|
+
f"{m_status.group(1)!r} is not exactly live or merging"}
|
|
1851
|
+
# base is read ONLY from the FIRST `base:` line, token on THAT line (heal-3 Pex:
|
|
1852
|
+
# `(?m)^base:\s*(\S+)` let \s cross the newline, so an EMPTY base: line parsed
|
|
1853
|
+
# as filled with whatever token the next line started with).
|
|
1854
|
+
m_base_line = re.search(r"(?m)^base:.*$", text)
|
|
1855
|
+
base = ""
|
|
1856
|
+
if m_base_line:
|
|
1857
|
+
m_tok = re.search(r"base:[ \t]*(\S+)", m_base_line.group(0))
|
|
1858
|
+
base = m_tok.group(1) if m_tok else ""
|
|
1859
|
+
if not re.fullmatch(r"[0-9a-f]{7,40}", base):
|
|
1860
|
+
return {"error": "no parseable 'base:' sha (7-40 hex)"}
|
|
1861
|
+
rows, in_roster, echo_col = [], False, None
|
|
1862
|
+
for line in text.splitlines():
|
|
1863
|
+
if line.startswith("### "):
|
|
1864
|
+
in_roster = line.lower().startswith("### roster")
|
|
1865
|
+
echo_col = None
|
|
1866
|
+
continue
|
|
1867
|
+
if not in_roster or not line.lstrip().startswith("|"):
|
|
1868
|
+
continue
|
|
1869
|
+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
1870
|
+
if echo_col is None:
|
|
1871
|
+
# the column-header row MUST name the fork-base column, and the echo is
|
|
1872
|
+
# read from WHEREVER that label sits (heal-3: a hardcoded cells[2] let an
|
|
1873
|
+
# extra leading column hide the echo, and a headerless roster silently
|
|
1874
|
+
# swallowed its first DATA row as the header — a silent skip, refused).
|
|
1875
|
+
# EXACTLY one label may match (v2 ambiguity refusal): first-wins on a
|
|
1876
|
+
# hand-written artifact is fail-open — a second matching label such as
|
|
1877
|
+
# "fork-base-prev" would steal the echo and green a mismatched roster
|
|
1878
|
+
# (4th refute pass, N1/N10).
|
|
1879
|
+
matches = [i for i, c in enumerate(cells) if "fork-base" in c.lower()]
|
|
1880
|
+
if not matches:
|
|
1881
|
+
return {"error": "roster column-header row names no 'fork-base' column"}
|
|
1882
|
+
if len(matches) > 1:
|
|
1883
|
+
labels = ", ".join(cells[i] for i in matches)
|
|
1884
|
+
return {"error": f"ambiguous fork-base columns: {labels}"}
|
|
1885
|
+
echo_col = matches[0]
|
|
1886
|
+
continue
|
|
1887
|
+
if all(set(c) <= set("-: ") for c in cells):
|
|
1888
|
+
continue # the |---| separator row
|
|
1889
|
+
if len(cells) <= echo_col:
|
|
1890
|
+
return {"error": f"roster row with no fork-base cell: {line.strip()!r}"}
|
|
1891
|
+
shas = _WAVE_SHA_RE.findall(cells[echo_col])
|
|
1892
|
+
# fail-closed cell semantics (heal-1 FG-1): the cell must BE the pasted echo,
|
|
1893
|
+
# so EVERY sha token in it must match base — `any()` would green a drift note
|
|
1894
|
+
# ("<alien-sha> synced-to <base-prefix>") that documents the very mismatch
|
|
1895
|
+
# this gate exists to refuse. One alien token -> the row is NOT verified.
|
|
1896
|
+
rows.append({"task": cells[0], "filled": bool(shas),
|
|
1897
|
+
"matched": bool(shas) and all(_sha_match(s, base) for s in shas)})
|
|
1898
|
+
if not rows:
|
|
1899
|
+
return {"error": "no roster row"}
|
|
1900
|
+
return {"status": m_status.group(1), "base": base, "rows": rows}
|
|
1901
|
+
|
|
1902
|
+
|
|
1903
|
+
def cmd_wave_verify(args: argparse.Namespace) -> None:
|
|
1904
|
+
"""The explicit merge-time gate: strict at any status, read-only, judgment-free.
|
|
1905
|
+
Exit 0 only when EVERY roster echo matches `base:` — run before the first
|
|
1906
|
+
merge-back. Never mutates the ledger, its status field, or state.json."""
|
|
1907
|
+
root = _require_root()
|
|
1908
|
+
if args.milestone:
|
|
1909
|
+
target = root / "milestones" / args.milestone / "WAVE.md"
|
|
1910
|
+
if not target.is_file():
|
|
1911
|
+
_die(f"wave_not_found: no WAVE.md for milestone '{args.milestone}'")
|
|
1912
|
+
else:
|
|
1913
|
+
ledgers = _wave_ledgers(root)
|
|
1914
|
+
if not ledgers:
|
|
1915
|
+
_die("wave_not_found: no WAVE.md under .add/milestones/ — nothing to verify")
|
|
1916
|
+
if len(ledgers) > 1:
|
|
1917
|
+
_die("wave_ambiguous: " + ", ".join(p.parent.name for p in ledgers)
|
|
1918
|
+
+ " — name one: add.py wave-verify <milestone>")
|
|
1919
|
+
target = ledgers[0]
|
|
1920
|
+
w = _parse_wave_ledger(target)
|
|
1921
|
+
if w.get("error"):
|
|
1922
|
+
_die(f"wave_ledger_malformed: {w['error']} ({target.parent.name}/WAVE.md)")
|
|
1923
|
+
bad = []
|
|
1924
|
+
for r in w["rows"]:
|
|
1925
|
+
verdict = "ok" if r["matched"] else ("MISMATCH" if r["filled"] else "PENDING")
|
|
1926
|
+
print(f" {r['task']}: {verdict}")
|
|
1927
|
+
if not r["matched"]:
|
|
1928
|
+
bad.append(r["task"])
|
|
1929
|
+
if bad:
|
|
1930
|
+
_die("unverified_fork_base: " + ", ".join(bad)
|
|
1931
|
+
+ f" — every roster echo must match base {w['base'][:12]} before merge-back")
|
|
1932
|
+
print(f"wave '{target.parent.name}' verified — every fork-base echo matches base "
|
|
1933
|
+
f"{w['base'][:12]}; merge-back may proceed (the ledger is untouched).")
|
|
1934
|
+
|
|
1935
|
+
|
|
1025
1936
|
def cmd_new_milestone(args: argparse.Namespace) -> None:
|
|
1026
1937
|
root = _require_root()
|
|
1027
1938
|
state = load_state(root)
|
|
@@ -1045,7 +1956,8 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
|
|
|
1045
1956
|
state["active_milestone"] = slug
|
|
1046
1957
|
save_state(root, state)
|
|
1047
1958
|
print(f"created milestone '{slug}' -> {mfile}")
|
|
1048
|
-
print(
|
|
1959
|
+
print("active milestone set.")
|
|
1960
|
+
print(_next_footer(root, state)) # converges the old "Decompose it into tasks: …" hint
|
|
1049
1961
|
|
|
1050
1962
|
|
|
1051
1963
|
def cmd_ready(args: argparse.Namespace) -> None:
|
|
@@ -1093,6 +2005,144 @@ def cmd_ready(args: argparse.Namespace) -> None:
|
|
|
1093
2005
|
print(f" {slug}{suffix}")
|
|
1094
2006
|
|
|
1095
2007
|
|
|
2008
|
+
def _wave_schedule(state: dict, mslug: str) -> dict:
|
|
2009
|
+
"""Pure, total: derive the DAG schedule for milestone `mslug` from state — never
|
|
2010
|
+
mutates, never raises on dict input. Returns one of:
|
|
2011
|
+
{"cycle": [slug, ...]} — unschedulable cycle
|
|
2012
|
+
{"waves", "critical_path", "critical_path_len", "tiers", "blocked"} — a schedule
|
|
2013
|
+
|
|
2014
|
+
A dep is SATISFIED (does not block) if it is archived or `_task_done` — the SAME
|
|
2015
|
+
predicate cmd_ready uses. A not-done dep that is an OPEN MEMBER of this milestone
|
|
2016
|
+
forces a later wave. A not-done dep that is NOT an open member (external/unknown)
|
|
2017
|
+
is UNSATISFIABLE here -> the task is `blocked`, never scheduled. Critical path is the
|
|
2018
|
+
longest chain (most tasks) through the scheduled sub-DAG; ties break by sorted slug.
|
|
2019
|
+
Tier is advisory: `top` on the critical path, `mid` elsewhere (scheduled tasks only)."""
|
|
2020
|
+
tasks = state.get("tasks") or {}
|
|
2021
|
+
archived = _archived_task_slugs(state)
|
|
2022
|
+
|
|
2023
|
+
def _ok(d: str) -> bool: # satisfied externally / already done
|
|
2024
|
+
return d in archived or (d in tasks and _task_done(tasks[d]))
|
|
2025
|
+
|
|
2026
|
+
open_members = {s: t for s, t in tasks.items()
|
|
2027
|
+
if t.get("milestone") == mslug and not _task_done(t)}
|
|
2028
|
+
|
|
2029
|
+
# partition open members into blocked vs schedulable — to a FIXED POINT, so blocking
|
|
2030
|
+
# propagates transitively: a task is blocked if any dep is unsatisfiable here, where
|
|
2031
|
+
# unsatisfiable = not _ok AND not a STILL-schedulable member. A dep on an already-blocked
|
|
2032
|
+
# member is itself unsatisfiable, so the dependent blocks too (it would otherwise be
|
|
2033
|
+
# mis-reported as wave-1-ready while its only dep can never complete).
|
|
2034
|
+
blocked: dict[str, list[str]] = {}
|
|
2035
|
+
changed = True
|
|
2036
|
+
while changed:
|
|
2037
|
+
changed = False
|
|
2038
|
+
for s, t in open_members.items():
|
|
2039
|
+
if s in blocked:
|
|
2040
|
+
continue
|
|
2041
|
+
bad = [d for d in (t.get("depends_on") or [])
|
|
2042
|
+
if not _ok(d) and not (d in open_members and d not in blocked)]
|
|
2043
|
+
if bad:
|
|
2044
|
+
blocked[s] = sorted(set(bad))
|
|
2045
|
+
changed = True
|
|
2046
|
+
schedulable = {s for s in open_members if s not in blocked}
|
|
2047
|
+
blocked_sorted = {k: blocked[k] for k in sorted(blocked)}
|
|
2048
|
+
if not schedulable:
|
|
2049
|
+
# nothing to schedule (all-done, empty, or every open task externally blocked)
|
|
2050
|
+
return {"waves": [], "critical_path": [], "critical_path_len": 0,
|
|
2051
|
+
"tiers": {}, "blocked": blocked_sorted}
|
|
2052
|
+
|
|
2053
|
+
def _member_deps(s: str) -> set[str]: # deps that are open members forcing order
|
|
2054
|
+
return {d for d in (open_members[s].get("depends_on") or []) if d in schedulable}
|
|
2055
|
+
|
|
2056
|
+
# Kahn waves over the schedulable sub-DAG
|
|
2057
|
+
waves: list[list[str]] = []
|
|
2058
|
+
placed: set[str] = set()
|
|
2059
|
+
remaining = set(schedulable)
|
|
2060
|
+
while remaining:
|
|
2061
|
+
wave = sorted(s for s in remaining if _member_deps(s) <= placed)
|
|
2062
|
+
if not wave: # no progress => a cycle among the remaining
|
|
2063
|
+
sub = {s: tasks[s] for s in remaining}
|
|
2064
|
+
cyc = _find_cycle(sub) or sorted(remaining)
|
|
2065
|
+
return {"cycle": cyc}
|
|
2066
|
+
waves.append(wave)
|
|
2067
|
+
placed.update(wave)
|
|
2068
|
+
remaining -= set(wave)
|
|
2069
|
+
|
|
2070
|
+
# critical path = longest chain by memoized depth over member-deps
|
|
2071
|
+
depth: dict[str, int] = {}
|
|
2072
|
+
pick: dict[str, str | None] = {}
|
|
2073
|
+
|
|
2074
|
+
def _depth(s: str) -> int:
|
|
2075
|
+
if s in depth:
|
|
2076
|
+
return depth[s]
|
|
2077
|
+
best_d, best_dep = 0, None
|
|
2078
|
+
for d in sorted(_member_deps(s)):
|
|
2079
|
+
dd = _depth(d)
|
|
2080
|
+
if dd > best_d or (dd == best_d and (best_dep is None or d < best_dep)):
|
|
2081
|
+
best_d, best_dep = dd, d
|
|
2082
|
+
depth[s] = 1 + best_d
|
|
2083
|
+
pick[s] = best_dep
|
|
2084
|
+
return depth[s]
|
|
2085
|
+
|
|
2086
|
+
leaf = min(schedulable, key=lambda s: (-_depth(s), s)) # deepest, tie -> smallest slug
|
|
2087
|
+
chain: list[str] = []
|
|
2088
|
+
cur: str | None = leaf
|
|
2089
|
+
while cur is not None:
|
|
2090
|
+
chain.append(cur)
|
|
2091
|
+
cur = pick.get(cur)
|
|
2092
|
+
critical = list(reversed(chain)) # root -> leaf order
|
|
2093
|
+
crit_set = set(critical)
|
|
2094
|
+
tiers = {s: ("top" if s in crit_set else "mid") for s in sorted(schedulable)}
|
|
2095
|
+
return {"waves": waves, "critical_path": critical, "critical_path_len": len(critical),
|
|
2096
|
+
"tiers": tiers, "blocked": blocked_sorted}
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
def cmd_waves(args: argparse.Namespace) -> None:
|
|
2100
|
+
"""READ-ONLY DAG scheduler: print the active milestone's topological waves, critical
|
|
2101
|
+
path, advisory tier hint, and blocked set. Writes nothing; emits no `next:` footer."""
|
|
2102
|
+
is_json = getattr(args, "json", False)
|
|
2103
|
+
if is_json:
|
|
2104
|
+
_, state = _load_state_for_json()
|
|
2105
|
+
else:
|
|
2106
|
+
state = load_state(_require_root())
|
|
2107
|
+
mslug = getattr(args, "milestone", None) or state.get("active_milestone")
|
|
2108
|
+
if not mslug:
|
|
2109
|
+
_die("no_active_milestone: no active milestone and no --milestone given")
|
|
2110
|
+
if mslug not in (state.get("milestones") or {}):
|
|
2111
|
+
_die(f"unknown_milestone: '{mslug}' is not a milestone in this project")
|
|
2112
|
+
sched = _wave_schedule(state, mslug)
|
|
2113
|
+
if "cycle" in sched:
|
|
2114
|
+
_die(f"dependency_cycle: not-done deps form a cycle "
|
|
2115
|
+
f"({' -> '.join(sched['cycle'])}) — no valid schedule")
|
|
2116
|
+
|
|
2117
|
+
if is_json:
|
|
2118
|
+
print(json.dumps({"milestone": mslug, **sched}))
|
|
2119
|
+
return
|
|
2120
|
+
|
|
2121
|
+
print(f"milestone: {mslug}")
|
|
2122
|
+
if not sched["waves"]:
|
|
2123
|
+
if sched["blocked"]:
|
|
2124
|
+
for s in sched["blocked"]:
|
|
2125
|
+
print(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
|
|
2126
|
+
else:
|
|
2127
|
+
print("all tasks done — nothing to schedule")
|
|
2128
|
+
return
|
|
2129
|
+
scheduled_set = {x for w in sched["waves"] for x in w}
|
|
2130
|
+
for i, wave in enumerate(sched["waves"], start=1):
|
|
2131
|
+
parts = []
|
|
2132
|
+
for s in wave:
|
|
2133
|
+
md = sorted(d for d in (state["tasks"][s].get("depends_on") or [])
|
|
2134
|
+
if d in scheduled_set)
|
|
2135
|
+
parts.append(f"{s} (deps: {', '.join(md)})" if md else s)
|
|
2136
|
+
print(f"wave {i}: {', '.join(parts)}")
|
|
2137
|
+
crit = sched["critical_path"]
|
|
2138
|
+
print(f"critical path: {' → '.join(crit)} ({sched['critical_path_len']} tasks)")
|
|
2139
|
+
tops = [s for s, tier in sched["tiers"].items() if tier == "top"]
|
|
2140
|
+
mids = [s for s, tier in sched["tiers"].items() if tier == "mid"]
|
|
2141
|
+
print(f"tier hint: top → {', '.join(tops)}; mid → {', '.join(mids) or '(none)'}")
|
|
2142
|
+
for s in sched["blocked"]:
|
|
2143
|
+
print(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
|
|
2144
|
+
|
|
2145
|
+
|
|
1096
2146
|
def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
1097
2147
|
root = _require_root()
|
|
1098
2148
|
state = load_state(root)
|
|
@@ -1134,13 +2184,14 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
|
1134
2184
|
tail = f" ({len(waived)} via a signed RISK-ACCEPTED waiver)" if waived else ""
|
|
1135
2185
|
print(f"milestone '{slug}' -> done ({len(members)} tasks complete{tail}).")
|
|
1136
2186
|
print(f"wrote {retro_path.relative_to(root.parent)} (milestone exit report)")
|
|
1137
|
-
print("Confirm the MILESTONE.md exit criteria are checked, then archive/start the next.")
|
|
1138
2187
|
# fold-pressure nudge: milestone close is the natural fold point for open deltas (v11)
|
|
1139
2188
|
open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
|
|
1140
2189
|
if open_deltas:
|
|
1141
2190
|
noun = "delta" if open_deltas == 1 else "deltas"
|
|
1142
2191
|
print(f"note: {open_deltas} open {noun} to consolidate into the foundation "
|
|
1143
2192
|
f"— review with: add.py deltas")
|
|
2193
|
+
# the engine-sourced next step (converges the old "Confirm … archive/start the next" hint)
|
|
2194
|
+
print(_next_footer(root, state))
|
|
1144
2195
|
|
|
1145
2196
|
|
|
1146
2197
|
def cmd_archive_milestone(args: argparse.Namespace) -> None:
|
|
@@ -1193,6 +2244,7 @@ def cmd_archive_milestone(args: argparse.Namespace) -> None:
|
|
|
1193
2244
|
save_state(root, state)
|
|
1194
2245
|
print(f"archived milestone '{slug}' ({len(members)} tasks) — removed from active state.")
|
|
1195
2246
|
print("files on disk are untouched; see `add.py status` for the archived rollup.")
|
|
2247
|
+
print(_next_footer(root, state))
|
|
1196
2248
|
|
|
1197
2249
|
|
|
1198
2250
|
def cmd_compact(args: argparse.Namespace) -> None:
|
|
@@ -1257,6 +2309,7 @@ def cmd_compact(args: argparse.Namespace) -> None:
|
|
|
1257
2309
|
for path, n in moved:
|
|
1258
2310
|
print(f" moved {path} ({n} files)")
|
|
1259
2311
|
print("recovery: reverse the moves (mv the bundle's parts back) — state needs no edit.")
|
|
2312
|
+
print(_next_footer(root, state))
|
|
1260
2313
|
|
|
1261
2314
|
|
|
1262
2315
|
def cmd_set_milestone(args: argparse.Namespace) -> None:
|
|
@@ -1275,6 +2328,7 @@ def cmd_set_milestone(args: argparse.Namespace) -> None:
|
|
|
1275
2328
|
state["tasks"][task]["updated"] = _now()
|
|
1276
2329
|
save_state(root, state)
|
|
1277
2330
|
print(f"task '{task}' -> milestone '{new}'" if new else f"task '{task}' -> milestone (none)")
|
|
2331
|
+
print(_next_footer(root, state))
|
|
1278
2332
|
|
|
1279
2333
|
|
|
1280
2334
|
def cmd_use(args: argparse.Namespace) -> None:
|
|
@@ -1289,6 +2343,7 @@ def cmd_use(args: argparse.Namespace) -> None:
|
|
|
1289
2343
|
state["active_task"] = slug
|
|
1290
2344
|
save_state(root, state)
|
|
1291
2345
|
print(f"active task -> '{slug}' (phase={state['tasks'][slug]['phase']})")
|
|
2346
|
+
print(_next_footer(root, state))
|
|
1292
2347
|
|
|
1293
2348
|
|
|
1294
2349
|
def _find_cycle(tasks: dict) -> list[str] | None:
|
|
@@ -1370,7 +2425,7 @@ def _bar(num: int, den: int, cells: int, g: dict) -> str:
|
|
|
1370
2425
|
|
|
1371
2426
|
|
|
1372
2427
|
def _phase_track(phase: str, g: dict) -> str:
|
|
1373
|
-
"""Compact
|
|
2428
|
+
"""Compact 9-cell pipeline (no labels — a single legend explains it):
|
|
1374
2429
|
reached · current · pending. A done task -> all reached."""
|
|
1375
2430
|
try:
|
|
1376
2431
|
ci = PHASES.index(phase)
|
|
@@ -1434,6 +2489,27 @@ def _project_goal(root: Path) -> str:
|
|
|
1434
2489
|
return GOAL_UNSET
|
|
1435
2490
|
|
|
1436
2491
|
|
|
2492
|
+
def _project_autonomy_token(root: Path):
|
|
2493
|
+
"""The RAW autonomy declaration in PROJECT.md — a recognized rung, None when no
|
|
2494
|
+
declaration line is present, or "?" for a real-but-unrecognized token. Uses the
|
|
2495
|
+
anchored _autonomy_level (a title/prose substring is never a declaration) with
|
|
2496
|
+
HTML comments stripped. Unreadable foundation -> None. Read-only and PURE."""
|
|
2497
|
+
try:
|
|
2498
|
+
text = (root / "PROJECT.md").read_text(encoding="utf-8")
|
|
2499
|
+
except OSError:
|
|
2500
|
+
return None
|
|
2501
|
+
return _autonomy_level(re.sub(r"<!--.*?-->", "", text, flags=re.S))
|
|
2502
|
+
|
|
2503
|
+
|
|
2504
|
+
def _project_autonomy(root: Path) -> str:
|
|
2505
|
+
"""The autonomy rung a new task INHERITS from the project default. Fail-SAFE:
|
|
2506
|
+
no declaration -> "auto" (the method default; v7: absent = auto); an unrecognized
|
|
2507
|
+
token -> "conservative" (NEVER silently "auto"); an unreadable foundation -> "auto".
|
|
2508
|
+
Read-only and PURE — mirrors _project_goal; the seed source for cmd_new_task."""
|
|
2509
|
+
tok = _project_autonomy_token(root)
|
|
2510
|
+
return "auto" if tok is None else ("conservative" if tok == "?" else tok)
|
|
2511
|
+
|
|
2512
|
+
|
|
1437
2513
|
def _milestone_doc(root: Path, mslug: str) -> tuple[str, str]:
|
|
1438
2514
|
"""(title, goal) from MILESTONE.md; ('(unknown)','(unknown)') if the doc is gone."""
|
|
1439
2515
|
f = root / "milestones" / mslug / MILESTONE_FILE
|
|
@@ -1463,6 +2539,41 @@ def _exit_criteria(root: Path, mslug: str) -> tuple[int, int]:
|
|
|
1463
2539
|
return met, total
|
|
1464
2540
|
|
|
1465
2541
|
|
|
2542
|
+
# A non-empty `(verify: <citation>)` on an exit-criterion line — at least one non-whitespace
|
|
2543
|
+
# char inside, so a bare `(verify:)`/`(verify: )` does NOT count (the mid-text substring trap).
|
|
2544
|
+
_VERIFY_CITE_RE = re.compile(r"\(verify:\s*\S.*?\)", re.I)
|
|
2545
|
+
|
|
2546
|
+
|
|
2547
|
+
def _exit_criteria_cited(root: Path, mslug: str) -> tuple[int, int]:
|
|
2548
|
+
"""(cited, total) over MILESTONE.md's 'Exit criteria' section. total = every
|
|
2549
|
+
`- [ ]`/`- [x]` criterion line; cited = those carrying a NON-EMPTY
|
|
2550
|
+
`(verify: <citation>)`. Read-only and PURE; missing file/section -> (0, 0).
|
|
2551
|
+
Mirrors _exit_criteria (the checkbox tally) — an ADDITIVE classification beside
|
|
2552
|
+
it; it never touches `milestone_goal_unmet`."""
|
|
2553
|
+
f = root / "milestones" / mslug / MILESTONE_FILE
|
|
2554
|
+
if not f.exists():
|
|
2555
|
+
return 0, 0
|
|
2556
|
+
m = re.search(r"## Exit criteria.*?(?=\n## |\Z)", f.read_text(encoding="utf-8"), re.S)
|
|
2557
|
+
if not m:
|
|
2558
|
+
return 0, 0
|
|
2559
|
+
cited = total = 0
|
|
2560
|
+
for ln in m.group(0).splitlines():
|
|
2561
|
+
if re.match(r"\s*- \[[ x]\]", ln):
|
|
2562
|
+
total += 1
|
|
2563
|
+
if _VERIFY_CITE_RE.search(ln):
|
|
2564
|
+
cited += 1
|
|
2565
|
+
return cited, total
|
|
2566
|
+
|
|
2567
|
+
|
|
2568
|
+
def _goal_auto_ready(root: Path, mslug: str) -> bool:
|
|
2569
|
+
"""True iff the milestone goal is AUTO-READY: its Exit criteria has >= 1 criterion
|
|
2570
|
+
AND every one cites a verifier (cited == total) — so the engine can self-verify the
|
|
2571
|
+
result against the goal without human judgement. A zero-criteria goal is NOT
|
|
2572
|
+
auto-ready (you cannot self-verify against nothing). PURE."""
|
|
2573
|
+
cited, total = _exit_criteria_cited(root, mslug)
|
|
2574
|
+
return total >= 1 and cited == total
|
|
2575
|
+
|
|
2576
|
+
|
|
1466
2577
|
def _stage_criteria(root: Path) -> tuple[int, int]:
|
|
1467
2578
|
"""(met, total) checkbox tally inside PROJECT.md's 'Stage goal criteria' section — the
|
|
1468
2579
|
PROJECT.md analog of _exit_criteria (v22): the human's stage-covered affirmation. Read-only
|
|
@@ -1507,11 +2618,17 @@ def _count_test_defs(f: Path) -> int:
|
|
|
1507
2618
|
return 0
|
|
1508
2619
|
|
|
1509
2620
|
|
|
1510
|
-
def
|
|
2621
|
+
def _primary_test_files(root: Path, slug: str) -> list[Path]:
|
|
2622
|
+
"""The PRIMARY test set — *.py directly in the task's tests/ dir (the stable
|
|
2623
|
+
path). A list so the tamper tripwire can hash exactly what the engine counts."""
|
|
1511
2624
|
d = root / "tasks" / slug / "tests"
|
|
1512
2625
|
if not d.is_dir():
|
|
1513
|
-
return
|
|
1514
|
-
return
|
|
2626
|
+
return []
|
|
2627
|
+
return sorted(d.glob("*.py"))
|
|
2628
|
+
|
|
2629
|
+
|
|
2630
|
+
def _tests_count(root: Path, slug: str) -> int:
|
|
2631
|
+
return sum(_count_test_defs(f) for f in _primary_test_files(root, slug))
|
|
1515
2632
|
|
|
1516
2633
|
|
|
1517
2634
|
def _confined(p: Path, rootp: Path) -> bool:
|
|
@@ -1523,18 +2640,18 @@ def _confined(p: Path, rootp: Path) -> bool:
|
|
|
1523
2640
|
return False
|
|
1524
2641
|
|
|
1525
2642
|
|
|
1526
|
-
def
|
|
1527
|
-
"""
|
|
2643
|
+
def _declared_test_files(root: Path, slug: str) -> list[Path]:
|
|
2644
|
+
"""Resolve the §4 'Tests live in:' declared path(s) to a deduped file list. PURE.
|
|
1528
2645
|
Tokens are the backticked spans on the FIRST declaring line of the raw §4 body.
|
|
1529
2646
|
Resolution: './…' -> task dir · contains '/' -> project root (parent of .add) ·
|
|
1530
2647
|
bare name -> sibling of the previous resolved token (else task dir). A directory
|
|
1531
|
-
token
|
|
1532
|
-
v2 confinement: every
|
|
1533
|
-
|
|
2648
|
+
token yields the *.py files directly inside it; resolved files are deduped.
|
|
2649
|
+
v2 confinement: every path must resolve inside the project root — '..' traversal,
|
|
2650
|
+
absolute tokens, and symlink escapes are all dropped, fail-closed."""
|
|
1534
2651
|
body = _raw_phase_bodies(root, slug).get(4, "")
|
|
1535
2652
|
m = re.search(r"^\s*Tests live in:.*$", body, re.M)
|
|
1536
2653
|
if not m:
|
|
1537
|
-
return
|
|
2654
|
+
return []
|
|
1538
2655
|
tdir = root / "tasks" / slug
|
|
1539
2656
|
rootp = root.parent.resolve()
|
|
1540
2657
|
files: list[Path] = []
|
|
@@ -1560,7 +2677,12 @@ def _declared_tests_count(root: Path, slug: str) -> int:
|
|
|
1560
2677
|
except OSError:
|
|
1561
2678
|
continue
|
|
1562
2679
|
files.extend(f for f in cand if f not in files)
|
|
1563
|
-
return
|
|
2680
|
+
return files
|
|
2681
|
+
|
|
2682
|
+
|
|
2683
|
+
def _declared_tests_count(root: Path, slug: str) -> int:
|
|
2684
|
+
"""Count tests at the §4 'Tests live in:' declared path(s). PURE, fail-closed 0."""
|
|
2685
|
+
return sum(_count_test_defs(f) for f in _declared_test_files(root, slug))
|
|
1564
2686
|
|
|
1565
2687
|
|
|
1566
2688
|
def _tests_info(root: Path, slug: str) -> tuple[int, bool]:
|
|
@@ -1574,6 +2696,279 @@ def _tests_info(root: Path, slug: str) -> tuple[int, bool]:
|
|
|
1574
2696
|
return (declared, True) if declared > 0 else (0, False)
|
|
1575
2697
|
|
|
1576
2698
|
|
|
2699
|
+
def _resolved_test_files(root: Path, slug: str) -> list[Path]:
|
|
2700
|
+
"""The file set the engine treats as this task's tests — the PRIMARY set wins
|
|
2701
|
+
when it yields any test defs, else the §4-declared set (mirrors _tests_info's
|
|
2702
|
+
selection). The tamper tripwire hashes exactly THIS set, never a fresh glob."""
|
|
2703
|
+
primary = _primary_test_files(root, slug)
|
|
2704
|
+
if sum(_count_test_defs(f) for f in primary) > 0:
|
|
2705
|
+
return primary
|
|
2706
|
+
return _declared_test_files(root, slug)
|
|
2707
|
+
|
|
2708
|
+
|
|
2709
|
+
def _md5_text(s: str) -> str:
|
|
2710
|
+
return hashlib.md5(s.encode("utf-8")).hexdigest()
|
|
2711
|
+
|
|
2712
|
+
|
|
2713
|
+
def _md5_file(p: Path) -> str | None:
|
|
2714
|
+
"""md5 of a file's bytes; None on ANY read error (fail-closed — a tracked file
|
|
2715
|
+
that cannot be read counts as DIVERGED at the gate, never a crash)."""
|
|
2716
|
+
try:
|
|
2717
|
+
return hashlib.md5(p.read_bytes()).hexdigest()
|
|
2718
|
+
except OSError:
|
|
2719
|
+
return None
|
|
2720
|
+
|
|
2721
|
+
|
|
2722
|
+
def _tripwire_snapshot(root: Path, slug: str, raw3: str) -> dict:
|
|
2723
|
+
"""Freeze the md5 of the resolved red test files + the frozen §3 contract — the
|
|
2724
|
+
tamper baseline (verify-integrity). Keys are project-root-relative paths (stable
|
|
2725
|
+
across the snapshot->gate window). Tool-agnostic: hashes bytes only, never runs
|
|
2726
|
+
tests or measures coverage."""
|
|
2727
|
+
rootp = root.parent.resolve()
|
|
2728
|
+
tests: dict[str, str] = {}
|
|
2729
|
+
for f in _resolved_test_files(root, slug):
|
|
2730
|
+
h = _md5_file(f)
|
|
2731
|
+
if h is None:
|
|
2732
|
+
continue
|
|
2733
|
+
try:
|
|
2734
|
+
rel = str(f.resolve().relative_to(rootp))
|
|
2735
|
+
except (ValueError, OSError):
|
|
2736
|
+
rel = str(f)
|
|
2737
|
+
tests[rel] = h
|
|
2738
|
+
return {"contract_md5": _md5_text(raw3), "tests": tests}
|
|
2739
|
+
|
|
2740
|
+
|
|
2741
|
+
def _tripwire_divergence(root: Path, slug: str, tw: dict) -> list[str]:
|
|
2742
|
+
"""Tamper codes for a PRESENT snapshot; [] means clean. Re-reads each tracked
|
|
2743
|
+
path directly (never re-globs), so a weakened, deleted, or unreadable test file
|
|
2744
|
+
and an edited frozen §3 all surface. Fail-closed: an unreadable file -> diverged."""
|
|
2745
|
+
diffs: list[str] = []
|
|
2746
|
+
if _md5_text(_raw_phase_bodies(root, slug).get(3, "")) != tw.get("contract_md5"):
|
|
2747
|
+
diffs.append("contract_tampered")
|
|
2748
|
+
rootp = root.parent.resolve()
|
|
2749
|
+
for rel, snap in (tw.get("tests") or {}).items():
|
|
2750
|
+
if _md5_file(rootp / rel) != snap:
|
|
2751
|
+
diffs.append(f"build_tampered:{rel}")
|
|
2752
|
+
return diffs
|
|
2753
|
+
|
|
2754
|
+
|
|
2755
|
+
# ── §5 scope gate (build-scope-lock): touched ⊆ declared, from bytes alone ──────────
|
|
2756
|
+
# The walk's NAMED exclusion set — ONE constant; widening it is an additive
|
|
2757
|
+
# change-request, never silent. `.add` is engine domain (tripwire + audit guard it);
|
|
2758
|
+
# the rest is VCS/bytecode/OS junk with no build signal.
|
|
2759
|
+
_SCOPE_EXCLUDE_DIRS = (".git", ".add", "__pycache__", "node_modules")
|
|
2760
|
+
_SCOPE_EXCLUDE_FILES = (".DS_Store",) # plus *.pyc by suffix
|
|
2761
|
+
|
|
2762
|
+
|
|
2763
|
+
def _declared_scope(root: Path, slug: str) -> list[str] | None:
|
|
2764
|
+
"""Resolve the §5 'Scope (may touch):' declaration to project-root-relative
|
|
2765
|
+
strings (directory tokens keep a trailing '/'). The frozen scope-decl-template
|
|
2766
|
+
grammar: the §4 token rules — backticked spans on the FIRST declaring line ·
|
|
2767
|
+
'./…' -> task dir · contains '/' -> project root · bare -> sibling of the
|
|
2768
|
+
previous token's dir · v2 confinement drops everything outside the project
|
|
2769
|
+
root, fail-closed — with ONE divergence: a directory token covers its WHOLE
|
|
2770
|
+
subtree (containment, judged by _in_scope). None = no Scope line (UNDECLARED,
|
|
2771
|
+
grandfathered — never retro-red); [] = a line whose every token was dropped
|
|
2772
|
+
(a garbage declaration grants NO cover)."""
|
|
2773
|
+
body = _raw_phase_bodies(root, slug).get(5, "")
|
|
2774
|
+
m = re.search(r"^\s*Scope \(may touch\):.*$", body, re.M)
|
|
2775
|
+
if not m:
|
|
2776
|
+
return None
|
|
2777
|
+
tdir = root / "tasks" / slug
|
|
2778
|
+
rootp = root.parent.resolve()
|
|
2779
|
+
out: list[str] = []
|
|
2780
|
+
prev_dir = None
|
|
2781
|
+
for tok in re.findall(r"`([^`]+)`", m.group(0)):
|
|
2782
|
+
tok = tok.strip()
|
|
2783
|
+
if tok.startswith("./"):
|
|
2784
|
+
p = tdir / tok[2:]
|
|
2785
|
+
elif "/" in tok:
|
|
2786
|
+
p = root.parent / tok
|
|
2787
|
+
else:
|
|
2788
|
+
p = (prev_dir or tdir) / tok
|
|
2789
|
+
try:
|
|
2790
|
+
if not _confined(p, rootp):
|
|
2791
|
+
continue
|
|
2792
|
+
rp = p.resolve()
|
|
2793
|
+
rel = str(rp.relative_to(rootp))
|
|
2794
|
+
if tok.endswith("/") or rp.is_dir():
|
|
2795
|
+
prev_dir, rel = p, rel.rstrip("/") + "/"
|
|
2796
|
+
else:
|
|
2797
|
+
prev_dir = p.parent
|
|
2798
|
+
except OSError:
|
|
2799
|
+
continue
|
|
2800
|
+
if rel not in out:
|
|
2801
|
+
out.append(rel)
|
|
2802
|
+
return out
|
|
2803
|
+
|
|
2804
|
+
|
|
2805
|
+
def _in_scope(rel: str, declared: list[str]) -> bool:
|
|
2806
|
+
"""True when rel falls under any declared token — exact match for a file
|
|
2807
|
+
token, whole-subtree prefix containment for a directory token ('…/')."""
|
|
2808
|
+
for tok in declared:
|
|
2809
|
+
if tok.endswith("/"):
|
|
2810
|
+
if rel.startswith(tok) or rel == tok.rstrip("/"):
|
|
2811
|
+
return True
|
|
2812
|
+
elif rel == tok:
|
|
2813
|
+
return True
|
|
2814
|
+
return False
|
|
2815
|
+
|
|
2816
|
+
|
|
2817
|
+
def _scope_walk(rootp: Path) -> dict[str, str]:
|
|
2818
|
+
"""{project-root-relative path: md5} over the project tree, pruning
|
|
2819
|
+
_SCOPE_EXCLUDE_DIRS at any depth and skipping bytecode/OS junk. A file
|
|
2820
|
+
unreadable at SNAPSHOT time is skipped; at the GATE the resulting absence
|
|
2821
|
+
reads as a touch (fail-closed at the biting end). Bytes only — no git."""
|
|
2822
|
+
files: dict[str, str] = {}
|
|
2823
|
+
for dirpath, dirnames, filenames in os.walk(rootp):
|
|
2824
|
+
dirnames[:] = [d for d in dirnames if d not in _SCOPE_EXCLUDE_DIRS]
|
|
2825
|
+
for name in filenames:
|
|
2826
|
+
if name in _SCOPE_EXCLUDE_FILES or name.endswith(".pyc"):
|
|
2827
|
+
continue
|
|
2828
|
+
p = Path(dirpath) / name
|
|
2829
|
+
h = _md5_file(p)
|
|
2830
|
+
if h is None:
|
|
2831
|
+
continue
|
|
2832
|
+
try:
|
|
2833
|
+
files[str(p.relative_to(rootp))] = h
|
|
2834
|
+
except ValueError:
|
|
2835
|
+
continue
|
|
2836
|
+
return files
|
|
2837
|
+
|
|
2838
|
+
|
|
2839
|
+
def _scope_findings(root: Path, slug: str, anchor: dict) -> tuple[str | None, list[str]]:
|
|
2840
|
+
"""(tamper_reason, out_of_scope_touches) for a scope-anchored task. PURE read.
|
|
2841
|
+
The sidecar is integrity-checked against the state.json anchor BEFORE it is
|
|
2842
|
+
trusted; touched = modified ∪ added ∪ deleted vs the snapshot."""
|
|
2843
|
+
side = root / "tasks" / slug / "scope-snapshot.json"
|
|
2844
|
+
try:
|
|
2845
|
+
raw = side.read_text(encoding="utf-8")
|
|
2846
|
+
except OSError:
|
|
2847
|
+
return "missing", []
|
|
2848
|
+
if _md5_text(raw) != anchor.get("snapshot_md5"):
|
|
2849
|
+
return "diverged", []
|
|
2850
|
+
try:
|
|
2851
|
+
snap = json.loads(raw).get("files", {})
|
|
2852
|
+
except (ValueError, AttributeError):
|
|
2853
|
+
return "unparseable", []
|
|
2854
|
+
if not isinstance(snap, dict):
|
|
2855
|
+
return "unparseable", []
|
|
2856
|
+
now = _scope_walk(root.parent.resolve())
|
|
2857
|
+
touched = sorted({k for k, v in snap.items() if now.get(k) != v}
|
|
2858
|
+
| {k for k in now if k not in snap})
|
|
2859
|
+
declared = anchor.get("declared") or []
|
|
2860
|
+
return None, [p for p in touched if not _in_scope(p, declared)]
|
|
2861
|
+
|
|
2862
|
+
|
|
2863
|
+
def _scope_guard(root: Path, state: dict, slug: str) -> None:
|
|
2864
|
+
"""Refuse a COMPLETING gate when the build touched outside its declared §5
|
|
2865
|
+
Scope (build-scope-lock). The anchor (state.json) and the sidecar co-witness
|
|
2866
|
+
each other — born in the same tests->build crossing, so EITHER single-file
|
|
2867
|
+
erase is caught (v2, refute-driven): an anchor-less task whose sidecar still
|
|
2868
|
+
EXISTS is scope_anchor_missing, never a silent skip. Both absent -> UNDECLARED
|
|
2869
|
+
or legacy: silent, the grandfather rule (the simultaneous two-file erase is
|
|
2870
|
+
the explicitly accepted floor — the tripwire shares it). Sits directly after
|
|
2871
|
+
_tamper_guard, BEFORE the waiver write, so a violation is never launderable
|
|
2872
|
+
through RISK-ACCEPTED; HARD-STOP never calls it (stopping is always allowed).
|
|
2873
|
+
|
|
2874
|
+
Routing (scope-violation-heal, build-scope-lock 3/3) — tripwire-parity: the
|
|
2875
|
+
RECOVERABLE findings (an out-of-scope touch, a present-but-wrong sidecar) are
|
|
2876
|
+
fixable from BUILD, so they enter the SAME bounded self-heal loop the tamper
|
|
2877
|
+
tripwire uses (_heal_or_escalate, shared HEAL_CAP) — return to build for an
|
|
2878
|
+
honest redo (exit 3), then HARD-STOP at the cap. The ERASED baselines stay
|
|
2879
|
+
die-in-place (exit 1, no heal): a redo cannot recreate an erased anchor or a
|
|
2880
|
+
deleted sidecar — that is tripwire_missing parity. Every heal reason CARRIES
|
|
2881
|
+
its named code, so the existing refusal-token assertions still match."""
|
|
2882
|
+
anchor = state["tasks"][slug].get("scope")
|
|
2883
|
+
if not isinstance(anchor, dict):
|
|
2884
|
+
if (root / "tasks" / slug / "scope-snapshot.json").exists():
|
|
2885
|
+
_die(f"scope_anchor_missing: task '{slug}' carries a scope-snapshot.json "
|
|
2886
|
+
"but no state.json anchor — the touch baseline was erased from "
|
|
2887
|
+
"state; re-establish it (re-advance through tests->build) before "
|
|
2888
|
+
"completing")
|
|
2889
|
+
return
|
|
2890
|
+
tamper, out = _scope_findings(root, slug, anchor)
|
|
2891
|
+
if tamper == "missing":
|
|
2892
|
+
# erased baseline — a redo cannot recreate the evidence (tripwire_missing parity)
|
|
2893
|
+
_die(f"scope_snapshot_tampered: task '{slug}' — scope-snapshot.json is "
|
|
2894
|
+
"missing against its state.json anchor; the touch baseline is "
|
|
2895
|
+
"evidence and must survive the build untouched")
|
|
2896
|
+
if tamper:
|
|
2897
|
+
# diverged | unparseable — present-but-wrong bytes are revertable from build
|
|
2898
|
+
_heal_or_escalate(root, state, slug, source="scope-tamper",
|
|
2899
|
+
reason=(f"scope_snapshot_tampered: task '{slug}' — "
|
|
2900
|
+
f"scope-snapshot.json is {tamper} against its "
|
|
2901
|
+
"state.json anchor; revert it to the snapshot bytes"))
|
|
2902
|
+
if out:
|
|
2903
|
+
shown = " · ".join(out[:5])
|
|
2904
|
+
_heal_or_escalate(root, state, slug, source="scope",
|
|
2905
|
+
reason=(f"scope_violation: task '{slug}' touched outside its "
|
|
2906
|
+
f"declared §5 Scope — {shown} ({len(out)} total)"))
|
|
2907
|
+
|
|
2908
|
+
|
|
2909
|
+
def _heal_or_escalate(root: Path, state: dict, slug: str, *, reason: str, source: str) -> None:
|
|
2910
|
+
"""The bounded self-heal router (verify-integrity, heal-then-escalate). Called ONLY when
|
|
2911
|
+
a cheat is CONFIRMED at this point — mechanical (tripwire divergence, source "tamper") or
|
|
2912
|
+
semantic (an agent-reported refute-read finding, source "refute-read").
|
|
2913
|
+
|
|
2914
|
+
attempts < HEAL_CAP -> record the attempt, return the task to BUILD for an honest redo,
|
|
2915
|
+
exit 3 (a redo signal, NOT a completing outcome). The phase is set DIRECTLY (never via
|
|
2916
|
+
advance) so the tripwire baseline is not re-snapshotted mid-loop. The increment is saved
|
|
2917
|
+
BEFORE the exit, so a re-run never grants a free attempt (atomic, fail-closed).
|
|
2918
|
+
|
|
2919
|
+
attempts >= HEAL_CAP -> the next confirmed cheat: record gate = HARD-STOP and escalate to
|
|
2920
|
+
the human (_die). A gamed green is NEVER auto-passed; the loop is never unbounded. The
|
|
2921
|
+
counter is MONOTONIC — it never auto-resets (cmd_phase is unguarded, so a reset would be a
|
|
2922
|
+
zero-human cap bypass)."""
|
|
2923
|
+
t = state["tasks"][slug]
|
|
2924
|
+
heal = t.setdefault("heal", {"attempts": 0, "history": []})
|
|
2925
|
+
entry = {"at": _now(), "reason": reason, "source": source}
|
|
2926
|
+
if heal.get("attempts", 0) >= HEAL_CAP:
|
|
2927
|
+
heal.setdefault("history", []).append(entry)
|
|
2928
|
+
t["gate"] = "HARD-STOP" # never a completing outcome; phase stays put
|
|
2929
|
+
t["updated"] = _now()
|
|
2930
|
+
save_state(root, state) # the escalation verdict is durable
|
|
2931
|
+
_die(f"heal_exhausted: task '{slug}' — a confirmed cheat ({reason}) persisted past "
|
|
2932
|
+
f"{HEAL_CAP} honest re-build attempts. HARD-STOP escalated to the human: fix the "
|
|
2933
|
+
"spec (change-request -> re-freeze) or abandon. A gamed green is never auto-passed.")
|
|
2934
|
+
heal["attempts"] = heal.get("attempts", 0) + 1
|
|
2935
|
+
heal.setdefault("history", []).append(entry)
|
|
2936
|
+
t["phase"] = "build" # DIRECT — never via advance (no re-snapshot)
|
|
2937
|
+
t["updated"] = _now()
|
|
2938
|
+
_sync_task_marker(root, slug, "build")
|
|
2939
|
+
save_state(root, state) # the increment is durable BEFORE the exit
|
|
2940
|
+
print(f"return_to_build: task '{slug}' — cheat detected ({reason}); RETURN TO BUILD for an "
|
|
2941
|
+
f"HONEST redo, attempt {heal['attempts']} of {HEAL_CAP}. Revert the tampered file or "
|
|
2942
|
+
"rebuild src honestly, then advance back to verify.")
|
|
2943
|
+
raise SystemExit(3) # redo signal (distinct from _die's 1, argparse's 2)
|
|
2944
|
+
|
|
2945
|
+
|
|
2946
|
+
def _tamper_guard(root: Path, state: dict, slug: str) -> None:
|
|
2947
|
+
"""HARD-STOP a COMPLETING gate when the tripwire shows tampering — the method's
|
|
2948
|
+
first mechanical cheat block (verify-integrity). Tri-state, co-witnessed by
|
|
2949
|
+
flag_verified: present+diverged -> stop; absent+flag_verified -> suspicious stop
|
|
2950
|
+
(the snapshot was crossed-then-erased); absent+not-verified -> skip (a legacy task
|
|
2951
|
+
or one that never crossed tests->build). A cheat is HARD-STOP-class — this runs
|
|
2952
|
+
for RISK-ACCEPTED too, BEFORE the waiver is recorded, so it is never launderable."""
|
|
2953
|
+
t = state["tasks"][slug]
|
|
2954
|
+
tw = t.get("tripwire")
|
|
2955
|
+
if tw is None:
|
|
2956
|
+
if t.get("flag_verified"):
|
|
2957
|
+
_die(f"tripwire_missing: task '{slug}' crossed tests->build "
|
|
2958
|
+
"(flag_verified) but carries no tamper snapshot — the evidence "
|
|
2959
|
+
"baseline was erased. Re-establish it (reopen -> re-advance through "
|
|
2960
|
+
"tests->build) before completing; a missing baseline is HARD-STOP.")
|
|
2961
|
+
return # legacy: predates the tripwire, or never crossed tests->build
|
|
2962
|
+
diffs = _tripwire_divergence(root, slug, tw)
|
|
2963
|
+
if diffs:
|
|
2964
|
+
# heal-then-escalate (verify-integrity): a mechanical cheat no longer dies on sight —
|
|
2965
|
+
# it enters the bounded self-heal loop (≤HEAL_CAP honest re-build attempts, then a
|
|
2966
|
+
# HARD-STOP escalation). Still HARD-STOP-class: never auto-passed, never launderable
|
|
2967
|
+
# (this runs BEFORE the waiver write). The router returns to build or escalates.
|
|
2968
|
+
_heal_or_escalate(root, state, slug,
|
|
2969
|
+
reason="tamper_detected:" + ",".join(diffs), source="tamper")
|
|
2970
|
+
|
|
2971
|
+
|
|
1577
2972
|
def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
|
|
1578
2973
|
"""(observe_delta, [delta lines]) from the task's TASK.md §7 — captured at FULL
|
|
1579
2974
|
fidelity: both fields wrap across physical lines in real files, so continuation
|
|
@@ -1730,7 +3125,7 @@ def _phase_spans(text: str) -> dict[int, str]:
|
|
|
1730
3125
|
m = head.match(ln)
|
|
1731
3126
|
if m:
|
|
1732
3127
|
n = int(m.group(1))
|
|
1733
|
-
if
|
|
3128
|
+
if 0 <= n <= 7 and n not in starts:
|
|
1734
3129
|
starts[n] = idx
|
|
1735
3130
|
out: dict[int, str] = {}
|
|
1736
3131
|
for n, idx in starts.items():
|
|
@@ -1754,23 +3149,23 @@ def _raw_phase_bodies(root: Path, slug: str) -> dict[int, str]:
|
|
|
1754
3149
|
|
|
1755
3150
|
|
|
1756
3151
|
def task_phases(root: Path, slug: str) -> list[dict]:
|
|
1757
|
-
"""The frozen per-task PHASE-DETAIL shape (v9-1): parse TASK.md §
|
|
1758
|
-
blocks
|
|
1759
|
-
{ "phase": <name>, "n": <
|
|
3152
|
+
"""The frozen per-task PHASE-DETAIL shape (v9-1): parse TASK.md §0–§7 into eight
|
|
3153
|
+
blocks ground→observe. PURE — NO writes. Each entry is
|
|
3154
|
+
{ "phase": <name>, "n": <0..7>, "body": <cleaned text | "(empty)"> }.
|
|
1760
3155
|
|
|
1761
3156
|
The heading scan lives in _phase_spans (shared with the decide digest); this view
|
|
1762
3157
|
CLEANS each body. Missing file / missing section / placeholder-only body ->
|
|
1763
3158
|
"(empty)" (fail-closed)."""
|
|
1764
|
-
names = PHASES[
|
|
3159
|
+
names = PHASES[:-1] # ground..observe; "done" is a terminal STATE, not a section
|
|
1765
3160
|
f = root / "tasks" / slug / "TASK.md"
|
|
1766
3161
|
try:
|
|
1767
3162
|
text = f.read_text(encoding="utf-8")
|
|
1768
3163
|
except OSError: # missing OR unreadable -> every phase fail-closed to "(empty)"
|
|
1769
|
-
return [{"phase": names[n
|
|
3164
|
+
return [{"phase": names[n], "n": n, "body": "(empty)"} for n in range(0, 8)]
|
|
1770
3165
|
spans = _phase_spans(text)
|
|
1771
|
-
return [{"phase": names[n
|
|
3166
|
+
return [{"phase": names[n], "n": n,
|
|
1772
3167
|
"body": _clean_phase_body(spans[n]) if n in spans else "(empty)"}
|
|
1773
|
-
for n in range(
|
|
3168
|
+
for n in range(0, 8)]
|
|
1774
3169
|
|
|
1775
3170
|
|
|
1776
3171
|
def _task_title(root: Path, slug: str) -> str:
|
|
@@ -1846,7 +3241,7 @@ def render_task_detail(root: Path, state: dict, mslug: str, slug: str, *,
|
|
|
1846
3241
|
L.append(f" PHASE {phase} GATE {gate}")
|
|
1847
3242
|
L.append(banner)
|
|
1848
3243
|
for p in task_phases(root, slug):
|
|
1849
|
-
i = p["n"]
|
|
3244
|
+
i = p["n"] # n IS the PHASES index now (ground=0 .. observe=7)
|
|
1850
3245
|
mk = (g["reached"] if (phase == "done" or i < ci)
|
|
1851
3246
|
else g["current"] if i == ci else g["pending"])
|
|
1852
3247
|
L.append("")
|
|
@@ -1981,6 +3376,36 @@ def _contract_frozen(raw3: str) -> bool:
|
|
|
1981
3376
|
return any(re.match(r"\s*Status:\s*FROZEN", ln) for ln in raw3.splitlines())
|
|
1982
3377
|
|
|
1983
3378
|
|
|
3379
|
+
def _section0_anchors(raw0: str) -> str | None:
|
|
3380
|
+
"""The value of the §0 GROUND "Anchors the contract cites:" line, stripped.
|
|
3381
|
+
None when the §0 body carries no such line (no §0, or a malformed map). PURE."""
|
|
3382
|
+
for ln in raw0.splitlines():
|
|
3383
|
+
m = re.match(r"\s*Anchors the contract cites:\s*(.*)$", ln)
|
|
3384
|
+
if m:
|
|
3385
|
+
return m.group(1).strip()
|
|
3386
|
+
return None
|
|
3387
|
+
|
|
3388
|
+
|
|
3389
|
+
def _grounded_state(raw: dict[int, str]) -> bool | None:
|
|
3390
|
+
"""Tri-state grounding measure over a task's RAW §bodies (measure-not-block):
|
|
3391
|
+
True — the §0 "Anchors the contract cites:" line is filled (real content)
|
|
3392
|
+
False — the §0 section exists but its Anchors line is the "<…>" placeholder / empty
|
|
3393
|
+
None — no §0 section (a pre-ground / legacy task), OR a §0 with no Anchors line
|
|
3394
|
+
PURE; fail-open (an unparseable §0 -> None, never a false False). The freeze review
|
|
3395
|
+
checklist asks the human to confirm True; status/check surface it, never block on it."""
|
|
3396
|
+
if 0 not in raw:
|
|
3397
|
+
return None
|
|
3398
|
+
anchors = _section0_anchors(raw[0])
|
|
3399
|
+
if anchors is None:
|
|
3400
|
+
return None
|
|
3401
|
+
return bool(anchors) and not anchors.startswith("<")
|
|
3402
|
+
|
|
3403
|
+
|
|
3404
|
+
def _task_grounded(root: Path, slug: str) -> bool | None:
|
|
3405
|
+
"""`_grounded_state` for one task by slug (reads its RAW §bodies). Read-only."""
|
|
3406
|
+
return _grounded_state(_raw_phase_bodies(root, slug))
|
|
3407
|
+
|
|
3408
|
+
|
|
1984
3409
|
_FLAG_LABEL_RE = re.compile(r"Least-sure flag surfaced at freeze\s*:", re.I)
|
|
1985
3410
|
_FLAG_PART_RE = re.compile(
|
|
1986
3411
|
r"\[(?:spec|scenario|contract|test)(?:/(?:spec|scenario|contract|test))*\]")
|
|
@@ -2022,6 +3447,8 @@ def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict:
|
|
|
2022
3447
|
gate = t.get("gate", "none")
|
|
2023
3448
|
if gate != "none" or phase in ("observe", "done"):
|
|
2024
3449
|
seam = "recorded"
|
|
3450
|
+
elif phase == "ground":
|
|
3451
|
+
seam = "ground"
|
|
2025
3452
|
elif phase in _FRONT_PHASES:
|
|
2026
3453
|
seam = "front"
|
|
2027
3454
|
else:
|
|
@@ -2032,6 +3459,8 @@ def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict:
|
|
|
2032
3459
|
judgment = _decision_markers(raw.get(6, ""), 6) + _decision_markers(raw.get(1, ""), 1)
|
|
2033
3460
|
elif seam == "front" and not frozen:
|
|
2034
3461
|
judgment = _decision_markers(raw.get(1, ""), 1) + _decision_markers(raw.get(3, ""), 3)
|
|
3462
|
+
elif seam == "ground":
|
|
3463
|
+
judgment = _decision_markers(raw.get(0, ""), 0)
|
|
2035
3464
|
else:
|
|
2036
3465
|
judgment = []
|
|
2037
3466
|
|
|
@@ -2051,6 +3480,9 @@ def decide_data(root: Path, state: dict, mslug: str, slug: str) -> dict:
|
|
|
2051
3480
|
elif seam == "front":
|
|
2052
3481
|
unlocks = "none"
|
|
2053
3482
|
decide = "no decision pending — frozen; the run owns it. next decision point: verify gate"
|
|
3483
|
+
elif seam == "ground":
|
|
3484
|
+
unlocks = "gather the codebase -> advance to specify"
|
|
3485
|
+
decide = "gather the real codebase (the section 0 GROUND map), then: add.py advance"
|
|
2054
3486
|
else:
|
|
2055
3487
|
unlocks = "none"
|
|
2056
3488
|
decide = f"no decision pending — recorded gate: {gate}"
|
|
@@ -2069,7 +3501,7 @@ def render_decide(root: Path, state: dict, mslug: str, slug: str, *,
|
|
|
2069
3501
|
g = _ASCII if ascii else _UNICODE
|
|
2070
3502
|
banner = g["h"] * width
|
|
2071
3503
|
seam_label = {"gate": "VERIFY GATE", "front": "CONTRACT APPROVAL",
|
|
2072
|
-
"recorded": "RECORDED"}[d["seam"]]
|
|
3504
|
+
"recorded": "RECORDED", "ground": "GROUND"}[d["seam"]]
|
|
2073
3505
|
L = [banner, f" DECIDE · {mslug or '—'} · {slug} · decision point: {seam_label}", banner]
|
|
2074
3506
|
if d["decide"].startswith("no decision pending"):
|
|
2075
3507
|
L.append(f" {d['decide']}")
|
|
@@ -2134,14 +3566,22 @@ def _planned_hint(d: dict) -> str:
|
|
|
2134
3566
|
return f" — {len(planned)} planned not yet scaffolded: " + " · ".join(planned)
|
|
2135
3567
|
|
|
2136
3568
|
|
|
2137
|
-
def
|
|
3569
|
+
def _decide_next_pair(state: dict, d: dict) -> tuple[str, bool]:
|
|
3570
|
+
"""(next-step text, human_stop) over the active-milestone rollup. `human_stop` is the
|
|
3571
|
+
driver behind the step (task gate-owner-marker): True for every DECISION point a human
|
|
3572
|
+
owns — decompose · resolve HARD-STOP · goal-not-met · consolidate/archive · approve
|
|
3573
|
+
contract · gate — and False ONLY for the run-in-progress fallthrough, the one branch
|
|
3574
|
+
where the AI just continues an in-flight run. Derived from the rollup `d`, never from
|
|
3575
|
+
the rendered prose (the §5 safety rule). The bare string is `_decide_next_base` below."""
|
|
2138
3576
|
ms = d["milestone"]["slug"]
|
|
2139
3577
|
rows = d["tasks"]
|
|
2140
3578
|
if not rows:
|
|
2141
|
-
|
|
3579
|
+
# command-first (next-footer-engine): an empty milestone's next step is to
|
|
3580
|
+
# decompose it — name the command, not the dead-end "none — no tasks yet".
|
|
3581
|
+
return f"decompose into tasks — add.py new-task {ms}", True
|
|
2142
3582
|
stopped = [r for r in rows if r["gate"] == "HARD-STOP"]
|
|
2143
3583
|
if stopped:
|
|
2144
|
-
return f"resolve HARD-STOP on {stopped[0]['slug']}"
|
|
3584
|
+
return f"resolve HARD-STOP on {stopped[0]['slug']}", True
|
|
2145
3585
|
s = d["summary"]
|
|
2146
3586
|
if s["tasks_done"] == s["tasks_total"]:
|
|
2147
3587
|
# tasks complete — but the milestone holds while the goal (exit criteria) is
|
|
@@ -2151,8 +3591,8 @@ def _decide_next_base(state: dict, d: dict) -> str:
|
|
|
2151
3591
|
met, total = ec.get("met", 0), ec.get("total", 0)
|
|
2152
3592
|
if total > 0 and met < total:
|
|
2153
3593
|
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}"
|
|
3594
|
+
f"from open deltas / the unscaffolded plan (add.py deltas)"), True
|
|
3595
|
+
return f"consolidate learnings + archive-milestone {ms}", True
|
|
2156
3596
|
active = state.get("active_task")
|
|
2157
3597
|
order = sorted(rows, key=lambda r: 0 if r["slug"] == active else 1) # stable
|
|
2158
3598
|
for r in order:
|
|
@@ -2160,11 +3600,58 @@ def _decide_next_base(state: dict, d: dict) -> str:
|
|
|
2160
3600
|
continue
|
|
2161
3601
|
if r["phase"] in _FRONT_PHASES:
|
|
2162
3602
|
return (f"approve the contract of {r['slug']} — "
|
|
2163
|
-
f"add.py report {ms} {r['slug']} --decide")
|
|
3603
|
+
f"add.py report {ms} {r['slug']} --decide"), True
|
|
2164
3604
|
if r["phase"] == "verify" and r["gate"] == "none":
|
|
2165
|
-
return f"gate {r['slug']} — add.py report {ms} {r['slug']} --decide"
|
|
3605
|
+
return f"gate {r['slug']} — add.py report {ms} {r['slug']} --decide", True
|
|
2166
3606
|
r = next(x for x in order if not x["done"])
|
|
2167
|
-
return f"none — run in progress ({r['slug']} at {r['phase']})"
|
|
3607
|
+
return f"none — run in progress ({r['slug']} at {r['phase']})", False
|
|
3608
|
+
|
|
3609
|
+
|
|
3610
|
+
def _decide_next_base(state: dict, d: dict) -> str:
|
|
3611
|
+
"""The next-step TEXT only — the thin str wrapper the report rollup/digest callers use.
|
|
3612
|
+
The driver behind it (human_stop) is in _decide_next_pair, read by the footer Arm B."""
|
|
3613
|
+
return _decide_next_pair(state, d)[0]
|
|
3614
|
+
|
|
3615
|
+
|
|
3616
|
+
def _next_footer(root: Path, state: dict) -> str:
|
|
3617
|
+
"""The single engine-sourced `next:` line a COMPLETING (exit-0) mutating verb prints
|
|
3618
|
+
as its last stdout (task next-footer-engine). ONE resolver, two arms — reusing the
|
|
3619
|
+
guide path, never a parallel next-step source:
|
|
3620
|
+
|
|
3621
|
+
Arm A — an active IN-FLIGHT task (gate == "none" AND phase != "done"): the phase's
|
|
3622
|
+
own command (advance, or the gate verbs at verify) + its PHASE_GUIDE why.
|
|
3623
|
+
The gate=="none" guard is precise — a HARD-STOPped task keeps gate=="HARD-STOP"
|
|
3624
|
+
(never done) so it falls to Arm B and is never told to re-gate itself.
|
|
3625
|
+
Arm B — otherwise: `_decide_next_base` over the active milestone's rollup — the SAME
|
|
3626
|
+
precedence the report dashboard renders (HARD-STOP -> "resolve HARD-STOP …",
|
|
3627
|
+
empty milestone -> "decompose … add.py new-task <ms>").
|
|
3628
|
+
|
|
3629
|
+
Fail-soft (design-for-failure): the footer is computed AFTER save_state, so a
|
|
3630
|
+
resolution error — no active milestone, an unreadable doc, a corrupt rollup — must
|
|
3631
|
+
NEVER turn a saved mutation into a crash; it degrades to one generic re-orient line.
|
|
3632
|
+
Pure render: it writes nothing. The trailing MARKER slot (task gate-owner-marker) names
|
|
3633
|
+
the driver — ` [you drive]` (the AI proceeds) / ` [human gate]` (a human owns it) — from
|
|
3634
|
+
`_driver_stop`: Arm A by phase×autonomy, Arm B by the rollup's own decision (human_stop).
|
|
3635
|
+
The fail-soft line carries NO marker — never assert a driver that could not be computed.
|
|
3636
|
+
"""
|
|
3637
|
+
try:
|
|
3638
|
+
slug = state.get("active_task")
|
|
3639
|
+
t = (state.get("tasks") or {}).get(slug) if slug else None
|
|
3640
|
+
if t and t.get("gate", "none") == "none" and t.get("phase") != "done":
|
|
3641
|
+
phase = t.get("phase")
|
|
3642
|
+
why = PHASE_GUIDE[phase][0].split(" — ")[0].strip() # the short phase clause
|
|
3643
|
+
command = ("add.py gate PASS | RISK-ACCEPTED | HARD-STOP"
|
|
3644
|
+
if phase == "verify" else "add.py advance")
|
|
3645
|
+
marker = _driver_marker(_driver_stop(root, state, slug, phase))
|
|
3646
|
+
return f"next: {command} — {why}{marker}"
|
|
3647
|
+
mslug = state.get("active_milestone")
|
|
3648
|
+
if mslug:
|
|
3649
|
+
d = report_data(root, state, mslug)
|
|
3650
|
+
text, human_stop = _decide_next_pair(state, d)
|
|
3651
|
+
return "next: " + text + _driver_marker(human_stop)
|
|
3652
|
+
except Exception:
|
|
3653
|
+
pass # a footer never aborts the verb that already saved its state
|
|
3654
|
+
return "next: add.py status — re-orient"
|
|
2168
3655
|
|
|
2169
3656
|
|
|
2170
3657
|
def render_decide_next(root: Path, state: dict, mslug: str, *,
|
|
@@ -2421,9 +3908,9 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
|
|
|
2421
3908
|
# catches post-gate header tampering and auto-resolved high-risk gates.
|
|
2422
3909
|
hdr = _task_header(root, slug)
|
|
2423
3910
|
if _RISK_HIGH_RE.search(hdr):
|
|
2424
|
-
if not
|
|
3911
|
+
if not _autonomy_lowered(hdr):
|
|
2425
3912
|
f(slug, "unguarded_high_risk_auto",
|
|
2426
|
-
"risk: high declared but autonomy is not
|
|
3913
|
+
"risk: high declared but autonomy is not lowered (manual or conservative)")
|
|
2427
3914
|
elif rev and "auto-gate" in rev.group(1):
|
|
2428
3915
|
f(slug, "unguarded_high_risk_auto",
|
|
2429
3916
|
"risk: high task whose GATE RECORD reviewer is the auto-gate")
|
|
@@ -2772,6 +4259,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2772
4259
|
pr.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
2773
4260
|
pr.set_defaults(func=cmd_ready)
|
|
2774
4261
|
|
|
4262
|
+
pwa = sub.add_parser("waves", help="read-only DAG schedule of a milestone: topological "
|
|
4263
|
+
"waves + critical path + advisory tier hint")
|
|
4264
|
+
pwa.add_argument("--milestone", default=None,
|
|
4265
|
+
help="milestone slug to schedule (default: the active milestone)")
|
|
4266
|
+
pwa.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
4267
|
+
pwa.set_defaults(func=cmd_waves)
|
|
4268
|
+
|
|
2775
4269
|
pmd = sub.add_parser("milestone-done", help="exit-gate a milestone (all tasks must PASS)")
|
|
2776
4270
|
pmd.add_argument("slug")
|
|
2777
4271
|
pmd.set_defaults(func=cmd_milestone_done)
|
|
@@ -2799,11 +4293,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2799
4293
|
pp = sub.add_parser("phase", help="set a task's phase explicitly")
|
|
2800
4294
|
pp.add_argument("phase", choices=PHASES)
|
|
2801
4295
|
pp.add_argument("slug", nargs="?", default=None)
|
|
2802
|
-
pp.set_defaults(func=cmd_phase)
|
|
4296
|
+
pp.set_defaults(func=cmd_phase, _opt_positionals=("slug",))
|
|
2803
4297
|
|
|
2804
4298
|
pa = sub.add_parser("advance", help="move a task to the next phase")
|
|
2805
4299
|
pa.add_argument("slug", nargs="?", default=None)
|
|
2806
|
-
pa.set_defaults(func=cmd_advance)
|
|
4300
|
+
pa.set_defaults(func=cmd_advance, _opt_positionals=("slug",))
|
|
2807
4301
|
|
|
2808
4302
|
pg = sub.add_parser("gate", help="record a verify gate outcome")
|
|
2809
4303
|
pg.add_argument("outcome", choices=GATES)
|
|
@@ -2811,15 +4305,32 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2811
4305
|
pg.add_argument("--owner", help="RISK-ACCEPTED waiver: accountable owner")
|
|
2812
4306
|
pg.add_argument("--ticket", help="RISK-ACCEPTED waiver: tracking ticket/link")
|
|
2813
4307
|
pg.add_argument("--expires", help="RISK-ACCEPTED waiver: expiry date")
|
|
2814
|
-
pg.set_defaults(func=cmd_gate)
|
|
4308
|
+
pg.set_defaults(func=cmd_gate, _opt_positionals=("slug",))
|
|
4309
|
+
|
|
4310
|
+
pan = sub.add_parser("autonomy", help="show or set the autonomy level (the verify-gate owner)")
|
|
4311
|
+
pan.add_argument("action", nargs="?", choices=("show", "set"), default="show")
|
|
4312
|
+
pan.add_argument("a1", nargs="?", default=None, help="set: <level>; show: [slug]")
|
|
4313
|
+
pan.add_argument("a2", nargs="?", default=None, help="set: [slug]")
|
|
4314
|
+
pan.add_argument("--project", action="store_true",
|
|
4315
|
+
help="set the PROJECT.md default instead of a task header")
|
|
4316
|
+
pan.add_argument("--yes", action="store_true",
|
|
4317
|
+
help="confirm a RAISE toward auto (a human-owned trust escalation)")
|
|
4318
|
+
pan.set_defaults(func=cmd_autonomy, _opt_positionals=("a1", "a2"))
|
|
2815
4319
|
|
|
2816
4320
|
pr = sub.add_parser("reopen", help="return a done task to an earlier phase with a recorded reason")
|
|
2817
4321
|
pr.add_argument("slug", nargs="?", default=None)
|
|
2818
4322
|
# --to / --reason are validated in-body (not argparse choices) so the named reject
|
|
2819
4323
|
# codes fire (reopen_target_invalid / reopen_reason_required), not a bare exit-2.
|
|
2820
|
-
pr.add_argument("--to", default=None, help="target phase (
|
|
4324
|
+
pr.add_argument("--to", default=None, help="target phase (ground..observe)")
|
|
2821
4325
|
pr.add_argument("--reason", default="", help="why the task is reopened (required, non-empty)")
|
|
2822
|
-
pr.set_defaults(func=cmd_reopen)
|
|
4326
|
+
pr.set_defaults(func=cmd_reopen, _opt_positionals=("slug",))
|
|
4327
|
+
|
|
4328
|
+
ph = sub.add_parser("heal", help="report a confirmed cheat: bounded return-to-build, then escalate")
|
|
4329
|
+
ph.add_argument("slug", nargs="?", default=None)
|
|
4330
|
+
# --reason validated in-body so the named rejects fire (heal_reason_required /
|
|
4331
|
+
# heal_not_at_verify), not a bare argparse usage-2.
|
|
4332
|
+
ph.add_argument("--reason", default="", help="the refute-read finding (required, non-empty)")
|
|
4333
|
+
ph.set_defaults(func=cmd_heal, _opt_positionals=("slug",))
|
|
2823
4334
|
|
|
2824
4335
|
ps = sub.add_parser("stage", help="set the project stage")
|
|
2825
4336
|
ps.add_argument("stage", choices=STAGES)
|
|
@@ -2835,6 +4346,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2835
4346
|
pck.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
2836
4347
|
pck.set_defaults(func=cmd_check)
|
|
2837
4348
|
|
|
4349
|
+
pwv = sub.add_parser("wave-verify",
|
|
4350
|
+
help="read-only merge-time gate: every WAVE.md roster echo must match "
|
|
4351
|
+
"base (refuses unverified_fork_base) — run before the first merge-back")
|
|
4352
|
+
pwv.add_argument("milestone", nargs="?", default=None,
|
|
4353
|
+
help="milestone whose WAVE.md to verify (default: the single live ledger)")
|
|
4354
|
+
pwv.set_defaults(func=cmd_wave_verify, _opt_positionals=("milestone",))
|
|
4355
|
+
|
|
2838
4356
|
psg = sub.add_parser("sync-guidelines",
|
|
2839
4357
|
help="(re)write the ADD guideline block into AGENTS.md + CLAUDE.md")
|
|
2840
4358
|
psg.set_defaults(func=cmd_sync_guidelines)
|
|
@@ -2842,7 +4360,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2842
4360
|
pgd = sub.add_parser("guide", help="print the one concrete next step for the active task")
|
|
2843
4361
|
pgd.add_argument("slug", nargs="?", default=None, help="task slug (default: active task)")
|
|
2844
4362
|
pgd.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
2845
|
-
pgd.set_defaults(func=cmd_guide)
|
|
4363
|
+
pgd.set_defaults(func=cmd_guide, _opt_positionals=("slug",))
|
|
2846
4364
|
|
|
2847
4365
|
prp = sub.add_parser("report",
|
|
2848
4366
|
help="capture/render a milestone's what-happened report (read-only)")
|
|
@@ -2862,7 +4380,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2862
4380
|
help="decision-point digest: what needs the human's judgment NOW "
|
|
2863
4381
|
"(task -> decision digest; milestone -> DECIDE NEXT only; "
|
|
2864
4382
|
"bare -> the active task)")
|
|
2865
|
-
prp.set_defaults(func=cmd_report)
|
|
4383
|
+
prp.set_defaults(func=cmd_report, _opt_positionals=("milestone", "task"))
|
|
2866
4384
|
|
|
2867
4385
|
pdt = sub.add_parser("deltas",
|
|
2868
4386
|
help="read-only report: open lessons learned grouped by competency")
|
|
@@ -2888,9 +4406,142 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2888
4406
|
return p
|
|
2889
4407
|
|
|
2890
4408
|
|
|
4409
|
+
def _rebind_optional_positionals(parser: argparse.ArgumentParser,
|
|
4410
|
+
args: argparse.Namespace,
|
|
4411
|
+
extras: list[str]) -> argparse.Namespace:
|
|
4412
|
+
"""argv portability (py<=3.12): argparse cannot bind an optional positional that
|
|
4413
|
+
trails value-taking flags once a REQUIRED positional was consumed in an earlier
|
|
4414
|
+
block — `gate RISK-ACCEPTED --owner X --ticket Y --expires Z <slug>` dies
|
|
4415
|
+
`unrecognized arguments: <slug>` on 3.10/3.11/3.12 (3.13+ parses it natively).
|
|
4416
|
+
Fix at main(): parse_known_args leaves the stranded slug in `extras`; re-bind
|
|
4417
|
+
non-flag extras into UNFILLED (still-default-None) optional positionals, in the
|
|
4418
|
+
order each subparser declared via set_defaults(_opt_positionals=...).
|
|
4419
|
+
Safety rule (frozen §3, engine-argv-portability): ANY flag-like extra refuses the
|
|
4420
|
+
WHOLE re-bind, and leftover extras re-raise the stock exit-2 error — a typo'd
|
|
4421
|
+
flag's value must never be mis-bound as a slug (that would gate the WRONG task)."""
|
|
4422
|
+
slots = [name for name in getattr(args, "_opt_positionals", ())
|
|
4423
|
+
if getattr(args, name, None) is None]
|
|
4424
|
+
if any(tok.startswith("-") for tok in extras) or len(extras) > len(slots):
|
|
4425
|
+
parser.error("unrecognized arguments: " + " ".join(extras))
|
|
4426
|
+
for name, value in zip(slots, extras):
|
|
4427
|
+
setattr(args, name, value)
|
|
4428
|
+
return args
|
|
4429
|
+
|
|
4430
|
+
|
|
4431
|
+
# --- agent-agnostic update nudge --------------------------------------------
|
|
4432
|
+
# ADD is agent-agnostic: ANY agent (Claude Code · Gemini CLI · Codex) is told by the
|
|
4433
|
+
# guideline block to run `add.py status`/`guide` FIRST, every session. That is the one
|
|
4434
|
+
# universal chokepoint to tell a stale install to refresh — a plain line on STDERR the
|
|
4435
|
+
# agent reads and acts on. Bounded + fail-open by design (see _maybe_nudge_update).
|
|
4436
|
+
#
|
|
4437
|
+
# This is the engine's ONE deliberate, isolated network touch. It is justified narrowly:
|
|
4438
|
+
# an agent that is offline cannot run at all, so when the network is unreachable this
|
|
4439
|
+
# silently does nothing and nothing is lost. It NEVER changes a command's stdout or exit.
|
|
4440
|
+
_UPDATE_CACHE = ".update-cache.json"
|
|
4441
|
+
_UPDATE_TTL = timedelta(hours=24) # hit the registry at most once / day
|
|
4442
|
+
_REGISTRY_LATEST = "https://registry.npmjs.org/@pilotspace/add/latest"
|
|
4443
|
+
|
|
4444
|
+
|
|
4445
|
+
def _read_json_safe(path: Path):
|
|
4446
|
+
try:
|
|
4447
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
4448
|
+
except (OSError, json.JSONDecodeError):
|
|
4449
|
+
return None
|
|
4450
|
+
|
|
4451
|
+
|
|
4452
|
+
def _write_json_safe(path: Path, obj) -> None:
|
|
4453
|
+
try:
|
|
4454
|
+
path.write_text(json.dumps(obj, indent=2) + "\n", encoding="utf-8")
|
|
4455
|
+
except OSError:
|
|
4456
|
+
pass
|
|
4457
|
+
|
|
4458
|
+
|
|
4459
|
+
def _version_gt(a: str, b: str) -> bool:
|
|
4460
|
+
"""True if version a is newer than b (dotted numeric; prerelease suffix dropped)."""
|
|
4461
|
+
def key(v: str):
|
|
4462
|
+
out = []
|
|
4463
|
+
for part in str(v).split("."):
|
|
4464
|
+
part = part.split("-", 1)[0]
|
|
4465
|
+
out.append((0, int(part)) if part.isdigit() else (1, part))
|
|
4466
|
+
return out
|
|
4467
|
+
try:
|
|
4468
|
+
return key(a) > key(b)
|
|
4469
|
+
except Exception:
|
|
4470
|
+
return False
|
|
4471
|
+
|
|
4472
|
+
|
|
4473
|
+
def _fetch_latest_version(timeout: float = 1.5):
|
|
4474
|
+
"""GET the registry's latest version. Returns a string, or None on ANY failure
|
|
4475
|
+
(offline, timeout, bad payload) — the caller treats None as 'unknown, skip'."""
|
|
4476
|
+
try:
|
|
4477
|
+
req = urllib.request.Request(_REGISTRY_LATEST, headers={"Accept": "application/json"})
|
|
4478
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
4479
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
4480
|
+
v = data.get("version")
|
|
4481
|
+
return v if isinstance(v, str) and v else None
|
|
4482
|
+
except Exception:
|
|
4483
|
+
return None
|
|
4484
|
+
|
|
4485
|
+
|
|
4486
|
+
def _cached_latest(add_dir: Path):
|
|
4487
|
+
"""The registry's latest version, throttled: served from .update-cache.json within
|
|
4488
|
+
the TTL, else refreshed over the network (fail-open). None when unknown."""
|
|
4489
|
+
cache = _read_json_safe(add_dir / _UPDATE_CACHE)
|
|
4490
|
+
if cache and cache.get("latest") and cache.get("checked_at"):
|
|
4491
|
+
try:
|
|
4492
|
+
ts = datetime.fromisoformat(cache["checked_at"])
|
|
4493
|
+
if datetime.now(timezone.utc) - ts < _UPDATE_TTL:
|
|
4494
|
+
return cache["latest"] # fresh -> no network
|
|
4495
|
+
except ValueError:
|
|
4496
|
+
pass
|
|
4497
|
+
latest = _fetch_latest_version()
|
|
4498
|
+
if latest:
|
|
4499
|
+
_write_json_safe(add_dir / _UPDATE_CACHE, {
|
|
4500
|
+
"checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
4501
|
+
"latest": latest,
|
|
4502
|
+
})
|
|
4503
|
+
return latest
|
|
4504
|
+
return cache.get("latest") if cache else None # stale beats nothing
|
|
4505
|
+
|
|
4506
|
+
|
|
4507
|
+
def _maybe_nudge_update(args: argparse.Namespace) -> None:
|
|
4508
|
+
"""If the tooling is stale, print an ACTION-REQUIRED line to STDERR so the agent
|
|
4509
|
+
updates before the user's task. Fully bounded + fail-open — never raises, never
|
|
4510
|
+
touches stdout or the exit code."""
|
|
4511
|
+
try:
|
|
4512
|
+
if os.environ.get("ADD_NO_UPDATE_CHECK"):
|
|
4513
|
+
return
|
|
4514
|
+
fn = getattr(args, "func", None)
|
|
4515
|
+
# orientation reads only — never a mid-run mutating verb (gate/advance/...)
|
|
4516
|
+
if getattr(fn, "__name__", "") not in ("cmd_status", "cmd_guide"):
|
|
4517
|
+
return
|
|
4518
|
+
add_dir = find_root()
|
|
4519
|
+
if add_dir is None:
|
|
4520
|
+
return
|
|
4521
|
+
stamp = _read_json_safe(add_dir / ".add-version")
|
|
4522
|
+
current = (stamp or {}).get("version")
|
|
4523
|
+
if not current:
|
|
4524
|
+
return # no launcher stamp -> inert (engine-only/test projects)
|
|
4525
|
+
latest = _cached_latest(add_dir)
|
|
4526
|
+
if not latest or not _version_gt(latest, current):
|
|
4527
|
+
return
|
|
4528
|
+
cmd = ("pipx run pilotspace-add update"
|
|
4529
|
+
if (stamp or {}).get("channel") == "pip"
|
|
4530
|
+
else "npx @pilotspace/add@latest update")
|
|
4531
|
+
sys.stderr.write(
|
|
4532
|
+
f"\n⚠ ADD is out of date: this project is on {current}, latest is {latest}.\n"
|
|
4533
|
+
f" ACTION REQUIRED before the user's task: run `{cmd}`, then re-read the "
|
|
4534
|
+
f"guide from disk, then continue.\n\n")
|
|
4535
|
+
except Exception:
|
|
4536
|
+
return # a nudge must NEVER break a command (design-for-failure)
|
|
4537
|
+
|
|
4538
|
+
|
|
2891
4539
|
def main(argv: list[str] | None = None) -> int:
|
|
2892
4540
|
parser = build_parser()
|
|
2893
|
-
args = parser.
|
|
4541
|
+
args, extras = parser.parse_known_args(argv)
|
|
4542
|
+
if extras:
|
|
4543
|
+
args = _rebind_optional_positionals(parser, args, extras)
|
|
4544
|
+
_maybe_nudge_update(args) # advisory preamble; stderr-only, fail-open
|
|
2894
4545
|
args.func(args)
|
|
2895
4546
|
return 0
|
|
2896
4547
|
|