@pilotspace/add 1.7.3 → 1.9.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 +82 -0
- package/docs/appendix-c-glossary.md +2 -0
- package/package.json +1 -1
- package/skill/add/SKILL.md +86 -116
- package/skill/add/adopt.md +13 -34
- package/skill/add/advisor.md +9 -10
- package/skill/add/compact-foundation.md +21 -34
- package/skill/add/confidence.md +9 -25
- package/skill/add/deltas.md +12 -28
- package/skill/add/design.md +26 -32
- package/skill/add/fold.md +26 -51
- package/skill/add/graduate.md +18 -54
- package/skill/add/intake.md +30 -34
- package/skill/add/loop.md +30 -34
- package/skill/add/phases/0-ground.md +6 -14
- package/skill/add/phases/0-setup.md +30 -81
- package/skill/add/phases/1-specify.md +7 -22
- package/skill/add/phases/2-scenarios.md +1 -3
- package/skill/add/phases/3-contract.md +8 -22
- package/skill/add/phases/4-tests.md +3 -6
- package/skill/add/phases/5-build.md +8 -22
- package/skill/add/phases/6-verify.md +14 -49
- package/skill/add/phases/7-observe.md +1 -3
- package/skill/add/phases/fast-lane.md +31 -0
- package/skill/add/release.md +36 -90
- package/skill/add/report-template.md +41 -126
- package/skill/add/run.md +89 -159
- package/skill/add/scope.md +40 -89
- package/skill/add/setup-review.md +10 -27
- package/skill/add/soul.md +17 -36
- package/skill/add/streams.md +90 -149
- package/tooling/add.py +1075 -152
- package/tooling/templates/TASK.fast.md.tmpl +72 -0
package/tooling/add.py
CHANGED
|
@@ -17,6 +17,8 @@ import hashlib
|
|
|
17
17
|
import json
|
|
18
18
|
import os
|
|
19
19
|
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
20
22
|
import sys
|
|
21
23
|
import tempfile
|
|
22
24
|
import urllib.request
|
|
@@ -150,6 +152,47 @@ Outcome:
|
|
|
150
152
|
"""
|
|
151
153
|
|
|
152
154
|
|
|
155
|
+
# Fast-lane fallback: the minimal TASK.md variant (sections {0,1,3,4,5,6}; §2 + §7 dropped).
|
|
156
|
+
# Mirrors templates/TASK.fast.md.tmpl's section set (circuit-breaker parity); a deleted
|
|
157
|
+
# templates/ never hard-fails the fast lane. Keeps the trust floor: §3 freeze-flag + Status,
|
|
158
|
+
# §6 GATE RECORD/Outcome, §0 Anchors, §4 red-before-build, §5 Scope.
|
|
159
|
+
_FALLBACK_TASK_FAST = """# TASK: {title}
|
|
160
|
+
|
|
161
|
+
slug: {slug} · created: {date} · stage: {stage}
|
|
162
|
+
autonomy: auto
|
|
163
|
+
phase: ground
|
|
164
|
+
fast: true
|
|
165
|
+
|
|
166
|
+
## 0 · GROUND
|
|
167
|
+
Touches (files · symbols):
|
|
168
|
+
Anchors the contract cites:
|
|
169
|
+
|
|
170
|
+
## 1 · SPECIFY
|
|
171
|
+
Feature:
|
|
172
|
+
Must:
|
|
173
|
+
Reject:
|
|
174
|
+
Accept:
|
|
175
|
+
Assumptions: ⚠ <most likely wrong> — why; if wrong: <cost>
|
|
176
|
+
|
|
177
|
+
## 3 · CONTRACT
|
|
178
|
+
Least-sure flag surfaced at freeze:
|
|
179
|
+
Status: DRAFT
|
|
180
|
+
|
|
181
|
+
## 4 · TESTS
|
|
182
|
+
Plan:
|
|
183
|
+
Tests live in: `./tests/` · MUST run red before Build.
|
|
184
|
+
|
|
185
|
+
## 5 · BUILD
|
|
186
|
+
Scope (may touch): `./src/`
|
|
187
|
+
|
|
188
|
+
## 6 · VERIFY
|
|
189
|
+
Build expectations (from §1 Accept + §3 CONTRACT):
|
|
190
|
+
### GATE RECORD
|
|
191
|
+
Outcome:
|
|
192
|
+
Reviewed by:
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
|
|
153
196
|
# --- low-level IO (designed for failure: atomic, no silent clobber) ----------
|
|
154
197
|
|
|
155
198
|
def _now() -> str:
|
|
@@ -173,29 +216,59 @@ def _atomic_write(path: Path, text: str) -> None:
|
|
|
173
216
|
|
|
174
217
|
|
|
175
218
|
def _atomic_write_many(writes: list[tuple[Path, str]]) -> None:
|
|
176
|
-
"""
|
|
177
|
-
|
|
178
|
-
Phase 1 STAGES every (path, text) to a sibling
|
|
179
|
-
(disk full, permission denied) surface HERE, before any
|
|
180
|
-
failure every staged temp is removed, so NOTHING is committed. Phase 2 then
|
|
181
|
-
each
|
|
182
|
-
|
|
183
|
-
the
|
|
219
|
+
"""True all-or-nothing commit across N files — design-for-failure for a multi-file write.
|
|
220
|
+
|
|
221
|
+
Phase 1 STAGES every (path, text) to a sibling `.tmp`, flushing + fsync-ing each, so the
|
|
222
|
+
realistic IO failures (disk full, permission denied) surface HERE, before any target changes —
|
|
223
|
+
and on any stage failure every staged temp is removed, so NOTHING is committed. Phase 2 then
|
|
224
|
+
COMMITS each file by renaming any existing target ASIDE to a sibling `.bak`, then `os.replace`-ing
|
|
225
|
+
the staged `.tmp` into place. If ANY commit rename raises, every file already committed is rolled
|
|
226
|
+
back IN REVERSE (remove the landed new file, rename its `.bak` back, or leave it absent) and the
|
|
227
|
+
original error re-raised — so the whole set is all-or-nothing: either every file holds its new
|
|
228
|
+
text, or every file holds its prior content. Restoring is an atomic rename of an already-written
|
|
229
|
+
`.bak` (no content held in memory, no re-write), the cheapest recovery under failing IO. Leftover
|
|
230
|
+
`.tmp`/`.bak` siblings are removed on every exit path.
|
|
184
231
|
"""
|
|
185
232
|
staged: list[tuple[str, Path]] = []
|
|
233
|
+
committed: list[list] = [] # [path, bak_or_None, new_landed] per committed file
|
|
186
234
|
try:
|
|
187
|
-
for path, text in writes:
|
|
235
|
+
for path, text in writes: # phase 1: stage every temp (fsync'd before any commit)
|
|
188
236
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
189
237
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
|
238
|
+
staged.append((tmp, path)) # track BEFORE write so a write/fsync failure still cleans it
|
|
190
239
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
191
240
|
fh.write(text)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
241
|
+
fh.flush()
|
|
242
|
+
os.fsync(fh.fileno())
|
|
243
|
+
try:
|
|
244
|
+
for tmp, path in staged: # phase 2: commit via rename-aside
|
|
245
|
+
bak = None
|
|
246
|
+
existed = path.exists()
|
|
247
|
+
if existed:
|
|
248
|
+
fd2, bak = tempfile.mkstemp(dir=str(path.parent), suffix=".bak")
|
|
249
|
+
os.close(fd2)
|
|
250
|
+
committed.append([path, bak, False]) # track .bak NOW so cleanup never leaks it
|
|
251
|
+
if existed:
|
|
252
|
+
os.replace(path, bak) # move the existing target aside
|
|
253
|
+
os.replace(tmp, path) # move the new file in
|
|
254
|
+
committed[-1][2] = True
|
|
255
|
+
except OSError:
|
|
256
|
+
for path, bak, landed in reversed(committed): # roll back, newest-first
|
|
257
|
+
try:
|
|
258
|
+
if landed and path.exists():
|
|
259
|
+
os.unlink(path) # drop the new file we put in
|
|
260
|
+
if bak is not None and not path.exists():
|
|
261
|
+
os.replace(bak, path) # restore the prior target (atomic rename)
|
|
262
|
+
except OSError:
|
|
263
|
+
pass # best-effort restore under already-failing IO
|
|
264
|
+
raise
|
|
195
265
|
finally:
|
|
196
|
-
for tmp, _ in staged: # leftover
|
|
266
|
+
for tmp, _ in staged: # leftover .tmp (failed/aborted stage) never persists
|
|
197
267
|
if os.path.exists(tmp):
|
|
198
268
|
os.unlink(tmp)
|
|
269
|
+
for path, bak, landed in committed: # leftover .bak (success, or orphaned post-rollback)
|
|
270
|
+
if bak is not None and os.path.exists(bak):
|
|
271
|
+
os.unlink(bak)
|
|
199
272
|
|
|
200
273
|
|
|
201
274
|
def _templates_dir() -> Path:
|
|
@@ -205,13 +278,14 @@ def _templates_dir() -> Path:
|
|
|
205
278
|
def _render_template(name: str, **subs: str) -> str:
|
|
206
279
|
"""Load templates/<name>.tmpl and substitute {{key}} tokens.
|
|
207
280
|
|
|
208
|
-
Falls back to a built-in minimal template
|
|
281
|
+
Falls back to a built-in minimal template for TASK.md and the fast-lane TASK.fast.md.
|
|
209
282
|
"""
|
|
210
283
|
tmpl = _templates_dir() / f"{name}.tmpl"
|
|
284
|
+
_fallbacks = {"TASK.md": _FALLBACK_TASK, "TASK.fast.md": _FALLBACK_TASK_FAST}
|
|
211
285
|
if tmpl.exists():
|
|
212
286
|
text = tmpl.read_text(encoding="utf-8")
|
|
213
|
-
elif name
|
|
214
|
-
text =
|
|
287
|
+
elif name in _fallbacks:
|
|
288
|
+
text = _fallbacks[name].replace("{title}", "{{title}}").replace(
|
|
215
289
|
"{slug}", "{{slug}}").replace("{date}", "{{date}}").replace("{stage}", "{{stage}}")
|
|
216
290
|
else:
|
|
217
291
|
text = ""
|
|
@@ -238,12 +312,238 @@ def _require_root() -> Path:
|
|
|
238
312
|
return root
|
|
239
313
|
|
|
240
314
|
|
|
315
|
+
def _migrate_state(state: dict) -> dict:
|
|
316
|
+
"""Forward-migrate a single-active state to the multi-active schema (team-collaboration
|
|
317
|
+
foundation). PURE · idempotent · TOTAL · never raises · no I/O.
|
|
318
|
+
|
|
319
|
+
A state lacking the `active_milestones` key gains it — DERIVED from the scalar
|
|
320
|
+
`active_milestone` (grandfather-by-missing-key, mirroring `_setup_locked`): None -> [],
|
|
321
|
+
"x" -> ["x"]. A per-milestone active-task map `active_tasks` is added; the old global
|
|
322
|
+
`active_task` is placed under its owning active milestone only when it genuinely belongs
|
|
323
|
+
there, else it stays as the top-level scalar fallback (orphan rule, FROZEN decision (a)).
|
|
324
|
+
The scalar `active_milestone` / `active_task` keys are KEPT as the N<=1 mirror so the
|
|
325
|
+
not-yet-routed readers keep working. An already-migrated state (key present) is returned
|
|
326
|
+
unchanged — never re-derived, never clobbered. Corrupt parsing stays the loader's job.
|
|
327
|
+
|
|
328
|
+
PURE in the observable sense: the caller's dict is NEVER mutated — a state that needs
|
|
329
|
+
migrating is upgraded on a fresh top-level copy (nested objects are shared but only read)."""
|
|
330
|
+
if not isinstance(state, dict) or "active_milestones" in state:
|
|
331
|
+
return state
|
|
332
|
+
migrated = dict(state)
|
|
333
|
+
active_ms = migrated.get("active_milestone")
|
|
334
|
+
migrated["active_milestones"] = [] if active_ms is None else [active_ms]
|
|
335
|
+
active_task = migrated.get("active_task")
|
|
336
|
+
tasks = migrated.get("tasks") or {}
|
|
337
|
+
owns = (active_ms is not None and active_task is not None
|
|
338
|
+
and isinstance(tasks.get(active_task), dict)
|
|
339
|
+
and tasks[active_task].get("milestone") == active_ms)
|
|
340
|
+
migrated["active_tasks"] = {active_ms: active_task} if owns else {}
|
|
341
|
+
return migrated
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# --- active milestone/task accessor seam (multi-active foundation) -----------
|
|
345
|
+
#
|
|
346
|
+
# Every engine call site reads & writes the active milestone(s)/task through these
|
|
347
|
+
# four helpers, so multi-active SEMANTICS can land in one place later. Today they
|
|
348
|
+
# preserve single-active behavior exactly: reads return the scalar mirror, writes
|
|
349
|
+
# keep the scalar AND the new active_milestones/active_tasks structures in sync.
|
|
350
|
+
|
|
351
|
+
def _active_milestone(state: dict) -> str | None:
|
|
352
|
+
"""The primary active milestone — the N<=1 scalar mirror (== active_milestones[0])."""
|
|
353
|
+
return state.get("active_milestone")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _active_task(state: dict, milestone: str | None = None) -> str | None:
|
|
357
|
+
"""The active task: per-milestone when `milestone` is given (partial-state -> None),
|
|
358
|
+
else the global/primary scalar active task. Total — never raises."""
|
|
359
|
+
if milestone is None:
|
|
360
|
+
return state.get("active_task")
|
|
361
|
+
return (state.get("active_tasks") or {}).get(milestone)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _set_active_milestone(state: dict, slug: str | None) -> None:
|
|
365
|
+
"""Set the primary active milestone, keeping `active_milestones` consistent (N<=1 sync)."""
|
|
366
|
+
state["active_milestone"] = slug
|
|
367
|
+
state["active_milestones"] = [] if slug is None else [slug]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _set_active_task(state: dict, slug: str | None, milestone: str | None = None) -> None:
|
|
371
|
+
"""Set the active task, keeping the scalar mirror AND the per-milestone map in sync.
|
|
372
|
+
With no owning active milestone the active task is scalar-only (the migration's orphan
|
|
373
|
+
rule); clearing (slug is None) pops the milestone's entry."""
|
|
374
|
+
state["active_task"] = slug
|
|
375
|
+
ms = milestone if milestone is not None else _active_milestone(state)
|
|
376
|
+
tasks_map = state.setdefault("active_tasks", {})
|
|
377
|
+
if ms is None:
|
|
378
|
+
return
|
|
379
|
+
if slug is None:
|
|
380
|
+
tasks_map.pop(ms, None)
|
|
381
|
+
else:
|
|
382
|
+
tasks_map[ms] = slug
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _activate_milestone(state: dict, slug: str) -> None:
|
|
386
|
+
"""Add a milestone to the active SET (idempotent) and make it the primary focus,
|
|
387
|
+
syncing the scalar active_task to that milestone's entry. Does NOT remove other members
|
|
388
|
+
(this is how a user reaches N>=2 active milestones)."""
|
|
389
|
+
ms_list = state.setdefault("active_milestones", [])
|
|
390
|
+
if slug not in ms_list:
|
|
391
|
+
ms_list.append(slug)
|
|
392
|
+
state["active_milestone"] = slug
|
|
393
|
+
state["active_task"] = (state.get("active_tasks") or {}).get(slug)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _deactivate_milestone(state: dict, slug: str) -> None:
|
|
397
|
+
"""Remove a milestone from the active SET, pop its active-task entry, and (if it was the
|
|
398
|
+
primary) repoint the primary to the most-recent remaining member (or None when empty)."""
|
|
399
|
+
ms_list = state.setdefault("active_milestones", [])
|
|
400
|
+
if slug in ms_list:
|
|
401
|
+
ms_list.remove(slug)
|
|
402
|
+
(state.setdefault("active_tasks", {})).pop(slug, None)
|
|
403
|
+
if state.get("active_milestone") == slug:
|
|
404
|
+
new = ms_list[-1] if ms_list else None
|
|
405
|
+
state["active_milestone"] = new
|
|
406
|
+
state["active_task"] = (state.get("active_tasks") or {}).get(new) if new else None
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _git_config(key: str) -> str | None:
|
|
410
|
+
"""Read one `git config --get <key>`, STRICTLY fail-soft: the engine's FIRST git call,
|
|
411
|
+
so it never raises, never hangs, never shells. Returns the trimmed value, or None when
|
|
412
|
+
git is absent / errors / times out / the value is empty."""
|
|
413
|
+
if shutil.which("git") is None:
|
|
414
|
+
return None
|
|
415
|
+
try:
|
|
416
|
+
out = subprocess.run(
|
|
417
|
+
["git", "config", "--get", key],
|
|
418
|
+
capture_output=True, text=True, timeout=2,
|
|
419
|
+
).stdout.strip()
|
|
420
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
421
|
+
# OSError: git vanished between which() and run() / spawn error · SubprocessError:
|
|
422
|
+
# TimeoutExpired · ValueError: a non-UTF-8 config value (latin-1 legacy name) makes
|
|
423
|
+
# text=True decoding raise UnicodeDecodeError (a ValueError) — all fail soft to None.
|
|
424
|
+
return None
|
|
425
|
+
return out or None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _os_user() -> str:
|
|
429
|
+
"""The guaranteed non-empty OS floor. getpass.getuser() reads LOGNAME/USER/... then
|
|
430
|
+
falls back to the passwd database — but in a bare container (no env var AND no passwd
|
|
431
|
+
entry) CPython raises KeyError (OSError only on 3.13+). Catch broadly and return a
|
|
432
|
+
sentinel so _whoami stays TOTAL: it always yields a non-empty name, never crashes."""
|
|
433
|
+
try:
|
|
434
|
+
return getpass.getuser() or "unknown"
|
|
435
|
+
except (KeyError, OSError):
|
|
436
|
+
return "unknown"
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _whoami(state: dict) -> dict:
|
|
440
|
+
"""Resolve the current git-native ACTOR -> {name, email, source}. Priority:
|
|
441
|
+
(1) an `actor_override` (whoami --set) with a non-blank name -> source 'override';
|
|
442
|
+
(2) `git config user.name`/`user.email` -> source 'git';
|
|
443
|
+
(3) the OS user (_os_user) -> source 'os', the guaranteed non-empty floor.
|
|
444
|
+
Total: always returns a dict with a non-empty name; `email` may be None."""
|
|
445
|
+
ov = state.get("actor_override")
|
|
446
|
+
if ov and (ov.get("name") or "").strip():
|
|
447
|
+
return {"name": ov["name"], "email": ov.get("email"), "source": "override"}
|
|
448
|
+
name = _git_config("user.name")
|
|
449
|
+
if name:
|
|
450
|
+
return {"name": name, "email": _git_config("user.email"), "source": "git"}
|
|
451
|
+
return {"name": _os_user(), "email": None, "source": "os"}
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _actor_stamp(state: dict) -> dict:
|
|
455
|
+
"""The SINGLE source of the structured-actor stamp every engine-WRITTEN human action
|
|
456
|
+
records — lock · gate · milestone-done · release (user-identity actor-stamping). It IS
|
|
457
|
+
`_whoami(state)`: a TOTAL {name,email,source} (always a non-empty name), so a stamp can
|
|
458
|
+
never fail or block a write. Descriptive only — no command's decision reads it."""
|
|
459
|
+
return _whoami(state)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _render_actor_line(state: dict) -> str:
|
|
463
|
+
"""Render the actor stamp as one human-readable line: name, an optional angle-bracketed
|
|
464
|
+
email, then the source in parens — used on the RELEASES.md row (no state.json write)."""
|
|
465
|
+
a = _actor_stamp(state)
|
|
466
|
+
email = f" <{a['email']}>" if a.get("email") else ""
|
|
467
|
+
return f"{a['name']}{email} ({a['source']})"
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _parse_actor_arg(s: str) -> dict:
|
|
471
|
+
"""Parse an `assign --owner`/`--assignee` value into a {name, email, source: "assigned"}
|
|
472
|
+
actor (ownership-assignment). "Name <email>" -> both; a bare "Name" -> email None. TOTAL:
|
|
473
|
+
a malformed value (no closing bracket) never raises — the whole stripped string is the name.
|
|
474
|
+
`source` is "assigned" — a human typed this name (not git-resolved nor an ADD override)."""
|
|
475
|
+
m = re.match(r"^\s*(.*?)\s*<([^>]*)>\s*$", s)
|
|
476
|
+
if m:
|
|
477
|
+
return {"name": m.group(1), "email": m.group(2) or None, "source": "assigned"}
|
|
478
|
+
return {"name": s.strip(), "email": None, "source": "assigned"}
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _actor_matches(rec_actor: dict | None, me: dict) -> bool:
|
|
482
|
+
"""Does a recorded owner/assignee actor identify the SAME person as `me` (multi-active-UX)?
|
|
483
|
+
Email-first (the stabler key): when BOTH carry a non-empty email, emails decide; otherwise
|
|
484
|
+
fall back to name-equality. Both comparisons are stripped + case-insensitive. TOTAL — a None,
|
|
485
|
+
non-dict, or blank-name record returns False (an unowned/garbage slot is no one's)."""
|
|
486
|
+
if not isinstance(rec_actor, dict):
|
|
487
|
+
return False
|
|
488
|
+
rec_name = (rec_actor.get("name") or "").strip()
|
|
489
|
+
if not rec_name:
|
|
490
|
+
return False
|
|
491
|
+
rec_email = (rec_actor.get("email") or "").strip()
|
|
492
|
+
me_email = (me.get("email") or "").strip()
|
|
493
|
+
if rec_email and me_email:
|
|
494
|
+
return rec_email.lower() == me_email.lower()
|
|
495
|
+
return rec_name.lower() == (me.get("name") or "").strip().lower()
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _my_work(state: dict, me: dict) -> list[dict]:
|
|
499
|
+
"""The "my work" lens (multi-active-UX): across ALL active milestones, the NOT-done tasks
|
|
500
|
+
whose owner OR assignee is `me`. Returns ordered rows {slug, milestone, phase, role} with
|
|
501
|
+
role in {owner, assignee, both}, sorted by active-milestone order then slug. PURE · no I/O."""
|
|
502
|
+
active = list(state.get("active_milestones") or [])
|
|
503
|
+
active_set = set(active)
|
|
504
|
+
tasks = state.get("tasks") if isinstance(state.get("tasks"), dict) else {}
|
|
505
|
+
rows: list[dict] = []
|
|
506
|
+
for slug, t in tasks.items():
|
|
507
|
+
if not isinstance(t, dict) or t.get("milestone") not in active_set or _task_done(t):
|
|
508
|
+
continue
|
|
509
|
+
owns = _actor_matches(t.get("owner"), me)
|
|
510
|
+
assigned = _actor_matches(t.get("assignee"), me)
|
|
511
|
+
if not (owns or assigned):
|
|
512
|
+
continue
|
|
513
|
+
role = "both" if owns and assigned else ("owner" if owns else "assignee")
|
|
514
|
+
rows.append({"slug": slug, "milestone": t.get("milestone"),
|
|
515
|
+
"phase": t.get("phase"), "role": role})
|
|
516
|
+
order = {m: i for i, m in enumerate(active)}
|
|
517
|
+
rows.sort(key=lambda r: (order.get(r["milestone"], len(order)), r["slug"]))
|
|
518
|
+
return rows
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# A git conflict marker BEGINS a line with 7 of `<`, `=`, or `>` (`(?m)^…`). An unresolved
|
|
522
|
+
# merge writes these into state.json, making it invalid JSON; the line-anchor keeps a
|
|
523
|
+
# legitimate value (always on an INDENTED JSON line) from false-tripping the guard.
|
|
524
|
+
_CONFLICT_MARKER_RE = re.compile(r"(?m)^(<{7}|={7}|>{7})")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _state_text_or_die(root: Path) -> str:
|
|
528
|
+
"""Read state.json's raw text, failing CLOSED with a merge-SPECIFIC `state_conflicted`
|
|
529
|
+
message when it carries git conflict markers (an unresolved merge — the major's #1 failure
|
|
530
|
+
mode). A genuine read OSError is NOT swallowed: it propagates to the caller, which maps it
|
|
531
|
+
to its own existing code (state_invalid / no_state). The guard only READS — never writes."""
|
|
532
|
+
text = (root / STATE_FILE).read_text(encoding="utf-8")
|
|
533
|
+
if _CONFLICT_MARKER_RE.search(text):
|
|
534
|
+
_die(f"state_conflicted: {root / STATE_FILE} has unresolved git merge markers "
|
|
535
|
+
f"(<<<<<<< / ======= / >>>>>>>) — resolve them (or "
|
|
536
|
+
f"`git checkout --ours/--theirs {STATE_FILE}`), then run `add.py doctor` to verify")
|
|
537
|
+
return text
|
|
538
|
+
|
|
539
|
+
|
|
241
540
|
def load_state(root: Path) -> dict:
|
|
242
|
-
"""Load + parse state.json, failing CLOSED. A
|
|
243
|
-
dies with a clean 'state_invalid'
|
|
244
|
-
command that loads state degrades gracefully
|
|
541
|
+
"""Load + parse state.json, failing CLOSED. A git-conflicted file dies with a merge-specific
|
|
542
|
+
'state_conflicted'; any other corrupt/unreadable file dies with a clean 'state_invalid'
|
|
543
|
+
message (never a raw traceback), so every command that loads state degrades gracefully
|
|
544
|
+
(design-for-failure). The parsed state is forward-migrated to the multi-active schema."""
|
|
245
545
|
try:
|
|
246
|
-
return json.loads((root
|
|
546
|
+
return _migrate_state(json.loads(_state_text_or_die(root)))
|
|
247
547
|
except (json.JSONDecodeError, OSError) as e:
|
|
248
548
|
_die(f"state_invalid: {root / STATE_FILE} is corrupt or unreadable "
|
|
249
549
|
f"({e.__class__.__name__}) — restore it from git or a backup")
|
|
@@ -252,12 +552,13 @@ def load_state(root: Path) -> dict:
|
|
|
252
552
|
def _load_state_for_json() -> tuple[Path, dict]:
|
|
253
553
|
"""Fail-closed state load for `--json` paths: a missing project or unparseable
|
|
254
554
|
state.json -> `no_state` on stderr + exit 1, with EMPTY stdout (never a partial
|
|
255
|
-
JSON object a harness might parse). Built from State only — reads no docs/ chapter.
|
|
555
|
+
JSON object a harness might parse). Built from State only — reads no docs/ chapter.
|
|
556
|
+
The parsed state is forward-migrated to the multi-active schema before it is returned."""
|
|
256
557
|
root = find_root()
|
|
257
558
|
if root is None:
|
|
258
559
|
_die("no_state")
|
|
259
560
|
try:
|
|
260
|
-
return root, json.loads((root
|
|
561
|
+
return root, _migrate_state(json.loads(_state_text_or_die(root)))
|
|
261
562
|
except (json.JSONDecodeError, OSError):
|
|
262
563
|
_die("no_state")
|
|
263
564
|
|
|
@@ -284,6 +585,80 @@ def _setup_locked(state: dict) -> bool:
|
|
|
284
585
|
return ("setup" not in state) or (state["setup"].get("locked") is True)
|
|
285
586
|
|
|
286
587
|
|
|
588
|
+
def _milestone_confirmed(state: dict, mslug: str) -> bool:
|
|
589
|
+
"""True when milestone `mslug` is confirmed — i.e. the new-task gate is OPEN.
|
|
590
|
+
|
|
591
|
+
Mirrors `_setup_locked` one level down. A milestone record with NO "confirmed" key is
|
|
592
|
+
GRANDFATHERED-confirmed: every milestone created WITHOUT `--await-confirm` (and every
|
|
593
|
+
pre-existing one) is never gated. Opt-in: `new-milestone --await-confirm` seeds confirmed:false,
|
|
594
|
+
so the gate is active in exactly one case: the record is present AND confirmed is False. An
|
|
595
|
+
unknown milestone is treated as confirmed here (existence is cmd_new_task's separate check)."""
|
|
596
|
+
m = (state.get("milestones") or {}).get(mslug)
|
|
597
|
+
if not isinstance(m, dict) or "confirmed" not in m:
|
|
598
|
+
return True
|
|
599
|
+
return m["confirmed"] is True
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _section_unfilled(md_text: str, header: str) -> bool:
|
|
603
|
+
"""True iff the `header` section is PRESENT but UNFILLED — empty (no real bullet) or
|
|
604
|
+
still a `<…>` template placeholder. ABSENT section -> False (grandfathered legacy);
|
|
605
|
+
a filled section (>=1 real bullet, no `<…>`) -> False. Pure predicate — the shared
|
|
606
|
+
placeholder test the fill gates use (contract-fill at confirm; build-expectations at build)."""
|
|
607
|
+
body, in_sec, present = [], False, False
|
|
608
|
+
for ln in md_text.splitlines():
|
|
609
|
+
if ln.startswith(header):
|
|
610
|
+
in_sec, present = True, True
|
|
611
|
+
continue
|
|
612
|
+
if in_sec:
|
|
613
|
+
if ln.startswith("#"): # ANY next header (## or ###) ends our section
|
|
614
|
+
break
|
|
615
|
+
if ln.lstrip().startswith(">"): # skip blockquote GUIDANCE — it is not content
|
|
616
|
+
continue
|
|
617
|
+
body.append(ln)
|
|
618
|
+
if not present:
|
|
619
|
+
return False # absent -> grandfather
|
|
620
|
+
text = "\n".join(body).strip()
|
|
621
|
+
if not text:
|
|
622
|
+
return True # present but empty
|
|
623
|
+
return bool(re.search(r"<[^>\n]+>", text)) # a <…> placeholder remains
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None:
|
|
627
|
+
"""Write-back (gate-record-writeback): mirror the resolved gate verdict into the task's
|
|
628
|
+
§6 `### GATE RECORD`, so the file and state.json never silently diverge (Finding C). Runs
|
|
629
|
+
for EVERY task — the write is ADDITIVE and never refuses, so (unlike the two refusal gates)
|
|
630
|
+
it needs no `--await-confirm` opt-in to protect the census. GRANDFATHER is the safety: a
|
|
631
|
+
GATE RECORD line is rewritten ONLY while it still holds a `<…>` placeholder; a resolved
|
|
632
|
+
(hand-filled) line is byte-untouched. No GATE RECORD block / no placeholder line / an
|
|
633
|
+
unreadable file -> silent no-op, the file stays byte-identical. Called AFTER save_state —
|
|
634
|
+
state is the source of truth; the file only mirrors it, so a write fault never loses a verdict."""
|
|
635
|
+
f = root / "tasks" / slug / "TASK.md"
|
|
636
|
+
try:
|
|
637
|
+
text = f.read_text(encoding="utf-8")
|
|
638
|
+
except OSError:
|
|
639
|
+
return # unreadable -> no-op (never blocks the gate)
|
|
640
|
+
if "### GATE RECORD" not in text:
|
|
641
|
+
return # nothing to mirror into
|
|
642
|
+
actor = _actor_stamp(state)
|
|
643
|
+
today = date.today().isoformat()
|
|
644
|
+
# each rule matches ONLY a line still carrying a `<…>` placeholder -> grandfather a resolved line.
|
|
645
|
+
rules = [
|
|
646
|
+
(r"(?m)^(Outcome:[ \t]*)<[^>\n]*>.*$", f"Outcome: {outcome}"),
|
|
647
|
+
(r"(?m)^Reviewed by:[ \t]*.*<[^>\n]*>.*$",
|
|
648
|
+
f"Reviewed by: {actor['name']} · date: {today}"),
|
|
649
|
+
]
|
|
650
|
+
if outcome == "RISK-ACCEPTED":
|
|
651
|
+
w = ((state.get("tasks") or {}).get(slug) or {}).get("waiver") or {}
|
|
652
|
+
rules.append((r"(?m)^If RISK-ACCEPTED ->.*<[^>\n]*>.*$",
|
|
653
|
+
f"If RISK-ACCEPTED -> owner: {w.get('owner', '?')} · "
|
|
654
|
+
f"ticket: {w.get('ticket', '?')} · expires: {w.get('expires', '?')}"))
|
|
655
|
+
new = text
|
|
656
|
+
for pat, repl in rules:
|
|
657
|
+
new = re.sub(pat, repl, new, count=1)
|
|
658
|
+
if new != text: # no-op = no write (mtime stable)
|
|
659
|
+
_atomic_write(f, new)
|
|
660
|
+
|
|
661
|
+
|
|
287
662
|
def _die(msg: str, code: int = 1) -> None:
|
|
288
663
|
print(f"add: error: {msg}", file=sys.stderr)
|
|
289
664
|
raise SystemExit(code)
|
|
@@ -453,6 +828,8 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
453
828
|
"stage": args.stage,
|
|
454
829
|
"active_task": None,
|
|
455
830
|
"active_milestone": None,
|
|
831
|
+
"active_milestones": [],
|
|
832
|
+
"active_tasks": {},
|
|
456
833
|
"tasks": {},
|
|
457
834
|
"milestones": {},
|
|
458
835
|
"created": _now(),
|
|
@@ -499,9 +876,15 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
499
876
|
_die(f"task '{slug}' already exists (use --force to overwrite TASK.md)")
|
|
500
877
|
|
|
501
878
|
# link to a milestone (explicit, or the active one) — validate before any write
|
|
502
|
-
milestone = getattr(args, "milestone", None) or state
|
|
879
|
+
milestone = getattr(args, "milestone", None) or _active_milestone(state)
|
|
503
880
|
if milestone and milestone not in state.get("milestones", {}):
|
|
504
881
|
_die("unknown_milestone")
|
|
882
|
+
# confirm-parent gate (OPT-IN): a task may not be detailed before its parent milestone is
|
|
883
|
+
# human-confirmed — but ONLY when the milestone opted in via `new-milestone --await-confirm`.
|
|
884
|
+
# validate-then-write — refuse BEFORE any scaffold/state mutation. A milestone with no
|
|
885
|
+
# `confirmed` key (non-flag + pre-existing) is grandfathered (mirrors the setup-lock).
|
|
886
|
+
if milestone and not _milestone_confirmed(state, milestone):
|
|
887
|
+
_die(f"milestone_unconfirmed: confirm it first — add.py milestone-confirm {milestone}")
|
|
505
888
|
depends_on = _parse_deps(getattr(args, "depends_on", None))
|
|
506
889
|
|
|
507
890
|
# SEED (--from-delta): resolve a prior task's FIRST open SPEC delta into THIS task.
|
|
@@ -509,16 +892,25 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
509
892
|
# seeded flip NOW (before any write); the slug-free check above has already passed, so
|
|
510
893
|
# the only writes below are the new TASK.md, then the prior flip, then state.
|
|
511
894
|
from_delta = getattr(args, "from_delta", None)
|
|
895
|
+
match = getattr(args, "match", None)
|
|
896
|
+
if match and not from_delta: # --match targets the PRIOR's delta
|
|
897
|
+
_die("match_requires_from_delta: --match needs --from-delta <prior> (it selects the "
|
|
898
|
+
"prior task's open SPEC delta to seed)")
|
|
512
899
|
feature_override = prior_md = flipped_prior = None
|
|
513
900
|
if from_delta:
|
|
514
901
|
prior = _resolve_task(state, from_delta) # unknown prior -> _die
|
|
515
902
|
prior_md = root / "tasks" / prior / "TASK.md"
|
|
516
903
|
prior_text = prior_md.read_text(encoding="utf-8")
|
|
517
|
-
delta_text =
|
|
518
|
-
if
|
|
904
|
+
status, idx, delta_text = _select_spec_delta(prior_text, match)
|
|
905
|
+
if status == "no_open":
|
|
519
906
|
_die(f"no_open_spec_delta: task '{prior}' has no open SPEC delta to seed")
|
|
907
|
+
if status == "no_match":
|
|
908
|
+
_die(f"no_matching_spec_delta: no open SPEC delta in '{prior}' matches --match '{match}'")
|
|
909
|
+
if status == "ambiguous":
|
|
910
|
+
_die(f"ambiguous_spec_match: --match '{match}' matches multiple open SPEC deltas in "
|
|
911
|
+
f"'{prior}' — narrow it")
|
|
520
912
|
feature_override = f"{delta_text} (from {prior} spec-delta)"
|
|
521
|
-
flipped_prior = _resolve_spec_delta(prior_text, "seeded", pointer=slug)
|
|
913
|
+
flipped_prior = _resolve_spec_delta(prior_text, "seeded", pointer=slug, line_index=idx)
|
|
522
914
|
|
|
523
915
|
(tdir / "tests").mkdir(parents=True, exist_ok=True)
|
|
524
916
|
(tdir / "src").mkdir(parents=True, exist_ok=True)
|
|
@@ -526,15 +918,21 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
526
918
|
# inherit the project's DECLARED autonomy default (task init-auto-default) — fail-SAFE:
|
|
527
919
|
# absent -> auto, garbled -> conservative; the posture is project-scoped, not hardcoded.
|
|
528
920
|
autonomy = _project_autonomy(root)
|
|
921
|
+
# fast lane (fast-new-task-flag): --fast scaffolds the MINIMAL template instead of the full one.
|
|
922
|
+
# The human opts in explicitly (the engine never guesses ceremony); the freeze floor is held by
|
|
923
|
+
# the freeze-before-build gate's fast arm (cmd_advance), so the lighter shape never drops the trust seam.
|
|
924
|
+
fast = bool(getattr(args, "fast", False))
|
|
529
925
|
rendered = _render_template(
|
|
530
|
-
"TASK.md"
|
|
926
|
+
"TASK.fast.md" if fast else "TASK.md",
|
|
927
|
+
title=title, slug=slug, date=date.today().isoformat(),
|
|
531
928
|
stage=state["stage"], autonomy=autonomy)
|
|
532
929
|
if feature_override: # pre-fill §1 from the seeded delta
|
|
533
930
|
rendered = re.sub(r"(?m)^Feature:.*$",
|
|
534
931
|
lambda _m: f"Feature: {feature_override}", rendered, count=1)
|
|
535
|
-
|
|
932
|
+
seed_writes: list[tuple[Path, str]] = [(task_md, rendered)]
|
|
536
933
|
if flipped_prior is not None: # consume the source delta -> seeded
|
|
537
|
-
|
|
934
|
+
seed_writes.append((prior_md, flipped_prior))
|
|
935
|
+
_atomic_write_many(seed_writes) # new TASK.md + consumed source as one commit
|
|
538
936
|
if _project_autonomy_token(root) == "?":
|
|
539
937
|
print("warning: garbled_project_autonomy — PROJECT.md declares an unrecognized "
|
|
540
938
|
f"autonomy token; new task seeded fail-safe '{autonomy}' "
|
|
@@ -551,7 +949,9 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
551
949
|
}
|
|
552
950
|
if from_delta:
|
|
553
951
|
state["tasks"][slug]["from_delta"] = from_delta # lineage: seeded from <prior>
|
|
554
|
-
|
|
952
|
+
if fast:
|
|
953
|
+
state["tasks"][slug]["fast"] = True # durable lane marker (absent == not-fast)
|
|
954
|
+
_set_active_task(state, slug, milestone)
|
|
555
955
|
save_state(root, state)
|
|
556
956
|
print(f"created task '{slug}' -> {task_md}")
|
|
557
957
|
if milestone:
|
|
@@ -579,11 +979,19 @@ def cmd_drop_delta(args: argparse.Namespace) -> None:
|
|
|
579
979
|
state = load_state(root)
|
|
580
980
|
slug = _resolve_task(state, args.slug) # unknown task -> _die
|
|
581
981
|
task_md = root / "tasks" / slug / "TASK.md"
|
|
582
|
-
|
|
583
|
-
|
|
982
|
+
text = task_md.read_text(encoding="utf-8")
|
|
983
|
+
match = getattr(args, "match", None)
|
|
984
|
+
status, idx, _disp = _select_spec_delta(text, match)
|
|
985
|
+
if status == "no_open":
|
|
584
986
|
_die(f"no_open_spec_delta: task '{slug}' has no open SPEC delta to drop")
|
|
987
|
+
if status == "no_match":
|
|
988
|
+
_die(f"no_matching_spec_delta: no open SPEC delta in '{slug}' matches --match '{match}'")
|
|
989
|
+
if status == "ambiguous":
|
|
990
|
+
_die(f"ambiguous_spec_match: --match '{match}' matches multiple open SPEC deltas in "
|
|
991
|
+
f"'{slug}' — narrow it")
|
|
992
|
+
new_text = _resolve_spec_delta(text, "dropped", line_index=idx)
|
|
585
993
|
_atomic_write(task_md, new_text)
|
|
586
|
-
print(f"dropped the first open SPEC delta in '{slug}' -> [SPEC · dropped]")
|
|
994
|
+
print(f"dropped the {'matched' if match else 'first'} open SPEC delta in '{slug}' -> [SPEC · dropped]")
|
|
587
995
|
print(_next_footer(root, state))
|
|
588
996
|
|
|
589
997
|
|
|
@@ -618,7 +1026,7 @@ def _archived_task_slugs(state: dict) -> set[str]:
|
|
|
618
1026
|
|
|
619
1027
|
|
|
620
1028
|
def _resolve_task(state: dict, slug: str | None) -> str:
|
|
621
|
-
slug = slug or state
|
|
1029
|
+
slug = slug or _active_task(state)
|
|
622
1030
|
if not slug:
|
|
623
1031
|
_die("no task specified and no active task set")
|
|
624
1032
|
if slug not in state["tasks"]:
|
|
@@ -659,7 +1067,29 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
659
1067
|
# freezes — the unmarked predecessors are never retro-redded). REFUSE writes
|
|
660
1068
|
# nothing (fail-closed); below the build boundary the flag is never checked.
|
|
661
1069
|
if nxt == "build":
|
|
1070
|
+
# the OPTED-IN crossing guards (fast-lane + flow-enforcement): a task whose PARENT
|
|
1071
|
+
# milestone opted into --await-confirm is held to the trust floor at tests->build. A
|
|
1072
|
+
# task under a plain / no milestone is never gated (every existing advance-to-build flow
|
|
1073
|
+
# stays green). validate-then-write — every refusal runs BEFORE the tripwire/scope
|
|
1074
|
+
# snapshots below, writing nothing; the task stays at `tests`.
|
|
1075
|
+
_ms = state["tasks"][slug].get("milestone")
|
|
1076
|
+
_optin = bool(_ms) and (state.get("milestones") or {}).get(_ms, {}).get("await_confirm") is True
|
|
662
1077
|
raw3 = _raw_phase_bodies(root, slug).get(3, "")
|
|
1078
|
+
# freeze-before-build gate (fast-lane): "collapse-never-skip" made REAL — the freeze is
|
|
1079
|
+
# engine-enforced for an opted-in milestone OR any --fast task (the fast arm, fast-new-task-flag:
|
|
1080
|
+
# a fast task is held to the floor under ANY milestone, so the lighter lane never drops the
|
|
1081
|
+
# trust seam). PRECEDES build-expectations: you freeze §3 before pre-declaring §6's outcomes.
|
|
1082
|
+
_freeze_gated = _optin or state["tasks"][slug].get("fast") is True
|
|
1083
|
+
if _freeze_gated and not _contract_frozen(raw3):
|
|
1084
|
+
_die("contract_not_frozen: freeze §3 before crossing into build — approve "
|
|
1085
|
+
f"the contract in {slug}'s TASK.md (Status: FROZEN @ vN)")
|
|
1086
|
+
# build-expectations gate (flow-enforcement): an opted-in task may not enter build until
|
|
1087
|
+
# its §6 `### Build expectations` are pre-declared — so verify checks the build is RIGHT,
|
|
1088
|
+
# not just green. Same opt-in switch as the contract-fill gate, one level out.
|
|
1089
|
+
if _optin:
|
|
1090
|
+
if _section_unfilled(_raw_phase_bodies(root, slug).get(6, ""), "### Build expectations"):
|
|
1091
|
+
_die("build_expectations_unfilled: fill the §6 '### Build expectations' block "
|
|
1092
|
+
f"of {slug}'s TASK.md before crossing into build")
|
|
663
1093
|
if _contract_frozen(raw3):
|
|
664
1094
|
if not _flag_well_formed(raw3):
|
|
665
1095
|
_die("unflagged_freeze: a frozen §3 must surface a well-formed "
|
|
@@ -699,6 +1129,15 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
699
1129
|
_sync_task_marker(root, slug, nxt)
|
|
700
1130
|
save_state(root, state)
|
|
701
1131
|
print(f"task '{slug}' phase {cur} -> {nxt}")
|
|
1132
|
+
if nxt == "observe":
|
|
1133
|
+
# OBSERVE is where this loop's lessons get captured (TASK.md §7) — suggest routing
|
|
1134
|
+
# them into PROJECT.md right away (a per-task fold is engine-legal; otherwise the
|
|
1135
|
+
# lessons sit unconsolidated until milestone close). Additive: fires only at this
|
|
1136
|
+
# one transition, and the human still decides whether/when to run it. The verb word
|
|
1137
|
+
# is interpolated via _FOLD_VERB so the domain wording-lint sees no bare slang.
|
|
1138
|
+
print(" note: record the lessons this loop taught the foundation in §7 "
|
|
1139
|
+
"OBSERVE, then update PROJECT.md when ready:")
|
|
1140
|
+
print(f" add.py {_FOLD_VERB} --task {slug} (review first: add.py deltas)")
|
|
702
1141
|
print(_next_footer(root, state))
|
|
703
1142
|
|
|
704
1143
|
|
|
@@ -835,8 +1274,10 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
835
1274
|
state["tasks"][slug]["phase"] = "done"
|
|
836
1275
|
_sync_task_marker(root, slug, "done")
|
|
837
1276
|
state["tasks"][slug]["gate"] = args.outcome
|
|
1277
|
+
state["tasks"][slug]["gate_actor"] = _actor_stamp(state) # WHO recorded the verdict (every outcome)
|
|
838
1278
|
state["tasks"][slug]["updated"] = _now()
|
|
839
1279
|
save_state(root, state)
|
|
1280
|
+
_stamp_gate_record(root, state, slug, args.outcome) # mirror the verdict into §6 (Finding C)
|
|
840
1281
|
print(f"task '{slug}' gate -> {args.outcome}")
|
|
841
1282
|
# the engine-sourced next step (next-footer-engine): a completing gate hands off to the
|
|
842
1283
|
# state arm; HARD-STOP routes to "resolve HARD-STOP …" — converging the old bespoke line.
|
|
@@ -1002,7 +1443,8 @@ def cmd_lock(args: argparse.Namespace) -> None:
|
|
|
1002
1443
|
who = args.by or getpass.getuser()
|
|
1003
1444
|
when = _now()
|
|
1004
1445
|
# ONE atomic write — no partial lock state.
|
|
1005
|
-
state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers
|
|
1446
|
+
state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers,
|
|
1447
|
+
"actor": _actor_stamp(state)} # structured actor alongside the free-text locked_by
|
|
1006
1448
|
save_state(root, state)
|
|
1007
1449
|
if getattr(args, "json", False):
|
|
1008
1450
|
print(json.dumps(
|
|
@@ -1013,6 +1455,92 @@ def cmd_lock(args: argparse.Namespace) -> None:
|
|
|
1013
1455
|
print(_next_footer(root, state))
|
|
1014
1456
|
|
|
1015
1457
|
|
|
1458
|
+
def cmd_whoami(args: argparse.Namespace) -> None:
|
|
1459
|
+
"""Show / set / unset the git-native ACTOR — the identity every human-owned stamp reads.
|
|
1460
|
+
No flags: print the resolved actor (override -> git -> os). --name/--email: store an
|
|
1461
|
+
override. --unset: clear it. Validates before mutating (a reject leaves state unchanged)."""
|
|
1462
|
+
root = _require_root()
|
|
1463
|
+
state = load_state(root)
|
|
1464
|
+
if args.unset:
|
|
1465
|
+
if "actor_override" not in state:
|
|
1466
|
+
_die("no_actor_override")
|
|
1467
|
+
del state["actor_override"]
|
|
1468
|
+
save_state(root, state)
|
|
1469
|
+
elif args.name is not None:
|
|
1470
|
+
if not args.name.strip():
|
|
1471
|
+
_die("actor_name_blank")
|
|
1472
|
+
state["actor_override"] = {"name": args.name, "email": args.email or None}
|
|
1473
|
+
save_state(root, state)
|
|
1474
|
+
who = _whoami(state)
|
|
1475
|
+
if getattr(args, "json", False):
|
|
1476
|
+
print(json.dumps(who, separators=(",", ":")))
|
|
1477
|
+
return
|
|
1478
|
+
email = f" <{who['email']}>" if who.get("email") else ""
|
|
1479
|
+
print(f"actor : {who['name']}{email} (source: {who['source']})")
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
def _ownership_record(state: dict, slug: str) -> dict | None:
|
|
1483
|
+
"""Resolve the record a slug names for ownership — a TASK first, else a MILESTONE
|
|
1484
|
+
(tasks win, matching cmd_use/report precedent). None if neither exists."""
|
|
1485
|
+
rec = state.get("tasks", {}).get(slug)
|
|
1486
|
+
if rec is not None:
|
|
1487
|
+
return rec
|
|
1488
|
+
return state.get("milestones", {}).get(slug)
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def cmd_assign(args: argparse.Namespace) -> None:
|
|
1492
|
+
"""Assign an OWNER (accountable) and/or ASSIGNEE (working it) to a task or milestone
|
|
1493
|
+
(ownership-assignment). No role flag -> set BOTH to the resolved self (_whoami); --owner/
|
|
1494
|
+
--assignee "Name <email>" -> set that role only (partial update). Descriptive, never a gate.
|
|
1495
|
+
Validate-before-mutate: a reject leaves state.json byte-identical (no partial write)."""
|
|
1496
|
+
root = _require_root()
|
|
1497
|
+
state = load_state(root)
|
|
1498
|
+
rec = _ownership_record(state, args.slug)
|
|
1499
|
+
if rec is None:
|
|
1500
|
+
_die("unknown_slug")
|
|
1501
|
+
# parse + validate ALL flags BEFORE the first write — a blank name is rejected on the
|
|
1502
|
+
# PARSED name (so "<>" or " <a@x.io>", whose name parses empty, is caught like " ").
|
|
1503
|
+
parsed_owner = _parse_actor_arg(args.owner) if args.owner is not None else None
|
|
1504
|
+
parsed_assignee = _parse_actor_arg(args.assignee) if args.assignee is not None else None
|
|
1505
|
+
if parsed_owner is not None and not parsed_owner["name"].strip():
|
|
1506
|
+
_die("owner_name_blank")
|
|
1507
|
+
if parsed_assignee is not None and not parsed_assignee["name"].strip():
|
|
1508
|
+
_die("assignee_name_blank")
|
|
1509
|
+
if parsed_owner is None and parsed_assignee is None:
|
|
1510
|
+
who = _whoami(state)
|
|
1511
|
+
rec["owner"] = dict(who)
|
|
1512
|
+
rec["assignee"] = dict(who)
|
|
1513
|
+
else:
|
|
1514
|
+
if parsed_owner is not None:
|
|
1515
|
+
rec["owner"] = parsed_owner
|
|
1516
|
+
if parsed_assignee is not None:
|
|
1517
|
+
rec["assignee"] = parsed_assignee
|
|
1518
|
+
save_state(root, state)
|
|
1519
|
+
parts = [f"{role}: {rec[role]['name']}" for role in ("owner", "assignee") if role in rec]
|
|
1520
|
+
print(f"assigned {args.slug} -> " + " · ".join(parts))
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def cmd_unassign(args: argparse.Namespace) -> None:
|
|
1524
|
+
"""Clear the OWNER and/or ASSIGNEE of a task or milestone (ownership-assignment). No role
|
|
1525
|
+
flag -> clear BOTH; --owner/--assignee -> clear that role only. Reject not_assigned if the
|
|
1526
|
+
targeted role(s) are already absent. Validate-before-mutate (a reject changes nothing)."""
|
|
1527
|
+
root = _require_root()
|
|
1528
|
+
state = load_state(root)
|
|
1529
|
+
rec = _ownership_record(state, args.slug)
|
|
1530
|
+
if rec is None:
|
|
1531
|
+
_die("unknown_slug")
|
|
1532
|
+
if args.owner or args.assignee:
|
|
1533
|
+
roles = [r for r in ("owner", "assignee") if getattr(args, r)]
|
|
1534
|
+
else:
|
|
1535
|
+
roles = ["owner", "assignee"]
|
|
1536
|
+
if not all(r in rec for r in roles): # every targeted role must exist (frozen: "both must exist")
|
|
1537
|
+
_die("not_assigned")
|
|
1538
|
+
for r in roles:
|
|
1539
|
+
rec.pop(r, None)
|
|
1540
|
+
save_state(root, state)
|
|
1541
|
+
print(f"unassigned {args.slug} ({', '.join(roles)})")
|
|
1542
|
+
|
|
1543
|
+
|
|
1016
1544
|
def _has_production_roadmap(state: dict) -> bool:
|
|
1017
1545
|
"""True iff ≥1 milestone in state has stage == "production" (STATUS-AGNOSTIC).
|
|
1018
1546
|
The single source of the stage-graduation floor (v22 graduate-guide): the guard counts
|
|
@@ -1057,20 +1585,26 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1057
1585
|
members = [t for t in tasks.values() if t.get("milestone") == mslug]
|
|
1058
1586
|
ms_list.append({"slug": mslug, "status": m.get("status", "active"),
|
|
1059
1587
|
"done": sum(1 for t in members if _task_done(t)),
|
|
1060
|
-
"total": len(members)
|
|
1588
|
+
"total": len(members),
|
|
1589
|
+
"owner": m.get("owner"), "assignee": m.get("assignee")})
|
|
1061
1590
|
grad_ready, grad_met, grad_total = _graduation_ready(root, state)
|
|
1062
1591
|
print(json.dumps({
|
|
1063
1592
|
"project": state.get("project"), "stage": state.get("stage"),
|
|
1064
|
-
"
|
|
1593
|
+
"actor": _whoami(state),
|
|
1594
|
+
"active_task": _active_task(state),
|
|
1595
|
+
"active_milestones": list(state.get("active_milestones") or []),
|
|
1596
|
+
"active_tasks": dict(state.get("active_tasks") or {}),
|
|
1065
1597
|
"milestones": ms_list,
|
|
1066
1598
|
"tasks": [{"slug": s, "phase": t.get("phase"), "gate": t.get("gate"),
|
|
1067
|
-
"milestone": t.get("milestone")
|
|
1599
|
+
"milestone": t.get("milestone"),
|
|
1600
|
+
"owner": t.get("owner"), "assignee": t.get("assignee")}
|
|
1601
|
+
for s, t in tasks.items()],
|
|
1068
1602
|
"graduation_ready": grad_ready,
|
|
1069
1603
|
"stage_criteria": {"met": grad_met, "total": grad_total}}))
|
|
1070
1604
|
return
|
|
1071
1605
|
root = _require_root()
|
|
1072
1606
|
state = load_state(root)
|
|
1073
|
-
active = state
|
|
1607
|
+
active = _active_task(state)
|
|
1074
1608
|
tasks = state.get("tasks", {})
|
|
1075
1609
|
# Compute once: True when setup is present AND locked is False (the lock-gate window).
|
|
1076
1610
|
# Reuses the canonical helper — do NOT write a parallel predicate.
|
|
@@ -1079,12 +1613,17 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1079
1613
|
# project autonomy default (task init-auto-default): the posture new tasks INHERIT,
|
|
1080
1614
|
# read LIVE from PROJECT.md so the human sees the project-wide throttle every session.
|
|
1081
1615
|
print(f"project autonomy: {_project_autonomy(root)} (default — new tasks inherit)")
|
|
1616
|
+
# git-native actor (user-identity): who ADD sees you as this session — the identity every
|
|
1617
|
+
# human-owned stamp records. Always present (the resolver is TOTAL). Read-only, no write.
|
|
1618
|
+
_who = _whoami(state)
|
|
1619
|
+
_who_email = f" <{_who['email']}>" if _who.get("email") else ""
|
|
1620
|
+
print(f"actor : {_who['name']}{_who_email} (source: {_who['source']})")
|
|
1082
1621
|
print(f"stage : {state.get('stage', '(unknown)')}")
|
|
1083
1622
|
# project GOAL + active-milestone goal (v20) — the loop's orientation anchor, read
|
|
1084
1623
|
# LIVE from PROJECT.md / MILESTONE.md (never state.json). Additive: every existing
|
|
1085
1624
|
# line stays put. A missing source degrades to a sentinel — one never blanks the other.
|
|
1086
1625
|
print(f"goal : {_project_goal(root)}")
|
|
1087
|
-
_active_ms = state
|
|
1626
|
+
_active_ms = _active_milestone(state)
|
|
1088
1627
|
if _active_ms:
|
|
1089
1628
|
print(f"m-goal : {_milestone_doc(root, _active_ms)[1]} (← {_active_ms})")
|
|
1090
1629
|
# goal-ready (task goal-auto-ready-gate): is the active milestone's goal AUTO-READY
|
|
@@ -1112,13 +1651,13 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1112
1651
|
|
|
1113
1652
|
# milestone rollup (only when milestones are in use)
|
|
1114
1653
|
milestones = state.get("milestones") or {}
|
|
1115
|
-
active_ms = state
|
|
1654
|
+
active_ms = _active_milestone(state)
|
|
1116
1655
|
if milestones:
|
|
1117
1656
|
print("milestones:")
|
|
1118
1657
|
for mslug, m in milestones.items():
|
|
1119
1658
|
members = [t for t in tasks.values() if t.get("milestone") == mslug]
|
|
1120
1659
|
done = sum(1 for t in members if _task_done(t))
|
|
1121
|
-
mark = "*" if mslug
|
|
1660
|
+
mark = "*" if mslug in (state.get("active_milestones") or []) else " "
|
|
1122
1661
|
print(f" {mark} {mslug:<20} {done}/{len(members)} tasks done"
|
|
1123
1662
|
f" status={m.get('status', 'active')}")
|
|
1124
1663
|
# graduation cue (v22): project-global + read-only. Fires only when every milestone
|
|
@@ -1144,11 +1683,42 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1144
1683
|
if _rel:
|
|
1145
1684
|
print(f" → {RELEASABLE_CUE.format(n=len(_rel))}")
|
|
1146
1685
|
|
|
1147
|
-
|
|
1686
|
+
# fast-lane marker (fast-new-task-flag): tag an ACTIVE fast task so the lane is visible at a
|
|
1687
|
+
# glance. Presentation-only, existence-gated — a plain/absent active task is byte-unchanged.
|
|
1688
|
+
_fast_mark = " · fast" if active and tasks.get(active, {}).get("fast") is True else ""
|
|
1689
|
+
print(f"active : {active or '(none)'}{_fast_mark}")
|
|
1690
|
+
# parallel streams (parallel-status-view): when >=2 milestones are active, render each as its
|
|
1691
|
+
# own stream (active task + phase) so a user working N fronts reads them all at once. ADDITIVE —
|
|
1692
|
+
# the N<=1 output above is byte-identical (the standing additive-cue convention); presentation
|
|
1693
|
+
# only, no command DECISION changes. Reads the SET/map via the task-2 seam shape.
|
|
1694
|
+
_ams = state.get("active_milestones") or []
|
|
1695
|
+
if len(_ams) >= 2:
|
|
1696
|
+
_primary = _active_milestone(state)
|
|
1697
|
+
_order = ([_primary] if _primary in _ams else []) + [m for m in _ams if m != _primary]
|
|
1698
|
+
_atasks = state.get("active_tasks") or {}
|
|
1699
|
+
print(f"streams : {len(_ams)} active milestones")
|
|
1700
|
+
for _m in _order:
|
|
1701
|
+
_tk = _atasks.get(_m)
|
|
1702
|
+
_ph = (tasks.get(_tk) or {}).get("phase", "-") if _tk else "-"
|
|
1703
|
+
_mk = "▸" if _m == _primary else " "
|
|
1704
|
+
_tag = " (primary)" if _m == _primary else ""
|
|
1705
|
+
# per-stream owner (per-stream-owner): the milestone's lead, present-only — a stream
|
|
1706
|
+
# whose milestone has no owner (or a blank-name owner) renders byte-identically
|
|
1707
|
+
# (additive-cue convention). Guard on the name like `_fmt_ownership`, so a hand-edited
|
|
1708
|
+
# blank-name record never emits an `owner:` fragment.
|
|
1709
|
+
_owner_rec = (milestones.get(_m) or {}).get("owner") or {}
|
|
1710
|
+
_so = _fmt_actor(_owner_rec) if _owner_rec.get("name") else ""
|
|
1711
|
+
_own_frag = f" · owner: {_so}" if _so else ""
|
|
1712
|
+
print(f" {_mk} {_m:<20} task={_tk or '(none)'} phase={_ph}{_tag}{_own_frag}")
|
|
1148
1713
|
# surface the active task's autonomy level (task explicit-autonomy-dial) so the human
|
|
1149
1714
|
# reads the throttle every session; "unset" when no explicit `autonomy:` line is present.
|
|
1150
1715
|
if active and active in tasks:
|
|
1151
1716
|
print(f"autonomy: {_autonomy_level(_task_header(root, active)) or 'unset'}")
|
|
1717
|
+
# owner/assignee of the active task (ownership-assignment) — present-only, never a
|
|
1718
|
+
# placeholder; an unassigned active task adds no line (additive-cue convention).
|
|
1719
|
+
_own = _fmt_ownership(tasks[active])
|
|
1720
|
+
if _own:
|
|
1721
|
+
print(f"owned : {_own}")
|
|
1152
1722
|
# grounded (task ground-bundle-wiring): does the active task's §0 GROUND map cite the
|
|
1153
1723
|
# anchors §3 names? measure-not-block, human-readable only (never the JSON surface). A
|
|
1154
1724
|
# pre-ground / legacy task (no §0) -> _task_grounded None -> NO line, so the surface is
|
|
@@ -1237,7 +1807,7 @@ def cmd_guide(args: argparse.Namespace) -> None:
|
|
|
1237
1807
|
"""
|
|
1238
1808
|
if getattr(args, "json", False):
|
|
1239
1809
|
json_root, state = _load_state_for_json()
|
|
1240
|
-
slug = args.slug or state
|
|
1810
|
+
slug = args.slug or _active_task(state)
|
|
1241
1811
|
if not slug:
|
|
1242
1812
|
print(json.dumps({"task": None, "phase": None, "owner": "human", "stop": True,
|
|
1243
1813
|
"next_step": "start your first feature -> add.py new-task <slug>",
|
|
@@ -1257,7 +1827,7 @@ def cmd_guide(args: argparse.Namespace) -> None:
|
|
|
1257
1827
|
return
|
|
1258
1828
|
root = _require_root()
|
|
1259
1829
|
state = load_state(root)
|
|
1260
|
-
slug = args.slug or state
|
|
1830
|
+
slug = args.slug or _active_task(state)
|
|
1261
1831
|
if not slug:
|
|
1262
1832
|
print("active : (none)")
|
|
1263
1833
|
print('next : start your first feature -> add.py new-task <slug> --title "..."')
|
|
@@ -1732,7 +2302,7 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
1732
2302
|
if root is None:
|
|
1733
2303
|
_die("no_project")
|
|
1734
2304
|
try:
|
|
1735
|
-
state = json.loads((root
|
|
2305
|
+
state = json.loads(_state_text_or_die(root))
|
|
1736
2306
|
except (json.JSONDecodeError, OSError):
|
|
1737
2307
|
_die("state_invalid")
|
|
1738
2308
|
|
|
@@ -1832,7 +2402,7 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
1832
2402
|
# zero-criteria milestone is shaping's nudge, not this one's. LIVE-ONLY: the OPEN active
|
|
1833
2403
|
# milestone only — a done-but-not-yet-archived one (still the active pointer until
|
|
1834
2404
|
# archive clears it) and closed/archived predecessors are never retro-flagged (Must #4).
|
|
1835
|
-
_active_ms = state
|
|
2405
|
+
_active_ms = _active_milestone(state)
|
|
1836
2406
|
if _active_ms in milestones and milestones[_active_ms].get("status") != "done":
|
|
1837
2407
|
_cited, _total = _exit_criteria_cited(root, _active_ms)
|
|
1838
2408
|
if _total >= 1 and _cited < _total:
|
|
@@ -1847,7 +2417,7 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
1847
2417
|
# FROZEN AND its §0 GROUND map is ungrounded (the precise "froze without grounding" gap, so
|
|
1848
2418
|
# no nag during pre-freeze drafting). A pre-ground / legacy task (no §0 -> _grounded_state
|
|
1849
2419
|
# None) is EXEMPT, never retro-flagged. Rides the existing `warnings` array — no new key.
|
|
1850
|
-
_at = state
|
|
2420
|
+
_at = _active_task(state)
|
|
1851
2421
|
if _at in tasks:
|
|
1852
2422
|
_raw = _raw_phase_bodies(root, _at)
|
|
1853
2423
|
if _contract_frozen(_raw.get(3, "")) and _grounded_state(_raw) is False:
|
|
@@ -1921,6 +2491,93 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
1921
2491
|
raise SystemExit(1)
|
|
1922
2492
|
|
|
1923
2493
|
|
|
2494
|
+
def _doctor_findings(root: Path) -> list[str]:
|
|
2495
|
+
"""Read-only diagnosis of state.json: each item is "<problem> — fix: <fix>".
|
|
2496
|
+
|
|
2497
|
+
Reads the RAW text with its OWN try/except — NEVER through the dying load_state — so a
|
|
2498
|
+
conflicted/corrupt state is REPORTED, not aborted on (the proactive counterpart to the
|
|
2499
|
+
merge-guard load guard, which fails fast at the first problem). Reports the FIRST blocking
|
|
2500
|
+
class then stops (can't parse deeper): missing/unreadable file -> conflict markers -> bad
|
|
2501
|
+
JSON. On a PARSEABLE state (normalized through `_migrate_state` so the canonical multi-active
|
|
2502
|
+
shape is judged) it appends EVERY referential violation. PURE: reads only, returns the list."""
|
|
2503
|
+
try:
|
|
2504
|
+
text = (root / STATE_FILE).read_text(encoding="utf-8")
|
|
2505
|
+
except OSError:
|
|
2506
|
+
return ["state.json missing/unreadable — fix: restore it from git/backup"]
|
|
2507
|
+
if _CONFLICT_MARKER_RE.search(text):
|
|
2508
|
+
return ["state.json has unresolved git merge markers — fix: resolve "
|
|
2509
|
+
"<<<<<<< / ======= / >>>>>>> (or git checkout --ours/--theirs), then re-run doctor"]
|
|
2510
|
+
try:
|
|
2511
|
+
parsed = json.loads(text)
|
|
2512
|
+
except json.JSONDecodeError:
|
|
2513
|
+
return ["state.json is not valid JSON — fix: restore it from git/backup"]
|
|
2514
|
+
|
|
2515
|
+
state = _migrate_state(parsed)
|
|
2516
|
+
findings: list[str] = []
|
|
2517
|
+
milestones = state.get("milestones") if isinstance(state.get("milestones"), dict) else {}
|
|
2518
|
+
tasks = state.get("tasks") if isinstance(state.get("tasks"), dict) else {}
|
|
2519
|
+
|
|
2520
|
+
active_ms = state.get("active_milestones") if isinstance(state.get("active_milestones"), list) else []
|
|
2521
|
+
active_tasks = state.get("active_tasks") if isinstance(state.get("active_tasks"), dict) else {}
|
|
2522
|
+
for am in active_ms:
|
|
2523
|
+
if am not in milestones:
|
|
2524
|
+
findings.append(f"active milestone '{am}' has no record — fix: deactivate it or "
|
|
2525
|
+
"recreate the milestone")
|
|
2526
|
+
for ms, t in active_tasks.items():
|
|
2527
|
+
if not t:
|
|
2528
|
+
continue
|
|
2529
|
+
if t not in tasks:
|
|
2530
|
+
findings.append(f"active task '{t}' (milestone '{ms}') has no record — fix: use a "
|
|
2531
|
+
"real task or clear the active pointer")
|
|
2532
|
+
elif (tasks[t].get("milestone") if isinstance(tasks[t], dict) else None) != ms:
|
|
2533
|
+
findings.append(f"active task '{t}' is mislabeled under '{ms}' — fix: re-use it "
|
|
2534
|
+
"under its own milestone")
|
|
2535
|
+
for slug, t in tasks.items():
|
|
2536
|
+
m = t.get("milestone") if isinstance(t, dict) else None
|
|
2537
|
+
if m is not None and m not in milestones:
|
|
2538
|
+
findings.append(f"task '{slug}' references missing milestone '{m}' — fix: set its "
|
|
2539
|
+
"milestone to a real one (or none)")
|
|
2540
|
+
return findings
|
|
2541
|
+
|
|
2542
|
+
|
|
2543
|
+
def cmd_doctor(args: argparse.Namespace) -> None:
|
|
2544
|
+
"""Read-only `add.py doctor`: PASS + exit 0 on a healthy state, else report each problem +
|
|
2545
|
+
fix to stdout and exit non-zero. NEVER mutates state (detect, never auto-resolve)."""
|
|
2546
|
+
root = find_root()
|
|
2547
|
+
if root is None:
|
|
2548
|
+
_die("no_project")
|
|
2549
|
+
findings = _doctor_findings(root)
|
|
2550
|
+
if not findings:
|
|
2551
|
+
print("doctor: PASS — state.json is healthy (parseable · conflict-free · references intact)")
|
|
2552
|
+
return
|
|
2553
|
+
print(f"doctor: {len(findings)} problem(s):")
|
|
2554
|
+
for f in findings:
|
|
2555
|
+
print(f" ✗ {f}")
|
|
2556
|
+
raise SystemExit(1)
|
|
2557
|
+
|
|
2558
|
+
|
|
2559
|
+
def cmd_mine(args: argparse.Namespace) -> None:
|
|
2560
|
+
"""Read-only `add.py mine`: across all active milestones, the not-done tasks owned-by or
|
|
2561
|
+
assigned-to the resolved actor (`_whoami`, or `--actor "Name <email>"`). Text or `--json`.
|
|
2562
|
+
An empty queue is a plain exit-0 line, not an error. NEVER writes state."""
|
|
2563
|
+
root = find_root()
|
|
2564
|
+
if root is None:
|
|
2565
|
+
_die("no_project")
|
|
2566
|
+
state = load_state(root)
|
|
2567
|
+
me = _parse_actor_arg(args.actor) if getattr(args, "actor", None) else _whoami(state)
|
|
2568
|
+
rows = _my_work(state, me)
|
|
2569
|
+
if getattr(args, "json", False):
|
|
2570
|
+
print(json.dumps({"actor": me, "tasks": rows}))
|
|
2571
|
+
return
|
|
2572
|
+
who = _fmt_actor(me) or me.get("name", "you")
|
|
2573
|
+
if not rows:
|
|
2574
|
+
print(f"mine: no open tasks for {who} across active milestones")
|
|
2575
|
+
return
|
|
2576
|
+
print(f"mine: {who} — {len(rows)} open task(s) across active milestones:")
|
|
2577
|
+
for r in rows:
|
|
2578
|
+
print(f" {r['slug']:<24} [{r['milestone']}] phase={r['phase']} ({r['role']})")
|
|
2579
|
+
|
|
2580
|
+
|
|
1924
2581
|
# ---------------------------------------------------------------------------
|
|
1925
2582
|
# wave-ledger fork-base enforcement (engine-merge-base-enforcement)
|
|
1926
2583
|
#
|
|
@@ -2075,17 +2732,64 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
|
|
|
2075
2732
|
_atomic_write(mfile, _render_template(
|
|
2076
2733
|
"MILESTONE.md", title=title, goal=args.goal or "<goal>",
|
|
2077
2734
|
stage=args.stage, date=date.today().isoformat()))
|
|
2078
|
-
|
|
2735
|
+
# confirm-parent gate (OPT-IN, mirrors `init --await-lock`): `--await-confirm` seeds the
|
|
2736
|
+
# milestone UNCONFIRMED so new-task is held until `add.py milestone-confirm`. WITHOUT the flag
|
|
2737
|
+
# NO `confirmed` key is written → grandfathered-confirmed → no gate (so the existing engine
|
|
2738
|
+
# tests stay byte-green). The guided skill flow passes the flag at the human-review point.
|
|
2739
|
+
await_confirm = bool(getattr(args, "await_confirm", False))
|
|
2740
|
+
record = {
|
|
2079
2741
|
"title": title, "goal": args.goal or "", "stage": args.stage,
|
|
2080
2742
|
"status": "active", "created": _now(), "updated": _now(),
|
|
2081
2743
|
}
|
|
2082
|
-
|
|
2744
|
+
if await_confirm:
|
|
2745
|
+
# `await_confirm` is the STABLE opt-in marker (set ONLY here, at creation). `confirmed`
|
|
2746
|
+
# alone is NOT a reliable opt-in signal: milestone-confirm stamps confirmed:true on a plain
|
|
2747
|
+
# milestone too, so a later build-entry gate must key on `await_confirm`, not `confirmed`.
|
|
2748
|
+
record.update(confirmed=False, confirmed_at=None, confirmed_by=None, await_confirm=True)
|
|
2749
|
+
state["milestones"][slug] = record
|
|
2750
|
+
_set_active_milestone(state, slug)
|
|
2083
2751
|
save_state(root, state)
|
|
2084
2752
|
print(f"created milestone '{slug}' -> {mfile}")
|
|
2085
|
-
print("active milestone set."
|
|
2753
|
+
print("active milestone set." + ("" if not await_confirm else
|
|
2754
|
+
" (unconfirmed — show the MILESTONE.md, then: add.py milestone-confirm " + slug + ")"))
|
|
2086
2755
|
print(_next_footer(root, state)) # converges the old "Decompose it into tasks: …" hint
|
|
2087
2756
|
|
|
2088
2757
|
|
|
2758
|
+
def cmd_milestone_confirm(args: argparse.Namespace) -> None:
|
|
2759
|
+
"""The human gate that opens new-task for a milestone (confirm-parent). Mirrors `cmd_lock`
|
|
2760
|
+
one level down: the human reviews the filled MILESTONE.md, then RECORDS confirmation here.
|
|
2761
|
+
The engine never self-confirms. Validate-then-write; re-confirm is an idempotent note."""
|
|
2762
|
+
root = _require_root()
|
|
2763
|
+
state = load_state(root)
|
|
2764
|
+
slug = args.slug
|
|
2765
|
+
if slug not in state.get("milestones", {}):
|
|
2766
|
+
_die("unknown_milestone")
|
|
2767
|
+
m = state["milestones"][slug]
|
|
2768
|
+
if m.get("confirmed") is True:
|
|
2769
|
+
print(f"milestone '{slug}' already confirmed (by {m.get('confirmed_by', '?')}).")
|
|
2770
|
+
return
|
|
2771
|
+
# contract-fill gate (flow-enforcement, OPTED-IN only): a milestone that opted into
|
|
2772
|
+
# --await-confirm (carries a `confirmed` key) may not be confirmed until its cross-task
|
|
2773
|
+
# `## Shared / risky contracts` section is filled — so "confirmed" MEANS the contracts
|
|
2774
|
+
# were present at confirm time. A grandfathered no-key milestone keeps the plain stamp
|
|
2775
|
+
# (gate skipped — keeps the census + existing flows green). Validate-then-write.
|
|
2776
|
+
if "confirmed" in m:
|
|
2777
|
+
mfile = root / "milestones" / slug / MILESTONE_FILE
|
|
2778
|
+
md = mfile.read_text(encoding="utf-8") if mfile.exists() else ""
|
|
2779
|
+
if _section_unfilled(md, "## Shared / risky contracts"):
|
|
2780
|
+
_die("milestone_contracts_unfilled: fill the '## Shared / risky contracts' "
|
|
2781
|
+
f"section of {slug}'s MILESTONE.md before confirming")
|
|
2782
|
+
who = getattr(args, "by", None) or getpass.getuser()
|
|
2783
|
+
m["confirmed"] = True
|
|
2784
|
+
m["confirmed_at"] = _now()
|
|
2785
|
+
m["confirmed_by"] = who
|
|
2786
|
+
m["actor"] = _actor_stamp(state) # structured actor alongside the free-text confirmed_by
|
|
2787
|
+
m["updated"] = _now()
|
|
2788
|
+
save_state(root, state)
|
|
2789
|
+
print(f"confirmed milestone '{slug}' (by {who}) — new-task is now open for it.")
|
|
2790
|
+
print(_next_footer(root, state))
|
|
2791
|
+
|
|
2792
|
+
|
|
2089
2793
|
def cmd_ready(args: argparse.Namespace) -> None:
|
|
2090
2794
|
if getattr(args, "json", False):
|
|
2091
2795
|
_, state = _load_state_for_json()
|
|
@@ -2128,7 +2832,11 @@ def cmd_ready(args: argparse.Namespace) -> None:
|
|
|
2128
2832
|
for slug in ready:
|
|
2129
2833
|
deps = tasks[slug].get("depends_on") or []
|
|
2130
2834
|
suffix = f" (after {', '.join(deps)})" if deps else ""
|
|
2131
|
-
|
|
2835
|
+
# cross-active legibility (cross-active-waves): name the stream each ready task belongs
|
|
2836
|
+
# to, present-only — a milestone-less task gets no bracket (byte-identical).
|
|
2837
|
+
_ms = tasks[slug].get("milestone")
|
|
2838
|
+
ms_frag = f" [{_ms}]" if _ms else ""
|
|
2839
|
+
print(f" {slug}{ms_frag}{suffix}")
|
|
2132
2840
|
|
|
2133
2841
|
|
|
2134
2842
|
def _wave_schedule(state: dict, mslug: str) -> dict:
|
|
@@ -2222,36 +2930,17 @@ def _wave_schedule(state: dict, mslug: str) -> dict:
|
|
|
2222
2930
|
"tiers": tiers, "blocked": blocked_sorted}
|
|
2223
2931
|
|
|
2224
2932
|
|
|
2225
|
-
def
|
|
2226
|
-
"""
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
if is_json:
|
|
2230
|
-
_, state = _load_state_for_json()
|
|
2231
|
-
else:
|
|
2232
|
-
state = load_state(_require_root())
|
|
2233
|
-
mslug = getattr(args, "milestone", None) or state.get("active_milestone")
|
|
2234
|
-
if not mslug:
|
|
2235
|
-
_die("no_active_milestone: no active milestone and no --milestone given")
|
|
2236
|
-
if mslug not in (state.get("milestones") or {}):
|
|
2237
|
-
_die(f"unknown_milestone: '{mslug}' is not a milestone in this project")
|
|
2238
|
-
sched = _wave_schedule(state, mslug)
|
|
2239
|
-
if "cycle" in sched:
|
|
2240
|
-
_die(f"dependency_cycle: not-done deps form a cycle "
|
|
2241
|
-
f"({' -> '.join(sched['cycle'])}) — no valid schedule")
|
|
2242
|
-
|
|
2243
|
-
if is_json:
|
|
2244
|
-
print(json.dumps({"milestone": mslug, **sched}))
|
|
2245
|
-
return
|
|
2246
|
-
|
|
2247
|
-
print(f"milestone: {mslug}")
|
|
2933
|
+
def _wave_block_lines(state: dict, mslug: str, sched: dict) -> list[str]:
|
|
2934
|
+
"""The exact text lines `waves` renders for ONE milestone's schedule (cross-active-waves
|
|
2935
|
+
extracts this so a single target stays byte-identical and N targets each get a block)."""
|
|
2936
|
+
lines = [f"milestone: {mslug}"]
|
|
2248
2937
|
if not sched["waves"]:
|
|
2249
2938
|
if sched["blocked"]:
|
|
2250
2939
|
for s in sched["blocked"]:
|
|
2251
|
-
|
|
2940
|
+
lines.append(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
|
|
2252
2941
|
else:
|
|
2253
|
-
|
|
2254
|
-
return
|
|
2942
|
+
lines.append("all tasks done — nothing to schedule")
|
|
2943
|
+
return lines
|
|
2255
2944
|
scheduled_set = {x for w in sched["waves"] for x in w}
|
|
2256
2945
|
for i, wave in enumerate(sched["waves"], start=1):
|
|
2257
2946
|
parts = []
|
|
@@ -2259,14 +2948,62 @@ def cmd_waves(args: argparse.Namespace) -> None:
|
|
|
2259
2948
|
md = sorted(d for d in (state["tasks"][s].get("depends_on") or [])
|
|
2260
2949
|
if d in scheduled_set)
|
|
2261
2950
|
parts.append(f"{s} (deps: {', '.join(md)})" if md else s)
|
|
2262
|
-
|
|
2951
|
+
lines.append(f"wave {i}: {', '.join(parts)}")
|
|
2263
2952
|
crit = sched["critical_path"]
|
|
2264
|
-
|
|
2953
|
+
lines.append(f"critical path: {' → '.join(crit)} ({sched['critical_path_len']} tasks)")
|
|
2265
2954
|
tops = [s for s, tier in sched["tiers"].items() if tier == "top"]
|
|
2266
2955
|
mids = [s for s, tier in sched["tiers"].items() if tier == "mid"]
|
|
2267
|
-
|
|
2956
|
+
lines.append(f"tier hint: top → {', '.join(tops)}; mid → {', '.join(mids) or '(none)'}")
|
|
2268
2957
|
for s in sched["blocked"]:
|
|
2269
|
-
|
|
2958
|
+
lines.append(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
|
|
2959
|
+
return lines
|
|
2960
|
+
|
|
2961
|
+
|
|
2962
|
+
def cmd_waves(args: argparse.Namespace) -> None:
|
|
2963
|
+
"""READ-ONLY DAG scheduler: print the topological waves, critical path, advisory tier hint,
|
|
2964
|
+
and blocked set. With no --milestone it spans EVERY active milestone (cross-active-waves);
|
|
2965
|
+
a single target / --milestone renders byte-identically. Writes nothing; no `next:` footer."""
|
|
2966
|
+
is_json = getattr(args, "json", False)
|
|
2967
|
+
if is_json:
|
|
2968
|
+
_, state = _load_state_for_json()
|
|
2969
|
+
else:
|
|
2970
|
+
state = load_state(_require_root())
|
|
2971
|
+
mslug_arg = getattr(args, "milestone", None)
|
|
2972
|
+
if mslug_arg:
|
|
2973
|
+
targets = [mslug_arg] # explicit single target — unchanged
|
|
2974
|
+
else:
|
|
2975
|
+
primary = _active_milestone(state) # the SCALAR is the gate (test relies on it)
|
|
2976
|
+
if not primary:
|
|
2977
|
+
_die("no_active_milestone: no active milestone and no --milestone given")
|
|
2978
|
+
# additively widen to the other active milestones (cross-active); primary first
|
|
2979
|
+
targets = [primary] + [m for m in (state.get("active_milestones") or [])
|
|
2980
|
+
if m != primary]
|
|
2981
|
+
scheds = []
|
|
2982
|
+
for t in targets:
|
|
2983
|
+
if t not in (state.get("milestones") or {}):
|
|
2984
|
+
_die(f"unknown_milestone: '{t}' is not a milestone in this project")
|
|
2985
|
+
sched = _wave_schedule(state, t)
|
|
2986
|
+
if "cycle" in sched:
|
|
2987
|
+
_die(f"dependency_cycle: not-done deps form a cycle "
|
|
2988
|
+
f"({' -> '.join(sched['cycle'])}) — no valid schedule")
|
|
2989
|
+
scheds.append(sched)
|
|
2990
|
+
|
|
2991
|
+
if is_json:
|
|
2992
|
+
if len(targets) == 1:
|
|
2993
|
+
print(json.dumps({"milestone": targets[0], **scheds[0]})) # unchanged shape
|
|
2994
|
+
else:
|
|
2995
|
+
print(json.dumps({"streams": [{"milestone": t, **s}
|
|
2996
|
+
for t, s in zip(targets, scheds)]}))
|
|
2997
|
+
return
|
|
2998
|
+
|
|
2999
|
+
if len(targets) == 1:
|
|
3000
|
+
print("\n".join(_wave_block_lines(state, targets[0], scheds[0]))) # byte-identical
|
|
3001
|
+
return
|
|
3002
|
+
print(f"active streams: {len(targets)}")
|
|
3003
|
+
for i, (t, s) in enumerate(zip(targets, scheds)):
|
|
3004
|
+
if i:
|
|
3005
|
+
print()
|
|
3006
|
+
print("\n".join(_wave_block_lines(state, t, s)))
|
|
2270
3007
|
|
|
2271
3008
|
|
|
2272
3009
|
def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
@@ -2296,6 +3033,10 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
|
2296
3033
|
_die(f"milestone_goal_unmet: milestone '{slug}' has {met}/{total} exit criteria met "
|
|
2297
3034
|
f"— check the remaining boxes in MILESTONE.md (the goal-gate holds the loop "
|
|
2298
3035
|
f"open) or propose the next tasks (add.py deltas)")
|
|
3036
|
+
# Stamp WHO closed it BEFORE rendering the retro, so the persisted exit report records
|
|
3037
|
+
# the closer (identity-in-status: the retro IS the report `report <ms>` re-renders, so both
|
|
3038
|
+
# must reflect the same final state). In-memory only here — save_state below commits it.
|
|
3039
|
+
state["milestones"][slug]["done_actor"] = _actor_stamp(state)
|
|
2299
3040
|
# Fail-closed: render+persist the exit report (RETRO.md) BEFORE committing the
|
|
2300
3041
|
# status flip, so a write failure rolls back naturally (status never commits ->
|
|
2301
3042
|
# no done-without-retro state). The retro step is read-only on state.json.
|
|
@@ -2311,11 +3052,15 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
|
2311
3052
|
print(f"milestone '{slug}' -> done ({len(members)} tasks complete{tail}).")
|
|
2312
3053
|
print(f"wrote {retro_path.relative_to(root.parent)} (milestone exit report)")
|
|
2313
3054
|
# fold-pressure nudge: milestone close is the natural fold point for open deltas (v11)
|
|
2314
|
-
|
|
3055
|
+
by_comp = _collect_open_deltas(root)
|
|
3056
|
+
open_deltas = sum(len(v) for v in by_comp.values())
|
|
2315
3057
|
if open_deltas:
|
|
2316
3058
|
noun = "delta" if open_deltas == 1 else "deltas"
|
|
2317
|
-
print(f"note: {open_deltas} open {noun} to
|
|
2318
|
-
|
|
3059
|
+
print(f"note: {open_deltas} open {noun} ready to {_FOLD_VERB} into the foundation:")
|
|
3060
|
+
for comp in _COMPETENCY_ORDER:
|
|
3061
|
+
for e in by_comp[comp]:
|
|
3062
|
+
print(f" ({comp}) {e['text']} [{e['task']}]")
|
|
3063
|
+
print(f" run: add.py {_FOLD_VERB} (review first: add.py deltas)")
|
|
2319
3064
|
# SPEC-delta nudge (project-wide): the close is also a natural prompt to RESOLVE the
|
|
2320
3065
|
# forward hand-offs (seed/drop) so none is orphaned at the eventual compaction.
|
|
2321
3066
|
open_spec = len(_collect_open_spec_deltas(root))
|
|
@@ -2369,10 +3114,9 @@ def cmd_archive_milestone(args: argparse.Namespace) -> None:
|
|
|
2369
3114
|
del state["milestones"][slug]
|
|
2370
3115
|
for s in members:
|
|
2371
3116
|
del tasks[s]
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
state["active_task"] = None
|
|
3117
|
+
_deactivate_milestone(state, slug) # drop from the active SET + pop its task entry, repointing the primary focus
|
|
3118
|
+
if _active_task(state) in members: # N<=1 oracle: a NON-primary archive (new-milestone replace-to-focus leaves
|
|
3119
|
+
state["active_task"] = None # active_task pointing at m1's task while primary is m2) would dangle at a deleted task
|
|
2376
3120
|
save_state(root, state)
|
|
2377
3121
|
print(f"archived milestone '{slug}' ({len(members)} tasks) — removed from active state.")
|
|
2378
3122
|
print("files on disk are untouched; see `add.py status` for the archived rollup.")
|
|
@@ -2422,10 +3166,15 @@ def cmd_compact(args: argparse.Namespace) -> None:
|
|
|
2422
3166
|
# hand-off that resolves into a task, not a foundation lesson — an open one ANYWHERE would
|
|
2423
3167
|
# be orphaned at the next compaction. Deliberately broader than the member-scoped competency
|
|
2424
3168
|
# guard above. Still validate-before-move: refuses BEFORE the first rename.
|
|
3169
|
+
# --force overrides THIS guard ONLY (never a structural reject above) — the escape hatch
|
|
3170
|
+
# for a settled milestone blocked by an unrelated open SPEC delta elsewhere. Bypass is
|
|
3171
|
+
# loud (warns + records `force_bypassed_spec_deltas`), never silent.
|
|
3172
|
+
forced = getattr(args, "force", False)
|
|
2425
3173
|
spec_offenders = sorted({d["task"] for d in _collect_open_spec_deltas(root)})
|
|
2426
|
-
if spec_offenders:
|
|
3174
|
+
if spec_offenders and not forced:
|
|
2427
3175
|
_die("open_spec_deltas_unresolved: resolve every open SPEC delta first "
|
|
2428
|
-
"(`add.py deltas`; seed with `new-task --from-delta`, or `drop-delta
|
|
3176
|
+
"(`add.py deltas`; seed with `new-task --from-delta`, or `drop-delta`; "
|
|
3177
|
+
"or re-run with --force to compact past them) — "
|
|
2429
3178
|
"open in: " + " · ".join(spec_offenders))
|
|
2430
3179
|
# every precondition passed — move (same-filesystem renames, never a delete)
|
|
2431
3180
|
def _files(d: Path) -> int:
|
|
@@ -2443,7 +3192,12 @@ def cmd_compact(args: argparse.Namespace) -> None:
|
|
|
2443
3192
|
moved.append((f"tasks/{t}/", n))
|
|
2444
3193
|
# state write is the LAST step: additive stamp only — task_slugs untouched
|
|
2445
3194
|
entry["compacted"] = date.today().isoformat()
|
|
3195
|
+
if spec_offenders: # forced is implied (un-forced would have _die'd)
|
|
3196
|
+
entry["force_bypassed_spec_deltas"] = spec_offenders
|
|
2446
3197
|
save_state(root, state)
|
|
3198
|
+
if spec_offenders:
|
|
3199
|
+
print("⚠ --force bypassed open SPEC delta(s) in: " + " · ".join(spec_offenders) +
|
|
3200
|
+
" — recorded as force_bypassed_spec_deltas; resolve them before the next release.")
|
|
2447
3201
|
total = sum(n for _, n in moved)
|
|
2448
3202
|
print(f"compacted milestone '{slug}' -> .add/archive/{slug}/ "
|
|
2449
3203
|
f"({len(members)} task dirs, {total} files moved)")
|
|
@@ -2472,16 +3226,54 @@ def cmd_set_milestone(args: argparse.Namespace) -> None:
|
|
|
2472
3226
|
print(_next_footer(root, state))
|
|
2473
3227
|
|
|
2474
3228
|
|
|
3229
|
+
def cmd_activate(args: argparse.Namespace) -> None:
|
|
3230
|
+
"""Add a milestone to the active working SET and focus it — how a user works N milestones
|
|
3231
|
+
in parallel. Idempotent (re-activating just refocuses). Validates before mutating."""
|
|
3232
|
+
root = _require_root()
|
|
3233
|
+
state = load_state(root)
|
|
3234
|
+
slug = args.slug
|
|
3235
|
+
if slug not in state.get("milestones", {}):
|
|
3236
|
+
_die("unknown_milestone")
|
|
3237
|
+
if state["milestones"][slug].get("status") == "done":
|
|
3238
|
+
_die("milestone_done")
|
|
3239
|
+
_activate_milestone(state, slug)
|
|
3240
|
+
save_state(root, state)
|
|
3241
|
+
print(f"activated '{slug}' — active: {', '.join(state['active_milestones'])}")
|
|
3242
|
+
print(_next_footer(root, state))
|
|
3243
|
+
|
|
3244
|
+
|
|
3245
|
+
def cmd_deactivate(args: argparse.Namespace) -> None:
|
|
3246
|
+
"""Remove a milestone from the active working SET (its files + status are untouched);
|
|
3247
|
+
repoints the primary focus to a remaining member. Validates before mutating."""
|
|
3248
|
+
root = _require_root()
|
|
3249
|
+
state = load_state(root)
|
|
3250
|
+
slug = args.slug
|
|
3251
|
+
if slug not in (state.get("active_milestones") or []):
|
|
3252
|
+
_die("milestone_not_active")
|
|
3253
|
+
_deactivate_milestone(state, slug)
|
|
3254
|
+
save_state(root, state)
|
|
3255
|
+
remaining = state.get("active_milestones") or []
|
|
3256
|
+
print(f"deactivated '{slug}' — active: {', '.join(remaining) if remaining else '(none)'}")
|
|
3257
|
+
print(_next_footer(root, state))
|
|
3258
|
+
|
|
3259
|
+
|
|
2475
3260
|
def cmd_use(args: argparse.Namespace) -> None:
|
|
2476
3261
|
"""Set the active task to an EXISTING task (switch focus) without scaffolding a new
|
|
2477
3262
|
one or hand-editing state.json. advance/gate/phase still take an explicit slug; `use`
|
|
2478
|
-
just moves the default focus, closing the only gap that forced manual state edits.
|
|
3263
|
+
just moves the default focus, closing the only gap that forced manual state edits.
|
|
3264
|
+
Milestone-aware: focuses the task's OWN milestone (activating it into the set) so the
|
|
3265
|
+
active task is switched WITHIN that milestone, not mislabeled under a stale primary."""
|
|
2479
3266
|
root = _require_root()
|
|
2480
3267
|
state = load_state(root)
|
|
2481
3268
|
slug = args.slug
|
|
2482
3269
|
if slug not in state.get("tasks", {}):
|
|
2483
3270
|
_die("unknown_task")
|
|
2484
|
-
state["
|
|
3271
|
+
ms = state["tasks"][slug].get("milestone")
|
|
3272
|
+
if ms is not None and ms in state.get("milestones", {}):
|
|
3273
|
+
_activate_milestone(state, ms) # focus the task's milestone (adds to the set if needed)
|
|
3274
|
+
_set_active_task(state, slug, ms)
|
|
3275
|
+
else:
|
|
3276
|
+
_set_active_task(state, slug) # milestone-less task: scalar only (back-compat)
|
|
2485
3277
|
save_state(root, state)
|
|
2486
3278
|
print(f"active task -> '{slug}' (phase={state['tasks'][slug]['phase']})")
|
|
2487
3279
|
print(_next_footer(root, state))
|
|
@@ -3226,6 +4018,9 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
|
|
|
3226
4018
|
"phase_index": PHASES.index(phase) if phase in PHASES else 0,
|
|
3227
4019
|
"done": _task_done(t),
|
|
3228
4020
|
"gate": gate,
|
|
4021
|
+
"gate_actor": t.get("gate_actor"), # WHO recorded the verdict (None when unstamped)
|
|
4022
|
+
"owner": t.get("owner"), # WHO is accountable (None when unassigned)
|
|
4023
|
+
"assignee": t.get("assignee"), # WHO is working it (None when unassigned)
|
|
3229
4024
|
"tests": n_tests,
|
|
3230
4025
|
"tests_declared": t_declared,
|
|
3231
4026
|
"observe": observe,
|
|
@@ -3241,7 +4036,10 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
|
|
|
3241
4036
|
|
|
3242
4037
|
return {
|
|
3243
4038
|
"milestone": {"slug": mslug, "title": title, "goal": goal,
|
|
3244
|
-
"status": ms.get("status", "active")
|
|
4039
|
+
"status": ms.get("status", "active"),
|
|
4040
|
+
"done_actor": ms.get("done_actor"), # WHO closed it (None when unstamped/open)
|
|
4041
|
+
"owner": ms.get("owner"), # WHO is accountable for the milestone
|
|
4042
|
+
"assignee": ms.get("assignee")}, # WHO is working it (None when unassigned)
|
|
3245
4043
|
"summary": {
|
|
3246
4044
|
"tasks_done": sum(1 for r in task_rows if r["done"]),
|
|
3247
4045
|
"tasks_total": len(task_rows),
|
|
@@ -3423,6 +4221,24 @@ def render_task_detail(root: Path, state: dict, mslug: str, slug: str, *,
|
|
|
3423
4221
|
return "\n".join(L)
|
|
3424
4222
|
|
|
3425
4223
|
|
|
4224
|
+
def _fmt_actor(actor: dict | None) -> str:
|
|
4225
|
+
"""Format a recorded actor stamp `{name,email,source}` as `name [<email>]` for the
|
|
4226
|
+
report surface — "" when absent (user-identity: present-only render, no placeholder)."""
|
|
4227
|
+
if not actor:
|
|
4228
|
+
return ""
|
|
4229
|
+
email = f" <{actor['email']}>" if actor.get("email") else ""
|
|
4230
|
+
return f"{actor.get('name', '')}{email}"
|
|
4231
|
+
|
|
4232
|
+
|
|
4233
|
+
def _fmt_ownership(rec: dict) -> str:
|
|
4234
|
+
"""Format a record's owner/assignee as `owner: <name> · assignee: <name>` for the
|
|
4235
|
+
surface (ownership-assignment) — present-only: each role appears only when set, and
|
|
4236
|
+
"" when neither is. Reuses _fmt_actor to render each `{name,email,source}` actor."""
|
|
4237
|
+
bits = [f"{role}: {_fmt_actor(rec[role])}" for role in ("owner", "assignee")
|
|
4238
|
+
if rec.get(role) and rec[role].get("name")] # skip a hand-edited blank-name record
|
|
4239
|
+
return " · ".join(bits)
|
|
4240
|
+
|
|
4241
|
+
|
|
3426
4242
|
def render_report(root: Path, state: dict, mslug: str, *,
|
|
3427
4243
|
width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str:
|
|
3428
4244
|
"""Format the FACTS (report_data) as the text DASHBOARD — verdict-first header,
|
|
@@ -3457,6 +4273,13 @@ def render_report(root: Path, state: dict, mslug: str, *,
|
|
|
3457
4273
|
L.append(f" {'GATES':<9} {gate_txt:<18} {'WAIVERS':<9} {waiver_txt}")
|
|
3458
4274
|
L.append("")
|
|
3459
4275
|
L.extend(_wrap(m["goal"], W - 7, " goal "))
|
|
4276
|
+
# who closed the milestone (user-identity) — present-only, never a placeholder
|
|
4277
|
+
if m.get("done_actor"):
|
|
4278
|
+
L.append(f" closed by {_fmt_actor(m['done_actor'])}")
|
|
4279
|
+
# who owns/works the milestone (ownership-assignment) — present-only
|
|
4280
|
+
_ms_own = _fmt_ownership(m)
|
|
4281
|
+
if _ms_own:
|
|
4282
|
+
L.append(f" owned by {_ms_own}")
|
|
3460
4283
|
L.append("")
|
|
3461
4284
|
if d["tasks"]:
|
|
3462
4285
|
L.append(f" {'TASK':<27} {'PHASE':<9} {'GATE':<4} {'TESTS':<5} PROGRESS")
|
|
@@ -3471,6 +4294,21 @@ def render_report(root: Path, state: dict, mslug: str, *,
|
|
|
3471
4294
|
f"{g['pending']} pending spec→…→done")
|
|
3472
4295
|
if any(r.get("tests_declared") for r in d["tasks"]):
|
|
3473
4296
|
L.append(" † counted at the §4-declared path")
|
|
4297
|
+
# who recorded each verdict (user-identity) — present-only audit trail
|
|
4298
|
+
gated = [r for r in d["tasks"] if r.get("gate_actor")]
|
|
4299
|
+
if gated:
|
|
4300
|
+
L.append("")
|
|
4301
|
+
L.append(" GATED BY")
|
|
4302
|
+
for r in gated:
|
|
4303
|
+
short = _GATE_SHORT.get(r["gate"], r["gate"])
|
|
4304
|
+
L.append(f" {_clip(r['slug'], 24):<24} {short:<4} {_fmt_actor(r['gate_actor'])}")
|
|
4305
|
+
# who owns/works each task (ownership-assignment) — present-only, mirror of GATED BY
|
|
4306
|
+
owned = [r for r in d["tasks"] if r.get("owner") or r.get("assignee")]
|
|
4307
|
+
if owned:
|
|
4308
|
+
L.append("")
|
|
4309
|
+
L.append(" OWNED BY")
|
|
4310
|
+
for r in owned:
|
|
4311
|
+
L.append(f" {_clip(r['slug'], 24):<24} {_fmt_ownership(r)}")
|
|
3474
4312
|
else:
|
|
3475
4313
|
L.append(" (no tasks yet)")
|
|
3476
4314
|
L.append("")
|
|
@@ -3764,7 +4602,7 @@ def _decide_next_pair(state: dict, d: dict) -> tuple[str, bool]:
|
|
|
3764
4602
|
return (f"goal not met ({met}/{total} exit criteria) — propose next tasks "
|
|
3765
4603
|
f"from open deltas / the unscaffolded plan (add.py deltas)"), True
|
|
3766
4604
|
return f"consolidate learnings + archive-milestone {ms}", True
|
|
3767
|
-
active = state
|
|
4605
|
+
active = _active_task(state)
|
|
3768
4606
|
order = sorted(rows, key=lambda r: 0 if r["slug"] == active else 1) # stable
|
|
3769
4607
|
for r in order:
|
|
3770
4608
|
if r["done"]:
|
|
@@ -3806,7 +4644,7 @@ def _next_footer(root: Path, state: dict) -> str:
|
|
|
3806
4644
|
The fail-soft line carries NO marker — never assert a driver that could not be computed.
|
|
3807
4645
|
"""
|
|
3808
4646
|
try:
|
|
3809
|
-
slug = state
|
|
4647
|
+
slug = _active_task(state)
|
|
3810
4648
|
t = (state.get("tasks") or {}).get(slug) if slug else None
|
|
3811
4649
|
if t and t.get("gate", "none") == "none" and t.get("phase") != "done":
|
|
3812
4650
|
phase = t.get("phase")
|
|
@@ -3815,7 +4653,7 @@ def _next_footer(root: Path, state: dict) -> str:
|
|
|
3815
4653
|
if phase == "verify" else "add.py advance")
|
|
3816
4654
|
marker = _driver_marker(_driver_stop(root, state, slug, phase))
|
|
3817
4655
|
return f"next: {command} — {why}{marker}"
|
|
3818
|
-
mslug = state
|
|
4656
|
+
mslug = _active_milestone(state)
|
|
3819
4657
|
if mslug:
|
|
3820
4658
|
d = report_data(root, state, mslug)
|
|
3821
4659
|
text, human_stop = _decide_next_pair(state, d)
|
|
@@ -4092,40 +4930,61 @@ def _collect_open_spec_deltas(root: Path) -> list[dict]:
|
|
|
4092
4930
|
_SPEC_OPEN_TOKEN_RE = re.compile(r"(\[\s*SPEC\s*·\s*)open(\s*\])")
|
|
4093
4931
|
|
|
4094
4932
|
|
|
4095
|
-
def _resolve_spec_delta(text: str, new_status: str, pointer: str | None = None
|
|
4096
|
-
|
|
4933
|
+
def _resolve_spec_delta(text: str, new_status: str, pointer: str | None = None,
|
|
4934
|
+
line_index: int | None = None) -> str | None:
|
|
4935
|
+
"""Flip ONE `[SPEC · open]` line in `text` to `new_status`; return the new text.
|
|
4097
4936
|
|
|
4098
|
-
PURE — no IO.
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
(
|
|
4937
|
+
PURE — no IO. With `line_index` (a splitlines(keepends=True) index, as `_select_spec_delta`
|
|
4938
|
+
returns) flip THAT line; without it, flip the FIRST open delta (back-compat). Only the status
|
|
4939
|
+
token changes (+ a trailing ` [→ <pointer>]` provenance stamp when seeding); the entry's text
|
|
4940
|
+
and `(evidence: …)` are byte-preserved. Returns None when there is NO open SPEC delta to flip —
|
|
4941
|
+
the caller then refuses and writes nothing. Mirrors the `_autonomy_decl_line` pure-transform."""
|
|
4102
4942
|
lines = text.splitlines(keepends=True)
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4943
|
+
target = line_index
|
|
4944
|
+
if target is None: # back-compat: the FIRST open delta
|
|
4945
|
+
for i, ln in enumerate(lines):
|
|
4946
|
+
m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
|
|
4947
|
+
if m and m.group(2) == "open":
|
|
4948
|
+
target = i
|
|
4949
|
+
break
|
|
4950
|
+
if target is None:
|
|
4951
|
+
return None
|
|
4952
|
+
ln = lines[target]
|
|
4953
|
+
eol = ln[len(ln.rstrip("\n")):] # preserve the exact line ending
|
|
4954
|
+
body = _SPEC_OPEN_TOKEN_RE.sub(rf"\g<1>{new_status}\g<2>", ln.rstrip("\n"), count=1)
|
|
4955
|
+
if pointer:
|
|
4956
|
+
body = f"{body} [→ {pointer}]"
|
|
4957
|
+
lines[target] = body + eol
|
|
4958
|
+
return "".join(lines)
|
|
4114
4959
|
|
|
4115
4960
|
|
|
4116
|
-
def
|
|
4117
|
-
"""
|
|
4961
|
+
def _select_spec_delta(text: str, match: str | None = None) -> tuple[str, int | None, str | None]:
|
|
4962
|
+
"""Pick the open SPEC delta to resolve (delta-match-selector). PURE — no IO.
|
|
4118
4963
|
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4964
|
+
`match=None` -> the FIRST open delta. `match=<substr>` -> the UNIQUE open delta whose display
|
|
4965
|
+
text (status token + `(evidence: …)` excluded) contains <substr>, case-insensitive. Returns
|
|
4966
|
+
(status, line_index, display_text) where status is one of: "ok" (line_index/display set),
|
|
4967
|
+
"no_open" (no open delta at all), "no_match" (--match hit zero), "ambiguous" (--match hit >1).
|
|
4968
|
+
line_index is a splitlines(keepends=True) index, the same `_resolve_spec_delta` flips."""
|
|
4969
|
+
opens: list[tuple[int, str]] = []
|
|
4970
|
+
for i, ln in enumerate(text.splitlines(keepends=True)):
|
|
4971
|
+
m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
|
|
4972
|
+
if not m or m.group(2) != "open":
|
|
4124
4973
|
continue
|
|
4125
|
-
tail =
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4974
|
+
tail = m.group(3).strip()
|
|
4975
|
+
cut = tail.find("(evidence:") # exclude the evidence tail even if its paren is unclosed
|
|
4976
|
+
opens.append((i, (tail[:cut].strip() if cut != -1 else tail)))
|
|
4977
|
+
if not opens:
|
|
4978
|
+
return ("no_open", None, None)
|
|
4979
|
+
if match is None:
|
|
4980
|
+
return ("ok", opens[0][0], opens[0][1])
|
|
4981
|
+
needle = match.lower()
|
|
4982
|
+
hits = [(i, d) for i, d in opens if needle in d.lower()]
|
|
4983
|
+
if not hits:
|
|
4984
|
+
return ("no_match", None, None)
|
|
4985
|
+
if len(hits) > 1:
|
|
4986
|
+
return ("ambiguous", None, None)
|
|
4987
|
+
return ("ok", hits[0][0], hits[0][1])
|
|
4129
4988
|
|
|
4130
4989
|
|
|
4131
4990
|
# ── add.py fold — mechanized competency-lesson consolidation ────────────────────────────────
|
|
@@ -4295,13 +5154,9 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
4295
5154
|
proj_text = _prepend_key_decision_row(proj_text, row)
|
|
4296
5155
|
proj_text = re.sub(r"foundation-version:\s*\d+", f"foundation-version: {new_v}", proj_text, count=1)
|
|
4297
5156
|
|
|
4298
|
-
# ── all bodies built; commit via
|
|
4299
|
-
#
|
|
4300
|
-
#
|
|
4301
|
-
# foundation advanced while a TASK.md stays unflipped; files are ordered foundation-FIRST so
|
|
4302
|
-
# the lesson then stays visibly `open` and a re-run re-transcribes (DUPLICATING, never
|
|
4303
|
-
# losing — manual fixup), rather than a silently-flipped-but-untranscribed loss. A true
|
|
4304
|
-
# all-or-nothing N-file commit is the multi-file-commit follow-up task. ────────────────────
|
|
5157
|
+
# ── all bodies built; commit ALL via the all-or-nothing multi-file primitive: a stage failure
|
|
5158
|
+
# (disk-full / permission) writes nothing, and a mid-commit rename failure rolls back every
|
|
5159
|
+
# already-committed file — so the foundation never advances while a TASK.md stays unflipped. ─
|
|
4305
5160
|
writes: list[tuple[Path, str]] = [(project_md, proj_text)]
|
|
4306
5161
|
touched = ["PROJECT.md"]
|
|
4307
5162
|
if conv_text != conventions_text:
|
|
@@ -4764,13 +5619,18 @@ def _render_changelog_block(version: str, day: str, bundle: list[dict],
|
|
|
4764
5619
|
|
|
4765
5620
|
|
|
4766
5621
|
def _render_releases_row(version: str, day: str, bundle: list[dict],
|
|
4767
|
-
waiver_slugs: list[str], evidence: str | None
|
|
4768
|
-
|
|
5622
|
+
waiver_slugs: list[str], evidence: str | None,
|
|
5623
|
+
actor: str | None = None) -> str:
|
|
5624
|
+
"""One append-only RELEASES.md row — the attribution source (`milestones:` membership).
|
|
5625
|
+
The `actor:` line records WHO cut the release (structured-actor stamping); absent on a
|
|
5626
|
+
legacy row (back-compat) when no actor is supplied."""
|
|
4769
5627
|
ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
|
|
4770
5628
|
wv = ", ".join(waiver_slugs) if waiver_slugs else "none"
|
|
5629
|
+
actor_line = f"actor: {actor}\n" if actor else ""
|
|
4771
5630
|
return (f"## {version} — {day}\n"
|
|
4772
5631
|
f"milestones: {ms}\n"
|
|
4773
5632
|
f"waivers: {wv}\n"
|
|
5633
|
+
f"{actor_line}"
|
|
4774
5634
|
f"evidence: {evidence or 'recorded by add.py release'}\n\n")
|
|
4775
5635
|
|
|
4776
5636
|
|
|
@@ -4816,20 +5676,13 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
4816
5676
|
_render_changelog_block(args.version, day, bundle, changed_by_slug))
|
|
4817
5677
|
new_rel = _prepend_block(rel_before, "# Releases",
|
|
4818
5678
|
_render_releases_row(args.version, day, bundle, waiver_slugs,
|
|
4819
|
-
getattr(args, "evidence", None)
|
|
4820
|
-
|
|
4821
|
-
try:
|
|
4822
|
-
|
|
5679
|
+
getattr(args, "evidence", None),
|
|
5680
|
+
_render_actor_line(state)))
|
|
5681
|
+
try: # CHANGELOG + RELEASES as one all-or-nothing commit
|
|
5682
|
+
_atomic_write_many([(changelog_path, new_cl), (releases_path, new_rel)])
|
|
4823
5683
|
except OSError as e:
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
else:
|
|
4827
|
-
try:
|
|
4828
|
-
changelog_path.unlink()
|
|
4829
|
-
except OSError:
|
|
4830
|
-
pass
|
|
4831
|
-
_die(f"release_write_failed: the ledger write failed ({e}); CHANGELOG was rolled back — "
|
|
4832
|
-
"nothing was recorded. Retry the release.")
|
|
5684
|
+
_die(f"release_write_failed: the ledger write failed ({e}); nothing was recorded — both "
|
|
5685
|
+
"files were rolled back to their prior content. Retry the release.")
|
|
4833
5686
|
|
|
4834
5687
|
# NO save_state — attribution lives in RELEASES.md (the cue re-reads it), never state.json
|
|
4835
5688
|
ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
|
|
@@ -4926,13 +5779,13 @@ def cmd_report(args: argparse.Namespace) -> None:
|
|
|
4926
5779
|
else:
|
|
4927
5780
|
_die(f"unknown_milestone: '{name}' is not a milestone")
|
|
4928
5781
|
elif getattr(args, "decide", False): # bare --decide -> the ACTIVE TASK
|
|
4929
|
-
slug = state
|
|
5782
|
+
slug = _active_task(state)
|
|
4930
5783
|
if not slug or slug not in tasks:
|
|
4931
5784
|
_die("no_active_task — name one: add.py report <milestone> <task> --decide")
|
|
4932
5785
|
drill_task = slug
|
|
4933
5786
|
mslug = tasks[slug].get("milestone") or ""
|
|
4934
5787
|
else: # no positional -> active milestone
|
|
4935
|
-
mslug = state
|
|
5788
|
+
mslug = _active_milestone(state)
|
|
4936
5789
|
if not mslug:
|
|
4937
5790
|
_die("no_active_milestone: no milestone given and none is active; "
|
|
4938
5791
|
"try `add.py report <milestone>`")
|
|
@@ -5005,6 +5858,33 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5005
5858
|
pl.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
|
|
5006
5859
|
pl.set_defaults(func=cmd_lock)
|
|
5007
5860
|
|
|
5861
|
+
pwho = sub.add_parser("whoami",
|
|
5862
|
+
help="show / set the git-native actor (git config -> OS user; --name to override)")
|
|
5863
|
+
# --name (set) and --unset (clear) are mutually exclusive — argparse rejects the
|
|
5864
|
+
# contradiction (exit 2) before any state read, so neither silently wins.
|
|
5865
|
+
pwho_mut = pwho.add_mutually_exclusive_group()
|
|
5866
|
+
pwho_mut.add_argument("--name", default=None, help="set an actor override (name)")
|
|
5867
|
+
pwho_mut.add_argument("--unset", action="store_true", help="clear the actor override")
|
|
5868
|
+
pwho.add_argument("--email", default=None, help="set the override email (with --name)")
|
|
5869
|
+
pwho.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
|
|
5870
|
+
pwho.set_defaults(func=cmd_whoami)
|
|
5871
|
+
|
|
5872
|
+
pas = sub.add_parser("assign",
|
|
5873
|
+
help="assign an owner/assignee to a task or milestone (no flag = self)")
|
|
5874
|
+
pas.add_argument("slug")
|
|
5875
|
+
pas.add_argument("--owner", default=None, metavar="\"Name <email>\"",
|
|
5876
|
+
help="set the accountable owner (default with no flag: self)")
|
|
5877
|
+
pas.add_argument("--assignee", default=None, metavar="\"Name <email>\"",
|
|
5878
|
+
help="set the working assignee (default with no flag: self)")
|
|
5879
|
+
pas.set_defaults(func=cmd_assign)
|
|
5880
|
+
|
|
5881
|
+
pun = sub.add_parser("unassign",
|
|
5882
|
+
help="clear the owner/assignee of a task or milestone (no flag = both)")
|
|
5883
|
+
pun.add_argument("slug")
|
|
5884
|
+
pun.add_argument("--owner", action="store_true", help="clear only the owner")
|
|
5885
|
+
pun.add_argument("--assignee", action="store_true", help="clear only the assignee")
|
|
5886
|
+
pun.set_defaults(func=cmd_unassign)
|
|
5887
|
+
|
|
5008
5888
|
pn = sub.add_parser("new-task", help="scaffold a new task (TASK.md + tests/ + src/)")
|
|
5009
5889
|
pn.add_argument("slug")
|
|
5010
5890
|
pn.add_argument("--title", default=None)
|
|
@@ -5012,14 +5892,23 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5012
5892
|
pn.add_argument("--depends-on", dest="depends_on", default=None,
|
|
5013
5893
|
help="comma-separated task slugs this task depends on")
|
|
5014
5894
|
pn.add_argument("--from-delta", dest="from_delta", default=None, metavar="PRIOR",
|
|
5015
|
-
help="SEED PRIOR's
|
|
5895
|
+
help="SEED PRIOR's open SPEC delta into this task (pre-fills §1 "
|
|
5016
5896
|
"Feature, flips the source -> [SPEC · seeded] [→ this])")
|
|
5897
|
+
pn.add_argument("--match", default=None, metavar="SUBSTR",
|
|
5898
|
+
help="with --from-delta: target the UNIQUE open SPEC delta whose text "
|
|
5899
|
+
"contains SUBSTR (case-insensitive) instead of the first")
|
|
5017
5900
|
pn.add_argument("--force", action="store_true", help="overwrite TASK.md if present")
|
|
5901
|
+
pn.add_argument("--fast", action="store_true",
|
|
5902
|
+
help="opt into the fast lane: scaffold the minimal TASK.fast.md template + "
|
|
5903
|
+
"hold the task to the freeze floor under any milestone")
|
|
5018
5904
|
pn.set_defaults(func=cmd_new_task)
|
|
5019
5905
|
|
|
5020
5906
|
pdd = sub.add_parser("drop-delta",
|
|
5021
|
-
help="dismiss a task's
|
|
5022
|
-
pdd.add_argument("slug", help="task whose
|
|
5907
|
+
help="dismiss a task's open SPEC delta -> [SPEC · dropped]")
|
|
5908
|
+
pdd.add_argument("slug", help="task whose open SPEC delta to drop")
|
|
5909
|
+
pdd.add_argument("--match", default=None, metavar="SUBSTR",
|
|
5910
|
+
help="target the UNIQUE open SPEC delta whose text contains SUBSTR "
|
|
5911
|
+
"(case-insensitive) instead of the first")
|
|
5023
5912
|
pdd.set_defaults(func=cmd_drop_delta)
|
|
5024
5913
|
|
|
5025
5914
|
pm = sub.add_parser("new-milestone", help="scaffold a milestone (SDD living doc)")
|
|
@@ -5028,8 +5917,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5028
5917
|
pm.add_argument("--goal", default=None, help="one-sentence outcome")
|
|
5029
5918
|
pm.add_argument("--stage", default="mvp", choices=STAGES)
|
|
5030
5919
|
pm.add_argument("--force", action="store_true", help="overwrite MILESTONE.md if present")
|
|
5920
|
+
pm.add_argument("--await-confirm", action="store_true",
|
|
5921
|
+
help="opt into the confirm-parent gate: seed the milestone unconfirmed so "
|
|
5922
|
+
"new-task is held until `milestone-confirm` (mirrors `init --await-lock`); "
|
|
5923
|
+
"the guided skill flow passes this at the human-review point")
|
|
5031
5924
|
pm.set_defaults(func=cmd_new_milestone)
|
|
5032
5925
|
|
|
5926
|
+
pmc = sub.add_parser("milestone-confirm",
|
|
5927
|
+
help="confirm a milestone (the human gate that opens new-task for it)")
|
|
5928
|
+
pmc.add_argument("slug")
|
|
5929
|
+
pmc.add_argument("--by", default=None, help="free-text confirmer name (defaults to the OS user)")
|
|
5930
|
+
pmc.set_defaults(func=cmd_milestone_confirm)
|
|
5931
|
+
|
|
5033
5932
|
pr = sub.add_parser("ready", help="list tasks whose dependencies are satisfied")
|
|
5034
5933
|
pr.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
5035
5934
|
pr.set_defaults(func=cmd_ready)
|
|
@@ -5054,6 +5953,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5054
5953
|
pu.add_argument("slug")
|
|
5055
5954
|
pu.set_defaults(func=cmd_use)
|
|
5056
5955
|
|
|
5956
|
+
pac = sub.add_parser("activate",
|
|
5957
|
+
help="add a milestone to the active working SET and focus it (parallel milestones)")
|
|
5958
|
+
pac.add_argument("slug")
|
|
5959
|
+
pac.set_defaults(func=cmd_activate)
|
|
5960
|
+
|
|
5961
|
+
pdac = sub.add_parser("deactivate",
|
|
5962
|
+
help="remove a milestone from the active working SET (its files stay on disk)")
|
|
5963
|
+
pdac.add_argument("slug")
|
|
5964
|
+
pdac.set_defaults(func=cmd_deactivate)
|
|
5965
|
+
|
|
5057
5966
|
pam = sub.add_parser("archive-milestone",
|
|
5058
5967
|
help="collapse a done milestone out of active state (files stay on disk)")
|
|
5059
5968
|
pam.add_argument("slug")
|
|
@@ -5063,6 +5972,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5063
5972
|
help="heavy archive: move an archived milestone's files into "
|
|
5064
5973
|
".add/archive/<slug>/ (recoverable reverse move)")
|
|
5065
5974
|
pco.add_argument("slug")
|
|
5975
|
+
pco.add_argument("--force", action="store_true",
|
|
5976
|
+
help="compact past an unrelated open SPEC delta (the open_spec_deltas_unresolved "
|
|
5977
|
+
"guard ONLY; never a structural reject) — bypass is warned + recorded")
|
|
5066
5978
|
pco.set_defaults(func=cmd_compact)
|
|
5067
5979
|
|
|
5068
5980
|
pp = sub.add_parser("phase", help="set a task's phase explicitly")
|
|
@@ -5121,6 +6033,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5121
6033
|
pck.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
5122
6034
|
pck.set_defaults(func=cmd_check)
|
|
5123
6035
|
|
|
6036
|
+
pdoc = sub.add_parser("doctor", help="read-only diagnosis of state.json integrity + "
|
|
6037
|
+
"referential consistency (run after a git merge)")
|
|
6038
|
+
pdoc.set_defaults(func=cmd_doctor)
|
|
6039
|
+
|
|
6040
|
+
pmine = sub.add_parser("mine", help="read-only: my not-done tasks (owner or assignee) "
|
|
6041
|
+
"across all active milestones")
|
|
6042
|
+
pmine.add_argument("--actor", default=None, metavar="\"Name <email>\"",
|
|
6043
|
+
help="inspect another actor's queue instead of your own")
|
|
6044
|
+
pmine.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
|
|
6045
|
+
pmine.set_defaults(func=cmd_mine)
|
|
6046
|
+
|
|
5124
6047
|
pwv = sub.add_parser("wave-verify",
|
|
5125
6048
|
help="read-only merge-time gate: every WAVE.md roster echo must match "
|
|
5126
6049
|
"base (refuses unverified_fork_base) — run before the first merge-back")
|