@pilotspace/add 1.9.0 → 1.11.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 +104 -0
- package/bin/cli.js +192 -2
- package/docs/09-the-loop.md +1 -1
- package/docs/11-governance.md +1 -1
- package/docs/14-foundation.md +1 -1
- package/docs/16-releasing.md +4 -4
- package/docs/17-components.md +125 -0
- package/docs/appendix-c-glossary.md +9 -3
- package/package.json +5 -2
- package/skill/add/SKILL.md +11 -1
- package/skill/add/components.md +54 -0
- package/skill/add/phases/0-ground.md +9 -3
- package/skill/add/phases/0-setup.md +2 -2
- package/skill/add/scope.md +1 -1
- package/tooling/add.py +717 -1352
- package/tooling/add_engine/__init__.py +5 -0
- package/tooling/add_engine/accessors.py +61 -0
- package/tooling/add_engine/autonomy.py +58 -0
- package/tooling/add_engine/components.py +117 -0
- package/tooling/add_engine/constants.py +221 -0
- package/tooling/add_engine/guidelines.py +252 -0
- package/tooling/add_engine/identity.py +107 -0
- package/tooling/add_engine/io_state.py +201 -0
- package/tooling/add_engine/milestones.py +108 -0
- package/tooling/add_engine/predicates.py +75 -0
- package/tooling/add_engine/release.py +86 -0
- package/tooling/add_engine/render.py +90 -0
- package/tooling/add_engine/taskdoc.py +217 -0
- package/tooling/add_engine/version.py +45 -0
- package/tooling/templates/TASK.fast.md.tmpl +2 -0
- package/tooling/templates/TASK.md.tmpl +1 -1
- package/tooling/templates/gitignore.tmpl +6 -0
package/tooling/add.py
CHANGED
|
@@ -25,250 +25,107 @@ import urllib.request
|
|
|
25
25
|
from datetime import date, datetime, timedelta, timezone
|
|
26
26
|
from pathlib import Path
|
|
27
27
|
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
# unreleased. The 5th scope level (release.md). `{n}` is filled at print time; the
|
|
43
|
-
# wording matches SKILL.md's "Beyond the bundle" cross-ref byte-for-byte.
|
|
44
|
-
RELEASABLE_CUE = "releasable: {n} milestone(s) closed since last release"
|
|
45
|
-
# the append-only release ledger lives at the PROJECT ROOT (the dir containing .add/),
|
|
46
|
-
# a sibling of CHANGELOG.md — NOT inside .add/. The ledger IS the attribution source:
|
|
47
|
-
# a milestone is "released" iff its slug appears on a `milestones:` row.
|
|
48
|
-
RELEASES_FILE = "RELEASES.md"
|
|
49
|
-
PHASES = ("ground", "specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done")
|
|
50
|
-
GATES = ("none", "PASS", "RISK-ACCEPTED", "HARD-STOP")
|
|
51
|
-
# heal-then-escalate (verify-integrity): the bounded self-heal loop cap. A CONFIRMED cheat
|
|
52
|
-
# (mechanical tripwire divergence, or an agent-reported semantic refute-read finding) returns
|
|
53
|
-
# the task to BUILD for an honest redo; after HEAL_CAP such attempts the next confirmed cheat
|
|
54
|
-
# forces a HARD-STOP escalation to the human. MONOTONIC — attempts never auto-resets (a gamed
|
|
55
|
-
# green is never auto-passed; the loop is never unbounded).
|
|
56
|
-
HEAL_CAP = 3
|
|
28
|
+
try: # component-aware-add registry parse (Python 3.11+ stdlib)
|
|
29
|
+
import tomllib
|
|
30
|
+
except ModuleNotFoundError: # < 3.11: the registry is unsupported → degrade to opt-out
|
|
31
|
+
tomllib = None
|
|
32
|
+
|
|
33
|
+
# --- constants (moved to add_engine/constants.py — engine-modularization) ----
|
|
34
|
+
from add_engine.constants import * # noqa: F401,F403 (public constants via __all__)
|
|
35
|
+
from add_engine.constants import ( # the _-prefixed names (import * skips them)
|
|
36
|
+
_GITIGNORE_BODY, _GUIDE_BEGIN, _GUIDE_END,
|
|
37
|
+
_RULE_REF_LINE, _FALLBACK_TASK, _FALLBACK_TASK_FAST,
|
|
38
|
+
_DEFAULT_WIDTH,
|
|
39
|
+
_DELTA_RE, _EVIDENCE_RE, _SPEC_DELTA_RE, # shared delta regexes (taskdoc + deltas-web lint)
|
|
40
|
+
_AUTONOMY_LEVELS, # shared (autonomy resolvers + _AUTONOMY_ORDER/cmd_autonomy)
|
|
41
|
+
)
|
|
57
42
|
|
|
43
|
+
# --- terminal-render primitives (moved to add_engine/render.py) -------------
|
|
44
|
+
from add_engine.render import (
|
|
45
|
+
_bar, _phase_track, _use_ascii, _color_enabled, _term_width, _colorize, _clip, _wrap,
|
|
46
|
+
)
|
|
58
47
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
48
|
+
# --- milestone-doc readers (moved to add_engine/milestones.py) --------------
|
|
49
|
+
from add_engine.milestones import (
|
|
50
|
+
_has_production_roadmap, _project_goal, _milestone_doc, _exit_criteria,
|
|
51
|
+
_exit_criteria_cited, _stage_criteria, _all_milestones_done,
|
|
52
|
+
)
|
|
62
53
|
|
|
63
|
-
#
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
"specify": ("state every rule — Must / Reject (+ named code) / After; rank assumptions lowest-confidence first and flag the biggest risk",
|
|
69
|
-
"03-step-1-specify.md"),
|
|
70
|
-
"scenarios": ("write one Given/When/Then per Must AND per Reject; every result observable",
|
|
71
|
-
"04-step-2-scenarios.md"),
|
|
72
|
-
"contract": ("freeze the shape — signature, fields, error codes; names match the glossary",
|
|
73
|
-
"05-step-3-contract.md"),
|
|
74
|
-
"tests": ("write one failing test per scenario; run them RED for the right reason",
|
|
75
|
-
"06-step-4-tests.md"),
|
|
76
|
-
"build": ("write the minimum code to pass the tests; change no test and no contract",
|
|
77
|
-
"07-step-5-build.md"),
|
|
78
|
-
"verify": ("run the suite + non-functional checks, then record the gate",
|
|
79
|
-
"08-step-6-verify.md"),
|
|
80
|
-
"observe": ("note what to watch + the spec delta for the next loop",
|
|
81
|
-
"09-the-loop.md"),
|
|
82
|
-
"done": ("this task is done — pick the next feature",
|
|
83
|
-
"02-the-flow.md"),
|
|
84
|
-
}
|
|
85
|
-
# Phase -> who owns it, for the `--json` autonomy signal. An autonomous harness may run a
|
|
86
|
-
# phase only when owner=="ai" (stop is false); every other phase is a checkpoint. The map
|
|
87
|
-
# follows the book's who-does-what table (Verify is "human only"); `tests`/`build`/`observe`
|
|
88
|
-
# are AI-led. A phase missing here is `unmapped_phase` (fail closed) — never defaulted.
|
|
89
|
-
PHASE_OWNER = {
|
|
90
|
-
"ground": "ai",
|
|
91
|
-
"specify": "human", "scenarios": "human", "contract": "seam",
|
|
92
|
-
"tests": "ai", "build": "ai", "verify": "human", "observe": "ai", "done": "human",
|
|
93
|
-
}
|
|
94
|
-
SETUP_FILES = ("PROJECT.md", "CONVENTIONS.md", "GLOSSARY.md", "MODEL_REGISTRY.md", "dependencies.allowlist", "DESIGN.md", "SOUL.md")
|
|
95
|
-
|
|
96
|
-
# Scaffolded into .add/.gitignore at init so the engine's transient LOCAL artifacts
|
|
97
|
-
# never reach git. Bare-filename patterns match at any depth under .add/ (tasks/,
|
|
98
|
-
# milestones/, archive/). These are working state, not records: scope-snapshot.json
|
|
99
|
-
# is the tests->build touch baseline the verify scope-gate reads from disk (the
|
|
100
|
-
# durable scope declaration is the state.json anchor); pre-archive-state.bak.json is
|
|
101
|
-
# archive-milestone's pre-delete recovery net — needed on disk, never in history;
|
|
102
|
-
# .update-cache.json is the update-nudge's once-a-day registry throttle. All stay on
|
|
103
|
-
# disk; git-ignoring them is hygiene, never deletion.
|
|
104
|
-
_GITIGNORE_BODY = """\
|
|
105
|
-
# ADD engine transient artifacts — local working state, never committed.
|
|
106
|
-
# (Scaffolded by `add.py init`; edit freely — init never clobbers an existing copy.)
|
|
107
|
-
scope-snapshot.json
|
|
108
|
-
pre-archive-state.bak.json
|
|
109
|
-
.update-cache.json
|
|
110
|
-
"""
|
|
54
|
+
# --- component/federation subsystem (moved to add_engine/components.py) ------
|
|
55
|
+
from add_engine.components import (
|
|
56
|
+
_confined, _components, _cite_region, _contracts, _federation,
|
|
57
|
+
_contract_snapshot, _in_scope,
|
|
58
|
+
)
|
|
111
59
|
|
|
112
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
_GUIDE_BEGIN = "<!-- ADD:BEGIN — managed by `add.py sync-guidelines`; do not edit inside -->"
|
|
117
|
-
_GUIDE_END = "<!-- ADD:END -->"
|
|
118
|
-
|
|
119
|
-
# Minimal embedded fallback so the tool still works if templates/ is missing
|
|
120
|
-
# (circuit breaker: never hard-fail just because a template file was deleted).
|
|
121
|
-
_FALLBACK_TASK = """# TASK: {title}
|
|
122
|
-
|
|
123
|
-
slug: {slug} · created: {date} · stage: {stage}
|
|
124
|
-
autonomy: auto
|
|
125
|
-
phase: ground
|
|
126
|
-
|
|
127
|
-
## 0 · GROUND
|
|
128
|
-
Touches (files · symbols · signatures):
|
|
129
|
-
Honors (patterns / conventions):
|
|
130
|
-
Anchors the contract cites:
|
|
131
|
-
|
|
132
|
-
## 1 · SPECIFY
|
|
133
|
-
Feature:
|
|
134
|
-
Framings weighed:
|
|
135
|
-
Must:
|
|
136
|
-
Reject:
|
|
137
|
-
After:
|
|
138
|
-
Assumptions — lowest-confidence first:
|
|
139
|
-
⚠ <most likely wrong> — lowest confidence because <why>; if wrong: <cost>
|
|
140
|
-
|
|
141
|
-
## 2 · SCENARIOS
|
|
142
|
-
## 3 · CONTRACT
|
|
143
|
-
Status: DRAFT
|
|
144
|
-
## 4 · TESTS
|
|
145
|
-
## 5 · BUILD
|
|
146
|
-
## 6 · VERIFY
|
|
147
|
-
### GATE RECORD
|
|
148
|
-
Outcome:
|
|
149
|
-
## 7 · OBSERVE
|
|
150
|
-
### Spec delta
|
|
151
|
-
### Competency deltas
|
|
152
|
-
"""
|
|
60
|
+
# --- update-nudge version helpers (moved to add_engine/version.py) ----------
|
|
61
|
+
from add_engine.version import (
|
|
62
|
+
_read_json_safe, _version_gt, _fetch_latest_version,
|
|
63
|
+
)
|
|
153
64
|
|
|
65
|
+
# --- changelog/RELEASES render helpers (moved to add_engine/release.py) -----
|
|
66
|
+
from add_engine.release import (
|
|
67
|
+
_releases_path, _closed_milestones, _key_decisions_for,
|
|
68
|
+
_build_in_flight, _render_changelog_block, _render_releases_row,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# --- TASK.md structural readers (moved to add_engine/taskdoc.py) ------------
|
|
72
|
+
from add_engine.taskdoc import (
|
|
73
|
+
_task_header, _count_test_defs, _primary_test_files, _tests_count,
|
|
74
|
+
_declared_test_files, _declared_tests_count, _tests_info, _task_prose,
|
|
75
|
+
_phase_spans, _raw_phase_bodies, _spec_delta_entries,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# --- autonomy-level resolvers (moved to add_engine/autonomy.py) -------------
|
|
79
|
+
from add_engine.autonomy import (
|
|
80
|
+
_autonomy_level, _effective_autonomy, _project_autonomy, _project_autonomy_token,
|
|
81
|
+
)
|
|
154
82
|
|
|
155
|
-
# Fast-lane fallback: the minimal TASK.md variant (sections {0,1,3,4,5,6}; §2 + §7 dropped).
|
|
156
|
-
# Mirrors templates/TASK.fast.md.tmpl's section set (circuit-breaker parity); a deleted
|
|
157
|
-
# templates/ never hard-fails the fast lane. Keeps the trust floor: §3 freeze-flag + Status,
|
|
158
|
-
# §6 GATE RECORD/Outcome, §0 Anchors, §4 red-before-build, §5 Scope.
|
|
159
|
-
_FALLBACK_TASK_FAST = """# TASK: {title}
|
|
160
|
-
|
|
161
|
-
slug: {slug} · created: {date} · stage: {stage}
|
|
162
|
-
autonomy: auto
|
|
163
|
-
phase: ground
|
|
164
|
-
fast: true
|
|
165
|
-
|
|
166
|
-
## 0 · GROUND
|
|
167
|
-
Touches (files · symbols):
|
|
168
|
-
Anchors the contract cites:
|
|
169
|
-
|
|
170
|
-
## 1 · SPECIFY
|
|
171
|
-
Feature:
|
|
172
|
-
Must:
|
|
173
|
-
Reject:
|
|
174
|
-
Accept:
|
|
175
|
-
Assumptions: ⚠ <most likely wrong> — why; if wrong: <cost>
|
|
176
|
-
|
|
177
|
-
## 3 · CONTRACT
|
|
178
|
-
Least-sure flag surfaced at freeze:
|
|
179
|
-
Status: DRAFT
|
|
180
|
-
|
|
181
|
-
## 4 · TESTS
|
|
182
|
-
Plan:
|
|
183
|
-
Tests live in: `./tests/` · MUST run red before Build.
|
|
184
|
-
|
|
185
|
-
## 5 · BUILD
|
|
186
|
-
Scope (may touch): `./src/`
|
|
187
|
-
|
|
188
|
-
## 6 · VERIFY
|
|
189
|
-
Build expectations (from §1 Accept + §3 CONTRACT):
|
|
190
|
-
### GATE RECORD
|
|
191
|
-
Outcome:
|
|
192
|
-
Reviewed by:
|
|
193
|
-
"""
|
|
194
83
|
|
|
84
|
+
def _phase_index(name: str) -> int:
|
|
85
|
+
"""Ordinal of a phase in PHASES; used to enforce forward-skip rules."""
|
|
86
|
+
return PHASES.index(name)
|
|
195
87
|
|
|
196
|
-
# --- low-level IO (
|
|
88
|
+
# --- low-level IO (moved to add_engine/io_state.py — engine-modularization) -
|
|
89
|
+
from add_engine.io_state import ( # re-exported as module globals: callers use bare
|
|
90
|
+
_now, _atomic_write, _atomic_write_bytes, _atomic_write_many, # names so patches
|
|
91
|
+
find_root, _require_root, _migrate_state, _state_text_or_die, # on add.<name>
|
|
92
|
+
_die, # still resolve;
|
|
93
|
+
_CONFLICT_MARKER_RE, # conflict-marker re
|
|
94
|
+
_load_state_for_json, # --json state loader
|
|
95
|
+
_md5_text, _md5_file, # md5 hashing helpers
|
|
96
|
+
)
|
|
197
97
|
|
|
198
|
-
def _now() -> str:
|
|
199
|
-
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
200
98
|
|
|
99
|
+
# --- active milestone/task accessors (moved to add_engine/accessors.py) -------
|
|
100
|
+
from add_engine.accessors import (
|
|
101
|
+
_active_milestone, _active_task, _set_active_milestone,
|
|
102
|
+
_set_active_task, _activate_milestone, _deactivate_milestone,
|
|
103
|
+
)
|
|
201
104
|
|
|
202
|
-
|
|
203
|
-
"""Write via a temp file in the same dir, then atomically replace.
|
|
105
|
+
# --- state load/save (KEPT in add.py: write-path pinned by add._atomic_write tests) -
|
|
204
106
|
|
|
205
|
-
|
|
206
|
-
"""
|
|
207
|
-
|
|
208
|
-
|
|
107
|
+
def load_state(root: Path) -> dict:
|
|
108
|
+
"""Load + parse state.json, failing CLOSED. A git-conflicted file dies with a merge-specific
|
|
109
|
+
'state_conflicted'; any other corrupt/unreadable file dies with a clean 'state_invalid'
|
|
110
|
+
message (never a raw traceback), so every command that loads state degrades gracefully
|
|
111
|
+
(design-for-failure). The parsed state is forward-migrated to the multi-active schema."""
|
|
209
112
|
try:
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def _atomic_write_many(writes: list[tuple[Path, str]]) -> None:
|
|
219
|
-
"""True all-or-nothing commit across N files — design-for-failure for a multi-file write.
|
|
220
|
-
|
|
221
|
-
Phase 1 STAGES every (path, text) to a sibling `.tmp`, flushing + fsync-ing each, so the
|
|
222
|
-
realistic IO failures (disk full, permission denied) surface HERE, before any target changes —
|
|
223
|
-
and on any stage failure every staged temp is removed, so NOTHING is committed. Phase 2 then
|
|
224
|
-
COMMITS each file by renaming any existing target ASIDE to a sibling `.bak`, then `os.replace`-ing
|
|
225
|
-
the staged `.tmp` into place. If ANY commit rename raises, every file already committed is rolled
|
|
226
|
-
back IN REVERSE (remove the landed new file, rename its `.bak` back, or leave it absent) and the
|
|
227
|
-
original error re-raised — so the whole set is all-or-nothing: either every file holds its new
|
|
228
|
-
text, or every file holds its prior content. Restoring is an atomic rename of an already-written
|
|
229
|
-
`.bak` (no content held in memory, no re-write), the cheapest recovery under failing IO. Leftover
|
|
230
|
-
`.tmp`/`.bak` siblings are removed on every exit path.
|
|
231
|
-
"""
|
|
232
|
-
staged: list[tuple[str, Path]] = []
|
|
233
|
-
committed: list[list] = [] # [path, bak_or_None, new_landed] per committed file
|
|
113
|
+
return _migrate_state(json.loads(_state_text_or_die(root)))
|
|
114
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
115
|
+
_die(f"state_invalid: {root / STATE_FILE} is corrupt or unreadable "
|
|
116
|
+
f"({e.__class__.__name__}) — restore it from git or a backup")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def save_state(root: Path, state: dict) -> None:
|
|
120
|
+
state["updated"] = _now()
|
|
234
121
|
try:
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
os.fsync(fh.fileno())
|
|
243
|
-
try:
|
|
244
|
-
for tmp, path in staged: # phase 2: commit via rename-aside
|
|
245
|
-
bak = None
|
|
246
|
-
existed = path.exists()
|
|
247
|
-
if existed:
|
|
248
|
-
fd2, bak = tempfile.mkstemp(dir=str(path.parent), suffix=".bak")
|
|
249
|
-
os.close(fd2)
|
|
250
|
-
committed.append([path, bak, False]) # track .bak NOW so cleanup never leaks it
|
|
251
|
-
if existed:
|
|
252
|
-
os.replace(path, bak) # move the existing target aside
|
|
253
|
-
os.replace(tmp, path) # move the new file in
|
|
254
|
-
committed[-1][2] = True
|
|
255
|
-
except OSError:
|
|
256
|
-
for path, bak, landed in reversed(committed): # roll back, newest-first
|
|
257
|
-
try:
|
|
258
|
-
if landed and path.exists():
|
|
259
|
-
os.unlink(path) # drop the new file we put in
|
|
260
|
-
if bak is not None and not path.exists():
|
|
261
|
-
os.replace(bak, path) # restore the prior target (atomic rename)
|
|
262
|
-
except OSError:
|
|
263
|
-
pass # best-effort restore under already-failing IO
|
|
264
|
-
raise
|
|
265
|
-
finally:
|
|
266
|
-
for tmp, _ in staged: # leftover .tmp (failed/aborted stage) never persists
|
|
267
|
-
if os.path.exists(tmp):
|
|
268
|
-
os.unlink(tmp)
|
|
269
|
-
for path, bak, landed in committed: # leftover .bak (success, or orphaned post-rollback)
|
|
270
|
-
if bak is not None and os.path.exists(bak):
|
|
271
|
-
os.unlink(bak)
|
|
122
|
+
_atomic_write(root / STATE_FILE, json.dumps(state, indent=2) + "\n")
|
|
123
|
+
except OSError as e:
|
|
124
|
+
# Fail CLOSED like load_state: a named, recoverable error — never a raw traceback. The
|
|
125
|
+
# atomic temp+replace leaves the prior state.json byte-unchanged, so it is safe to retry.
|
|
126
|
+
_die(f"state_write_failed: could not write {root / STATE_FILE} "
|
|
127
|
+
f"({e.__class__.__name__}) — the prior state.json is intact; "
|
|
128
|
+
"free disk / fix permissions and re-run")
|
|
272
129
|
|
|
273
130
|
|
|
274
131
|
def _templates_dir() -> Path:
|
|
@@ -293,206 +150,18 @@ def _render_template(name: str, **subs: str) -> str:
|
|
|
293
150
|
text = text.replace("{{" + key + "}}", val)
|
|
294
151
|
return text
|
|
295
152
|
|
|
153
|
+
# --- state/markdown predicates (moved to add_engine/predicates.py) -----------
|
|
154
|
+
from add_engine.predicates import (
|
|
155
|
+
_phase_owner, _setup_locked, _milestone_confirmed, _section_unfilled,
|
|
156
|
+
_task_done,
|
|
157
|
+
)
|
|
296
158
|
|
|
297
|
-
# ---
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (d / ROOT_DIRNAME / STATE_FILE).exists():
|
|
304
|
-
return d / ROOT_DIRNAME
|
|
305
|
-
return None
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
def _require_root() -> Path:
|
|
309
|
-
root = find_root()
|
|
310
|
-
if root is None:
|
|
311
|
-
_die("no .add/ project found. Run `add.py init` first.")
|
|
312
|
-
return root
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
def _migrate_state(state: dict) -> dict:
|
|
316
|
-
"""Forward-migrate a single-active state to the multi-active schema (team-collaboration
|
|
317
|
-
foundation). PURE · idempotent · TOTAL · never raises · no I/O.
|
|
318
|
-
|
|
319
|
-
A state lacking the `active_milestones` key gains it — DERIVED from the scalar
|
|
320
|
-
`active_milestone` (grandfather-by-missing-key, mirroring `_setup_locked`): None -> [],
|
|
321
|
-
"x" -> ["x"]. A per-milestone active-task map `active_tasks` is added; the old global
|
|
322
|
-
`active_task` is placed under its owning active milestone only when it genuinely belongs
|
|
323
|
-
there, else it stays as the top-level scalar fallback (orphan rule, FROZEN decision (a)).
|
|
324
|
-
The scalar `active_milestone` / `active_task` keys are KEPT as the N<=1 mirror so the
|
|
325
|
-
not-yet-routed readers keep working. An already-migrated state (key present) is returned
|
|
326
|
-
unchanged — never re-derived, never clobbered. Corrupt parsing stays the loader's job.
|
|
327
|
-
|
|
328
|
-
PURE in the observable sense: the caller's dict is NEVER mutated — a state that needs
|
|
329
|
-
migrating is upgraded on a fresh top-level copy (nested objects are shared but only read)."""
|
|
330
|
-
if not isinstance(state, dict) or "active_milestones" in state:
|
|
331
|
-
return state
|
|
332
|
-
migrated = dict(state)
|
|
333
|
-
active_ms = migrated.get("active_milestone")
|
|
334
|
-
migrated["active_milestones"] = [] if active_ms is None else [active_ms]
|
|
335
|
-
active_task = migrated.get("active_task")
|
|
336
|
-
tasks = migrated.get("tasks") or {}
|
|
337
|
-
owns = (active_ms is not None and active_task is not None
|
|
338
|
-
and isinstance(tasks.get(active_task), dict)
|
|
339
|
-
and tasks[active_task].get("milestone") == active_ms)
|
|
340
|
-
migrated["active_tasks"] = {active_ms: active_task} if owns else {}
|
|
341
|
-
return migrated
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
# --- active milestone/task accessor seam (multi-active foundation) -----------
|
|
345
|
-
#
|
|
346
|
-
# Every engine call site reads & writes the active milestone(s)/task through these
|
|
347
|
-
# four helpers, so multi-active SEMANTICS can land in one place later. Today they
|
|
348
|
-
# preserve single-active behavior exactly: reads return the scalar mirror, writes
|
|
349
|
-
# keep the scalar AND the new active_milestones/active_tasks structures in sync.
|
|
350
|
-
|
|
351
|
-
def _active_milestone(state: dict) -> str | None:
|
|
352
|
-
"""The primary active milestone — the N<=1 scalar mirror (== active_milestones[0])."""
|
|
353
|
-
return state.get("active_milestone")
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
def _active_task(state: dict, milestone: str | None = None) -> str | None:
|
|
357
|
-
"""The active task: per-milestone when `milestone` is given (partial-state -> None),
|
|
358
|
-
else the global/primary scalar active task. Total — never raises."""
|
|
359
|
-
if milestone is None:
|
|
360
|
-
return state.get("active_task")
|
|
361
|
-
return (state.get("active_tasks") or {}).get(milestone)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
def _set_active_milestone(state: dict, slug: str | None) -> None:
|
|
365
|
-
"""Set the primary active milestone, keeping `active_milestones` consistent (N<=1 sync)."""
|
|
366
|
-
state["active_milestone"] = slug
|
|
367
|
-
state["active_milestones"] = [] if slug is None else [slug]
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
def _set_active_task(state: dict, slug: str | None, milestone: str | None = None) -> None:
|
|
371
|
-
"""Set the active task, keeping the scalar mirror AND the per-milestone map in sync.
|
|
372
|
-
With no owning active milestone the active task is scalar-only (the migration's orphan
|
|
373
|
-
rule); clearing (slug is None) pops the milestone's entry."""
|
|
374
|
-
state["active_task"] = slug
|
|
375
|
-
ms = milestone if milestone is not None else _active_milestone(state)
|
|
376
|
-
tasks_map = state.setdefault("active_tasks", {})
|
|
377
|
-
if ms is None:
|
|
378
|
-
return
|
|
379
|
-
if slug is None:
|
|
380
|
-
tasks_map.pop(ms, None)
|
|
381
|
-
else:
|
|
382
|
-
tasks_map[ms] = slug
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
def _activate_milestone(state: dict, slug: str) -> None:
|
|
386
|
-
"""Add a milestone to the active SET (idempotent) and make it the primary focus,
|
|
387
|
-
syncing the scalar active_task to that milestone's entry. Does NOT remove other members
|
|
388
|
-
(this is how a user reaches N>=2 active milestones)."""
|
|
389
|
-
ms_list = state.setdefault("active_milestones", [])
|
|
390
|
-
if slug not in ms_list:
|
|
391
|
-
ms_list.append(slug)
|
|
392
|
-
state["active_milestone"] = slug
|
|
393
|
-
state["active_task"] = (state.get("active_tasks") or {}).get(slug)
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
def _deactivate_milestone(state: dict, slug: str) -> None:
|
|
397
|
-
"""Remove a milestone from the active SET, pop its active-task entry, and (if it was the
|
|
398
|
-
primary) repoint the primary to the most-recent remaining member (or None when empty)."""
|
|
399
|
-
ms_list = state.setdefault("active_milestones", [])
|
|
400
|
-
if slug in ms_list:
|
|
401
|
-
ms_list.remove(slug)
|
|
402
|
-
(state.setdefault("active_tasks", {})).pop(slug, None)
|
|
403
|
-
if state.get("active_milestone") == slug:
|
|
404
|
-
new = ms_list[-1] if ms_list else None
|
|
405
|
-
state["active_milestone"] = new
|
|
406
|
-
state["active_task"] = (state.get("active_tasks") or {}).get(new) if new else None
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
def _git_config(key: str) -> str | None:
|
|
410
|
-
"""Read one `git config --get <key>`, STRICTLY fail-soft: the engine's FIRST git call,
|
|
411
|
-
so it never raises, never hangs, never shells. Returns the trimmed value, or None when
|
|
412
|
-
git is absent / errors / times out / the value is empty."""
|
|
413
|
-
if shutil.which("git") is None:
|
|
414
|
-
return None
|
|
415
|
-
try:
|
|
416
|
-
out = subprocess.run(
|
|
417
|
-
["git", "config", "--get", key],
|
|
418
|
-
capture_output=True, text=True, timeout=2,
|
|
419
|
-
).stdout.strip()
|
|
420
|
-
except (OSError, subprocess.SubprocessError, ValueError):
|
|
421
|
-
# OSError: git vanished between which() and run() / spawn error · SubprocessError:
|
|
422
|
-
# TimeoutExpired · ValueError: a non-UTF-8 config value (latin-1 legacy name) makes
|
|
423
|
-
# text=True decoding raise UnicodeDecodeError (a ValueError) — all fail soft to None.
|
|
424
|
-
return None
|
|
425
|
-
return out or None
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
def _os_user() -> str:
|
|
429
|
-
"""The guaranteed non-empty OS floor. getpass.getuser() reads LOGNAME/USER/... then
|
|
430
|
-
falls back to the passwd database — but in a bare container (no env var AND no passwd
|
|
431
|
-
entry) CPython raises KeyError (OSError only on 3.13+). Catch broadly and return a
|
|
432
|
-
sentinel so _whoami stays TOTAL: it always yields a non-empty name, never crashes."""
|
|
433
|
-
try:
|
|
434
|
-
return getpass.getuser() or "unknown"
|
|
435
|
-
except (KeyError, OSError):
|
|
436
|
-
return "unknown"
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
def _whoami(state: dict) -> dict:
|
|
440
|
-
"""Resolve the current git-native ACTOR -> {name, email, source}. Priority:
|
|
441
|
-
(1) an `actor_override` (whoami --set) with a non-blank name -> source 'override';
|
|
442
|
-
(2) `git config user.name`/`user.email` -> source 'git';
|
|
443
|
-
(3) the OS user (_os_user) -> source 'os', the guaranteed non-empty floor.
|
|
444
|
-
Total: always returns a dict with a non-empty name; `email` may be None."""
|
|
445
|
-
ov = state.get("actor_override")
|
|
446
|
-
if ov and (ov.get("name") or "").strip():
|
|
447
|
-
return {"name": ov["name"], "email": ov.get("email"), "source": "override"}
|
|
448
|
-
name = _git_config("user.name")
|
|
449
|
-
if name:
|
|
450
|
-
return {"name": name, "email": _git_config("user.email"), "source": "git"}
|
|
451
|
-
return {"name": _os_user(), "email": None, "source": "os"}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
def _actor_stamp(state: dict) -> dict:
|
|
455
|
-
"""The SINGLE source of the structured-actor stamp every engine-WRITTEN human action
|
|
456
|
-
records — lock · gate · milestone-done · release (user-identity actor-stamping). It IS
|
|
457
|
-
`_whoami(state)`: a TOTAL {name,email,source} (always a non-empty name), so a stamp can
|
|
458
|
-
never fail or block a write. Descriptive only — no command's decision reads it."""
|
|
459
|
-
return _whoami(state)
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
def _render_actor_line(state: dict) -> str:
|
|
463
|
-
"""Render the actor stamp as one human-readable line: name, an optional angle-bracketed
|
|
464
|
-
email, then the source in parens — used on the RELEASES.md row (no state.json write)."""
|
|
465
|
-
a = _actor_stamp(state)
|
|
466
|
-
email = f" <{a['email']}>" if a.get("email") else ""
|
|
467
|
-
return f"{a['name']}{email} ({a['source']})"
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
def _parse_actor_arg(s: str) -> dict:
|
|
471
|
-
"""Parse an `assign --owner`/`--assignee` value into a {name, email, source: "assigned"}
|
|
472
|
-
actor (ownership-assignment). "Name <email>" -> both; a bare "Name" -> email None. TOTAL:
|
|
473
|
-
a malformed value (no closing bracket) never raises — the whole stripped string is the name.
|
|
474
|
-
`source` is "assigned" — a human typed this name (not git-resolved nor an ADD override)."""
|
|
475
|
-
m = re.match(r"^\s*(.*?)\s*<([^>]*)>\s*$", s)
|
|
476
|
-
if m:
|
|
477
|
-
return {"name": m.group(1), "email": m.group(2) or None, "source": "assigned"}
|
|
478
|
-
return {"name": s.strip(), "email": None, "source": "assigned"}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
def _actor_matches(rec_actor: dict | None, me: dict) -> bool:
|
|
482
|
-
"""Does a recorded owner/assignee actor identify the SAME person as `me` (multi-active-UX)?
|
|
483
|
-
Email-first (the stabler key): when BOTH carry a non-empty email, emails decide; otherwise
|
|
484
|
-
fall back to name-equality. Both comparisons are stripped + case-insensitive. TOTAL — a None,
|
|
485
|
-
non-dict, or blank-name record returns False (an unowned/garbage slot is no one's)."""
|
|
486
|
-
if not isinstance(rec_actor, dict):
|
|
487
|
-
return False
|
|
488
|
-
rec_name = (rec_actor.get("name") or "").strip()
|
|
489
|
-
if not rec_name:
|
|
490
|
-
return False
|
|
491
|
-
rec_email = (rec_actor.get("email") or "").strip()
|
|
492
|
-
me_email = (me.get("email") or "").strip()
|
|
493
|
-
if rec_email and me_email:
|
|
494
|
-
return rec_email.lower() == me_email.lower()
|
|
495
|
-
return rec_name.lower() == (me.get("name") or "").strip().lower()
|
|
159
|
+
# --- git-native identity/actor seam (moved to add_engine/identity.py) --------
|
|
160
|
+
from add_engine import identity # qualified calls: identity._whoami(...)
|
|
161
|
+
from add_engine.identity import ( # re-exported for `add.<name>` attr compat
|
|
162
|
+
_git_config, _os_user, _whoami, _actor_stamp,
|
|
163
|
+
_render_actor_line, _parse_actor_arg, _actor_matches,
|
|
164
|
+
)
|
|
496
165
|
|
|
497
166
|
|
|
498
167
|
def _my_work(state: dict, me: dict) -> list[dict]:
|
|
@@ -506,8 +175,8 @@ def _my_work(state: dict, me: dict) -> list[dict]:
|
|
|
506
175
|
for slug, t in tasks.items():
|
|
507
176
|
if not isinstance(t, dict) or t.get("milestone") not in active_set or _task_done(t):
|
|
508
177
|
continue
|
|
509
|
-
owns = _actor_matches(t.get("owner"), me)
|
|
510
|
-
assigned = _actor_matches(t.get("assignee"), me)
|
|
178
|
+
owns = identity._actor_matches(t.get("owner"), me)
|
|
179
|
+
assigned = identity._actor_matches(t.get("assignee"), me)
|
|
511
180
|
if not (owns or assigned):
|
|
512
181
|
continue
|
|
513
182
|
role = "both" if owns and assigned else ("owner" if owns else "assignee")
|
|
@@ -521,106 +190,6 @@ def _my_work(state: dict, me: dict) -> list[dict]:
|
|
|
521
190
|
# A git conflict marker BEGINS a line with 7 of `<`, `=`, or `>` (`(?m)^…`). An unresolved
|
|
522
191
|
# merge writes these into state.json, making it invalid JSON; the line-anchor keeps a
|
|
523
192
|
# legitimate value (always on an INDENTED JSON line) from false-tripping the guard.
|
|
524
|
-
_CONFLICT_MARKER_RE = re.compile(r"(?m)^(<{7}|={7}|>{7})")
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
def _state_text_or_die(root: Path) -> str:
|
|
528
|
-
"""Read state.json's raw text, failing CLOSED with a merge-SPECIFIC `state_conflicted`
|
|
529
|
-
message when it carries git conflict markers (an unresolved merge — the major's #1 failure
|
|
530
|
-
mode). A genuine read OSError is NOT swallowed: it propagates to the caller, which maps it
|
|
531
|
-
to its own existing code (state_invalid / no_state). The guard only READS — never writes."""
|
|
532
|
-
text = (root / STATE_FILE).read_text(encoding="utf-8")
|
|
533
|
-
if _CONFLICT_MARKER_RE.search(text):
|
|
534
|
-
_die(f"state_conflicted: {root / STATE_FILE} has unresolved git merge markers "
|
|
535
|
-
f"(<<<<<<< / ======= / >>>>>>>) — resolve them (or "
|
|
536
|
-
f"`git checkout --ours/--theirs {STATE_FILE}`), then run `add.py doctor` to verify")
|
|
537
|
-
return text
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
def load_state(root: Path) -> dict:
|
|
541
|
-
"""Load + parse state.json, failing CLOSED. A git-conflicted file dies with a merge-specific
|
|
542
|
-
'state_conflicted'; any other corrupt/unreadable file dies with a clean 'state_invalid'
|
|
543
|
-
message (never a raw traceback), so every command that loads state degrades gracefully
|
|
544
|
-
(design-for-failure). The parsed state is forward-migrated to the multi-active schema."""
|
|
545
|
-
try:
|
|
546
|
-
return _migrate_state(json.loads(_state_text_or_die(root)))
|
|
547
|
-
except (json.JSONDecodeError, OSError) as e:
|
|
548
|
-
_die(f"state_invalid: {root / STATE_FILE} is corrupt or unreadable "
|
|
549
|
-
f"({e.__class__.__name__}) — restore it from git or a backup")
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
def _load_state_for_json() -> tuple[Path, dict]:
|
|
553
|
-
"""Fail-closed state load for `--json` paths: a missing project or unparseable
|
|
554
|
-
state.json -> `no_state` on stderr + exit 1, with EMPTY stdout (never a partial
|
|
555
|
-
JSON object a harness might parse). Built from State only — reads no docs/ chapter.
|
|
556
|
-
The parsed state is forward-migrated to the multi-active schema before it is returned."""
|
|
557
|
-
root = find_root()
|
|
558
|
-
if root is None:
|
|
559
|
-
_die("no_state")
|
|
560
|
-
try:
|
|
561
|
-
return root, _migrate_state(json.loads(_state_text_or_die(root)))
|
|
562
|
-
except (json.JSONDecodeError, OSError):
|
|
563
|
-
_die("no_state")
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
def _phase_owner(phase: str) -> str:
|
|
567
|
-
"""Map a phase to its owner (human|seam|ai); `unmapped_phase` if absent (fail closed)."""
|
|
568
|
-
owner = PHASE_OWNER.get(phase)
|
|
569
|
-
if owner is None:
|
|
570
|
-
_die("unmapped_phase")
|
|
571
|
-
return owner
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
def save_state(root: Path, state: dict) -> None:
|
|
575
|
-
state["updated"] = _now()
|
|
576
|
-
_atomic_write(root / STATE_FILE, json.dumps(state, indent=2) + "\n")
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
def _setup_locked(state: dict) -> bool:
|
|
580
|
-
"""True when the project's setup is locked — i.e. the build-boundary gate is OPEN.
|
|
581
|
-
|
|
582
|
-
A state with NO "setup" key is GRANDFATHERED-locked: plain `init` and every legacy
|
|
583
|
-
project are never gated (the lock is opt-in via `init --await-lock`). The gate is
|
|
584
|
-
therefore active in exactly one case: "setup" present AND locked is False."""
|
|
585
|
-
return ("setup" not in state) or (state["setup"].get("locked") is True)
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
def _milestone_confirmed(state: dict, mslug: str) -> bool:
|
|
589
|
-
"""True when milestone `mslug` is confirmed — i.e. the new-task gate is OPEN.
|
|
590
|
-
|
|
591
|
-
Mirrors `_setup_locked` one level down. A milestone record with NO "confirmed" key is
|
|
592
|
-
GRANDFATHERED-confirmed: every milestone created WITHOUT `--await-confirm` (and every
|
|
593
|
-
pre-existing one) is never gated. Opt-in: `new-milestone --await-confirm` seeds confirmed:false,
|
|
594
|
-
so the gate is active in exactly one case: the record is present AND confirmed is False. An
|
|
595
|
-
unknown milestone is treated as confirmed here (existence is cmd_new_task's separate check)."""
|
|
596
|
-
m = (state.get("milestones") or {}).get(mslug)
|
|
597
|
-
if not isinstance(m, dict) or "confirmed" not in m:
|
|
598
|
-
return True
|
|
599
|
-
return m["confirmed"] is True
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
def _section_unfilled(md_text: str, header: str) -> bool:
|
|
603
|
-
"""True iff the `header` section is PRESENT but UNFILLED — empty (no real bullet) or
|
|
604
|
-
still a `<…>` template placeholder. ABSENT section -> False (grandfathered legacy);
|
|
605
|
-
a filled section (>=1 real bullet, no `<…>`) -> False. Pure predicate — the shared
|
|
606
|
-
placeholder test the fill gates use (contract-fill at confirm; build-expectations at build)."""
|
|
607
|
-
body, in_sec, present = [], False, False
|
|
608
|
-
for ln in md_text.splitlines():
|
|
609
|
-
if ln.startswith(header):
|
|
610
|
-
in_sec, present = True, True
|
|
611
|
-
continue
|
|
612
|
-
if in_sec:
|
|
613
|
-
if ln.startswith("#"): # ANY next header (## or ###) ends our section
|
|
614
|
-
break
|
|
615
|
-
if ln.lstrip().startswith(">"): # skip blockquote GUIDANCE — it is not content
|
|
616
|
-
continue
|
|
617
|
-
body.append(ln)
|
|
618
|
-
if not present:
|
|
619
|
-
return False # absent -> grandfather
|
|
620
|
-
text = "\n".join(body).strip()
|
|
621
|
-
if not text:
|
|
622
|
-
return True # present but empty
|
|
623
|
-
return bool(re.search(r"<[^>\n]+>", text)) # a <…> placeholder remains
|
|
624
193
|
|
|
625
194
|
|
|
626
195
|
def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None:
|
|
@@ -639,7 +208,7 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
|
|
|
639
208
|
return # unreadable -> no-op (never blocks the gate)
|
|
640
209
|
if "### GATE RECORD" not in text:
|
|
641
210
|
return # nothing to mirror into
|
|
642
|
-
actor = _actor_stamp(state)
|
|
211
|
+
actor = identity._actor_stamp(state)
|
|
643
212
|
today = date.today().isoformat()
|
|
644
213
|
# each rule matches ONLY a line still carrying a `<…>` placeholder -> grandfather a resolved line.
|
|
645
214
|
rules = [
|
|
@@ -655,141 +224,22 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
|
|
|
655
224
|
new = text
|
|
656
225
|
for pat, repl in rules:
|
|
657
226
|
new = re.sub(pat, repl, new, count=1)
|
|
227
|
+
# component-aware-add (per-component-verify): record WHICH green-bar a bound task gated
|
|
228
|
+
# against, right after the Outcome line. Unbound / no green_bar -> no line (byte-identical).
|
|
229
|
+
_bar = _task_green_bar(root, slug)
|
|
230
|
+
if _bar:
|
|
231
|
+
_line = f"component: {_task_component(root, slug)} · expected green-bar: {_bar}"
|
|
232
|
+
if _line not in new:
|
|
233
|
+
new = re.sub(r"(?m)^(Outcome:.*$)", lambda m: m.group(1) + "\n" + _line, new, count=1)
|
|
658
234
|
if new != text: # no-op = no write (mtime stable)
|
|
659
235
|
_atomic_write(f, new)
|
|
660
236
|
|
|
661
237
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
# --- guideline injection (dynamic-by-reference; designed for failure) --------
|
|
668
|
-
#
|
|
669
|
-
# Inject one stable, marker-delimited ADD block into the project root's AGENTS.md
|
|
670
|
-
# and CLAUDE.md. The block is DYNAMIC-BY-REFERENCE: it tells the agent to run
|
|
671
|
-
# `add.py status` and read PROJECT.md — it never embeds live state (slug, phase,
|
|
672
|
-
# gate). Auto-updated context files measurably hurt (ETH-Zurich: ~3% lower success,
|
|
673
|
-
# 20%+ more cost), so the stable pointer is the whole point.
|
|
674
|
-
|
|
675
|
-
def _guideline_block() -> str:
|
|
676
|
-
"""The canonical ADD block (markers + body, no trailing newline).
|
|
677
|
-
|
|
678
|
-
Agent-agnostic by design (v14 agent-portability): the routing steps depend
|
|
679
|
-
only on the CLI and plain files, so any agent — Claude, Cursor, Copilot,
|
|
680
|
-
Codex — can follow them. Claude additionally gets the `add` skill."""
|
|
681
|
-
return (
|
|
682
|
-
f"{_GUIDE_BEGIN}\n"
|
|
683
|
-
"## ADD — how to work in this repo\n"
|
|
684
|
-
"\n"
|
|
685
|
-
"This project uses **ADD (AI-Driven Development)**: you, the AI, drive the build;\n"
|
|
686
|
-
"the human owns direction and verification. The loop below works for any agent —\n"
|
|
687
|
-
"Claude, Cursor, Copilot, Codex — through the CLI alone. Before you change code:\n"
|
|
688
|
-
"\n"
|
|
689
|
-
"1. Run `python3 .add/tooling/add.py status` — where the project is and what's\n"
|
|
690
|
-
" next (the resume point; read it first every session).\n"
|
|
691
|
-
"2. Read `.add/PROJECT.md` — the foundation (domain · spec · UI/UX) every task\n"
|
|
692
|
-
" builds on.\n"
|
|
693
|
-
"3. Run `python3 .add/tooling/add.py guide` — it names the phase and the exact\n"
|
|
694
|
-
" phase-guide file to read (the `guide :` line). Work ONLY that phase — each\n"
|
|
695
|
-
" guide ends with its exit gate and the command to move on.\n"
|
|
696
|
-
"\n"
|
|
697
|
-
"The flow: INTAKE sizes a request into a milestone; each task runs the\n"
|
|
698
|
-
"**specification bundle** — Spec+Scenarios+Contract+Tests as one bundle,\n"
|
|
699
|
-
"ONE human approval at the frozen contract — then a self-driving build→verify\n"
|
|
700
|
-
"run. Non-negotiable for every agent:\n"
|
|
701
|
-
"Never weaken a test or edit a frozen contract to make a build pass; a security\n"
|
|
702
|
-
"finding is always HARD-STOP — never auto-passed.\n"
|
|
703
|
-
"\n"
|
|
704
|
-
"On Claude Code the `add` skill drives this loop automatically; other agents\n"
|
|
705
|
-
"follow the three steps. The book is in `.add/docs/`. This block is generated\n"
|
|
706
|
-
"by `add.py sync-guidelines`; edit outside the markers, not inside.\n"
|
|
707
|
-
f"{_GUIDE_END}"
|
|
708
|
-
)
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
def _inject_block(path: Path) -> str:
|
|
712
|
-
"""Write the ADD block into `path`. Returns created|updated|unchanged.
|
|
713
|
-
|
|
714
|
-
- unchanged: on-disk block already matches -> no write, no .bak (idempotent).
|
|
715
|
-
- updated: existing content changes -> back up the original to <path>.bak first.
|
|
716
|
-
- created: file did not exist -> write the block, no .bak.
|
|
717
|
-
User content outside the markers is always preserved.
|
|
718
|
-
"""
|
|
719
|
-
block = _guideline_block()
|
|
720
|
-
if path.exists():
|
|
721
|
-
current = path.read_text(encoding="utf-8")
|
|
722
|
-
begin = current.find(_GUIDE_BEGIN)
|
|
723
|
-
if begin != -1:
|
|
724
|
-
end = current.find(_GUIDE_END, begin)
|
|
725
|
-
if end != -1: # replace only the marked region
|
|
726
|
-
end += len(_GUIDE_END)
|
|
727
|
-
new = current[:begin] + block + current[end:]
|
|
728
|
-
else: # begin without end: corrupt — append fresh
|
|
729
|
-
print(f"add: warning: {path.name}: found an ADD:BEGIN with no ADD:END "
|
|
730
|
-
"— appending a fresh block; review the result", file=sys.stderr)
|
|
731
|
-
new = current.rstrip("\n") + "\n\n" + block + "\n"
|
|
732
|
-
else: # no block yet — append, keep user content
|
|
733
|
-
new = current.rstrip("\n") + "\n\n" + block + "\n"
|
|
734
|
-
if new == current:
|
|
735
|
-
return "unchanged"
|
|
736
|
-
_atomic_write(Path(str(path) + ".bak"), current) # rollback path before mutate
|
|
737
|
-
_atomic_write(path, new)
|
|
738
|
-
return "updated"
|
|
739
|
-
_atomic_write(path, block + "\n")
|
|
740
|
-
return "created"
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
def _inject_guidelines(project_root: Path) -> list[tuple[str, str]]:
|
|
744
|
-
"""Inject the block into each guideline file under `project_root`.
|
|
745
|
-
|
|
746
|
-
Symlink-dedup: targets resolving (os.path.realpath) to the same inode are
|
|
747
|
-
written once, against the REAL file (never replacing the symlink with a
|
|
748
|
-
regular file). Per-target OSError is isolated (warn+skip) so one unwritable
|
|
749
|
-
file never aborts the run or `init`.
|
|
750
|
-
"""
|
|
751
|
-
results: list[tuple[str, str]] = []
|
|
752
|
-
seen: set[str] = set()
|
|
753
|
-
for name in GUIDELINE_FILES:
|
|
754
|
-
target = project_root / name
|
|
755
|
-
real = os.path.realpath(target)
|
|
756
|
-
if real in seen:
|
|
757
|
-
continue
|
|
758
|
-
seen.add(real)
|
|
759
|
-
write_target = Path(real) if target.is_symlink() else target
|
|
760
|
-
try:
|
|
761
|
-
action = _inject_block(write_target)
|
|
762
|
-
except (OSError, UnicodeDecodeError) as exc:
|
|
763
|
-
# design for failure: an unwritable target OR a non-UTF-8 existing file
|
|
764
|
-
# (e.g. a UTF-16 CLAUDE.md from a Windows editor) must not crash init or
|
|
765
|
-
# abort the other target — warn and skip this one.
|
|
766
|
-
print(f"add: warning: could not sync {name} — {exc}; skipped",
|
|
767
|
-
file=sys.stderr)
|
|
768
|
-
action = "skipped"
|
|
769
|
-
results.append((name, action))
|
|
770
|
-
return results
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
# --- commands ----------------------------------------------------------------
|
|
774
|
-
|
|
775
|
-
_INIT_EXCLUDE = {
|
|
776
|
-
".add", "AGENTS.md", "CLAUDE.md", ".git",
|
|
777
|
-
".gitignore", ".gitattributes", ".github", ".editorconfig", # VCS/CI/editor scaffolding — no domain signal
|
|
778
|
-
"LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", # legal boilerplate — no domain signal
|
|
779
|
-
} # README/docs/source are NOT excluded: they carry domain content adopt.md maps -> brownfield
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
def _is_brownfield(base: Path) -> bool:
|
|
783
|
-
"""True when `base` already holds project content beyond the tool's own scaffolding.
|
|
784
|
-
|
|
785
|
-
Judgment-free: a mechanical fact (does the dir hold a non-excluded entry?), so the
|
|
786
|
-
autonomous-onboarding flow knows to map existing code into the living documentation. INTERPRETING
|
|
787
|
-
that code stays with the AI (skill/add/adopt.md) — the engine only detects + signals."""
|
|
788
|
-
if not base.is_dir():
|
|
789
|
-
return False
|
|
790
|
-
return any(child.name not in _INIT_EXCLUDE for child in base.iterdir())
|
|
791
|
-
|
|
792
|
-
|
|
238
|
+
# --- guidelines / CLAUDE.md-injection subsystem (moved to add_engine/guidelines.py) -
|
|
239
|
+
from add_engine.guidelines import (
|
|
240
|
+
_guideline_block, _inject_block, _rule_file_mode, _strip_inline_block,
|
|
241
|
+
_insert_rule_reference, _ensure_claude_reference, _inject_guidelines, _is_brownfield,
|
|
242
|
+
)
|
|
793
243
|
def cmd_init(args: argparse.Namespace) -> None:
|
|
794
244
|
base = Path(args.dir).resolve()
|
|
795
245
|
root = base / ROOT_DIRNAME
|
|
@@ -841,7 +291,7 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
841
291
|
state["setup"] = {"locked": False, "locked_at": None, "locked_by": None, "layers": []}
|
|
842
292
|
save_state(root, state)
|
|
843
293
|
# zero-config: give any agent a stable pointer into the ADD runtime.
|
|
844
|
-
for name, action in _inject_guidelines(base):
|
|
294
|
+
for name, action in _inject_guidelines(base, getattr(args, "rule_file", False)):
|
|
845
295
|
if action != "unchanged":
|
|
846
296
|
print(f"{action:>9} {name}")
|
|
847
297
|
print(f"initialised ADD project '{state['project']}' (stage: {state['stage']}) at {root}")
|
|
@@ -853,11 +303,15 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
853
303
|
else:
|
|
854
304
|
print("next: open Claude Code, run `/add`, and say what you want to build —")
|
|
855
305
|
print(" the `add` skill sizes it into a milestone and drives the build with you.")
|
|
306
|
+
# setup hygiene (both branches): the .add/ folder IS the shared project state — commit it
|
|
307
|
+
# so the team shares one source of truth; its transient working files are already gitignored.
|
|
308
|
+
print("tip: commit the .add/ folder to git so your team shares the ADD state "
|
|
309
|
+
"(its transient files are already .gitignored).")
|
|
856
310
|
|
|
857
311
|
|
|
858
312
|
def cmd_sync_guidelines(args: argparse.Namespace) -> None:
|
|
859
313
|
project_root = _require_root().parent
|
|
860
|
-
for name, action in _inject_guidelines(project_root):
|
|
314
|
+
for name, action in _inject_guidelines(project_root, getattr(args, "rule_file", False)):
|
|
861
315
|
print(f"{action:>9} {name}")
|
|
862
316
|
|
|
863
317
|
|
|
@@ -938,6 +392,11 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
938
392
|
f"autonomy token; new task seeded fail-safe '{autonomy}' "
|
|
939
393
|
"(fix it with `add.py autonomy set <level> --project`)", file=sys.stderr)
|
|
940
394
|
|
|
395
|
+
# F8 (force-preserve-heal): a --force overwrite RE-CREATES the record; capture the prior
|
|
396
|
+
# MONOTONIC heal counter first so it survives. Else a task that accrued heal attempts (or
|
|
397
|
+
# was HARD-STOP escalated) could launder the cap (HEAL_CAP) to zero by re-creating itself —
|
|
398
|
+
# a zero-human cap bypass (the same invariant _heal_or_escalate guards: "never auto-resets").
|
|
399
|
+
prior_heal = state["tasks"].get(slug, {}).get("heal") if args.force else None
|
|
941
400
|
state["tasks"][slug] = {
|
|
942
401
|
"title": title,
|
|
943
402
|
"phase": "ground",
|
|
@@ -947,6 +406,8 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
947
406
|
"created": _now(),
|
|
948
407
|
"updated": _now(),
|
|
949
408
|
}
|
|
409
|
+
if prior_heal is not None:
|
|
410
|
+
state["tasks"][slug]["heal"] = prior_heal # monotonic — survives the --force re-create
|
|
950
411
|
if from_delta:
|
|
951
412
|
state["tasks"][slug]["from_delta"] = from_delta # lineage: seeded from <prior>
|
|
952
413
|
if fast:
|
|
@@ -957,6 +418,11 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
957
418
|
if milestone:
|
|
958
419
|
print(f"linked to milestone '{milestone}'" +
|
|
959
420
|
(f", depends-on {depends_on}" if depends_on else ""))
|
|
421
|
+
elif fast:
|
|
422
|
+
# blessed milestone-free fast lane (standalone-fast-task): a --fast task with no owning
|
|
423
|
+
# milestone is a DELIBERATE low-ceremony lane, not an orphan to nag — AFFIRM it.
|
|
424
|
+
print(f"standalone fast task '{slug}' — milestone-free by design (low-ceremony lane); "
|
|
425
|
+
f"attach later with `add.py set-milestone {slug} --milestone <id>` if it grows")
|
|
960
426
|
else:
|
|
961
427
|
# warn-never-block: the task is created (escape hatch), but nudge back toward the
|
|
962
428
|
# intake -> milestone flow. Speaks of STRUCTURE (not attached), never the act.
|
|
@@ -1001,16 +467,6 @@ def _parse_deps(raw: str | None) -> list[str]:
|
|
|
1001
467
|
return [d.strip() for d in raw.split(",") if d.strip()]
|
|
1002
468
|
|
|
1003
469
|
|
|
1004
|
-
def _task_done(t: dict) -> bool:
|
|
1005
|
-
# Matrix 3: a task is done when Verify reads PASS *or a signed RISK-ACCEPTED*.
|
|
1006
|
-
# Both completing gates advance phase to "done" (cmd_gate), and a waiver is
|
|
1007
|
-
# signed at gate time — so a verdict gate is enough here; we need not re-read
|
|
1008
|
-
# the waiver. HARD-STOP never reaches "done". A bare `phase done` (escape
|
|
1009
|
-
# hatch, gate still "none") deliberately does NOT count: completion needs a
|
|
1010
|
-
# recorded verdict, not just a phase marker.
|
|
1011
|
-
return t.get("phase") == "done" and t.get("gate") in ("PASS", "RISK-ACCEPTED")
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
470
|
def _archived_task_slugs(state: dict) -> set[str]:
|
|
1015
471
|
"""Slugs of tasks that left active state via archive — all were PASS-done at
|
|
1016
472
|
archive time, so a dep on one of them counts as satisfied (not dangling).
|
|
@@ -1034,16 +490,94 @@ def _resolve_task(state: dict, slug: str | None) -> str:
|
|
|
1034
490
|
return slug
|
|
1035
491
|
|
|
1036
492
|
|
|
493
|
+
def _build_entry(root: Path, state: dict, slug: str) -> None:
|
|
494
|
+
"""The shared tests->build entry guards + snapshots (task phase-build-guard, F4).
|
|
495
|
+
|
|
496
|
+
Extracted VERBATIM from cmd_advance's `nxt == "build"` block so BOTH `advance` and the
|
|
497
|
+
`phase build` admin override run the identical gate stack — the freeze gate, the
|
|
498
|
+
build-expectations gate, the unflagged-freeze check + flag stamp, the tamper tripwire, and
|
|
499
|
+
the §5 scope snapshot. validate-then-write: every `_die` precedes the first state mutation,
|
|
500
|
+
so a refused entry leaves the task byte-unchanged. The heal loop sets phase=build DIRECTLY
|
|
501
|
+
and never routes here, so it stays exempt.
|
|
502
|
+
"""
|
|
503
|
+
# the OPTED-IN crossing guards (fast-lane + flow-enforcement): a task whose PARENT
|
|
504
|
+
# milestone opted into --await-confirm is held to the trust floor at tests->build. A
|
|
505
|
+
# task under a plain / no milestone is never gated (every existing advance-to-build flow
|
|
506
|
+
# stays green). validate-then-write — every refusal runs BEFORE the tripwire/scope
|
|
507
|
+
# snapshots below, writing nothing; the task stays at `tests`.
|
|
508
|
+
_ms = state["tasks"][slug].get("milestone")
|
|
509
|
+
_optin = bool(_ms) and (state.get("milestones") or {}).get(_ms, {}).get("await_confirm") is True
|
|
510
|
+
raw3 = _raw_phase_bodies(root, slug).get(3, "")
|
|
511
|
+
# freeze-before-build gate (fast-lane): "collapse-never-skip" made REAL — the freeze is
|
|
512
|
+
# engine-enforced for an opted-in milestone OR any --fast task (the fast arm, fast-new-task-flag:
|
|
513
|
+
# a fast task is held to the floor under ANY milestone, so the lighter lane never drops the
|
|
514
|
+
# trust seam). PRECEDES build-expectations: you freeze §3 before pre-declaring §6's outcomes.
|
|
515
|
+
_freeze_gated = _optin or state["tasks"][slug].get("fast") is True
|
|
516
|
+
if _freeze_gated and not _contract_frozen(raw3):
|
|
517
|
+
_die("contract_not_frozen: freeze §3 before crossing into build — approve "
|
|
518
|
+
f"the contract in {slug}'s TASK.md (Status: FROZEN @ vN)")
|
|
519
|
+
# build-expectations gate (flow-enforcement): an opted-in task may not enter build until
|
|
520
|
+
# its §6 `### Build expectations` are pre-declared — so verify checks the build is RIGHT,
|
|
521
|
+
# not just green. Same opt-in switch as the contract-fill gate, one level out.
|
|
522
|
+
if _optin:
|
|
523
|
+
if _section_unfilled(_raw_phase_bodies(root, slug).get(6, ""), "### Build expectations"):
|
|
524
|
+
_die("build_expectations_unfilled: fill the §6 '### Build expectations' block "
|
|
525
|
+
f"of {slug}'s TASK.md before crossing into build")
|
|
526
|
+
if _contract_frozen(raw3):
|
|
527
|
+
if not _flag_well_formed(raw3):
|
|
528
|
+
_die("unflagged_freeze: a frozen §3 must surface a well-formed "
|
|
529
|
+
"'Least-sure flag surfaced at freeze:' unit (>=1 [part] tag "
|
|
530
|
+
"+ substantive content; bare 'none' only as 'none material — "
|
|
531
|
+
"biggest risk: X') before crossing into build")
|
|
532
|
+
state["tasks"][slug]["flag_verified"] = True
|
|
533
|
+
# tamper tripwire (verify-integrity): snapshot the red test files + the frozen
|
|
534
|
+
# §3 md5s so the verify gate can prove the green was EARNED, not edited into
|
|
535
|
+
# place. UNCONDITIONAL overwrite — a legit change-request that re-crosses
|
|
536
|
+
# tests->build re-snapshots cleanly. Co-witnessed by flag_verified (above).
|
|
537
|
+
state["tasks"][slug]["tripwire"] = _tripwire_snapshot(root, slug, raw3)
|
|
538
|
+
# §5 scope gate (build-scope-lock): when the task declares its Scope, freeze
|
|
539
|
+
# the project tree into a sidecar (payload) + a state.json anchor (md5 of the
|
|
540
|
+
# sidecar bytes). Same UNCONDITIONAL-overwrite semantics as the tripwire.
|
|
541
|
+
# UNDECLARED (no Scope line) takes no snapshot — grandfathered, never retro-red
|
|
542
|
+
# — and CLEANS UP a previous declaration's leftovers (v3): a declared->
|
|
543
|
+
# undeclared re-cross pops the stale anchor + unlinks the stale sidecar, so
|
|
544
|
+
# "UNDECLARED is never refused" holds on every path.
|
|
545
|
+
declared = _declared_scope(root, slug)
|
|
546
|
+
side = root / "tasks" / slug / "scope-snapshot.json"
|
|
547
|
+
if declared is not None:
|
|
548
|
+
payload = json.dumps({"version": 1,
|
|
549
|
+
"files": _scope_walk(root.parent.resolve())},
|
|
550
|
+
sort_keys=True)
|
|
551
|
+
_atomic_write(side, payload) # temp+replace, like save_state — a crash can't leave a
|
|
552
|
+
# torn sidecar (payload verbatim, no newline → md5 anchor holds)
|
|
553
|
+
state["tasks"][slug]["scope"] = {"declared": declared,
|
|
554
|
+
"snapshot_md5": _md5_text(payload)}
|
|
555
|
+
else:
|
|
556
|
+
state["tasks"][slug].pop("scope", None)
|
|
557
|
+
try:
|
|
558
|
+
side.unlink()
|
|
559
|
+
except OSError:
|
|
560
|
+
pass
|
|
561
|
+
|
|
562
|
+
|
|
1037
563
|
def cmd_phase(args: argparse.Namespace) -> None:
|
|
1038
564
|
root = _require_root()
|
|
1039
565
|
state = load_state(root)
|
|
1040
566
|
slug = _resolve_task(state, args.slug)
|
|
1041
567
|
if args.phase not in PHASES:
|
|
1042
568
|
_die(f"phase must be one of: {', '.join(PHASES)}")
|
|
569
|
+
# phase-build-guard (F4): the admin override is NOT a backdoor around the tests->build gate
|
|
570
|
+
# stack — setting a task to build runs the SAME _build_entry guards `advance` runs (freeze
|
|
571
|
+
# gate · build-expectations · flag check · tamper tripwire · scope snapshot), so verify's
|
|
572
|
+
# _tamper_guard is armed and a freeze-gated DRAFT §3 is refused. Other targets are unchanged.
|
|
573
|
+
# validate-then-write: a refusal raises BEFORE the phase is set, so nothing moves. The heal
|
|
574
|
+
# loop sets phase=build directly (never via cmd_phase) and so stays exempt.
|
|
575
|
+
if args.phase == "build":
|
|
576
|
+
_build_entry(root, state, slug)
|
|
1043
577
|
state["tasks"][slug]["phase"] = args.phase
|
|
1044
578
|
state["tasks"][slug]["updated"] = _now()
|
|
1045
|
-
|
|
1046
|
-
|
|
579
|
+
save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
|
|
580
|
+
_sync_task_marker(root, slug, args.phase) # then mirror into TASK.md (best-effort) — no split-brain
|
|
1047
581
|
print(f"task '{slug}' phase -> {args.phase}")
|
|
1048
582
|
print(_next_footer(root, state))
|
|
1049
583
|
|
|
@@ -1061,73 +595,71 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
1061
595
|
# into build/verify/observe/done is refused until `add.py lock`.
|
|
1062
596
|
if not _setup_locked(state) and nxt in ("build", "verify", "observe", "done"):
|
|
1063
597
|
_die("setup_unlocked: lock the foundation first — add.py lock")
|
|
598
|
+
# intra-milestone cross-component HOLD (cross-component-milestone): a consumer of a DECLARED
|
|
599
|
+
# contract may not enter §3 (scenarios->contract) until its producer froze — proven by the
|
|
600
|
+
# snapshot the producer's contract->tests crossing writes (task 3). This orders a BE→FE slice
|
|
601
|
+
# inside ONE milestone (the FE stays downstream of the frozen endpoint). Validate-before-write:
|
|
602
|
+
# the HARD-STOP precedes the phase bump, so the task stays at `scenarios`. Undeclared id / no
|
|
603
|
+
# `consumes:` header -> no hold (byte-identical; a typo'd id is a cmd_check registry finding).
|
|
604
|
+
if nxt == "contract":
|
|
605
|
+
_cid = _task_consumes(root, slug)
|
|
606
|
+
_cmap = _contracts(root)
|
|
607
|
+
if _cid and _cid in _cmap and not _contract_snapshot(root, _cid).exists():
|
|
608
|
+
_die(f"producer_contract_unfrozen: the producer '{_cmap[_cid].get('producer', '?')}' of "
|
|
609
|
+
f"contract '{_cid}' must freeze its contract before you write §3 — wait for "
|
|
610
|
+
f".add/contracts/{_cid}.json")
|
|
1064
611
|
# flag-first freeze guard (task unflagged-freeze): a FROZEN §3 may not cross
|
|
1065
612
|
# into build without a WELL-FORMED lowest-confidence flag. On pass, stamp the
|
|
1066
613
|
# verified marker so `audit` enforces the flag on THIS record only (open/new
|
|
1067
614
|
# freezes — the unmarked predecessors are never retro-redded). REFUSE writes
|
|
1068
615
|
# nothing (fail-closed); below the build boundary the flag is never checked.
|
|
1069
616
|
if nxt == "build":
|
|
1070
|
-
# the
|
|
1071
|
-
#
|
|
1072
|
-
#
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
# tests->build re-snapshots cleanly. Co-witnessed by flag_verified (above).
|
|
1104
|
-
state["tasks"][slug]["tripwire"] = _tripwire_snapshot(root, slug, raw3)
|
|
1105
|
-
# §5 scope gate (build-scope-lock): when the task declares its Scope, freeze
|
|
1106
|
-
# the project tree into a sidecar (payload) + a state.json anchor (md5 of the
|
|
1107
|
-
# sidecar bytes). Same UNCONDITIONAL-overwrite semantics as the tripwire.
|
|
1108
|
-
# UNDECLARED (no Scope line) takes no snapshot — grandfathered, never retro-red
|
|
1109
|
-
# — and CLEANS UP a previous declaration's leftovers (v3): a declared->
|
|
1110
|
-
# undeclared re-cross pops the stale anchor + unlinks the stale sidecar, so
|
|
1111
|
-
# "UNDECLARED is never refused" holds on every path.
|
|
1112
|
-
declared = _declared_scope(root, slug)
|
|
1113
|
-
side = root / "tasks" / slug / "scope-snapshot.json"
|
|
1114
|
-
if declared is not None:
|
|
1115
|
-
payload = json.dumps({"version": 1,
|
|
1116
|
-
"files": _scope_walk(root.parent.resolve())},
|
|
1117
|
-
sort_keys=True)
|
|
1118
|
-
side.write_text(payload, encoding="utf-8")
|
|
1119
|
-
state["tasks"][slug]["scope"] = {"declared": declared,
|
|
1120
|
-
"snapshot_md5": _md5_text(payload)}
|
|
1121
|
-
else:
|
|
1122
|
-
state["tasks"][slug].pop("scope", None)
|
|
617
|
+
# the tests->build entry guards + snapshots now live in the shared _build_entry helper
|
|
618
|
+
# (task phase-build-guard, F4) so `advance` and the `phase build` admin override run the
|
|
619
|
+
# IDENTICAL gate stack. Behavior here is byte-unchanged — this is a pure extraction.
|
|
620
|
+
_build_entry(root, state, slug)
|
|
621
|
+
# cross-component contract artifact (cross-component-contract): the contract->tests crossing
|
|
622
|
+
# is the producer's freeze-approval moment. A `produces:` task WRITES the immutable snapshot;
|
|
623
|
+
# a `consumes:` task PINS the live hash — a missing/unreadable snapshot HARD-STOPS here (the
|
|
624
|
+
# phase stays at `contract`, nothing pinned), so a consumer never builds against a guessed
|
|
625
|
+
# shape. No role / no contracts ⇒ neither branch is taken (byte-identical).
|
|
626
|
+
if nxt == "tests":
|
|
627
|
+
_prod = _task_produces(root, slug)
|
|
628
|
+
if _prod:
|
|
629
|
+
raw3c = _raw_phase_bodies(root, slug).get(3, "")
|
|
630
|
+
vm = re.search(r"FROZEN @ (v\d+)", raw3c)
|
|
631
|
+
snap = {"id": _prod, "producer": (_contracts(root).get(_prod) or {}).get("producer", "?"),
|
|
632
|
+
"task": slug, "version": vm.group(1) if vm else "?",
|
|
633
|
+
"frozen": date.today().isoformat(), "hash": _contract_body_hash(raw3c)}
|
|
634
|
+
sp = _contract_snapshot(root, _prod)
|
|
635
|
+
cur_snap = None
|
|
636
|
+
if sp.exists():
|
|
637
|
+
try:
|
|
638
|
+
cur_snap = json.loads(sp.read_text(encoding="utf-8"))
|
|
639
|
+
except (OSError, ValueError):
|
|
640
|
+
cur_snap = None
|
|
641
|
+
# idempotent: re-write only when the shape-hash or version actually changed (so a
|
|
642
|
+
# no-op re-cross leaves the file — and its `frozen` date — byte-identical).
|
|
643
|
+
if not (cur_snap and cur_snap.get("hash") == snap["hash"]
|
|
644
|
+
and cur_snap.get("version") == snap["version"]):
|
|
645
|
+
sp.parent.mkdir(parents=True, exist_ok=True)
|
|
646
|
+
_atomic_write(sp, json.dumps(snap, sort_keys=True))
|
|
647
|
+
_cons = _task_consumes(root, slug)
|
|
648
|
+
if _cons:
|
|
649
|
+
sp = _contract_snapshot(root, _cons)
|
|
1123
650
|
try:
|
|
1124
|
-
|
|
1125
|
-
except OSError:
|
|
1126
|
-
|
|
651
|
+
pinned = json.loads(sp.read_text(encoding="utf-8")).get("hash")
|
|
652
|
+
except (OSError, ValueError, AttributeError):
|
|
653
|
+
pinned = None
|
|
654
|
+
if not pinned: # absent / unreadable / valid-JSON-but-no-hash all fail loud
|
|
655
|
+
_die(f"contract_snapshot_missing: no readable hashed .add/contracts/{_cons}.json — the "
|
|
656
|
+
f"producer of '{_cons}' must freeze its contract first "
|
|
657
|
+
"(never build against a guessed shape)")
|
|
658
|
+
state["tasks"][slug]["contract_pin"] = {"id": _cons, "hash": pinned}
|
|
1127
659
|
state["tasks"][slug]["phase"] = nxt
|
|
1128
660
|
state["tasks"][slug]["updated"] = _now()
|
|
1129
|
-
|
|
1130
|
-
|
|
661
|
+
save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
|
|
662
|
+
_sync_task_marker(root, slug, nxt) # then mirror into TASK.md (best-effort) — no split-brain
|
|
1131
663
|
print(f"task '{slug}' phase {cur} -> {nxt}")
|
|
1132
664
|
if nxt == "observe":
|
|
1133
665
|
# OBSERVE is where this loop's lessons get captured (TASK.md §7) — suggest routing
|
|
@@ -1155,25 +687,18 @@ _RISK_HIGH_RE = re.compile(r"(?:^|·)[ \t]*risk:[ \t]*high\b", re.MULTILINE)
|
|
|
1155
687
|
|
|
1156
688
|
# the explicit 3-mode autonomy dial (task explicit-autonomy-dial): an ordered ladder
|
|
1157
689
|
# manual < conservative < auto, declared as a per-task `autonomy:` header token.
|
|
1158
|
-
_AUTONOMY_LEVELS = ("manual", "conservative", "auto")
|
|
1159
690
|
# anchored to a DECLARATION position — line-start `autonomy:` OR the inline slug-line form
|
|
1160
691
|
# `… · autonomy: conservative` (the `·`-preceded shape) — never a title/prose substring; the
|
|
1161
692
|
# value stops at space/`<`/`#`/`|` so an unfilled `<manual | … >` placeholder captures nothing
|
|
1162
693
|
# and reads as UNSET.
|
|
1163
|
-
_AUTONOMY_LINE_RE = re.compile(r"(?:^|·)[ \t]*autonomy:[ \t]*([^\s<#|]+)", re.MULTILINE)
|
|
1164
|
-
|
|
1165
694
|
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
if not m:
|
|
1174
|
-
return None
|
|
1175
|
-
tok = m.group(1).strip().lower()
|
|
1176
|
-
return tok if tok in _AUTONOMY_LEVELS else "?"
|
|
695
|
+
# component-aware-add: a task binds to a registered component via a `component: <name>`
|
|
696
|
+
# header token — the SAME anchored grammar as autonomy (line-start or the `·`-inline
|
|
697
|
+
# slug-line form), and an unfilled `<name>` placeholder captures nothing (reads UNBOUND).
|
|
698
|
+
_COMPONENT_LINE_RE = re.compile(r"(?:^|·)[ \t]*component:[ \t]*([^\s<#|]+)", re.MULTILINE)
|
|
699
|
+
# cross-component-contract: a task's role in a cross-component seam — same anchored grammar.
|
|
700
|
+
_PRODUCES_LINE_RE = re.compile(r"(?:^|·)[ \t]*produces:[ \t]*([^\s<#|]+)", re.MULTILINE)
|
|
701
|
+
_CONSUMES_LINE_RE = re.compile(r"(?:^|·)[ \t]*consumes:[ \t]*([^\s<#|]+)", re.MULTILINE)
|
|
1177
702
|
|
|
1178
703
|
|
|
1179
704
|
def _autonomy_lowered(hdr: str) -> bool:
|
|
@@ -1182,26 +707,6 @@ def _autonomy_lowered(hdr: str) -> bool:
|
|
|
1182
707
|
return _autonomy_level(hdr) in ("manual", "conservative")
|
|
1183
708
|
|
|
1184
709
|
|
|
1185
|
-
def _task_header(root: Path, slug: str) -> str:
|
|
1186
|
-
"""The TASK.md header region — where declared tokens (risk · autonomy)
|
|
1187
|
-
live — with HTML comments stripped. Missing file -> '' (no tokens)."""
|
|
1188
|
-
try:
|
|
1189
|
-
text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8")
|
|
1190
|
-
except OSError:
|
|
1191
|
-
return ""
|
|
1192
|
-
return re.sub(r"<!--.*?-->", "", text.split("\n## ", 1)[0], flags=re.S)
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
def _effective_autonomy(root: Path, state: dict, slug: str) -> str:
|
|
1196
|
-
"""The autonomy rung that governs `slug` right now: the task's own declared rung,
|
|
1197
|
-
falling back to the project default when the task line is UNSET (None) or an
|
|
1198
|
-
unrecognized token ("?") — the same fail-safe chain cmd_new_task seeds from
|
|
1199
|
-
(_project_autonomy: absent -> auto, garbled -> conservative). PURE. `state` is unused
|
|
1200
|
-
today; it is kept in the signature beside _driver_stop for symmetry."""
|
|
1201
|
-
lvl = _autonomy_level(_task_header(root, slug))
|
|
1202
|
-
return lvl if lvl in _AUTONOMY_LEVELS else _project_autonomy(root)
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
710
|
def _driver_stop(root: Path, state: dict, slug: str, phase: str) -> bool:
|
|
1206
711
|
"""True iff a HUMAN owns the next step for `phase` under the effective autonomy — the
|
|
1207
712
|
SINGLE source the footer marker and the guide TEXT marker both render (task
|
|
@@ -1260,6 +765,22 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
1260
765
|
# §5 scope gate (build-scope-lock): touched ⊆ declared, or a named refusal —
|
|
1261
766
|
# same placement discipline as the tripwire (before the waiver, never on HARD-STOP).
|
|
1262
767
|
_scope_guard(root, state, slug)
|
|
768
|
+
# consumer-stale gate (consumer-stale-gate): a `consumes:` task whose pinned producer
|
|
769
|
+
# contract hash has drifted from the live snapshot built against an out-of-date shape —
|
|
770
|
+
# refuse the completing outcome (same before-the-waiver discipline). Re-pin to recover.
|
|
771
|
+
_consumer_stale_guard(root, state, slug)
|
|
772
|
+
# per-component verify (component-aware-add): a component-bound task with a declared
|
|
773
|
+
# green_bar must CITE that bar in its §6 evidence before a completing outcome — the
|
|
774
|
+
# engine never runs the suite, it checks the right bar was recorded. Unbound / no
|
|
775
|
+
# green_bar -> _bar is None -> this is skipped (byte-identical). HARD-STOP never here.
|
|
776
|
+
_bar = _task_green_bar(root, slug)
|
|
777
|
+
# the cite must live in the user-authored Build-expectations evidence region (_cite_region,
|
|
778
|
+
# v3): excludes the §6 checklist boilerplate + the GATE RECORD template/stamp, works for both
|
|
779
|
+
# the standard and fast-lane §6 shapes. Unbound / no green_bar -> _bar None -> skipped.
|
|
780
|
+
if _bar and _bar not in _cite_region(_raw_phase_bodies(root, slug).get(6, "")):
|
|
781
|
+
_die(f"component_green_bar_uncited: task '{slug}' is bound to component "
|
|
782
|
+
f"'{_task_component(root, slug)}'; its §6 Build-expectations must cite the "
|
|
783
|
+
f"green-bar '{_bar}' — record the evidence that bar was met before PASS")
|
|
1263
784
|
if args.outcome == "RISK-ACCEPTED":
|
|
1264
785
|
# A waiver must be SIGNED: owner, ticket, expiry (glossary). Stored in state
|
|
1265
786
|
# so a later `check` can read/expire it. Refuse a partial waiver outright.
|
|
@@ -1272,13 +793,17 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
1272
793
|
}
|
|
1273
794
|
if completing:
|
|
1274
795
|
state["tasks"][slug]["phase"] = "done"
|
|
1275
|
-
_sync_task_marker(root, slug, "done")
|
|
1276
796
|
state["tasks"][slug]["gate"] = args.outcome
|
|
1277
|
-
state["tasks"][slug]["gate_actor"] = _actor_stamp(state) # WHO recorded the verdict (every outcome)
|
|
797
|
+
state["tasks"][slug]["gate_actor"] = identity._actor_stamp(state) # WHO recorded the verdict (every outcome)
|
|
1278
798
|
state["tasks"][slug]["updated"] = _now()
|
|
1279
|
-
save_state(root, state)
|
|
799
|
+
save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
|
|
800
|
+
if completing:
|
|
801
|
+
_sync_task_marker(root, slug, "done") # then mirror the phase into TASK.md — no split-brain
|
|
1280
802
|
_stamp_gate_record(root, state, slug, args.outcome) # mirror the verdict into §6 (Finding C)
|
|
1281
803
|
print(f"task '{slug}' gate -> {args.outcome}")
|
|
804
|
+
_gbar = _task_green_bar(root, slug) # per-component-verify: surface the bound bar
|
|
805
|
+
if _gbar:
|
|
806
|
+
print(f"component: {_task_component(root, slug)} · expected green-bar: {_gbar}")
|
|
1282
807
|
# the engine-sourced next step (next-footer-engine): a completing gate hands off to the
|
|
1283
808
|
# state arm; HARD-STOP routes to "resolve HARD-STOP …" — converging the old bespoke line.
|
|
1284
809
|
print(_next_footer(root, state))
|
|
@@ -1358,6 +883,45 @@ def cmd_autonomy(args: argparse.Namespace) -> None:
|
|
|
1358
883
|
_print_autonomy(root, state, slug)
|
|
1359
884
|
|
|
1360
885
|
|
|
886
|
+
def cmd_todo(args: argparse.Namespace) -> None:
|
|
887
|
+
"""Capture / list / close a lightweight backlog todo (task: todo-capture).
|
|
888
|
+
|
|
889
|
+
A todo is a JOTTED IDEA, not a task — it carries no spec/contract/gate. It lets you
|
|
890
|
+
record an intent without sizing it. Promote one to a real task with
|
|
891
|
+
`add.py new-task <slug> --fast` when you decide to build it. Stored in state["todos"]
|
|
892
|
+
as {id (1-based = max+1), text, created, status:"open"|"done"}.
|
|
893
|
+
"""
|
|
894
|
+
root = _require_root() # reused -> "no .add/ project found …"
|
|
895
|
+
state = load_state(root)
|
|
896
|
+
todos = state.get("todos")
|
|
897
|
+
if not isinstance(todos, list): # absent / corrupt -> fresh list (drift-safe)
|
|
898
|
+
todos = state["todos"] = []
|
|
899
|
+
done_id = getattr(args, "done", None)
|
|
900
|
+
if done_id is not None: # --done <id> : close an OPEN todo
|
|
901
|
+
for t in todos:
|
|
902
|
+
if isinstance(t, dict) and t.get("id") == done_id and t.get("status") == "open":
|
|
903
|
+
t["status"] = "done"
|
|
904
|
+
save_state(root, state)
|
|
905
|
+
print(f"todo #{done_id} done")
|
|
906
|
+
return
|
|
907
|
+
_die(f"todo_unknown: no open todo #{done_id}")
|
|
908
|
+
if args.text is not None: # capture attempt (text positional present)
|
|
909
|
+
text = args.text.strip()
|
|
910
|
+
if not text:
|
|
911
|
+
_die("todo_empty: a todo needs text")
|
|
912
|
+
new_id = max((t.get("id", 0) for t in todos if isinstance(t, dict)), default=0) + 1
|
|
913
|
+
todos.append({"id": new_id, "text": text, "created": _now(), "status": "open"})
|
|
914
|
+
save_state(root, state)
|
|
915
|
+
print(f"captured todo #{new_id}: {text}")
|
|
916
|
+
return
|
|
917
|
+
open_todos = [t for t in todos if isinstance(t, dict) and t.get("status") == "open"]
|
|
918
|
+
if not open_todos: # bare `todo` -> list OPEN todos
|
|
919
|
+
print("no open todos")
|
|
920
|
+
return
|
|
921
|
+
for t in open_todos:
|
|
922
|
+
print(f"#{t.get('id')} {t.get('text')}")
|
|
923
|
+
|
|
924
|
+
|
|
1361
925
|
def cmd_reopen(args: argparse.Namespace) -> None:
|
|
1362
926
|
"""Return an already-`done` task to an earlier phase with a never-silent record.
|
|
1363
927
|
|
|
@@ -1392,8 +956,8 @@ def cmd_reopen(args: argparse.Namespace) -> None:
|
|
|
1392
956
|
t["phase"] = target
|
|
1393
957
|
t["gate"] = "none"
|
|
1394
958
|
t["updated"] = now
|
|
1395
|
-
|
|
1396
|
-
|
|
959
|
+
save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
|
|
960
|
+
_sync_task_marker(root, slug, target) # then mirror into TASK.md (best-effort) — no split-brain
|
|
1397
961
|
print(f"task '{slug}' reopened: done -> {target} (reason recorded); gate reset to none")
|
|
1398
962
|
print(_next_footer(root, state))
|
|
1399
963
|
|
|
@@ -1444,7 +1008,7 @@ def cmd_lock(args: argparse.Namespace) -> None:
|
|
|
1444
1008
|
when = _now()
|
|
1445
1009
|
# ONE atomic write — no partial lock state.
|
|
1446
1010
|
state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers,
|
|
1447
|
-
"actor": _actor_stamp(state)} # structured actor alongside the free-text locked_by
|
|
1011
|
+
"actor": identity._actor_stamp(state)} # structured actor alongside the free-text locked_by
|
|
1448
1012
|
save_state(root, state)
|
|
1449
1013
|
if getattr(args, "json", False):
|
|
1450
1014
|
print(json.dumps(
|
|
@@ -1471,7 +1035,7 @@ def cmd_whoami(args: argparse.Namespace) -> None:
|
|
|
1471
1035
|
_die("actor_name_blank")
|
|
1472
1036
|
state["actor_override"] = {"name": args.name, "email": args.email or None}
|
|
1473
1037
|
save_state(root, state)
|
|
1474
|
-
who = _whoami(state)
|
|
1038
|
+
who = identity._whoami(state)
|
|
1475
1039
|
if getattr(args, "json", False):
|
|
1476
1040
|
print(json.dumps(who, separators=(",", ":")))
|
|
1477
1041
|
return
|
|
@@ -1500,14 +1064,14 @@ def cmd_assign(args: argparse.Namespace) -> None:
|
|
|
1500
1064
|
_die("unknown_slug")
|
|
1501
1065
|
# parse + validate ALL flags BEFORE the first write — a blank name is rejected on the
|
|
1502
1066
|
# PARSED name (so "<>" or " <a@x.io>", whose name parses empty, is caught like " ").
|
|
1503
|
-
parsed_owner = _parse_actor_arg(args.owner) if args.owner is not None else None
|
|
1504
|
-
parsed_assignee = _parse_actor_arg(args.assignee) if args.assignee is not None else None
|
|
1067
|
+
parsed_owner = identity._parse_actor_arg(args.owner) if args.owner is not None else None
|
|
1068
|
+
parsed_assignee = identity._parse_actor_arg(args.assignee) if args.assignee is not None else None
|
|
1505
1069
|
if parsed_owner is not None and not parsed_owner["name"].strip():
|
|
1506
1070
|
_die("owner_name_blank")
|
|
1507
1071
|
if parsed_assignee is not None and not parsed_assignee["name"].strip():
|
|
1508
1072
|
_die("assignee_name_blank")
|
|
1509
1073
|
if parsed_owner is None and parsed_assignee is None:
|
|
1510
|
-
who = _whoami(state)
|
|
1074
|
+
who = identity._whoami(state)
|
|
1511
1075
|
rec["owner"] = dict(who)
|
|
1512
1076
|
rec["assignee"] = dict(who)
|
|
1513
1077
|
else:
|
|
@@ -1541,15 +1105,6 @@ def cmd_unassign(args: argparse.Namespace) -> None:
|
|
|
1541
1105
|
print(f"unassigned {args.slug} ({', '.join(roles)})")
|
|
1542
1106
|
|
|
1543
1107
|
|
|
1544
|
-
def _has_production_roadmap(state: dict) -> bool:
|
|
1545
|
-
"""True iff ≥1 milestone in state has stage == "production" (STATUS-AGNOSTIC).
|
|
1546
|
-
The single source of the stage-graduation floor (v22 graduate-guide): the guard counts
|
|
1547
|
-
that a production-roadmap RECORD exists — it never judges whether those milestones are
|
|
1548
|
-
done/good/sufficient (gather-not-judge). An archived-out-of-state roadmap falls to --force."""
|
|
1549
|
-
return any(m.get("stage") == "production"
|
|
1550
|
-
for m in state.get("milestones", {}).values())
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
1108
|
def cmd_stage(args: argparse.Namespace) -> None:
|
|
1554
1109
|
root = _require_root()
|
|
1555
1110
|
state = load_state(root)
|
|
@@ -1575,6 +1130,38 @@ def cmd_stage(args: argparse.Namespace) -> None:
|
|
|
1575
1130
|
print(_next_footer(root, state))
|
|
1576
1131
|
|
|
1577
1132
|
|
|
1133
|
+
def _done_resume(root: Path, state: dict, slug: str) -> tuple[str, str, str]:
|
|
1134
|
+
"""At a DONE task, what should the agent do NEXT? Classify from the task's
|
|
1135
|
+
milestone exit-criteria tally (_exit_criteria) so the orient surfaces (status,
|
|
1136
|
+
guide) STEER into the loop instead of always saying "start the next feature".
|
|
1137
|
+
|
|
1138
|
+
Returns (headline, next_step, chapter) where chapter is a docs/ filename:
|
|
1139
|
+
LOOP-JUNCTURE total>0 and met<total -> name the unmet goal, route to the loop
|
|
1140
|
+
GOAL-MET total>0 and met==total -> point at milestone-done
|
|
1141
|
+
PLAIN no criteria / no milestone / any read error -> today's "next feature"
|
|
1142
|
+
PURE and fail-closed (design-for-failure): a missing milestone or unreadable
|
|
1143
|
+
MILESTONE.md degrades to PLAIN — it never raises into a status/guide print path."""
|
|
1144
|
+
PLAIN = ("this task is done",
|
|
1145
|
+
"start the next feature -> add.py new-task <slug>",
|
|
1146
|
+
"02-the-flow.md")
|
|
1147
|
+
try:
|
|
1148
|
+
ms = ((state.get("tasks") or {}).get(slug) or {}).get("milestone")
|
|
1149
|
+
if not ms:
|
|
1150
|
+
return PLAIN
|
|
1151
|
+
met, total = _exit_criteria(root, ms)
|
|
1152
|
+
except Exception: # noqa: BLE001 — never break orient output
|
|
1153
|
+
return PLAIN
|
|
1154
|
+
if total > 0 and met < total:
|
|
1155
|
+
return (f"milestone '{ms}' goal not met ({met}/{total} exit criteria)",
|
|
1156
|
+
"propose the next tasks from open deltas / the unscaffolded plan -> add.py deltas",
|
|
1157
|
+
"09-the-loop.md")
|
|
1158
|
+
if total > 0 and met == total:
|
|
1159
|
+
return (f"milestone '{ms}' goal met ({met}/{total})",
|
|
1160
|
+
f"close it -> add.py milestone-done {ms}",
|
|
1161
|
+
"09-the-loop.md")
|
|
1162
|
+
return PLAIN
|
|
1163
|
+
|
|
1164
|
+
|
|
1578
1165
|
def cmd_status(args: argparse.Namespace) -> None:
|
|
1579
1166
|
if getattr(args, "json", False):
|
|
1580
1167
|
root, state = _load_state_for_json()
|
|
@@ -1590,7 +1177,7 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1590
1177
|
grad_ready, grad_met, grad_total = _graduation_ready(root, state)
|
|
1591
1178
|
print(json.dumps({
|
|
1592
1179
|
"project": state.get("project"), "stage": state.get("stage"),
|
|
1593
|
-
"actor": _whoami(state),
|
|
1180
|
+
"actor": identity._whoami(state),
|
|
1594
1181
|
"active_task": _active_task(state),
|
|
1595
1182
|
"active_milestones": list(state.get("active_milestones") or []),
|
|
1596
1183
|
"active_tasks": dict(state.get("active_tasks") or {}),
|
|
@@ -1615,7 +1202,7 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1615
1202
|
print(f"project autonomy: {_project_autonomy(root)} (default — new tasks inherit)")
|
|
1616
1203
|
# git-native actor (user-identity): who ADD sees you as this session — the identity every
|
|
1617
1204
|
# human-owned stamp records. Always present (the resolver is TOTAL). Read-only, no write.
|
|
1618
|
-
_who = _whoami(state)
|
|
1205
|
+
_who = identity._whoami(state)
|
|
1619
1206
|
_who_email = f" <{_who['email']}>" if _who.get("email") else ""
|
|
1620
1207
|
print(f"actor : {_who['name']}{_who_email} (source: {_who['source']})")
|
|
1621
1208
|
print(f"stage : {state.get('stage', '(unknown)')}")
|
|
@@ -1682,6 +1269,12 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1682
1269
|
_rel = _releasable(root, state)
|
|
1683
1270
|
if _rel:
|
|
1684
1271
|
print(f" → {RELEASABLE_CUE.format(n=len(_rel))}")
|
|
1272
|
+
# loose-task release cue (loose-task-release): a SEPARATE additive line — done milestone-free
|
|
1273
|
+
# tasks not yet attributed to a RELEASES.md `loose tasks:` row. Peer to the milestone cue (its
|
|
1274
|
+
# constant is untouched); fires even with zero releasable milestones. Fail-open ledger read.
|
|
1275
|
+
_loose = _releasable_loose_tasks(root, state)
|
|
1276
|
+
if _loose:
|
|
1277
|
+
print(f" → releasable: {len(_loose)} loose task(s) since last release")
|
|
1685
1278
|
|
|
1686
1279
|
# fast-lane marker (fast-new-task-flag): tag an ACTIVE fast task so the lane is visible at a
|
|
1687
1280
|
# glance. Presentation-only, existence-gated — a plain/absent active task is byte-unchanged.
|
|
@@ -1769,8 +1362,16 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1769
1362
|
elif active and active in tasks:
|
|
1770
1363
|
ph = tasks[active]["phase"]
|
|
1771
1364
|
if ph == "done":
|
|
1365
|
+
# loop-aware resume (loop-aware-orient): a done task is NOT always "start the
|
|
1366
|
+
# next feature" — if its milestone goal is unmet we are at the loop juncture, so
|
|
1367
|
+
# STEER into the loop; if met, point at the close. PLAIN stays byte-identical.
|
|
1368
|
+
_hl, _nxt, _chap = _done_resume(root, state, active)
|
|
1772
1369
|
print(f"\nresume : task '{active}' is done ({tasks[active]['gate']}).")
|
|
1773
|
-
|
|
1370
|
+
if _chap == "02-the-flow.md":
|
|
1371
|
+
print(" start the next feature: add.py new-task <slug>")
|
|
1372
|
+
else:
|
|
1373
|
+
print(f" {_hl} — {_nxt}")
|
|
1374
|
+
print(f" (the loop: .add/docs/{_chap})")
|
|
1774
1375
|
else:
|
|
1775
1376
|
print(f"\nresume : task '{active}' is at phase '{ph}'.")
|
|
1776
1377
|
print(f" read .add/tasks/{active}/TASK.md and continue that phase.")
|
|
@@ -1820,6 +1421,10 @@ def cmd_guide(args: argparse.Namespace) -> None:
|
|
|
1820
1421
|
phase = t.get("phase")
|
|
1821
1422
|
owner = _phase_owner(phase) # _die unmapped_phase before any stdout
|
|
1822
1423
|
action, chapter = PHASE_GUIDE[phase] # phase is mapped, so PHASE_GUIDE has it too
|
|
1424
|
+
if phase == "done": # loop-aware-orient: steer the --json surface too
|
|
1425
|
+
_hl, _nxt, _chap = _done_resume(json_root, state, slug)
|
|
1426
|
+
if _chap != "02-the-flow.md": # loop juncture / goal met; PLAIN stays unchanged
|
|
1427
|
+
action, chapter = _nxt, _chap
|
|
1823
1428
|
print(json.dumps({"task": slug, "phase": phase, "owner": owner,
|
|
1824
1429
|
"stop": owner != "ai", "next_step": action,
|
|
1825
1430
|
"chapter": f".add/docs/{chapter}", "gate": t.get("gate"),
|
|
@@ -1840,6 +1445,10 @@ def cmd_guide(args: argparse.Namespace) -> None:
|
|
|
1840
1445
|
if entry is None: # corrupted/hand-edited state.json — fail clean, not KeyError
|
|
1841
1446
|
_die(f"task '{slug}' has unknown phase '{phase}' (state.json corrupted?)")
|
|
1842
1447
|
action, chapter = entry
|
|
1448
|
+
if phase == "done": # loop-aware-orient: steer at the loop juncture
|
|
1449
|
+
_hl, _nxt, _chap = _done_resume(root, state, slug)
|
|
1450
|
+
if _chap != "02-the-flow.md": # loop juncture / goal met; PLAIN stays unchanged
|
|
1451
|
+
action, chapter = _nxt, _chap
|
|
1843
1452
|
# the guide names the driver too (task gate-owner-marker) — the SAME _driver_stop the
|
|
1844
1453
|
# footer renders, on the next-step line. Computed AFTER the unknown-phase guard above,
|
|
1845
1454
|
# so a bad phase fails clean and never reaches the marker (it invents no default).
|
|
@@ -1856,7 +1465,10 @@ def cmd_guide(args: argparse.Namespace) -> None:
|
|
|
1856
1465
|
if phase == "verify":
|
|
1857
1466
|
print("then : add.py gate PASS | RISK-ACCEPTED | HARD-STOP")
|
|
1858
1467
|
elif phase == "done":
|
|
1859
|
-
|
|
1468
|
+
if chapter != "02-the-flow.md": # loop juncture / goal met -> the steered command
|
|
1469
|
+
print(f"then : {action}")
|
|
1470
|
+
else:
|
|
1471
|
+
print("then : start the next feature -> add.py new-task <slug>")
|
|
1860
1472
|
else:
|
|
1861
1473
|
print("then : add.py advance")
|
|
1862
1474
|
|
|
@@ -2292,6 +1904,41 @@ def _missing_captures(root: Path) -> list[str]:
|
|
|
2292
1904
|
if not any((cap_dir / f"{n}.{ext}").is_file() for ext in _CAPTURE_EXTS)]
|
|
2293
1905
|
|
|
2294
1906
|
|
|
1907
|
+
def cmd_federate(args: argparse.Namespace) -> None:
|
|
1908
|
+
"""Multi-repo federation: pull a producer repo's published, immutable contract snapshot into
|
|
1909
|
+
this repo. Mono vs multi-repo differ ONLY in snapshot-transport — this lands the byte-copy at
|
|
1910
|
+
the SAME local `.add/contracts/<id>.json` the monorepo path (tasks 3/4) already reads, so a
|
|
1911
|
+
`consumes: <id>` task then holds/pins identically. Designed-for-failure: unknown / missing /
|
|
1912
|
+
invalid / version-mismatched sources HARD-STOP and land NOTHING (never build blind)."""
|
|
1913
|
+
root = find_root()
|
|
1914
|
+
if root is None:
|
|
1915
|
+
_die("no_project")
|
|
1916
|
+
fid = args.id
|
|
1917
|
+
fed = _federation(root)
|
|
1918
|
+
if fid not in fed:
|
|
1919
|
+
_die(f"federation_unknown: no [federation.{fid}] in components.toml — declare the producer "
|
|
1920
|
+
f"repo's published snapshot source before pulling")
|
|
1921
|
+
source = (root.parent / fed[fid]["source"])
|
|
1922
|
+
try:
|
|
1923
|
+
raw = source.read_bytes() # bytes — the landed snapshot must be a byte-for-byte copy
|
|
1924
|
+
except OSError:
|
|
1925
|
+
_die(f"federation_source_missing: cannot read the producer snapshot at '{fed[fid]['source']}' "
|
|
1926
|
+
f"(resolved {source}) — publish/commit it in the producer repo first")
|
|
1927
|
+
try:
|
|
1928
|
+
snap = json.loads(raw.decode("utf-8"))
|
|
1929
|
+
except (json.JSONDecodeError, ValueError, UnicodeDecodeError):
|
|
1930
|
+
snap = None
|
|
1931
|
+
if not isinstance(snap, dict) or snap.get("id") != fid or not snap.get("hash"):
|
|
1932
|
+
_die(f"federation_snapshot_invalid: the source for '{fid}' is not a valid contract snapshot "
|
|
1933
|
+
f"(needs JSON with matching id + a hash) — refusing to land a guessed shape")
|
|
1934
|
+
pin = fed[fid]["pin"]
|
|
1935
|
+
if pin and snap.get("version") != pin:
|
|
1936
|
+
_die(f"federation_version_mismatch: [federation.{fid}] pins '{pin}' but the source is "
|
|
1937
|
+
f"'{snap.get('version')}' — bump the pin or wait for the producer to publish {pin}")
|
|
1938
|
+
_atomic_write_bytes(_contract_snapshot(root, fid), raw)
|
|
1939
|
+
print(f"federated '{fid}' {snap.get('version', '?')} {snap['hash']} from {fed[fid]['source']}")
|
|
1940
|
+
|
|
1941
|
+
|
|
2295
1942
|
def cmd_check(args: argparse.Namespace) -> None:
|
|
2296
1943
|
"""Read-only integrity check of the .add project. Exit 1 if anything fails."""
|
|
2297
1944
|
as_json = getattr(args, "json", False)
|
|
@@ -2314,6 +1961,7 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2314
1961
|
milestones = state.get("milestones") if isinstance(state.get("milestones"), dict) else {}
|
|
2315
1962
|
archived_slugs = _archived_task_slugs(state) # archived deps still resolve
|
|
2316
1963
|
warnings: list[tuple[str, str]] = [] # (name, reason) — nudges that NEVER feed `failed`
|
|
1964
|
+
infos: list[tuple[str, str]] = [] # (name, reason) — affirmations; NEVER feed `warned`/`failed`
|
|
2317
1965
|
for slug, t in tasks.items():
|
|
2318
1966
|
task_md = root / "tasks" / slug / "TASK.md"
|
|
2319
1967
|
checks.append((task_md.exists(), f"task '{slug}' has TASK.md", "file missing"))
|
|
@@ -2325,6 +1973,10 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2325
1973
|
if ms is not None:
|
|
2326
1974
|
checks.append((ms in milestones, f"task '{slug}' milestone resolves",
|
|
2327
1975
|
f"unknown milestone {ms!r}"))
|
|
1976
|
+
elif t.get("fast"):
|
|
1977
|
+
# blessed milestone-free fast lane (standalone-fast-task): a --fast task with no
|
|
1978
|
+
# milestone is DELIBERATE — a soft INFO affirmation, never a WARN/orphan nudge.
|
|
1979
|
+
infos.append((f"task '{slug}'", "— standalone fast lane (milestone-free by design)"))
|
|
2328
1980
|
else:
|
|
2329
1981
|
# warn-never-block: a task outside a milestone is a structural nudge back toward
|
|
2330
1982
|
# the intake flow — NOT a failure. Names structure, never the act of intake.
|
|
@@ -2340,6 +1992,29 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2340
1992
|
if _alvl is None and t.get("phase") not in ("done", "observe"):
|
|
2341
1993
|
warnings.append((f"task '{slug}'", "has no explicit autonomy level (implicit_autonomy) "
|
|
2342
1994
|
"— run `add.py autonomy set <level>` to set it"))
|
|
1995
|
+
# per-component-verify: a bound task whose component declares no green_bar can't be
|
|
1996
|
+
# gated on a bar — surface it (WARN, never red). Unbound / "?" -> silent.
|
|
1997
|
+
_tc = _task_component(root, slug)
|
|
1998
|
+
if _tc and _tc != "?" and not (_components(root).get(_tc) or {}).get("green_bar"):
|
|
1999
|
+
warnings.append((f"task '{slug}'", f"component_green_bar_unset — bound component '{_tc}' "
|
|
2000
|
+
"declares no green_bar; the per-component gate cannot check a bar"))
|
|
2001
|
+
# cross-component-contract: a consumer whose pinned hash drifted from the live snapshot
|
|
2002
|
+
# (the producer re-froze a CHANGED shape) — the §7-stale cue. Degrade-safe (unreadable
|
|
2003
|
+
# snapshot ⇒ no finding here; the missing-snapshot HARD-STOP lives at the advance crossing).
|
|
2004
|
+
_pin = t.get("contract_pin")
|
|
2005
|
+
if _pin:
|
|
2006
|
+
try:
|
|
2007
|
+
_live = json.loads(_contract_snapshot(root, _pin["id"]).read_text(encoding="utf-8")).get("hash")
|
|
2008
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
|
2009
|
+
_live = None
|
|
2010
|
+
if _live is None: # missing / corrupt / hash-less ⇒ SURFACE, never mask
|
|
2011
|
+
warnings.append((f"task '{slug}'", f"contract_snapshot_unreadable — pinned contract "
|
|
2012
|
+
f"'{_pin.get('id')}' snapshot is missing or corrupt; re-publish the "
|
|
2013
|
+
"producer contract (cannot confirm the pin is current)"))
|
|
2014
|
+
elif _live != _pin.get("hash"):
|
|
2015
|
+
warnings.append((f"task '{slug}'", f"contract_consumer_stale — pinned contract "
|
|
2016
|
+
f"'{_pin.get('id')}' changed shape since pin; re-pin (re-cross contract→tests) "
|
|
2017
|
+
"after reviewing the producer's new frozen shape"))
|
|
2343
2018
|
for dep in t.get("depends_on") or []:
|
|
2344
2019
|
checks.append((dep in tasks or dep in archived_slugs,
|
|
2345
2020
|
f"task '{slug}' dep '{dep}' resolves", "unknown task"))
|
|
@@ -2453,6 +2128,26 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2453
2128
|
checks.append((cycle is None, "task dependencies are acyclic",
|
|
2454
2129
|
f"cycle: {' -> '.join(cycle)}" if cycle else ""))
|
|
2455
2130
|
|
|
2131
|
+
# component registry (component-aware-add): a malformed .add/components.toml, a root
|
|
2132
|
+
# escaping the project, or a task binding an unregistered component are integrity FAILS
|
|
2133
|
+
# — fail-closed RED (like wave_ledger_malformed), loud but never a crash (the readers
|
|
2134
|
+
# themselves degrade-safe). Silent when there is no components.toml.
|
|
2135
|
+
for _ccode, _cdetail in _component_findings(root):
|
|
2136
|
+
checks.append((False, f"component registry ({_ccode})", _cdetail))
|
|
2137
|
+
# cross-component-contract: a [contract.<id>] naming an unregistered producer is an
|
|
2138
|
+
# integrity FAIL (same fail-closed RED discipline; the readers stay degrade-safe).
|
|
2139
|
+
for _ccode, _cdetail in _contract_findings(root):
|
|
2140
|
+
checks.append((False, f"contract registry ({_ccode})", _cdetail))
|
|
2141
|
+
# multirepo-federation: a declared [federation.<id>] whose producer-repo source is unreadable
|
|
2142
|
+
# is a BROKEN JOIN — surface it EARLY as a WARN (never red alone; `federate pull` is where it
|
|
2143
|
+
# HARD-STOPs). Silent when no federation is declared (opt-in / byte-identical).
|
|
2144
|
+
for _fid, _fspec in _federation(root).items():
|
|
2145
|
+
if not (root.parent / _fspec["source"]).is_file():
|
|
2146
|
+
warnings.append((f"federation '{_fid}'",
|
|
2147
|
+
f"federation_source_unreadable — the producer snapshot at "
|
|
2148
|
+
f"'{_fspec['source']}' is missing/unreadable; `federate pull {_fid}` "
|
|
2149
|
+
"will hard-stop until the producer repo publishes it"))
|
|
2150
|
+
|
|
2456
2151
|
# UDD foundation (udd-check-lint): lint a project's named set under .add/design/ —
|
|
2457
2152
|
# composes the token + catalog/tree validators + the cross-file prop-token resolution.
|
|
2458
2153
|
# Silent when absent; read-only; fail-closed on malformed JSON.
|
|
@@ -2471,10 +2166,15 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2471
2166
|
passed = sum(1 for ok, _, _ in checks if ok)
|
|
2472
2167
|
failed = len(checks) - passed
|
|
2473
2168
|
if as_json:
|
|
2169
|
+
# `infos`/`informed` are ADDITIVE (standalone-fast-task) — affirmations that never feed
|
|
2170
|
+
# `warned`/`failed`; existing keys are untouched so prior consumers keep working.
|
|
2474
2171
|
print(json.dumps({"passed": passed, "failed": failed,
|
|
2475
2172
|
"warned": len(warnings),
|
|
2476
2173
|
"warnings": [{"name": name, "reason": reason}
|
|
2477
2174
|
for name, reason in warnings],
|
|
2175
|
+
"informed": len(infos),
|
|
2176
|
+
"infos": [{"name": name, "reason": reason}
|
|
2177
|
+
for name, reason in infos],
|
|
2478
2178
|
"checks": [{"ok": ok, "name": desc,
|
|
2479
2179
|
"reason": reason if not ok else ""}
|
|
2480
2180
|
for ok, desc, reason in checks]}))
|
|
@@ -2483,6 +2183,8 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2483
2183
|
print(f"PASS {desc}" if ok else f"FAIL {desc}: {reason}")
|
|
2484
2184
|
for name, reason in warnings:
|
|
2485
2185
|
print(f"WARN {name} {reason}")
|
|
2186
|
+
for name, reason in infos:
|
|
2187
|
+
print(f"INFO {name} {reason}")
|
|
2486
2188
|
summary = f"check: {passed} passed, {failed} failed"
|
|
2487
2189
|
if warnings:
|
|
2488
2190
|
summary += f" ({len(warnings)} warnings)" # frozen §3: summary gains "(N warnings)"
|
|
@@ -2564,7 +2266,7 @@ def cmd_mine(args: argparse.Namespace) -> None:
|
|
|
2564
2266
|
if root is None:
|
|
2565
2267
|
_die("no_project")
|
|
2566
2268
|
state = load_state(root)
|
|
2567
|
-
me = _parse_actor_arg(args.actor) if getattr(args, "actor", None) else _whoami(state)
|
|
2269
|
+
me = identity._parse_actor_arg(args.actor) if getattr(args, "actor", None) else identity._whoami(state)
|
|
2568
2270
|
rows = _my_work(state, me)
|
|
2569
2271
|
if getattr(args, "json", False):
|
|
2570
2272
|
print(json.dumps({"actor": me, "tasks": rows}))
|
|
@@ -2783,7 +2485,7 @@ def cmd_milestone_confirm(args: argparse.Namespace) -> None:
|
|
|
2783
2485
|
m["confirmed"] = True
|
|
2784
2486
|
m["confirmed_at"] = _now()
|
|
2785
2487
|
m["confirmed_by"] = who
|
|
2786
|
-
m["actor"] = _actor_stamp(state) # structured actor alongside the free-text confirmed_by
|
|
2488
|
+
m["actor"] = identity._actor_stamp(state) # structured actor alongside the free-text confirmed_by
|
|
2787
2489
|
m["updated"] = _now()
|
|
2788
2490
|
save_state(root, state)
|
|
2789
2491
|
print(f"confirmed milestone '{slug}' (by {who}) — new-task is now open for it.")
|
|
@@ -3036,7 +2738,7 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
|
3036
2738
|
# Stamp WHO closed it BEFORE rendering the retro, so the persisted exit report records
|
|
3037
2739
|
# the closer (identity-in-status: the retro IS the report `report <ms>` re-renders, so both
|
|
3038
2740
|
# must reflect the same final state). In-memory only here — save_state below commits it.
|
|
3039
|
-
state["milestones"][slug]["done_actor"] = _actor_stamp(state)
|
|
2741
|
+
state["milestones"][slug]["done_actor"] = identity._actor_stamp(state)
|
|
3040
2742
|
# Fail-closed: render+persist the exit report (RETRO.md) BEFORE committing the
|
|
3041
2743
|
# status flip, so a write failure rolls back naturally (status never commits ->
|
|
3042
2744
|
# no done-without-retro state). The retro step is read-only on state.json.
|
|
@@ -3339,165 +3041,14 @@ def _sync_task_marker(root: Path, slug: str, phase: str) -> None:
|
|
|
3339
3041
|
# state.json; prose (observe delta, deltas) is parsed from each TASK.md and
|
|
3340
3042
|
# fails CLOSED to `(unknown)` rather than omitting silently.
|
|
3341
3043
|
|
|
3342
|
-
_DEFAULT_WIDTH = 72 # fixed width for the persisted/canonical render (RETRO.md)
|
|
3343
3044
|
# Two glyph tiers. Alignment is correct only with ASCII in column-positioned
|
|
3344
3045
|
# cells (every ASCII char is 1 display cell); Unicode glyphs sit at line-END
|
|
3345
3046
|
# (the PROGRESS track) or in non-aligned rows, where width can't break columns.
|
|
3346
3047
|
_UNICODE = {"reached": "●", "current": "◉", "pending": "○", "h": "═", "rule": "─", "bullet": "•"}
|
|
3347
3048
|
_ASCII = {"reached": "#", "current": ">", "pending": ".", "h": "=", "rule": "-", "bullet": "*"}
|
|
3348
3049
|
_GATE_SHORT = {"PASS": "PASS", "RISK-ACCEPTED": "RISK", "HARD-STOP": "STOP", "none": "—"}
|
|
3349
|
-
_ANSI = {"green": "\x1b[32m", "yellow": "\x1b[33m", "red": "\x1b[31m",
|
|
3350
|
-
"dim": "\x1b[2m", "reset": "\x1b[0m"}
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
def _bar(num: int, den: int, cells: int, g: dict) -> str:
|
|
3354
|
-
"""A progress bar; 0/0 -> all-empty (no divide-by-zero)."""
|
|
3355
|
-
filled = 0 if den <= 0 else round(num / den * cells)
|
|
3356
|
-
filled = max(0, min(cells, filled))
|
|
3357
|
-
return g["reached"] * filled + g["pending"] * (cells - filled)
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
def _phase_track(phase: str, g: dict) -> str:
|
|
3361
|
-
"""Compact 9-cell pipeline (no labels — a single legend explains it):
|
|
3362
|
-
reached · current · pending. A done task -> all reached."""
|
|
3363
|
-
try:
|
|
3364
|
-
ci = PHASES.index(phase)
|
|
3365
|
-
except ValueError:
|
|
3366
|
-
ci = 0
|
|
3367
|
-
cells = []
|
|
3368
|
-
for i in range(len(PHASES)):
|
|
3369
|
-
if phase == "done" or i < ci:
|
|
3370
|
-
cells.append(g["reached"])
|
|
3371
|
-
elif i == ci:
|
|
3372
|
-
cells.append(g["current"])
|
|
3373
|
-
else:
|
|
3374
|
-
cells.append(g["pending"])
|
|
3375
|
-
return "".join(cells)
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
def _use_ascii() -> bool:
|
|
3379
|
-
"""ASCII tier when the terminal can't render Unicode (non-UTF-8 / dumb)."""
|
|
3380
|
-
enc = (getattr(sys.stdout, "encoding", "") or "").lower()
|
|
3381
|
-
return ("utf" not in enc) or (os.environ.get("TERM") == "dumb")
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
def _color_enabled() -> bool:
|
|
3385
|
-
"""Color only on an interactive tty, honoring NO_COLOR and TERM."""
|
|
3386
|
-
return (sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
3387
|
-
and os.environ.get("TERM", "") not in ("dumb", ""))
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
def _term_width() -> int:
|
|
3391
|
-
try:
|
|
3392
|
-
import shutil
|
|
3393
|
-
return min(max(shutil.get_terminal_size().columns, 64), 100)
|
|
3394
|
-
except Exception:
|
|
3395
|
-
return _DEFAULT_WIDTH
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
def _colorize(s: str) -> str:
|
|
3399
|
-
"""Apply ANSI to status tokens — redundant to the text, never the sole signal.
|
|
3400
|
-
Applied ONLY to tty stdout; the persisted RETRO.md string stays plain."""
|
|
3401
|
-
c = _ANSI
|
|
3402
|
-
s = re.sub(r"\bDONE\b", c["green"] + "DONE" + c["reset"], s)
|
|
3403
|
-
s = re.sub(r"\bBLOCKED\b", c["red"] + "BLOCKED" + c["reset"], s)
|
|
3404
|
-
s = re.sub(r"\bPASS\b", c["green"] + "PASS" + c["reset"], s)
|
|
3405
|
-
s = re.sub(r"\bRISK\b", c["yellow"] + "RISK" + c["reset"], s)
|
|
3406
|
-
s = re.sub(r"\bSTOP\b", c["red"] + "STOP" + c["reset"], s)
|
|
3407
|
-
return s
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
def _project_goal(root: Path) -> str:
|
|
3411
|
-
"""The project GOAL — the value of the first `goal:` line in PROJECT.md, else
|
|
3412
|
-
GOAL_UNSET. Read-only and fail-closed: a missing/unreadable foundation or a
|
|
3413
|
-
blank value degrades to the sentinel (orientation never raises). Mirrors how
|
|
3414
|
-
_milestone_doc reads the milestone goal — the foundation is the single source."""
|
|
3415
|
-
f = root / "PROJECT.md"
|
|
3416
|
-
try:
|
|
3417
|
-
for line in f.read_text(encoding="utf-8").splitlines():
|
|
3418
|
-
if line.startswith("goal:"):
|
|
3419
|
-
return line.split(":", 1)[1].strip() or GOAL_UNSET
|
|
3420
|
-
except OSError:
|
|
3421
|
-
pass
|
|
3422
|
-
return GOAL_UNSET
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
def _project_autonomy_token(root: Path):
|
|
3426
|
-
"""The RAW autonomy declaration in PROJECT.md — a recognized rung, None when no
|
|
3427
|
-
declaration line is present, or "?" for a real-but-unrecognized token. Uses the
|
|
3428
|
-
anchored _autonomy_level (a title/prose substring is never a declaration) with
|
|
3429
|
-
HTML comments stripped. Unreadable foundation -> None. Read-only and PURE."""
|
|
3430
|
-
try:
|
|
3431
|
-
text = (root / "PROJECT.md").read_text(encoding="utf-8")
|
|
3432
|
-
except OSError:
|
|
3433
|
-
return None
|
|
3434
|
-
return _autonomy_level(re.sub(r"<!--.*?-->", "", text, flags=re.S))
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
def _project_autonomy(root: Path) -> str:
|
|
3438
|
-
"""The autonomy rung a new task INHERITS from the project default. Fail-SAFE:
|
|
3439
|
-
no declaration -> "auto" (the method default; v7: absent = auto); an unrecognized
|
|
3440
|
-
token -> "conservative" (NEVER silently "auto"); an unreadable foundation -> "auto".
|
|
3441
|
-
Read-only and PURE — mirrors _project_goal; the seed source for cmd_new_task."""
|
|
3442
|
-
tok = _project_autonomy_token(root)
|
|
3443
|
-
return "auto" if tok is None else ("conservative" if tok == "?" else tok)
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
def _milestone_doc(root: Path, mslug: str) -> tuple[str, str]:
|
|
3447
|
-
"""(title, goal) from MILESTONE.md; ('(unknown)','(unknown)') if the doc is gone."""
|
|
3448
|
-
f = root / "milestones" / mslug / MILESTONE_FILE
|
|
3449
|
-
if not f.exists():
|
|
3450
|
-
return "(unknown)", "(unknown)"
|
|
3451
|
-
title, goal = "(unknown)", "(unknown)"
|
|
3452
|
-
for line in f.read_text(encoding="utf-8").splitlines():
|
|
3453
|
-
if line.startswith("# MILESTONE:"):
|
|
3454
|
-
title = line.split(":", 1)[1].strip() or "(unknown)"
|
|
3455
|
-
elif line.startswith("goal:"):
|
|
3456
|
-
goal = line.split(":", 1)[1].strip() or "(unknown)"
|
|
3457
|
-
break
|
|
3458
|
-
return title, goal
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
def _exit_criteria(root: Path, mslug: str) -> tuple[int, int]:
|
|
3462
|
-
"""(met, total) checkbox tally inside MILESTONE.md's 'Exit criteria' section."""
|
|
3463
|
-
f = root / "milestones" / mslug / MILESTONE_FILE
|
|
3464
|
-
if not f.exists():
|
|
3465
|
-
return 0, 0
|
|
3466
|
-
m = re.search(r"## Exit criteria.*?(?=\n## |\Z)", f.read_text(encoding="utf-8"), re.S)
|
|
3467
|
-
if not m:
|
|
3468
|
-
return 0, 0
|
|
3469
|
-
sec = m.group(0)
|
|
3470
|
-
met = len(re.findall(r"- \[x\]", sec))
|
|
3471
|
-
total = met + len(re.findall(r"- \[ \]", sec))
|
|
3472
|
-
return met, total
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
3050
|
# A non-empty `(verify: <citation>)` on an exit-criterion line — at least one non-whitespace
|
|
3476
3051
|
# char inside, so a bare `(verify:)`/`(verify: )` does NOT count (the mid-text substring trap).
|
|
3477
|
-
_VERIFY_CITE_RE = re.compile(r"\(verify:\s*\S.*?\)", re.I)
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
def _exit_criteria_cited(root: Path, mslug: str) -> tuple[int, int]:
|
|
3481
|
-
"""(cited, total) over MILESTONE.md's 'Exit criteria' section. total = every
|
|
3482
|
-
`- [ ]`/`- [x]` criterion line; cited = those carrying a NON-EMPTY
|
|
3483
|
-
`(verify: <citation>)`. Read-only and PURE; missing file/section -> (0, 0).
|
|
3484
|
-
Mirrors _exit_criteria (the checkbox tally) — an ADDITIVE classification beside
|
|
3485
|
-
it; it never touches `milestone_goal_unmet`."""
|
|
3486
|
-
f = root / "milestones" / mslug / MILESTONE_FILE
|
|
3487
|
-
if not f.exists():
|
|
3488
|
-
return 0, 0
|
|
3489
|
-
m = re.search(r"## Exit criteria.*?(?=\n## |\Z)", f.read_text(encoding="utf-8"), re.S)
|
|
3490
|
-
if not m:
|
|
3491
|
-
return 0, 0
|
|
3492
|
-
cited = total = 0
|
|
3493
|
-
for ln in m.group(0).splitlines():
|
|
3494
|
-
if re.match(r"\s*- \[[ x]\]", ln):
|
|
3495
|
-
total += 1
|
|
3496
|
-
if _VERIFY_CITE_RE.search(ln):
|
|
3497
|
-
cited += 1
|
|
3498
|
-
return cited, total
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
3052
|
def _goal_auto_ready(root: Path, mslug: str) -> bool:
|
|
3502
3053
|
"""True iff the milestone goal is AUTO-READY: its Exit criteria has >= 1 criterion
|
|
3503
3054
|
AND every one cites a verifier (cited == total) — so the engine can self-verify the
|
|
@@ -3507,32 +3058,6 @@ def _goal_auto_ready(root: Path, mslug: str) -> bool:
|
|
|
3507
3058
|
return total >= 1 and cited == total
|
|
3508
3059
|
|
|
3509
3060
|
|
|
3510
|
-
def _stage_criteria(root: Path) -> tuple[int, int]:
|
|
3511
|
-
"""(met, total) checkbox tally inside PROJECT.md's 'Stage goal criteria' section — the
|
|
3512
|
-
PROJECT.md analog of _exit_criteria (v22): the human's stage-covered affirmation. Read-only
|
|
3513
|
-
and fail-closed to (0, 0): a missing file, a missing section, or any read error never raises
|
|
3514
|
-
and never fabricates a cue (so an unreadable foundation withholds graduation, design-for-failure)."""
|
|
3515
|
-
try:
|
|
3516
|
-
text = (root / "PROJECT.md").read_text(encoding="utf-8")
|
|
3517
|
-
except OSError:
|
|
3518
|
-
return 0, 0
|
|
3519
|
-
m = re.search(r"## Stage goal criteria.*?(?=\n## |\Z)", text, re.S)
|
|
3520
|
-
if not m:
|
|
3521
|
-
return 0, 0
|
|
3522
|
-
sec = m.group(0)
|
|
3523
|
-
met = len(re.findall(r"- \[x\]", sec))
|
|
3524
|
-
total = met + len(re.findall(r"- \[ \]", sec))
|
|
3525
|
-
return met, total
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
def _all_milestones_done(state: dict) -> bool:
|
|
3529
|
-
"""True when the project HAS milestones and EVERY one is status=done (v22). Archived
|
|
3530
|
-
milestones are absent from state['milestones'] (removed by the archive lifecycle), so they
|
|
3531
|
-
do not count; a project with zero milestones is not 'covered' and returns False."""
|
|
3532
|
-
ms = state.get("milestones") or {}
|
|
3533
|
-
return bool(ms) and all(m.get("status") == "done" for m in ms.values())
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
3061
|
def _graduation_ready(root: Path, state: dict) -> tuple[bool, int, int]:
|
|
3537
3062
|
"""(ready, met, total) for the stage-graduation cue (v22): every milestone done AND the
|
|
3538
3063
|
human's stage-goal-criteria all checked (total>0 and met==total). The SINGLE source the
|
|
@@ -3542,93 +3067,6 @@ def _graduation_ready(root: Path, state: dict) -> tuple[bool, int, int]:
|
|
|
3542
3067
|
return ready, met, total
|
|
3543
3068
|
|
|
3544
3069
|
|
|
3545
|
-
def _count_test_defs(f: Path) -> int:
|
|
3546
|
-
"""`def test_` occurrences in one file — the ONE counting regex (primary and
|
|
3547
|
-
§4-declared fallback share it by construction). OSError -> 0, fail-closed."""
|
|
3548
|
-
try:
|
|
3549
|
-
return len(re.findall(r"^\s*def test_", f.read_text(encoding="utf-8"), re.M))
|
|
3550
|
-
except OSError:
|
|
3551
|
-
return 0
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
def _primary_test_files(root: Path, slug: str) -> list[Path]:
|
|
3555
|
-
"""The PRIMARY test set — *.py directly in the task's tests/ dir (the stable
|
|
3556
|
-
path). A list so the tamper tripwire can hash exactly what the engine counts."""
|
|
3557
|
-
d = root / "tasks" / slug / "tests"
|
|
3558
|
-
if not d.is_dir():
|
|
3559
|
-
return []
|
|
3560
|
-
return sorted(d.glob("*.py"))
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
def _tests_count(root: Path, slug: str) -> int:
|
|
3564
|
-
return sum(_count_test_defs(f) for f in _primary_test_files(root, slug))
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
def _confined(p: Path, rootp: Path) -> bool:
|
|
3568
|
-
"""True only if p resolves (symlinks followed) inside rootp; errors -> False.
|
|
3569
|
-
The v2 confinement check — no read is attempted on a path that fails it."""
|
|
3570
|
-
try:
|
|
3571
|
-
return p.resolve().is_relative_to(rootp)
|
|
3572
|
-
except OSError:
|
|
3573
|
-
return False
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
def _declared_test_files(root: Path, slug: str) -> list[Path]:
|
|
3577
|
-
"""Resolve the §4 'Tests live in:' declared path(s) to a deduped file list. PURE.
|
|
3578
|
-
Tokens are the backticked spans on the FIRST declaring line of the raw §4 body.
|
|
3579
|
-
Resolution: './…' -> task dir · contains '/' -> project root (parent of .add) ·
|
|
3580
|
-
bare name -> sibling of the previous resolved token (else task dir). A directory
|
|
3581
|
-
token yields the *.py files directly inside it; resolved files are deduped.
|
|
3582
|
-
v2 confinement: every path must resolve inside the project root — '..' traversal,
|
|
3583
|
-
absolute tokens, and symlink escapes are all dropped, fail-closed."""
|
|
3584
|
-
body = _raw_phase_bodies(root, slug).get(4, "")
|
|
3585
|
-
m = re.search(r"^\s*Tests live in:.*$", body, re.M)
|
|
3586
|
-
if not m:
|
|
3587
|
-
return []
|
|
3588
|
-
tdir = root / "tasks" / slug
|
|
3589
|
-
rootp = root.parent.resolve()
|
|
3590
|
-
files: list[Path] = []
|
|
3591
|
-
prev_dir = None
|
|
3592
|
-
for tok in re.findall(r"`([^`]+)`", m.group(0)):
|
|
3593
|
-
tok = tok.strip()
|
|
3594
|
-
if tok.startswith("./"):
|
|
3595
|
-
p = tdir / tok[2:]
|
|
3596
|
-
elif "/" in tok:
|
|
3597
|
-
p = root.parent / tok
|
|
3598
|
-
else:
|
|
3599
|
-
p = (prev_dir or tdir) / tok
|
|
3600
|
-
try:
|
|
3601
|
-
if not _confined(p, rootp):
|
|
3602
|
-
continue
|
|
3603
|
-
if p.is_dir():
|
|
3604
|
-
cand, prev_dir = sorted(f for f in p.glob("*.py")
|
|
3605
|
-
if _confined(f, rootp)), p
|
|
3606
|
-
elif p.is_file() and p.suffix == ".py":
|
|
3607
|
-
cand, prev_dir = [p], p.parent
|
|
3608
|
-
else:
|
|
3609
|
-
continue
|
|
3610
|
-
except OSError:
|
|
3611
|
-
continue
|
|
3612
|
-
files.extend(f for f in cand if f not in files)
|
|
3613
|
-
return files
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
def _declared_tests_count(root: Path, slug: str) -> int:
|
|
3617
|
-
"""Count tests at the §4 'Tests live in:' declared path(s). PURE, fail-closed 0."""
|
|
3618
|
-
return sum(_count_test_defs(f) for f in _declared_test_files(root, slug))
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
def _tests_info(root: Path, slug: str) -> tuple[int, bool]:
|
|
3622
|
-
"""(count, declared). The tests/ dir count ALWAYS wins when > 0; otherwise the
|
|
3623
|
-
§4-declared fallback — flagged True only when it supplied a non-zero count, so
|
|
3624
|
-
a true zero stays a bare, honest 0."""
|
|
3625
|
-
primary = _tests_count(root, slug)
|
|
3626
|
-
if primary > 0:
|
|
3627
|
-
return primary, False
|
|
3628
|
-
declared = _declared_tests_count(root, slug)
|
|
3629
|
-
return (declared, True) if declared > 0 else (0, False)
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
3070
|
def _resolved_test_files(root: Path, slug: str) -> list[Path]:
|
|
3633
3071
|
"""The file set the engine treats as this task's tests — the PRIMARY set wins
|
|
3634
3072
|
when it yields any test defs, else the §4-declared set (mirrors _tests_info's
|
|
@@ -3639,19 +3077,6 @@ def _resolved_test_files(root: Path, slug: str) -> list[Path]:
|
|
|
3639
3077
|
return _declared_test_files(root, slug)
|
|
3640
3078
|
|
|
3641
3079
|
|
|
3642
|
-
def _md5_text(s: str) -> str:
|
|
3643
|
-
return hashlib.md5(s.encode("utf-8")).hexdigest()
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
def _md5_file(p: Path) -> str | None:
|
|
3647
|
-
"""md5 of a file's bytes; None on ANY read error (fail-closed — a tracked file
|
|
3648
|
-
that cannot be read counts as DIVERGED at the gate, never a crash)."""
|
|
3649
|
-
try:
|
|
3650
|
-
return hashlib.md5(p.read_bytes()).hexdigest()
|
|
3651
|
-
except OSError:
|
|
3652
|
-
return None
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
3080
|
def _tripwire_snapshot(root: Path, slug: str, raw3: str) -> dict:
|
|
3656
3081
|
"""Freeze the md5 of the resolved red test files + the frozen §3 contract — the
|
|
3657
3082
|
tamper baseline (verify-integrity). Keys are project-root-relative paths (stable
|
|
@@ -3701,6 +3126,119 @@ _SCOPE_EXCLUDE_FILES = (".DS_Store",) # plus *.pyc / *.tsbuildi
|
|
|
3701
3126
|
_SCOPE_EXCLUDE_SUFFIXES = (".pyc", ".tsbuildinfo")
|
|
3702
3127
|
|
|
3703
3128
|
|
|
3129
|
+
# ── component registry (component-aware-add): declared components + task binding ─────
|
|
3130
|
+
# OPT-IN + DEGRADE-SAFE: with no .add/components.toml every reader is byte-identical to
|
|
3131
|
+
# pre-component ADD. A read NEVER raises (absent/unreadable/malformed → {} / dropped
|
|
3132
|
+
# cover); the loud surface is _component_findings, consumed by the scope gate (cmd_check).
|
|
3133
|
+
def _component_root(root: Path, name: str) -> str | None:
|
|
3134
|
+
"""Project-root-relative path (trailing '/') of component `name`'s root, or None
|
|
3135
|
+
when the name is absent OR the root escapes the project (fail-closed — grants no
|
|
3136
|
+
scope cover, mirroring _declared_scope's _confined drop). PURE."""
|
|
3137
|
+
spec = _components(root).get(name)
|
|
3138
|
+
if not spec:
|
|
3139
|
+
return None
|
|
3140
|
+
rootp = root.parent.resolve()
|
|
3141
|
+
p = root.parent / spec["root"]
|
|
3142
|
+
if not _confined(p, rootp):
|
|
3143
|
+
return None
|
|
3144
|
+
try:
|
|
3145
|
+
return str(p.resolve().relative_to(rootp)).rstrip("/") + "/"
|
|
3146
|
+
except (OSError, ValueError):
|
|
3147
|
+
return None
|
|
3148
|
+
|
|
3149
|
+
|
|
3150
|
+
def _task_component(root: Path, slug: str):
|
|
3151
|
+
"""The component a task binds to via its `component:` header token (anchored like
|
|
3152
|
+
autonomy). None = no line / unfilled `<…>` placeholder; "?" = a real token absent
|
|
3153
|
+
from the registry; otherwise the component name. PURE."""
|
|
3154
|
+
m = _COMPONENT_LINE_RE.search(_task_header(root, slug))
|
|
3155
|
+
if not m:
|
|
3156
|
+
return None
|
|
3157
|
+
tok = m.group(1).strip()
|
|
3158
|
+
return tok if tok in _components(root) else "?"
|
|
3159
|
+
|
|
3160
|
+
|
|
3161
|
+
def _task_green_bar(root: Path, slug: str) -> str | None:
|
|
3162
|
+
"""The green_bar phrase of the task's bound component (per-component-verify), else
|
|
3163
|
+
None — unbound, "?", or no green_bar declared all yield None. PURE."""
|
|
3164
|
+
comp = _task_component(root, slug)
|
|
3165
|
+
if not comp or comp == "?":
|
|
3166
|
+
return None
|
|
3167
|
+
return (_components(root).get(comp) or {}).get("green_bar") or None
|
|
3168
|
+
|
|
3169
|
+
|
|
3170
|
+
def _component_findings(root: Path) -> list[tuple[str, str]]:
|
|
3171
|
+
"""The loud gate surface for the registry — the codes a degrade-safe read passes
|
|
3172
|
+
over silently. Consumed by cmd_check (the scope_violation surface). [] when clean."""
|
|
3173
|
+
findings: list[tuple[str, str]] = []
|
|
3174
|
+
try:
|
|
3175
|
+
raw = (root / "components.toml").read_bytes()
|
|
3176
|
+
except OSError:
|
|
3177
|
+
return findings # absent/unreadable = opt-out, nothing to report
|
|
3178
|
+
data = None
|
|
3179
|
+
if tomllib is None:
|
|
3180
|
+
findings.append(("components_malformed", "components.toml present but tomllib unavailable (Python < 3.11)"))
|
|
3181
|
+
else:
|
|
3182
|
+
try:
|
|
3183
|
+
data = tomllib.loads(raw.decode("utf-8"))
|
|
3184
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError) as e:
|
|
3185
|
+
findings.append(("components_malformed", f"components.toml: {e}"))
|
|
3186
|
+
if data is not None:
|
|
3187
|
+
rootp = root.parent.resolve()
|
|
3188
|
+
for name, spec in (data.get("component") or {}).items():
|
|
3189
|
+
if name == "?":
|
|
3190
|
+
findings.append(("components_malformed", "component name '?' is reserved (the unknown-binding sentinel)"))
|
|
3191
|
+
continue
|
|
3192
|
+
if not isinstance(spec, dict) or not isinstance(spec.get("root"), str):
|
|
3193
|
+
findings.append(("components_malformed", f"[component.{name}] missing required `root`"))
|
|
3194
|
+
continue
|
|
3195
|
+
if not _confined(root.parent / spec["root"], rootp):
|
|
3196
|
+
findings.append(("component_root_outside", f"[component.{name}] root {spec['root']!r} escapes the project"))
|
|
3197
|
+
known = set(_components(root))
|
|
3198
|
+
try:
|
|
3199
|
+
task_dirs = sorted(p for p in (root / "tasks").iterdir() if p.is_dir())
|
|
3200
|
+
except OSError:
|
|
3201
|
+
task_dirs = [] # unreadable tasks/ degrades safe — never crash a read
|
|
3202
|
+
for d in task_dirs:
|
|
3203
|
+
tc = _task_component(root, d.name)
|
|
3204
|
+
if tc is not None and tc not in known: # "?" or a stale name
|
|
3205
|
+
findings.append(("component_unknown", f"task {d.name} binds an unregistered component"))
|
|
3206
|
+
return findings
|
|
3207
|
+
|
|
3208
|
+
|
|
3209
|
+
# ── cross-component contracts (cross-component-contract) ──────────────────────────────────
|
|
3210
|
+
# OPT-IN + DEGRADE-SAFE, like the component readers: no [contract.*] / no produces|consumes
|
|
3211
|
+
# header ⇒ every path below is byte-identical to pre-contract ADD. A read NEVER raises.
|
|
3212
|
+
def _task_produces(root: Path, slug: str) -> str | None:
|
|
3213
|
+
m = _PRODUCES_LINE_RE.search(_task_header(root, slug))
|
|
3214
|
+
return m.group(1).strip() if m else None
|
|
3215
|
+
|
|
3216
|
+
|
|
3217
|
+
def _task_consumes(root: Path, slug: str) -> str | None:
|
|
3218
|
+
m = _CONSUMES_LINE_RE.search(_task_header(root, slug))
|
|
3219
|
+
return m.group(1).strip() if m else None
|
|
3220
|
+
|
|
3221
|
+
|
|
3222
|
+
def _contract_body_hash(raw3: str) -> str:
|
|
3223
|
+
"""md5 of the §3 contract SHAPE — the first ```fenced``` block, whitespace-normalized. The
|
|
3224
|
+
version stamp + freeze flags are excluded (fallback strips Status:/flag/change-request lines)
|
|
3225
|
+
so a pure version bump does NOT churn pinned consumers stale. PURE."""
|
|
3226
|
+
m = re.search(r"```(.*?)```", raw3, re.DOTALL)
|
|
3227
|
+
body = m.group(1) if m else re.sub(r"(?m)^(Status:|.*surfaced at freeze:|v\d+ CHANGE REQUEST).*$", "", raw3)
|
|
3228
|
+
return _md5_text(re.sub(r"\s+", " ", body).strip())
|
|
3229
|
+
|
|
3230
|
+
|
|
3231
|
+
def _contract_findings(root: Path) -> list[tuple[str, str]]:
|
|
3232
|
+
"""The loud gate surface for cross-component contracts — [] when clean / opted-out."""
|
|
3233
|
+
findings: list[tuple[str, str]] = []
|
|
3234
|
+
known = set(_components(root))
|
|
3235
|
+
for cid, spec in _contracts(root).items():
|
|
3236
|
+
if spec["producer"] not in known:
|
|
3237
|
+
findings.append(("contract_producer_unknown",
|
|
3238
|
+
f"[contract.{cid}] producer {spec['producer']!r} is not a declared component"))
|
|
3239
|
+
return findings
|
|
3240
|
+
|
|
3241
|
+
|
|
3704
3242
|
def _declared_scope(root: Path, slug: str) -> list[str] | None:
|
|
3705
3243
|
"""Resolve the §5 'Scope (may touch):' declaration to project-root-relative
|
|
3706
3244
|
strings (directory tokens keep a trailing '/'). The frozen scope-decl-template
|
|
@@ -3710,11 +3248,19 @@ def _declared_scope(root: Path, slug: str) -> list[str] | None:
|
|
|
3710
3248
|
root, fail-closed — with ONE divergence: a directory token covers its WHOLE
|
|
3711
3249
|
subtree (containment, judged by _in_scope). None = no Scope line (UNDECLARED,
|
|
3712
3250
|
grandfathered — never retro-red); [] = a line whose every token was dropped
|
|
3713
|
-
(a garbage declaration grants NO cover).
|
|
3251
|
+
(a garbage declaration grants NO cover).
|
|
3252
|
+
|
|
3253
|
+
component-aware-add: when the task binds a known `component:` (_task_component),
|
|
3254
|
+
that component's root subtree (_component_root) is APPENDED to the resolved tokens
|
|
3255
|
+
(dedup) — composing with the explicit declaration, never redrawing token resolution.
|
|
3256
|
+
A bound task with NO Scope line returns [component_root] (not None); an UNBOUND task
|
|
3257
|
+
is byte-identical to before."""
|
|
3258
|
+
comp = _task_component(root, slug)
|
|
3259
|
+
croot = _component_root(root, comp) if comp and comp != "?" else None
|
|
3714
3260
|
body = _raw_phase_bodies(root, slug).get(5, "")
|
|
3715
3261
|
m = re.search(r"^\s*Scope \(may touch\):.*$", body, re.M)
|
|
3716
3262
|
if not m:
|
|
3717
|
-
return None
|
|
3263
|
+
return [croot] if croot else None
|
|
3718
3264
|
tdir = root / "tasks" / slug
|
|
3719
3265
|
rootp = root.parent.resolve()
|
|
3720
3266
|
out: list[str] = []
|
|
@@ -3740,21 +3286,11 @@ def _declared_scope(root: Path, slug: str) -> list[str] | None:
|
|
|
3740
3286
|
continue
|
|
3741
3287
|
if rel not in out:
|
|
3742
3288
|
out.append(rel)
|
|
3289
|
+
if croot and croot not in out: # component-aware-add: compose, never redraw
|
|
3290
|
+
out.append(croot)
|
|
3743
3291
|
return out
|
|
3744
3292
|
|
|
3745
3293
|
|
|
3746
|
-
def _in_scope(rel: str, declared: list[str]) -> bool:
|
|
3747
|
-
"""True when rel falls under any declared token — exact match for a file
|
|
3748
|
-
token, whole-subtree prefix containment for a directory token ('…/')."""
|
|
3749
|
-
for tok in declared:
|
|
3750
|
-
if tok.endswith("/"):
|
|
3751
|
-
if rel.startswith(tok) or rel == tok.rstrip("/"):
|
|
3752
|
-
return True
|
|
3753
|
-
elif rel == tok:
|
|
3754
|
-
return True
|
|
3755
|
-
return False
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
3294
|
def _scope_walk(rootp: Path) -> dict[str, str]:
|
|
3759
3295
|
"""{project-root-relative path: md5} over the project tree, pruning
|
|
3760
3296
|
_SCOPE_EXCLUDE_DIRS at any depth and skipping bytecode/OS junk +
|
|
@@ -3885,6 +3421,28 @@ def _heal_or_escalate(root: Path, state: dict, slug: str, *, reason: str, source
|
|
|
3885
3421
|
raise SystemExit(3) # redo signal (distinct from _die's 1, argparse's 2)
|
|
3886
3422
|
|
|
3887
3423
|
|
|
3424
|
+
def _consumer_stale_guard(root: Path, state: dict, slug: str) -> None:
|
|
3425
|
+
"""Refuse a COMPLETING gate when a `consumes:` task's pinned producer contract hash is STALE
|
|
3426
|
+
(the producer re-froze a CHANGED shape since the pin) — the consumer built against an
|
|
3427
|
+
out-of-date contract (consumer-stale-gate, the gate twin of cmd_check's contract_consumer_stale
|
|
3428
|
+
warning). Recoverable, not a cheat: re-pin by re-crossing contract->tests after reviewing the
|
|
3429
|
+
new frozen shape. Degrade-safe — an unreadable/missing live snapshot is NOT decided here (it
|
|
3430
|
+
stays a cmd_check warning + the advance-time contract_snapshot_missing HARD-STOP); only a
|
|
3431
|
+
CONFIRMED hash drift blocks. Placed with the other completing guards, BEFORE the waiver write,
|
|
3432
|
+
so a stale pin is never launderable through RISK-ACCEPTED; HARD-STOP never reaches here."""
|
|
3433
|
+
pin = state["tasks"][slug].get("contract_pin")
|
|
3434
|
+
if not pin:
|
|
3435
|
+
return
|
|
3436
|
+
try:
|
|
3437
|
+
live = json.loads(_contract_snapshot(root, pin["id"]).read_text(encoding="utf-8")).get("hash")
|
|
3438
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
|
3439
|
+
return # unreadable -> surfaced by cmd_check, not confirmable as stale here
|
|
3440
|
+
if live is not None and live != pin.get("hash"):
|
|
3441
|
+
_die(f"contract_consumer_stale: task '{slug}' pinned contract '{pin['id']}' changed shape "
|
|
3442
|
+
"since the pin (the producer re-froze) — re-pin by re-crossing contract->tests after "
|
|
3443
|
+
"reviewing the producer's new frozen shape; never complete against a stale contract")
|
|
3444
|
+
|
|
3445
|
+
|
|
3888
3446
|
def _tamper_guard(root: Path, state: dict, slug: str) -> None:
|
|
3889
3447
|
"""HARD-STOP a COMPLETING gate when the tripwire shows tampering — the method's
|
|
3890
3448
|
first mechanical cheat block (verify-integrity). Tri-state, co-witnessed by
|
|
@@ -3911,89 +3469,6 @@ def _tamper_guard(root: Path, state: dict, slug: str) -> None:
|
|
|
3911
3469
|
reason="tamper_detected:" + ",".join(diffs), source="tamper")
|
|
3912
3470
|
|
|
3913
3471
|
|
|
3914
|
-
def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
|
|
3915
|
-
"""(observe_delta, [delta lines]) from the task's TASK.md §7 — captured at FULL
|
|
3916
|
-
fidelity: both fields wrap across physical lines in real files, so continuation
|
|
3917
|
-
lines are JOINED. Scoped to the OBSERVE section so we read the FIELD, not §1 prose
|
|
3918
|
-
that names it. Fail-closed to '(unknown)' on a missing file / `<...>` placeholder."""
|
|
3919
|
-
f = root / "tasks" / slug / "TASK.md"
|
|
3920
|
-
if not f.exists():
|
|
3921
|
-
return "(unknown)", []
|
|
3922
|
-
text = f.read_text(encoding="utf-8")
|
|
3923
|
-
m7 = re.search(r"##\s*7\s*·\s*OBSERVE.*\Z", text, re.S)
|
|
3924
|
-
section = m7.group(0) if m7 else text
|
|
3925
|
-
lines = section.splitlines()
|
|
3926
|
-
# observe: prefer the first OPEN SPEC delta from the "### Spec delta" block; fall
|
|
3927
|
-
# back to the legacy "Spec delta for the next loop:" free-text field (archived
|
|
3928
|
-
# tasks predate the block); else "(unknown)".
|
|
3929
|
-
observe = "(unknown)"
|
|
3930
|
-
for unit in _spec_delta_entries(section):
|
|
3931
|
-
m = _SPEC_DELTA_RE.match(unit[0])
|
|
3932
|
-
if m.group(2) != "open":
|
|
3933
|
-
continue
|
|
3934
|
-
tail = " ".join([m.group(3).strip(), *unit[1:]]).strip()
|
|
3935
|
-
em = _EVIDENCE_RE.match(tail)
|
|
3936
|
-
first = (em.group(1).strip() if em else tail)
|
|
3937
|
-
if first and not first.startswith("<"):
|
|
3938
|
-
observe = first
|
|
3939
|
-
break
|
|
3940
|
-
if observe == "(unknown)":
|
|
3941
|
-
for i, ln in enumerate(lines):
|
|
3942
|
-
m = re.match(r"\s*Spec delta for the next loop:\s*(.*)", ln)
|
|
3943
|
-
if not m:
|
|
3944
|
-
continue
|
|
3945
|
-
parts = [m.group(1).strip()]
|
|
3946
|
-
for nxt in lines[i + 1:]:
|
|
3947
|
-
t = nxt.strip()
|
|
3948
|
-
if not t or t.startswith("#") or t.startswith("- ") or t.startswith("Watch"):
|
|
3949
|
-
break
|
|
3950
|
-
parts.append(t)
|
|
3951
|
-
joined = " ".join(p for p in parts if p).strip()
|
|
3952
|
-
if joined and not joined.startswith("<"):
|
|
3953
|
-
observe = joined
|
|
3954
|
-
break
|
|
3955
|
-
|
|
3956
|
-
# deltas: each "- [COMP · status] ..." plus its indented continuation lines
|
|
3957
|
-
deltas, i = [], 0
|
|
3958
|
-
while i < len(lines):
|
|
3959
|
-
m = _DELTA_RE.match(lines[i])
|
|
3960
|
-
if not m:
|
|
3961
|
-
i += 1
|
|
3962
|
-
continue
|
|
3963
|
-
parts, j = [m.group(3).strip()], i + 1
|
|
3964
|
-
while j < len(lines):
|
|
3965
|
-
t = lines[j].strip()
|
|
3966
|
-
if not t or t.startswith("#") or _DELTA_RE.match(lines[j]):
|
|
3967
|
-
break
|
|
3968
|
-
parts.append(t)
|
|
3969
|
-
j += 1
|
|
3970
|
-
deltas.append(f"{m.group(1)} · {m.group(2)} · {' '.join(parts).strip()}")
|
|
3971
|
-
i = j
|
|
3972
|
-
return observe, deltas
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
def _clip(s: str, maxlen: int) -> str:
|
|
3976
|
-
"""Trim a string to fit a fixed-width frame, ellipsizing if it overruns."""
|
|
3977
|
-
return s if len(s) <= maxlen else s[:maxlen - 1].rstrip() + "…"
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
def _wrap(text: str, width: int, label: str) -> list[str]:
|
|
3981
|
-
"""Wrap `text` to `width`; the first line carries `label`, continuations are
|
|
3982
|
-
blank-indented to the same width (so a multi-line goal shows 'goal' once)."""
|
|
3983
|
-
cont = " " * len(label)
|
|
3984
|
-
lines, cur = [], ""
|
|
3985
|
-
for w in text.split():
|
|
3986
|
-
if cur and len(cur) + 1 + len(w) > width:
|
|
3987
|
-
lines.append(cur)
|
|
3988
|
-
cur = w
|
|
3989
|
-
else:
|
|
3990
|
-
cur = f"{cur} {w}".strip()
|
|
3991
|
-
if cur:
|
|
3992
|
-
lines.append(cur)
|
|
3993
|
-
lines = lines or ["(unknown)"]
|
|
3994
|
-
return [(label if i == 0 else cont) + ln for i, ln in enumerate(lines)]
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
3472
|
def report_data(root: Path, state: dict, mslug: str) -> dict:
|
|
3998
3473
|
"""The single source of FACTS for a milestone report — pure, NO writes.
|
|
3999
3474
|
Both the text dashboard (render_report) and `report --json` render from this,
|
|
@@ -4075,43 +3550,6 @@ def _clean_phase_body(body: str) -> str:
|
|
|
4075
3550
|
return "\n".join(lines) if meaningful else "(empty)"
|
|
4076
3551
|
|
|
4077
3552
|
|
|
4078
|
-
def _phase_spans(text: str) -> dict[int, str]:
|
|
4079
|
-
"""Split a TASK.md into RAW §1–§7 bodies keyed by section number — the ONE
|
|
4080
|
-
canonical heading scan (`^##\\s*<n>\\s*·`, case/locale-proof); a body runs from
|
|
4081
|
-
its heading to the next `## `/`---`/EOF. RAW = byte-faithful lines, no cleaning:
|
|
4082
|
-
the decision-marker extractor (decide-digest) depends on byte-verbatim text.
|
|
4083
|
-
KNOWN LIMIT: a §body containing a line-start `## ` or bare `---` truncates early —
|
|
4084
|
-
today's TASK.md bodies don't (box-chars ─═, `### ` sub-heads)."""
|
|
4085
|
-
lines = text.splitlines()
|
|
4086
|
-
head = re.compile(r"^##\s*(\d+)\s*·")
|
|
4087
|
-
starts: dict[int, int] = {}
|
|
4088
|
-
for idx, ln in enumerate(lines):
|
|
4089
|
-
m = head.match(ln)
|
|
4090
|
-
if m:
|
|
4091
|
-
n = int(m.group(1))
|
|
4092
|
-
if 0 <= n <= 7 and n not in starts:
|
|
4093
|
-
starts[n] = idx
|
|
4094
|
-
out: dict[int, str] = {}
|
|
4095
|
-
for n, idx in starts.items():
|
|
4096
|
-
body_lines = []
|
|
4097
|
-
for ln in lines[idx + 1:]:
|
|
4098
|
-
if re.match(r"^##\s", ln) or re.match(r"^---\s*$", ln):
|
|
4099
|
-
break
|
|
4100
|
-
body_lines.append(ln)
|
|
4101
|
-
out[n] = "\n".join(body_lines)
|
|
4102
|
-
return out
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
def _raw_phase_bodies(root: Path, slug: str) -> dict[int, str]:
|
|
4106
|
-
"""RAW §bodies for one task (byte-faithful, for marker extraction). PURE.
|
|
4107
|
-
Missing/unreadable TASK.md -> {} (fail-closed, like task_phases)."""
|
|
4108
|
-
f = root / "tasks" / slug / "TASK.md"
|
|
4109
|
-
try:
|
|
4110
|
-
return _phase_spans(f.read_text(encoding="utf-8"))
|
|
4111
|
-
except OSError:
|
|
4112
|
-
return {}
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
3553
|
def task_phases(root: Path, slug: str) -> list[dict]:
|
|
4116
3554
|
"""The frozen per-task PHASE-DETAIL shape (v9-1): parse TASK.md §0–§7 into eight
|
|
4117
3555
|
blocks ground→observe. PURE — NO writes. Each entry is
|
|
@@ -4698,10 +4136,6 @@ _DELTA_STATUSES = ("open", "folded", "rejected")
|
|
|
4698
4136
|
# un-stripped lines directly; callers that pre-strip their input
|
|
4699
4137
|
# (e.g. _collect_open_deltas, _lint_task_deltas) match the same way (\s*
|
|
4700
4138
|
# matches zero). Anchored at line-start via re.match.
|
|
4701
|
-
_DELTA_RE = re.compile(
|
|
4702
|
-
r"\s*-\s*\[\s*(DDD|SDD|UDD|TDD|ADD)\s*·\s*(open|folded|rejected)\s*\]\s*(.+)$"
|
|
4703
|
-
)
|
|
4704
|
-
_EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
|
|
4705
4139
|
|
|
4706
4140
|
# SPEC-delta track — a SEPARATE resolution lifecycle from the competency deltas
|
|
4707
4141
|
# above. SPEC shares the "- [TAG · status]" LINE shape but its statuses are
|
|
@@ -4710,9 +4144,6 @@ _EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
|
|
|
4710
4144
|
# tag to its legal status set so the ONE lint can reject a cross-set pairing
|
|
4711
4145
|
# ([SPEC · folded], [SDD · seeded]) without a parallel grammar.
|
|
4712
4146
|
_SPEC_STATUSES = ("open", "seeded", "dropped")
|
|
4713
|
-
_SPEC_DELTA_RE = re.compile(
|
|
4714
|
-
r"\s*-\s*\[\s*(SPEC)\s*·\s*(open|seeded|dropped)\s*\]\s*(.+)$"
|
|
4715
|
-
)
|
|
4716
4147
|
_STATUS_SETS = {**{c: _DELTA_STATUSES for c in _COMPETENCY_ORDER}, "SPEC": _SPEC_STATUSES}
|
|
4717
4148
|
|
|
4718
4149
|
# Broad structural tag detector: finds ANY "- [tok · tok]" line (valid OR malformed).
|
|
@@ -4868,32 +4299,6 @@ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
|
|
|
4868
4299
|
return by_comp
|
|
4869
4300
|
|
|
4870
4301
|
|
|
4871
|
-
def _spec_delta_entries(text: str) -> list[list[str]]:
|
|
4872
|
-
"""Group a "### Spec delta" block into entries (tag line + continuation lines).
|
|
4873
|
-
|
|
4874
|
-
Same grouping discipline as _collect_open_deltas' competency pass, keyed on
|
|
4875
|
-
_SPEC_DELTA_RE: a tag line starts an entry; a non-"- " line continues it; a
|
|
4876
|
-
blank/comment or a new "- " item ends it. Returns [] when the block is absent."""
|
|
4877
|
-
bm = re.search(r"###\s*Spec delta\s*\n(.*?)(?=\n##|\Z)", text, re.S)
|
|
4878
|
-
if not bm:
|
|
4879
|
-
return []
|
|
4880
|
-
entries: list[list[str]] = []
|
|
4881
|
-
current: list[str] | None = None
|
|
4882
|
-
for line in bm.group(1).splitlines():
|
|
4883
|
-
stripped = line.strip()
|
|
4884
|
-
if not stripped or stripped.startswith("<!--"):
|
|
4885
|
-
current = None
|
|
4886
|
-
continue
|
|
4887
|
-
if _SPEC_DELTA_RE.match(stripped):
|
|
4888
|
-
current = [stripped]
|
|
4889
|
-
entries.append(current)
|
|
4890
|
-
elif current is not None and not stripped.startswith("-"):
|
|
4891
|
-
current.append(stripped) # genuine wrap of the current entry
|
|
4892
|
-
else:
|
|
4893
|
-
current = None # a new / malformed list item ends the run
|
|
4894
|
-
return entries
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
4302
|
def _collect_open_spec_deltas(root: Path) -> list[dict]:
|
|
4898
4303
|
"""Scan every .add/tasks/*/TASK.md "### Spec delta" block for OPEN SPEC deltas.
|
|
4899
4304
|
|
|
@@ -5221,6 +4626,15 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
|
|
|
5221
4626
|
elif gate != "none" and outcomes[0] != gate:
|
|
5222
4627
|
f(slug, "gate_record_mismatch",
|
|
5223
4628
|
f"§6 records {outcomes[0]} but state.json records {gate}")
|
|
4629
|
+
elif gate == "none":
|
|
4630
|
+
# F13 ungated_verdict: §6 carries a verdict the engine never gated.
|
|
4631
|
+
# cmd_gate is the ONLY writer of `gate` (it also marks done), so a
|
|
4632
|
+
# done/observe task at gate=="none" reached its verdict without the
|
|
4633
|
+
# engine — a hand-stamped §6 or an `advance` past verify. Constraint 4
|
|
4634
|
+
# requires a RECORDED outcome; a §6 verdict without one is not trusted.
|
|
4635
|
+
f(slug, "ungated_verdict",
|
|
4636
|
+
f"§6 records {outcomes[0]} but state.json recorded no gate (gate=none) — "
|
|
4637
|
+
f"the verdict was written without the engine gate")
|
|
5224
4638
|
sec = _AUDIT_SECURITY_RE.search(s6)
|
|
5225
4639
|
marked = bool(sec and ("NOTE" in sec.group(0) or "⚠" in sec.group(0)))
|
|
5226
4640
|
rev = _AUDIT_REVIEWED_RE.search(s6)
|
|
@@ -5406,12 +4820,6 @@ def cmd_graduation_report(args: argparse.Namespace) -> None:
|
|
|
5406
4820
|
print("\n".join(L))
|
|
5407
4821
|
|
|
5408
4822
|
|
|
5409
|
-
def _releases_path(root: Path) -> Path:
|
|
5410
|
-
"""The append-only release ledger — at the PROJECT ROOT (root IS the .add dir, so its
|
|
5411
|
-
parent), a sibling of CHANGELOG.md. NOT inside .add/."""
|
|
5412
|
-
return root.parent / RELEASES_FILE
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
4823
|
def _released_milestones(root: Path) -> set[str]:
|
|
5416
4824
|
"""Slugs already attributed to a release — the union of every `milestones:` row in
|
|
5417
4825
|
RELEASES.md. Fail-OPEN: a missing/unreadable/malformed ledger yields the empty set, so
|
|
@@ -5432,21 +4840,6 @@ def _released_milestones(root: Path) -> set[str]:
|
|
|
5432
4840
|
return out
|
|
5433
4841
|
|
|
5434
4842
|
|
|
5435
|
-
def _closed_milestones(state: dict) -> list[dict]:
|
|
5436
|
-
"""Every CLOSED milestone (its milestone-done gate passed): LIVE done milestones
|
|
5437
|
-
(status == 'done', still in state) + ARCHIVED milestones (all were PASS-done before
|
|
5438
|
-
archive — see _archived_task_slugs). Each: {slug, title, tier}."""
|
|
5439
|
-
out: list[dict] = []
|
|
5440
|
-
for slug, m in (state.get("milestones") or {}).items():
|
|
5441
|
-
if m.get("status") == "done":
|
|
5442
|
-
out.append({"slug": slug, "title": m.get("title", slug), "tier": "live"})
|
|
5443
|
-
for rec in state.get("archived") or []:
|
|
5444
|
-
if rec.get("slug"):
|
|
5445
|
-
out.append({"slug": rec["slug"], "title": rec.get("title", rec["slug"]),
|
|
5446
|
-
"tier": "archived"})
|
|
5447
|
-
return out
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
4843
|
def _releasable(root: Path, state: dict) -> list[dict]:
|
|
5451
4844
|
"""Closed milestones NOT yet attributed to any RELEASES.md row — the cut's candidate
|
|
5452
4845
|
bundle. Drives BOTH the `→ releasable: N` status cue and release-report. READ-ONLY."""
|
|
@@ -5454,19 +4847,38 @@ def _releasable(root: Path, state: dict) -> list[dict]:
|
|
|
5454
4847
|
return [m for m in _closed_milestones(state) if m["slug"] not in released]
|
|
5455
4848
|
|
|
5456
4849
|
|
|
5457
|
-
def
|
|
5458
|
-
"""
|
|
5459
|
-
|
|
5460
|
-
|
|
4850
|
+
def _released_loose_tasks(root: Path) -> set[str]:
|
|
4851
|
+
"""Slugs already attributed to a release as LOOSE tasks — the union of every
|
|
4852
|
+
`loose tasks:` row in RELEASES.md. The exact parallel of _released_milestones:
|
|
4853
|
+
fail-OPEN (a missing/unreadable/malformed ledger yields the empty set, so every
|
|
4854
|
+
done loose task reads as still-releasable). READ-ONLY."""
|
|
5461
4855
|
try:
|
|
5462
|
-
text = (root
|
|
4856
|
+
text = _releases_path(root).read_text(encoding="utf-8")
|
|
5463
4857
|
except OSError:
|
|
5464
|
-
return
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
4858
|
+
return set() # no ledger -> nothing released yet
|
|
4859
|
+
out: set[str] = set()
|
|
4860
|
+
for line in text.splitlines():
|
|
4861
|
+
st = line.strip()
|
|
4862
|
+
if st.lower().startswith("loose tasks:"):
|
|
4863
|
+
for tok in re.split(r"[,\s]+", st.split(":", 1)[1]):
|
|
4864
|
+
tok = tok.strip()
|
|
4865
|
+
if tok and tok.lower() != "none":
|
|
4866
|
+
out.add(tok)
|
|
4867
|
+
return out
|
|
4868
|
+
|
|
4869
|
+
|
|
4870
|
+
def _releasable_loose_tasks(root: Path, state: dict) -> list[dict]:
|
|
4871
|
+
"""Done milestone-free tasks NOT yet attributed to any RELEASES.md `loose tasks:` row —
|
|
4872
|
+
the loose half of the cut's candidate bundle, peer to _releasable. A loose task is a
|
|
4873
|
+
first-class releasable item: milestone-free (a standalone, fast OR full) AND done (a
|
|
4874
|
+
completing gate). Drives the loose status cue + release_data["loose"]. READ-ONLY."""
|
|
4875
|
+
released = _released_loose_tasks(root)
|
|
4876
|
+
out: list[dict] = []
|
|
4877
|
+
for slug, t in (state.get("tasks") or {}).items():
|
|
4878
|
+
if (isinstance(t, dict) and t.get("milestone") is None and _task_done(t)
|
|
4879
|
+
and slug not in released):
|
|
4880
|
+
out.append({"slug": slug, "title": t.get("title", slug)})
|
|
4881
|
+
return out
|
|
5470
4882
|
|
|
5471
4883
|
|
|
5472
4884
|
def release_data(root: Path, state: dict) -> dict:
|
|
@@ -5531,15 +4943,19 @@ def release_data(root: Path, state: dict) -> dict:
|
|
|
5531
4943
|
monitors.append({"slug": slug, "watch": st})
|
|
5532
4944
|
break
|
|
5533
4945
|
|
|
4946
|
+
# loose — done milestone-free tasks not yet attributed (the cut's loose bundle, peer to releasable)
|
|
4947
|
+
loose = _releasable_loose_tasks(root, state)
|
|
4948
|
+
|
|
5534
4949
|
return {
|
|
5535
4950
|
"releasable": releasable,
|
|
5536
4951
|
"changed": changed,
|
|
5537
4952
|
"waivers": waivers,
|
|
5538
4953
|
"blockers": blockers,
|
|
5539
4954
|
"monitors": monitors,
|
|
4955
|
+
"loose": loose,
|
|
5540
4956
|
"summary": {
|
|
5541
4957
|
"releasable": len(releasable), "changed": len(changed), "waivers": len(waivers),
|
|
5542
|
-
"blockers": len(blockers), "monitors": len(monitors),
|
|
4958
|
+
"blockers": len(blockers), "monitors": len(monitors), "loose": len(loose),
|
|
5543
4959
|
},
|
|
5544
4960
|
}
|
|
5545
4961
|
|
|
@@ -5583,14 +4999,6 @@ def cmd_release_report(args: argparse.Namespace) -> None:
|
|
|
5583
4999
|
print("\n".join(L))
|
|
5584
5000
|
|
|
5585
5001
|
|
|
5586
|
-
def _build_in_flight(state: dict) -> bool:
|
|
5587
|
-
"""release_tests_red proxy (PURE): is any ACTIVE task mid-build without a recorded green gate
|
|
5588
|
-
— phase ∈ {build, verify} AND gate == 'none'? The tool-agnostic engine never runs the suite,
|
|
5589
|
-
so an entered-but-ungated build is the recorded-evidence stand-in for 'the suite is red'."""
|
|
5590
|
-
return any(t.get("phase") in ("build", "verify") and t.get("gate") == "none"
|
|
5591
|
-
for t in (state.get("tasks") or {}).values())
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
5002
|
def _prepend_block(existing: str, header: str, block: str) -> str:
|
|
5595
5003
|
"""Newest-first prepend: insert `block` directly under the top H1 `header`, creating the
|
|
5596
5004
|
header when `existing` is empty / headerless. Existing content is preserved VERBATIM
|
|
@@ -5603,37 +5011,6 @@ def _prepend_block(existing: str, header: str, block: str) -> str:
|
|
|
5603
5011
|
return f"{block}{existing}" # no recognized header -> block goes on top, verbatim tail
|
|
5604
5012
|
|
|
5605
5013
|
|
|
5606
|
-
def _render_changelog_block(version: str, day: str, bundle: list[dict],
|
|
5607
|
-
changed_by_slug: dict) -> str:
|
|
5608
|
-
"""A CHANGELOG block: `## <version> — <date>` + one bullet per bundled milestone (title +
|
|
5609
|
-
carried-delta / key-decision counts from release_data['changed'])."""
|
|
5610
|
-
lines = [f"## {version} — {day}", ""]
|
|
5611
|
-
if bundle:
|
|
5612
|
-
for m in bundle:
|
|
5613
|
-
c = changed_by_slug.get(m["slug"], {})
|
|
5614
|
-
lines.append(f"- {m['title']} — {c.get('carried_deltas', 0)} carried · "
|
|
5615
|
-
f"{len(c.get('key_decisions', []))} key decision(s)")
|
|
5616
|
-
else:
|
|
5617
|
-
lines.append("- (no milestone bundled)")
|
|
5618
|
-
return "\n".join(lines) + "\n\n"
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
def _render_releases_row(version: str, day: str, bundle: list[dict],
|
|
5622
|
-
waiver_slugs: list[str], evidence: str | None,
|
|
5623
|
-
actor: str | None = None) -> str:
|
|
5624
|
-
"""One append-only RELEASES.md row — the attribution source (`milestones:` membership).
|
|
5625
|
-
The `actor:` line records WHO cut the release (structured-actor stamping); absent on a
|
|
5626
|
-
legacy row (back-compat) when no actor is supplied."""
|
|
5627
|
-
ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
|
|
5628
|
-
wv = ", ".join(waiver_slugs) if waiver_slugs else "none"
|
|
5629
|
-
actor_line = f"actor: {actor}\n" if actor else ""
|
|
5630
|
-
return (f"## {version} — {day}\n"
|
|
5631
|
-
f"milestones: {ms}\n"
|
|
5632
|
-
f"waivers: {wv}\n"
|
|
5633
|
-
f"{actor_line}"
|
|
5634
|
-
f"evidence: {evidence or 'recorded by add.py release'}\n\n")
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
5014
|
def cmd_release(args: argparse.Namespace) -> None:
|
|
5638
5015
|
"""GUARDED, record-only: cut a version. Enforce the 4-code readiness floor, then RECORD by
|
|
5639
5016
|
prepending CHANGELOG.md + an append-only RELEASES.md row (whose `milestones:` line attributes
|
|
@@ -5657,9 +5034,11 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
5657
5034
|
_die("release_tests_red: a build is in flight without a recorded green gate — finish and "
|
|
5658
5035
|
"gate it first, or pass --force to override.")
|
|
5659
5036
|
bundle = _releasable(root, state)
|
|
5660
|
-
|
|
5037
|
+
loose_bundle = _releasable_loose_tasks(root, state)
|
|
5038
|
+
if not forced and not bundle and not loose_bundle:
|
|
5661
5039
|
_die("release_no_closed_milestone: nothing closed-and-unreleased to bundle — the cut "
|
|
5662
|
-
"would be a no-op. Close a milestone first, or pass --force
|
|
5040
|
+
"would be a no-op. Close a milestone (or a standalone task) first, or pass --force "
|
|
5041
|
+
"to override.")
|
|
5663
5042
|
if not forced and d["waivers"] and not disclosed:
|
|
5664
5043
|
_die("release_undisclosed_waiver: a RISK-ACCEPTED waiver rides into this release — pass "
|
|
5665
5044
|
"--with-waivers to disclose it in the notes, or --force to override.")
|
|
@@ -5677,7 +5056,7 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
5677
5056
|
new_rel = _prepend_block(rel_before, "# Releases",
|
|
5678
5057
|
_render_releases_row(args.version, day, bundle, waiver_slugs,
|
|
5679
5058
|
getattr(args, "evidence", None),
|
|
5680
|
-
_render_actor_line(state)))
|
|
5059
|
+
identity._render_actor_line(state), loose_bundle))
|
|
5681
5060
|
try: # CHANGELOG + RELEASES as one all-or-nothing commit
|
|
5682
5061
|
_atomic_write_many([(changelog_path, new_cl), (releases_path, new_rel)])
|
|
5683
5062
|
except OSError as e:
|
|
@@ -5686,7 +5065,9 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
5686
5065
|
|
|
5687
5066
|
# NO save_state — attribution lives in RELEASES.md (the cue re-reads it), never state.json
|
|
5688
5067
|
ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
|
|
5689
|
-
|
|
5068
|
+
lt = ", ".join(t["slug"] for t in loose_bundle) if loose_bundle else "none"
|
|
5069
|
+
print(f"released {args.version} — recorded {len(bundle)} milestone(s): {ms} "
|
|
5070
|
+
f"+ {len(loose_bundle)} loose task(s): {lt}")
|
|
5690
5071
|
print(" CHANGELOG.md + RELEASES.md updated (project root). The engine records; "
|
|
5691
5072
|
"you run the tag / publish / deploy.")
|
|
5692
5073
|
if forced:
|
|
@@ -5847,6 +5228,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5847
5228
|
pi.add_argument("--force", action="store_true", help="reset state.json if present")
|
|
5848
5229
|
pi.add_argument("--await-lock", dest="await_lock", action="store_true",
|
|
5849
5230
|
help="seed an unlocked setup; gates new-task/advance/gate until `add.py lock`")
|
|
5231
|
+
pi.add_argument("--rule-file", dest="rule_file", action="store_true",
|
|
5232
|
+
help="write the ADD block to .claude/rules/add-workflows.md and reference it "
|
|
5233
|
+
"from CLAUDE.md (auto-on for ccsk projects with a .ccsk/ dir)")
|
|
5850
5234
|
pi.set_defaults(func=cmd_init)
|
|
5851
5235
|
|
|
5852
5236
|
pl = sub.add_parser("lock",
|
|
@@ -6004,6 +5388,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6004
5388
|
help="confirm a RAISE toward auto (a human-owned trust escalation)")
|
|
6005
5389
|
pan.set_defaults(func=cmd_autonomy, _opt_positionals=("a1", "a2"))
|
|
6006
5390
|
|
|
5391
|
+
pto = sub.add_parser("todo", help="capture / list / close a lightweight backlog todo (jot an idea)")
|
|
5392
|
+
pto.add_argument("text", nargs="?", default=None,
|
|
5393
|
+
help="todo text to capture; omit to LIST open todos")
|
|
5394
|
+
pto.add_argument("--done", type=int, default=None, metavar="ID",
|
|
5395
|
+
help="close an open todo by id")
|
|
5396
|
+
pto.set_defaults(func=cmd_todo)
|
|
5397
|
+
|
|
6007
5398
|
pr = sub.add_parser("reopen", help="return a done task to an earlier phase with a recorded reason")
|
|
6008
5399
|
pr.add_argument("slug", nargs="?", default=None)
|
|
6009
5400
|
# --to / --reason are validated in-body (not argparse choices) so the named reject
|
|
@@ -6033,6 +5424,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6033
5424
|
pck.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
6034
5425
|
pck.set_defaults(func=cmd_check)
|
|
6035
5426
|
|
|
5427
|
+
pfed = sub.add_parser("federate", help="multi-repo: pull a producer repo's published, immutable "
|
|
5428
|
+
"contract snapshot into this repo (fail-loud)")
|
|
5429
|
+
pfedsub = pfed.add_subparsers(dest="action", required=True)
|
|
5430
|
+
pfedpull = pfedsub.add_parser("pull", help="land [federation.<id>].source at the local "
|
|
5431
|
+
".add/contracts/<id>.json (hard-stops on a bad source)")
|
|
5432
|
+
pfedpull.add_argument("id", help="the contract id declared under [federation.<id>]")
|
|
5433
|
+
pfedpull.set_defaults(func=cmd_federate)
|
|
5434
|
+
|
|
6036
5435
|
pdoc = sub.add_parser("doctor", help="read-only diagnosis of state.json integrity + "
|
|
6037
5436
|
"referential consistency (run after a git merge)")
|
|
6038
5437
|
pdoc.set_defaults(func=cmd_doctor)
|
|
@@ -6053,6 +5452,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6053
5452
|
|
|
6054
5453
|
psg = sub.add_parser("sync-guidelines",
|
|
6055
5454
|
help="(re)write the ADD guideline block into AGENTS.md + CLAUDE.md")
|
|
5455
|
+
psg.add_argument("--rule-file", dest="rule_file", action="store_true",
|
|
5456
|
+
help="relocate CLAUDE.md's block to .claude/rules/add-workflows.md + reference "
|
|
5457
|
+
"it (auto-on for ccsk projects)")
|
|
6056
5458
|
psg.set_defaults(func=cmd_sync_guidelines)
|
|
6057
5459
|
|
|
6058
5460
|
pgd = sub.add_parser("guide", help="print the one concrete next step for the active task")
|
|
@@ -6164,16 +5566,6 @@ def _rebind_optional_positionals(parser: argparse.ArgumentParser,
|
|
|
6164
5566
|
# silently does nothing and nothing is lost. It NEVER changes a command's stdout or exit.
|
|
6165
5567
|
_UPDATE_CACHE = ".update-cache.json"
|
|
6166
5568
|
_UPDATE_TTL = timedelta(hours=24) # hit the registry at most once / day
|
|
6167
|
-
_REGISTRY_LATEST = "https://registry.npmjs.org/@pilotspace/add/latest"
|
|
6168
|
-
|
|
6169
|
-
|
|
6170
|
-
def _read_json_safe(path: Path):
|
|
6171
|
-
try:
|
|
6172
|
-
return json.loads(path.read_text(encoding="utf-8"))
|
|
6173
|
-
except (OSError, json.JSONDecodeError):
|
|
6174
|
-
return None
|
|
6175
|
-
|
|
6176
|
-
|
|
6177
5569
|
def _write_json_safe(path: Path, obj) -> None:
|
|
6178
5570
|
try:
|
|
6179
5571
|
path.write_text(json.dumps(obj, indent=2) + "\n", encoding="utf-8")
|
|
@@ -6181,33 +5573,6 @@ def _write_json_safe(path: Path, obj) -> None:
|
|
|
6181
5573
|
pass
|
|
6182
5574
|
|
|
6183
5575
|
|
|
6184
|
-
def _version_gt(a: str, b: str) -> bool:
|
|
6185
|
-
"""True if version a is newer than b (dotted numeric; prerelease suffix dropped)."""
|
|
6186
|
-
def key(v: str):
|
|
6187
|
-
out = []
|
|
6188
|
-
for part in str(v).split("."):
|
|
6189
|
-
part = part.split("-", 1)[0]
|
|
6190
|
-
out.append((0, int(part)) if part.isdigit() else (1, part))
|
|
6191
|
-
return out
|
|
6192
|
-
try:
|
|
6193
|
-
return key(a) > key(b)
|
|
6194
|
-
except Exception:
|
|
6195
|
-
return False
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
def _fetch_latest_version(timeout: float = 1.5):
|
|
6199
|
-
"""GET the registry's latest version. Returns a string, or None on ANY failure
|
|
6200
|
-
(offline, timeout, bad payload) — the caller treats None as 'unknown, skip'."""
|
|
6201
|
-
try:
|
|
6202
|
-
req = urllib.request.Request(_REGISTRY_LATEST, headers={"Accept": "application/json"})
|
|
6203
|
-
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
6204
|
-
data = json.loads(resp.read().decode("utf-8"))
|
|
6205
|
-
v = data.get("version")
|
|
6206
|
-
return v if isinstance(v, str) and v else None
|
|
6207
|
-
except Exception:
|
|
6208
|
-
return None
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
5576
|
def _cached_latest(add_dir: Path):
|
|
6212
5577
|
"""The registry's latest version, throttled: served from .update-cache.json within
|
|
6213
5578
|
the TTL, else refreshed over the network (fail-open). None when unknown."""
|