@pilotspace/add 1.7.2 → 1.8.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 +64 -0
- package/GETTING-STARTED.md +5 -2
- package/README.md +7 -0
- package/bin/cli.js +69 -1
- package/package.json +1 -1
- package/tooling/add.py +853 -145
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
|
|
@@ -173,29 +175,59 @@ def _atomic_write(path: Path, text: str) -> None:
|
|
|
173
175
|
|
|
174
176
|
|
|
175
177
|
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
|
|
178
|
+
"""True all-or-nothing commit across N files — design-for-failure for a multi-file write.
|
|
179
|
+
|
|
180
|
+
Phase 1 STAGES every (path, text) to a sibling `.tmp`, flushing + fsync-ing each, so the
|
|
181
|
+
realistic IO failures (disk full, permission denied) surface HERE, before any target changes —
|
|
182
|
+
and on any stage failure every staged temp is removed, so NOTHING is committed. Phase 2 then
|
|
183
|
+
COMMITS each file by renaming any existing target ASIDE to a sibling `.bak`, then `os.replace`-ing
|
|
184
|
+
the staged `.tmp` into place. If ANY commit rename raises, every file already committed is rolled
|
|
185
|
+
back IN REVERSE (remove the landed new file, rename its `.bak` back, or leave it absent) and the
|
|
186
|
+
original error re-raised — so the whole set is all-or-nothing: either every file holds its new
|
|
187
|
+
text, or every file holds its prior content. Restoring is an atomic rename of an already-written
|
|
188
|
+
`.bak` (no content held in memory, no re-write), the cheapest recovery under failing IO. Leftover
|
|
189
|
+
`.tmp`/`.bak` siblings are removed on every exit path.
|
|
184
190
|
"""
|
|
185
191
|
staged: list[tuple[str, Path]] = []
|
|
192
|
+
committed: list[list] = [] # [path, bak_or_None, new_landed] per committed file
|
|
186
193
|
try:
|
|
187
|
-
for path, text in writes:
|
|
194
|
+
for path, text in writes: # phase 1: stage every temp (fsync'd before any commit)
|
|
188
195
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
189
196
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
|
197
|
+
staged.append((tmp, path)) # track BEFORE write so a write/fsync failure still cleans it
|
|
190
198
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
191
199
|
fh.write(text)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
200
|
+
fh.flush()
|
|
201
|
+
os.fsync(fh.fileno())
|
|
202
|
+
try:
|
|
203
|
+
for tmp, path in staged: # phase 2: commit via rename-aside
|
|
204
|
+
bak = None
|
|
205
|
+
existed = path.exists()
|
|
206
|
+
if existed:
|
|
207
|
+
fd2, bak = tempfile.mkstemp(dir=str(path.parent), suffix=".bak")
|
|
208
|
+
os.close(fd2)
|
|
209
|
+
committed.append([path, bak, False]) # track .bak NOW so cleanup never leaks it
|
|
210
|
+
if existed:
|
|
211
|
+
os.replace(path, bak) # move the existing target aside
|
|
212
|
+
os.replace(tmp, path) # move the new file in
|
|
213
|
+
committed[-1][2] = True
|
|
214
|
+
except OSError:
|
|
215
|
+
for path, bak, landed in reversed(committed): # roll back, newest-first
|
|
216
|
+
try:
|
|
217
|
+
if landed and path.exists():
|
|
218
|
+
os.unlink(path) # drop the new file we put in
|
|
219
|
+
if bak is not None and not path.exists():
|
|
220
|
+
os.replace(bak, path) # restore the prior target (atomic rename)
|
|
221
|
+
except OSError:
|
|
222
|
+
pass # best-effort restore under already-failing IO
|
|
223
|
+
raise
|
|
195
224
|
finally:
|
|
196
|
-
for tmp, _ in staged: # leftover
|
|
225
|
+
for tmp, _ in staged: # leftover .tmp (failed/aborted stage) never persists
|
|
197
226
|
if os.path.exists(tmp):
|
|
198
227
|
os.unlink(tmp)
|
|
228
|
+
for path, bak, landed in committed: # leftover .bak (success, or orphaned post-rollback)
|
|
229
|
+
if bak is not None and os.path.exists(bak):
|
|
230
|
+
os.unlink(bak)
|
|
199
231
|
|
|
200
232
|
|
|
201
233
|
def _templates_dir() -> Path:
|
|
@@ -238,12 +270,238 @@ def _require_root() -> Path:
|
|
|
238
270
|
return root
|
|
239
271
|
|
|
240
272
|
|
|
273
|
+
def _migrate_state(state: dict) -> dict:
|
|
274
|
+
"""Forward-migrate a single-active state to the multi-active schema (team-collaboration
|
|
275
|
+
foundation). PURE · idempotent · TOTAL · never raises · no I/O.
|
|
276
|
+
|
|
277
|
+
A state lacking the `active_milestones` key gains it — DERIVED from the scalar
|
|
278
|
+
`active_milestone` (grandfather-by-missing-key, mirroring `_setup_locked`): None -> [],
|
|
279
|
+
"x" -> ["x"]. A per-milestone active-task map `active_tasks` is added; the old global
|
|
280
|
+
`active_task` is placed under its owning active milestone only when it genuinely belongs
|
|
281
|
+
there, else it stays as the top-level scalar fallback (orphan rule, FROZEN decision (a)).
|
|
282
|
+
The scalar `active_milestone` / `active_task` keys are KEPT as the N<=1 mirror so the
|
|
283
|
+
not-yet-routed readers keep working. An already-migrated state (key present) is returned
|
|
284
|
+
unchanged — never re-derived, never clobbered. Corrupt parsing stays the loader's job.
|
|
285
|
+
|
|
286
|
+
PURE in the observable sense: the caller's dict is NEVER mutated — a state that needs
|
|
287
|
+
migrating is upgraded on a fresh top-level copy (nested objects are shared but only read)."""
|
|
288
|
+
if not isinstance(state, dict) or "active_milestones" in state:
|
|
289
|
+
return state
|
|
290
|
+
migrated = dict(state)
|
|
291
|
+
active_ms = migrated.get("active_milestone")
|
|
292
|
+
migrated["active_milestones"] = [] if active_ms is None else [active_ms]
|
|
293
|
+
active_task = migrated.get("active_task")
|
|
294
|
+
tasks = migrated.get("tasks") or {}
|
|
295
|
+
owns = (active_ms is not None and active_task is not None
|
|
296
|
+
and isinstance(tasks.get(active_task), dict)
|
|
297
|
+
and tasks[active_task].get("milestone") == active_ms)
|
|
298
|
+
migrated["active_tasks"] = {active_ms: active_task} if owns else {}
|
|
299
|
+
return migrated
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# --- active milestone/task accessor seam (multi-active foundation) -----------
|
|
303
|
+
#
|
|
304
|
+
# Every engine call site reads & writes the active milestone(s)/task through these
|
|
305
|
+
# four helpers, so multi-active SEMANTICS can land in one place later. Today they
|
|
306
|
+
# preserve single-active behavior exactly: reads return the scalar mirror, writes
|
|
307
|
+
# keep the scalar AND the new active_milestones/active_tasks structures in sync.
|
|
308
|
+
|
|
309
|
+
def _active_milestone(state: dict) -> str | None:
|
|
310
|
+
"""The primary active milestone — the N<=1 scalar mirror (== active_milestones[0])."""
|
|
311
|
+
return state.get("active_milestone")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _active_task(state: dict, milestone: str | None = None) -> str | None:
|
|
315
|
+
"""The active task: per-milestone when `milestone` is given (partial-state -> None),
|
|
316
|
+
else the global/primary scalar active task. Total — never raises."""
|
|
317
|
+
if milestone is None:
|
|
318
|
+
return state.get("active_task")
|
|
319
|
+
return (state.get("active_tasks") or {}).get(milestone)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _set_active_milestone(state: dict, slug: str | None) -> None:
|
|
323
|
+
"""Set the primary active milestone, keeping `active_milestones` consistent (N<=1 sync)."""
|
|
324
|
+
state["active_milestone"] = slug
|
|
325
|
+
state["active_milestones"] = [] if slug is None else [slug]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _set_active_task(state: dict, slug: str | None, milestone: str | None = None) -> None:
|
|
329
|
+
"""Set the active task, keeping the scalar mirror AND the per-milestone map in sync.
|
|
330
|
+
With no owning active milestone the active task is scalar-only (the migration's orphan
|
|
331
|
+
rule); clearing (slug is None) pops the milestone's entry."""
|
|
332
|
+
state["active_task"] = slug
|
|
333
|
+
ms = milestone if milestone is not None else _active_milestone(state)
|
|
334
|
+
tasks_map = state.setdefault("active_tasks", {})
|
|
335
|
+
if ms is None:
|
|
336
|
+
return
|
|
337
|
+
if slug is None:
|
|
338
|
+
tasks_map.pop(ms, None)
|
|
339
|
+
else:
|
|
340
|
+
tasks_map[ms] = slug
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _activate_milestone(state: dict, slug: str) -> None:
|
|
344
|
+
"""Add a milestone to the active SET (idempotent) and make it the primary focus,
|
|
345
|
+
syncing the scalar active_task to that milestone's entry. Does NOT remove other members
|
|
346
|
+
(this is how a user reaches N>=2 active milestones)."""
|
|
347
|
+
ms_list = state.setdefault("active_milestones", [])
|
|
348
|
+
if slug not in ms_list:
|
|
349
|
+
ms_list.append(slug)
|
|
350
|
+
state["active_milestone"] = slug
|
|
351
|
+
state["active_task"] = (state.get("active_tasks") or {}).get(slug)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _deactivate_milestone(state: dict, slug: str) -> None:
|
|
355
|
+
"""Remove a milestone from the active SET, pop its active-task entry, and (if it was the
|
|
356
|
+
primary) repoint the primary to the most-recent remaining member (or None when empty)."""
|
|
357
|
+
ms_list = state.setdefault("active_milestones", [])
|
|
358
|
+
if slug in ms_list:
|
|
359
|
+
ms_list.remove(slug)
|
|
360
|
+
(state.setdefault("active_tasks", {})).pop(slug, None)
|
|
361
|
+
if state.get("active_milestone") == slug:
|
|
362
|
+
new = ms_list[-1] if ms_list else None
|
|
363
|
+
state["active_milestone"] = new
|
|
364
|
+
state["active_task"] = (state.get("active_tasks") or {}).get(new) if new else None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _git_config(key: str) -> str | None:
|
|
368
|
+
"""Read one `git config --get <key>`, STRICTLY fail-soft: the engine's FIRST git call,
|
|
369
|
+
so it never raises, never hangs, never shells. Returns the trimmed value, or None when
|
|
370
|
+
git is absent / errors / times out / the value is empty."""
|
|
371
|
+
if shutil.which("git") is None:
|
|
372
|
+
return None
|
|
373
|
+
try:
|
|
374
|
+
out = subprocess.run(
|
|
375
|
+
["git", "config", "--get", key],
|
|
376
|
+
capture_output=True, text=True, timeout=2,
|
|
377
|
+
).stdout.strip()
|
|
378
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
379
|
+
# OSError: git vanished between which() and run() / spawn error · SubprocessError:
|
|
380
|
+
# TimeoutExpired · ValueError: a non-UTF-8 config value (latin-1 legacy name) makes
|
|
381
|
+
# text=True decoding raise UnicodeDecodeError (a ValueError) — all fail soft to None.
|
|
382
|
+
return None
|
|
383
|
+
return out or None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _os_user() -> str:
|
|
387
|
+
"""The guaranteed non-empty OS floor. getpass.getuser() reads LOGNAME/USER/... then
|
|
388
|
+
falls back to the passwd database — but in a bare container (no env var AND no passwd
|
|
389
|
+
entry) CPython raises KeyError (OSError only on 3.13+). Catch broadly and return a
|
|
390
|
+
sentinel so _whoami stays TOTAL: it always yields a non-empty name, never crashes."""
|
|
391
|
+
try:
|
|
392
|
+
return getpass.getuser() or "unknown"
|
|
393
|
+
except (KeyError, OSError):
|
|
394
|
+
return "unknown"
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _whoami(state: dict) -> dict:
|
|
398
|
+
"""Resolve the current git-native ACTOR -> {name, email, source}. Priority:
|
|
399
|
+
(1) an `actor_override` (whoami --set) with a non-blank name -> source 'override';
|
|
400
|
+
(2) `git config user.name`/`user.email` -> source 'git';
|
|
401
|
+
(3) the OS user (_os_user) -> source 'os', the guaranteed non-empty floor.
|
|
402
|
+
Total: always returns a dict with a non-empty name; `email` may be None."""
|
|
403
|
+
ov = state.get("actor_override")
|
|
404
|
+
if ov and (ov.get("name") or "").strip():
|
|
405
|
+
return {"name": ov["name"], "email": ov.get("email"), "source": "override"}
|
|
406
|
+
name = _git_config("user.name")
|
|
407
|
+
if name:
|
|
408
|
+
return {"name": name, "email": _git_config("user.email"), "source": "git"}
|
|
409
|
+
return {"name": _os_user(), "email": None, "source": "os"}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _actor_stamp(state: dict) -> dict:
|
|
413
|
+
"""The SINGLE source of the structured-actor stamp every engine-WRITTEN human action
|
|
414
|
+
records — lock · gate · milestone-done · release (user-identity actor-stamping). It IS
|
|
415
|
+
`_whoami(state)`: a TOTAL {name,email,source} (always a non-empty name), so a stamp can
|
|
416
|
+
never fail or block a write. Descriptive only — no command's decision reads it."""
|
|
417
|
+
return _whoami(state)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _render_actor_line(state: dict) -> str:
|
|
421
|
+
"""Render the actor stamp as one human-readable line: name, an optional angle-bracketed
|
|
422
|
+
email, then the source in parens — used on the RELEASES.md row (no state.json write)."""
|
|
423
|
+
a = _actor_stamp(state)
|
|
424
|
+
email = f" <{a['email']}>" if a.get("email") else ""
|
|
425
|
+
return f"{a['name']}{email} ({a['source']})"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _parse_actor_arg(s: str) -> dict:
|
|
429
|
+
"""Parse an `assign --owner`/`--assignee` value into a {name, email, source: "assigned"}
|
|
430
|
+
actor (ownership-assignment). "Name <email>" -> both; a bare "Name" -> email None. TOTAL:
|
|
431
|
+
a malformed value (no closing bracket) never raises — the whole stripped string is the name.
|
|
432
|
+
`source` is "assigned" — a human typed this name (not git-resolved nor an ADD override)."""
|
|
433
|
+
m = re.match(r"^\s*(.*?)\s*<([^>]*)>\s*$", s)
|
|
434
|
+
if m:
|
|
435
|
+
return {"name": m.group(1), "email": m.group(2) or None, "source": "assigned"}
|
|
436
|
+
return {"name": s.strip(), "email": None, "source": "assigned"}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _actor_matches(rec_actor: dict | None, me: dict) -> bool:
|
|
440
|
+
"""Does a recorded owner/assignee actor identify the SAME person as `me` (multi-active-UX)?
|
|
441
|
+
Email-first (the stabler key): when BOTH carry a non-empty email, emails decide; otherwise
|
|
442
|
+
fall back to name-equality. Both comparisons are stripped + case-insensitive. TOTAL — a None,
|
|
443
|
+
non-dict, or blank-name record returns False (an unowned/garbage slot is no one's)."""
|
|
444
|
+
if not isinstance(rec_actor, dict):
|
|
445
|
+
return False
|
|
446
|
+
rec_name = (rec_actor.get("name") or "").strip()
|
|
447
|
+
if not rec_name:
|
|
448
|
+
return False
|
|
449
|
+
rec_email = (rec_actor.get("email") or "").strip()
|
|
450
|
+
me_email = (me.get("email") or "").strip()
|
|
451
|
+
if rec_email and me_email:
|
|
452
|
+
return rec_email.lower() == me_email.lower()
|
|
453
|
+
return rec_name.lower() == (me.get("name") or "").strip().lower()
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _my_work(state: dict, me: dict) -> list[dict]:
|
|
457
|
+
"""The "my work" lens (multi-active-UX): across ALL active milestones, the NOT-done tasks
|
|
458
|
+
whose owner OR assignee is `me`. Returns ordered rows {slug, milestone, phase, role} with
|
|
459
|
+
role in {owner, assignee, both}, sorted by active-milestone order then slug. PURE · no I/O."""
|
|
460
|
+
active = list(state.get("active_milestones") or [])
|
|
461
|
+
active_set = set(active)
|
|
462
|
+
tasks = state.get("tasks") if isinstance(state.get("tasks"), dict) else {}
|
|
463
|
+
rows: list[dict] = []
|
|
464
|
+
for slug, t in tasks.items():
|
|
465
|
+
if not isinstance(t, dict) or t.get("milestone") not in active_set or _task_done(t):
|
|
466
|
+
continue
|
|
467
|
+
owns = _actor_matches(t.get("owner"), me)
|
|
468
|
+
assigned = _actor_matches(t.get("assignee"), me)
|
|
469
|
+
if not (owns or assigned):
|
|
470
|
+
continue
|
|
471
|
+
role = "both" if owns and assigned else ("owner" if owns else "assignee")
|
|
472
|
+
rows.append({"slug": slug, "milestone": t.get("milestone"),
|
|
473
|
+
"phase": t.get("phase"), "role": role})
|
|
474
|
+
order = {m: i for i, m in enumerate(active)}
|
|
475
|
+
rows.sort(key=lambda r: (order.get(r["milestone"], len(order)), r["slug"]))
|
|
476
|
+
return rows
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# A git conflict marker BEGINS a line with 7 of `<`, `=`, or `>` (`(?m)^…`). An unresolved
|
|
480
|
+
# merge writes these into state.json, making it invalid JSON; the line-anchor keeps a
|
|
481
|
+
# legitimate value (always on an INDENTED JSON line) from false-tripping the guard.
|
|
482
|
+
_CONFLICT_MARKER_RE = re.compile(r"(?m)^(<{7}|={7}|>{7})")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _state_text_or_die(root: Path) -> str:
|
|
486
|
+
"""Read state.json's raw text, failing CLOSED with a merge-SPECIFIC `state_conflicted`
|
|
487
|
+
message when it carries git conflict markers (an unresolved merge — the major's #1 failure
|
|
488
|
+
mode). A genuine read OSError is NOT swallowed: it propagates to the caller, which maps it
|
|
489
|
+
to its own existing code (state_invalid / no_state). The guard only READS — never writes."""
|
|
490
|
+
text = (root / STATE_FILE).read_text(encoding="utf-8")
|
|
491
|
+
if _CONFLICT_MARKER_RE.search(text):
|
|
492
|
+
_die(f"state_conflicted: {root / STATE_FILE} has unresolved git merge markers "
|
|
493
|
+
f"(<<<<<<< / ======= / >>>>>>>) — resolve them (or "
|
|
494
|
+
f"`git checkout --ours/--theirs {STATE_FILE}`), then run `add.py doctor` to verify")
|
|
495
|
+
return text
|
|
496
|
+
|
|
497
|
+
|
|
241
498
|
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
|
|
499
|
+
"""Load + parse state.json, failing CLOSED. A git-conflicted file dies with a merge-specific
|
|
500
|
+
'state_conflicted'; any other corrupt/unreadable file dies with a clean 'state_invalid'
|
|
501
|
+
message (never a raw traceback), so every command that loads state degrades gracefully
|
|
502
|
+
(design-for-failure). The parsed state is forward-migrated to the multi-active schema."""
|
|
245
503
|
try:
|
|
246
|
-
return json.loads((root
|
|
504
|
+
return _migrate_state(json.loads(_state_text_or_die(root)))
|
|
247
505
|
except (json.JSONDecodeError, OSError) as e:
|
|
248
506
|
_die(f"state_invalid: {root / STATE_FILE} is corrupt or unreadable "
|
|
249
507
|
f"({e.__class__.__name__}) — restore it from git or a backup")
|
|
@@ -252,12 +510,13 @@ def load_state(root: Path) -> dict:
|
|
|
252
510
|
def _load_state_for_json() -> tuple[Path, dict]:
|
|
253
511
|
"""Fail-closed state load for `--json` paths: a missing project or unparseable
|
|
254
512
|
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.
|
|
513
|
+
JSON object a harness might parse). Built from State only — reads no docs/ chapter.
|
|
514
|
+
The parsed state is forward-migrated to the multi-active schema before it is returned."""
|
|
256
515
|
root = find_root()
|
|
257
516
|
if root is None:
|
|
258
517
|
_die("no_state")
|
|
259
518
|
try:
|
|
260
|
-
return root, json.loads((root
|
|
519
|
+
return root, _migrate_state(json.loads(_state_text_or_die(root)))
|
|
261
520
|
except (json.JSONDecodeError, OSError):
|
|
262
521
|
_die("no_state")
|
|
263
522
|
|
|
@@ -453,6 +712,8 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
453
712
|
"stage": args.stage,
|
|
454
713
|
"active_task": None,
|
|
455
714
|
"active_milestone": None,
|
|
715
|
+
"active_milestones": [],
|
|
716
|
+
"active_tasks": {},
|
|
456
717
|
"tasks": {},
|
|
457
718
|
"milestones": {},
|
|
458
719
|
"created": _now(),
|
|
@@ -499,7 +760,7 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
499
760
|
_die(f"task '{slug}' already exists (use --force to overwrite TASK.md)")
|
|
500
761
|
|
|
501
762
|
# link to a milestone (explicit, or the active one) — validate before any write
|
|
502
|
-
milestone = getattr(args, "milestone", None) or state
|
|
763
|
+
milestone = getattr(args, "milestone", None) or _active_milestone(state)
|
|
503
764
|
if milestone and milestone not in state.get("milestones", {}):
|
|
504
765
|
_die("unknown_milestone")
|
|
505
766
|
depends_on = _parse_deps(getattr(args, "depends_on", None))
|
|
@@ -509,16 +770,25 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
509
770
|
# seeded flip NOW (before any write); the slug-free check above has already passed, so
|
|
510
771
|
# the only writes below are the new TASK.md, then the prior flip, then state.
|
|
511
772
|
from_delta = getattr(args, "from_delta", None)
|
|
773
|
+
match = getattr(args, "match", None)
|
|
774
|
+
if match and not from_delta: # --match targets the PRIOR's delta
|
|
775
|
+
_die("match_requires_from_delta: --match needs --from-delta <prior> (it selects the "
|
|
776
|
+
"prior task's open SPEC delta to seed)")
|
|
512
777
|
feature_override = prior_md = flipped_prior = None
|
|
513
778
|
if from_delta:
|
|
514
779
|
prior = _resolve_task(state, from_delta) # unknown prior -> _die
|
|
515
780
|
prior_md = root / "tasks" / prior / "TASK.md"
|
|
516
781
|
prior_text = prior_md.read_text(encoding="utf-8")
|
|
517
|
-
delta_text =
|
|
518
|
-
if
|
|
782
|
+
status, idx, delta_text = _select_spec_delta(prior_text, match)
|
|
783
|
+
if status == "no_open":
|
|
519
784
|
_die(f"no_open_spec_delta: task '{prior}' has no open SPEC delta to seed")
|
|
785
|
+
if status == "no_match":
|
|
786
|
+
_die(f"no_matching_spec_delta: no open SPEC delta in '{prior}' matches --match '{match}'")
|
|
787
|
+
if status == "ambiguous":
|
|
788
|
+
_die(f"ambiguous_spec_match: --match '{match}' matches multiple open SPEC deltas in "
|
|
789
|
+
f"'{prior}' — narrow it")
|
|
520
790
|
feature_override = f"{delta_text} (from {prior} spec-delta)"
|
|
521
|
-
flipped_prior = _resolve_spec_delta(prior_text, "seeded", pointer=slug)
|
|
791
|
+
flipped_prior = _resolve_spec_delta(prior_text, "seeded", pointer=slug, line_index=idx)
|
|
522
792
|
|
|
523
793
|
(tdir / "tests").mkdir(parents=True, exist_ok=True)
|
|
524
794
|
(tdir / "src").mkdir(parents=True, exist_ok=True)
|
|
@@ -532,9 +802,10 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
532
802
|
if feature_override: # pre-fill §1 from the seeded delta
|
|
533
803
|
rendered = re.sub(r"(?m)^Feature:.*$",
|
|
534
804
|
lambda _m: f"Feature: {feature_override}", rendered, count=1)
|
|
535
|
-
|
|
805
|
+
seed_writes: list[tuple[Path, str]] = [(task_md, rendered)]
|
|
536
806
|
if flipped_prior is not None: # consume the source delta -> seeded
|
|
537
|
-
|
|
807
|
+
seed_writes.append((prior_md, flipped_prior))
|
|
808
|
+
_atomic_write_many(seed_writes) # new TASK.md + consumed source as one commit
|
|
538
809
|
if _project_autonomy_token(root) == "?":
|
|
539
810
|
print("warning: garbled_project_autonomy — PROJECT.md declares an unrecognized "
|
|
540
811
|
f"autonomy token; new task seeded fail-safe '{autonomy}' "
|
|
@@ -551,7 +822,7 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
551
822
|
}
|
|
552
823
|
if from_delta:
|
|
553
824
|
state["tasks"][slug]["from_delta"] = from_delta # lineage: seeded from <prior>
|
|
554
|
-
state
|
|
825
|
+
_set_active_task(state, slug, milestone)
|
|
555
826
|
save_state(root, state)
|
|
556
827
|
print(f"created task '{slug}' -> {task_md}")
|
|
557
828
|
if milestone:
|
|
@@ -579,11 +850,19 @@ def cmd_drop_delta(args: argparse.Namespace) -> None:
|
|
|
579
850
|
state = load_state(root)
|
|
580
851
|
slug = _resolve_task(state, args.slug) # unknown task -> _die
|
|
581
852
|
task_md = root / "tasks" / slug / "TASK.md"
|
|
582
|
-
|
|
583
|
-
|
|
853
|
+
text = task_md.read_text(encoding="utf-8")
|
|
854
|
+
match = getattr(args, "match", None)
|
|
855
|
+
status, idx, _disp = _select_spec_delta(text, match)
|
|
856
|
+
if status == "no_open":
|
|
584
857
|
_die(f"no_open_spec_delta: task '{slug}' has no open SPEC delta to drop")
|
|
858
|
+
if status == "no_match":
|
|
859
|
+
_die(f"no_matching_spec_delta: no open SPEC delta in '{slug}' matches --match '{match}'")
|
|
860
|
+
if status == "ambiguous":
|
|
861
|
+
_die(f"ambiguous_spec_match: --match '{match}' matches multiple open SPEC deltas in "
|
|
862
|
+
f"'{slug}' — narrow it")
|
|
863
|
+
new_text = _resolve_spec_delta(text, "dropped", line_index=idx)
|
|
585
864
|
_atomic_write(task_md, new_text)
|
|
586
|
-
print(f"dropped the first open SPEC delta in '{slug}' -> [SPEC · dropped]")
|
|
865
|
+
print(f"dropped the {'matched' if match else 'first'} open SPEC delta in '{slug}' -> [SPEC · dropped]")
|
|
587
866
|
print(_next_footer(root, state))
|
|
588
867
|
|
|
589
868
|
|
|
@@ -618,7 +897,7 @@ def _archived_task_slugs(state: dict) -> set[str]:
|
|
|
618
897
|
|
|
619
898
|
|
|
620
899
|
def _resolve_task(state: dict, slug: str | None) -> str:
|
|
621
|
-
slug = slug or state
|
|
900
|
+
slug = slug or _active_task(state)
|
|
622
901
|
if not slug:
|
|
623
902
|
_die("no task specified and no active task set")
|
|
624
903
|
if slug not in state["tasks"]:
|
|
@@ -699,6 +978,15 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
699
978
|
_sync_task_marker(root, slug, nxt)
|
|
700
979
|
save_state(root, state)
|
|
701
980
|
print(f"task '{slug}' phase {cur} -> {nxt}")
|
|
981
|
+
if nxt == "observe":
|
|
982
|
+
# OBSERVE is where this loop's lessons get captured (TASK.md §7) — suggest routing
|
|
983
|
+
# them into PROJECT.md right away (a per-task fold is engine-legal; otherwise the
|
|
984
|
+
# lessons sit unconsolidated until milestone close). Additive: fires only at this
|
|
985
|
+
# one transition, and the human still decides whether/when to run it. The verb word
|
|
986
|
+
# is interpolated via _FOLD_VERB so the domain wording-lint sees no bare slang.
|
|
987
|
+
print(" note: record the lessons this loop taught the foundation in §7 "
|
|
988
|
+
"OBSERVE, then update PROJECT.md when ready:")
|
|
989
|
+
print(f" add.py {_FOLD_VERB} --task {slug} (review first: add.py deltas)")
|
|
702
990
|
print(_next_footer(root, state))
|
|
703
991
|
|
|
704
992
|
|
|
@@ -835,6 +1123,7 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
835
1123
|
state["tasks"][slug]["phase"] = "done"
|
|
836
1124
|
_sync_task_marker(root, slug, "done")
|
|
837
1125
|
state["tasks"][slug]["gate"] = args.outcome
|
|
1126
|
+
state["tasks"][slug]["gate_actor"] = _actor_stamp(state) # WHO recorded the verdict (every outcome)
|
|
838
1127
|
state["tasks"][slug]["updated"] = _now()
|
|
839
1128
|
save_state(root, state)
|
|
840
1129
|
print(f"task '{slug}' gate -> {args.outcome}")
|
|
@@ -1002,7 +1291,8 @@ def cmd_lock(args: argparse.Namespace) -> None:
|
|
|
1002
1291
|
who = args.by or getpass.getuser()
|
|
1003
1292
|
when = _now()
|
|
1004
1293
|
# ONE atomic write — no partial lock state.
|
|
1005
|
-
state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers
|
|
1294
|
+
state["setup"] = {"locked": True, "locked_at": when, "locked_by": who, "layers": layers,
|
|
1295
|
+
"actor": _actor_stamp(state)} # structured actor alongside the free-text locked_by
|
|
1006
1296
|
save_state(root, state)
|
|
1007
1297
|
if getattr(args, "json", False):
|
|
1008
1298
|
print(json.dumps(
|
|
@@ -1013,6 +1303,92 @@ def cmd_lock(args: argparse.Namespace) -> None:
|
|
|
1013
1303
|
print(_next_footer(root, state))
|
|
1014
1304
|
|
|
1015
1305
|
|
|
1306
|
+
def cmd_whoami(args: argparse.Namespace) -> None:
|
|
1307
|
+
"""Show / set / unset the git-native ACTOR — the identity every human-owned stamp reads.
|
|
1308
|
+
No flags: print the resolved actor (override -> git -> os). --name/--email: store an
|
|
1309
|
+
override. --unset: clear it. Validates before mutating (a reject leaves state unchanged)."""
|
|
1310
|
+
root = _require_root()
|
|
1311
|
+
state = load_state(root)
|
|
1312
|
+
if args.unset:
|
|
1313
|
+
if "actor_override" not in state:
|
|
1314
|
+
_die("no_actor_override")
|
|
1315
|
+
del state["actor_override"]
|
|
1316
|
+
save_state(root, state)
|
|
1317
|
+
elif args.name is not None:
|
|
1318
|
+
if not args.name.strip():
|
|
1319
|
+
_die("actor_name_blank")
|
|
1320
|
+
state["actor_override"] = {"name": args.name, "email": args.email or None}
|
|
1321
|
+
save_state(root, state)
|
|
1322
|
+
who = _whoami(state)
|
|
1323
|
+
if getattr(args, "json", False):
|
|
1324
|
+
print(json.dumps(who, separators=(",", ":")))
|
|
1325
|
+
return
|
|
1326
|
+
email = f" <{who['email']}>" if who.get("email") else ""
|
|
1327
|
+
print(f"actor : {who['name']}{email} (source: {who['source']})")
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def _ownership_record(state: dict, slug: str) -> dict | None:
|
|
1331
|
+
"""Resolve the record a slug names for ownership — a TASK first, else a MILESTONE
|
|
1332
|
+
(tasks win, matching cmd_use/report precedent). None if neither exists."""
|
|
1333
|
+
rec = state.get("tasks", {}).get(slug)
|
|
1334
|
+
if rec is not None:
|
|
1335
|
+
return rec
|
|
1336
|
+
return state.get("milestones", {}).get(slug)
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def cmd_assign(args: argparse.Namespace) -> None:
|
|
1340
|
+
"""Assign an OWNER (accountable) and/or ASSIGNEE (working it) to a task or milestone
|
|
1341
|
+
(ownership-assignment). No role flag -> set BOTH to the resolved self (_whoami); --owner/
|
|
1342
|
+
--assignee "Name <email>" -> set that role only (partial update). Descriptive, never a gate.
|
|
1343
|
+
Validate-before-mutate: a reject leaves state.json byte-identical (no partial write)."""
|
|
1344
|
+
root = _require_root()
|
|
1345
|
+
state = load_state(root)
|
|
1346
|
+
rec = _ownership_record(state, args.slug)
|
|
1347
|
+
if rec is None:
|
|
1348
|
+
_die("unknown_slug")
|
|
1349
|
+
# parse + validate ALL flags BEFORE the first write — a blank name is rejected on the
|
|
1350
|
+
# PARSED name (so "<>" or " <a@x.io>", whose name parses empty, is caught like " ").
|
|
1351
|
+
parsed_owner = _parse_actor_arg(args.owner) if args.owner is not None else None
|
|
1352
|
+
parsed_assignee = _parse_actor_arg(args.assignee) if args.assignee is not None else None
|
|
1353
|
+
if parsed_owner is not None and not parsed_owner["name"].strip():
|
|
1354
|
+
_die("owner_name_blank")
|
|
1355
|
+
if parsed_assignee is not None and not parsed_assignee["name"].strip():
|
|
1356
|
+
_die("assignee_name_blank")
|
|
1357
|
+
if parsed_owner is None and parsed_assignee is None:
|
|
1358
|
+
who = _whoami(state)
|
|
1359
|
+
rec["owner"] = dict(who)
|
|
1360
|
+
rec["assignee"] = dict(who)
|
|
1361
|
+
else:
|
|
1362
|
+
if parsed_owner is not None:
|
|
1363
|
+
rec["owner"] = parsed_owner
|
|
1364
|
+
if parsed_assignee is not None:
|
|
1365
|
+
rec["assignee"] = parsed_assignee
|
|
1366
|
+
save_state(root, state)
|
|
1367
|
+
parts = [f"{role}: {rec[role]['name']}" for role in ("owner", "assignee") if role in rec]
|
|
1368
|
+
print(f"assigned {args.slug} -> " + " · ".join(parts))
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
def cmd_unassign(args: argparse.Namespace) -> None:
|
|
1372
|
+
"""Clear the OWNER and/or ASSIGNEE of a task or milestone (ownership-assignment). No role
|
|
1373
|
+
flag -> clear BOTH; --owner/--assignee -> clear that role only. Reject not_assigned if the
|
|
1374
|
+
targeted role(s) are already absent. Validate-before-mutate (a reject changes nothing)."""
|
|
1375
|
+
root = _require_root()
|
|
1376
|
+
state = load_state(root)
|
|
1377
|
+
rec = _ownership_record(state, args.slug)
|
|
1378
|
+
if rec is None:
|
|
1379
|
+
_die("unknown_slug")
|
|
1380
|
+
if args.owner or args.assignee:
|
|
1381
|
+
roles = [r for r in ("owner", "assignee") if getattr(args, r)]
|
|
1382
|
+
else:
|
|
1383
|
+
roles = ["owner", "assignee"]
|
|
1384
|
+
if not all(r in rec for r in roles): # every targeted role must exist (frozen: "both must exist")
|
|
1385
|
+
_die("not_assigned")
|
|
1386
|
+
for r in roles:
|
|
1387
|
+
rec.pop(r, None)
|
|
1388
|
+
save_state(root, state)
|
|
1389
|
+
print(f"unassigned {args.slug} ({', '.join(roles)})")
|
|
1390
|
+
|
|
1391
|
+
|
|
1016
1392
|
def _has_production_roadmap(state: dict) -> bool:
|
|
1017
1393
|
"""True iff ≥1 milestone in state has stage == "production" (STATUS-AGNOSTIC).
|
|
1018
1394
|
The single source of the stage-graduation floor (v22 graduate-guide): the guard counts
|
|
@@ -1057,20 +1433,26 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1057
1433
|
members = [t for t in tasks.values() if t.get("milestone") == mslug]
|
|
1058
1434
|
ms_list.append({"slug": mslug, "status": m.get("status", "active"),
|
|
1059
1435
|
"done": sum(1 for t in members if _task_done(t)),
|
|
1060
|
-
"total": len(members)
|
|
1436
|
+
"total": len(members),
|
|
1437
|
+
"owner": m.get("owner"), "assignee": m.get("assignee")})
|
|
1061
1438
|
grad_ready, grad_met, grad_total = _graduation_ready(root, state)
|
|
1062
1439
|
print(json.dumps({
|
|
1063
1440
|
"project": state.get("project"), "stage": state.get("stage"),
|
|
1064
|
-
"
|
|
1441
|
+
"actor": _whoami(state),
|
|
1442
|
+
"active_task": _active_task(state),
|
|
1443
|
+
"active_milestones": list(state.get("active_milestones") or []),
|
|
1444
|
+
"active_tasks": dict(state.get("active_tasks") or {}),
|
|
1065
1445
|
"milestones": ms_list,
|
|
1066
1446
|
"tasks": [{"slug": s, "phase": t.get("phase"), "gate": t.get("gate"),
|
|
1067
|
-
"milestone": t.get("milestone")
|
|
1447
|
+
"milestone": t.get("milestone"),
|
|
1448
|
+
"owner": t.get("owner"), "assignee": t.get("assignee")}
|
|
1449
|
+
for s, t in tasks.items()],
|
|
1068
1450
|
"graduation_ready": grad_ready,
|
|
1069
1451
|
"stage_criteria": {"met": grad_met, "total": grad_total}}))
|
|
1070
1452
|
return
|
|
1071
1453
|
root = _require_root()
|
|
1072
1454
|
state = load_state(root)
|
|
1073
|
-
active = state
|
|
1455
|
+
active = _active_task(state)
|
|
1074
1456
|
tasks = state.get("tasks", {})
|
|
1075
1457
|
# Compute once: True when setup is present AND locked is False (the lock-gate window).
|
|
1076
1458
|
# Reuses the canonical helper — do NOT write a parallel predicate.
|
|
@@ -1079,12 +1461,17 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1079
1461
|
# project autonomy default (task init-auto-default): the posture new tasks INHERIT,
|
|
1080
1462
|
# read LIVE from PROJECT.md so the human sees the project-wide throttle every session.
|
|
1081
1463
|
print(f"project autonomy: {_project_autonomy(root)} (default — new tasks inherit)")
|
|
1464
|
+
# git-native actor (user-identity): who ADD sees you as this session — the identity every
|
|
1465
|
+
# human-owned stamp records. Always present (the resolver is TOTAL). Read-only, no write.
|
|
1466
|
+
_who = _whoami(state)
|
|
1467
|
+
_who_email = f" <{_who['email']}>" if _who.get("email") else ""
|
|
1468
|
+
print(f"actor : {_who['name']}{_who_email} (source: {_who['source']})")
|
|
1082
1469
|
print(f"stage : {state.get('stage', '(unknown)')}")
|
|
1083
1470
|
# project GOAL + active-milestone goal (v20) — the loop's orientation anchor, read
|
|
1084
1471
|
# LIVE from PROJECT.md / MILESTONE.md (never state.json). Additive: every existing
|
|
1085
1472
|
# line stays put. A missing source degrades to a sentinel — one never blanks the other.
|
|
1086
1473
|
print(f"goal : {_project_goal(root)}")
|
|
1087
|
-
_active_ms = state
|
|
1474
|
+
_active_ms = _active_milestone(state)
|
|
1088
1475
|
if _active_ms:
|
|
1089
1476
|
print(f"m-goal : {_milestone_doc(root, _active_ms)[1]} (← {_active_ms})")
|
|
1090
1477
|
# goal-ready (task goal-auto-ready-gate): is the active milestone's goal AUTO-READY
|
|
@@ -1112,13 +1499,13 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1112
1499
|
|
|
1113
1500
|
# milestone rollup (only when milestones are in use)
|
|
1114
1501
|
milestones = state.get("milestones") or {}
|
|
1115
|
-
active_ms = state
|
|
1502
|
+
active_ms = _active_milestone(state)
|
|
1116
1503
|
if milestones:
|
|
1117
1504
|
print("milestones:")
|
|
1118
1505
|
for mslug, m in milestones.items():
|
|
1119
1506
|
members = [t for t in tasks.values() if t.get("milestone") == mslug]
|
|
1120
1507
|
done = sum(1 for t in members if _task_done(t))
|
|
1121
|
-
mark = "*" if mslug
|
|
1508
|
+
mark = "*" if mslug in (state.get("active_milestones") or []) else " "
|
|
1122
1509
|
print(f" {mark} {mslug:<20} {done}/{len(members)} tasks done"
|
|
1123
1510
|
f" status={m.get('status', 'active')}")
|
|
1124
1511
|
# graduation cue (v22): project-global + read-only. Fires only when every milestone
|
|
@@ -1145,10 +1532,38 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1145
1532
|
print(f" → {RELEASABLE_CUE.format(n=len(_rel))}")
|
|
1146
1533
|
|
|
1147
1534
|
print(f"active : {active or '(none)'}")
|
|
1535
|
+
# parallel streams (parallel-status-view): when >=2 milestones are active, render each as its
|
|
1536
|
+
# own stream (active task + phase) so a user working N fronts reads them all at once. ADDITIVE —
|
|
1537
|
+
# the N<=1 output above is byte-identical (the standing additive-cue convention); presentation
|
|
1538
|
+
# only, no command DECISION changes. Reads the SET/map via the task-2 seam shape.
|
|
1539
|
+
_ams = state.get("active_milestones") or []
|
|
1540
|
+
if len(_ams) >= 2:
|
|
1541
|
+
_primary = _active_milestone(state)
|
|
1542
|
+
_order = ([_primary] if _primary in _ams else []) + [m for m in _ams if m != _primary]
|
|
1543
|
+
_atasks = state.get("active_tasks") or {}
|
|
1544
|
+
print(f"streams : {len(_ams)} active milestones")
|
|
1545
|
+
for _m in _order:
|
|
1546
|
+
_tk = _atasks.get(_m)
|
|
1547
|
+
_ph = (tasks.get(_tk) or {}).get("phase", "-") if _tk else "-"
|
|
1548
|
+
_mk = "▸" if _m == _primary else " "
|
|
1549
|
+
_tag = " (primary)" if _m == _primary else ""
|
|
1550
|
+
# per-stream owner (per-stream-owner): the milestone's lead, present-only — a stream
|
|
1551
|
+
# whose milestone has no owner (or a blank-name owner) renders byte-identically
|
|
1552
|
+
# (additive-cue convention). Guard on the name like `_fmt_ownership`, so a hand-edited
|
|
1553
|
+
# blank-name record never emits an `owner:` fragment.
|
|
1554
|
+
_owner_rec = (milestones.get(_m) or {}).get("owner") or {}
|
|
1555
|
+
_so = _fmt_actor(_owner_rec) if _owner_rec.get("name") else ""
|
|
1556
|
+
_own_frag = f" · owner: {_so}" if _so else ""
|
|
1557
|
+
print(f" {_mk} {_m:<20} task={_tk or '(none)'} phase={_ph}{_tag}{_own_frag}")
|
|
1148
1558
|
# surface the active task's autonomy level (task explicit-autonomy-dial) so the human
|
|
1149
1559
|
# reads the throttle every session; "unset" when no explicit `autonomy:` line is present.
|
|
1150
1560
|
if active and active in tasks:
|
|
1151
1561
|
print(f"autonomy: {_autonomy_level(_task_header(root, active)) or 'unset'}")
|
|
1562
|
+
# owner/assignee of the active task (ownership-assignment) — present-only, never a
|
|
1563
|
+
# placeholder; an unassigned active task adds no line (additive-cue convention).
|
|
1564
|
+
_own = _fmt_ownership(tasks[active])
|
|
1565
|
+
if _own:
|
|
1566
|
+
print(f"owned : {_own}")
|
|
1152
1567
|
# grounded (task ground-bundle-wiring): does the active task's §0 GROUND map cite the
|
|
1153
1568
|
# anchors §3 names? measure-not-block, human-readable only (never the JSON surface). A
|
|
1154
1569
|
# pre-ground / legacy task (no §0) -> _task_grounded None -> NO line, so the surface is
|
|
@@ -1237,7 +1652,7 @@ def cmd_guide(args: argparse.Namespace) -> None:
|
|
|
1237
1652
|
"""
|
|
1238
1653
|
if getattr(args, "json", False):
|
|
1239
1654
|
json_root, state = _load_state_for_json()
|
|
1240
|
-
slug = args.slug or state
|
|
1655
|
+
slug = args.slug or _active_task(state)
|
|
1241
1656
|
if not slug:
|
|
1242
1657
|
print(json.dumps({"task": None, "phase": None, "owner": "human", "stop": True,
|
|
1243
1658
|
"next_step": "start your first feature -> add.py new-task <slug>",
|
|
@@ -1257,7 +1672,7 @@ def cmd_guide(args: argparse.Namespace) -> None:
|
|
|
1257
1672
|
return
|
|
1258
1673
|
root = _require_root()
|
|
1259
1674
|
state = load_state(root)
|
|
1260
|
-
slug = args.slug or state
|
|
1675
|
+
slug = args.slug or _active_task(state)
|
|
1261
1676
|
if not slug:
|
|
1262
1677
|
print("active : (none)")
|
|
1263
1678
|
print('next : start your first feature -> add.py new-task <slug> --title "..."')
|
|
@@ -1732,7 +2147,7 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
1732
2147
|
if root is None:
|
|
1733
2148
|
_die("no_project")
|
|
1734
2149
|
try:
|
|
1735
|
-
state = json.loads((root
|
|
2150
|
+
state = json.loads(_state_text_or_die(root))
|
|
1736
2151
|
except (json.JSONDecodeError, OSError):
|
|
1737
2152
|
_die("state_invalid")
|
|
1738
2153
|
|
|
@@ -1832,7 +2247,7 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
1832
2247
|
# zero-criteria milestone is shaping's nudge, not this one's. LIVE-ONLY: the OPEN active
|
|
1833
2248
|
# milestone only — a done-but-not-yet-archived one (still the active pointer until
|
|
1834
2249
|
# archive clears it) and closed/archived predecessors are never retro-flagged (Must #4).
|
|
1835
|
-
_active_ms = state
|
|
2250
|
+
_active_ms = _active_milestone(state)
|
|
1836
2251
|
if _active_ms in milestones and milestones[_active_ms].get("status") != "done":
|
|
1837
2252
|
_cited, _total = _exit_criteria_cited(root, _active_ms)
|
|
1838
2253
|
if _total >= 1 and _cited < _total:
|
|
@@ -1847,7 +2262,7 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
1847
2262
|
# FROZEN AND its §0 GROUND map is ungrounded (the precise "froze without grounding" gap, so
|
|
1848
2263
|
# no nag during pre-freeze drafting). A pre-ground / legacy task (no §0 -> _grounded_state
|
|
1849
2264
|
# None) is EXEMPT, never retro-flagged. Rides the existing `warnings` array — no new key.
|
|
1850
|
-
_at = state
|
|
2265
|
+
_at = _active_task(state)
|
|
1851
2266
|
if _at in tasks:
|
|
1852
2267
|
_raw = _raw_phase_bodies(root, _at)
|
|
1853
2268
|
if _contract_frozen(_raw.get(3, "")) and _grounded_state(_raw) is False:
|
|
@@ -1921,6 +2336,93 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
1921
2336
|
raise SystemExit(1)
|
|
1922
2337
|
|
|
1923
2338
|
|
|
2339
|
+
def _doctor_findings(root: Path) -> list[str]:
|
|
2340
|
+
"""Read-only diagnosis of state.json: each item is "<problem> — fix: <fix>".
|
|
2341
|
+
|
|
2342
|
+
Reads the RAW text with its OWN try/except — NEVER through the dying load_state — so a
|
|
2343
|
+
conflicted/corrupt state is REPORTED, not aborted on (the proactive counterpart to the
|
|
2344
|
+
merge-guard load guard, which fails fast at the first problem). Reports the FIRST blocking
|
|
2345
|
+
class then stops (can't parse deeper): missing/unreadable file -> conflict markers -> bad
|
|
2346
|
+
JSON. On a PARSEABLE state (normalized through `_migrate_state` so the canonical multi-active
|
|
2347
|
+
shape is judged) it appends EVERY referential violation. PURE: reads only, returns the list."""
|
|
2348
|
+
try:
|
|
2349
|
+
text = (root / STATE_FILE).read_text(encoding="utf-8")
|
|
2350
|
+
except OSError:
|
|
2351
|
+
return ["state.json missing/unreadable — fix: restore it from git/backup"]
|
|
2352
|
+
if _CONFLICT_MARKER_RE.search(text):
|
|
2353
|
+
return ["state.json has unresolved git merge markers — fix: resolve "
|
|
2354
|
+
"<<<<<<< / ======= / >>>>>>> (or git checkout --ours/--theirs), then re-run doctor"]
|
|
2355
|
+
try:
|
|
2356
|
+
parsed = json.loads(text)
|
|
2357
|
+
except json.JSONDecodeError:
|
|
2358
|
+
return ["state.json is not valid JSON — fix: restore it from git/backup"]
|
|
2359
|
+
|
|
2360
|
+
state = _migrate_state(parsed)
|
|
2361
|
+
findings: list[str] = []
|
|
2362
|
+
milestones = state.get("milestones") if isinstance(state.get("milestones"), dict) else {}
|
|
2363
|
+
tasks = state.get("tasks") if isinstance(state.get("tasks"), dict) else {}
|
|
2364
|
+
|
|
2365
|
+
active_ms = state.get("active_milestones") if isinstance(state.get("active_milestones"), list) else []
|
|
2366
|
+
active_tasks = state.get("active_tasks") if isinstance(state.get("active_tasks"), dict) else {}
|
|
2367
|
+
for am in active_ms:
|
|
2368
|
+
if am not in milestones:
|
|
2369
|
+
findings.append(f"active milestone '{am}' has no record — fix: deactivate it or "
|
|
2370
|
+
"recreate the milestone")
|
|
2371
|
+
for ms, t in active_tasks.items():
|
|
2372
|
+
if not t:
|
|
2373
|
+
continue
|
|
2374
|
+
if t not in tasks:
|
|
2375
|
+
findings.append(f"active task '{t}' (milestone '{ms}') has no record — fix: use a "
|
|
2376
|
+
"real task or clear the active pointer")
|
|
2377
|
+
elif (tasks[t].get("milestone") if isinstance(tasks[t], dict) else None) != ms:
|
|
2378
|
+
findings.append(f"active task '{t}' is mislabeled under '{ms}' — fix: re-use it "
|
|
2379
|
+
"under its own milestone")
|
|
2380
|
+
for slug, t in tasks.items():
|
|
2381
|
+
m = t.get("milestone") if isinstance(t, dict) else None
|
|
2382
|
+
if m is not None and m not in milestones:
|
|
2383
|
+
findings.append(f"task '{slug}' references missing milestone '{m}' — fix: set its "
|
|
2384
|
+
"milestone to a real one (or none)")
|
|
2385
|
+
return findings
|
|
2386
|
+
|
|
2387
|
+
|
|
2388
|
+
def cmd_doctor(args: argparse.Namespace) -> None:
|
|
2389
|
+
"""Read-only `add.py doctor`: PASS + exit 0 on a healthy state, else report each problem +
|
|
2390
|
+
fix to stdout and exit non-zero. NEVER mutates state (detect, never auto-resolve)."""
|
|
2391
|
+
root = find_root()
|
|
2392
|
+
if root is None:
|
|
2393
|
+
_die("no_project")
|
|
2394
|
+
findings = _doctor_findings(root)
|
|
2395
|
+
if not findings:
|
|
2396
|
+
print("doctor: PASS — state.json is healthy (parseable · conflict-free · references intact)")
|
|
2397
|
+
return
|
|
2398
|
+
print(f"doctor: {len(findings)} problem(s):")
|
|
2399
|
+
for f in findings:
|
|
2400
|
+
print(f" ✗ {f}")
|
|
2401
|
+
raise SystemExit(1)
|
|
2402
|
+
|
|
2403
|
+
|
|
2404
|
+
def cmd_mine(args: argparse.Namespace) -> None:
|
|
2405
|
+
"""Read-only `add.py mine`: across all active milestones, the not-done tasks owned-by or
|
|
2406
|
+
assigned-to the resolved actor (`_whoami`, or `--actor "Name <email>"`). Text or `--json`.
|
|
2407
|
+
An empty queue is a plain exit-0 line, not an error. NEVER writes state."""
|
|
2408
|
+
root = find_root()
|
|
2409
|
+
if root is None:
|
|
2410
|
+
_die("no_project")
|
|
2411
|
+
state = load_state(root)
|
|
2412
|
+
me = _parse_actor_arg(args.actor) if getattr(args, "actor", None) else _whoami(state)
|
|
2413
|
+
rows = _my_work(state, me)
|
|
2414
|
+
if getattr(args, "json", False):
|
|
2415
|
+
print(json.dumps({"actor": me, "tasks": rows}))
|
|
2416
|
+
return
|
|
2417
|
+
who = _fmt_actor(me) or me.get("name", "you")
|
|
2418
|
+
if not rows:
|
|
2419
|
+
print(f"mine: no open tasks for {who} across active milestones")
|
|
2420
|
+
return
|
|
2421
|
+
print(f"mine: {who} — {len(rows)} open task(s) across active milestones:")
|
|
2422
|
+
for r in rows:
|
|
2423
|
+
print(f" {r['slug']:<24} [{r['milestone']}] phase={r['phase']} ({r['role']})")
|
|
2424
|
+
|
|
2425
|
+
|
|
1924
2426
|
# ---------------------------------------------------------------------------
|
|
1925
2427
|
# wave-ledger fork-base enforcement (engine-merge-base-enforcement)
|
|
1926
2428
|
#
|
|
@@ -2079,7 +2581,7 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
|
|
|
2079
2581
|
"title": title, "goal": args.goal or "", "stage": args.stage,
|
|
2080
2582
|
"status": "active", "created": _now(), "updated": _now(),
|
|
2081
2583
|
}
|
|
2082
|
-
state
|
|
2584
|
+
_set_active_milestone(state, slug)
|
|
2083
2585
|
save_state(root, state)
|
|
2084
2586
|
print(f"created milestone '{slug}' -> {mfile}")
|
|
2085
2587
|
print("active milestone set.")
|
|
@@ -2128,7 +2630,11 @@ def cmd_ready(args: argparse.Namespace) -> None:
|
|
|
2128
2630
|
for slug in ready:
|
|
2129
2631
|
deps = tasks[slug].get("depends_on") or []
|
|
2130
2632
|
suffix = f" (after {', '.join(deps)})" if deps else ""
|
|
2131
|
-
|
|
2633
|
+
# cross-active legibility (cross-active-waves): name the stream each ready task belongs
|
|
2634
|
+
# to, present-only — a milestone-less task gets no bracket (byte-identical).
|
|
2635
|
+
_ms = tasks[slug].get("milestone")
|
|
2636
|
+
ms_frag = f" [{_ms}]" if _ms else ""
|
|
2637
|
+
print(f" {slug}{ms_frag}{suffix}")
|
|
2132
2638
|
|
|
2133
2639
|
|
|
2134
2640
|
def _wave_schedule(state: dict, mslug: str) -> dict:
|
|
@@ -2222,36 +2728,17 @@ def _wave_schedule(state: dict, mslug: str) -> dict:
|
|
|
2222
2728
|
"tiers": tiers, "blocked": blocked_sorted}
|
|
2223
2729
|
|
|
2224
2730
|
|
|
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}")
|
|
2731
|
+
def _wave_block_lines(state: dict, mslug: str, sched: dict) -> list[str]:
|
|
2732
|
+
"""The exact text lines `waves` renders for ONE milestone's schedule (cross-active-waves
|
|
2733
|
+
extracts this so a single target stays byte-identical and N targets each get a block)."""
|
|
2734
|
+
lines = [f"milestone: {mslug}"]
|
|
2248
2735
|
if not sched["waves"]:
|
|
2249
2736
|
if sched["blocked"]:
|
|
2250
2737
|
for s in sched["blocked"]:
|
|
2251
|
-
|
|
2738
|
+
lines.append(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
|
|
2252
2739
|
else:
|
|
2253
|
-
|
|
2254
|
-
return
|
|
2740
|
+
lines.append("all tasks done — nothing to schedule")
|
|
2741
|
+
return lines
|
|
2255
2742
|
scheduled_set = {x for w in sched["waves"] for x in w}
|
|
2256
2743
|
for i, wave in enumerate(sched["waves"], start=1):
|
|
2257
2744
|
parts = []
|
|
@@ -2259,14 +2746,62 @@ def cmd_waves(args: argparse.Namespace) -> None:
|
|
|
2259
2746
|
md = sorted(d for d in (state["tasks"][s].get("depends_on") or [])
|
|
2260
2747
|
if d in scheduled_set)
|
|
2261
2748
|
parts.append(f"{s} (deps: {', '.join(md)})" if md else s)
|
|
2262
|
-
|
|
2749
|
+
lines.append(f"wave {i}: {', '.join(parts)}")
|
|
2263
2750
|
crit = sched["critical_path"]
|
|
2264
|
-
|
|
2751
|
+
lines.append(f"critical path: {' → '.join(crit)} ({sched['critical_path_len']} tasks)")
|
|
2265
2752
|
tops = [s for s, tier in sched["tiers"].items() if tier == "top"]
|
|
2266
2753
|
mids = [s for s, tier in sched["tiers"].items() if tier == "mid"]
|
|
2267
|
-
|
|
2754
|
+
lines.append(f"tier hint: top → {', '.join(tops)}; mid → {', '.join(mids) or '(none)'}")
|
|
2268
2755
|
for s in sched["blocked"]:
|
|
2269
|
-
|
|
2756
|
+
lines.append(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
|
|
2757
|
+
return lines
|
|
2758
|
+
|
|
2759
|
+
|
|
2760
|
+
def cmd_waves(args: argparse.Namespace) -> None:
|
|
2761
|
+
"""READ-ONLY DAG scheduler: print the topological waves, critical path, advisory tier hint,
|
|
2762
|
+
and blocked set. With no --milestone it spans EVERY active milestone (cross-active-waves);
|
|
2763
|
+
a single target / --milestone renders byte-identically. Writes nothing; no `next:` footer."""
|
|
2764
|
+
is_json = getattr(args, "json", False)
|
|
2765
|
+
if is_json:
|
|
2766
|
+
_, state = _load_state_for_json()
|
|
2767
|
+
else:
|
|
2768
|
+
state = load_state(_require_root())
|
|
2769
|
+
mslug_arg = getattr(args, "milestone", None)
|
|
2770
|
+
if mslug_arg:
|
|
2771
|
+
targets = [mslug_arg] # explicit single target — unchanged
|
|
2772
|
+
else:
|
|
2773
|
+
primary = _active_milestone(state) # the SCALAR is the gate (test relies on it)
|
|
2774
|
+
if not primary:
|
|
2775
|
+
_die("no_active_milestone: no active milestone and no --milestone given")
|
|
2776
|
+
# additively widen to the other active milestones (cross-active); primary first
|
|
2777
|
+
targets = [primary] + [m for m in (state.get("active_milestones") or [])
|
|
2778
|
+
if m != primary]
|
|
2779
|
+
scheds = []
|
|
2780
|
+
for t in targets:
|
|
2781
|
+
if t not in (state.get("milestones") or {}):
|
|
2782
|
+
_die(f"unknown_milestone: '{t}' is not a milestone in this project")
|
|
2783
|
+
sched = _wave_schedule(state, t)
|
|
2784
|
+
if "cycle" in sched:
|
|
2785
|
+
_die(f"dependency_cycle: not-done deps form a cycle "
|
|
2786
|
+
f"({' -> '.join(sched['cycle'])}) — no valid schedule")
|
|
2787
|
+
scheds.append(sched)
|
|
2788
|
+
|
|
2789
|
+
if is_json:
|
|
2790
|
+
if len(targets) == 1:
|
|
2791
|
+
print(json.dumps({"milestone": targets[0], **scheds[0]})) # unchanged shape
|
|
2792
|
+
else:
|
|
2793
|
+
print(json.dumps({"streams": [{"milestone": t, **s}
|
|
2794
|
+
for t, s in zip(targets, scheds)]}))
|
|
2795
|
+
return
|
|
2796
|
+
|
|
2797
|
+
if len(targets) == 1:
|
|
2798
|
+
print("\n".join(_wave_block_lines(state, targets[0], scheds[0]))) # byte-identical
|
|
2799
|
+
return
|
|
2800
|
+
print(f"active streams: {len(targets)}")
|
|
2801
|
+
for i, (t, s) in enumerate(zip(targets, scheds)):
|
|
2802
|
+
if i:
|
|
2803
|
+
print()
|
|
2804
|
+
print("\n".join(_wave_block_lines(state, t, s)))
|
|
2270
2805
|
|
|
2271
2806
|
|
|
2272
2807
|
def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
@@ -2296,6 +2831,10 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
|
2296
2831
|
_die(f"milestone_goal_unmet: milestone '{slug}' has {met}/{total} exit criteria met "
|
|
2297
2832
|
f"— check the remaining boxes in MILESTONE.md (the goal-gate holds the loop "
|
|
2298
2833
|
f"open) or propose the next tasks (add.py deltas)")
|
|
2834
|
+
# Stamp WHO closed it BEFORE rendering the retro, so the persisted exit report records
|
|
2835
|
+
# the closer (identity-in-status: the retro IS the report `report <ms>` re-renders, so both
|
|
2836
|
+
# must reflect the same final state). In-memory only here — save_state below commits it.
|
|
2837
|
+
state["milestones"][slug]["done_actor"] = _actor_stamp(state)
|
|
2299
2838
|
# Fail-closed: render+persist the exit report (RETRO.md) BEFORE committing the
|
|
2300
2839
|
# status flip, so a write failure rolls back naturally (status never commits ->
|
|
2301
2840
|
# no done-without-retro state). The retro step is read-only on state.json.
|
|
@@ -2311,11 +2850,15 @@ def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
|
2311
2850
|
print(f"milestone '{slug}' -> done ({len(members)} tasks complete{tail}).")
|
|
2312
2851
|
print(f"wrote {retro_path.relative_to(root.parent)} (milestone exit report)")
|
|
2313
2852
|
# fold-pressure nudge: milestone close is the natural fold point for open deltas (v11)
|
|
2314
|
-
|
|
2853
|
+
by_comp = _collect_open_deltas(root)
|
|
2854
|
+
open_deltas = sum(len(v) for v in by_comp.values())
|
|
2315
2855
|
if open_deltas:
|
|
2316
2856
|
noun = "delta" if open_deltas == 1 else "deltas"
|
|
2317
|
-
print(f"note: {open_deltas} open {noun} to
|
|
2318
|
-
|
|
2857
|
+
print(f"note: {open_deltas} open {noun} ready to {_FOLD_VERB} into the foundation:")
|
|
2858
|
+
for comp in _COMPETENCY_ORDER:
|
|
2859
|
+
for e in by_comp[comp]:
|
|
2860
|
+
print(f" ({comp}) {e['text']} [{e['task']}]")
|
|
2861
|
+
print(f" run: add.py {_FOLD_VERB} (review first: add.py deltas)")
|
|
2319
2862
|
# SPEC-delta nudge (project-wide): the close is also a natural prompt to RESOLVE the
|
|
2320
2863
|
# forward hand-offs (seed/drop) so none is orphaned at the eventual compaction.
|
|
2321
2864
|
open_spec = len(_collect_open_spec_deltas(root))
|
|
@@ -2369,10 +2912,9 @@ def cmd_archive_milestone(args: argparse.Namespace) -> None:
|
|
|
2369
2912
|
del state["milestones"][slug]
|
|
2370
2913
|
for s in members:
|
|
2371
2914
|
del tasks[s]
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
state["active_task"] = None
|
|
2915
|
+
_deactivate_milestone(state, slug) # drop from the active SET + pop its task entry, repointing the primary focus
|
|
2916
|
+
if _active_task(state) in members: # N<=1 oracle: a NON-primary archive (new-milestone replace-to-focus leaves
|
|
2917
|
+
state["active_task"] = None # active_task pointing at m1's task while primary is m2) would dangle at a deleted task
|
|
2376
2918
|
save_state(root, state)
|
|
2377
2919
|
print(f"archived milestone '{slug}' ({len(members)} tasks) — removed from active state.")
|
|
2378
2920
|
print("files on disk are untouched; see `add.py status` for the archived rollup.")
|
|
@@ -2422,10 +2964,15 @@ def cmd_compact(args: argparse.Namespace) -> None:
|
|
|
2422
2964
|
# hand-off that resolves into a task, not a foundation lesson — an open one ANYWHERE would
|
|
2423
2965
|
# be orphaned at the next compaction. Deliberately broader than the member-scoped competency
|
|
2424
2966
|
# guard above. Still validate-before-move: refuses BEFORE the first rename.
|
|
2967
|
+
# --force overrides THIS guard ONLY (never a structural reject above) — the escape hatch
|
|
2968
|
+
# for a settled milestone blocked by an unrelated open SPEC delta elsewhere. Bypass is
|
|
2969
|
+
# loud (warns + records `force_bypassed_spec_deltas`), never silent.
|
|
2970
|
+
forced = getattr(args, "force", False)
|
|
2425
2971
|
spec_offenders = sorted({d["task"] for d in _collect_open_spec_deltas(root)})
|
|
2426
|
-
if spec_offenders:
|
|
2972
|
+
if spec_offenders and not forced:
|
|
2427
2973
|
_die("open_spec_deltas_unresolved: resolve every open SPEC delta first "
|
|
2428
|
-
"(`add.py deltas`; seed with `new-task --from-delta`, or `drop-delta
|
|
2974
|
+
"(`add.py deltas`; seed with `new-task --from-delta`, or `drop-delta`; "
|
|
2975
|
+
"or re-run with --force to compact past them) — "
|
|
2429
2976
|
"open in: " + " · ".join(spec_offenders))
|
|
2430
2977
|
# every precondition passed — move (same-filesystem renames, never a delete)
|
|
2431
2978
|
def _files(d: Path) -> int:
|
|
@@ -2443,7 +2990,12 @@ def cmd_compact(args: argparse.Namespace) -> None:
|
|
|
2443
2990
|
moved.append((f"tasks/{t}/", n))
|
|
2444
2991
|
# state write is the LAST step: additive stamp only — task_slugs untouched
|
|
2445
2992
|
entry["compacted"] = date.today().isoformat()
|
|
2993
|
+
if spec_offenders: # forced is implied (un-forced would have _die'd)
|
|
2994
|
+
entry["force_bypassed_spec_deltas"] = spec_offenders
|
|
2446
2995
|
save_state(root, state)
|
|
2996
|
+
if spec_offenders:
|
|
2997
|
+
print("⚠ --force bypassed open SPEC delta(s) in: " + " · ".join(spec_offenders) +
|
|
2998
|
+
" — recorded as force_bypassed_spec_deltas; resolve them before the next release.")
|
|
2447
2999
|
total = sum(n for _, n in moved)
|
|
2448
3000
|
print(f"compacted milestone '{slug}' -> .add/archive/{slug}/ "
|
|
2449
3001
|
f"({len(members)} task dirs, {total} files moved)")
|
|
@@ -2472,16 +3024,54 @@ def cmd_set_milestone(args: argparse.Namespace) -> None:
|
|
|
2472
3024
|
print(_next_footer(root, state))
|
|
2473
3025
|
|
|
2474
3026
|
|
|
3027
|
+
def cmd_activate(args: argparse.Namespace) -> None:
|
|
3028
|
+
"""Add a milestone to the active working SET and focus it — how a user works N milestones
|
|
3029
|
+
in parallel. Idempotent (re-activating just refocuses). Validates before mutating."""
|
|
3030
|
+
root = _require_root()
|
|
3031
|
+
state = load_state(root)
|
|
3032
|
+
slug = args.slug
|
|
3033
|
+
if slug not in state.get("milestones", {}):
|
|
3034
|
+
_die("unknown_milestone")
|
|
3035
|
+
if state["milestones"][slug].get("status") == "done":
|
|
3036
|
+
_die("milestone_done")
|
|
3037
|
+
_activate_milestone(state, slug)
|
|
3038
|
+
save_state(root, state)
|
|
3039
|
+
print(f"activated '{slug}' — active: {', '.join(state['active_milestones'])}")
|
|
3040
|
+
print(_next_footer(root, state))
|
|
3041
|
+
|
|
3042
|
+
|
|
3043
|
+
def cmd_deactivate(args: argparse.Namespace) -> None:
|
|
3044
|
+
"""Remove a milestone from the active working SET (its files + status are untouched);
|
|
3045
|
+
repoints the primary focus to a remaining member. Validates before mutating."""
|
|
3046
|
+
root = _require_root()
|
|
3047
|
+
state = load_state(root)
|
|
3048
|
+
slug = args.slug
|
|
3049
|
+
if slug not in (state.get("active_milestones") or []):
|
|
3050
|
+
_die("milestone_not_active")
|
|
3051
|
+
_deactivate_milestone(state, slug)
|
|
3052
|
+
save_state(root, state)
|
|
3053
|
+
remaining = state.get("active_milestones") or []
|
|
3054
|
+
print(f"deactivated '{slug}' — active: {', '.join(remaining) if remaining else '(none)'}")
|
|
3055
|
+
print(_next_footer(root, state))
|
|
3056
|
+
|
|
3057
|
+
|
|
2475
3058
|
def cmd_use(args: argparse.Namespace) -> None:
|
|
2476
3059
|
"""Set the active task to an EXISTING task (switch focus) without scaffolding a new
|
|
2477
3060
|
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.
|
|
3061
|
+
just moves the default focus, closing the only gap that forced manual state edits.
|
|
3062
|
+
Milestone-aware: focuses the task's OWN milestone (activating it into the set) so the
|
|
3063
|
+
active task is switched WITHIN that milestone, not mislabeled under a stale primary."""
|
|
2479
3064
|
root = _require_root()
|
|
2480
3065
|
state = load_state(root)
|
|
2481
3066
|
slug = args.slug
|
|
2482
3067
|
if slug not in state.get("tasks", {}):
|
|
2483
3068
|
_die("unknown_task")
|
|
2484
|
-
state["
|
|
3069
|
+
ms = state["tasks"][slug].get("milestone")
|
|
3070
|
+
if ms is not None and ms in state.get("milestones", {}):
|
|
3071
|
+
_activate_milestone(state, ms) # focus the task's milestone (adds to the set if needed)
|
|
3072
|
+
_set_active_task(state, slug, ms)
|
|
3073
|
+
else:
|
|
3074
|
+
_set_active_task(state, slug) # milestone-less task: scalar only (back-compat)
|
|
2485
3075
|
save_state(root, state)
|
|
2486
3076
|
print(f"active task -> '{slug}' (phase={state['tasks'][slug]['phase']})")
|
|
2487
3077
|
print(_next_footer(root, state))
|
|
@@ -3226,6 +3816,9 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
|
|
|
3226
3816
|
"phase_index": PHASES.index(phase) if phase in PHASES else 0,
|
|
3227
3817
|
"done": _task_done(t),
|
|
3228
3818
|
"gate": gate,
|
|
3819
|
+
"gate_actor": t.get("gate_actor"), # WHO recorded the verdict (None when unstamped)
|
|
3820
|
+
"owner": t.get("owner"), # WHO is accountable (None when unassigned)
|
|
3821
|
+
"assignee": t.get("assignee"), # WHO is working it (None when unassigned)
|
|
3229
3822
|
"tests": n_tests,
|
|
3230
3823
|
"tests_declared": t_declared,
|
|
3231
3824
|
"observe": observe,
|
|
@@ -3241,7 +3834,10 @@ def report_data(root: Path, state: dict, mslug: str) -> dict:
|
|
|
3241
3834
|
|
|
3242
3835
|
return {
|
|
3243
3836
|
"milestone": {"slug": mslug, "title": title, "goal": goal,
|
|
3244
|
-
"status": ms.get("status", "active")
|
|
3837
|
+
"status": ms.get("status", "active"),
|
|
3838
|
+
"done_actor": ms.get("done_actor"), # WHO closed it (None when unstamped/open)
|
|
3839
|
+
"owner": ms.get("owner"), # WHO is accountable for the milestone
|
|
3840
|
+
"assignee": ms.get("assignee")}, # WHO is working it (None when unassigned)
|
|
3245
3841
|
"summary": {
|
|
3246
3842
|
"tasks_done": sum(1 for r in task_rows if r["done"]),
|
|
3247
3843
|
"tasks_total": len(task_rows),
|
|
@@ -3423,6 +4019,24 @@ def render_task_detail(root: Path, state: dict, mslug: str, slug: str, *,
|
|
|
3423
4019
|
return "\n".join(L)
|
|
3424
4020
|
|
|
3425
4021
|
|
|
4022
|
+
def _fmt_actor(actor: dict | None) -> str:
|
|
4023
|
+
"""Format a recorded actor stamp `{name,email,source}` as `name [<email>]` for the
|
|
4024
|
+
report surface — "" when absent (user-identity: present-only render, no placeholder)."""
|
|
4025
|
+
if not actor:
|
|
4026
|
+
return ""
|
|
4027
|
+
email = f" <{actor['email']}>" if actor.get("email") else ""
|
|
4028
|
+
return f"{actor.get('name', '')}{email}"
|
|
4029
|
+
|
|
4030
|
+
|
|
4031
|
+
def _fmt_ownership(rec: dict) -> str:
|
|
4032
|
+
"""Format a record's owner/assignee as `owner: <name> · assignee: <name>` for the
|
|
4033
|
+
surface (ownership-assignment) — present-only: each role appears only when set, and
|
|
4034
|
+
"" when neither is. Reuses _fmt_actor to render each `{name,email,source}` actor."""
|
|
4035
|
+
bits = [f"{role}: {_fmt_actor(rec[role])}" for role in ("owner", "assignee")
|
|
4036
|
+
if rec.get(role) and rec[role].get("name")] # skip a hand-edited blank-name record
|
|
4037
|
+
return " · ".join(bits)
|
|
4038
|
+
|
|
4039
|
+
|
|
3426
4040
|
def render_report(root: Path, state: dict, mslug: str, *,
|
|
3427
4041
|
width: int = _DEFAULT_WIDTH, ascii: bool = False) -> str:
|
|
3428
4042
|
"""Format the FACTS (report_data) as the text DASHBOARD — verdict-first header,
|
|
@@ -3457,6 +4071,13 @@ def render_report(root: Path, state: dict, mslug: str, *,
|
|
|
3457
4071
|
L.append(f" {'GATES':<9} {gate_txt:<18} {'WAIVERS':<9} {waiver_txt}")
|
|
3458
4072
|
L.append("")
|
|
3459
4073
|
L.extend(_wrap(m["goal"], W - 7, " goal "))
|
|
4074
|
+
# who closed the milestone (user-identity) — present-only, never a placeholder
|
|
4075
|
+
if m.get("done_actor"):
|
|
4076
|
+
L.append(f" closed by {_fmt_actor(m['done_actor'])}")
|
|
4077
|
+
# who owns/works the milestone (ownership-assignment) — present-only
|
|
4078
|
+
_ms_own = _fmt_ownership(m)
|
|
4079
|
+
if _ms_own:
|
|
4080
|
+
L.append(f" owned by {_ms_own}")
|
|
3460
4081
|
L.append("")
|
|
3461
4082
|
if d["tasks"]:
|
|
3462
4083
|
L.append(f" {'TASK':<27} {'PHASE':<9} {'GATE':<4} {'TESTS':<5} PROGRESS")
|
|
@@ -3471,6 +4092,21 @@ def render_report(root: Path, state: dict, mslug: str, *,
|
|
|
3471
4092
|
f"{g['pending']} pending spec→…→done")
|
|
3472
4093
|
if any(r.get("tests_declared") for r in d["tasks"]):
|
|
3473
4094
|
L.append(" † counted at the §4-declared path")
|
|
4095
|
+
# who recorded each verdict (user-identity) — present-only audit trail
|
|
4096
|
+
gated = [r for r in d["tasks"] if r.get("gate_actor")]
|
|
4097
|
+
if gated:
|
|
4098
|
+
L.append("")
|
|
4099
|
+
L.append(" GATED BY")
|
|
4100
|
+
for r in gated:
|
|
4101
|
+
short = _GATE_SHORT.get(r["gate"], r["gate"])
|
|
4102
|
+
L.append(f" {_clip(r['slug'], 24):<24} {short:<4} {_fmt_actor(r['gate_actor'])}")
|
|
4103
|
+
# who owns/works each task (ownership-assignment) — present-only, mirror of GATED BY
|
|
4104
|
+
owned = [r for r in d["tasks"] if r.get("owner") or r.get("assignee")]
|
|
4105
|
+
if owned:
|
|
4106
|
+
L.append("")
|
|
4107
|
+
L.append(" OWNED BY")
|
|
4108
|
+
for r in owned:
|
|
4109
|
+
L.append(f" {_clip(r['slug'], 24):<24} {_fmt_ownership(r)}")
|
|
3474
4110
|
else:
|
|
3475
4111
|
L.append(" (no tasks yet)")
|
|
3476
4112
|
L.append("")
|
|
@@ -3764,7 +4400,7 @@ def _decide_next_pair(state: dict, d: dict) -> tuple[str, bool]:
|
|
|
3764
4400
|
return (f"goal not met ({met}/{total} exit criteria) — propose next tasks "
|
|
3765
4401
|
f"from open deltas / the unscaffolded plan (add.py deltas)"), True
|
|
3766
4402
|
return f"consolidate learnings + archive-milestone {ms}", True
|
|
3767
|
-
active = state
|
|
4403
|
+
active = _active_task(state)
|
|
3768
4404
|
order = sorted(rows, key=lambda r: 0 if r["slug"] == active else 1) # stable
|
|
3769
4405
|
for r in order:
|
|
3770
4406
|
if r["done"]:
|
|
@@ -3806,7 +4442,7 @@ def _next_footer(root: Path, state: dict) -> str:
|
|
|
3806
4442
|
The fail-soft line carries NO marker — never assert a driver that could not be computed.
|
|
3807
4443
|
"""
|
|
3808
4444
|
try:
|
|
3809
|
-
slug = state
|
|
4445
|
+
slug = _active_task(state)
|
|
3810
4446
|
t = (state.get("tasks") or {}).get(slug) if slug else None
|
|
3811
4447
|
if t and t.get("gate", "none") == "none" and t.get("phase") != "done":
|
|
3812
4448
|
phase = t.get("phase")
|
|
@@ -3815,7 +4451,7 @@ def _next_footer(root: Path, state: dict) -> str:
|
|
|
3815
4451
|
if phase == "verify" else "add.py advance")
|
|
3816
4452
|
marker = _driver_marker(_driver_stop(root, state, slug, phase))
|
|
3817
4453
|
return f"next: {command} — {why}{marker}"
|
|
3818
|
-
mslug = state
|
|
4454
|
+
mslug = _active_milestone(state)
|
|
3819
4455
|
if mslug:
|
|
3820
4456
|
d = report_data(root, state, mslug)
|
|
3821
4457
|
text, human_stop = _decide_next_pair(state, d)
|
|
@@ -4092,40 +4728,61 @@ def _collect_open_spec_deltas(root: Path) -> list[dict]:
|
|
|
4092
4728
|
_SPEC_OPEN_TOKEN_RE = re.compile(r"(\[\s*SPEC\s*·\s*)open(\s*\])")
|
|
4093
4729
|
|
|
4094
4730
|
|
|
4095
|
-
def _resolve_spec_delta(text: str, new_status: str, pointer: str | None = None
|
|
4096
|
-
|
|
4731
|
+
def _resolve_spec_delta(text: str, new_status: str, pointer: str | None = None,
|
|
4732
|
+
line_index: int | None = None) -> str | None:
|
|
4733
|
+
"""Flip ONE `[SPEC · open]` line in `text` to `new_status`; return the new text.
|
|
4097
4734
|
|
|
4098
|
-
PURE — no IO.
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
(
|
|
4735
|
+
PURE — no IO. With `line_index` (a splitlines(keepends=True) index, as `_select_spec_delta`
|
|
4736
|
+
returns) flip THAT line; without it, flip the FIRST open delta (back-compat). Only the status
|
|
4737
|
+
token changes (+ a trailing ` [→ <pointer>]` provenance stamp when seeding); the entry's text
|
|
4738
|
+
and `(evidence: …)` are byte-preserved. Returns None when there is NO open SPEC delta to flip —
|
|
4739
|
+
the caller then refuses and writes nothing. Mirrors the `_autonomy_decl_line` pure-transform."""
|
|
4102
4740
|
lines = text.splitlines(keepends=True)
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4741
|
+
target = line_index
|
|
4742
|
+
if target is None: # back-compat: the FIRST open delta
|
|
4743
|
+
for i, ln in enumerate(lines):
|
|
4744
|
+
m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
|
|
4745
|
+
if m and m.group(2) == "open":
|
|
4746
|
+
target = i
|
|
4747
|
+
break
|
|
4748
|
+
if target is None:
|
|
4749
|
+
return None
|
|
4750
|
+
ln = lines[target]
|
|
4751
|
+
eol = ln[len(ln.rstrip("\n")):] # preserve the exact line ending
|
|
4752
|
+
body = _SPEC_OPEN_TOKEN_RE.sub(rf"\g<1>{new_status}\g<2>", ln.rstrip("\n"), count=1)
|
|
4753
|
+
if pointer:
|
|
4754
|
+
body = f"{body} [→ {pointer}]"
|
|
4755
|
+
lines[target] = body + eol
|
|
4756
|
+
return "".join(lines)
|
|
4114
4757
|
|
|
4115
4758
|
|
|
4116
|
-
def
|
|
4117
|
-
"""
|
|
4759
|
+
def _select_spec_delta(text: str, match: str | None = None) -> tuple[str, int | None, str | None]:
|
|
4760
|
+
"""Pick the open SPEC delta to resolve (delta-match-selector). PURE — no IO.
|
|
4118
4761
|
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4762
|
+
`match=None` -> the FIRST open delta. `match=<substr>` -> the UNIQUE open delta whose display
|
|
4763
|
+
text (status token + `(evidence: …)` excluded) contains <substr>, case-insensitive. Returns
|
|
4764
|
+
(status, line_index, display_text) where status is one of: "ok" (line_index/display set),
|
|
4765
|
+
"no_open" (no open delta at all), "no_match" (--match hit zero), "ambiguous" (--match hit >1).
|
|
4766
|
+
line_index is a splitlines(keepends=True) index, the same `_resolve_spec_delta` flips."""
|
|
4767
|
+
opens: list[tuple[int, str]] = []
|
|
4768
|
+
for i, ln in enumerate(text.splitlines(keepends=True)):
|
|
4769
|
+
m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
|
|
4770
|
+
if not m or m.group(2) != "open":
|
|
4124
4771
|
continue
|
|
4125
|
-
tail =
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4772
|
+
tail = m.group(3).strip()
|
|
4773
|
+
cut = tail.find("(evidence:") # exclude the evidence tail even if its paren is unclosed
|
|
4774
|
+
opens.append((i, (tail[:cut].strip() if cut != -1 else tail)))
|
|
4775
|
+
if not opens:
|
|
4776
|
+
return ("no_open", None, None)
|
|
4777
|
+
if match is None:
|
|
4778
|
+
return ("ok", opens[0][0], opens[0][1])
|
|
4779
|
+
needle = match.lower()
|
|
4780
|
+
hits = [(i, d) for i, d in opens if needle in d.lower()]
|
|
4781
|
+
if not hits:
|
|
4782
|
+
return ("no_match", None, None)
|
|
4783
|
+
if len(hits) > 1:
|
|
4784
|
+
return ("ambiguous", None, None)
|
|
4785
|
+
return ("ok", hits[0][0], hits[0][1])
|
|
4129
4786
|
|
|
4130
4787
|
|
|
4131
4788
|
# ── add.py fold — mechanized competency-lesson consolidation ────────────────────────────────
|
|
@@ -4295,13 +4952,9 @@ def cmd_fold(args: argparse.Namespace) -> None:
|
|
|
4295
4952
|
proj_text = _prepend_key_decision_row(proj_text, row)
|
|
4296
4953
|
proj_text = re.sub(r"foundation-version:\s*\d+", f"foundation-version: {new_v}", proj_text, count=1)
|
|
4297
4954
|
|
|
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. ────────────────────
|
|
4955
|
+
# ── all bodies built; commit ALL via the all-or-nothing multi-file primitive: a stage failure
|
|
4956
|
+
# (disk-full / permission) writes nothing, and a mid-commit rename failure rolls back every
|
|
4957
|
+
# already-committed file — so the foundation never advances while a TASK.md stays unflipped. ─
|
|
4305
4958
|
writes: list[tuple[Path, str]] = [(project_md, proj_text)]
|
|
4306
4959
|
touched = ["PROJECT.md"]
|
|
4307
4960
|
if conv_text != conventions_text:
|
|
@@ -4764,13 +5417,18 @@ def _render_changelog_block(version: str, day: str, bundle: list[dict],
|
|
|
4764
5417
|
|
|
4765
5418
|
|
|
4766
5419
|
def _render_releases_row(version: str, day: str, bundle: list[dict],
|
|
4767
|
-
waiver_slugs: list[str], evidence: str | None
|
|
4768
|
-
|
|
5420
|
+
waiver_slugs: list[str], evidence: str | None,
|
|
5421
|
+
actor: str | None = None) -> str:
|
|
5422
|
+
"""One append-only RELEASES.md row — the attribution source (`milestones:` membership).
|
|
5423
|
+
The `actor:` line records WHO cut the release (structured-actor stamping); absent on a
|
|
5424
|
+
legacy row (back-compat) when no actor is supplied."""
|
|
4769
5425
|
ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
|
|
4770
5426
|
wv = ", ".join(waiver_slugs) if waiver_slugs else "none"
|
|
5427
|
+
actor_line = f"actor: {actor}\n" if actor else ""
|
|
4771
5428
|
return (f"## {version} — {day}\n"
|
|
4772
5429
|
f"milestones: {ms}\n"
|
|
4773
5430
|
f"waivers: {wv}\n"
|
|
5431
|
+
f"{actor_line}"
|
|
4774
5432
|
f"evidence: {evidence or 'recorded by add.py release'}\n\n")
|
|
4775
5433
|
|
|
4776
5434
|
|
|
@@ -4816,20 +5474,13 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
4816
5474
|
_render_changelog_block(args.version, day, bundle, changed_by_slug))
|
|
4817
5475
|
new_rel = _prepend_block(rel_before, "# Releases",
|
|
4818
5476
|
_render_releases_row(args.version, day, bundle, waiver_slugs,
|
|
4819
|
-
getattr(args, "evidence", None)
|
|
4820
|
-
|
|
4821
|
-
try:
|
|
4822
|
-
|
|
5477
|
+
getattr(args, "evidence", None),
|
|
5478
|
+
_render_actor_line(state)))
|
|
5479
|
+
try: # CHANGELOG + RELEASES as one all-or-nothing commit
|
|
5480
|
+
_atomic_write_many([(changelog_path, new_cl), (releases_path, new_rel)])
|
|
4823
5481
|
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.")
|
|
5482
|
+
_die(f"release_write_failed: the ledger write failed ({e}); nothing was recorded — both "
|
|
5483
|
+
"files were rolled back to their prior content. Retry the release.")
|
|
4833
5484
|
|
|
4834
5485
|
# NO save_state — attribution lives in RELEASES.md (the cue re-reads it), never state.json
|
|
4835
5486
|
ms = ", ".join(m["slug"] for m in bundle) if bundle else "none"
|
|
@@ -4926,13 +5577,13 @@ def cmd_report(args: argparse.Namespace) -> None:
|
|
|
4926
5577
|
else:
|
|
4927
5578
|
_die(f"unknown_milestone: '{name}' is not a milestone")
|
|
4928
5579
|
elif getattr(args, "decide", False): # bare --decide -> the ACTIVE TASK
|
|
4929
|
-
slug = state
|
|
5580
|
+
slug = _active_task(state)
|
|
4930
5581
|
if not slug or slug not in tasks:
|
|
4931
5582
|
_die("no_active_task — name one: add.py report <milestone> <task> --decide")
|
|
4932
5583
|
drill_task = slug
|
|
4933
5584
|
mslug = tasks[slug].get("milestone") or ""
|
|
4934
5585
|
else: # no positional -> active milestone
|
|
4935
|
-
mslug = state
|
|
5586
|
+
mslug = _active_milestone(state)
|
|
4936
5587
|
if not mslug:
|
|
4937
5588
|
_die("no_active_milestone: no milestone given and none is active; "
|
|
4938
5589
|
"try `add.py report <milestone>`")
|
|
@@ -5005,6 +5656,33 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5005
5656
|
pl.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
|
|
5006
5657
|
pl.set_defaults(func=cmd_lock)
|
|
5007
5658
|
|
|
5659
|
+
pwho = sub.add_parser("whoami",
|
|
5660
|
+
help="show / set the git-native actor (git config -> OS user; --name to override)")
|
|
5661
|
+
# --name (set) and --unset (clear) are mutually exclusive — argparse rejects the
|
|
5662
|
+
# contradiction (exit 2) before any state read, so neither silently wins.
|
|
5663
|
+
pwho_mut = pwho.add_mutually_exclusive_group()
|
|
5664
|
+
pwho_mut.add_argument("--name", default=None, help="set an actor override (name)")
|
|
5665
|
+
pwho_mut.add_argument("--unset", action="store_true", help="clear the actor override")
|
|
5666
|
+
pwho.add_argument("--email", default=None, help="set the override email (with --name)")
|
|
5667
|
+
pwho.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
|
|
5668
|
+
pwho.set_defaults(func=cmd_whoami)
|
|
5669
|
+
|
|
5670
|
+
pas = sub.add_parser("assign",
|
|
5671
|
+
help="assign an owner/assignee to a task or milestone (no flag = self)")
|
|
5672
|
+
pas.add_argument("slug")
|
|
5673
|
+
pas.add_argument("--owner", default=None, metavar="\"Name <email>\"",
|
|
5674
|
+
help="set the accountable owner (default with no flag: self)")
|
|
5675
|
+
pas.add_argument("--assignee", default=None, metavar="\"Name <email>\"",
|
|
5676
|
+
help="set the working assignee (default with no flag: self)")
|
|
5677
|
+
pas.set_defaults(func=cmd_assign)
|
|
5678
|
+
|
|
5679
|
+
pun = sub.add_parser("unassign",
|
|
5680
|
+
help="clear the owner/assignee of a task or milestone (no flag = both)")
|
|
5681
|
+
pun.add_argument("slug")
|
|
5682
|
+
pun.add_argument("--owner", action="store_true", help="clear only the owner")
|
|
5683
|
+
pun.add_argument("--assignee", action="store_true", help="clear only the assignee")
|
|
5684
|
+
pun.set_defaults(func=cmd_unassign)
|
|
5685
|
+
|
|
5008
5686
|
pn = sub.add_parser("new-task", help="scaffold a new task (TASK.md + tests/ + src/)")
|
|
5009
5687
|
pn.add_argument("slug")
|
|
5010
5688
|
pn.add_argument("--title", default=None)
|
|
@@ -5012,14 +5690,20 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5012
5690
|
pn.add_argument("--depends-on", dest="depends_on", default=None,
|
|
5013
5691
|
help="comma-separated task slugs this task depends on")
|
|
5014
5692
|
pn.add_argument("--from-delta", dest="from_delta", default=None, metavar="PRIOR",
|
|
5015
|
-
help="SEED PRIOR's
|
|
5693
|
+
help="SEED PRIOR's open SPEC delta into this task (pre-fills §1 "
|
|
5016
5694
|
"Feature, flips the source -> [SPEC · seeded] [→ this])")
|
|
5695
|
+
pn.add_argument("--match", default=None, metavar="SUBSTR",
|
|
5696
|
+
help="with --from-delta: target the UNIQUE open SPEC delta whose text "
|
|
5697
|
+
"contains SUBSTR (case-insensitive) instead of the first")
|
|
5017
5698
|
pn.add_argument("--force", action="store_true", help="overwrite TASK.md if present")
|
|
5018
5699
|
pn.set_defaults(func=cmd_new_task)
|
|
5019
5700
|
|
|
5020
5701
|
pdd = sub.add_parser("drop-delta",
|
|
5021
|
-
help="dismiss a task's
|
|
5022
|
-
pdd.add_argument("slug", help="task whose
|
|
5702
|
+
help="dismiss a task's open SPEC delta -> [SPEC · dropped]")
|
|
5703
|
+
pdd.add_argument("slug", help="task whose open SPEC delta to drop")
|
|
5704
|
+
pdd.add_argument("--match", default=None, metavar="SUBSTR",
|
|
5705
|
+
help="target the UNIQUE open SPEC delta whose text contains SUBSTR "
|
|
5706
|
+
"(case-insensitive) instead of the first")
|
|
5023
5707
|
pdd.set_defaults(func=cmd_drop_delta)
|
|
5024
5708
|
|
|
5025
5709
|
pm = sub.add_parser("new-milestone", help="scaffold a milestone (SDD living doc)")
|
|
@@ -5054,6 +5738,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5054
5738
|
pu.add_argument("slug")
|
|
5055
5739
|
pu.set_defaults(func=cmd_use)
|
|
5056
5740
|
|
|
5741
|
+
pac = sub.add_parser("activate",
|
|
5742
|
+
help="add a milestone to the active working SET and focus it (parallel milestones)")
|
|
5743
|
+
pac.add_argument("slug")
|
|
5744
|
+
pac.set_defaults(func=cmd_activate)
|
|
5745
|
+
|
|
5746
|
+
pdac = sub.add_parser("deactivate",
|
|
5747
|
+
help="remove a milestone from the active working SET (its files stay on disk)")
|
|
5748
|
+
pdac.add_argument("slug")
|
|
5749
|
+
pdac.set_defaults(func=cmd_deactivate)
|
|
5750
|
+
|
|
5057
5751
|
pam = sub.add_parser("archive-milestone",
|
|
5058
5752
|
help="collapse a done milestone out of active state (files stay on disk)")
|
|
5059
5753
|
pam.add_argument("slug")
|
|
@@ -5063,6 +5757,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5063
5757
|
help="heavy archive: move an archived milestone's files into "
|
|
5064
5758
|
".add/archive/<slug>/ (recoverable reverse move)")
|
|
5065
5759
|
pco.add_argument("slug")
|
|
5760
|
+
pco.add_argument("--force", action="store_true",
|
|
5761
|
+
help="compact past an unrelated open SPEC delta (the open_spec_deltas_unresolved "
|
|
5762
|
+
"guard ONLY; never a structural reject) — bypass is warned + recorded")
|
|
5066
5763
|
pco.set_defaults(func=cmd_compact)
|
|
5067
5764
|
|
|
5068
5765
|
pp = sub.add_parser("phase", help="set a task's phase explicitly")
|
|
@@ -5121,6 +5818,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5121
5818
|
pck.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
5122
5819
|
pck.set_defaults(func=cmd_check)
|
|
5123
5820
|
|
|
5821
|
+
pdoc = sub.add_parser("doctor", help="read-only diagnosis of state.json integrity + "
|
|
5822
|
+
"referential consistency (run after a git merge)")
|
|
5823
|
+
pdoc.set_defaults(func=cmd_doctor)
|
|
5824
|
+
|
|
5825
|
+
pmine = sub.add_parser("mine", help="read-only: my not-done tasks (owner or assignee) "
|
|
5826
|
+
"across all active milestones")
|
|
5827
|
+
pmine.add_argument("--actor", default=None, metavar="\"Name <email>\"",
|
|
5828
|
+
help="inspect another actor's queue instead of your own")
|
|
5829
|
+
pmine.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
|
|
5830
|
+
pmine.set_defaults(func=cmd_mine)
|
|
5831
|
+
|
|
5124
5832
|
pwv = sub.add_parser("wave-verify",
|
|
5125
5833
|
help="read-only merge-time gate: every WAVE.md roster echo must match "
|
|
5126
5834
|
"base (refuses unverified_fork_base) — run before the first merge-back")
|