@pilotspace/add 1.10.0 → 1.12.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 +102 -0
- package/bin/cli.js +55 -0
- package/docs/02-the-flow.md +10 -0
- package/docs/09-the-loop.md +1 -1
- package/docs/11-governance.md +1 -1
- package/docs/14-foundation.md +7 -5
- package/docs/16-releasing.md +2 -2
- package/docs/appendix-c-glossary.md +10 -4
- package/package.json +4 -1
- package/skill/add/SKILL.md +9 -3
- package/skill/add/design.md +19 -2
- package/skill/add/intake.md +16 -0
- package/skill/add/phases/0-ground.md +9 -3
- package/skill/add/phases/0-setup.md +2 -2
- package/skill/add/scope.md +1 -1
- package/tooling/add.py +614 -1598
- 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/DESIGN.md.tmpl +11 -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
|
+
)
|
|
169
70
|
|
|
170
|
-
#
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
"""
|
|
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
|
+
)
|
|
209
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
|
+
)
|
|
210
82
|
|
|
211
|
-
# --- low-level IO (designed for failure: atomic, no silent clobber) ----------
|
|
212
83
|
|
|
213
|
-
def
|
|
214
|
-
|
|
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)
|
|
215
87
|
|
|
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
|
+
)
|
|
216
97
|
|
|
217
|
-
def _atomic_write(path: Path, text: str) -> None:
|
|
218
|
-
"""Write via a temp file in the same dir, then atomically replace.
|
|
219
98
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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")
|
|
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
|
+
)
|
|
104
|
+
|
|
105
|
+
# --- state load/save (KEPT in add.py: write-path pinned by add._atomic_write tests) -
|
|
106
|
+
|
|
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,221 +150,37 @@ 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
|
-
def _my_work(state: dict, me: dict) -> list[dict]:
|
|
528
|
-
"""The "my work" lens (multi-active-UX):
|
|
529
|
-
|
|
530
|
-
|
|
167
|
+
def _my_work(state: dict, me: dict, scope_all: bool = False) -> list[dict]:
|
|
168
|
+
"""The "my work" lens (multi-active-UX): the NOT-done tasks whose owner OR assignee is `me`.
|
|
169
|
+
By default the lens is the active SET; `scope_all=True` (mine-all-lens) widens it to EVERY
|
|
170
|
+
milestone plus loose (milestone-less) tasks. Returns ordered rows {slug, milestone, phase,
|
|
171
|
+
role} with role in {owner, assignee, both}, sorted by active-milestone order then slug
|
|
172
|
+
(non-active/loose sort after the active block, then by slug). PURE · no I/O."""
|
|
531
173
|
active = list(state.get("active_milestones") or [])
|
|
532
174
|
active_set = set(active)
|
|
533
175
|
tasks = state.get("tasks") if isinstance(state.get("tasks"), dict) else {}
|
|
534
176
|
rows: list[dict] = []
|
|
535
177
|
for slug, t in tasks.items():
|
|
536
|
-
if not isinstance(t, dict) or
|
|
178
|
+
if not isinstance(t, dict) or _task_done(t):
|
|
537
179
|
continue
|
|
538
|
-
|
|
539
|
-
|
|
180
|
+
if not scope_all and t.get("milestone") not in active_set:
|
|
181
|
+
continue
|
|
182
|
+
owns = identity._actor_matches(t.get("owner"), me)
|
|
183
|
+
assigned = identity._actor_matches(t.get("assignee"), me)
|
|
540
184
|
if not (owns or assigned):
|
|
541
185
|
continue
|
|
542
186
|
role = "both" if owns and assigned else ("owner" if owns else "assignee")
|
|
@@ -550,106 +194,6 @@ def _my_work(state: dict, me: dict) -> list[dict]:
|
|
|
550
194
|
# A git conflict marker BEGINS a line with 7 of `<`, `=`, or `>` (`(?m)^…`). An unresolved
|
|
551
195
|
# merge writes these into state.json, making it invalid JSON; the line-anchor keeps a
|
|
552
196
|
# 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
197
|
|
|
654
198
|
|
|
655
199
|
def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None:
|
|
@@ -668,7 +212,7 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
|
|
|
668
212
|
return # unreadable -> no-op (never blocks the gate)
|
|
669
213
|
if "### GATE RECORD" not in text:
|
|
670
214
|
return # nothing to mirror into
|
|
671
|
-
actor = _actor_stamp(state)
|
|
215
|
+
actor = identity._actor_stamp(state)
|
|
672
216
|
today = date.today().isoformat()
|
|
673
217
|
# each rule matches ONLY a line still carrying a `<…>` placeholder -> grandfather a resolved line.
|
|
674
218
|
rules = [
|
|
@@ -695,251 +239,11 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
|
|
|
695
239
|
_atomic_write(f, new)
|
|
696
240
|
|
|
697
241
|
|
|
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
|
-
|
|
242
|
+
# --- guidelines / CLAUDE.md-injection subsystem (moved to add_engine/guidelines.py) -
|
|
243
|
+
from add_engine.guidelines import (
|
|
244
|
+
_guideline_block, _inject_block, _rule_file_mode, _strip_inline_block,
|
|
245
|
+
_insert_rule_reference, _ensure_claude_reference, _inject_guidelines, _is_brownfield,
|
|
246
|
+
)
|
|
943
247
|
def cmd_init(args: argparse.Namespace) -> None:
|
|
944
248
|
base = Path(args.dir).resolve()
|
|
945
249
|
root = base / ROOT_DIRNAME
|
|
@@ -1003,6 +307,10 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
1003
307
|
else:
|
|
1004
308
|
print("next: open Claude Code, run `/add`, and say what you want to build —")
|
|
1005
309
|
print(" the `add` skill sizes it into a milestone and drives the build with you.")
|
|
310
|
+
# setup hygiene (both branches): the .add/ folder IS the shared project state — commit it
|
|
311
|
+
# so the team shares one source of truth; its transient working files are already gitignored.
|
|
312
|
+
print("tip: commit the .add/ folder to git so your team shares the ADD state "
|
|
313
|
+
"(its transient files are already .gitignored).")
|
|
1006
314
|
|
|
1007
315
|
|
|
1008
316
|
def cmd_sync_guidelines(args: argparse.Namespace) -> None:
|
|
@@ -1088,6 +396,11 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
1088
396
|
f"autonomy token; new task seeded fail-safe '{autonomy}' "
|
|
1089
397
|
"(fix it with `add.py autonomy set <level> --project`)", file=sys.stderr)
|
|
1090
398
|
|
|
399
|
+
# F8 (force-preserve-heal): a --force overwrite RE-CREATES the record; capture the prior
|
|
400
|
+
# MONOTONIC heal counter first so it survives. Else a task that accrued heal attempts (or
|
|
401
|
+
# was HARD-STOP escalated) could launder the cap (HEAL_CAP) to zero by re-creating itself —
|
|
402
|
+
# a zero-human cap bypass (the same invariant _heal_or_escalate guards: "never auto-resets").
|
|
403
|
+
prior_heal = state["tasks"].get(slug, {}).get("heal") if args.force else None
|
|
1091
404
|
state["tasks"][slug] = {
|
|
1092
405
|
"title": title,
|
|
1093
406
|
"phase": "ground",
|
|
@@ -1097,6 +410,8 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
1097
410
|
"created": _now(),
|
|
1098
411
|
"updated": _now(),
|
|
1099
412
|
}
|
|
413
|
+
if prior_heal is not None:
|
|
414
|
+
state["tasks"][slug]["heal"] = prior_heal # monotonic — survives the --force re-create
|
|
1100
415
|
if from_delta:
|
|
1101
416
|
state["tasks"][slug]["from_delta"] = from_delta # lineage: seeded from <prior>
|
|
1102
417
|
if fast:
|
|
@@ -1107,6 +422,11 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
1107
422
|
if milestone:
|
|
1108
423
|
print(f"linked to milestone '{milestone}'" +
|
|
1109
424
|
(f", depends-on {depends_on}" if depends_on else ""))
|
|
425
|
+
elif fast:
|
|
426
|
+
# blessed milestone-free fast lane (standalone-fast-task): a --fast task with no owning
|
|
427
|
+
# milestone is a DELIBERATE low-ceremony lane, not an orphan to nag — AFFIRM it.
|
|
428
|
+
print(f"standalone fast task '{slug}' — milestone-free by design (low-ceremony lane); "
|
|
429
|
+
f"attach later with `add.py set-milestone {slug} --milestone <id>` if it grows")
|
|
1110
430
|
else:
|
|
1111
431
|
# warn-never-block: the task is created (escape hatch), but nudge back toward the
|
|
1112
432
|
# intake -> milestone flow. Speaks of STRUCTURE (not attached), never the act.
|
|
@@ -1145,22 +465,80 @@ def cmd_drop_delta(args: argparse.Namespace) -> None:
|
|
|
1145
465
|
print(_next_footer(root, state))
|
|
1146
466
|
|
|
1147
467
|
|
|
468
|
+
# a §3 still carrying this template placeholder is NOT a drafted contract yet
|
|
469
|
+
_CONTRACT_TEMPLATE_RE = re.compile(r"<METHOD>")
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _next_freeze_version(state: dict, slug: str) -> str:
|
|
473
|
+
"""v1 on the first freeze; N+1 of the highest prior freeze version recorded on the
|
|
474
|
+
task's state record on a re-freeze (after a change request). PURE — reads state only."""
|
|
475
|
+
prior = ((state.get("tasks") or {}).get(slug) or {}).get("freeze") or {}
|
|
476
|
+
m = re.fullmatch(r"v(\d+)", str(prior.get("version", "")))
|
|
477
|
+
return f"v{int(m.group(1)) + 1}" if m else "v1"
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def cmd_freeze(args: argparse.Namespace) -> None:
|
|
481
|
+
"""The §3 contract-freeze write command — the 5th engine-WRITTEN human approval (task
|
|
482
|
+
freeze-actor-stamp), joining lock · gate · milestone-done · release. Flips the target
|
|
483
|
+
task's §3 `Status: DRAFT` -> `FROZEN @ vN — approved by <name>` AND records a structured
|
|
484
|
+
actor on the task's state record (mirrors cmd_lock's `setup.actor`), so the audit trail
|
|
485
|
+
has no hole at freeze. The human RUNS it as their approval — never pre-stamped.
|
|
486
|
+
|
|
487
|
+
validate-then-write: every refusal fires before any write. Writes TASK.md first, then
|
|
488
|
+
state; a crash between degrades to today's legacy text-only freeze (never corrupt state),
|
|
489
|
+
design-for-failure."""
|
|
490
|
+
root = _require_root()
|
|
491
|
+
state = load_state(root)
|
|
492
|
+
raw_slug = getattr(args, "slug", None)
|
|
493
|
+
if not raw_slug and not _active_task(state):
|
|
494
|
+
_die("no_active_task: no task given and no active task is set")
|
|
495
|
+
slug = _resolve_task(state, raw_slug) # unknown slug -> _die
|
|
496
|
+
task_md = root / "tasks" / slug / "TASK.md"
|
|
497
|
+
text = task_md.read_text(encoding="utf-8")
|
|
498
|
+
raw3 = _phase_spans(text).get(3, "")
|
|
499
|
+
phase = (state["tasks"].get(slug) or {}).get("phase", "specify")
|
|
500
|
+
# --- validate (no writes); error precedence: frozen -> not-drafted -> unflagged ---
|
|
501
|
+
if _contract_frozen(raw3):
|
|
502
|
+
_die(f"already_frozen: {slug}'s §3 is already FROZEN — re-freeze only via a change "
|
|
503
|
+
f"request back to SPECIFY")
|
|
504
|
+
if _phase_index(phase) < _phase_index("contract") or _CONTRACT_TEMPLATE_RE.search(raw3):
|
|
505
|
+
_die(f"contract_not_drafted: {slug}'s §3 is not a drafted contract yet — reach the "
|
|
506
|
+
f"`contract` phase and replace the template before freezing")
|
|
507
|
+
if not _flag_well_formed(raw3):
|
|
508
|
+
_die(f"unflagged_freeze: {slug}'s §3 must surface a well-formed lowest-confidence flag "
|
|
509
|
+
f"('Least-sure flag surfaced at freeze:' + a [part] tag) before it freezes")
|
|
510
|
+
# --- write ---
|
|
511
|
+
ver = _next_freeze_version(state, slug)
|
|
512
|
+
who = args.by or identity._actor_stamp(state)["name"]
|
|
513
|
+
# flip the `Status: DRAFT` line WITHIN the §3 region only — a bare `Status: DRAFT` in
|
|
514
|
+
# §1/§2 prose must never be frozen by mistake (refute-read finding). §3 span runs from
|
|
515
|
+
# its `## 3 ·` heading to the next `## `/`---`/EOF (same boundary as _phase_spans).
|
|
516
|
+
h3 = re.search(r"(?m)^##\s*3\s*·.*$", text)
|
|
517
|
+
if not h3:
|
|
518
|
+
_die(f"contract_not_drafted: {slug}'s TASK.md has no §3 CONTRACT section")
|
|
519
|
+
seg_start = h3.end()
|
|
520
|
+
nxt = re.search(r"(?m)^(?:##\s|---\s*$)", text[seg_start:])
|
|
521
|
+
seg_end = seg_start + (nxt.start() if nxt else len(text) - seg_start)
|
|
522
|
+
new_seg, n = re.subn(r"(?m)^(\s*)Status:\s*DRAFT\s*$",
|
|
523
|
+
lambda m: f"{m.group(1)}Status: FROZEN @ {ver} — approved by {who}",
|
|
524
|
+
text[seg_start:seg_end], count=1)
|
|
525
|
+
if n == 0:
|
|
526
|
+
_die(f"contract_not_drafted: {slug}'s §3 has no 'Status: DRAFT' line to freeze")
|
|
527
|
+
new_text = text[:seg_start] + new_seg + text[seg_end:]
|
|
528
|
+
_atomic_write(task_md, new_text) # TASK.md first (audit source of truth)
|
|
529
|
+
state["tasks"][slug]["freeze"] = {"version": ver, "frozen_at": _now(),
|
|
530
|
+
"approved_by": who, "actor": identity._actor_stamp(state)}
|
|
531
|
+
save_state(root, state)
|
|
532
|
+
print(f"froze §3 of {slug} @ {ver} — approved by {who}")
|
|
533
|
+
print(_next_footer(root, state))
|
|
534
|
+
|
|
535
|
+
|
|
1148
536
|
def _parse_deps(raw: str | None) -> list[str]:
|
|
1149
537
|
if not raw:
|
|
1150
538
|
return []
|
|
1151
539
|
return [d.strip() for d in raw.split(",") if d.strip()]
|
|
1152
540
|
|
|
1153
541
|
|
|
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
542
|
def _archived_task_slugs(state: dict) -> set[str]:
|
|
1165
543
|
"""Slugs of tasks that left active state via archive — all were PASS-done at
|
|
1166
544
|
archive time, so a dep on one of them counts as satisfied (not dangling).
|
|
@@ -1184,16 +562,94 @@ def _resolve_task(state: dict, slug: str | None) -> str:
|
|
|
1184
562
|
return slug
|
|
1185
563
|
|
|
1186
564
|
|
|
565
|
+
def _build_entry(root: Path, state: dict, slug: str) -> None:
|
|
566
|
+
"""The shared tests->build entry guards + snapshots (task phase-build-guard, F4).
|
|
567
|
+
|
|
568
|
+
Extracted VERBATIM from cmd_advance's `nxt == "build"` block so BOTH `advance` and the
|
|
569
|
+
`phase build` admin override run the identical gate stack — the freeze gate, the
|
|
570
|
+
build-expectations gate, the unflagged-freeze check + flag stamp, the tamper tripwire, and
|
|
571
|
+
the §5 scope snapshot. validate-then-write: every `_die` precedes the first state mutation,
|
|
572
|
+
so a refused entry leaves the task byte-unchanged. The heal loop sets phase=build DIRECTLY
|
|
573
|
+
and never routes here, so it stays exempt.
|
|
574
|
+
"""
|
|
575
|
+
# the OPTED-IN crossing guards (fast-lane + flow-enforcement): a task whose PARENT
|
|
576
|
+
# milestone opted into --await-confirm is held to the trust floor at tests->build. A
|
|
577
|
+
# task under a plain / no milestone is never gated (every existing advance-to-build flow
|
|
578
|
+
# stays green). validate-then-write — every refusal runs BEFORE the tripwire/scope
|
|
579
|
+
# snapshots below, writing nothing; the task stays at `tests`.
|
|
580
|
+
_ms = state["tasks"][slug].get("milestone")
|
|
581
|
+
_optin = bool(_ms) and (state.get("milestones") or {}).get(_ms, {}).get("await_confirm") is True
|
|
582
|
+
raw3 = _raw_phase_bodies(root, slug).get(3, "")
|
|
583
|
+
# freeze-before-build gate (fast-lane): "collapse-never-skip" made REAL — the freeze is
|
|
584
|
+
# engine-enforced for an opted-in milestone OR any --fast task (the fast arm, fast-new-task-flag:
|
|
585
|
+
# a fast task is held to the floor under ANY milestone, so the lighter lane never drops the
|
|
586
|
+
# trust seam). PRECEDES build-expectations: you freeze §3 before pre-declaring §6's outcomes.
|
|
587
|
+
_freeze_gated = _optin or state["tasks"][slug].get("fast") is True
|
|
588
|
+
if _freeze_gated and not _contract_frozen(raw3):
|
|
589
|
+
_die("contract_not_frozen: freeze §3 before crossing into build — approve "
|
|
590
|
+
f"the contract in {slug}'s TASK.md (Status: FROZEN @ vN)")
|
|
591
|
+
# build-expectations gate (flow-enforcement): an opted-in task may not enter build until
|
|
592
|
+
# its §6 `### Build expectations` are pre-declared — so verify checks the build is RIGHT,
|
|
593
|
+
# not just green. Same opt-in switch as the contract-fill gate, one level out.
|
|
594
|
+
if _optin:
|
|
595
|
+
if _section_unfilled(_raw_phase_bodies(root, slug).get(6, ""), "### Build expectations"):
|
|
596
|
+
_die("build_expectations_unfilled: fill the §6 '### Build expectations' block "
|
|
597
|
+
f"of {slug}'s TASK.md before crossing into build")
|
|
598
|
+
if _contract_frozen(raw3):
|
|
599
|
+
if not _flag_well_formed(raw3):
|
|
600
|
+
_die("unflagged_freeze: a frozen §3 must surface a well-formed "
|
|
601
|
+
"'Least-sure flag surfaced at freeze:' unit (>=1 [part] tag "
|
|
602
|
+
"+ substantive content; bare 'none' only as 'none material — "
|
|
603
|
+
"biggest risk: X') before crossing into build")
|
|
604
|
+
state["tasks"][slug]["flag_verified"] = True
|
|
605
|
+
# tamper tripwire (verify-integrity): snapshot the red test files + the frozen
|
|
606
|
+
# §3 md5s so the verify gate can prove the green was EARNED, not edited into
|
|
607
|
+
# place. UNCONDITIONAL overwrite — a legit change-request that re-crosses
|
|
608
|
+
# tests->build re-snapshots cleanly. Co-witnessed by flag_verified (above).
|
|
609
|
+
state["tasks"][slug]["tripwire"] = _tripwire_snapshot(root, slug, raw3)
|
|
610
|
+
# §5 scope gate (build-scope-lock): when the task declares its Scope, freeze
|
|
611
|
+
# the project tree into a sidecar (payload) + a state.json anchor (md5 of the
|
|
612
|
+
# sidecar bytes). Same UNCONDITIONAL-overwrite semantics as the tripwire.
|
|
613
|
+
# UNDECLARED (no Scope line) takes no snapshot — grandfathered, never retro-red
|
|
614
|
+
# — and CLEANS UP a previous declaration's leftovers (v3): a declared->
|
|
615
|
+
# undeclared re-cross pops the stale anchor + unlinks the stale sidecar, so
|
|
616
|
+
# "UNDECLARED is never refused" holds on every path.
|
|
617
|
+
declared = _declared_scope(root, slug)
|
|
618
|
+
side = root / "tasks" / slug / "scope-snapshot.json"
|
|
619
|
+
if declared is not None:
|
|
620
|
+
payload = json.dumps({"version": 1,
|
|
621
|
+
"files": _scope_walk(root.parent.resolve())},
|
|
622
|
+
sort_keys=True)
|
|
623
|
+
_atomic_write(side, payload) # temp+replace, like save_state — a crash can't leave a
|
|
624
|
+
# torn sidecar (payload verbatim, no newline → md5 anchor holds)
|
|
625
|
+
state["tasks"][slug]["scope"] = {"declared": declared,
|
|
626
|
+
"snapshot_md5": _md5_text(payload)}
|
|
627
|
+
else:
|
|
628
|
+
state["tasks"][slug].pop("scope", None)
|
|
629
|
+
try:
|
|
630
|
+
side.unlink()
|
|
631
|
+
except OSError:
|
|
632
|
+
pass
|
|
633
|
+
|
|
634
|
+
|
|
1187
635
|
def cmd_phase(args: argparse.Namespace) -> None:
|
|
1188
636
|
root = _require_root()
|
|
1189
637
|
state = load_state(root)
|
|
1190
638
|
slug = _resolve_task(state, args.slug)
|
|
1191
639
|
if args.phase not in PHASES:
|
|
1192
640
|
_die(f"phase must be one of: {', '.join(PHASES)}")
|
|
641
|
+
# phase-build-guard (F4): the admin override is NOT a backdoor around the tests->build gate
|
|
642
|
+
# stack — setting a task to build runs the SAME _build_entry guards `advance` runs (freeze
|
|
643
|
+
# gate · build-expectations · flag check · tamper tripwire · scope snapshot), so verify's
|
|
644
|
+
# _tamper_guard is armed and a freeze-gated DRAFT §3 is refused. Other targets are unchanged.
|
|
645
|
+
# validate-then-write: a refusal raises BEFORE the phase is set, so nothing moves. The heal
|
|
646
|
+
# loop sets phase=build directly (never via cmd_phase) and so stays exempt.
|
|
647
|
+
if args.phase == "build":
|
|
648
|
+
_build_entry(root, state, slug)
|
|
1193
649
|
state["tasks"][slug]["phase"] = args.phase
|
|
1194
650
|
state["tasks"][slug]["updated"] = _now()
|
|
1195
|
-
|
|
1196
|
-
|
|
651
|
+
save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
|
|
652
|
+
_sync_task_marker(root, slug, args.phase) # then mirror into TASK.md (best-effort) — no split-brain
|
|
1197
653
|
print(f"task '{slug}' phase -> {args.phase}")
|
|
1198
654
|
print(_next_footer(root, state))
|
|
1199
655
|
|
|
@@ -1230,63 +686,10 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
1230
686
|
# freezes — the unmarked predecessors are never retro-redded). REFUSE writes
|
|
1231
687
|
# nothing (fail-closed); below the build boundary the flag is never checked.
|
|
1232
688
|
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
|
|
689
|
+
# the tests->build entry guards + snapshots now live in the shared _build_entry helper
|
|
690
|
+
# (task phase-build-guard, F4) so `advance` and the `phase build` admin override run the
|
|
691
|
+
# IDENTICAL gate stack. Behavior here is byte-unchanged — this is a pure extraction.
|
|
692
|
+
_build_entry(root, state, slug)
|
|
1290
693
|
# cross-component contract artifact (cross-component-contract): the contract->tests crossing
|
|
1291
694
|
# is the producer's freeze-approval moment. A `produces:` task WRITES the immutable snapshot;
|
|
1292
695
|
# a `consumes:` task PINS the live hash — a missing/unreadable snapshot HARD-STOPS here (the
|
|
@@ -1327,8 +730,8 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
1327
730
|
state["tasks"][slug]["contract_pin"] = {"id": _cons, "hash": pinned}
|
|
1328
731
|
state["tasks"][slug]["phase"] = nxt
|
|
1329
732
|
state["tasks"][slug]["updated"] = _now()
|
|
1330
|
-
|
|
1331
|
-
|
|
733
|
+
save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
|
|
734
|
+
_sync_task_marker(root, slug, nxt) # then mirror into TASK.md (best-effort) — no split-brain
|
|
1332
735
|
print(f"task '{slug}' phase {cur} -> {nxt}")
|
|
1333
736
|
if nxt == "observe":
|
|
1334
737
|
# OBSERVE is where this loop's lessons get captured (TASK.md §7) — suggest routing
|
|
@@ -1356,12 +759,10 @@ _RISK_HIGH_RE = re.compile(r"(?:^|·)[ \t]*risk:[ \t]*high\b", re.MULTILINE)
|
|
|
1356
759
|
|
|
1357
760
|
# the explicit 3-mode autonomy dial (task explicit-autonomy-dial): an ordered ladder
|
|
1358
761
|
# manual < conservative < auto, declared as a per-task `autonomy:` header token.
|
|
1359
|
-
_AUTONOMY_LEVELS = ("manual", "conservative", "auto")
|
|
1360
762
|
# anchored to a DECLARATION position — line-start `autonomy:` OR the inline slug-line form
|
|
1361
763
|
# `… · autonomy: conservative` (the `·`-preceded shape) — never a title/prose substring; the
|
|
1362
764
|
# value stops at space/`<`/`#`/`|` so an unfilled `<manual | … >` placeholder captures nothing
|
|
1363
765
|
# and reads as UNSET.
|
|
1364
|
-
_AUTONOMY_LINE_RE = re.compile(r"(?:^|·)[ \t]*autonomy:[ \t]*([^\s<#|]+)", re.MULTILINE)
|
|
1365
766
|
|
|
1366
767
|
# component-aware-add: a task binds to a registered component via a `component: <name>`
|
|
1367
768
|
# header token — the SAME anchored grammar as autonomy (line-start or the `·`-inline
|
|
@@ -1372,45 +773,12 @@ _PRODUCES_LINE_RE = re.compile(r"(?:^|·)[ \t]*produces:[ \t]*([^\s<#|]+)", re.M
|
|
|
1372
773
|
_CONSUMES_LINE_RE = re.compile(r"(?:^|·)[ \t]*consumes:[ \t]*([^\s<#|]+)", re.MULTILINE)
|
|
1373
774
|
|
|
1374
775
|
|
|
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
776
|
def _autonomy_lowered(hdr: str) -> bool:
|
|
1389
777
|
"""True iff the declared rung is high-risk-safe (manual or conservative). A
|
|
1390
778
|
high-risk scope must be lowered to one of these; `auto` and UNSET are not."""
|
|
1391
779
|
return _autonomy_level(hdr) in ("manual", "conservative")
|
|
1392
780
|
|
|
1393
781
|
|
|
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
782
|
def _driver_stop(root: Path, state: dict, slug: str, phase: str) -> bool:
|
|
1415
783
|
"""True iff a HUMAN owns the next step for `phase` under the effective autonomy — the
|
|
1416
784
|
SINGLE source the footer marker and the guide TEXT marker both render (task
|
|
@@ -1469,6 +837,10 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
1469
837
|
# §5 scope gate (build-scope-lock): touched ⊆ declared, or a named refusal —
|
|
1470
838
|
# same placement discipline as the tripwire (before the waiver, never on HARD-STOP).
|
|
1471
839
|
_scope_guard(root, state, slug)
|
|
840
|
+
# consumer-stale gate (consumer-stale-gate): a `consumes:` task whose pinned producer
|
|
841
|
+
# contract hash has drifted from the live snapshot built against an out-of-date shape —
|
|
842
|
+
# refuse the completing outcome (same before-the-waiver discipline). Re-pin to recover.
|
|
843
|
+
_consumer_stale_guard(root, state, slug)
|
|
1472
844
|
# per-component verify (component-aware-add): a component-bound task with a declared
|
|
1473
845
|
# green_bar must CITE that bar in its §6 evidence before a completing outcome — the
|
|
1474
846
|
# engine never runs the suite, it checks the right bar was recorded. Unbound / no
|
|
@@ -1493,11 +865,12 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
1493
865
|
}
|
|
1494
866
|
if completing:
|
|
1495
867
|
state["tasks"][slug]["phase"] = "done"
|
|
1496
|
-
_sync_task_marker(root, slug, "done")
|
|
1497
868
|
state["tasks"][slug]["gate"] = args.outcome
|
|
1498
|
-
state["tasks"][slug]["gate_actor"] = _actor_stamp(state) # WHO recorded the verdict (every outcome)
|
|
869
|
+
state["tasks"][slug]["gate_actor"] = identity._actor_stamp(state) # WHO recorded the verdict (every outcome)
|
|
1499
870
|
state["tasks"][slug]["updated"] = _now()
|
|
1500
|
-
save_state(root, state)
|
|
871
|
+
save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
|
|
872
|
+
if completing:
|
|
873
|
+
_sync_task_marker(root, slug, "done") # then mirror the phase into TASK.md — no split-brain
|
|
1501
874
|
_stamp_gate_record(root, state, slug, args.outcome) # mirror the verdict into §6 (Finding C)
|
|
1502
875
|
print(f"task '{slug}' gate -> {args.outcome}")
|
|
1503
876
|
_gbar = _task_green_bar(root, slug) # per-component-verify: surface the bound bar
|
|
@@ -1582,6 +955,45 @@ def cmd_autonomy(args: argparse.Namespace) -> None:
|
|
|
1582
955
|
_print_autonomy(root, state, slug)
|
|
1583
956
|
|
|
1584
957
|
|
|
958
|
+
def cmd_todo(args: argparse.Namespace) -> None:
|
|
959
|
+
"""Capture / list / close a lightweight backlog todo (task: todo-capture).
|
|
960
|
+
|
|
961
|
+
A todo is a JOTTED IDEA, not a task — it carries no spec/contract/gate. It lets you
|
|
962
|
+
record an intent without sizing it. Promote one to a real task with
|
|
963
|
+
`add.py new-task <slug> --fast` when you decide to build it. Stored in state["todos"]
|
|
964
|
+
as {id (1-based = max+1), text, created, status:"open"|"done"}.
|
|
965
|
+
"""
|
|
966
|
+
root = _require_root() # reused -> "no .add/ project found …"
|
|
967
|
+
state = load_state(root)
|
|
968
|
+
todos = state.get("todos")
|
|
969
|
+
if not isinstance(todos, list): # absent / corrupt -> fresh list (drift-safe)
|
|
970
|
+
todos = state["todos"] = []
|
|
971
|
+
done_id = getattr(args, "done", None)
|
|
972
|
+
if done_id is not None: # --done <id> : close an OPEN todo
|
|
973
|
+
for t in todos:
|
|
974
|
+
if isinstance(t, dict) and t.get("id") == done_id and t.get("status") == "open":
|
|
975
|
+
t["status"] = "done"
|
|
976
|
+
save_state(root, state)
|
|
977
|
+
print(f"todo #{done_id} done")
|
|
978
|
+
return
|
|
979
|
+
_die(f"todo_unknown: no open todo #{done_id}")
|
|
980
|
+
if args.text is not None: # capture attempt (text positional present)
|
|
981
|
+
text = args.text.strip()
|
|
982
|
+
if not text:
|
|
983
|
+
_die("todo_empty: a todo needs text")
|
|
984
|
+
new_id = max((t.get("id", 0) for t in todos if isinstance(t, dict)), default=0) + 1
|
|
985
|
+
todos.append({"id": new_id, "text": text, "created": _now(), "status": "open"})
|
|
986
|
+
save_state(root, state)
|
|
987
|
+
print(f"captured todo #{new_id}: {text}")
|
|
988
|
+
return
|
|
989
|
+
open_todos = [t for t in todos if isinstance(t, dict) and t.get("status") == "open"]
|
|
990
|
+
if not open_todos: # bare `todo` -> list OPEN todos
|
|
991
|
+
print("no open todos")
|
|
992
|
+
return
|
|
993
|
+
for t in open_todos:
|
|
994
|
+
print(f"#{t.get('id')} {t.get('text')}")
|
|
995
|
+
|
|
996
|
+
|
|
1585
997
|
def cmd_reopen(args: argparse.Namespace) -> None:
|
|
1586
998
|
"""Return an already-`done` task to an earlier phase with a never-silent record.
|
|
1587
999
|
|
|
@@ -1616,8 +1028,8 @@ def cmd_reopen(args: argparse.Namespace) -> None:
|
|
|
1616
1028
|
t["phase"] = target
|
|
1617
1029
|
t["gate"] = "none"
|
|
1618
1030
|
t["updated"] = now
|
|
1619
|
-
|
|
1620
|
-
|
|
1031
|
+
save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
|
|
1032
|
+
_sync_task_marker(root, slug, target) # then mirror into TASK.md (best-effort) — no split-brain
|
|
1621
1033
|
print(f"task '{slug}' reopened: done -> {target} (reason recorded); gate reset to none")
|
|
1622
1034
|
print(_next_footer(root, state))
|
|
1623
1035
|
|
|
@@ -1668,7 +1080,7 @@ def cmd_lock(args: argparse.Namespace) -> None:
|
|
|
1668
1080
|
when = _now()
|
|
1669
1081
|
# ONE atomic write — no partial lock state.
|
|
1670
1082
|
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
|
|
1083
|
+
"actor": identity._actor_stamp(state)} # structured actor alongside the free-text locked_by
|
|
1672
1084
|
save_state(root, state)
|
|
1673
1085
|
if getattr(args, "json", False):
|
|
1674
1086
|
print(json.dumps(
|
|
@@ -1695,7 +1107,7 @@ def cmd_whoami(args: argparse.Namespace) -> None:
|
|
|
1695
1107
|
_die("actor_name_blank")
|
|
1696
1108
|
state["actor_override"] = {"name": args.name, "email": args.email or None}
|
|
1697
1109
|
save_state(root, state)
|
|
1698
|
-
who = _whoami(state)
|
|
1110
|
+
who = identity._whoami(state)
|
|
1699
1111
|
if getattr(args, "json", False):
|
|
1700
1112
|
print(json.dumps(who, separators=(",", ":")))
|
|
1701
1113
|
return
|
|
@@ -1724,14 +1136,14 @@ def cmd_assign(args: argparse.Namespace) -> None:
|
|
|
1724
1136
|
_die("unknown_slug")
|
|
1725
1137
|
# parse + validate ALL flags BEFORE the first write — a blank name is rejected on the
|
|
1726
1138
|
# 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
|
|
1139
|
+
parsed_owner = identity._parse_actor_arg(args.owner) if args.owner is not None else None
|
|
1140
|
+
parsed_assignee = identity._parse_actor_arg(args.assignee) if args.assignee is not None else None
|
|
1729
1141
|
if parsed_owner is not None and not parsed_owner["name"].strip():
|
|
1730
1142
|
_die("owner_name_blank")
|
|
1731
1143
|
if parsed_assignee is not None and not parsed_assignee["name"].strip():
|
|
1732
1144
|
_die("assignee_name_blank")
|
|
1733
1145
|
if parsed_owner is None and parsed_assignee is None:
|
|
1734
|
-
who = _whoami(state)
|
|
1146
|
+
who = identity._whoami(state)
|
|
1735
1147
|
rec["owner"] = dict(who)
|
|
1736
1148
|
rec["assignee"] = dict(who)
|
|
1737
1149
|
else:
|
|
@@ -1765,15 +1177,6 @@ def cmd_unassign(args: argparse.Namespace) -> None:
|
|
|
1765
1177
|
print(f"unassigned {args.slug} ({', '.join(roles)})")
|
|
1766
1178
|
|
|
1767
1179
|
|
|
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
1180
|
def cmd_stage(args: argparse.Namespace) -> None:
|
|
1778
1181
|
root = _require_root()
|
|
1779
1182
|
state = load_state(root)
|
|
@@ -1846,7 +1249,7 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1846
1249
|
grad_ready, grad_met, grad_total = _graduation_ready(root, state)
|
|
1847
1250
|
print(json.dumps({
|
|
1848
1251
|
"project": state.get("project"), "stage": state.get("stage"),
|
|
1849
|
-
"actor": _whoami(state),
|
|
1252
|
+
"actor": identity._whoami(state),
|
|
1850
1253
|
"active_task": _active_task(state),
|
|
1851
1254
|
"active_milestones": list(state.get("active_milestones") or []),
|
|
1852
1255
|
"active_tasks": dict(state.get("active_tasks") or {}),
|
|
@@ -1871,7 +1274,7 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1871
1274
|
print(f"project autonomy: {_project_autonomy(root)} (default — new tasks inherit)")
|
|
1872
1275
|
# git-native actor (user-identity): who ADD sees you as this session — the identity every
|
|
1873
1276
|
# human-owned stamp records. Always present (the resolver is TOTAL). Read-only, no write.
|
|
1874
|
-
_who = _whoami(state)
|
|
1277
|
+
_who = identity._whoami(state)
|
|
1875
1278
|
_who_email = f" <{_who['email']}>" if _who.get("email") else ""
|
|
1876
1279
|
print(f"actor : {_who['name']}{_who_email} (source: {_who['source']})")
|
|
1877
1280
|
print(f"stage : {state.get('stage', '(unknown)')}")
|
|
@@ -1938,6 +1341,12 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1938
1341
|
_rel = _releasable(root, state)
|
|
1939
1342
|
if _rel:
|
|
1940
1343
|
print(f" → {RELEASABLE_CUE.format(n=len(_rel))}")
|
|
1344
|
+
# loose-task release cue (loose-task-release): a SEPARATE additive line — done milestone-free
|
|
1345
|
+
# tasks not yet attributed to a RELEASES.md `loose tasks:` row. Peer to the milestone cue (its
|
|
1346
|
+
# constant is untouched); fires even with zero releasable milestones. Fail-open ledger read.
|
|
1347
|
+
_loose = _releasable_loose_tasks(root, state)
|
|
1348
|
+
if _loose:
|
|
1349
|
+
print(f" → releasable: {len(_loose)} loose task(s) since last release")
|
|
1941
1350
|
|
|
1942
1351
|
# fast-lane marker (fast-new-task-flag): tag an ACTIVE fast task so the lane is visible at a
|
|
1943
1352
|
# glance. Presentation-only, existence-gated — a plain/absent active task is byte-unchanged.
|
|
@@ -1966,6 +1375,14 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1966
1375
|
_so = _fmt_actor(_owner_rec) if _owner_rec.get("name") else ""
|
|
1967
1376
|
_own_frag = f" · owner: {_so}" if _so else ""
|
|
1968
1377
|
print(f" {_mk} {_m:<20} task={_tk or '(none)'} phase={_ph}{_tag}{_own_frag}")
|
|
1378
|
+
# queued backlog (queue-resume-surface): surface milestones awaiting promotion so a
|
|
1379
|
+
# multi-milestone session resumes cleanly — `active` is what you're on, `queued` is what's
|
|
1380
|
+
# next. ADDITIVE + present-only: silent when zero queued (byte-identical), exactly like the
|
|
1381
|
+
# release/loose/streams cues; reads state, writes nothing, changes no command decision.
|
|
1382
|
+
_queued = [ms for ms, m in milestones.items() if m.get("status") == "queued"]
|
|
1383
|
+
if _queued:
|
|
1384
|
+
print(f"queued : {len(_queued)} milestone(s) next — {', '.join(_queued)}")
|
|
1385
|
+
print(f" promote next: add.py activate {_queued[0]}")
|
|
1969
1386
|
# surface the active task's autonomy level (task explicit-autonomy-dial) so the human
|
|
1970
1387
|
# reads the throttle every session; "unset" when no explicit `autonomy:` line is present.
|
|
1971
1388
|
if active and active in tasks:
|
|
@@ -2624,6 +2041,7 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2624
2041
|
milestones = state.get("milestones") if isinstance(state.get("milestones"), dict) else {}
|
|
2625
2042
|
archived_slugs = _archived_task_slugs(state) # archived deps still resolve
|
|
2626
2043
|
warnings: list[tuple[str, str]] = [] # (name, reason) — nudges that NEVER feed `failed`
|
|
2044
|
+
infos: list[tuple[str, str]] = [] # (name, reason) — affirmations; NEVER feed `warned`/`failed`
|
|
2627
2045
|
for slug, t in tasks.items():
|
|
2628
2046
|
task_md = root / "tasks" / slug / "TASK.md"
|
|
2629
2047
|
checks.append((task_md.exists(), f"task '{slug}' has TASK.md", "file missing"))
|
|
@@ -2635,6 +2053,10 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2635
2053
|
if ms is not None:
|
|
2636
2054
|
checks.append((ms in milestones, f"task '{slug}' milestone resolves",
|
|
2637
2055
|
f"unknown milestone {ms!r}"))
|
|
2056
|
+
elif t.get("fast"):
|
|
2057
|
+
# blessed milestone-free fast lane (standalone-fast-task): a --fast task with no
|
|
2058
|
+
# milestone is DELIBERATE — a soft INFO affirmation, never a WARN/orphan nudge.
|
|
2059
|
+
infos.append((f"task '{slug}'", "— standalone fast lane (milestone-free by design)"))
|
|
2638
2060
|
else:
|
|
2639
2061
|
# warn-never-block: a task outside a milestone is a structural nudge back toward
|
|
2640
2062
|
# the intake flow — NOT a failure. Names structure, never the act of intake.
|
|
@@ -2824,10 +2246,15 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2824
2246
|
passed = sum(1 for ok, _, _ in checks if ok)
|
|
2825
2247
|
failed = len(checks) - passed
|
|
2826
2248
|
if as_json:
|
|
2249
|
+
# `infos`/`informed` are ADDITIVE (standalone-fast-task) — affirmations that never feed
|
|
2250
|
+
# `warned`/`failed`; existing keys are untouched so prior consumers keep working.
|
|
2827
2251
|
print(json.dumps({"passed": passed, "failed": failed,
|
|
2828
2252
|
"warned": len(warnings),
|
|
2829
2253
|
"warnings": [{"name": name, "reason": reason}
|
|
2830
2254
|
for name, reason in warnings],
|
|
2255
|
+
"informed": len(infos),
|
|
2256
|
+
"infos": [{"name": name, "reason": reason}
|
|
2257
|
+
for name, reason in infos],
|
|
2831
2258
|
"checks": [{"ok": ok, "name": desc,
|
|
2832
2259
|
"reason": reason if not ok else ""}
|
|
2833
2260
|
for ok, desc, reason in checks]}))
|
|
@@ -2836,6 +2263,8 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
2836
2263
|
print(f"PASS {desc}" if ok else f"FAIL {desc}: {reason}")
|
|
2837
2264
|
for name, reason in warnings:
|
|
2838
2265
|
print(f"WARN {name} {reason}")
|
|
2266
|
+
for name, reason in infos:
|
|
2267
|
+
print(f"INFO {name} {reason}")
|
|
2839
2268
|
summary = f"check: {passed} passed, {failed} failed"
|
|
2840
2269
|
if warnings:
|
|
2841
2270
|
summary += f" ({len(warnings)} warnings)" # frozen §3: summary gains "(N warnings)"
|
|
@@ -2890,6 +2319,39 @@ def _doctor_findings(root: Path) -> list[str]:
|
|
|
2890
2319
|
if m is not None and m not in milestones:
|
|
2891
2320
|
findings.append(f"task '{slug}' references missing milestone '{m}' — fix: set its "
|
|
2892
2321
|
"milestone to a real one (or none)")
|
|
2322
|
+
# value-domain checks (doctor-value-checks): gate/phase enum (required) · owner/assignee
|
|
2323
|
+
# shape (optional) · archived consistency. Present-but-invalid + missing-required only;
|
|
2324
|
+
# absent owner/assignee is fine. Pure/total — .get-guarded, isinstance-checked, never raises.
|
|
2325
|
+
for slug, t in tasks.items():
|
|
2326
|
+
if not isinstance(t, dict):
|
|
2327
|
+
continue
|
|
2328
|
+
g = t.get("gate")
|
|
2329
|
+
if g is None:
|
|
2330
|
+
findings.append(f"task '{slug}' is missing its gate — fix: one of {', '.join(GATES)}")
|
|
2331
|
+
elif g not in GATES:
|
|
2332
|
+
findings.append(f"task '{slug}' has invalid gate '{g}' — fix: one of {', '.join(GATES)}")
|
|
2333
|
+
p = t.get("phase")
|
|
2334
|
+
if p is None:
|
|
2335
|
+
findings.append(f"task '{slug}' is missing its phase — fix: one of {', '.join(PHASES)}")
|
|
2336
|
+
elif p not in PHASES:
|
|
2337
|
+
findings.append(f"task '{slug}' has invalid phase '{p}' — fix: one of {', '.join(PHASES)}")
|
|
2338
|
+
for role in ("owner", "assignee"):
|
|
2339
|
+
v = t.get(role)
|
|
2340
|
+
if v is not None and not (isinstance(v, dict) and isinstance(v.get("name"), str) and v.get("name")):
|
|
2341
|
+
findings.append(f"task '{slug}' has a malformed {role} — fix: an actor object "
|
|
2342
|
+
"{name, email, source} or remove it")
|
|
2343
|
+
archived = state.get("archived") if isinstance(state.get("archived"), list) else []
|
|
2344
|
+
for a in archived:
|
|
2345
|
+
if not isinstance(a, dict):
|
|
2346
|
+
continue
|
|
2347
|
+
aslug = a.get("slug")
|
|
2348
|
+
if aslug is not None and aslug in milestones:
|
|
2349
|
+
findings.append(f"archived milestone '{aslug}' is also a live milestone — fix: remove "
|
|
2350
|
+
"the live duplicate or the archived entry")
|
|
2351
|
+
ts = a.get("task_slugs")
|
|
2352
|
+
if isinstance(ts, list) and isinstance(a.get("tasks"), int) and a.get("tasks") != len(ts):
|
|
2353
|
+
findings.append(f"archived milestone '{aslug}' task count {a.get('tasks')} ≠ {len(ts)} "
|
|
2354
|
+
"listed — fix: reconcile its task_slugs")
|
|
2893
2355
|
return findings
|
|
2894
2356
|
|
|
2895
2357
|
|
|
@@ -2910,25 +2372,29 @@ def cmd_doctor(args: argparse.Namespace) -> None:
|
|
|
2910
2372
|
|
|
2911
2373
|
|
|
2912
2374
|
def cmd_mine(args: argparse.Namespace) -> None:
|
|
2913
|
-
"""Read-only `add.py mine`:
|
|
2914
|
-
|
|
2915
|
-
|
|
2375
|
+
"""Read-only `add.py mine`: the not-done tasks owned-by or assigned-to the resolved actor
|
|
2376
|
+
(`_whoami`, or `--actor "Name <email>"`). Default lens is the active SET; `--all` widens it
|
|
2377
|
+
to EVERY milestone plus loose (milestone-less) tasks. Text or `--json`. An empty queue is a
|
|
2378
|
+
plain exit-0 line, not an error. NEVER writes state."""
|
|
2916
2379
|
root = find_root()
|
|
2917
2380
|
if root is None:
|
|
2918
2381
|
_die("no_project")
|
|
2919
2382
|
state = load_state(root)
|
|
2920
|
-
me = _parse_actor_arg(args.actor) if getattr(args, "actor", None) else _whoami(state)
|
|
2921
|
-
|
|
2383
|
+
me = identity._parse_actor_arg(args.actor) if getattr(args, "actor", None) else identity._whoami(state)
|
|
2384
|
+
scope_all = getattr(args, "all", False)
|
|
2385
|
+
rows = _my_work(state, me, scope_all=scope_all)
|
|
2922
2386
|
if getattr(args, "json", False):
|
|
2923
2387
|
print(json.dumps({"actor": me, "tasks": rows}))
|
|
2924
2388
|
return
|
|
2389
|
+
scope = "all" if scope_all else "active"
|
|
2925
2390
|
who = _fmt_actor(me) or me.get("name", "you")
|
|
2926
2391
|
if not rows:
|
|
2927
|
-
print(f"mine: no open tasks for {who} across
|
|
2392
|
+
print(f"mine: no open tasks for {who} across {scope} milestones")
|
|
2928
2393
|
return
|
|
2929
|
-
print(f"mine: {who} — {len(rows)} open task(s) across
|
|
2394
|
+
print(f"mine: {who} — {len(rows)} open task(s) across {scope} milestones:")
|
|
2930
2395
|
for r in rows:
|
|
2931
|
-
|
|
2396
|
+
loc = f"[{r['milestone']}]" if r["milestone"] else "[loose]"
|
|
2397
|
+
print(f" {r['slug']:<24} {loc} phase={r['phase']} ({r['role']})")
|
|
2932
2398
|
|
|
2933
2399
|
|
|
2934
2400
|
# ---------------------------------------------------------------------------
|
|
@@ -3075,6 +2541,12 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
|
|
|
3075
2541
|
slug = args.slug
|
|
3076
2542
|
if not slug.replace("-", "").replace("_", "").isalnum():
|
|
3077
2543
|
_die("bad_slug")
|
|
2544
|
+
# Prefer a short DESCRIPTIVE slug over a bare version (v2, v1-1, 1.2): a descriptive
|
|
2545
|
+
# name keeps the milestones list legible. Advisory only — never blocks (matches the
|
|
2546
|
+
# engine's `note:` convention); a deliberate version slug still creates.
|
|
2547
|
+
if re.match(r"^v?\d+([._-]\d+)*$", slug, re.IGNORECASE):
|
|
2548
|
+
print(f"note: slug '{slug}' looks like a bare version — prefer a short "
|
|
2549
|
+
f"descriptive name (e.g. 'payment-retries'). Creating anyway.")
|
|
3078
2550
|
state.setdefault("milestones", {})
|
|
3079
2551
|
mdir = root / "milestones" / slug
|
|
3080
2552
|
mfile = mdir / MILESTONE_FILE
|
|
@@ -3082,17 +2554,24 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
|
|
|
3082
2554
|
_die("milestone_exists")
|
|
3083
2555
|
mdir.mkdir(parents=True, exist_ok=True)
|
|
3084
2556
|
title = args.title or slug.replace("-", " ").replace("_", " ").title()
|
|
2557
|
+
# One _now() instant feeds BOTH the MILESTONE.md render and the state record, so the
|
|
2558
|
+
# human-facing `created:` is a full ISO timestamp provably equal to state.json.
|
|
2559
|
+
now = _now()
|
|
3085
2560
|
_atomic_write(mfile, _render_template(
|
|
3086
2561
|
"MILESTONE.md", title=title, goal=args.goal or "<goal>",
|
|
3087
|
-
stage=args.stage, date=
|
|
2562
|
+
stage=args.stage, date=now))
|
|
3088
2563
|
# confirm-parent gate (OPT-IN, mirrors `init --await-lock`): `--await-confirm` seeds the
|
|
3089
2564
|
# milestone UNCONFIRMED so new-task is held until `add.py milestone-confirm`. WITHOUT the flag
|
|
3090
2565
|
# NO `confirmed` key is written → grandfathered-confirmed → no gate (so the existing engine
|
|
3091
2566
|
# tests stay byte-green). The guided skill flow passes the flag at the human-review point.
|
|
3092
2567
|
await_confirm = bool(getattr(args, "await_confirm", False))
|
|
2568
|
+
# --queued (OPT-IN): create the milestone non-active (status=queued) without stealing focus.
|
|
2569
|
+
# The active set is left UNCHANGED so the default path (no flag) stays byte-identical. Promote
|
|
2570
|
+
# later with `activate` (queued→active). Foundation for roadmap intake (1 active + N queued).
|
|
2571
|
+
queued = bool(getattr(args, "queued", False))
|
|
3093
2572
|
record = {
|
|
3094
2573
|
"title": title, "goal": args.goal or "", "stage": args.stage,
|
|
3095
|
-
"status": "active", "created":
|
|
2574
|
+
"status": "queued" if queued else "active", "created": now, "updated": now,
|
|
3096
2575
|
}
|
|
3097
2576
|
if await_confirm:
|
|
3098
2577
|
# `await_confirm` is the STABLE opt-in marker (set ONLY here, at creation). `confirmed`
|
|
@@ -3100,11 +2579,22 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
|
|
|
3100
2579
|
# milestone too, so a later build-entry gate must key on `await_confirm`, not `confirmed`.
|
|
3101
2580
|
record.update(confirmed=False, confirmed_at=None, confirmed_by=None, await_confirm=True)
|
|
3102
2581
|
state["milestones"][slug] = record
|
|
3103
|
-
|
|
2582
|
+
if not queued:
|
|
2583
|
+
# PRESERVE the active SET (new-milestone-add-focus): ADD this milestone + focus it, rather
|
|
2584
|
+
# than REPLACING the set and evicting the others. Single-active is identical ([] -> [slug]);
|
|
2585
|
+
# a user who already had P active now keeps P active alongside the new primary.
|
|
2586
|
+
_activate_milestone(state, slug)
|
|
3104
2587
|
save_state(root, state)
|
|
3105
2588
|
print(f"created milestone '{slug}' -> {mfile}")
|
|
3106
|
-
|
|
3107
|
-
|
|
2589
|
+
if queued:
|
|
2590
|
+
print(f"queued (not active) — promote it with: add.py activate {slug}")
|
|
2591
|
+
# surface the recorded confirm gate for a queued+await_confirm milestone (queued-await-confirm-hint):
|
|
2592
|
+
# additive — prints ONLY when await_confirm, so plain `--queued` output stays byte-identical.
|
|
2593
|
+
if await_confirm:
|
|
2594
|
+
print(f" (unconfirmed — after promote: add.py milestone-confirm {slug})")
|
|
2595
|
+
else:
|
|
2596
|
+
print("active milestone set." + ("" if not await_confirm else
|
|
2597
|
+
" (unconfirmed — show the MILESTONE.md, then: add.py milestone-confirm " + slug + ")"))
|
|
3108
2598
|
print(_next_footer(root, state)) # converges the old "Decompose it into tasks: …" hint
|
|
3109
2599
|
|
|
3110
2600
|
|
|
@@ -3136,7 +2626,7 @@ def cmd_milestone_confirm(args: argparse.Namespace) -> None:
|
|
|
3136
2626
|
m["confirmed"] = True
|
|
3137
2627
|
m["confirmed_at"] = _now()
|
|
3138
2628
|
m["confirmed_by"] = who
|
|
3139
|
-
m["actor"] = _actor_stamp(state) # structured actor alongside the free-text confirmed_by
|
|
2629
|
+
m["actor"] = identity._actor_stamp(state) # structured actor alongside the free-text confirmed_by
|
|
3140
2630
|
m["updated"] = _now()
|
|
3141
2631
|
save_state(root, state)
|
|
3142
2632
|
print(f"confirmed milestone '{slug}' (by {who}) — new-task is now open for it.")
|
|
@@ -3193,25 +2683,34 @@ def cmd_ready(args: argparse.Namespace) -> None:
|
|
|
3193
2683
|
|
|
3194
2684
|
|
|
3195
2685
|
def _wave_schedule(state: dict, mslug: str) -> dict:
|
|
3196
|
-
"""
|
|
3197
|
-
|
|
2686
|
+
"""One-element wrapper: a single milestone's schedule is the merge over just [mslug].
|
|
2687
|
+
Output is byte-identical to the historical per-milestone scheduler (the suite is the oracle)."""
|
|
2688
|
+
return _wave_schedule_merged(state, [mslug])
|
|
2689
|
+
|
|
2690
|
+
|
|
2691
|
+
def _wave_schedule_merged(state: dict, mslugs: list[str]) -> dict:
|
|
2692
|
+
"""Pure, total: derive ONE DAG schedule over the UNION of open members across `mslugs`
|
|
2693
|
+
(a single-element list is the historical per-milestone case) — never mutates, never raises
|
|
2694
|
+
on dict input. Returns one of:
|
|
3198
2695
|
{"cycle": [slug, ...]} — unschedulable cycle
|
|
3199
2696
|
{"waves", "critical_path", "critical_path_len", "tiers", "blocked"} — a schedule
|
|
3200
2697
|
|
|
3201
2698
|
A dep is SATISFIED (does not block) if it is archived or `_task_done` — the SAME
|
|
3202
|
-
predicate cmd_ready uses. A not-done dep that is an OPEN MEMBER of
|
|
3203
|
-
forces a later wave
|
|
3204
|
-
is
|
|
3205
|
-
|
|
3206
|
-
Tier is advisory: `top` on the critical
|
|
2699
|
+
predicate cmd_ready uses. A not-done dep that is an OPEN MEMBER of ANY target milestone
|
|
2700
|
+
forces a later wave (so a cross-milestone dep ORDERS, it does not block). A not-done dep
|
|
2701
|
+
that is NOT an open member of any target (external/unknown) is UNSATISFIABLE here -> the
|
|
2702
|
+
task is `blocked`, never scheduled. Critical path is the longest chain (most tasks) through
|
|
2703
|
+
the scheduled sub-DAG; ties break by sorted slug. Tier is advisory: `top` on the critical
|
|
2704
|
+
path, `mid` elsewhere (scheduled tasks only)."""
|
|
3207
2705
|
tasks = state.get("tasks") or {}
|
|
3208
2706
|
archived = _archived_task_slugs(state)
|
|
2707
|
+
targetset = set(mslugs)
|
|
3209
2708
|
|
|
3210
2709
|
def _ok(d: str) -> bool: # satisfied externally / already done
|
|
3211
2710
|
return d in archived or (d in tasks and _task_done(tasks[d]))
|
|
3212
2711
|
|
|
3213
2712
|
open_members = {s: t for s, t in tasks.items()
|
|
3214
|
-
if t.get("milestone")
|
|
2713
|
+
if t.get("milestone") in targetset and not _task_done(t)}
|
|
3215
2714
|
|
|
3216
2715
|
# partition open members into blocked vs schedulable — to a FIXED POINT, so blocking
|
|
3217
2716
|
# propagates transitively: a task is blocked if any dep is unsatisfiable here, where
|
|
@@ -3312,16 +2811,71 @@ def _wave_block_lines(state: dict, mslug: str, sched: dict) -> list[str]:
|
|
|
3312
2811
|
return lines
|
|
3313
2812
|
|
|
3314
2813
|
|
|
2814
|
+
def _wave_block_lines_merged(state: dict, mslugs: list[str], sched: dict) -> list[str]:
|
|
2815
|
+
"""The text lines `waves --merge` renders for ONE unified schedule over the milestone SET:
|
|
2816
|
+
a `merged: …` header naming the set + each scheduled task tagged with its `[milestone]` so
|
|
2817
|
+
cross-milestone tasks are unambiguous. Critical-path / tier-hint / blocked lines mirror
|
|
2818
|
+
`_wave_block_lines`."""
|
|
2819
|
+
n = len(mslugs)
|
|
2820
|
+
lines = [f"merged: {' + '.join(mslugs)} ({n} milestone{'s' if n != 1 else ''})"]
|
|
2821
|
+
if not sched["waves"]:
|
|
2822
|
+
if sched["blocked"]:
|
|
2823
|
+
for s in sched["blocked"]:
|
|
2824
|
+
lines.append(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
|
|
2825
|
+
else:
|
|
2826
|
+
lines.append("all tasks done — nothing to schedule")
|
|
2827
|
+
return lines
|
|
2828
|
+
scheduled_set = {x for w in sched["waves"] for x in w}
|
|
2829
|
+
for i, wave in enumerate(sched["waves"], start=1):
|
|
2830
|
+
parts = []
|
|
2831
|
+
for s in wave:
|
|
2832
|
+
label = f"{s} [{state['tasks'][s].get('milestone')}]"
|
|
2833
|
+
md = sorted(d for d in (state["tasks"][s].get("depends_on") or [])
|
|
2834
|
+
if d in scheduled_set)
|
|
2835
|
+
parts.append(f"{label} (deps: {', '.join(md)})" if md else label)
|
|
2836
|
+
lines.append(f"wave {i}: {', '.join(parts)}")
|
|
2837
|
+
crit = sched["critical_path"]
|
|
2838
|
+
lines.append(f"critical path: {' → '.join(crit)} ({sched['critical_path_len']} tasks)")
|
|
2839
|
+
tops = [s for s, tier in sched["tiers"].items() if tier == "top"]
|
|
2840
|
+
mids = [s for s, tier in sched["tiers"].items() if tier == "mid"]
|
|
2841
|
+
lines.append(f"tier hint: top → {', '.join(tops)}; mid → {', '.join(mids) or '(none)'}")
|
|
2842
|
+
for s in sched["blocked"]:
|
|
2843
|
+
lines.append(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
|
|
2844
|
+
return lines
|
|
2845
|
+
|
|
2846
|
+
|
|
3315
2847
|
def cmd_waves(args: argparse.Namespace) -> None:
|
|
3316
2848
|
"""READ-ONLY DAG scheduler: print the topological waves, critical path, advisory tier hint,
|
|
3317
|
-
and blocked set. With no --milestone it spans EVERY active milestone (cross-active-waves)
|
|
3318
|
-
a single target / --milestone renders byte-identically.
|
|
2849
|
+
and blocked set. With no --milestone it spans EVERY active milestone (cross-active-waves)
|
|
2850
|
+
as SEPARATE streams; a single target / --milestone renders byte-identically. With --merge it
|
|
2851
|
+
unifies the active SET into ONE schedule so cross-milestone deps order, not block.
|
|
2852
|
+
Writes nothing; no `next:` footer."""
|
|
3319
2853
|
is_json = getattr(args, "json", False)
|
|
3320
2854
|
if is_json:
|
|
3321
2855
|
_, state = _load_state_for_json()
|
|
3322
2856
|
else:
|
|
3323
2857
|
state = load_state(_require_root())
|
|
3324
2858
|
mslug_arg = getattr(args, "milestone", None)
|
|
2859
|
+
if getattr(args, "merge", False):
|
|
2860
|
+
if mslug_arg: # explicit target → a 1-milestone merge (NOT a conflict)
|
|
2861
|
+
if mslug_arg not in (state.get("milestones") or {}):
|
|
2862
|
+
_die(f"unknown_milestone: '{mslug_arg}' is not a milestone in this project")
|
|
2863
|
+
targets = [mslug_arg]
|
|
2864
|
+
else:
|
|
2865
|
+
primary = _active_milestone(state)
|
|
2866
|
+
if not primary:
|
|
2867
|
+
_die("no_active_milestone: no active milestone and no --milestone given")
|
|
2868
|
+
targets = [primary] + [m for m in (state.get("active_milestones") or [])
|
|
2869
|
+
if m != primary]
|
|
2870
|
+
sched = _wave_schedule_merged(state, targets)
|
|
2871
|
+
if "cycle" in sched:
|
|
2872
|
+
_die(f"dependency_cycle: not-done deps form a cycle "
|
|
2873
|
+
f"({' -> '.join(sched['cycle'])}) — no valid schedule")
|
|
2874
|
+
if is_json:
|
|
2875
|
+
print(json.dumps({"merged": targets, **sched}))
|
|
2876
|
+
else:
|
|
2877
|
+
print("\n".join(_wave_block_lines_merged(state, targets, sched)))
|
|
2878
|
+
return
|
|
3325
2879
|
if mslug_arg:
|
|
3326
2880
|
targets = [mslug_arg] # explicit single target — unchanged
|
|
3327
2881
|
else:
|
|
@@ -3389,7 +2943,7 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
|
3389
2943
|
# Stamp WHO closed it BEFORE rendering the retro, so the persisted exit report records
|
|
3390
2944
|
# the closer (identity-in-status: the retro IS the report `report <ms>` re-renders, so both
|
|
3391
2945
|
# must reflect the same final state). In-memory only here — save_state below commits it.
|
|
3392
|
-
state["milestones"][slug]["done_actor"] = _actor_stamp(state)
|
|
2946
|
+
state["milestones"][slug]["done_actor"] = identity._actor_stamp(state)
|
|
3393
2947
|
# Fail-closed: render+persist the exit report (RETRO.md) BEFORE committing the
|
|
3394
2948
|
# status flip, so a write failure rolls back naturally (status never commits ->
|
|
3395
2949
|
# no done-without-retro state). The retro step is read-only on state.json.
|
|
@@ -3589,6 +3143,11 @@ def cmd_activate(args: argparse.Namespace) -> None:
|
|
|
3589
3143
|
_die("unknown_milestone")
|
|
3590
3144
|
if state["milestones"][slug].get("status") == "done":
|
|
3591
3145
|
_die("milestone_done")
|
|
3146
|
+
# PROMOTE a queued milestone: activating it flips queued→active (human-gated promotion —
|
|
3147
|
+
# the chosen verb, reusing `activate` rather than a separate `promote`). An already-active
|
|
3148
|
+
# milestone is just refocused (status unchanged), keeping the default path byte-identical.
|
|
3149
|
+
if state["milestones"][slug].get("status") == "queued":
|
|
3150
|
+
state["milestones"][slug]["status"] = "active"
|
|
3592
3151
|
_activate_milestone(state, slug)
|
|
3593
3152
|
save_state(root, state)
|
|
3594
3153
|
print(f"activated '{slug}' — active: {', '.join(state['active_milestones'])}")
|
|
@@ -3692,165 +3251,14 @@ def _sync_task_marker(root: Path, slug: str, phase: str) -> None:
|
|
|
3692
3251
|
# state.json; prose (observe delta, deltas) is parsed from each TASK.md and
|
|
3693
3252
|
# fails CLOSED to `(unknown)` rather than omitting silently.
|
|
3694
3253
|
|
|
3695
|
-
_DEFAULT_WIDTH = 72 # fixed width for the persisted/canonical render (RETRO.md)
|
|
3696
3254
|
# Two glyph tiers. Alignment is correct only with ASCII in column-positioned
|
|
3697
3255
|
# cells (every ASCII char is 1 display cell); Unicode glyphs sit at line-END
|
|
3698
3256
|
# (the PROGRESS track) or in non-aligned rows, where width can't break columns.
|
|
3699
3257
|
_UNICODE = {"reached": "●", "current": "◉", "pending": "○", "h": "═", "rule": "─", "bullet": "•"}
|
|
3700
3258
|
_ASCII = {"reached": "#", "current": ">", "pending": ".", "h": "=", "rule": "-", "bullet": "*"}
|
|
3701
3259
|
_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
3260
|
# A non-empty `(verify: <citation>)` on an exit-criterion line — at least one non-whitespace
|
|
3829
3261
|
# 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
3262
|
def _goal_auto_ready(root: Path, mslug: str) -> bool:
|
|
3855
3263
|
"""True iff the milestone goal is AUTO-READY: its Exit criteria has >= 1 criterion
|
|
3856
3264
|
AND every one cites a verifier (cited == total) — so the engine can self-verify the
|
|
@@ -3860,32 +3268,6 @@ def _goal_auto_ready(root: Path, mslug: str) -> bool:
|
|
|
3860
3268
|
return total >= 1 and cited == total
|
|
3861
3269
|
|
|
3862
3270
|
|
|
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
3271
|
def _graduation_ready(root: Path, state: dict) -> tuple[bool, int, int]:
|
|
3890
3272
|
"""(ready, met, total) for the stage-graduation cue (v22): every milestone done AND the
|
|
3891
3273
|
human's stage-goal-criteria all checked (total>0 and met==total). The SINGLE source the
|
|
@@ -3895,93 +3277,6 @@ def _graduation_ready(root: Path, state: dict) -> tuple[bool, int, int]:
|
|
|
3895
3277
|
return ready, met, total
|
|
3896
3278
|
|
|
3897
3279
|
|
|
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
3280
|
def _resolved_test_files(root: Path, slug: str) -> list[Path]:
|
|
3986
3281
|
"""The file set the engine treats as this task's tests — the PRIMARY set wins
|
|
3987
3282
|
when it yields any test defs, else the §4-declared set (mirrors _tests_info's
|
|
@@ -3992,19 +3287,6 @@ def _resolved_test_files(root: Path, slug: str) -> list[Path]:
|
|
|
3992
3287
|
return _declared_test_files(root, slug)
|
|
3993
3288
|
|
|
3994
3289
|
|
|
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
3290
|
def _tripwire_snapshot(root: Path, slug: str, raw3: str) -> dict:
|
|
4009
3291
|
"""Freeze the md5 of the resolved red test files + the frozen §3 contract — the
|
|
4010
3292
|
tamper baseline (verify-integrity). Keys are project-root-relative paths (stable
|
|
@@ -4058,31 +3340,6 @@ _SCOPE_EXCLUDE_SUFFIXES = (".pyc", ".tsbuildinfo")
|
|
|
4058
3340
|
# OPT-IN + DEGRADE-SAFE: with no .add/components.toml every reader is byte-identical to
|
|
4059
3341
|
# pre-component ADD. A read NEVER raises (absent/unreadable/malformed → {} / dropped
|
|
4060
3342
|
# 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
3343
|
def _component_root(root: Path, name: str) -> str | None:
|
|
4087
3344
|
"""Project-root-relative path (trailing '/') of component `name`'s root, or None
|
|
4088
3345
|
when the name is absent OR the root escapes the project (fail-closed — grants no
|
|
@@ -4120,22 +3377,6 @@ def _task_green_bar(root: Path, slug: str) -> str | None:
|
|
|
4120
3377
|
return (_components(root).get(comp) or {}).get("green_bar") or None
|
|
4121
3378
|
|
|
4122
3379
|
|
|
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
3380
|
def _component_findings(root: Path) -> list[tuple[str, str]]:
|
|
4140
3381
|
"""The loud gate surface for the registry — the codes a degrade-safe read passes
|
|
4141
3382
|
over silently. Consumed by cmd_check (the scope_violation surface). [] when clean."""
|
|
@@ -4178,46 +3419,6 @@ def _component_findings(root: Path) -> list[tuple[str, str]]:
|
|
|
4178
3419
|
# ── cross-component contracts (cross-component-contract) ──────────────────────────────────
|
|
4179
3420
|
# OPT-IN + DEGRADE-SAFE, like the component readers: no [contract.*] / no produces|consumes
|
|
4180
3421
|
# 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
3422
|
def _task_produces(root: Path, slug: str) -> str | None:
|
|
4222
3423
|
m = _PRODUCES_LINE_RE.search(_task_header(root, slug))
|
|
4223
3424
|
return m.group(1).strip() if m else None
|
|
@@ -4228,10 +3429,6 @@ def _task_consumes(root: Path, slug: str) -> str | None:
|
|
|
4228
3429
|
return m.group(1).strip() if m else None
|
|
4229
3430
|
|
|
4230
3431
|
|
|
4231
|
-
def _contract_snapshot(root: Path, cid: str) -> Path:
|
|
4232
|
-
return root / "contracts" / f"{cid}.json"
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
3432
|
def _contract_body_hash(raw3: str) -> str:
|
|
4236
3433
|
"""md5 of the §3 contract SHAPE — the first ```fenced``` block, whitespace-normalized. The
|
|
4237
3434
|
version stamp + freeze flags are excluded (fallback strips Status:/flag/change-request lines)
|
|
@@ -4304,18 +3501,6 @@ def _declared_scope(root: Path, slug: str) -> list[str] | None:
|
|
|
4304
3501
|
return out
|
|
4305
3502
|
|
|
4306
3503
|
|
|
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
3504
|
def _scope_walk(rootp: Path) -> dict[str, str]:
|
|
4320
3505
|
"""{project-root-relative path: md5} over the project tree, pruning
|
|
4321
3506
|
_SCOPE_EXCLUDE_DIRS at any depth and skipping bytecode/OS junk +
|
|
@@ -4446,6 +3631,28 @@ def _heal_or_escalate(root: Path, state: dict, slug: str, *, reason: str, source
|
|
|
4446
3631
|
raise SystemExit(3) # redo signal (distinct from _die's 1, argparse's 2)
|
|
4447
3632
|
|
|
4448
3633
|
|
|
3634
|
+
def _consumer_stale_guard(root: Path, state: dict, slug: str) -> None:
|
|
3635
|
+
"""Refuse a COMPLETING gate when a `consumes:` task's pinned producer contract hash is STALE
|
|
3636
|
+
(the producer re-froze a CHANGED shape since the pin) — the consumer built against an
|
|
3637
|
+
out-of-date contract (consumer-stale-gate, the gate twin of cmd_check's contract_consumer_stale
|
|
3638
|
+
warning). Recoverable, not a cheat: re-pin by re-crossing contract->tests after reviewing the
|
|
3639
|
+
new frozen shape. Degrade-safe — an unreadable/missing live snapshot is NOT decided here (it
|
|
3640
|
+
stays a cmd_check warning + the advance-time contract_snapshot_missing HARD-STOP); only a
|
|
3641
|
+
CONFIRMED hash drift blocks. Placed with the other completing guards, BEFORE the waiver write,
|
|
3642
|
+
so a stale pin is never launderable through RISK-ACCEPTED; HARD-STOP never reaches here."""
|
|
3643
|
+
pin = state["tasks"][slug].get("contract_pin")
|
|
3644
|
+
if not pin:
|
|
3645
|
+
return
|
|
3646
|
+
try:
|
|
3647
|
+
live = json.loads(_contract_snapshot(root, pin["id"]).read_text(encoding="utf-8")).get("hash")
|
|
3648
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
|
3649
|
+
return # unreadable -> surfaced by cmd_check, not confirmable as stale here
|
|
3650
|
+
if live is not None and live != pin.get("hash"):
|
|
3651
|
+
_die(f"contract_consumer_stale: task '{slug}' pinned contract '{pin['id']}' changed shape "
|
|
3652
|
+
"since the pin (the producer re-froze) — re-pin by re-crossing contract->tests after "
|
|
3653
|
+
"reviewing the producer's new frozen shape; never complete against a stale contract")
|
|
3654
|
+
|
|
3655
|
+
|
|
4449
3656
|
def _tamper_guard(root: Path, state: dict, slug: str) -> None:
|
|
4450
3657
|
"""HARD-STOP a COMPLETING gate when the tripwire shows tampering — the method's
|
|
4451
3658
|
first mechanical cheat block (verify-integrity). Tri-state, co-witnessed by
|
|
@@ -4472,89 +3679,6 @@ def _tamper_guard(root: Path, state: dict, slug: str) -> None:
|
|
|
4472
3679
|
reason="tamper_detected:" + ",".join(diffs), source="tamper")
|
|
4473
3680
|
|
|
4474
3681
|
|
|
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
3682
|
def report_data(root: Path, state: dict, mslug: str) -> dict:
|
|
4559
3683
|
"""The single source of FACTS for a milestone report — pure, NO writes.
|
|
4560
3684
|
Both the text dashboard (render_report) and `report --json` render from this,
|
|
@@ -4636,43 +3760,6 @@ def _clean_phase_body(body: str) -> str:
|
|
|
4636
3760
|
return "\n".join(lines) if meaningful else "(empty)"
|
|
4637
3761
|
|
|
4638
3762
|
|
|
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
3763
|
def task_phases(root: Path, slug: str) -> list[dict]:
|
|
4677
3764
|
"""The frozen per-task PHASE-DETAIL shape (v9-1): parse TASK.md §0–§7 into eight
|
|
4678
3765
|
blocks ground→observe. PURE — NO writes. Each entry is
|
|
@@ -5259,10 +4346,6 @@ _DELTA_STATUSES = ("open", "folded", "rejected")
|
|
|
5259
4346
|
# un-stripped lines directly; callers that pre-strip their input
|
|
5260
4347
|
# (e.g. _collect_open_deltas, _lint_task_deltas) match the same way (\s*
|
|
5261
4348
|
# 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
4349
|
|
|
5267
4350
|
# SPEC-delta track — a SEPARATE resolution lifecycle from the competency deltas
|
|
5268
4351
|
# above. SPEC shares the "- [TAG · status]" LINE shape but its statuses are
|
|
@@ -5271,9 +4354,6 @@ _EVIDENCE_RE = re.compile(r"^(.*?)\s*\(evidence:\s*(.*?)\)\s*$")
|
|
|
5271
4354
|
# tag to its legal status set so the ONE lint can reject a cross-set pairing
|
|
5272
4355
|
# ([SPEC · folded], [SDD · seeded]) without a parallel grammar.
|
|
5273
4356
|
_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
4357
|
_STATUS_SETS = {**{c: _DELTA_STATUSES for c in _COMPETENCY_ORDER}, "SPEC": _SPEC_STATUSES}
|
|
5278
4358
|
|
|
5279
4359
|
# Broad structural tag detector: finds ANY "- [tok · tok]" line (valid OR malformed).
|
|
@@ -5429,32 +4509,6 @@ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
|
|
|
5429
4509
|
return by_comp
|
|
5430
4510
|
|
|
5431
4511
|
|
|
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
4512
|
def _collect_open_spec_deltas(root: Path) -> list[dict]:
|
|
5459
4513
|
"""Scan every .add/tasks/*/TASK.md "### Spec delta" block for OPEN SPEC deltas.
|
|
5460
4514
|
|
|
@@ -5782,6 +4836,15 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
|
|
|
5782
4836
|
elif gate != "none" and outcomes[0] != gate:
|
|
5783
4837
|
f(slug, "gate_record_mismatch",
|
|
5784
4838
|
f"§6 records {outcomes[0]} but state.json records {gate}")
|
|
4839
|
+
elif gate == "none":
|
|
4840
|
+
# F13 ungated_verdict: §6 carries a verdict the engine never gated.
|
|
4841
|
+
# cmd_gate is the ONLY writer of `gate` (it also marks done), so a
|
|
4842
|
+
# done/observe task at gate=="none" reached its verdict without the
|
|
4843
|
+
# engine — a hand-stamped §6 or an `advance` past verify. Constraint 4
|
|
4844
|
+
# requires a RECORDED outcome; a §6 verdict without one is not trusted.
|
|
4845
|
+
f(slug, "ungated_verdict",
|
|
4846
|
+
f"§6 records {outcomes[0]} but state.json recorded no gate (gate=none) — "
|
|
4847
|
+
f"the verdict was written without the engine gate")
|
|
5785
4848
|
sec = _AUDIT_SECURITY_RE.search(s6)
|
|
5786
4849
|
marked = bool(sec and ("NOTE" in sec.group(0) or "⚠" in sec.group(0)))
|
|
5787
4850
|
rev = _AUDIT_REVIEWED_RE.search(s6)
|
|
@@ -5967,12 +5030,6 @@ def cmd_graduation_report(args: argparse.Namespace) -> None:
|
|
|
5967
5030
|
print("\n".join(L))
|
|
5968
5031
|
|
|
5969
5032
|
|
|
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
5033
|
def _released_milestones(root: Path) -> set[str]:
|
|
5977
5034
|
"""Slugs already attributed to a release — the union of every `milestones:` row in
|
|
5978
5035
|
RELEASES.md. Fail-OPEN: a missing/unreadable/malformed ledger yields the empty set, so
|
|
@@ -5993,21 +5050,6 @@ def _released_milestones(root: Path) -> set[str]:
|
|
|
5993
5050
|
return out
|
|
5994
5051
|
|
|
5995
5052
|
|
|
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
5053
|
def _releasable(root: Path, state: dict) -> list[dict]:
|
|
6012
5054
|
"""Closed milestones NOT yet attributed to any RELEASES.md row — the cut's candidate
|
|
6013
5055
|
bundle. Drives BOTH the `→ releasable: N` status cue and release-report. READ-ONLY."""
|
|
@@ -6015,19 +5057,38 @@ def _releasable(root: Path, state: dict) -> list[dict]:
|
|
|
6015
5057
|
return [m for m in _closed_milestones(state) if m["slug"] not in released]
|
|
6016
5058
|
|
|
6017
5059
|
|
|
6018
|
-
def
|
|
6019
|
-
"""
|
|
6020
|
-
|
|
6021
|
-
|
|
5060
|
+
def _released_loose_tasks(root: Path) -> set[str]:
|
|
5061
|
+
"""Slugs already attributed to a release as LOOSE tasks — the union of every
|
|
5062
|
+
`loose tasks:` row in RELEASES.md. The exact parallel of _released_milestones:
|
|
5063
|
+
fail-OPEN (a missing/unreadable/malformed ledger yields the empty set, so every
|
|
5064
|
+
done loose task reads as still-releasable). READ-ONLY."""
|
|
6022
5065
|
try:
|
|
6023
|
-
text = (root
|
|
5066
|
+
text = _releases_path(root).read_text(encoding="utf-8")
|
|
6024
5067
|
except OSError:
|
|
6025
|
-
return
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
|
|
5068
|
+
return set() # no ledger -> nothing released yet
|
|
5069
|
+
out: set[str] = set()
|
|
5070
|
+
for line in text.splitlines():
|
|
5071
|
+
st = line.strip()
|
|
5072
|
+
if st.lower().startswith("loose tasks:"):
|
|
5073
|
+
for tok in re.split(r"[,\s]+", st.split(":", 1)[1]):
|
|
5074
|
+
tok = tok.strip()
|
|
5075
|
+
if tok and tok.lower() != "none":
|
|
5076
|
+
out.add(tok)
|
|
5077
|
+
return out
|
|
5078
|
+
|
|
5079
|
+
|
|
5080
|
+
def _releasable_loose_tasks(root: Path, state: dict) -> list[dict]:
|
|
5081
|
+
"""Done milestone-free tasks NOT yet attributed to any RELEASES.md `loose tasks:` row —
|
|
5082
|
+
the loose half of the cut's candidate bundle, peer to _releasable. A loose task is a
|
|
5083
|
+
first-class releasable item: milestone-free (a standalone, fast OR full) AND done (a
|
|
5084
|
+
completing gate). Drives the loose status cue + release_data["loose"]. READ-ONLY."""
|
|
5085
|
+
released = _released_loose_tasks(root)
|
|
5086
|
+
out: list[dict] = []
|
|
5087
|
+
for slug, t in (state.get("tasks") or {}).items():
|
|
5088
|
+
if (isinstance(t, dict) and t.get("milestone") is None and _task_done(t)
|
|
5089
|
+
and slug not in released):
|
|
5090
|
+
out.append({"slug": slug, "title": t.get("title", slug)})
|
|
5091
|
+
return out
|
|
6031
5092
|
|
|
6032
5093
|
|
|
6033
5094
|
def release_data(root: Path, state: dict) -> dict:
|
|
@@ -6092,15 +5153,19 @@ def release_data(root: Path, state: dict) -> dict:
|
|
|
6092
5153
|
monitors.append({"slug": slug, "watch": st})
|
|
6093
5154
|
break
|
|
6094
5155
|
|
|
5156
|
+
# loose — done milestone-free tasks not yet attributed (the cut's loose bundle, peer to releasable)
|
|
5157
|
+
loose = _releasable_loose_tasks(root, state)
|
|
5158
|
+
|
|
6095
5159
|
return {
|
|
6096
5160
|
"releasable": releasable,
|
|
6097
5161
|
"changed": changed,
|
|
6098
5162
|
"waivers": waivers,
|
|
6099
5163
|
"blockers": blockers,
|
|
6100
5164
|
"monitors": monitors,
|
|
5165
|
+
"loose": loose,
|
|
6101
5166
|
"summary": {
|
|
6102
5167
|
"releasable": len(releasable), "changed": len(changed), "waivers": len(waivers),
|
|
6103
|
-
"blockers": len(blockers), "monitors": len(monitors),
|
|
5168
|
+
"blockers": len(blockers), "monitors": len(monitors), "loose": len(loose),
|
|
6104
5169
|
},
|
|
6105
5170
|
}
|
|
6106
5171
|
|
|
@@ -6144,14 +5209,6 @@ def cmd_release_report(args: argparse.Namespace) -> None:
|
|
|
6144
5209
|
print("\n".join(L))
|
|
6145
5210
|
|
|
6146
5211
|
|
|
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
5212
|
def _prepend_block(existing: str, header: str, block: str) -> str:
|
|
6156
5213
|
"""Newest-first prepend: insert `block` directly under the top H1 `header`, creating the
|
|
6157
5214
|
header when `existing` is empty / headerless. Existing content is preserved VERBATIM
|
|
@@ -6164,37 +5221,6 @@ def _prepend_block(existing: str, header: str, block: str) -> str:
|
|
|
6164
5221
|
return f"{block}{existing}" # no recognized header -> block goes on top, verbatim tail
|
|
6165
5222
|
|
|
6166
5223
|
|
|
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
5224
|
def cmd_release(args: argparse.Namespace) -> None:
|
|
6199
5225
|
"""GUARDED, record-only: cut a version. Enforce the 4-code readiness floor, then RECORD by
|
|
6200
5226
|
prepending CHANGELOG.md + an append-only RELEASES.md row (whose `milestones:` line attributes
|
|
@@ -6218,9 +5244,11 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
6218
5244
|
_die("release_tests_red: a build is in flight without a recorded green gate — finish and "
|
|
6219
5245
|
"gate it first, or pass --force to override.")
|
|
6220
5246
|
bundle = _releasable(root, state)
|
|
6221
|
-
|
|
5247
|
+
loose_bundle = _releasable_loose_tasks(root, state)
|
|
5248
|
+
if not forced and not bundle and not loose_bundle:
|
|
6222
5249
|
_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
|
|
5250
|
+
"would be a no-op. Close a milestone (or a standalone task) first, or pass --force "
|
|
5251
|
+
"to override.")
|
|
6224
5252
|
if not forced and d["waivers"] and not disclosed:
|
|
6225
5253
|
_die("release_undisclosed_waiver: a RISK-ACCEPTED waiver rides into this release — pass "
|
|
6226
5254
|
"--with-waivers to disclose it in the notes, or --force to override.")
|
|
@@ -6238,7 +5266,7 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
6238
5266
|
new_rel = _prepend_block(rel_before, "# Releases",
|
|
6239
5267
|
_render_releases_row(args.version, day, bundle, waiver_slugs,
|
|
6240
5268
|
getattr(args, "evidence", None),
|
|
6241
|
-
_render_actor_line(state)))
|
|
5269
|
+
identity._render_actor_line(state), loose_bundle))
|
|
6242
5270
|
try: # CHANGELOG + RELEASES as one all-or-nothing commit
|
|
6243
5271
|
_atomic_write_many([(changelog_path, new_cl), (releases_path, new_rel)])
|
|
6244
5272
|
except OSError as e:
|
|
@@ -6247,7 +5275,9 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
6247
5275
|
|
|
6248
5276
|
# NO save_state — attribution lives in RELEASES.md (the cue re-reads it), never state.json
|
|
6249
5277
|
ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
|
|
6250
|
-
|
|
5278
|
+
lt = ", ".join(t["slug"] for t in loose_bundle) if loose_bundle else "none"
|
|
5279
|
+
print(f"released {args.version} — recorded {len(bundle)} milestone(s): {ms} "
|
|
5280
|
+
f"+ {len(loose_bundle)} loose task(s): {lt}")
|
|
6251
5281
|
print(" CHANGELOG.md + RELEASES.md updated (project root). The engine records; "
|
|
6252
5282
|
"you run the tag / publish / deploy.")
|
|
6253
5283
|
if forced:
|
|
@@ -6422,6 +5452,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6422
5452
|
pl.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
|
|
6423
5453
|
pl.set_defaults(func=cmd_lock)
|
|
6424
5454
|
|
|
5455
|
+
pfz = sub.add_parser("freeze",
|
|
5456
|
+
help="freeze a task's §3 contract (the human approval) — stamps "
|
|
5457
|
+
"FROZEN @ vN + a structured actor on the task record")
|
|
5458
|
+
pfz.add_argument("slug", nargs="?", default=None,
|
|
5459
|
+
help="task to freeze (default: the active task)")
|
|
5460
|
+
pfz.add_argument("--by", default=None, help="approver name (default: the resolved actor)")
|
|
5461
|
+
pfz.set_defaults(func=cmd_freeze)
|
|
5462
|
+
|
|
6425
5463
|
pwho = sub.add_parser("whoami",
|
|
6426
5464
|
help="show / set the git-native actor (git config -> OS user; --name to override)")
|
|
6427
5465
|
# --name (set) and --unset (clear) are mutually exclusive — argparse rejects the
|
|
@@ -6481,6 +5519,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6481
5519
|
pm.add_argument("--goal", default=None, help="one-sentence outcome")
|
|
6482
5520
|
pm.add_argument("--stage", default="mvp", choices=STAGES)
|
|
6483
5521
|
pm.add_argument("--force", action="store_true", help="overwrite MILESTONE.md if present")
|
|
5522
|
+
pm.add_argument("--queued", action="store_true",
|
|
5523
|
+
help="create the milestone QUEUED (status=queued), not active: it is recorded "
|
|
5524
|
+
"and its MILESTONE.md written, but the active focus is unchanged. Promote it "
|
|
5525
|
+
"later with `activate <slug>`. Foundation for roadmap intake (1 active + N queued).")
|
|
6484
5526
|
pm.add_argument("--await-confirm", action="store_true",
|
|
6485
5527
|
help="opt into the confirm-parent gate: seed the milestone unconfirmed so "
|
|
6486
5528
|
"new-task is held until `milestone-confirm` (mirrors `init --await-lock`); "
|
|
@@ -6501,6 +5543,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6501
5543
|
"waves + critical path + advisory tier hint")
|
|
6502
5544
|
pwa.add_argument("--milestone", default=None,
|
|
6503
5545
|
help="milestone slug to schedule (default: the active milestone)")
|
|
5546
|
+
pwa.add_argument("--merge", action="store_true",
|
|
5547
|
+
help="unify the active SET into ONE schedule (cross-milestone deps order, not block)")
|
|
6504
5548
|
pwa.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
6505
5549
|
pwa.set_defaults(func=cmd_waves)
|
|
6506
5550
|
|
|
@@ -6568,6 +5612,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6568
5612
|
help="confirm a RAISE toward auto (a human-owned trust escalation)")
|
|
6569
5613
|
pan.set_defaults(func=cmd_autonomy, _opt_positionals=("a1", "a2"))
|
|
6570
5614
|
|
|
5615
|
+
pto = sub.add_parser("todo", help="capture / list / close a lightweight backlog todo (jot an idea)")
|
|
5616
|
+
pto.add_argument("text", nargs="?", default=None,
|
|
5617
|
+
help="todo text to capture; omit to LIST open todos")
|
|
5618
|
+
pto.add_argument("--done", type=int, default=None, metavar="ID",
|
|
5619
|
+
help="close an open todo by id")
|
|
5620
|
+
pto.set_defaults(func=cmd_todo)
|
|
5621
|
+
|
|
6571
5622
|
pr = sub.add_parser("reopen", help="return a done task to an earlier phase with a recorded reason")
|
|
6572
5623
|
pr.add_argument("slug", nargs="?", default=None)
|
|
6573
5624
|
# --to / --reason are validated in-body (not argparse choices) so the named reject
|
|
@@ -6614,6 +5665,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
6614
5665
|
pmine.add_argument("--actor", default=None, metavar="\"Name <email>\"",
|
|
6615
5666
|
help="inspect another actor's queue instead of your own")
|
|
6616
5667
|
pmine.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
|
|
5668
|
+
pmine.add_argument("--all", action="store_true",
|
|
5669
|
+
help="widen past the active SET: every milestone + loose tasks, not just active")
|
|
6617
5670
|
pmine.set_defaults(func=cmd_mine)
|
|
6618
5671
|
|
|
6619
5672
|
pwv = sub.add_parser("wave-verify",
|
|
@@ -6739,16 +5792,6 @@ def _rebind_optional_positionals(parser: argparse.ArgumentParser,
|
|
|
6739
5792
|
# silently does nothing and nothing is lost. It NEVER changes a command's stdout or exit.
|
|
6740
5793
|
_UPDATE_CACHE = ".update-cache.json"
|
|
6741
5794
|
_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
5795
|
def _write_json_safe(path: Path, obj) -> None:
|
|
6753
5796
|
try:
|
|
6754
5797
|
path.write_text(json.dumps(obj, indent=2) + "\n", encoding="utf-8")
|
|
@@ -6756,33 +5799,6 @@ def _write_json_safe(path: Path, obj) -> None:
|
|
|
6756
5799
|
pass
|
|
6757
5800
|
|
|
6758
5801
|
|
|
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
5802
|
def _cached_latest(add_dir: Path):
|
|
6787
5803
|
"""The registry's latest version, throttled: served from .update-cache.json within
|
|
6788
5804
|
the TTL, else refreshed over the network (fail-open). None when unknown."""
|