@pilotspace/add 1.10.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 +40 -0
- package/bin/cli.js +55 -0
- 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 +2 -2
- package/docs/appendix-c-glossary.md +3 -3
- package/package.json +4 -1
- package/skill/add/SKILL.md +7 -1
- 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 +361 -1571
- 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/gitignore.tmpl +6 -0
package/tooling/add.py
CHANGED
|
@@ -30,274 +30,102 @@ try: # component-aware-add registry parse (Python 3.11+ st
|
|
|
30
30
|
except ModuleNotFoundError: # < 3.11: the registry is unsupported → degrade to opt-out
|
|
31
31
|
tomllib = None
|
|
32
32
|
|
|
33
|
-
# --- constants
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
#
|
|
41
|
-
|
|
42
|
-
STAGES = ("prototype", "poc", "mvp", "production")
|
|
43
|
-
# v22 stage-graduation: the read-only cue `status` shows when the MVP is covered.
|
|
44
|
-
# Worded as the ACTION (never a file) so it stands before graduate.md exists.
|
|
45
|
-
GRADUATION_CUE = "MVP covered → propose graduation"
|
|
46
|
-
# release-altitude: the read-only cue `status` shows when ≥1 closed milestone is
|
|
47
|
-
# unreleased. The 5th scope level (release.md). `{n}` is filled at print time; the
|
|
48
|
-
# wording matches SKILL.md's "Beyond the bundle" cross-ref byte-for-byte.
|
|
49
|
-
RELEASABLE_CUE = "releasable: {n} milestone(s) closed since last release"
|
|
50
|
-
# the append-only release ledger lives at the PROJECT ROOT (the dir containing .add/),
|
|
51
|
-
# a sibling of CHANGELOG.md — NOT inside .add/. The ledger IS the attribution source:
|
|
52
|
-
# a milestone is "released" iff its slug appears on a `milestones:` row.
|
|
53
|
-
RELEASES_FILE = "RELEASES.md"
|
|
54
|
-
PHASES = ("ground", "specify", "scenarios", "contract", "tests", "build", "verify", "observe", "done")
|
|
55
|
-
GATES = ("none", "PASS", "RISK-ACCEPTED", "HARD-STOP")
|
|
56
|
-
# heal-then-escalate (verify-integrity): the bounded self-heal loop cap. A CONFIRMED cheat
|
|
57
|
-
# (mechanical tripwire divergence, or an agent-reported semantic refute-read finding) returns
|
|
58
|
-
# the task to BUILD for an honest redo; after HEAL_CAP such attempts the next confirmed cheat
|
|
59
|
-
# forces a HARD-STOP escalation to the human. MONOTONIC — attempts never auto-resets (a gamed
|
|
60
|
-
# green is never auto-passed; the loop is never unbounded).
|
|
61
|
-
HEAL_CAP = 3
|
|
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
|
+
)
|
|
62
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
|
+
)
|
|
63
47
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
)
|
|
67
53
|
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"specify": ("state every rule — Must / Reject (+ named code) / After; rank assumptions lowest-confidence first and flag the biggest risk",
|
|
74
|
-
"03-step-1-specify.md"),
|
|
75
|
-
"scenarios": ("write one Given/When/Then per Must AND per Reject; every result observable",
|
|
76
|
-
"04-step-2-scenarios.md"),
|
|
77
|
-
"contract": ("freeze the shape — signature, fields, error codes; names match the glossary",
|
|
78
|
-
"05-step-3-contract.md"),
|
|
79
|
-
"tests": ("write one failing test per scenario; run them RED for the right reason",
|
|
80
|
-
"06-step-4-tests.md"),
|
|
81
|
-
"build": ("write the minimum code to pass the tests; change no test and no contract",
|
|
82
|
-
"07-step-5-build.md"),
|
|
83
|
-
"verify": ("run the suite + non-functional checks, then record the gate",
|
|
84
|
-
"08-step-6-verify.md"),
|
|
85
|
-
"observe": ("note what to watch + the spec delta for the next loop",
|
|
86
|
-
"09-the-loop.md"),
|
|
87
|
-
"done": ("this task is done — pick the next feature",
|
|
88
|
-
"02-the-flow.md"),
|
|
89
|
-
}
|
|
90
|
-
# Phase -> who owns it, for the `--json` autonomy signal. An autonomous harness may run a
|
|
91
|
-
# phase only when owner=="ai" (stop is false); every other phase is a checkpoint. The map
|
|
92
|
-
# follows the book's who-does-what table (Verify is "human only"); `tests`/`build`/`observe`
|
|
93
|
-
# are AI-led. A phase missing here is `unmapped_phase` (fail closed) — never defaulted.
|
|
94
|
-
PHASE_OWNER = {
|
|
95
|
-
"ground": "ai",
|
|
96
|
-
"specify": "human", "scenarios": "human", "contract": "seam",
|
|
97
|
-
"tests": "ai", "build": "ai", "verify": "human", "observe": "ai", "done": "human",
|
|
98
|
-
}
|
|
99
|
-
SETUP_FILES = ("PROJECT.md", "CONVENTIONS.md", "GLOSSARY.md", "MODEL_REGISTRY.md", "dependencies.allowlist", "DESIGN.md", "SOUL.md")
|
|
100
|
-
|
|
101
|
-
# Scaffolded into .add/.gitignore at init so the engine's transient LOCAL artifacts
|
|
102
|
-
# never reach git. Bare-filename patterns match at any depth under .add/ (tasks/,
|
|
103
|
-
# milestones/, archive/). These are working state, not records: scope-snapshot.json
|
|
104
|
-
# is the tests->build touch baseline the verify scope-gate reads from disk (the
|
|
105
|
-
# durable scope declaration is the state.json anchor); pre-archive-state.bak.json is
|
|
106
|
-
# archive-milestone's pre-delete recovery net — needed on disk, never in history;
|
|
107
|
-
# .update-cache.json is the update-nudge's once-a-day registry throttle. All stay on
|
|
108
|
-
# disk; git-ignoring them is hygiene, never deletion.
|
|
109
|
-
_GITIGNORE_BODY = """\
|
|
110
|
-
# ADD engine transient artifacts — local working state, never committed.
|
|
111
|
-
# (Scaffolded by `add.py init`; edit freely — init never clobbers an existing copy.)
|
|
112
|
-
scope-snapshot.json
|
|
113
|
-
pre-archive-state.bak.json
|
|
114
|
-
.update-cache.json
|
|
115
|
-
"""
|
|
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
|
+
)
|
|
116
59
|
|
|
117
|
-
#
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
_GUIDE_BEGIN = "<!-- ADD:BEGIN — managed by `add.py sync-guidelines`; do not edit inside -->"
|
|
122
|
-
_GUIDE_END = "<!-- ADD:END -->"
|
|
123
|
-
|
|
124
|
-
# Rule-file mode (ccsk-style projects): instead of inlining the block into CLAUDE.md,
|
|
125
|
-
# write it to a dedicated rule file under .claude/rules/ and leave a one-line reference
|
|
126
|
-
# in CLAUDE.md's Workflows section. .claude/rules/ is a CLAUDE-only convention, so this
|
|
127
|
-
# mode only ever relocates CLAUDE.md — AGENTS.md/.clinerules keep the inline block.
|
|
128
|
-
RULES_FILE_REL = Path(".claude") / "rules" / "add-workflows.md"
|
|
129
|
-
# Headings (most→least specific) a project may already use to group rule/workflow links.
|
|
130
|
-
# Match is case-insensitive on the heading TEXT, at any `#` level.
|
|
131
|
-
WORKFLOW_HEADINGS = ("Rules & Workflows", "Workflows", "Rules")
|
|
132
|
-
_RULE_REF_LINE = "- ADD (AI-Driven Development) Workflows rules: ./.claude/rules/add-workflows.md"
|
|
133
|
-
|
|
134
|
-
# Minimal embedded fallback so the tool still works if templates/ is missing
|
|
135
|
-
# (circuit breaker: never hard-fail just because a template file was deleted).
|
|
136
|
-
_FALLBACK_TASK = """# TASK: {title}
|
|
137
|
-
|
|
138
|
-
slug: {slug} · created: {date} · stage: {stage}
|
|
139
|
-
autonomy: auto
|
|
140
|
-
phase: ground
|
|
141
|
-
|
|
142
|
-
## 0 · GROUND
|
|
143
|
-
Touches (files · symbols · signatures):
|
|
144
|
-
Honors (patterns / conventions):
|
|
145
|
-
Anchors the contract cites:
|
|
146
|
-
|
|
147
|
-
## 1 · SPECIFY
|
|
148
|
-
Feature:
|
|
149
|
-
Framings weighed:
|
|
150
|
-
Must:
|
|
151
|
-
Reject:
|
|
152
|
-
After:
|
|
153
|
-
Assumptions — lowest-confidence first:
|
|
154
|
-
⚠ <most likely wrong> — lowest confidence because <why>; if wrong: <cost>
|
|
155
|
-
|
|
156
|
-
## 2 · SCENARIOS
|
|
157
|
-
## 3 · CONTRACT
|
|
158
|
-
Status: DRAFT
|
|
159
|
-
## 4 · TESTS
|
|
160
|
-
## 5 · BUILD
|
|
161
|
-
## 6 · VERIFY
|
|
162
|
-
### GATE RECORD
|
|
163
|
-
Outcome:
|
|
164
|
-
## 7 · OBSERVE
|
|
165
|
-
### Spec delta
|
|
166
|
-
### Competency deltas
|
|
167
|
-
"""
|
|
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
|
+
)
|
|
168
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
|
+
)
|
|
169
82
|
|
|
170
|
-
# Fast-lane fallback: the minimal TASK.md variant (sections {0,1,3,4,5,6}; §2 + §7 dropped).
|
|
171
|
-
# Mirrors templates/TASK.fast.md.tmpl's section set (circuit-breaker parity); a deleted
|
|
172
|
-
# templates/ never hard-fails the fast lane. Keeps the trust floor: §3 freeze-flag + Status,
|
|
173
|
-
# §6 GATE RECORD/Outcome, §0 Anchors, §4 red-before-build, §5 Scope.
|
|
174
|
-
_FALLBACK_TASK_FAST = """# TASK: {title}
|
|
175
|
-
|
|
176
|
-
slug: {slug} · created: {date} · stage: {stage}
|
|
177
|
-
autonomy: auto
|
|
178
|
-
phase: ground
|
|
179
|
-
fast: true
|
|
180
|
-
|
|
181
|
-
## 0 · GROUND
|
|
182
|
-
Touches (files · symbols):
|
|
183
|
-
Anchors the contract cites:
|
|
184
|
-
|
|
185
|
-
## 1 · SPECIFY
|
|
186
|
-
Feature:
|
|
187
|
-
Must:
|
|
188
|
-
Reject:
|
|
189
|
-
Accept:
|
|
190
|
-
Assumptions: ⚠ <most likely wrong> — why; if wrong: <cost>
|
|
191
|
-
|
|
192
|
-
## 3 · CONTRACT
|
|
193
|
-
Least-sure flag surfaced at freeze:
|
|
194
|
-
Status: DRAFT
|
|
195
|
-
|
|
196
|
-
## 4 · TESTS
|
|
197
|
-
Plan:
|
|
198
|
-
Tests live in: `./tests/` · MUST run red before Build.
|
|
199
|
-
|
|
200
|
-
## 5 · BUILD
|
|
201
|
-
Scope (may touch): `./src/`
|
|
202
|
-
|
|
203
|
-
## 6 · VERIFY
|
|
204
|
-
Build expectations (from §1 Accept + §3 CONTRACT):
|
|
205
|
-
### GATE RECORD
|
|
206
|
-
Outcome:
|
|
207
|
-
Reviewed by:
|
|
208
|
-
"""
|
|
209
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)
|
|
210
87
|
|
|
211
|
-
# --- 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
|
+
)
|
|
212
97
|
|
|
213
|
-
def _now() -> str:
|
|
214
|
-
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
215
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
|
+
)
|
|
216
104
|
|
|
217
|
-
|
|
218
|
-
"""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) -
|
|
219
106
|
|
|
220
|
-
|
|
221
|
-
"""
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
226
|
-
fh.write(text)
|
|
227
|
-
os.replace(tmp, path)
|
|
228
|
-
finally:
|
|
229
|
-
if os.path.exists(tmp):
|
|
230
|
-
os.unlink(tmp)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
def _atomic_write_bytes(path: Path, data: bytes) -> None:
|
|
234
|
-
"""Binary sibling of `_atomic_write` — lands `data` UNCHANGED (no newline translation), so a
|
|
235
|
-
byte-for-byte copy stays exact. Same temp-then-replace crash-safety."""
|
|
236
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
237
|
-
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
|
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."""
|
|
238
112
|
try:
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def _atomic_write_many(writes: list[tuple[Path, str]]) -> None:
|
|
248
|
-
"""True all-or-nothing commit across N files — design-for-failure for a multi-file write.
|
|
249
|
-
|
|
250
|
-
Phase 1 STAGES every (path, text) to a sibling `.tmp`, flushing + fsync-ing each, so the
|
|
251
|
-
realistic IO failures (disk full, permission denied) surface HERE, before any target changes —
|
|
252
|
-
and on any stage failure every staged temp is removed, so NOTHING is committed. Phase 2 then
|
|
253
|
-
COMMITS each file by renaming any existing target ASIDE to a sibling `.bak`, then `os.replace`-ing
|
|
254
|
-
the staged `.tmp` into place. If ANY commit rename raises, every file already committed is rolled
|
|
255
|
-
back IN REVERSE (remove the landed new file, rename its `.bak` back, or leave it absent) and the
|
|
256
|
-
original error re-raised — so the whole set is all-or-nothing: either every file holds its new
|
|
257
|
-
text, or every file holds its prior content. Restoring is an atomic rename of an already-written
|
|
258
|
-
`.bak` (no content held in memory, no re-write), the cheapest recovery under failing IO. Leftover
|
|
259
|
-
`.tmp`/`.bak` siblings are removed on every exit path.
|
|
260
|
-
"""
|
|
261
|
-
staged: list[tuple[str, Path]] = []
|
|
262
|
-
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()
|
|
263
121
|
try:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
os.fsync(fh.fileno())
|
|
272
|
-
try:
|
|
273
|
-
for tmp, path in staged: # phase 2: commit via rename-aside
|
|
274
|
-
bak = None
|
|
275
|
-
existed = path.exists()
|
|
276
|
-
if existed:
|
|
277
|
-
fd2, bak = tempfile.mkstemp(dir=str(path.parent), suffix=".bak")
|
|
278
|
-
os.close(fd2)
|
|
279
|
-
committed.append([path, bak, False]) # track .bak NOW so cleanup never leaks it
|
|
280
|
-
if existed:
|
|
281
|
-
os.replace(path, bak) # move the existing target aside
|
|
282
|
-
os.replace(tmp, path) # move the new file in
|
|
283
|
-
committed[-1][2] = True
|
|
284
|
-
except OSError:
|
|
285
|
-
for path, bak, landed in reversed(committed): # roll back, newest-first
|
|
286
|
-
try:
|
|
287
|
-
if landed and path.exists():
|
|
288
|
-
os.unlink(path) # drop the new file we put in
|
|
289
|
-
if bak is not None and not path.exists():
|
|
290
|
-
os.replace(bak, path) # restore the prior target (atomic rename)
|
|
291
|
-
except OSError:
|
|
292
|
-
pass # best-effort restore under already-failing IO
|
|
293
|
-
raise
|
|
294
|
-
finally:
|
|
295
|
-
for tmp, _ in staged: # leftover .tmp (failed/aborted stage) never persists
|
|
296
|
-
if os.path.exists(tmp):
|
|
297
|
-
os.unlink(tmp)
|
|
298
|
-
for path, bak, landed in committed: # leftover .bak (success, or orphaned post-rollback)
|
|
299
|
-
if bak is not None and os.path.exists(bak):
|
|
300
|
-
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")
|
|
301
129
|
|
|
302
130
|
|
|
303
131
|
def _templates_dir() -> Path:
|
|
@@ -322,206 +150,18 @@ def _render_template(name: str, **subs: str) -> str:
|
|
|
322
150
|
text = text.replace("{{" + key + "}}", val)
|
|
323
151
|
return text
|
|
324
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
|
+
)
|
|
325
158
|
|
|
326
|
-
# ---
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (d / ROOT_DIRNAME / STATE_FILE).exists():
|
|
333
|
-
return d / ROOT_DIRNAME
|
|
334
|
-
return None
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
def _require_root() -> Path:
|
|
338
|
-
root = find_root()
|
|
339
|
-
if root is None:
|
|
340
|
-
_die("no .add/ project found. Run `add.py init` first.")
|
|
341
|
-
return root
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
def _migrate_state(state: dict) -> dict:
|
|
345
|
-
"""Forward-migrate a single-active state to the multi-active schema (team-collaboration
|
|
346
|
-
foundation). PURE · idempotent · TOTAL · never raises · no I/O.
|
|
347
|
-
|
|
348
|
-
A state lacking the `active_milestones` key gains it — DERIVED from the scalar
|
|
349
|
-
`active_milestone` (grandfather-by-missing-key, mirroring `_setup_locked`): None -> [],
|
|
350
|
-
"x" -> ["x"]. A per-milestone active-task map `active_tasks` is added; the old global
|
|
351
|
-
`active_task` is placed under its owning active milestone only when it genuinely belongs
|
|
352
|
-
there, else it stays as the top-level scalar fallback (orphan rule, FROZEN decision (a)).
|
|
353
|
-
The scalar `active_milestone` / `active_task` keys are KEPT as the N<=1 mirror so the
|
|
354
|
-
not-yet-routed readers keep working. An already-migrated state (key present) is returned
|
|
355
|
-
unchanged — never re-derived, never clobbered. Corrupt parsing stays the loader's job.
|
|
356
|
-
|
|
357
|
-
PURE in the observable sense: the caller's dict is NEVER mutated — a state that needs
|
|
358
|
-
migrating is upgraded on a fresh top-level copy (nested objects are shared but only read)."""
|
|
359
|
-
if not isinstance(state, dict) or "active_milestones" in state:
|
|
360
|
-
return state
|
|
361
|
-
migrated = dict(state)
|
|
362
|
-
active_ms = migrated.get("active_milestone")
|
|
363
|
-
migrated["active_milestones"] = [] if active_ms is None else [active_ms]
|
|
364
|
-
active_task = migrated.get("active_task")
|
|
365
|
-
tasks = migrated.get("tasks") or {}
|
|
366
|
-
owns = (active_ms is not None and active_task is not None
|
|
367
|
-
and isinstance(tasks.get(active_task), dict)
|
|
368
|
-
and tasks[active_task].get("milestone") == active_ms)
|
|
369
|
-
migrated["active_tasks"] = {active_ms: active_task} if owns else {}
|
|
370
|
-
return migrated
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
# --- active milestone/task accessor seam (multi-active foundation) -----------
|
|
374
|
-
#
|
|
375
|
-
# Every engine call site reads & writes the active milestone(s)/task through these
|
|
376
|
-
# four helpers, so multi-active SEMANTICS can land in one place later. Today they
|
|
377
|
-
# preserve single-active behavior exactly: reads return the scalar mirror, writes
|
|
378
|
-
# keep the scalar AND the new active_milestones/active_tasks structures in sync.
|
|
379
|
-
|
|
380
|
-
def _active_milestone(state: dict) -> str | None:
|
|
381
|
-
"""The primary active milestone — the N<=1 scalar mirror (== active_milestones[0])."""
|
|
382
|
-
return state.get("active_milestone")
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
def _active_task(state: dict, milestone: str | None = None) -> str | None:
|
|
386
|
-
"""The active task: per-milestone when `milestone` is given (partial-state -> None),
|
|
387
|
-
else the global/primary scalar active task. Total — never raises."""
|
|
388
|
-
if milestone is None:
|
|
389
|
-
return state.get("active_task")
|
|
390
|
-
return (state.get("active_tasks") or {}).get(milestone)
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
def _set_active_milestone(state: dict, slug: str | None) -> None:
|
|
394
|
-
"""Set the primary active milestone, keeping `active_milestones` consistent (N<=1 sync)."""
|
|
395
|
-
state["active_milestone"] = slug
|
|
396
|
-
state["active_milestones"] = [] if slug is None else [slug]
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
def _set_active_task(state: dict, slug: str | None, milestone: str | None = None) -> None:
|
|
400
|
-
"""Set the active task, keeping the scalar mirror AND the per-milestone map in sync.
|
|
401
|
-
With no owning active milestone the active task is scalar-only (the migration's orphan
|
|
402
|
-
rule); clearing (slug is None) pops the milestone's entry."""
|
|
403
|
-
state["active_task"] = slug
|
|
404
|
-
ms = milestone if milestone is not None else _active_milestone(state)
|
|
405
|
-
tasks_map = state.setdefault("active_tasks", {})
|
|
406
|
-
if ms is None:
|
|
407
|
-
return
|
|
408
|
-
if slug is None:
|
|
409
|
-
tasks_map.pop(ms, None)
|
|
410
|
-
else:
|
|
411
|
-
tasks_map[ms] = slug
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
def _activate_milestone(state: dict, slug: str) -> None:
|
|
415
|
-
"""Add a milestone to the active SET (idempotent) and make it the primary focus,
|
|
416
|
-
syncing the scalar active_task to that milestone's entry. Does NOT remove other members
|
|
417
|
-
(this is how a user reaches N>=2 active milestones)."""
|
|
418
|
-
ms_list = state.setdefault("active_milestones", [])
|
|
419
|
-
if slug not in ms_list:
|
|
420
|
-
ms_list.append(slug)
|
|
421
|
-
state["active_milestone"] = slug
|
|
422
|
-
state["active_task"] = (state.get("active_tasks") or {}).get(slug)
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
def _deactivate_milestone(state: dict, slug: str) -> None:
|
|
426
|
-
"""Remove a milestone from the active SET, pop its active-task entry, and (if it was the
|
|
427
|
-
primary) repoint the primary to the most-recent remaining member (or None when empty)."""
|
|
428
|
-
ms_list = state.setdefault("active_milestones", [])
|
|
429
|
-
if slug in ms_list:
|
|
430
|
-
ms_list.remove(slug)
|
|
431
|
-
(state.setdefault("active_tasks", {})).pop(slug, None)
|
|
432
|
-
if state.get("active_milestone") == slug:
|
|
433
|
-
new = ms_list[-1] if ms_list else None
|
|
434
|
-
state["active_milestone"] = new
|
|
435
|
-
state["active_task"] = (state.get("active_tasks") or {}).get(new) if new else None
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
def _git_config(key: str) -> str | None:
|
|
439
|
-
"""Read one `git config --get <key>`, STRICTLY fail-soft: the engine's FIRST git call,
|
|
440
|
-
so it never raises, never hangs, never shells. Returns the trimmed value, or None when
|
|
441
|
-
git is absent / errors / times out / the value is empty."""
|
|
442
|
-
if shutil.which("git") is None:
|
|
443
|
-
return None
|
|
444
|
-
try:
|
|
445
|
-
out = subprocess.run(
|
|
446
|
-
["git", "config", "--get", key],
|
|
447
|
-
capture_output=True, text=True, timeout=2,
|
|
448
|
-
).stdout.strip()
|
|
449
|
-
except (OSError, subprocess.SubprocessError, ValueError):
|
|
450
|
-
# OSError: git vanished between which() and run() / spawn error · SubprocessError:
|
|
451
|
-
# TimeoutExpired · ValueError: a non-UTF-8 config value (latin-1 legacy name) makes
|
|
452
|
-
# text=True decoding raise UnicodeDecodeError (a ValueError) — all fail soft to None.
|
|
453
|
-
return None
|
|
454
|
-
return out or None
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
def _os_user() -> str:
|
|
458
|
-
"""The guaranteed non-empty OS floor. getpass.getuser() reads LOGNAME/USER/... then
|
|
459
|
-
falls back to the passwd database — but in a bare container (no env var AND no passwd
|
|
460
|
-
entry) CPython raises KeyError (OSError only on 3.13+). Catch broadly and return a
|
|
461
|
-
sentinel so _whoami stays TOTAL: it always yields a non-empty name, never crashes."""
|
|
462
|
-
try:
|
|
463
|
-
return getpass.getuser() or "unknown"
|
|
464
|
-
except (KeyError, OSError):
|
|
465
|
-
return "unknown"
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
def _whoami(state: dict) -> dict:
|
|
469
|
-
"""Resolve the current git-native ACTOR -> {name, email, source}. Priority:
|
|
470
|
-
(1) an `actor_override` (whoami --set) with a non-blank name -> source 'override';
|
|
471
|
-
(2) `git config user.name`/`user.email` -> source 'git';
|
|
472
|
-
(3) the OS user (_os_user) -> source 'os', the guaranteed non-empty floor.
|
|
473
|
-
Total: always returns a dict with a non-empty name; `email` may be None."""
|
|
474
|
-
ov = state.get("actor_override")
|
|
475
|
-
if ov and (ov.get("name") or "").strip():
|
|
476
|
-
return {"name": ov["name"], "email": ov.get("email"), "source": "override"}
|
|
477
|
-
name = _git_config("user.name")
|
|
478
|
-
if name:
|
|
479
|
-
return {"name": name, "email": _git_config("user.email"), "source": "git"}
|
|
480
|
-
return {"name": _os_user(), "email": None, "source": "os"}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
def _actor_stamp(state: dict) -> dict:
|
|
484
|
-
"""The SINGLE source of the structured-actor stamp every engine-WRITTEN human action
|
|
485
|
-
records — lock · gate · milestone-done · release (user-identity actor-stamping). It IS
|
|
486
|
-
`_whoami(state)`: a TOTAL {name,email,source} (always a non-empty name), so a stamp can
|
|
487
|
-
never fail or block a write. Descriptive only — no command's decision reads it."""
|
|
488
|
-
return _whoami(state)
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
def _render_actor_line(state: dict) -> str:
|
|
492
|
-
"""Render the actor stamp as one human-readable line: name, an optional angle-bracketed
|
|
493
|
-
email, then the source in parens — used on the RELEASES.md row (no state.json write)."""
|
|
494
|
-
a = _actor_stamp(state)
|
|
495
|
-
email = f" <{a['email']}>" if a.get("email") else ""
|
|
496
|
-
return f"{a['name']}{email} ({a['source']})"
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
def _parse_actor_arg(s: str) -> dict:
|
|
500
|
-
"""Parse an `assign --owner`/`--assignee` value into a {name, email, source: "assigned"}
|
|
501
|
-
actor (ownership-assignment). "Name <email>" -> both; a bare "Name" -> email None. TOTAL:
|
|
502
|
-
a malformed value (no closing bracket) never raises — the whole stripped string is the name.
|
|
503
|
-
`source` is "assigned" — a human typed this name (not git-resolved nor an ADD override)."""
|
|
504
|
-
m = re.match(r"^\s*(.*?)\s*<([^>]*)>\s*$", s)
|
|
505
|
-
if m:
|
|
506
|
-
return {"name": m.group(1), "email": m.group(2) or None, "source": "assigned"}
|
|
507
|
-
return {"name": s.strip(), "email": None, "source": "assigned"}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
def _actor_matches(rec_actor: dict | None, me: dict) -> bool:
|
|
511
|
-
"""Does a recorded owner/assignee actor identify the SAME person as `me` (multi-active-UX)?
|
|
512
|
-
Email-first (the stabler key): when BOTH carry a non-empty email, emails decide; otherwise
|
|
513
|
-
fall back to name-equality. Both comparisons are stripped + case-insensitive. TOTAL — a None,
|
|
514
|
-
non-dict, or blank-name record returns False (an unowned/garbage slot is no one's)."""
|
|
515
|
-
if not isinstance(rec_actor, dict):
|
|
516
|
-
return False
|
|
517
|
-
rec_name = (rec_actor.get("name") or "").strip()
|
|
518
|
-
if not rec_name:
|
|
519
|
-
return False
|
|
520
|
-
rec_email = (rec_actor.get("email") or "").strip()
|
|
521
|
-
me_email = (me.get("email") or "").strip()
|
|
522
|
-
if rec_email and me_email:
|
|
523
|
-
return rec_email.lower() == me_email.lower()
|
|
524
|
-
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
|
+
)
|
|
525
165
|
|
|
526
166
|
|
|
527
167
|
def _my_work(state: dict, me: dict) -> list[dict]:
|
|
@@ -535,8 +175,8 @@ def _my_work(state: dict, me: dict) -> list[dict]:
|
|
|
535
175
|
for slug, t in tasks.items():
|
|
536
176
|
if not isinstance(t, dict) or t.get("milestone") not in active_set or _task_done(t):
|
|
537
177
|
continue
|
|
538
|
-
owns = _actor_matches(t.get("owner"), me)
|
|
539
|
-
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)
|
|
540
180
|
if not (owns or assigned):
|
|
541
181
|
continue
|
|
542
182
|
role = "both" if owns and assigned else ("owner" if owns else "assignee")
|
|
@@ -550,106 +190,6 @@ def _my_work(state: dict, me: dict) -> list[dict]:
|
|
|
550
190
|
# A git conflict marker BEGINS a line with 7 of `<`, `=`, or `>` (`(?m)^…`). An unresolved
|
|
551
191
|
# merge writes these into state.json, making it invalid JSON; the line-anchor keeps a
|
|
552
192
|
# legitimate value (always on an INDENTED JSON line) from false-tripping the guard.
|
|
553
|
-
_CONFLICT_MARKER_RE = re.compile(r"(?m)^(<{7}|={7}|>{7})")
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
def _state_text_or_die(root: Path) -> str:
|
|
557
|
-
"""Read state.json's raw text, failing CLOSED with a merge-SPECIFIC `state_conflicted`
|
|
558
|
-
message when it carries git conflict markers (an unresolved merge — the major's #1 failure
|
|
559
|
-
mode). A genuine read OSError is NOT swallowed: it propagates to the caller, which maps it
|
|
560
|
-
to its own existing code (state_invalid / no_state). The guard only READS — never writes."""
|
|
561
|
-
text = (root / STATE_FILE).read_text(encoding="utf-8")
|
|
562
|
-
if _CONFLICT_MARKER_RE.search(text):
|
|
563
|
-
_die(f"state_conflicted: {root / STATE_FILE} has unresolved git merge markers "
|
|
564
|
-
f"(<<<<<<< / ======= / >>>>>>>) — resolve them (or "
|
|
565
|
-
f"`git checkout --ours/--theirs {STATE_FILE}`), then run `add.py doctor` to verify")
|
|
566
|
-
return text
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
def load_state(root: Path) -> dict:
|
|
570
|
-
"""Load + parse state.json, failing CLOSED. A git-conflicted file dies with a merge-specific
|
|
571
|
-
'state_conflicted'; any other corrupt/unreadable file dies with a clean 'state_invalid'
|
|
572
|
-
message (never a raw traceback), so every command that loads state degrades gracefully
|
|
573
|
-
(design-for-failure). The parsed state is forward-migrated to the multi-active schema."""
|
|
574
|
-
try:
|
|
575
|
-
return _migrate_state(json.loads(_state_text_or_die(root)))
|
|
576
|
-
except (json.JSONDecodeError, OSError) as e:
|
|
577
|
-
_die(f"state_invalid: {root / STATE_FILE} is corrupt or unreadable "
|
|
578
|
-
f"({e.__class__.__name__}) — restore it from git or a backup")
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
def _load_state_for_json() -> tuple[Path, dict]:
|
|
582
|
-
"""Fail-closed state load for `--json` paths: a missing project or unparseable
|
|
583
|
-
state.json -> `no_state` on stderr + exit 1, with EMPTY stdout (never a partial
|
|
584
|
-
JSON object a harness might parse). Built from State only — reads no docs/ chapter.
|
|
585
|
-
The parsed state is forward-migrated to the multi-active schema before it is returned."""
|
|
586
|
-
root = find_root()
|
|
587
|
-
if root is None:
|
|
588
|
-
_die("no_state")
|
|
589
|
-
try:
|
|
590
|
-
return root, _migrate_state(json.loads(_state_text_or_die(root)))
|
|
591
|
-
except (json.JSONDecodeError, OSError):
|
|
592
|
-
_die("no_state")
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
def _phase_owner(phase: str) -> str:
|
|
596
|
-
"""Map a phase to its owner (human|seam|ai); `unmapped_phase` if absent (fail closed)."""
|
|
597
|
-
owner = PHASE_OWNER.get(phase)
|
|
598
|
-
if owner is None:
|
|
599
|
-
_die("unmapped_phase")
|
|
600
|
-
return owner
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
def save_state(root: Path, state: dict) -> None:
|
|
604
|
-
state["updated"] = _now()
|
|
605
|
-
_atomic_write(root / STATE_FILE, json.dumps(state, indent=2) + "\n")
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
def _setup_locked(state: dict) -> bool:
|
|
609
|
-
"""True when the project's setup is locked — i.e. the build-boundary gate is OPEN.
|
|
610
|
-
|
|
611
|
-
A state with NO "setup" key is GRANDFATHERED-locked: plain `init` and every legacy
|
|
612
|
-
project are never gated (the lock is opt-in via `init --await-lock`). The gate is
|
|
613
|
-
therefore active in exactly one case: "setup" present AND locked is False."""
|
|
614
|
-
return ("setup" not in state) or (state["setup"].get("locked") is True)
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
def _milestone_confirmed(state: dict, mslug: str) -> bool:
|
|
618
|
-
"""True when milestone `mslug` is confirmed — i.e. the new-task gate is OPEN.
|
|
619
|
-
|
|
620
|
-
Mirrors `_setup_locked` one level down. A milestone record with NO "confirmed" key is
|
|
621
|
-
GRANDFATHERED-confirmed: every milestone created WITHOUT `--await-confirm` (and every
|
|
622
|
-
pre-existing one) is never gated. Opt-in: `new-milestone --await-confirm` seeds confirmed:false,
|
|
623
|
-
so the gate is active in exactly one case: the record is present AND confirmed is False. An
|
|
624
|
-
unknown milestone is treated as confirmed here (existence is cmd_new_task's separate check)."""
|
|
625
|
-
m = (state.get("milestones") or {}).get(mslug)
|
|
626
|
-
if not isinstance(m, dict) or "confirmed" not in m:
|
|
627
|
-
return True
|
|
628
|
-
return m["confirmed"] is True
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
def _section_unfilled(md_text: str, header: str) -> bool:
|
|
632
|
-
"""True iff the `header` section is PRESENT but UNFILLED — empty (no real bullet) or
|
|
633
|
-
still a `<…>` template placeholder. ABSENT section -> False (grandfathered legacy);
|
|
634
|
-
a filled section (>=1 real bullet, no `<…>`) -> False. Pure predicate — the shared
|
|
635
|
-
placeholder test the fill gates use (contract-fill at confirm; build-expectations at build)."""
|
|
636
|
-
body, in_sec, present = [], False, False
|
|
637
|
-
for ln in md_text.splitlines():
|
|
638
|
-
if ln.startswith(header):
|
|
639
|
-
in_sec, present = True, True
|
|
640
|
-
continue
|
|
641
|
-
if in_sec:
|
|
642
|
-
if ln.startswith("#"): # ANY next header (## or ###) ends our section
|
|
643
|
-
break
|
|
644
|
-
if ln.lstrip().startswith(">"): # skip blockquote GUIDANCE — it is not content
|
|
645
|
-
continue
|
|
646
|
-
body.append(ln)
|
|
647
|
-
if not present:
|
|
648
|
-
return False # absent -> grandfather
|
|
649
|
-
text = "\n".join(body).strip()
|
|
650
|
-
if not text:
|
|
651
|
-
return True # present but empty
|
|
652
|
-
return bool(re.search(r"<[^>\n]+>", text)) # a <…> placeholder remains
|
|
653
193
|
|
|
654
194
|
|
|
655
195
|
def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None:
|
|
@@ -668,7 +208,7 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
|
|
|
668
208
|
return # unreadable -> no-op (never blocks the gate)
|
|
669
209
|
if "### GATE RECORD" not in text:
|
|
670
210
|
return # nothing to mirror into
|
|
671
|
-
actor = _actor_stamp(state)
|
|
211
|
+
actor = identity._actor_stamp(state)
|
|
672
212
|
today = date.today().isoformat()
|
|
673
213
|
# each rule matches ONLY a line still carrying a `<…>` placeholder -> grandfather a resolved line.
|
|
674
214
|
rules = [
|
|
@@ -695,251 +235,11 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
|
|
|
695
235
|
_atomic_write(f, new)
|
|
696
236
|
|
|
697
237
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
# --- guideline injection (dynamic-by-reference; designed for failure) --------
|
|
704
|
-
#
|
|
705
|
-
# Inject one stable, marker-delimited ADD block into the project root's AGENTS.md
|
|
706
|
-
# and CLAUDE.md. The block is DYNAMIC-BY-REFERENCE: it tells the agent to run
|
|
707
|
-
# `add.py status` and read PROJECT.md — it never embeds live state (slug, phase,
|
|
708
|
-
# gate). Auto-updated context files measurably hurt (ETH-Zurich: ~3% lower success,
|
|
709
|
-
# 20%+ more cost), so the stable pointer is the whole point.
|
|
710
|
-
|
|
711
|
-
def _guideline_block() -> str:
|
|
712
|
-
"""The canonical ADD block (markers + body, no trailing newline).
|
|
713
|
-
|
|
714
|
-
Agent-agnostic by design (v14 agent-portability): the routing steps depend
|
|
715
|
-
only on the CLI and plain files, so any agent — Claude, Cursor, Copilot,
|
|
716
|
-
Codex — can follow them. Claude additionally gets the `add` skill."""
|
|
717
|
-
return (
|
|
718
|
-
f"{_GUIDE_BEGIN}\n"
|
|
719
|
-
"## ADD — how to work in this repo\n"
|
|
720
|
-
"\n"
|
|
721
|
-
"This project uses **ADD (AI-Driven Development)**: you, the AI, drive the build;\n"
|
|
722
|
-
"the human owns direction and verification. The loop below works for any agent —\n"
|
|
723
|
-
"Claude, Cursor, Copilot, Codex — through the CLI alone. Before you change code:\n"
|
|
724
|
-
"\n"
|
|
725
|
-
"1. Run `python3 .add/tooling/add.py status` — where the project is and what's\n"
|
|
726
|
-
" next (the resume point; read it first every session).\n"
|
|
727
|
-
"2. Read `.add/PROJECT.md` — the foundation (domain · spec · UI/UX) every task\n"
|
|
728
|
-
" builds on.\n"
|
|
729
|
-
"3. Run `python3 .add/tooling/add.py guide` — it names the phase and the exact\n"
|
|
730
|
-
" phase-guide file to read (the `guide :` line). Work ONLY that phase — each\n"
|
|
731
|
-
" guide ends with its exit gate and the command to move on.\n"
|
|
732
|
-
"\n"
|
|
733
|
-
"The flow: INTAKE sizes a request into a milestone; each task runs the\n"
|
|
734
|
-
"**specification bundle** — Spec+Scenarios+Contract+Tests as one bundle,\n"
|
|
735
|
-
"ONE human approval at the frozen contract — then a self-driving build→verify\n"
|
|
736
|
-
"run. Non-negotiable for every agent:\n"
|
|
737
|
-
"Never weaken a test or edit a frozen contract to make a build pass; a security\n"
|
|
738
|
-
"finding is always HARD-STOP — never auto-passed.\n"
|
|
739
|
-
"\n"
|
|
740
|
-
"On Claude Code the `add` skill drives this loop automatically; other agents\n"
|
|
741
|
-
"follow the three steps. The book is in `.add/docs/`. This block is generated\n"
|
|
742
|
-
"by `add.py sync-guidelines`; edit outside the markers, not inside.\n"
|
|
743
|
-
f"{_GUIDE_END}"
|
|
744
|
-
)
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
def _inject_block(path: Path) -> str:
|
|
748
|
-
"""Write the ADD block into `path`. Returns created|updated|unchanged.
|
|
749
|
-
|
|
750
|
-
- unchanged: on-disk block already matches -> no write, no .bak (idempotent).
|
|
751
|
-
- updated: existing content changes -> back up the original to <path>.bak first.
|
|
752
|
-
- created: file did not exist -> write the block, no .bak.
|
|
753
|
-
User content outside the markers is always preserved.
|
|
754
|
-
"""
|
|
755
|
-
block = _guideline_block()
|
|
756
|
-
if path.exists():
|
|
757
|
-
current = path.read_text(encoding="utf-8")
|
|
758
|
-
begin = current.find(_GUIDE_BEGIN)
|
|
759
|
-
if begin != -1:
|
|
760
|
-
end = current.find(_GUIDE_END, begin)
|
|
761
|
-
if end != -1: # replace only the marked region
|
|
762
|
-
end += len(_GUIDE_END)
|
|
763
|
-
new = current[:begin] + block + current[end:]
|
|
764
|
-
else: # begin without end: corrupt — append fresh
|
|
765
|
-
print(f"add: warning: {path.name}: found an ADD:BEGIN with no ADD:END "
|
|
766
|
-
"— appending a fresh block; review the result", file=sys.stderr)
|
|
767
|
-
new = current.rstrip("\n") + "\n\n" + block + "\n"
|
|
768
|
-
else: # no block yet — append, keep user content
|
|
769
|
-
new = current.rstrip("\n") + "\n\n" + block + "\n"
|
|
770
|
-
if new == current:
|
|
771
|
-
return "unchanged"
|
|
772
|
-
_atomic_write(Path(str(path) + ".bak"), current) # rollback path before mutate
|
|
773
|
-
_atomic_write(path, new)
|
|
774
|
-
return "updated"
|
|
775
|
-
_atomic_write(path, block + "\n")
|
|
776
|
-
return "created"
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
def _rule_file_mode(project_root: Path, flag: bool = False) -> bool:
|
|
780
|
-
"""True when the ADD block should live in .claude/rules/add-workflows.md (referenced
|
|
781
|
-
from CLAUDE.md) instead of inline. Re-derived from disk EACH phase — no persisted
|
|
782
|
-
state — so an explicit `--rule-file` at install carries into init via the rule file it
|
|
783
|
-
leaves behind. Three triggers: the explicit flag, a ccsk project (.ccsk/ sibling to
|
|
784
|
-
.claude/), or a rule file already written by a prior run. Pure + fail-soft."""
|
|
785
|
-
if flag:
|
|
786
|
-
return True
|
|
787
|
-
try:
|
|
788
|
-
if (project_root / ".ccsk").is_dir():
|
|
789
|
-
return True
|
|
790
|
-
if (project_root / RULES_FILE_REL).exists():
|
|
791
|
-
return True
|
|
792
|
-
except OSError:
|
|
793
|
-
pass
|
|
794
|
-
return False
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
def _strip_inline_block(text: str) -> str:
|
|
798
|
-
"""Remove an inline ADD:BEGIN..ADD:END region (migration to rule-file mode), collapsing
|
|
799
|
-
the blank-line gap it leaves behind. An unterminated BEGIN (no END) is left as-is rather
|
|
800
|
-
than eating the rest of the file (design-for-failure)."""
|
|
801
|
-
begin = text.find(_GUIDE_BEGIN)
|
|
802
|
-
if begin == -1:
|
|
803
|
-
return text
|
|
804
|
-
end = text.find(_GUIDE_END, begin)
|
|
805
|
-
if end == -1:
|
|
806
|
-
return text
|
|
807
|
-
end += len(_GUIDE_END)
|
|
808
|
-
head = text[:begin].rstrip("\n")
|
|
809
|
-
tail = text[end:].lstrip("\n")
|
|
810
|
-
if head and tail:
|
|
811
|
-
return head + "\n\n" + tail
|
|
812
|
-
return head or tail
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
def _insert_rule_reference(text: str) -> str:
|
|
816
|
-
"""Insert the ADD rule-file bullet under an existing Workflows/Rules heading, or append a
|
|
817
|
-
fresh '## Workflows' section when none is found. Caller guarantees the bullet is absent."""
|
|
818
|
-
lines = text.split("\n")
|
|
819
|
-
heading_idx = -1
|
|
820
|
-
for i, line in enumerate(lines):
|
|
821
|
-
m = re.match(r"^(#{1,6})\s+(.*?)\s*$", line)
|
|
822
|
-
if m and any(m.group(2).strip().lower() == h.lower() for h in WORKFLOW_HEADINGS):
|
|
823
|
-
heading_idx = i
|
|
824
|
-
break
|
|
825
|
-
if heading_idx == -1: # no section — append a fresh one
|
|
826
|
-
body = text.rstrip("\n")
|
|
827
|
-
sep = "\n\n" if body else ""
|
|
828
|
-
return f"{body}{sep}## Workflows\n\n{_RULE_REF_LINE}\n"
|
|
829
|
-
level = len(re.match(r"^(#{1,6})", lines[heading_idx]).group(1))
|
|
830
|
-
end = len(lines) # section ends at next same/higher heading or EOF
|
|
831
|
-
for j in range(heading_idx + 1, len(lines)):
|
|
832
|
-
m = re.match(r"^(#{1,6})\s+", lines[j])
|
|
833
|
-
if m and len(m.group(1)) <= level:
|
|
834
|
-
end = j
|
|
835
|
-
break
|
|
836
|
-
insert_at = heading_idx + 1 # after the last non-blank line in the section
|
|
837
|
-
for j in range(heading_idx + 1, end):
|
|
838
|
-
if lines[j].strip():
|
|
839
|
-
insert_at = j + 1
|
|
840
|
-
lines.insert(insert_at, _RULE_REF_LINE)
|
|
841
|
-
return "\n".join(lines)
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
def _ensure_claude_reference(claude_md: Path) -> str:
|
|
845
|
-
"""Make CLAUDE.md reference the ADD rule file under a Workflows/Rules heading, migrating
|
|
846
|
-
any prior inline ADD block out. Returns created|updated|unchanged.
|
|
847
|
-
|
|
848
|
-
- Strips a prior inline ADD:BEGIN..END block (rule-file mode supersedes inline).
|
|
849
|
-
- If a reference to add-workflows.md already exists -> no bullet change.
|
|
850
|
-
- Else inserts the bullet into the first matching section, or appends '## Workflows'.
|
|
851
|
-
.bak on change; idempotent. User content outside the touched region is preserved.
|
|
852
|
-
"""
|
|
853
|
-
existed = claude_md.exists()
|
|
854
|
-
current = claude_md.read_text(encoding="utf-8") if existed else ""
|
|
855
|
-
new = _strip_inline_block(current)
|
|
856
|
-
if "add-workflows.md" not in new:
|
|
857
|
-
new = _insert_rule_reference(new)
|
|
858
|
-
if not new.endswith("\n"):
|
|
859
|
-
new += "\n"
|
|
860
|
-
if new == current:
|
|
861
|
-
return "unchanged"
|
|
862
|
-
if existed:
|
|
863
|
-
_atomic_write(Path(str(claude_md) + ".bak"), current) # rollback path before mutate
|
|
864
|
-
_atomic_write(claude_md, new)
|
|
865
|
-
return "updated" if existed else "created"
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
def _inject_guidelines(project_root: Path, rule_file: bool = False) -> list[tuple[str, str]]:
|
|
869
|
-
"""Inject the block into each guideline file under `project_root`.
|
|
870
|
-
|
|
871
|
-
Symlink-dedup: targets resolving (os.path.realpath) to the same inode are
|
|
872
|
-
written once, against the REAL file (never replacing the symlink with a
|
|
873
|
-
regular file). Per-target OSError is isolated (warn+skip) so one unwritable
|
|
874
|
-
file never aborts the run or `init`.
|
|
875
|
-
|
|
876
|
-
Rule-file mode (ccsk projects / `--rule-file`): CLAUDE.md's full block is relocated
|
|
877
|
-
to .claude/rules/add-workflows.md and CLAUDE.md keeps only a reference bullet. This is
|
|
878
|
-
CLAUDE-only — AGENTS.md (and any other guideline file) keeps the inline block.
|
|
879
|
-
"""
|
|
880
|
-
results: list[tuple[str, str]] = []
|
|
881
|
-
seen: set[str] = set()
|
|
882
|
-
mode = _rule_file_mode(project_root, rule_file)
|
|
883
|
-
for name in GUIDELINE_FILES:
|
|
884
|
-
if name == "CLAUDE.md" and mode:
|
|
885
|
-
rules_path = project_root / RULES_FILE_REL
|
|
886
|
-
real = os.path.realpath(rules_path)
|
|
887
|
-
if real not in seen:
|
|
888
|
-
seen.add(real)
|
|
889
|
-
try:
|
|
890
|
-
action = _inject_block(rules_path)
|
|
891
|
-
except (OSError, UnicodeDecodeError) as exc:
|
|
892
|
-
print(f"add: warning: could not sync {RULES_FILE_REL} — {exc}; skipped",
|
|
893
|
-
file=sys.stderr)
|
|
894
|
-
action = "skipped"
|
|
895
|
-
results.append((str(RULES_FILE_REL), action))
|
|
896
|
-
try:
|
|
897
|
-
ref_action = _ensure_claude_reference(project_root / "CLAUDE.md")
|
|
898
|
-
except (OSError, UnicodeDecodeError) as exc:
|
|
899
|
-
print(f"add: warning: could not sync CLAUDE.md — {exc}; skipped",
|
|
900
|
-
file=sys.stderr)
|
|
901
|
-
ref_action = "skipped"
|
|
902
|
-
results.append(("CLAUDE.md", ref_action))
|
|
903
|
-
continue
|
|
904
|
-
target = project_root / name
|
|
905
|
-
real = os.path.realpath(target)
|
|
906
|
-
if real in seen:
|
|
907
|
-
continue
|
|
908
|
-
seen.add(real)
|
|
909
|
-
write_target = Path(real) if target.is_symlink() else target
|
|
910
|
-
try:
|
|
911
|
-
action = _inject_block(write_target)
|
|
912
|
-
except (OSError, UnicodeDecodeError) as exc:
|
|
913
|
-
# design for failure: an unwritable target OR a non-UTF-8 existing file
|
|
914
|
-
# (e.g. a UTF-16 CLAUDE.md from a Windows editor) must not crash init or
|
|
915
|
-
# abort the other target — warn and skip this one.
|
|
916
|
-
print(f"add: warning: could not sync {name} — {exc}; skipped",
|
|
917
|
-
file=sys.stderr)
|
|
918
|
-
action = "skipped"
|
|
919
|
-
results.append((name, action))
|
|
920
|
-
return results
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
# --- commands ----------------------------------------------------------------
|
|
924
|
-
|
|
925
|
-
_INIT_EXCLUDE = {
|
|
926
|
-
".add", "AGENTS.md", "CLAUDE.md", ".git",
|
|
927
|
-
".gitignore", ".gitattributes", ".github", ".editorconfig", # VCS/CI/editor scaffolding — no domain signal
|
|
928
|
-
"LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", # legal boilerplate — no domain signal
|
|
929
|
-
} # README/docs/source are NOT excluded: they carry domain content adopt.md maps -> brownfield
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
def _is_brownfield(base: Path) -> bool:
|
|
933
|
-
"""True when `base` already holds project content beyond the tool's own scaffolding.
|
|
934
|
-
|
|
935
|
-
Judgment-free: a mechanical fact (does the dir hold a non-excluded entry?), so the
|
|
936
|
-
autonomous-onboarding flow knows to map existing code into the living documentation. INTERPRETING
|
|
937
|
-
that code stays with the AI (skill/add/adopt.md) — the engine only detects + signals."""
|
|
938
|
-
if not base.is_dir():
|
|
939
|
-
return False
|
|
940
|
-
return any(child.name not in _INIT_EXCLUDE for child in base.iterdir())
|
|
941
|
-
|
|
942
|
-
|
|
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
|
+
)
|
|
943
243
|
def cmd_init(args: argparse.Namespace) -> None:
|
|
944
244
|
base = Path(args.dir).resolve()
|
|
945
245
|
root = base / ROOT_DIRNAME
|
|
@@ -1003,6 +303,10 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
1003
303
|
else:
|
|
1004
304
|
print("next: open Claude Code, run `/add`, and say what you want to build —")
|
|
1005
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).")
|
|
1006
310
|
|
|
1007
311
|
|
|
1008
312
|
def cmd_sync_guidelines(args: argparse.Namespace) -> None:
|
|
@@ -1088,6 +392,11 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
1088
392
|
f"autonomy token; new task seeded fail-safe '{autonomy}' "
|
|
1089
393
|
"(fix it with `add.py autonomy set <level> --project`)", file=sys.stderr)
|
|
1090
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
|
|
1091
400
|
state["tasks"][slug] = {
|
|
1092
401
|
"title": title,
|
|
1093
402
|
"phase": "ground",
|
|
@@ -1097,6 +406,8 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
1097
406
|
"created": _now(),
|
|
1098
407
|
"updated": _now(),
|
|
1099
408
|
}
|
|
409
|
+
if prior_heal is not None:
|
|
410
|
+
state["tasks"][slug]["heal"] = prior_heal # monotonic — survives the --force re-create
|
|
1100
411
|
if from_delta:
|
|
1101
412
|
state["tasks"][slug]["from_delta"] = from_delta # lineage: seeded from <prior>
|
|
1102
413
|
if fast:
|
|
@@ -1107,6 +418,11 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
1107
418
|
if milestone:
|
|
1108
419
|
print(f"linked to milestone '{milestone}'" +
|
|
1109
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")
|
|
1110
426
|
else:
|
|
1111
427
|
# warn-never-block: the task is created (escape hatch), but nudge back toward the
|
|
1112
428
|
# intake -> milestone flow. Speaks of STRUCTURE (not attached), never the act.
|
|
@@ -1151,16 +467,6 @@ def _parse_deps(raw: str | None) -> list[str]:
|
|
|
1151
467
|
return [d.strip() for d in raw.split(",") if d.strip()]
|
|
1152
468
|
|
|
1153
469
|
|
|
1154
|
-
def _task_done(t: dict) -> bool:
|
|
1155
|
-
# Matrix 3: a task is done when Verify reads PASS *or a signed RISK-ACCEPTED*.
|
|
1156
|
-
# Both completing gates advance phase to "done" (cmd_gate), and a waiver is
|
|
1157
|
-
# signed at gate time — so a verdict gate is enough here; we need not re-read
|
|
1158
|
-
# the waiver. HARD-STOP never reaches "done". A bare `phase done` (escape
|
|
1159
|
-
# hatch, gate still "none") deliberately does NOT count: completion needs a
|
|
1160
|
-
# recorded verdict, not just a phase marker.
|
|
1161
|
-
return t.get("phase") == "done" and t.get("gate") in ("PASS", "RISK-ACCEPTED")
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
470
|
def _archived_task_slugs(state: dict) -> set[str]:
|
|
1165
471
|
"""Slugs of tasks that left active state via archive — all were PASS-done at
|
|
1166
472
|
archive time, so a dep on one of them counts as satisfied (not dangling).
|
|
@@ -1184,16 +490,94 @@ def _resolve_task(state: dict, slug: str | None) -> str:
|
|
|
1184
490
|
return slug
|
|
1185
491
|
|
|
1186
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
|
+
|
|
1187
563
|
def cmd_phase(args: argparse.Namespace) -> None:
|
|
1188
564
|
root = _require_root()
|
|
1189
565
|
state = load_state(root)
|
|
1190
566
|
slug = _resolve_task(state, args.slug)
|
|
1191
567
|
if args.phase not in PHASES:
|
|
1192
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)
|
|
1193
577
|
state["tasks"][slug]["phase"] = args.phase
|
|
1194
578
|
state["tasks"][slug]["updated"] = _now()
|
|
1195
|
-
|
|
1196
|
-
|
|
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
|
|
1197
581
|
print(f"task '{slug}' phase -> {args.phase}")
|
|
1198
582
|
print(_next_footer(root, state))
|
|
1199
583
|
|
|
@@ -1230,63 +614,10 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
1230
614
|
# freezes — the unmarked predecessors are never retro-redded). REFUSE writes
|
|
1231
615
|
# nothing (fail-closed); below the build boundary the flag is never checked.
|
|
1232
616
|
if nxt == "build":
|
|
1233
|
-
# the
|
|
1234
|
-
#
|
|
1235
|
-
#
|
|
1236
|
-
|
|
1237
|
-
# snapshots below, writing nothing; the task stays at `tests`.
|
|
1238
|
-
_ms = state["tasks"][slug].get("milestone")
|
|
1239
|
-
_optin = bool(_ms) and (state.get("milestones") or {}).get(_ms, {}).get("await_confirm") is True
|
|
1240
|
-
raw3 = _raw_phase_bodies(root, slug).get(3, "")
|
|
1241
|
-
# freeze-before-build gate (fast-lane): "collapse-never-skip" made REAL — the freeze is
|
|
1242
|
-
# engine-enforced for an opted-in milestone OR any --fast task (the fast arm, fast-new-task-flag:
|
|
1243
|
-
# a fast task is held to the floor under ANY milestone, so the lighter lane never drops the
|
|
1244
|
-
# trust seam). PRECEDES build-expectations: you freeze §3 before pre-declaring §6's outcomes.
|
|
1245
|
-
_freeze_gated = _optin or state["tasks"][slug].get("fast") is True
|
|
1246
|
-
if _freeze_gated and not _contract_frozen(raw3):
|
|
1247
|
-
_die("contract_not_frozen: freeze §3 before crossing into build — approve "
|
|
1248
|
-
f"the contract in {slug}'s TASK.md (Status: FROZEN @ vN)")
|
|
1249
|
-
# build-expectations gate (flow-enforcement): an opted-in task may not enter build until
|
|
1250
|
-
# its §6 `### Build expectations` are pre-declared — so verify checks the build is RIGHT,
|
|
1251
|
-
# not just green. Same opt-in switch as the contract-fill gate, one level out.
|
|
1252
|
-
if _optin:
|
|
1253
|
-
if _section_unfilled(_raw_phase_bodies(root, slug).get(6, ""), "### Build expectations"):
|
|
1254
|
-
_die("build_expectations_unfilled: fill the §6 '### Build expectations' block "
|
|
1255
|
-
f"of {slug}'s TASK.md before crossing into build")
|
|
1256
|
-
if _contract_frozen(raw3):
|
|
1257
|
-
if not _flag_well_formed(raw3):
|
|
1258
|
-
_die("unflagged_freeze: a frozen §3 must surface a well-formed "
|
|
1259
|
-
"'Least-sure flag surfaced at freeze:' unit (>=1 [part] tag "
|
|
1260
|
-
"+ substantive content; bare 'none' only as 'none material — "
|
|
1261
|
-
"biggest risk: X') before crossing into build")
|
|
1262
|
-
state["tasks"][slug]["flag_verified"] = True
|
|
1263
|
-
# tamper tripwire (verify-integrity): snapshot the red test files + the frozen
|
|
1264
|
-
# §3 md5s so the verify gate can prove the green was EARNED, not edited into
|
|
1265
|
-
# place. UNCONDITIONAL overwrite — a legit change-request that re-crosses
|
|
1266
|
-
# tests->build re-snapshots cleanly. Co-witnessed by flag_verified (above).
|
|
1267
|
-
state["tasks"][slug]["tripwire"] = _tripwire_snapshot(root, slug, raw3)
|
|
1268
|
-
# §5 scope gate (build-scope-lock): when the task declares its Scope, freeze
|
|
1269
|
-
# the project tree into a sidecar (payload) + a state.json anchor (md5 of the
|
|
1270
|
-
# sidecar bytes). Same UNCONDITIONAL-overwrite semantics as the tripwire.
|
|
1271
|
-
# UNDECLARED (no Scope line) takes no snapshot — grandfathered, never retro-red
|
|
1272
|
-
# — and CLEANS UP a previous declaration's leftovers (v3): a declared->
|
|
1273
|
-
# undeclared re-cross pops the stale anchor + unlinks the stale sidecar, so
|
|
1274
|
-
# "UNDECLARED is never refused" holds on every path.
|
|
1275
|
-
declared = _declared_scope(root, slug)
|
|
1276
|
-
side = root / "tasks" / slug / "scope-snapshot.json"
|
|
1277
|
-
if declared is not None:
|
|
1278
|
-
payload = json.dumps({"version": 1,
|
|
1279
|
-
"files": _scope_walk(root.parent.resolve())},
|
|
1280
|
-
sort_keys=True)
|
|
1281
|
-
side.write_text(payload, encoding="utf-8")
|
|
1282
|
-
state["tasks"][slug]["scope"] = {"declared": declared,
|
|
1283
|
-
"snapshot_md5": _md5_text(payload)}
|
|
1284
|
-
else:
|
|
1285
|
-
state["tasks"][slug].pop("scope", None)
|
|
1286
|
-
try:
|
|
1287
|
-
side.unlink()
|
|
1288
|
-
except OSError:
|
|
1289
|
-
pass
|
|
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)
|
|
1290
621
|
# cross-component contract artifact (cross-component-contract): the contract->tests crossing
|
|
1291
622
|
# is the producer's freeze-approval moment. A `produces:` task WRITES the immutable snapshot;
|
|
1292
623
|
# a `consumes:` task PINS the live hash — a missing/unreadable snapshot HARD-STOPS here (the
|
|
@@ -1327,8 +658,8 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
1327
658
|
state["tasks"][slug]["contract_pin"] = {"id": _cons, "hash": pinned}
|
|
1328
659
|
state["tasks"][slug]["phase"] = nxt
|
|
1329
660
|
state["tasks"][slug]["updated"] = _now()
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
|
1332
663
|
print(f"task '{slug}' phase {cur} -> {nxt}")
|
|
1333
664
|
if nxt == "observe":
|
|
1334
665
|
# OBSERVE is where this loop's lessons get captured (TASK.md §7) — suggest routing
|
|
@@ -1356,12 +687,10 @@ _RISK_HIGH_RE = re.compile(r"(?:^|·)[ \t]*risk:[ \t]*high\b", re.MULTILINE)
|
|
|
1356
687
|
|
|
1357
688
|
# the explicit 3-mode autonomy dial (task explicit-autonomy-dial): an ordered ladder
|
|
1358
689
|
# manual < conservative < auto, declared as a per-task `autonomy:` header token.
|
|
1359
|
-
_AUTONOMY_LEVELS = ("manual", "conservative", "auto")
|
|
1360
690
|
# anchored to a DECLARATION position — line-start `autonomy:` OR the inline slug-line form
|
|
1361
691
|
# `… · autonomy: conservative` (the `·`-preceded shape) — never a title/prose substring; the
|
|
1362
692
|
# value stops at space/`<`/`#`/`|` so an unfilled `<manual | … >` placeholder captures nothing
|
|
1363
693
|
# and reads as UNSET.
|
|
1364
|
-
_AUTONOMY_LINE_RE = re.compile(r"(?:^|·)[ \t]*autonomy:[ \t]*([^\s<#|]+)", re.MULTILINE)
|
|
1365
694
|
|
|
1366
695
|
# component-aware-add: a task binds to a registered component via a `component: <name>`
|
|
1367
696
|
# header token — the SAME anchored grammar as autonomy (line-start or the `·`-inline
|
|
@@ -1372,45 +701,12 @@ _PRODUCES_LINE_RE = re.compile(r"(?:^|·)[ \t]*produces:[ \t]*([^\s<#|]+)", re.M
|
|
|
1372
701
|
_CONSUMES_LINE_RE = re.compile(r"(?:^|·)[ \t]*consumes:[ \t]*([^\s<#|]+)", re.MULTILINE)
|
|
1373
702
|
|
|
1374
703
|
|
|
1375
|
-
def _autonomy_level(hdr: str):
|
|
1376
|
-
"""The declared autonomy rung from a TASK.md header region (HTML comments
|
|
1377
|
-
already stripped by _task_header). Returns a member of _AUTONOMY_LEVELS, or
|
|
1378
|
-
None when no `autonomy:` line is present (UNSET — an unfilled `<…>` placeholder,
|
|
1379
|
-
whose value the regex declines, counts as unset), or "?" when a REAL token outside
|
|
1380
|
-
the set was written (unknown). PURE."""
|
|
1381
|
-
m = _AUTONOMY_LINE_RE.search(hdr)
|
|
1382
|
-
if not m:
|
|
1383
|
-
return None
|
|
1384
|
-
tok = m.group(1).strip().lower()
|
|
1385
|
-
return tok if tok in _AUTONOMY_LEVELS else "?"
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
704
|
def _autonomy_lowered(hdr: str) -> bool:
|
|
1389
705
|
"""True iff the declared rung is high-risk-safe (manual or conservative). A
|
|
1390
706
|
high-risk scope must be lowered to one of these; `auto` and UNSET are not."""
|
|
1391
707
|
return _autonomy_level(hdr) in ("manual", "conservative")
|
|
1392
708
|
|
|
1393
709
|
|
|
1394
|
-
def _task_header(root: Path, slug: str) -> str:
|
|
1395
|
-
"""The TASK.md header region — where declared tokens (risk · autonomy)
|
|
1396
|
-
live — with HTML comments stripped. Missing file -> '' (no tokens)."""
|
|
1397
|
-
try:
|
|
1398
|
-
text = (root / "tasks" / slug / "TASK.md").read_text(encoding="utf-8")
|
|
1399
|
-
except OSError:
|
|
1400
|
-
return ""
|
|
1401
|
-
return re.sub(r"<!--.*?-->", "", text.split("\n## ", 1)[0], flags=re.S)
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
def _effective_autonomy(root: Path, state: dict, slug: str) -> str:
|
|
1405
|
-
"""The autonomy rung that governs `slug` right now: the task's own declared rung,
|
|
1406
|
-
falling back to the project default when the task line is UNSET (None) or an
|
|
1407
|
-
unrecognized token ("?") — the same fail-safe chain cmd_new_task seeds from
|
|
1408
|
-
(_project_autonomy: absent -> auto, garbled -> conservative). PURE. `state` is unused
|
|
1409
|
-
today; it is kept in the signature beside _driver_stop for symmetry."""
|
|
1410
|
-
lvl = _autonomy_level(_task_header(root, slug))
|
|
1411
|
-
return lvl if lvl in _AUTONOMY_LEVELS else _project_autonomy(root)
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
710
|
def _driver_stop(root: Path, state: dict, slug: str, phase: str) -> bool:
|
|
1415
711
|
"""True iff a HUMAN owns the next step for `phase` under the effective autonomy — the
|
|
1416
712
|
SINGLE source the footer marker and the guide TEXT marker both render (task
|
|
@@ -1469,6 +765,10 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
1469
765
|
# §5 scope gate (build-scope-lock): touched ⊆ declared, or a named refusal —
|
|
1470
766
|
# same placement discipline as the tripwire (before the waiver, never on HARD-STOP).
|
|
1471
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)
|
|
1472
772
|
# per-component verify (component-aware-add): a component-bound task with a declared
|
|
1473
773
|
# green_bar must CITE that bar in its §6 evidence before a completing outcome — the
|
|
1474
774
|
# engine never runs the suite, it checks the right bar was recorded. Unbound / no
|
|
@@ -1493,11 +793,12 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
1493
793
|
}
|
|
1494
794
|
if completing:
|
|
1495
795
|
state["tasks"][slug]["phase"] = "done"
|
|
1496
|
-
_sync_task_marker(root, slug, "done")
|
|
1497
796
|
state["tasks"][slug]["gate"] = args.outcome
|
|
1498
|
-
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)
|
|
1499
798
|
state["tasks"][slug]["updated"] = _now()
|
|
1500
|
-
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
|
|
1501
802
|
_stamp_gate_record(root, state, slug, args.outcome) # mirror the verdict into §6 (Finding C)
|
|
1502
803
|
print(f"task '{slug}' gate -> {args.outcome}")
|
|
1503
804
|
_gbar = _task_green_bar(root, slug) # per-component-verify: surface the bound bar
|
|
@@ -1582,6 +883,45 @@ def cmd_autonomy(args: argparse.Namespace) -> None:
|
|
|
1582
883
|
_print_autonomy(root, state, slug)
|
|
1583
884
|
|
|
1584
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
|
+
|
|
1585
925
|
def cmd_reopen(args: argparse.Namespace) -> None:
|
|
1586
926
|
"""Return an already-`done` task to an earlier phase with a never-silent record.
|
|
1587
927
|
|
|
@@ -1616,8 +956,8 @@ def cmd_reopen(args: argparse.Namespace) -> None:
|
|
|
1616
956
|
t["phase"] = target
|
|
1617
957
|
t["gate"] = "none"
|
|
1618
958
|
t["updated"] = now
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
|
1621
961
|
print(f"task '{slug}' reopened: done -> {target} (reason recorded); gate reset to none")
|
|
1622
962
|
print(_next_footer(root, state))
|
|
1623
963
|
|
|
@@ -1668,7 +1008,7 @@ def cmd_lock(args: argparse.Namespace) -> None:
|
|
|
1668
1008
|
when = _now()
|
|
1669
1009
|
# ONE atomic write — no partial lock state.
|
|
1670
1010
|
state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers,
|
|
1671
|
-
"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
|
|
1672
1012
|
save_state(root, state)
|
|
1673
1013
|
if getattr(args, "json", False):
|
|
1674
1014
|
print(json.dumps(
|
|
@@ -1695,7 +1035,7 @@ def cmd_whoami(args: argparse.Namespace) -> None:
|
|
|
1695
1035
|
_die("actor_name_blank")
|
|
1696
1036
|
state["actor_override"] = {"name": args.name, "email": args.email or None}
|
|
1697
1037
|
save_state(root, state)
|
|
1698
|
-
who = _whoami(state)
|
|
1038
|
+
who = identity._whoami(state)
|
|
1699
1039
|
if getattr(args, "json", False):
|
|
1700
1040
|
print(json.dumps(who, separators=(",", ":")))
|
|
1701
1041
|
return
|
|
@@ -1724,14 +1064,14 @@ def cmd_assign(args: argparse.Namespace) -> None:
|
|
|
1724
1064
|
_die("unknown_slug")
|
|
1725
1065
|
# parse + validate ALL flags BEFORE the first write — a blank name is rejected on the
|
|
1726
1066
|
# PARSED name (so "<>" or " <a@x.io>", whose name parses empty, is caught like " ").
|
|
1727
|
-
parsed_owner = _parse_actor_arg(args.owner) if args.owner is not None else None
|
|
1728
|
-
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
|
|
1729
1069
|
if parsed_owner is not None and not parsed_owner["name"].strip():
|
|
1730
1070
|
_die("owner_name_blank")
|
|
1731
1071
|
if parsed_assignee is not None and not parsed_assignee["name"].strip():
|
|
1732
1072
|
_die("assignee_name_blank")
|
|
1733
1073
|
if parsed_owner is None and parsed_assignee is None:
|
|
1734
|
-
who = _whoami(state)
|
|
1074
|
+
who = identity._whoami(state)
|
|
1735
1075
|
rec["owner"] = dict(who)
|
|
1736
1076
|
rec["assignee"] = dict(who)
|
|
1737
1077
|
else:
|
|
@@ -1765,15 +1105,6 @@ def cmd_unassign(args: argparse.Namespace) -> None:
|
|
|
1765
1105
|
print(f"unassigned {args.slug} ({', '.join(roles)})")
|
|
1766
1106
|
|
|
1767
1107
|
|
|
1768
|
-
def _has_production_roadmap(state: dict) -> bool:
|
|
1769
|
-
"""True iff ≥1 milestone in state has stage == "production" (STATUS-AGNOSTIC).
|
|
1770
|
-
The single source of the stage-graduation floor (v22 graduate-guide): the guard counts
|
|
1771
|
-
that a production-roadmap RECORD exists — it never judges whether those milestones are
|
|
1772
|
-
done/good/sufficient (gather-not-judge). An archived-out-of-state roadmap falls to --force."""
|
|
1773
|
-
return any(m.get("stage") == "production"
|
|
1774
|
-
for m in state.get("milestones", {}).values())
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
1108
|
def cmd_stage(args: argparse.Namespace) -> None:
|
|
1778
1109
|
root = _require_root()
|
|
1779
1110
|
state = load_state(root)
|
|
@@ -1846,7 +1177,7 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1846
1177
|
grad_ready, grad_met, grad_total = _graduation_ready(root, state)
|
|
1847
1178
|
print(json.dumps({
|
|
1848
1179
|
"project": state.get("project"), "stage": state.get("stage"),
|
|
1849
|
-
"actor": _whoami(state),
|
|
1180
|
+
"actor": identity._whoami(state),
|
|
1850
1181
|
"active_task": _active_task(state),
|
|
1851
1182
|
"active_milestones": list(state.get("active_milestones") or []),
|
|
1852
1183
|
"active_tasks": dict(state.get("active_tasks") or {}),
|
|
@@ -1871,7 +1202,7 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1871
1202
|
print(f"project autonomy: {_project_autonomy(root)} (default — new tasks inherit)")
|
|
1872
1203
|
# git-native actor (user-identity): who ADD sees you as this session — the identity every
|
|
1873
1204
|
# human-owned stamp records. Always present (the resolver is TOTAL). Read-only, no write.
|
|
1874
|
-
_who = _whoami(state)
|
|
1205
|
+
_who = identity._whoami(state)
|
|
1875
1206
|
_who_email = f" <{_who['email']}>" if _who.get("email") else ""
|
|
1876
1207
|
print(f"actor : {_who['name']}{_who_email} (source: {_who['source']})")
|
|
1877
1208
|
print(f"stage : {state.get('stage', '(unknown)')}")
|
|
@@ -1938,6 +1269,12 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1938
1269
|
_rel = _releasable(root, state)
|
|
1939
1270
|
if _rel:
|
|
1940
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")
|
|
1941
1278
|
|
|
1942
1279
|
# fast-lane marker (fast-new-task-flag): tag an ACTIVE fast task so the lane is visible at a
|
|
1943
1280
|
# glance. Presentation-only, existence-gated — a plain/absent active task is byte-unchanged.
|
|
@@ -2624,6 +1961,7 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2624
1961
|
milestones = state.get("milestones") if isinstance(state.get("milestones"), dict) else {}
|
|
2625
1962
|
archived_slugs = _archived_task_slugs(state) # archived deps still resolve
|
|
2626
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`
|
|
2627
1965
|
for slug, t in tasks.items():
|
|
2628
1966
|
task_md = root / "tasks" / slug / "TASK.md"
|
|
2629
1967
|
checks.append((task_md.exists(), f"task '{slug}' has TASK.md", "file missing"))
|
|
@@ -2635,6 +1973,10 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2635
1973
|
if ms is not None:
|
|
2636
1974
|
checks.append((ms in milestones, f"task '{slug}' milestone resolves",
|
|
2637
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)"))
|
|
2638
1980
|
else:
|
|
2639
1981
|
# warn-never-block: a task outside a milestone is a structural nudge back toward
|
|
2640
1982
|
# the intake flow — NOT a failure. Names structure, never the act of intake.
|
|
@@ -2824,10 +2166,15 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2824
2166
|
passed = sum(1 for ok, _, _ in checks if ok)
|
|
2825
2167
|
failed = len(checks) - passed
|
|
2826
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.
|
|
2827
2171
|
print(json.dumps({"passed": passed, "failed": failed,
|
|
2828
2172
|
"warned": len(warnings),
|
|
2829
2173
|
"warnings": [{"name": name, "reason": reason}
|
|
2830
2174
|
for name, reason in warnings],
|
|
2175
|
+
"informed": len(infos),
|
|
2176
|
+
"infos": [{"name": name, "reason": reason}
|
|
2177
|
+
for name, reason in infos],
|
|
2831
2178
|
"checks": [{"ok": ok, "name": desc,
|
|
2832
2179
|
"reason": reason if not ok else ""}
|
|
2833
2180
|
for ok, desc, reason in checks]}))
|
|
@@ -2836,6 +2183,8 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2836
2183
|
print(f"PASS {desc}" if ok else f"FAIL {desc}: {reason}")
|
|
2837
2184
|
for name, reason in warnings:
|
|
2838
2185
|
print(f"WARN {name} {reason}")
|
|
2186
|
+
for name, reason in infos:
|
|
2187
|
+
print(f"INFO {name} {reason}")
|
|
2839
2188
|
summary = f"check: {passed} passed, {failed} failed"
|
|
2840
2189
|
if warnings:
|
|
2841
2190
|
summary += f" ({len(warnings)} warnings)" # frozen §3: summary gains "(N warnings)"
|
|
@@ -2917,7 +2266,7 @@ def cmd_mine(args: argparse.Namespace) -> None:
|
|
|
2917
2266
|
if root is None:
|
|
2918
2267
|
_die("no_project")
|
|
2919
2268
|
state = load_state(root)
|
|
2920
|
-
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)
|
|
2921
2270
|
rows = _my_work(state, me)
|
|
2922
2271
|
if getattr(args, "json", False):
|
|
2923
2272
|
print(json.dumps({"actor": me, "tasks": rows}))
|
|
@@ -3136,7 +2485,7 @@ def cmd_milestone_confirm(args: argparse.Namespace) -> None:
|
|
|
3136
2485
|
m["confirmed"] = True
|
|
3137
2486
|
m["confirmed_at"] = _now()
|
|
3138
2487
|
m["confirmed_by"] = who
|
|
3139
|
-
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
|
|
3140
2489
|
m["updated"] = _now()
|
|
3141
2490
|
save_state(root, state)
|
|
3142
2491
|
print(f"confirmed milestone '{slug}' (by {who}) — new-task is now open for it.")
|
|
@@ -3389,7 +2738,7 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
|
3389
2738
|
# Stamp WHO closed it BEFORE rendering the retro, so the persisted exit report records
|
|
3390
2739
|
# the closer (identity-in-status: the retro IS the report `report <ms>` re-renders, so both
|
|
3391
2740
|
# must reflect the same final state). In-memory only here — save_state below commits it.
|
|
3392
|
-
state["milestones"][slug]["done_actor"] = _actor_stamp(state)
|
|
2741
|
+
state["milestones"][slug]["done_actor"] = identity._actor_stamp(state)
|
|
3393
2742
|
# Fail-closed: render+persist the exit report (RETRO.md) BEFORE committing the
|
|
3394
2743
|
# status flip, so a write failure rolls back naturally (status never commits ->
|
|
3395
2744
|
# no done-without-retro state). The retro step is read-only on state.json.
|
|
@@ -3692,165 +3041,14 @@ def _sync_task_marker(root: Path, slug: str, phase: str) -> None:
|
|
|
3692
3041
|
# state.json; prose (observe delta, deltas) is parsed from each TASK.md and
|
|
3693
3042
|
# fails CLOSED to `(unknown)` rather than omitting silently.
|
|
3694
3043
|
|
|
3695
|
-
_DEFAULT_WIDTH = 72 # fixed width for the persisted/canonical render (RETRO.md)
|
|
3696
3044
|
# Two glyph tiers. Alignment is correct only with ASCII in column-positioned
|
|
3697
3045
|
# cells (every ASCII char is 1 display cell); Unicode glyphs sit at line-END
|
|
3698
3046
|
# (the PROGRESS track) or in non-aligned rows, where width can't break columns.
|
|
3699
3047
|
_UNICODE = {"reached": "●", "current": "◉", "pending": "○", "h": "═", "rule": "─", "bullet": "•"}
|
|
3700
3048
|
_ASCII = {"reached": "#", "current": ">", "pending": ".", "h": "=", "rule": "-", "bullet": "*"}
|
|
3701
3049
|
_GATE_SHORT = {"PASS": "PASS", "RISK-ACCEPTED": "RISK", "HARD-STOP": "STOP", "none": "—"}
|
|
3702
|
-
_ANSI = {"green": "\x1b[32m", "yellow": "\x1b[33m", "red": "\x1b[31m",
|
|
3703
|
-
"dim": "\x1b[2m", "reset": "\x1b[0m"}
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
def _bar(num: int, den: int, cells: int, g: dict) -> str:
|
|
3707
|
-
"""A progress bar; 0/0 -> all-empty (no divide-by-zero)."""
|
|
3708
|
-
filled = 0 if den <= 0 else round(num / den * cells)
|
|
3709
|
-
filled = max(0, min(cells, filled))
|
|
3710
|
-
return g["reached"] * filled + g["pending"] * (cells - filled)
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
def _phase_track(phase: str, g: dict) -> str:
|
|
3714
|
-
"""Compact 9-cell pipeline (no labels — a single legend explains it):
|
|
3715
|
-
reached · current · pending. A done task -> all reached."""
|
|
3716
|
-
try:
|
|
3717
|
-
ci = PHASES.index(phase)
|
|
3718
|
-
except ValueError:
|
|
3719
|
-
ci = 0
|
|
3720
|
-
cells = []
|
|
3721
|
-
for i in range(len(PHASES)):
|
|
3722
|
-
if phase == "done" or i < ci:
|
|
3723
|
-
cells.append(g["reached"])
|
|
3724
|
-
elif i == ci:
|
|
3725
|
-
cells.append(g["current"])
|
|
3726
|
-
else:
|
|
3727
|
-
cells.append(g["pending"])
|
|
3728
|
-
return "".join(cells)
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
def _use_ascii() -> bool:
|
|
3732
|
-
"""ASCII tier when the terminal can't render Unicode (non-UTF-8 / dumb)."""
|
|
3733
|
-
enc = (getattr(sys.stdout, "encoding", "") or "").lower()
|
|
3734
|
-
return ("utf" not in enc) or (os.environ.get("TERM") == "dumb")
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
def _color_enabled() -> bool:
|
|
3738
|
-
"""Color only on an interactive tty, honoring NO_COLOR and TERM."""
|
|
3739
|
-
return (sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
3740
|
-
and os.environ.get("TERM", "") not in ("dumb", ""))
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
def _term_width() -> int:
|
|
3744
|
-
try:
|
|
3745
|
-
import shutil
|
|
3746
|
-
return min(max(shutil.get_terminal_size().columns, 64), 100)
|
|
3747
|
-
except Exception:
|
|
3748
|
-
return _DEFAULT_WIDTH
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
def _colorize(s: str) -> str:
|
|
3752
|
-
"""Apply ANSI to status tokens — redundant to the text, never the sole signal.
|
|
3753
|
-
Applied ONLY to tty stdout; the persisted RETRO.md string stays plain."""
|
|
3754
|
-
c = _ANSI
|
|
3755
|
-
s = re.sub(r"\bDONE\b", c["green"] + "DONE" + c["reset"], s)
|
|
3756
|
-
s = re.sub(r"\bBLOCKED\b", c["red"] + "BLOCKED" + c["reset"], s)
|
|
3757
|
-
s = re.sub(r"\bPASS\b", c["green"] + "PASS" + c["reset"], s)
|
|
3758
|
-
s = re.sub(r"\bRISK\b", c["yellow"] + "RISK" + c["reset"], s)
|
|
3759
|
-
s = re.sub(r"\bSTOP\b", c["red"] + "STOP" + c["reset"], s)
|
|
3760
|
-
return s
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
def _project_goal(root: Path) -> str:
|
|
3764
|
-
"""The project GOAL — the value of the first `goal:` line in PROJECT.md, else
|
|
3765
|
-
GOAL_UNSET. Read-only and fail-closed: a missing/unreadable foundation or a
|
|
3766
|
-
blank value degrades to the sentinel (orientation never raises). Mirrors how
|
|
3767
|
-
_milestone_doc reads the milestone goal — the foundation is the single source."""
|
|
3768
|
-
f = root / "PROJECT.md"
|
|
3769
|
-
try:
|
|
3770
|
-
for line in f.read_text(encoding="utf-8").splitlines():
|
|
3771
|
-
if line.startswith("goal:"):
|
|
3772
|
-
return line.split(":", 1)[1].strip() or GOAL_UNSET
|
|
3773
|
-
except OSError:
|
|
3774
|
-
pass
|
|
3775
|
-
return GOAL_UNSET
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
def _project_autonomy_token(root: Path):
|
|
3779
|
-
"""The RAW autonomy declaration in PROJECT.md — a recognized rung, None when no
|
|
3780
|
-
declaration line is present, or "?" for a real-but-unrecognized token. Uses the
|
|
3781
|
-
anchored _autonomy_level (a title/prose substring is never a declaration) with
|
|
3782
|
-
HTML comments stripped. Unreadable foundation -> None. Read-only and PURE."""
|
|
3783
|
-
try:
|
|
3784
|
-
text = (root / "PROJECT.md").read_text(encoding="utf-8")
|
|
3785
|
-
except OSError:
|
|
3786
|
-
return None
|
|
3787
|
-
return _autonomy_level(re.sub(r"<!--.*?-->", "", text, flags=re.S))
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
def _project_autonomy(root: Path) -> str:
|
|
3791
|
-
"""The autonomy rung a new task INHERITS from the project default. Fail-SAFE:
|
|
3792
|
-
no declaration -> "auto" (the method default; v7: absent = auto); an unrecognized
|
|
3793
|
-
token -> "conservative" (NEVER silently "auto"); an unreadable foundation -> "auto".
|
|
3794
|
-
Read-only and PURE — mirrors _project_goal; the seed source for cmd_new_task."""
|
|
3795
|
-
tok = _project_autonomy_token(root)
|
|
3796
|
-
return "auto" if tok is None else ("conservative" if tok == "?" else tok)
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
def _milestone_doc(root: Path, mslug: str) -> tuple[str, str]:
|
|
3800
|
-
"""(title, goal) from MILESTONE.md; ('(unknown)','(unknown)') if the doc is gone."""
|
|
3801
|
-
f = root / "milestones" / mslug / MILESTONE_FILE
|
|
3802
|
-
if not f.exists():
|
|
3803
|
-
return "(unknown)", "(unknown)"
|
|
3804
|
-
title, goal = "(unknown)", "(unknown)"
|
|
3805
|
-
for line in f.read_text(encoding="utf-8").splitlines():
|
|
3806
|
-
if line.startswith("# MILESTONE:"):
|
|
3807
|
-
title = line.split(":", 1)[1].strip() or "(unknown)"
|
|
3808
|
-
elif line.startswith("goal:"):
|
|
3809
|
-
goal = line.split(":", 1)[1].strip() or "(unknown)"
|
|
3810
|
-
break
|
|
3811
|
-
return title, goal
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
def _exit_criteria(root: Path, mslug: str) -> tuple[int, int]:
|
|
3815
|
-
"""(met, total) checkbox tally inside MILESTONE.md's 'Exit criteria' section."""
|
|
3816
|
-
f = root / "milestones" / mslug / MILESTONE_FILE
|
|
3817
|
-
if not f.exists():
|
|
3818
|
-
return 0, 0
|
|
3819
|
-
m = re.search(r"## Exit criteria.*?(?=\n## |\Z)", f.read_text(encoding="utf-8"), re.S)
|
|
3820
|
-
if not m:
|
|
3821
|
-
return 0, 0
|
|
3822
|
-
sec = m.group(0)
|
|
3823
|
-
met = len(re.findall(r"- \[x\]", sec))
|
|
3824
|
-
total = met + len(re.findall(r"- \[ \]", sec))
|
|
3825
|
-
return met, total
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
3050
|
# A non-empty `(verify: <citation>)` on an exit-criterion line — at least one non-whitespace
|
|
3829
3051
|
# char inside, so a bare `(verify:)`/`(verify: )` does NOT count (the mid-text substring trap).
|
|
3830
|
-
_VERIFY_CITE_RE = re.compile(r"\(verify:\s*\S.*?\)", re.I)
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
def _exit_criteria_cited(root: Path, mslug: str) -> tuple[int, int]:
|
|
3834
|
-
"""(cited, total) over MILESTONE.md's 'Exit criteria' section. total = every
|
|
3835
|
-
`- [ ]`/`- [x]` criterion line; cited = those carrying a NON-EMPTY
|
|
3836
|
-
`(verify: <citation>)`. Read-only and PURE; missing file/section -> (0, 0).
|
|
3837
|
-
Mirrors _exit_criteria (the checkbox tally) — an ADDITIVE classification beside
|
|
3838
|
-
it; it never touches `milestone_goal_unmet`."""
|
|
3839
|
-
f = root / "milestones" / mslug / MILESTONE_FILE
|
|
3840
|
-
if not f.exists():
|
|
3841
|
-
return 0, 0
|
|
3842
|
-
m = re.search(r"## Exit criteria.*?(?=\n## |\Z)", f.read_text(encoding="utf-8"), re.S)
|
|
3843
|
-
if not m:
|
|
3844
|
-
return 0, 0
|
|
3845
|
-
cited = total = 0
|
|
3846
|
-
for ln in m.group(0).splitlines():
|
|
3847
|
-
if re.match(r"\s*- \[[ x]\]", ln):
|
|
3848
|
-
total += 1
|
|
3849
|
-
if _VERIFY_CITE_RE.search(ln):
|
|
3850
|
-
cited += 1
|
|
3851
|
-
return cited, total
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
3052
|
def _goal_auto_ready(root: Path, mslug: str) -> bool:
|
|
3855
3053
|
"""True iff the milestone goal is AUTO-READY: its Exit criteria has >= 1 criterion
|
|
3856
3054
|
AND every one cites a verifier (cited == total) — so the engine can self-verify the
|
|
@@ -3860,32 +3058,6 @@ def _goal_auto_ready(root: Path, mslug: str) -> bool:
|
|
|
3860
3058
|
return total >= 1 and cited == total
|
|
3861
3059
|
|
|
3862
3060
|
|
|
3863
|
-
def _stage_criteria(root: Path) -> tuple[int, int]:
|
|
3864
|
-
"""(met, total) checkbox tally inside PROJECT.md's 'Stage goal criteria' section — the
|
|
3865
|
-
PROJECT.md analog of _exit_criteria (v22): the human's stage-covered affirmation. Read-only
|
|
3866
|
-
and fail-closed to (0, 0): a missing file, a missing section, or any read error never raises
|
|
3867
|
-
and never fabricates a cue (so an unreadable foundation withholds graduation, design-for-failure)."""
|
|
3868
|
-
try:
|
|
3869
|
-
text = (root / "PROJECT.md").read_text(encoding="utf-8")
|
|
3870
|
-
except OSError:
|
|
3871
|
-
return 0, 0
|
|
3872
|
-
m = re.search(r"## Stage goal criteria.*?(?=\n## |\Z)", text, re.S)
|
|
3873
|
-
if not m:
|
|
3874
|
-
return 0, 0
|
|
3875
|
-
sec = m.group(0)
|
|
3876
|
-
met = len(re.findall(r"- \[x\]", sec))
|
|
3877
|
-
total = met + len(re.findall(r"- \[ \]", sec))
|
|
3878
|
-
return met, total
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
def _all_milestones_done(state: dict) -> bool:
|
|
3882
|
-
"""True when the project HAS milestones and EVERY one is status=done (v22). Archived
|
|
3883
|
-
milestones are absent from state['milestones'] (removed by the archive lifecycle), so they
|
|
3884
|
-
do not count; a project with zero milestones is not 'covered' and returns False."""
|
|
3885
|
-
ms = state.get("milestones") or {}
|
|
3886
|
-
return bool(ms) and all(m.get("status") == "done" for m in ms.values())
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
3061
|
def _graduation_ready(root: Path, state: dict) -> tuple[bool, int, int]:
|
|
3890
3062
|
"""(ready, met, total) for the stage-graduation cue (v22): every milestone done AND the
|
|
3891
3063
|
human's stage-goal-criteria all checked (total>0 and met==total). The SINGLE source the
|
|
@@ -3895,93 +3067,6 @@ def _graduation_ready(root: Path, state: dict) -> tuple[bool, int, int]:
|
|
|
3895
3067
|
return ready, met, total
|
|
3896
3068
|
|
|
3897
3069
|
|
|
3898
|
-
def _count_test_defs(f: Path) -> int:
|
|
3899
|
-
"""`def test_` occurrences in one file — the ONE counting regex (primary and
|
|
3900
|
-
§4-declared fallback share it by construction). OSError -> 0, fail-closed."""
|
|
3901
|
-
try:
|
|
3902
|
-
return len(re.findall(r"^\s*def test_", f.read_text(encoding="utf-8"), re.M))
|
|
3903
|
-
except OSError:
|
|
3904
|
-
return 0
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
def _primary_test_files(root: Path, slug: str) -> list[Path]:
|
|
3908
|
-
"""The PRIMARY test set — *.py directly in the task's tests/ dir (the stable
|
|
3909
|
-
path). A list so the tamper tripwire can hash exactly what the engine counts."""
|
|
3910
|
-
d = root / "tasks" / slug / "tests"
|
|
3911
|
-
if not d.is_dir():
|
|
3912
|
-
return []
|
|
3913
|
-
return sorted(d.glob("*.py"))
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
def _tests_count(root: Path, slug: str) -> int:
|
|
3917
|
-
return sum(_count_test_defs(f) for f in _primary_test_files(root, slug))
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
def _confined(p: Path, rootp: Path) -> bool:
|
|
3921
|
-
"""True only if p resolves (symlinks followed) inside rootp; errors -> False.
|
|
3922
|
-
The v2 confinement check — no read is attempted on a path that fails it."""
|
|
3923
|
-
try:
|
|
3924
|
-
return p.resolve().is_relative_to(rootp)
|
|
3925
|
-
except OSError:
|
|
3926
|
-
return False
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
def _declared_test_files(root: Path, slug: str) -> list[Path]:
|
|
3930
|
-
"""Resolve the §4 'Tests live in:' declared path(s) to a deduped file list. PURE.
|
|
3931
|
-
Tokens are the backticked spans on the FIRST declaring line of the raw §4 body.
|
|
3932
|
-
Resolution: './…' -> task dir · contains '/' -> project root (parent of .add) ·
|
|
3933
|
-
bare name -> sibling of the previous resolved token (else task dir). A directory
|
|
3934
|
-
token yields the *.py files directly inside it; resolved files are deduped.
|
|
3935
|
-
v2 confinement: every path must resolve inside the project root — '..' traversal,
|
|
3936
|
-
absolute tokens, and symlink escapes are all dropped, fail-closed."""
|
|
3937
|
-
body = _raw_phase_bodies(root, slug).get(4, "")
|
|
3938
|
-
m = re.search(r"^\s*Tests live in:.*$", body, re.M)
|
|
3939
|
-
if not m:
|
|
3940
|
-
return []
|
|
3941
|
-
tdir = root / "tasks" / slug
|
|
3942
|
-
rootp = root.parent.resolve()
|
|
3943
|
-
files: list[Path] = []
|
|
3944
|
-
prev_dir = None
|
|
3945
|
-
for tok in re.findall(r"`([^`]+)`", m.group(0)):
|
|
3946
|
-
tok = tok.strip()
|
|
3947
|
-
if tok.startswith("./"):
|
|
3948
|
-
p = tdir / tok[2:]
|
|
3949
|
-
elif "/" in tok:
|
|
3950
|
-
p = root.parent / tok
|
|
3951
|
-
else:
|
|
3952
|
-
p = (prev_dir or tdir) / tok
|
|
3953
|
-
try:
|
|
3954
|
-
if not _confined(p, rootp):
|
|
3955
|
-
continue
|
|
3956
|
-
if p.is_dir():
|
|
3957
|
-
cand, prev_dir = sorted(f for f in p.glob("*.py")
|
|
3958
|
-
if _confined(f, rootp)), p
|
|
3959
|
-
elif p.is_file() and p.suffix == ".py":
|
|
3960
|
-
cand, prev_dir = [p], p.parent
|
|
3961
|
-
else:
|
|
3962
|
-
continue
|
|
3963
|
-
except OSError:
|
|
3964
|
-
continue
|
|
3965
|
-
files.extend(f for f in cand if f not in files)
|
|
3966
|
-
return files
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
def _declared_tests_count(root: Path, slug: str) -> int:
|
|
3970
|
-
"""Count tests at the §4 'Tests live in:' declared path(s). PURE, fail-closed 0."""
|
|
3971
|
-
return sum(_count_test_defs(f) for f in _declared_test_files(root, slug))
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
def _tests_info(root: Path, slug: str) -> tuple[int, bool]:
|
|
3975
|
-
"""(count, declared). The tests/ dir count ALWAYS wins when > 0; otherwise the
|
|
3976
|
-
§4-declared fallback — flagged True only when it supplied a non-zero count, so
|
|
3977
|
-
a true zero stays a bare, honest 0."""
|
|
3978
|
-
primary = _tests_count(root, slug)
|
|
3979
|
-
if primary > 0:
|
|
3980
|
-
return primary, False
|
|
3981
|
-
declared = _declared_tests_count(root, slug)
|
|
3982
|
-
return (declared, True) if declared > 0 else (0, False)
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
3070
|
def _resolved_test_files(root: Path, slug: str) -> list[Path]:
|
|
3986
3071
|
"""The file set the engine treats as this task's tests — the PRIMARY set wins
|
|
3987
3072
|
when it yields any test defs, else the §4-declared set (mirrors _tests_info's
|
|
@@ -3992,19 +3077,6 @@ def _resolved_test_files(root: Path, slug: str) -> list[Path]:
|
|
|
3992
3077
|
return _declared_test_files(root, slug)
|
|
3993
3078
|
|
|
3994
3079
|
|
|
3995
|
-
def _md5_text(s: str) -> str:
|
|
3996
|
-
return hashlib.md5(s.encode("utf-8")).hexdigest()
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
def _md5_file(p: Path) -> str | None:
|
|
4000
|
-
"""md5 of a file's bytes; None on ANY read error (fail-closed — a tracked file
|
|
4001
|
-
that cannot be read counts as DIVERGED at the gate, never a crash)."""
|
|
4002
|
-
try:
|
|
4003
|
-
return hashlib.md5(p.read_bytes()).hexdigest()
|
|
4004
|
-
except OSError:
|
|
4005
|
-
return None
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
3080
|
def _tripwire_snapshot(root: Path, slug: str, raw3: str) -> dict:
|
|
4009
3081
|
"""Freeze the md5 of the resolved red test files + the frozen §3 contract — the
|
|
4010
3082
|
tamper baseline (verify-integrity). Keys are project-root-relative paths (stable
|
|
@@ -4058,31 +3130,6 @@ _SCOPE_EXCLUDE_SUFFIXES = (".pyc", ".tsbuildinfo")
|
|
|
4058
3130
|
# OPT-IN + DEGRADE-SAFE: with no .add/components.toml every reader is byte-identical to
|
|
4059
3131
|
# pre-component ADD. A read NEVER raises (absent/unreadable/malformed → {} / dropped
|
|
4060
3132
|
# cover); the loud surface is _component_findings, consumed by the scope gate (cmd_check).
|
|
4061
|
-
def _components(root: Path) -> dict[str, dict]:
|
|
4062
|
-
"""The registry from .add/components.toml → {name: {root, verify, green_bar,
|
|
4063
|
-
language}}. `root` required per entry; an entry missing it is skipped (the finding
|
|
4064
|
-
surface reports it). `verify` is stored OPAQUE — parsed as data, NEVER executed. PURE."""
|
|
4065
|
-
if tomllib is None:
|
|
4066
|
-
return {}
|
|
4067
|
-
try:
|
|
4068
|
-
raw = (root / "components.toml").read_bytes()
|
|
4069
|
-
except OSError:
|
|
4070
|
-
return {}
|
|
4071
|
-
try:
|
|
4072
|
-
data = tomllib.loads(raw.decode("utf-8"))
|
|
4073
|
-
except (tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
|
|
4074
|
-
return {}
|
|
4075
|
-
out: dict[str, dict] = {}
|
|
4076
|
-
for name, spec in (data.get("component") or {}).items():
|
|
4077
|
-
# "?" is the reserved unknown-binding sentinel (_task_component) — a component
|
|
4078
|
-
# named "?" would collide and silently drop cover, so it never registers.
|
|
4079
|
-
if name == "?" or not isinstance(spec, dict) or not isinstance(spec.get("root"), str):
|
|
4080
|
-
continue
|
|
4081
|
-
out[name] = {"root": spec["root"], "verify": spec.get("verify"),
|
|
4082
|
-
"green_bar": spec.get("green_bar"), "language": spec.get("language")}
|
|
4083
|
-
return out
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
3133
|
def _component_root(root: Path, name: str) -> str | None:
|
|
4087
3134
|
"""Project-root-relative path (trailing '/') of component `name`'s root, or None
|
|
4088
3135
|
when the name is absent OR the root escapes the project (fail-closed — grants no
|
|
@@ -4120,22 +3167,6 @@ def _task_green_bar(root: Path, slug: str) -> str | None:
|
|
|
4120
3167
|
return (_components(root).get(comp) or {}).get("green_bar") or None
|
|
4121
3168
|
|
|
4122
3169
|
|
|
4123
|
-
def _cite_region(body: str) -> str:
|
|
4124
|
-
"""The user-authored "Build expectations" evidence region of a §6 body, stamp-stripped —
|
|
4125
|
-
the only place a per-component green-bar cite counts (per-component-verify, v3). PURE.
|
|
4126
|
-
|
|
4127
|
-
The marker matches BOTH template shapes: the standard "### Build expectations …" heading AND
|
|
4128
|
-
the fast-lane bare "Build expectations (from …):" line, running up to the GATE RECORD sub-block.
|
|
4129
|
-
So the top-of-§6 checklist ("- [ ] all tests pass") and the "Outcome: <PASS|…>" placeholder are
|
|
4130
|
-
excluded, and a component-bound FAST task is still citable. The trailing strip removes the
|
|
4131
|
-
engine's own "component: … · expected green-bar: …" stamp wherever it landed, so a stamp that
|
|
4132
|
-
fell inside the region can never self-satisfy the gate. No marker -> "" (fail-closed for a bound
|
|
4133
|
-
task: it must declare its evidence)."""
|
|
4134
|
-
m = re.search(r"(?im)^#*[ \t]*Build expectations\b.*?(?=\n#+[ \t]*GATE RECORD\b|\Z)", body, re.DOTALL)
|
|
4135
|
-
region = m.group(0) if m else ""
|
|
4136
|
-
return re.sub(r"(?m)^component:.*·.*expected green-bar:.*$", "", region)
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
3170
|
def _component_findings(root: Path) -> list[tuple[str, str]]:
|
|
4140
3171
|
"""The loud gate surface for the registry — the codes a degrade-safe read passes
|
|
4141
3172
|
over silently. Consumed by cmd_check (the scope_violation surface). [] when clean."""
|
|
@@ -4178,46 +3209,6 @@ def _component_findings(root: Path) -> list[tuple[str, str]]:
|
|
|
4178
3209
|
# ── cross-component contracts (cross-component-contract) ──────────────────────────────────
|
|
4179
3210
|
# OPT-IN + DEGRADE-SAFE, like the component readers: no [contract.*] / no produces|consumes
|
|
4180
3211
|
# header ⇒ every path below is byte-identical to pre-contract ADD. A read NEVER raises.
|
|
4181
|
-
def _contracts(root: Path) -> dict[str, dict]:
|
|
4182
|
-
"""[contract.<id>] from .add/components.toml -> {id: {producer: str, consumers: list[str]}}.
|
|
4183
|
-
A malformed entry (producer not a str) is skipped (the finding surface reports it). PURE."""
|
|
4184
|
-
if tomllib is None:
|
|
4185
|
-
return {}
|
|
4186
|
-
try:
|
|
4187
|
-
data = tomllib.loads((root / "components.toml").read_bytes().decode("utf-8"))
|
|
4188
|
-
except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
|
|
4189
|
-
return {}
|
|
4190
|
-
out: dict[str, dict] = {}
|
|
4191
|
-
for cid, spec in (data.get("contract") or {}).items():
|
|
4192
|
-
if not isinstance(spec, dict) or not isinstance(spec.get("producer"), str):
|
|
4193
|
-
continue
|
|
4194
|
-
cons = spec.get("consumers")
|
|
4195
|
-
out[cid] = {"producer": spec["producer"],
|
|
4196
|
-
"consumers": [c for c in cons if isinstance(c, str)] if isinstance(cons, list) else []}
|
|
4197
|
-
return out
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
def _federation(root: Path) -> dict[str, dict]:
|
|
4201
|
-
"""[federation.<id>] from .add/components.toml -> {id: {source: str, pin: str|None}}.
|
|
4202
|
-
The cross-REPO join: a consumer repo names where a producer repo's published snapshot lives.
|
|
4203
|
-
A malformed entry (no string source) is skipped; a non-string `pin` degrades to None. Degrade-safe
|
|
4204
|
-
— never raises. PURE. On Python < 3.11 (no tomllib) this returns {} like the other component
|
|
4205
|
-
readers, so `federate` reports federation_unknown — components.toml needs a 3.11+ runtime."""
|
|
4206
|
-
if tomllib is None:
|
|
4207
|
-
return {}
|
|
4208
|
-
try:
|
|
4209
|
-
data = tomllib.loads((root / "components.toml").read_bytes().decode("utf-8"))
|
|
4210
|
-
except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError, ValueError):
|
|
4211
|
-
return {}
|
|
4212
|
-
out: dict[str, dict] = {}
|
|
4213
|
-
for fid, spec in (data.get("federation") or {}).items():
|
|
4214
|
-
if not isinstance(spec, dict) or not isinstance(spec.get("source"), str):
|
|
4215
|
-
continue
|
|
4216
|
-
pin = spec.get("pin")
|
|
4217
|
-
out[fid] = {"source": spec["source"], "pin": pin if isinstance(pin, str) else None}
|
|
4218
|
-
return out
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
3212
|
def _task_produces(root: Path, slug: str) -> str | None:
|
|
4222
3213
|
m = _PRODUCES_LINE_RE.search(_task_header(root, slug))
|
|
4223
3214
|
return m.group(1).strip() if m else None
|
|
@@ -4228,10 +3219,6 @@ def _task_consumes(root: Path, slug: str) -> str | None:
|
|
|
4228
3219
|
return m.group(1).strip() if m else None
|
|
4229
3220
|
|
|
4230
3221
|
|
|
4231
|
-
def _contract_snapshot(root: Path, cid: str) -> Path:
|
|
4232
|
-
return root / "contracts" / f"{cid}.json"
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
3222
|
def _contract_body_hash(raw3: str) -> str:
|
|
4236
3223
|
"""md5 of the §3 contract SHAPE — the first ```fenced``` block, whitespace-normalized. The
|
|
4237
3224
|
version stamp + freeze flags are excluded (fallback strips Status:/flag/change-request lines)
|
|
@@ -4304,18 +3291,6 @@ def _declared_scope(root: Path, slug: str) -> list[str] | None:
|
|
|
4304
3291
|
return out
|
|
4305
3292
|
|
|
4306
3293
|
|
|
4307
|
-
def _in_scope(rel: str, declared: list[str]) -> bool:
|
|
4308
|
-
"""True when rel falls under any declared token — exact match for a file
|
|
4309
|
-
token, whole-subtree prefix containment for a directory token ('…/')."""
|
|
4310
|
-
for tok in declared:
|
|
4311
|
-
if tok.endswith("/"):
|
|
4312
|
-
if rel.startswith(tok) or rel == tok.rstrip("/"):
|
|
4313
|
-
return True
|
|
4314
|
-
elif rel == tok:
|
|
4315
|
-
return True
|
|
4316
|
-
return False
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
3294
|
def _scope_walk(rootp: Path) -> dict[str, str]:
|
|
4320
3295
|
"""{project-root-relative path: md5} over the project tree, pruning
|
|
4321
3296
|
_SCOPE_EXCLUDE_DIRS at any depth and skipping bytecode/OS junk +
|
|
@@ -4446,6 +3421,28 @@ def _heal_or_escalate(root: Path, state: dict, slug: str, *, reason: str, source
|
|
|
4446
3421
|
raise SystemExit(3) # redo signal (distinct from _die's 1, argparse's 2)
|
|
4447
3422
|
|
|
4448
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
|
+
|
|
4449
3446
|
def _tamper_guard(root: Path, state: dict, slug: str) -> None:
|
|
4450
3447
|
"""HARD-STOP a COMPLETING gate when the tripwire shows tampering — the method's
|
|
4451
3448
|
first mechanical cheat block (verify-integrity). Tri-state, co-witnessed by
|
|
@@ -4472,89 +3469,6 @@ def _tamper_guard(root: Path, state: dict, slug: str) -> None:
|
|
|
4472
3469
|
reason="tamper_detected:" + ",".join(diffs), source="tamper")
|
|
4473
3470
|
|
|
4474
3471
|
|
|
4475
|
-
def _task_prose(root: Path, slug: str) -> tuple[str, list[str]]:
|
|
4476
|
-
"""(observe_delta, [delta lines]) from the task's TASK.md §7 — captured at FULL
|
|
4477
|
-
fidelity: both fields wrap across physical lines in real files, so continuation
|
|
4478
|
-
lines are JOINED. Scoped to the OBSERVE section so we read the FIELD, not §1 prose
|
|
4479
|
-
that names it. Fail-closed to '(unknown)' on a missing file / `<...>` placeholder."""
|
|
4480
|
-
f = root / "tasks" / slug / "TASK.md"
|
|
4481
|
-
if not f.exists():
|
|
4482
|
-
return "(unknown)", []
|
|
4483
|
-
text = f.read_text(encoding="utf-8")
|
|
4484
|
-
m7 = re.search(r"##\s*7\s*·\s*OBSERVE.*\Z", text, re.S)
|
|
4485
|
-
section = m7.group(0) if m7 else text
|
|
4486
|
-
lines = section.splitlines()
|
|
4487
|
-
# observe: prefer the first OPEN SPEC delta from the "### Spec delta" block; fall
|
|
4488
|
-
# back to the legacy "Spec delta for the next loop:" free-text field (archived
|
|
4489
|
-
# tasks predate the block); else "(unknown)".
|
|
4490
|
-
observe = "(unknown)"
|
|
4491
|
-
for unit in _spec_delta_entries(section):
|
|
4492
|
-
m = _SPEC_DELTA_RE.match(unit[0])
|
|
4493
|
-
if m.group(2) != "open":
|
|
4494
|
-
continue
|
|
4495
|
-
tail = " ".join([m.group(3).strip(), *unit[1:]]).strip()
|
|
4496
|
-
em = _EVIDENCE_RE.match(tail)
|
|
4497
|
-
first = (em.group(1).strip() if em else tail)
|
|
4498
|
-
if first and not first.startswith("<"):
|
|
4499
|
-
observe = first
|
|
4500
|
-
break
|
|
4501
|
-
if observe == "(unknown)":
|
|
4502
|
-
for i, ln in enumerate(lines):
|
|
4503
|
-
m = re.match(r"\s*Spec delta for the next loop:\s*(.*)", ln)
|
|
4504
|
-
if not m:
|
|
4505
|
-
continue
|
|
4506
|
-
parts = [m.group(1).strip()]
|
|
4507
|
-
for nxt in lines[i + 1:]:
|
|
4508
|
-
t = nxt.strip()
|
|
4509
|
-
if not t or t.startswith("#") or t.startswith("- ") or t.startswith("Watch"):
|
|
4510
|
-
break
|
|
4511
|
-
parts.append(t)
|
|
4512
|
-
joined = " ".join(p for p in parts if p).strip()
|
|
4513
|
-
if joined and not joined.startswith("<"):
|
|
4514
|
-
observe = joined
|
|
4515
|
-
break
|
|
4516
|
-
|
|
4517
|
-
# deltas: each "- [COMP · status] ..." plus its indented continuation lines
|
|
4518
|
-
deltas, i = [], 0
|
|
4519
|
-
while i < len(lines):
|
|
4520
|
-
m = _DELTA_RE.match(lines[i])
|
|
4521
|
-
if not m:
|
|
4522
|
-
i += 1
|
|
4523
|
-
continue
|
|
4524
|
-
parts, j = [m.group(3).strip()], i + 1
|
|
4525
|
-
while j < len(lines):
|
|
4526
|
-
t = lines[j].strip()
|
|
4527
|
-
if not t or t.startswith("#") or _DELTA_RE.match(lines[j]):
|
|
4528
|
-
break
|
|
4529
|
-
parts.append(t)
|
|
4530
|
-
j += 1
|
|
4531
|
-
deltas.append(f"{m.group(1)} · {m.group(2)} · {' '.join(parts).strip()}")
|
|
4532
|
-
i = j
|
|
4533
|
-
return observe, deltas
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
def _clip(s: str, maxlen: int) -> str:
|
|
4537
|
-
"""Trim a string to fit a fixed-width frame, ellipsizing if it overruns."""
|
|
4538
|
-
return s if len(s) <= maxlen else s[:maxlen - 1].rstrip() + "…"
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
def _wrap(text: str, width: int, label: str) -> list[str]:
|
|
4542
|
-
"""Wrap `text` to `width`; the first line carries `label`, continuations are
|
|
4543
|
-
blank-indented to the same width (so a multi-line goal shows 'goal' once)."""
|
|
4544
|
-
cont = " " * len(label)
|
|
4545
|
-
lines, cur = [], ""
|
|
4546
|
-
for w in text.split():
|
|
4547
|
-
if cur and len(cur) + 1 + len(w) > width:
|
|
4548
|
-
lines.append(cur)
|
|
4549
|
-
cur = w
|
|
4550
|
-
else:
|
|
4551
|
-
cur = f"{cur} {w}".strip()
|
|
4552
|
-
if cur:
|
|
4553
|
-
lines.append(cur)
|
|
4554
|
-
lines = lines or ["(unknown)"]
|
|
4555
|
-
return [(label if i == 0 else cont) + ln for i, ln in enumerate(lines)]
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
3472
|
def report_data(root: Path, state: dict, mslug: str) -> dict:
|
|
4559
3473
|
"""The single source of FACTS for a milestone report — pure, NO writes.
|
|
4560
3474
|
Both the text dashboard (render_report) and `report --json` render from this,
|
|
@@ -4636,43 +3550,6 @@ def _clean_phase_body(body: str) -> str:
|
|
|
4636
3550
|
return "\n".join(lines) if meaningful else "(empty)"
|
|
4637
3551
|
|
|
4638
3552
|
|
|
4639
|
-
def _phase_spans(text: str) -> dict[int, str]:
|
|
4640
|
-
"""Split a TASK.md into RAW §1–§7 bodies keyed by section number — the ONE
|
|
4641
|
-
canonical heading scan (`^##\\s*<n>\\s*·`, case/locale-proof); a body runs from
|
|
4642
|
-
its heading to the next `## `/`---`/EOF. RAW = byte-faithful lines, no cleaning:
|
|
4643
|
-
the decision-marker extractor (decide-digest) depends on byte-verbatim text.
|
|
4644
|
-
KNOWN LIMIT: a §body containing a line-start `## ` or bare `---` truncates early —
|
|
4645
|
-
today's TASK.md bodies don't (box-chars ─═, `### ` sub-heads)."""
|
|
4646
|
-
lines = text.splitlines()
|
|
4647
|
-
head = re.compile(r"^##\s*(\d+)\s*·")
|
|
4648
|
-
starts: dict[int, int] = {}
|
|
4649
|
-
for idx, ln in enumerate(lines):
|
|
4650
|
-
m = head.match(ln)
|
|
4651
|
-
if m:
|
|
4652
|
-
n = int(m.group(1))
|
|
4653
|
-
if 0 <= n <= 7 and n not in starts:
|
|
4654
|
-
starts[n] = idx
|
|
4655
|
-
out: dict[int, str] = {}
|
|
4656
|
-
for n, idx in starts.items():
|
|
4657
|
-
body_lines = []
|
|
4658
|
-
for ln in lines[idx + 1:]:
|
|
4659
|
-
if re.match(r"^##\s", ln) or re.match(r"^---\s*$", ln):
|
|
4660
|
-
break
|
|
4661
|
-
body_lines.append(ln)
|
|
4662
|
-
out[n] = "\n".join(body_lines)
|
|
4663
|
-
return out
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
def _raw_phase_bodies(root: Path, slug: str) -> dict[int, str]:
|
|
4667
|
-
"""RAW §bodies for one task (byte-faithful, for marker extraction). PURE.
|
|
4668
|
-
Missing/unreadable TASK.md -> {} (fail-closed, like task_phases)."""
|
|
4669
|
-
f = root / "tasks" / slug / "TASK.md"
|
|
4670
|
-
try:
|
|
4671
|
-
return _phase_spans(f.read_text(encoding="utf-8"))
|
|
4672
|
-
except OSError:
|
|
4673
|
-
return {}
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
3553
|
def task_phases(root: Path, slug: str) -> list[dict]:
|
|
4677
3554
|
"""The frozen per-task PHASE-DETAIL shape (v9-1): parse TASK.md §0–§7 into eight
|
|
4678
3555
|
blocks ground→observe. PURE — NO writes. Each entry is
|
|
@@ -5259,10 +4136,6 @@ _DELTA_STATUSES = ("open", "folded", "rejected")
|
|
|
5259
4136
|
# un-stripped lines directly; callers that pre-strip their input
|
|
5260
4137
|
# (e.g. _collect_open_deltas, _lint_task_deltas) match the same way (\s*
|
|
5261
4138
|
# matches zero). Anchored at line-start via re.match.
|
|
5262
|
-
_DELTA_RE = re.compile(
|
|
5263
|
-
r"\s*-\s*\[\s*(DDD|SDD|UDD|TDD|ADD)\s*·\s*(open|folded|rejected)\s*\]\s*(.+)$"
|
|
5264
|
-
)
|
|
5265
|
-
_EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
|
|
5266
4139
|
|
|
5267
4140
|
# SPEC-delta track — a SEPARATE resolution lifecycle from the competency deltas
|
|
5268
4141
|
# above. SPEC shares the "- [TAG · status]" LINE shape but its statuses are
|
|
@@ -5271,9 +4144,6 @@ _EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
|
|
|
5271
4144
|
# tag to its legal status set so the ONE lint can reject a cross-set pairing
|
|
5272
4145
|
# ([SPEC · folded], [SDD · seeded]) without a parallel grammar.
|
|
5273
4146
|
_SPEC_STATUSES = ("open", "seeded", "dropped")
|
|
5274
|
-
_SPEC_DELTA_RE = re.compile(
|
|
5275
|
-
r"\s*-\s*\[\s*(SPEC)\s*·\s*(open|seeded|dropped)\s*\]\s*(.+)$"
|
|
5276
|
-
)
|
|
5277
4147
|
_STATUS_SETS = {**{c: _DELTA_STATUSES for c in _COMPETENCY_ORDER}, "SPEC": _SPEC_STATUSES}
|
|
5278
4148
|
|
|
5279
4149
|
# Broad structural tag detector: finds ANY "- [tok · tok]" line (valid OR malformed).
|
|
@@ -5429,32 +4299,6 @@ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
|
|
|
5429
4299
|
return by_comp
|
|
5430
4300
|
|
|
5431
4301
|
|
|
5432
|
-
def _spec_delta_entries(text: str) -> list[list[str]]:
|
|
5433
|
-
"""Group a "### Spec delta" block into entries (tag line + continuation lines).
|
|
5434
|
-
|
|
5435
|
-
Same grouping discipline as _collect_open_deltas' competency pass, keyed on
|
|
5436
|
-
_SPEC_DELTA_RE: a tag line starts an entry; a non-"- " line continues it; a
|
|
5437
|
-
blank/comment or a new "- " item ends it. Returns [] when the block is absent."""
|
|
5438
|
-
bm = re.search(r"###\s*Spec delta\s*\n(.*?)(?=\n##|\Z)", text, re.S)
|
|
5439
|
-
if not bm:
|
|
5440
|
-
return []
|
|
5441
|
-
entries: list[list[str]] = []
|
|
5442
|
-
current: list[str] | None = None
|
|
5443
|
-
for line in bm.group(1).splitlines():
|
|
5444
|
-
stripped = line.strip()
|
|
5445
|
-
if not stripped or stripped.startswith("<!--"):
|
|
5446
|
-
current = None
|
|
5447
|
-
continue
|
|
5448
|
-
if _SPEC_DELTA_RE.match(stripped):
|
|
5449
|
-
current = [stripped]
|
|
5450
|
-
entries.append(current)
|
|
5451
|
-
elif current is not None and not stripped.startswith("-"):
|
|
5452
|
-
current.append(stripped) # genuine wrap of the current entry
|
|
5453
|
-
else:
|
|
5454
|
-
current = None # a new / malformed list item ends the run
|
|
5455
|
-
return entries
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
4302
|
def _collect_open_spec_deltas(root: Path) -> list[dict]:
|
|
5459
4303
|
"""Scan every .add/tasks/*/TASK.md "### Spec delta" block for OPEN SPEC deltas.
|
|
5460
4304
|
|
|
@@ -5782,6 +4626,15 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
|
|
|
5782
4626
|
elif gate != "none" and outcomes[0] != gate:
|
|
5783
4627
|
f(slug, "gate_record_mismatch",
|
|
5784
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")
|
|
5785
4638
|
sec = _AUDIT_SECURITY_RE.search(s6)
|
|
5786
4639
|
marked = bool(sec and ("NOTE" in sec.group(0) or "⚠" in sec.group(0)))
|
|
5787
4640
|
rev = _AUDIT_REVIEWED_RE.search(s6)
|
|
@@ -5967,12 +4820,6 @@ def cmd_graduation_report(args: argparse.Namespace) -> None:
|
|
|
5967
4820
|
print("\n".join(L))
|
|
5968
4821
|
|
|
5969
4822
|
|
|
5970
|
-
def _releases_path(root: Path) -> Path:
|
|
5971
|
-
"""The append-only release ledger — at the PROJECT ROOT (root IS the .add dir, so its
|
|
5972
|
-
parent), a sibling of CHANGELOG.md. NOT inside .add/."""
|
|
5973
|
-
return root.parent / RELEASES_FILE
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
4823
|
def _released_milestones(root: Path) -> set[str]:
|
|
5977
4824
|
"""Slugs already attributed to a release — the union of every `milestones:` row in
|
|
5978
4825
|
RELEASES.md. Fail-OPEN: a missing/unreadable/malformed ledger yields the empty set, so
|
|
@@ -5993,21 +4840,6 @@ def _released_milestones(root: Path) -> set[str]:
|
|
|
5993
4840
|
return out
|
|
5994
4841
|
|
|
5995
4842
|
|
|
5996
|
-
def _closed_milestones(state: dict) -> list[dict]:
|
|
5997
|
-
"""Every CLOSED milestone (its milestone-done gate passed): LIVE done milestones
|
|
5998
|
-
(status == 'done', still in state) + ARCHIVED milestones (all were PASS-done before
|
|
5999
|
-
archive — see _archived_task_slugs). Each: {slug, title, tier}."""
|
|
6000
|
-
out: list[dict] = []
|
|
6001
|
-
for slug, m in (state.get("milestones") or {}).items():
|
|
6002
|
-
if m.get("status") == "done":
|
|
6003
|
-
out.append({"slug": slug, "title": m.get("title", slug), "tier": "live"})
|
|
6004
|
-
for rec in state.get("archived") or []:
|
|
6005
|
-
if rec.get("slug"):
|
|
6006
|
-
out.append({"slug": rec["slug"], "title": rec.get("title", rec["slug"]),
|
|
6007
|
-
"tier": "archived"})
|
|
6008
|
-
return out
|
|
6009
|
-
|
|
6010
|
-
|
|
6011
4843
|
def _releasable(root: Path, state: dict) -> list[dict]:
|
|
6012
4844
|
"""Closed milestones NOT yet attributed to any RELEASES.md row — the cut's candidate
|
|
6013
4845
|
bundle. Drives BOTH the `→ releasable: N` status cue and release-report. READ-ONLY."""
|
|
@@ -6015,19 +4847,38 @@ def _releasable(root: Path, state: dict) -> list[dict]:
|
|
|
6015
4847
|
return [m for m in _closed_milestones(state) if m["slug"] not in released]
|
|
6016
4848
|
|
|
6017
4849
|
|
|
6018
|
-
def
|
|
6019
|
-
"""
|
|
6020
|
-
|
|
6021
|
-
|
|
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."""
|
|
6022
4855
|
try:
|
|
6023
|
-
text = (root
|
|
4856
|
+
text = _releases_path(root).read_text(encoding="utf-8")
|
|
6024
4857
|
except OSError:
|
|
6025
|
-
return
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
|
|
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
|
|
6031
4882
|
|
|
6032
4883
|
|
|
6033
4884
|
def release_data(root: Path, state: dict) -> dict:
|
|
@@ -6092,15 +4943,19 @@ def release_data(root: Path, state: dict) -> dict:
|
|
|
6092
4943
|
monitors.append({"slug": slug, "watch": st})
|
|
6093
4944
|
break
|
|
6094
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
|
+
|
|
6095
4949
|
return {
|
|
6096
4950
|
"releasable": releasable,
|
|
6097
4951
|
"changed": changed,
|
|
6098
4952
|
"waivers": waivers,
|
|
6099
4953
|
"blockers": blockers,
|
|
6100
4954
|
"monitors": monitors,
|
|
4955
|
+
"loose": loose,
|
|
6101
4956
|
"summary": {
|
|
6102
4957
|
"releasable": len(releasable), "changed": len(changed), "waivers": len(waivers),
|
|
6103
|
-
"blockers": len(blockers), "monitors": len(monitors),
|
|
4958
|
+
"blockers": len(blockers), "monitors": len(monitors), "loose": len(loose),
|
|
6104
4959
|
},
|
|
6105
4960
|
}
|
|
6106
4961
|
|
|
@@ -6144,14 +4999,6 @@ def cmd_release_report(args: argparse.Namespace) -> None:
|
|
|
6144
4999
|
print("\n".join(L))
|
|
6145
5000
|
|
|
6146
5001
|
|
|
6147
|
-
def _build_in_flight(state: dict) -> bool:
|
|
6148
|
-
"""release_tests_red proxy (PURE): is any ACTIVE task mid-build without a recorded green gate
|
|
6149
|
-
— phase ∈ {build, verify} AND gate == 'none'? The tool-agnostic engine never runs the suite,
|
|
6150
|
-
so an entered-but-ungated build is the recorded-evidence stand-in for 'the suite is red'."""
|
|
6151
|
-
return any(t.get("phase") in ("build", "verify") and t.get("gate") == "none"
|
|
6152
|
-
for t in (state.get("tasks") or {}).values())
|
|
6153
|
-
|
|
6154
|
-
|
|
6155
5002
|
def _prepend_block(existing: str, header: str, block: str) -> str:
|
|
6156
5003
|
"""Newest-first prepend: insert `block` directly under the top H1 `header`, creating the
|
|
6157
5004
|
header when `existing` is empty / headerless. Existing content is preserved VERBATIM
|
|
@@ -6164,37 +5011,6 @@ def _prepend_block(existing: str, header: str, block: str) -> str:
|
|
|
6164
5011
|
return f"{block}{existing}" # no recognized header -> block goes on top, verbatim tail
|
|
6165
5012
|
|
|
6166
5013
|
|
|
6167
|
-
def _render_changelog_block(version: str, day: str, bundle: list[dict],
|
|
6168
|
-
changed_by_slug: dict) -> str:
|
|
6169
|
-
"""A CHANGELOG block: `## <version> — <date>` + one bullet per bundled milestone (title +
|
|
6170
|
-
carried-delta / key-decision counts from release_data['changed'])."""
|
|
6171
|
-
lines = [f"## {version} — {day}", ""]
|
|
6172
|
-
if bundle:
|
|
6173
|
-
for m in bundle:
|
|
6174
|
-
c = changed_by_slug.get(m["slug"], {})
|
|
6175
|
-
lines.append(f"- {m['title']} — {c.get('carried_deltas', 0)} carried · "
|
|
6176
|
-
f"{len(c.get('key_decisions', []))} key decision(s)")
|
|
6177
|
-
else:
|
|
6178
|
-
lines.append("- (no milestone bundled)")
|
|
6179
|
-
return "\n".join(lines) + "\n\n"
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
def _render_releases_row(version: str, day: str, bundle: list[dict],
|
|
6183
|
-
waiver_slugs: list[str], evidence: str | None,
|
|
6184
|
-
actor: str | None = None) -> str:
|
|
6185
|
-
"""One append-only RELEASES.md row — the attribution source (`milestones:` membership).
|
|
6186
|
-
The `actor:` line records WHO cut the release (structured-actor stamping); absent on a
|
|
6187
|
-
legacy row (back-compat) when no actor is supplied."""
|
|
6188
|
-
ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
|
|
6189
|
-
wv = ", ".join(waiver_slugs) if waiver_slugs else "none"
|
|
6190
|
-
actor_line = f"actor: {actor}\n" if actor else ""
|
|
6191
|
-
return (f"## {version} — {day}\n"
|
|
6192
|
-
f"milestones: {ms}\n"
|
|
6193
|
-
f"waivers: {wv}\n"
|
|
6194
|
-
f"{actor_line}"
|
|
6195
|
-
f"evidence: {evidence or 'recorded by add.py release'}\n\n")
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
5014
|
def cmd_release(args: argparse.Namespace) -> None:
|
|
6199
5015
|
"""GUARDED, record-only: cut a version. Enforce the 4-code readiness floor, then RECORD by
|
|
6200
5016
|
prepending CHANGELOG.md + an append-only RELEASES.md row (whose `milestones:` line attributes
|
|
@@ -6218,9 +5034,11 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
6218
5034
|
_die("release_tests_red: a build is in flight without a recorded green gate — finish and "
|
|
6219
5035
|
"gate it first, or pass --force to override.")
|
|
6220
5036
|
bundle = _releasable(root, state)
|
|
6221
|
-
|
|
5037
|
+
loose_bundle = _releasable_loose_tasks(root, state)
|
|
5038
|
+
if not forced and not bundle and not loose_bundle:
|
|
6222
5039
|
_die("release_no_closed_milestone: nothing closed-and-unreleased to bundle — the cut "
|
|
6223
|
-
"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.")
|
|
6224
5042
|
if not forced and d["waivers"] and not disclosed:
|
|
6225
5043
|
_die("release_undisclosed_waiver: a RISK-ACCEPTED waiver rides into this release — pass "
|
|
6226
5044
|
"--with-waivers to disclose it in the notes, or --force to override.")
|
|
@@ -6238,7 +5056,7 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
6238
5056
|
new_rel = _prepend_block(rel_before, "# Releases",
|
|
6239
5057
|
_render_releases_row(args.version, day, bundle, waiver_slugs,
|
|
6240
5058
|
getattr(args, "evidence", None),
|
|
6241
|
-
_render_actor_line(state)))
|
|
5059
|
+
identity._render_actor_line(state), loose_bundle))
|
|
6242
5060
|
try: # CHANGELOG + RELEASES as one all-or-nothing commit
|
|
6243
5061
|
_atomic_write_many([(changelog_path, new_cl), (releases_path, new_rel)])
|
|
6244
5062
|
except OSError as e:
|
|
@@ -6247,7 +5065,9 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
6247
5065
|
|
|
6248
5066
|
# NO save_state — attribution lives in RELEASES.md (the cue re-reads it), never state.json
|
|
6249
5067
|
ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
|
|
6250
|
-
|
|
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}")
|
|
6251
5071
|
print(" CHANGELOG.md + RELEASES.md updated (project root). The engine records; "
|
|
6252
5072
|
"you run the tag / publish / deploy.")
|
|
6253
5073
|
if forced:
|
|
@@ -6568,6 +5388,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6568
5388
|
help="confirm a RAISE toward auto (a human-owned trust escalation)")
|
|
6569
5389
|
pan.set_defaults(func=cmd_autonomy, _opt_positionals=("a1", "a2"))
|
|
6570
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
|
+
|
|
6571
5398
|
pr = sub.add_parser("reopen", help="return a done task to an earlier phase with a recorded reason")
|
|
6572
5399
|
pr.add_argument("slug", nargs="?", default=None)
|
|
6573
5400
|
# --to / --reason are validated in-body (not argparse choices) so the named reject
|
|
@@ -6739,16 +5566,6 @@ def _rebind_optional_positionals(parser: argparse.ArgumentParser,
|
|
|
6739
5566
|
# silently does nothing and nothing is lost. It NEVER changes a command's stdout or exit.
|
|
6740
5567
|
_UPDATE_CACHE = ".update-cache.json"
|
|
6741
5568
|
_UPDATE_TTL = timedelta(hours=24) # hit the registry at most once / day
|
|
6742
|
-
_REGISTRY_LATEST = "https://registry.npmjs.org/@pilotspace/add/latest"
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
def _read_json_safe(path: Path):
|
|
6746
|
-
try:
|
|
6747
|
-
return json.loads(path.read_text(encoding="utf-8"))
|
|
6748
|
-
except (OSError, json.JSONDecodeError):
|
|
6749
|
-
return None
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
5569
|
def _write_json_safe(path: Path, obj) -> None:
|
|
6753
5570
|
try:
|
|
6754
5571
|
path.write_text(json.dumps(obj, indent=2) + "\n", encoding="utf-8")
|
|
@@ -6756,33 +5573,6 @@ def _write_json_safe(path: Path, obj) -> None:
|
|
|
6756
5573
|
pass
|
|
6757
5574
|
|
|
6758
5575
|
|
|
6759
|
-
def _version_gt(a: str, b: str) -> bool:
|
|
6760
|
-
"""True if version a is newer than b (dotted numeric; prerelease suffix dropped)."""
|
|
6761
|
-
def key(v: str):
|
|
6762
|
-
out = []
|
|
6763
|
-
for part in str(v).split("."):
|
|
6764
|
-
part = part.split("-", 1)[0]
|
|
6765
|
-
out.append((0, int(part)) if part.isdigit() else (1, part))
|
|
6766
|
-
return out
|
|
6767
|
-
try:
|
|
6768
|
-
return key(a) > key(b)
|
|
6769
|
-
except Exception:
|
|
6770
|
-
return False
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
def _fetch_latest_version(timeout: float = 1.5):
|
|
6774
|
-
"""GET the registry's latest version. Returns a string, or None on ANY failure
|
|
6775
|
-
(offline, timeout, bad payload) — the caller treats None as 'unknown, skip'."""
|
|
6776
|
-
try:
|
|
6777
|
-
req = urllib.request.Request(_REGISTRY_LATEST, headers={"Accept": "application/json"})
|
|
6778
|
-
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
6779
|
-
data = json.loads(resp.read().decode("utf-8"))
|
|
6780
|
-
v = data.get("version")
|
|
6781
|
-
return v if isinstance(v, str) and v else None
|
|
6782
|
-
except Exception:
|
|
6783
|
-
return None
|
|
6784
|
-
|
|
6785
|
-
|
|
6786
5576
|
def _cached_latest(add_dir: Path):
|
|
6787
5577
|
"""The registry's latest version, throttled: served from .update-cache.json within
|
|
6788
5578
|
the TTL, else refreshed over the network (fail-open). None when unknown."""
|