@pilotspace/add 1.11.0 → 1.13.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 +112 -0
- package/docs/02-the-flow.md +10 -0
- package/docs/03-step-1-specify.md +1 -1
- package/docs/04-step-2-scenarios.md +1 -1
- package/docs/08-step-6-verify.md +4 -2
- package/docs/09-the-loop.md +4 -0
- package/docs/11-governance.md +1 -1
- package/docs/14-foundation.md +6 -4
- package/docs/16-releasing.md +1 -1
- package/docs/appendix-c-glossary.md +10 -2
- package/package.json +1 -1
- package/skill/add/SKILL.md +7 -3
- package/skill/add/advisor.md +8 -1
- package/skill/add/design.md +19 -2
- package/skill/add/intake.md +16 -0
- package/skill/add/phases/5-build.md +1 -1
- package/skill/add/phases/6-verify.md +13 -14
- package/skill/add/phases/7-observe.md +11 -10
- package/skill/add/release.md +1 -1
- package/skill/add/run.md +20 -20
- package/skill/add/scope.md +1 -1
- package/skill/add/streams.md +7 -0
- package/tooling/add.py +688 -107
- package/tooling/add_engine/constants.py +1 -1
- package/tooling/add_engine/release.py +3 -3
- package/tooling/templates/DESIGN.md.tmpl +11 -0
- package/tooling/templates/TASK.fast.md.tmpl +2 -0
- package/tooling/templates/TASK.md.tmpl +15 -1
package/tooling/add.py
CHANGED
|
@@ -164,16 +164,20 @@ from add_engine.identity import ( # re-exported for `add.<name>` attr c
|
|
|
164
164
|
)
|
|
165
165
|
|
|
166
166
|
|
|
167
|
-
def _my_work(state: dict, me: dict) -> list[dict]:
|
|
168
|
-
"""The "my work" lens (multi-active-UX):
|
|
169
|
-
|
|
170
|
-
|
|
167
|
+
def _my_work(state: dict, me: dict, scope_all: bool = False) -> list[dict]:
|
|
168
|
+
"""The "my work" lens (multi-active-UX): the NOT-done tasks whose owner OR assignee is `me`.
|
|
169
|
+
By default the lens is the active SET; `scope_all=True` (mine-all-lens) widens it to EVERY
|
|
170
|
+
milestone plus loose (milestone-less) tasks. Returns ordered rows {slug, milestone, phase,
|
|
171
|
+
role} with role in {owner, assignee, both}, sorted by active-milestone order then slug
|
|
172
|
+
(non-active/loose sort after the active block, then by slug). PURE · no I/O."""
|
|
171
173
|
active = list(state.get("active_milestones") or [])
|
|
172
174
|
active_set = set(active)
|
|
173
175
|
tasks = state.get("tasks") if isinstance(state.get("tasks"), dict) else {}
|
|
174
176
|
rows: list[dict] = []
|
|
175
177
|
for slug, t in tasks.items():
|
|
176
|
-
if not isinstance(t, dict) or
|
|
178
|
+
if not isinstance(t, dict) or _task_done(t):
|
|
179
|
+
continue
|
|
180
|
+
if not scope_all and t.get("milestone") not in active_set:
|
|
177
181
|
continue
|
|
178
182
|
owns = identity._actor_matches(t.get("owner"), me)
|
|
179
183
|
assigned = identity._actor_matches(t.get("assignee"), me)
|
|
@@ -235,6 +239,112 @@ def _stamp_gate_record(root: Path, state: dict, slug: str, outcome: str) -> None
|
|
|
235
239
|
_atomic_write(f, new)
|
|
236
240
|
|
|
237
241
|
|
|
242
|
+
def _stamp_adr_record(root: Path, state: dict, slug: str) -> None:
|
|
243
|
+
"""Write-back (adr-at-observe): HARVEST a §7 `### Decisions (ADR)` block from the actor-stamps
|
|
244
|
+
ALREADY in the task — §1 framing (AI) · §3 freeze (human) · §5 strategy-actually-used (AI) · §6
|
|
245
|
+
gate (human|AI by autonomy). HARVEST-not-author: every rendered line is sourced from an existing
|
|
246
|
+
stamp; the engine invents no decision content (NO-EXEC). GRANDFATHER like _stamp_gate_record:
|
|
247
|
+
fills ONLY while the block still holds its `<harvested…>` placeholder line; a resolved/absent
|
|
248
|
+
block or an unreadable file -> a byte-identical no-op (legacy + fast tasks untouched). NEVER
|
|
249
|
+
raises: any per-source parse fault renders "<unrecorded>". Called from cmd_gate AFTER
|
|
250
|
+
_stamp_gate_record (so §6 is already mirrored) and AFTER save_state (state is the source of
|
|
251
|
+
truth; the file only mirrors it, so a write fault never loses the verdict).
|
|
252
|
+
|
|
253
|
+
§7-OBSERVE-scoped (INV-7): the placeholder is matched ONLY inside the "## 7 · OBSERVE" section,
|
|
254
|
+
so a "<harvested at done…>" line elsewhere — e.g. a §3 contract that ILLUSTRATES this very
|
|
255
|
+
feature — is never touched (a file-wide first-match would corrupt the frozen contract; caught
|
|
256
|
+
by dogfooding adr-harvest on itself)."""
|
|
257
|
+
f = root / "tasks" / slug / "TASK.md"
|
|
258
|
+
try:
|
|
259
|
+
text = f.read_text(encoding="utf-8")
|
|
260
|
+
except OSError:
|
|
261
|
+
return # unreadable -> no-op
|
|
262
|
+
sec7 = re.search(r"(?ms)^## 7 · OBSERVE\b.*?(?=\n## \d+ ·|\Z)", text)
|
|
263
|
+
if not sec7:
|
|
264
|
+
return # no §7 OBSERVE (fast / legacy) -> no-op
|
|
265
|
+
m = re.search(r"(?m)^<harvested at done[^\n]*>$", sec7.group(0))
|
|
266
|
+
if not m:
|
|
267
|
+
return # resolved (hand-edited) or absent -> grandfather no-op
|
|
268
|
+
ph_start, ph_end = sec7.start() + m.start(), sec7.start() + m.end()
|
|
269
|
+
UN = "<unrecorded>"
|
|
270
|
+
bodies = _raw_phase_bodies(root, slug)
|
|
271
|
+
|
|
272
|
+
def _framing(): # §1 -> [AI]: chosen + rejected
|
|
273
|
+
try:
|
|
274
|
+
m = re.search(r"(?m)^Framings weighed:[ \t]*(.+)$", bodies.get(1, ""))
|
|
275
|
+
if not m:
|
|
276
|
+
return UN, ""
|
|
277
|
+
chosen, rejected = UN, []
|
|
278
|
+
for p in (s.strip() for s in m.group(1).split("·") if s.strip()):
|
|
279
|
+
cm = re.match(r"(.*?)\s*\(chosen\b.*\)\s*$", p) # "(chosen)" OR "(chosen — rationale)"
|
|
280
|
+
if cm:
|
|
281
|
+
chosen = cm.group(1).strip() or UN
|
|
282
|
+
else:
|
|
283
|
+
rejected.append(p)
|
|
284
|
+
# an UNFILLED §1 is a "<chosen>" placeholder token — degrade to <unrecorded>, but a
|
|
285
|
+
# real framing that merely CONTAINS a "<" (e.g. quoting a type) is kept (faithful capture)
|
|
286
|
+
if chosen is UN or chosen.startswith("<"):
|
|
287
|
+
return UN, ""
|
|
288
|
+
return chosen, " · ".join(rejected)
|
|
289
|
+
except Exception:
|
|
290
|
+
return UN, ""
|
|
291
|
+
|
|
292
|
+
def _freeze(): # §3 -> [human]: "FROZEN @ vN — approved by NAME"
|
|
293
|
+
try:
|
|
294
|
+
m = re.search(r"(?m)^.*FROZEN @ (v\d+).*?approved by ([^\n<]+?)\s*$", bodies.get(3, ""))
|
|
295
|
+
if m:
|
|
296
|
+
return m.group(1), m.group(2).strip()
|
|
297
|
+
except Exception:
|
|
298
|
+
pass
|
|
299
|
+
t = ((state.get("tasks") or {}).get(slug) or {})
|
|
300
|
+
fr = t.get("freeze") or {}
|
|
301
|
+
ver = fr.get("version") or t.get("contract_version")
|
|
302
|
+
return (f"v{ver}" if ver else UN), (fr.get("by") or fr.get("actor") or UN)
|
|
303
|
+
|
|
304
|
+
def _strategy(): # §5 -> [AI]: the value, default "as planned"
|
|
305
|
+
try:
|
|
306
|
+
m = re.search(r"(?m)^Strategy actually used:[ \t]*(.+)$", bodies.get(5, ""))
|
|
307
|
+
if m:
|
|
308
|
+
# UNFILLED is the "<fill at …>" template token; a real value may legitimately
|
|
309
|
+
# contain "<" (quoting `<tag>`, "x < y") and must NOT degrade to the default
|
|
310
|
+
val = m.group(1).strip()
|
|
311
|
+
if val and not val.startswith("<fill"):
|
|
312
|
+
return val
|
|
313
|
+
except Exception:
|
|
314
|
+
pass
|
|
315
|
+
return "as planned"
|
|
316
|
+
|
|
317
|
+
def _gate(): # §6 -> [human|AI]: outcome + reviewer
|
|
318
|
+
try:
|
|
319
|
+
mo = re.search(r"(?m)^Outcome:[ \t]*(\S+)", bodies.get(6, ""))
|
|
320
|
+
outcome = mo.group(1) if mo else (((state.get("tasks") or {}).get(slug) or {}).get("gate") or UN)
|
|
321
|
+
mr = re.search(r"(?m)^Reviewed by:[ \t]*([^·\n<]+)", bodies.get(6, ""))
|
|
322
|
+
rev = mr.group(1).strip() if mr else UN
|
|
323
|
+
except Exception:
|
|
324
|
+
outcome, rev = UN, UN
|
|
325
|
+
am = re.search(r"(?m)^autonomy:[ \t]*(\w+)", text)
|
|
326
|
+
actor = "AI" if (am and am.group(1) == "auto") else "human"
|
|
327
|
+
return outcome, rev, actor
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
chosen, rejected = _framing()
|
|
331
|
+
fver, fby = _freeze()
|
|
332
|
+
strat = _strategy()
|
|
333
|
+
outcome, rev, gate_actor = _gate()
|
|
334
|
+
except Exception:
|
|
335
|
+
return # never block the gate
|
|
336
|
+
rej = f"; rejected {rejected}" if rejected else ""
|
|
337
|
+
lines = [
|
|
338
|
+
f"- [AI] specify — chose {chosen}{rej}",
|
|
339
|
+
f"- [human] freeze — froze §3 @ {fver} (approved by {fby})",
|
|
340
|
+
f"- [AI] build — strategy used: {strat}",
|
|
341
|
+
f"- [{gate_actor}] verify — gate {outcome} (reviewed by {rev})",
|
|
342
|
+
]
|
|
343
|
+
new = text[:ph_start] + "\n".join(lines) + text[ph_end:]
|
|
344
|
+
if new != text:
|
|
345
|
+
_atomic_write(f, new)
|
|
346
|
+
|
|
347
|
+
|
|
238
348
|
# --- guidelines / CLAUDE.md-injection subsystem (moved to add_engine/guidelines.py) -
|
|
239
349
|
from add_engine.guidelines import (
|
|
240
350
|
_guideline_block, _inject_block, _rule_file_mode, _strip_inline_block,
|
|
@@ -461,6 +571,151 @@ def cmd_drop_delta(args: argparse.Namespace) -> None:
|
|
|
461
571
|
print(_next_footer(root, state))
|
|
462
572
|
|
|
463
573
|
|
|
574
|
+
def _open_spec_delta_indices(text: str) -> list[int]:
|
|
575
|
+
"""Every splitlines(keepends=True) index of an `[SPEC · open]` line (carry-delta --all). PURE.
|
|
576
|
+
A SPEC flip preserves line count + position, so these indices stay valid across sequential
|
|
577
|
+
flips."""
|
|
578
|
+
return [i for i, ln in enumerate(text.splitlines(keepends=True))
|
|
579
|
+
if (m := _SPEC_DELTA_RE.match(ln.rstrip("\n"))) and m.group(2) == "open"]
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def cmd_carry_delta(args: argparse.Namespace) -> None:
|
|
583
|
+
"""DEFER a task's open SPEC delta(s) non-lossily — `[SPEC · open]` -> `[SPEC · carried]`
|
|
584
|
+
+ a ` [carried: <reason>]` stamp (delta-drain). A carried delta clears the release floor and
|
|
585
|
+
the `status` staleness count but SURVIVES on disk: retrievable via `add.py deltas --carried`
|
|
586
|
+
and re-activatable via `reopen-delta`. `--reason` is REQUIRED (no silent carry); `--all` carries
|
|
587
|
+
every open delta in the task; `--match` targets the unique one. Validate-then-write."""
|
|
588
|
+
root = _require_root()
|
|
589
|
+
state = load_state(root)
|
|
590
|
+
slug = _resolve_task(state, args.slug) # unknown task -> _die
|
|
591
|
+
reason = (getattr(args, "reason", None) or "").strip()
|
|
592
|
+
if not reason:
|
|
593
|
+
_die("carry_reason_required: carry-delta needs a --reason — a deferral must say why "
|
|
594
|
+
"(it is the breadcrumb a future loop reads)")
|
|
595
|
+
task_md = root / "tasks" / slug / "TASK.md"
|
|
596
|
+
text = task_md.read_text(encoding="utf-8")
|
|
597
|
+
stamp = f"[carried: {reason}]"
|
|
598
|
+
if getattr(args, "all", False):
|
|
599
|
+
idxs = _open_spec_delta_indices(text)
|
|
600
|
+
if not idxs:
|
|
601
|
+
_die(f"no_open_spec_delta: task '{slug}' has no open SPEC delta to carry")
|
|
602
|
+
for idx in idxs: # indices stay valid (flip is in-place)
|
|
603
|
+
text = _resolve_spec_delta(text, "carried", line_index=idx, stamp=stamp)
|
|
604
|
+
_atomic_write(task_md, text)
|
|
605
|
+
print(f"carried {len(idxs)} open SPEC delta(s) in '{slug}' -> [SPEC · carried] ({reason})")
|
|
606
|
+
print(_next_footer(root, state))
|
|
607
|
+
return
|
|
608
|
+
match = getattr(args, "match", None)
|
|
609
|
+
status, idx, _disp = _select_spec_delta(text, match)
|
|
610
|
+
if status == "no_open":
|
|
611
|
+
_die(f"no_open_spec_delta: task '{slug}' has no open SPEC delta to carry")
|
|
612
|
+
if status == "no_match": # a --match miss is DISTINCT from no-open
|
|
613
|
+
_die(f"no_matching_spec_delta: no open SPEC delta in '{slug}' matches --match '{match}'")
|
|
614
|
+
if status == "ambiguous":
|
|
615
|
+
_die(f"ambiguous_spec_match: --match '{match}' matches multiple open SPEC deltas in "
|
|
616
|
+
f"'{slug}' — narrow it, or use --all")
|
|
617
|
+
new_text = _resolve_spec_delta(text, "carried", line_index=idx, stamp=stamp)
|
|
618
|
+
_atomic_write(task_md, new_text)
|
|
619
|
+
print(f"carried the {'matched' if match else 'first'} open SPEC delta in '{slug}' -> "
|
|
620
|
+
f"[SPEC · carried] ({reason})")
|
|
621
|
+
print(_next_footer(root, state))
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def cmd_reopen_delta(args: argparse.Namespace) -> None:
|
|
625
|
+
"""RE-ACTIVATE a carried SPEC delta — `[SPEC · carried]` -> `[SPEC · open]` (delta-drain).
|
|
626
|
+
The inverse of carry: a deferred delta re-enters the open count + release floor + staleness.
|
|
627
|
+
The ` [carried: …]` breadcrumb is stripped so a re-activated delta reads clean. `--match`
|
|
628
|
+
targets the unique carried delta. Validate-then-write; refuse `no_carried_spec_delta`."""
|
|
629
|
+
root = _require_root()
|
|
630
|
+
state = load_state(root)
|
|
631
|
+
slug = _resolve_task(state, args.slug)
|
|
632
|
+
task_md = root / "tasks" / slug / "TASK.md"
|
|
633
|
+
text = task_md.read_text(encoding="utf-8")
|
|
634
|
+
match = getattr(args, "match", None)
|
|
635
|
+
status, idx, _disp = _select_spec_delta(text, match, status="carried")
|
|
636
|
+
if status in ("no_open", "no_match"):
|
|
637
|
+
_die(f"no_carried_spec_delta: task '{slug}' has no carried SPEC delta to reopen"
|
|
638
|
+
+ (f" matching --match '{match}'" if status == "no_match" else ""))
|
|
639
|
+
if status == "ambiguous":
|
|
640
|
+
_die(f"ambiguous_spec_match: --match '{match}' matches multiple carried SPEC deltas in "
|
|
641
|
+
f"'{slug}' — narrow it")
|
|
642
|
+
new_text = _resolve_spec_delta(text, "open", line_index=idx, from_status="carried")
|
|
643
|
+
lines = new_text.splitlines(keepends=True) # drop the carried breadcrumb (no accretion)
|
|
644
|
+
eol = lines[idx][len(lines[idx].rstrip("\n")):]
|
|
645
|
+
lines[idx] = re.sub(r"\s*\[carried:[^\]]*\]\s*$", "", lines[idx].rstrip("\n")) + eol
|
|
646
|
+
_atomic_write(task_md, "".join(lines))
|
|
647
|
+
print(f"reopened the {'matched' if match else 'first'} carried SPEC delta in '{slug}' -> [SPEC · open]")
|
|
648
|
+
print(_next_footer(root, state))
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
# a §3 still carrying this template placeholder is NOT a drafted contract yet
|
|
652
|
+
_CONTRACT_TEMPLATE_RE = re.compile(r"<METHOD>")
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _next_freeze_version(state: dict, slug: str) -> str:
|
|
656
|
+
"""v1 on the first freeze; N+1 of the highest prior freeze version recorded on the
|
|
657
|
+
task's state record on a re-freeze (after a change request). PURE — reads state only."""
|
|
658
|
+
prior = ((state.get("tasks") or {}).get(slug) or {}).get("freeze") or {}
|
|
659
|
+
m = re.fullmatch(r"v(\d+)", str(prior.get("version", "")))
|
|
660
|
+
return f"v{int(m.group(1)) + 1}" if m else "v1"
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def cmd_freeze(args: argparse.Namespace) -> None:
|
|
664
|
+
"""The §3 contract-freeze write command — the 5th engine-WRITTEN human approval (task
|
|
665
|
+
freeze-actor-stamp), joining lock · gate · milestone-done · release. Flips the target
|
|
666
|
+
task's §3 `Status: DRAFT` -> `FROZEN @ vN — approved by <name>` AND records a structured
|
|
667
|
+
actor on the task's state record (mirrors cmd_lock's `setup.actor`), so the audit trail
|
|
668
|
+
has no hole at freeze. The human RUNS it as their approval — never pre-stamped.
|
|
669
|
+
|
|
670
|
+
validate-then-write: every refusal fires before any write. Writes TASK.md first, then
|
|
671
|
+
state; a crash between degrades to today's legacy text-only freeze (never corrupt state),
|
|
672
|
+
design-for-failure."""
|
|
673
|
+
root = _require_root()
|
|
674
|
+
state = load_state(root)
|
|
675
|
+
raw_slug = getattr(args, "slug", None)
|
|
676
|
+
if not raw_slug and not _active_task(state):
|
|
677
|
+
_die("no_active_task: no task given and no active task is set")
|
|
678
|
+
slug = _resolve_task(state, raw_slug) # unknown slug -> _die
|
|
679
|
+
task_md = root / "tasks" / slug / "TASK.md"
|
|
680
|
+
text = task_md.read_text(encoding="utf-8")
|
|
681
|
+
raw3 = _phase_spans(text).get(3, "")
|
|
682
|
+
phase = (state["tasks"].get(slug) or {}).get("phase", "specify")
|
|
683
|
+
# --- validate (no writes); error precedence: frozen -> not-drafted -> unflagged ---
|
|
684
|
+
if _contract_frozen(raw3):
|
|
685
|
+
_die(f"already_frozen: {slug}'s §3 is already FROZEN — re-freeze only via a change "
|
|
686
|
+
f"request back to SPECIFY")
|
|
687
|
+
if _phase_index(phase) < _phase_index("contract") or _CONTRACT_TEMPLATE_RE.search(raw3):
|
|
688
|
+
_die(f"contract_not_drafted: {slug}'s §3 is not a drafted contract yet — reach the "
|
|
689
|
+
f"`contract` phase and replace the template before freezing")
|
|
690
|
+
if not _flag_well_formed(raw3):
|
|
691
|
+
_die(f"unflagged_freeze: {slug}'s §3 must surface a well-formed lowest-confidence flag "
|
|
692
|
+
f"('Least-sure flag surfaced at freeze:' + a [part] tag) before it freezes")
|
|
693
|
+
# --- write ---
|
|
694
|
+
ver = _next_freeze_version(state, slug)
|
|
695
|
+
who = args.by or identity._actor_stamp(state)["name"]
|
|
696
|
+
# flip the `Status: DRAFT` line WITHIN the §3 region only — a bare `Status: DRAFT` in
|
|
697
|
+
# §1/§2 prose must never be frozen by mistake (refute-read finding). §3 span runs from
|
|
698
|
+
# its `## 3 ·` heading to the next `## `/`---`/EOF (same boundary as _phase_spans).
|
|
699
|
+
h3 = re.search(r"(?m)^##\s*3\s*·.*$", text)
|
|
700
|
+
if not h3:
|
|
701
|
+
_die(f"contract_not_drafted: {slug}'s TASK.md has no §3 CONTRACT section")
|
|
702
|
+
seg_start = h3.end()
|
|
703
|
+
nxt = re.search(r"(?m)^(?:##\s|---\s*$)", text[seg_start:])
|
|
704
|
+
seg_end = seg_start + (nxt.start() if nxt else len(text) - seg_start)
|
|
705
|
+
new_seg, n = re.subn(r"(?m)^(\s*)Status:\s*DRAFT\s*$",
|
|
706
|
+
lambda m: f"{m.group(1)}Status: FROZEN @ {ver} — approved by {who}",
|
|
707
|
+
text[seg_start:seg_end], count=1)
|
|
708
|
+
if n == 0:
|
|
709
|
+
_die(f"contract_not_drafted: {slug}'s §3 has no 'Status: DRAFT' line to freeze")
|
|
710
|
+
new_text = text[:seg_start] + new_seg + text[seg_end:]
|
|
711
|
+
_atomic_write(task_md, new_text) # TASK.md first (audit source of truth)
|
|
712
|
+
state["tasks"][slug]["freeze"] = {"version": ver, "frozen_at": _now(),
|
|
713
|
+
"approved_by": who, "actor": identity._actor_stamp(state)}
|
|
714
|
+
save_state(root, state)
|
|
715
|
+
print(f"froze §3 of {slug} @ {ver} — approved by {who}")
|
|
716
|
+
print(_next_footer(root, state))
|
|
717
|
+
|
|
718
|
+
|
|
464
719
|
def _parse_deps(raw: str | None) -> list[str]:
|
|
465
720
|
if not raw:
|
|
466
721
|
return []
|
|
@@ -490,7 +745,7 @@ def _resolve_task(state: dict, slug: str | None) -> str:
|
|
|
490
745
|
return slug
|
|
491
746
|
|
|
492
747
|
|
|
493
|
-
def _build_entry(root: Path, state: dict, slug: str) -> None:
|
|
748
|
+
def _build_entry(root: Path, state: dict, slug: str, skip_freeze: bool = False) -> None:
|
|
494
749
|
"""The shared tests->build entry guards + snapshots (task phase-build-guard, F4).
|
|
495
750
|
|
|
496
751
|
Extracted VERBATIM from cmd_advance's `nxt == "build"` block so BOTH `advance` and the
|
|
@@ -500,22 +755,28 @@ def _build_entry(root: Path, state: dict, slug: str) -> None:
|
|
|
500
755
|
so a refused entry leaves the task byte-unchanged. The heal loop sets phase=build DIRECTLY
|
|
501
756
|
and never routes here, so it stays exempt.
|
|
502
757
|
"""
|
|
503
|
-
# the
|
|
504
|
-
#
|
|
505
|
-
#
|
|
506
|
-
# stays green). validate-then-write — every refusal runs BEFORE the tripwire/scope
|
|
507
|
-
# snapshots below, writing nothing; the task stays at `tests`.
|
|
758
|
+
# the crossing guards. validate-then-write — every refusal runs BEFORE the tripwire/scope
|
|
759
|
+
# snapshots below, writing nothing; the task stays at `tests` (the lone exception is the
|
|
760
|
+
# recorded freeze_skipped marker on the explicit --skip-freeze path).
|
|
508
761
|
_ms = state["tasks"][slug].get("milestone")
|
|
509
762
|
_optin = bool(_ms) and (state.get("milestones") or {}).get(_ms, {}).get("await_confirm") is True
|
|
510
763
|
raw3 = _raw_phase_bodies(root, slug).get(3, "")
|
|
511
|
-
# freeze
|
|
512
|
-
#
|
|
513
|
-
#
|
|
514
|
-
#
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
764
|
+
# freeze gate — UNIVERSAL (freeze-gate-universal, flow-honesty): closes audit finding H1.
|
|
765
|
+
# The gate used to be opt-in (`_optin or fast`), so a plain-milestone task could cross
|
|
766
|
+
# tests->build on a DRAFT §3 — the method's decision point was engine-enforced for only a
|
|
767
|
+
# subset. It now fires for EVERY task. The ONLY bypass is the RECORDED `--skip-freeze` escape:
|
|
768
|
+
# it stamps an auditable `freeze_skipped` marker (never silent) and never auto-freezes §3
|
|
769
|
+
# (Status stays DRAFT). Still PRECEDES build-expectations: freeze §3 before pre-declaring §6.
|
|
770
|
+
if not _contract_frozen(raw3):
|
|
771
|
+
if not skip_freeze:
|
|
772
|
+
_die("contract_not_frozen: freeze §3 before crossing into build — approve "
|
|
773
|
+
f"the contract in {slug}'s TASK.md (Status: FROZEN @ vN), or pass "
|
|
774
|
+
"--skip-freeze to cross with a recorded skip")
|
|
775
|
+
state["tasks"][slug]["freeze_skipped"] = {
|
|
776
|
+
"by": identity._actor_stamp(state)["name"],
|
|
777
|
+
"at": _now(),
|
|
778
|
+
"from_phase": state["tasks"][slug].get("phase", "tests"),
|
|
779
|
+
}
|
|
519
780
|
# build-expectations gate (flow-enforcement): an opted-in task may not enter build until
|
|
520
781
|
# its §6 `### Build expectations` are pre-declared — so verify checks the build is RIGHT,
|
|
521
782
|
# not just green. Same opt-in switch as the contract-fill gate, one level out.
|
|
@@ -573,7 +834,7 @@ def cmd_phase(args: argparse.Namespace) -> None:
|
|
|
573
834
|
# validate-then-write: a refusal raises BEFORE the phase is set, so nothing moves. The heal
|
|
574
835
|
# loop sets phase=build directly (never via cmd_phase) and so stays exempt.
|
|
575
836
|
if args.phase == "build":
|
|
576
|
-
_build_entry(root, state, slug)
|
|
837
|
+
_build_entry(root, state, slug, skip_freeze=getattr(args, "skip_freeze", False))
|
|
577
838
|
state["tasks"][slug]["phase"] = args.phase
|
|
578
839
|
state["tasks"][slug]["updated"] = _now()
|
|
579
840
|
save_state(root, state) # F12: durable state FIRST (source of truth) — may _die
|
|
@@ -616,8 +877,9 @@ def cmd_advance(args: argparse.Namespace) -> None:
|
|
|
616
877
|
if nxt == "build":
|
|
617
878
|
# the tests->build entry guards + snapshots now live in the shared _build_entry helper
|
|
618
879
|
# (task phase-build-guard, F4) so `advance` and the `phase build` admin override run the
|
|
619
|
-
# IDENTICAL gate stack.
|
|
620
|
-
|
|
880
|
+
# IDENTICAL gate stack. `--skip-freeze` (freeze-gate-universal) threads through to the
|
|
881
|
+
# universal freeze gate — the only recorded bypass of the now-mandatory §3 freeze.
|
|
882
|
+
_build_entry(root, state, slug, skip_freeze=getattr(args, "skip_freeze", False))
|
|
621
883
|
# cross-component contract artifact (cross-component-contract): the contract->tests crossing
|
|
622
884
|
# is the producer's freeze-approval moment. A `produces:` task WRITES the immutable snapshot;
|
|
623
885
|
# a `consumes:` task PINS the live hash — a missing/unreadable snapshot HARD-STOPS here (the
|
|
@@ -800,6 +1062,7 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
800
1062
|
if completing:
|
|
801
1063
|
_sync_task_marker(root, slug, "done") # then mirror the phase into TASK.md — no split-brain
|
|
802
1064
|
_stamp_gate_record(root, state, slug, args.outcome) # mirror the verdict into §6 (Finding C)
|
|
1065
|
+
_stamp_adr_record(root, state, slug) # adr-at-observe: harvest §7 Decisions (ADR) — AFTER §6 is mirrored
|
|
803
1066
|
print(f"task '{slug}' gate -> {args.outcome}")
|
|
804
1067
|
_gbar = _task_green_bar(root, slug) # per-component-verify: surface the bound bar
|
|
805
1068
|
if _gbar:
|
|
@@ -1303,6 +1566,14 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1303
1566
|
_so = _fmt_actor(_owner_rec) if _owner_rec.get("name") else ""
|
|
1304
1567
|
_own_frag = f" · owner: {_so}" if _so else ""
|
|
1305
1568
|
print(f" {_mk} {_m:<20} task={_tk or '(none)'} phase={_ph}{_tag}{_own_frag}")
|
|
1569
|
+
# queued backlog (queue-resume-surface): surface milestones awaiting promotion so a
|
|
1570
|
+
# multi-milestone session resumes cleanly — `active` is what you're on, `queued` is what's
|
|
1571
|
+
# next. ADDITIVE + present-only: silent when zero queued (byte-identical), exactly like the
|
|
1572
|
+
# release/loose/streams cues; reads state, writes nothing, changes no command decision.
|
|
1573
|
+
_queued = [ms for ms, m in milestones.items() if m.get("status") == "queued"]
|
|
1574
|
+
if _queued:
|
|
1575
|
+
print(f"queued : {len(_queued)} milestone(s) next — {', '.join(_queued)}")
|
|
1576
|
+
print(f" promote next: add.py activate {_queued[0]}")
|
|
1306
1577
|
# surface the active task's autonomy level (task explicit-autonomy-dial) so the human
|
|
1307
1578
|
# reads the throttle every session; "unset" when no explicit `autonomy:` line is present.
|
|
1308
1579
|
if active and active in tasks:
|
|
@@ -1347,12 +1618,16 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
1347
1618
|
open_deltas = sum(len(v) for v in _collect_open_deltas(root).values())
|
|
1348
1619
|
if open_deltas:
|
|
1349
1620
|
print(f"deltas : {open_deltas} open — consolidate at milestone close (add.py deltas)")
|
|
1350
|
-
# SPEC-delta nudge (project-wide): surface unresolved forward hand-offs
|
|
1351
|
-
#
|
|
1621
|
+
# SPEC-delta staleness nudge (project-wide): surface unresolved forward hand-offs as STALE
|
|
1622
|
+
# backpressure so they drain instead of silently accumulating (delta-drain). Read-only;
|
|
1623
|
+
# PRESENT-ONLY (silent when none → byte-identical). The prefix stays `spec :` (the cue the
|
|
1624
|
+
# spec-delta guards pin); the wording now names the staleness + the drain surface, which now
|
|
1625
|
+
# includes carry-delta (defer non-lossily) beside seed/drop.
|
|
1352
1626
|
open_spec = len(_collect_open_spec_deltas(root))
|
|
1353
1627
|
if open_spec:
|
|
1354
1628
|
noun = "delta" if open_spec == 1 else "deltas"
|
|
1355
|
-
print(f"spec : {open_spec} open SPEC {noun} —
|
|
1629
|
+
print(f"spec : {open_spec} open SPEC {noun} — stale; drain via add.py deltas "
|
|
1630
|
+
"(carry-delta / new-task --from-delta / drop-delta)")
|
|
1356
1631
|
# When the setup is unlocked, the only terminal guidance that matters is
|
|
1357
1632
|
# review+lock; suppress the generic resume block so it does not compete.
|
|
1358
1633
|
if unlocked:
|
|
@@ -2239,6 +2514,39 @@ def _doctor_findings(root: Path) -> list[str]:
|
|
|
2239
2514
|
if m is not None and m not in milestones:
|
|
2240
2515
|
findings.append(f"task '{slug}' references missing milestone '{m}' — fix: set its "
|
|
2241
2516
|
"milestone to a real one (or none)")
|
|
2517
|
+
# value-domain checks (doctor-value-checks): gate/phase enum (required) · owner/assignee
|
|
2518
|
+
# shape (optional) · archived consistency. Present-but-invalid + missing-required only;
|
|
2519
|
+
# absent owner/assignee is fine. Pure/total — .get-guarded, isinstance-checked, never raises.
|
|
2520
|
+
for slug, t in tasks.items():
|
|
2521
|
+
if not isinstance(t, dict):
|
|
2522
|
+
continue
|
|
2523
|
+
g = t.get("gate")
|
|
2524
|
+
if g is None:
|
|
2525
|
+
findings.append(f"task '{slug}' is missing its gate — fix: one of {', '.join(GATES)}")
|
|
2526
|
+
elif g not in GATES:
|
|
2527
|
+
findings.append(f"task '{slug}' has invalid gate '{g}' — fix: one of {', '.join(GATES)}")
|
|
2528
|
+
p = t.get("phase")
|
|
2529
|
+
if p is None:
|
|
2530
|
+
findings.append(f"task '{slug}' is missing its phase — fix: one of {', '.join(PHASES)}")
|
|
2531
|
+
elif p not in PHASES:
|
|
2532
|
+
findings.append(f"task '{slug}' has invalid phase '{p}' — fix: one of {', '.join(PHASES)}")
|
|
2533
|
+
for role in ("owner", "assignee"):
|
|
2534
|
+
v = t.get(role)
|
|
2535
|
+
if v is not None and not (isinstance(v, dict) and isinstance(v.get("name"), str) and v.get("name")):
|
|
2536
|
+
findings.append(f"task '{slug}' has a malformed {role} — fix: an actor object "
|
|
2537
|
+
"{name, email, source} or remove it")
|
|
2538
|
+
archived = state.get("archived") if isinstance(state.get("archived"), list) else []
|
|
2539
|
+
for a in archived:
|
|
2540
|
+
if not isinstance(a, dict):
|
|
2541
|
+
continue
|
|
2542
|
+
aslug = a.get("slug")
|
|
2543
|
+
if aslug is not None and aslug in milestones:
|
|
2544
|
+
findings.append(f"archived milestone '{aslug}' is also a live milestone — fix: remove "
|
|
2545
|
+
"the live duplicate or the archived entry")
|
|
2546
|
+
ts = a.get("task_slugs")
|
|
2547
|
+
if isinstance(ts, list) and isinstance(a.get("tasks"), int) and a.get("tasks") != len(ts):
|
|
2548
|
+
findings.append(f"archived milestone '{aslug}' task count {a.get('tasks')} ≠ {len(ts)} "
|
|
2549
|
+
"listed — fix: reconcile its task_slugs")
|
|
2242
2550
|
return findings
|
|
2243
2551
|
|
|
2244
2552
|
|
|
@@ -2259,25 +2567,29 @@ def cmd_doctor(args: argparse.Namespace) -> None:
|
|
|
2259
2567
|
|
|
2260
2568
|
|
|
2261
2569
|
def cmd_mine(args: argparse.Namespace) -> None:
|
|
2262
|
-
"""Read-only `add.py mine`:
|
|
2263
|
-
|
|
2264
|
-
|
|
2570
|
+
"""Read-only `add.py mine`: the not-done tasks owned-by or assigned-to the resolved actor
|
|
2571
|
+
(`_whoami`, or `--actor "Name <email>"`). Default lens is the active SET; `--all` widens it
|
|
2572
|
+
to EVERY milestone plus loose (milestone-less) tasks. Text or `--json`. An empty queue is a
|
|
2573
|
+
plain exit-0 line, not an error. NEVER writes state."""
|
|
2265
2574
|
root = find_root()
|
|
2266
2575
|
if root is None:
|
|
2267
2576
|
_die("no_project")
|
|
2268
2577
|
state = load_state(root)
|
|
2269
2578
|
me = identity._parse_actor_arg(args.actor) if getattr(args, "actor", None) else identity._whoami(state)
|
|
2270
|
-
|
|
2579
|
+
scope_all = getattr(args, "all", False)
|
|
2580
|
+
rows = _my_work(state, me, scope_all=scope_all)
|
|
2271
2581
|
if getattr(args, "json", False):
|
|
2272
2582
|
print(json.dumps({"actor": me, "tasks": rows}))
|
|
2273
2583
|
return
|
|
2584
|
+
scope = "all" if scope_all else "active"
|
|
2274
2585
|
who = _fmt_actor(me) or me.get("name", "you")
|
|
2275
2586
|
if not rows:
|
|
2276
|
-
print(f"mine: no open tasks for {who} across
|
|
2587
|
+
print(f"mine: no open tasks for {who} across {scope} milestones")
|
|
2277
2588
|
return
|
|
2278
|
-
print(f"mine: {who} — {len(rows)} open task(s) across
|
|
2589
|
+
print(f"mine: {who} — {len(rows)} open task(s) across {scope} milestones:")
|
|
2279
2590
|
for r in rows:
|
|
2280
|
-
|
|
2591
|
+
loc = f"[{r['milestone']}]" if r["milestone"] else "[loose]"
|
|
2592
|
+
print(f" {r['slug']:<24} {loc} phase={r['phase']} ({r['role']})")
|
|
2281
2593
|
|
|
2282
2594
|
|
|
2283
2595
|
# ---------------------------------------------------------------------------
|
|
@@ -2424,6 +2736,12 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
|
|
|
2424
2736
|
slug = args.slug
|
|
2425
2737
|
if not slug.replace("-", "").replace("_", "").isalnum():
|
|
2426
2738
|
_die("bad_slug")
|
|
2739
|
+
# Prefer a short DESCRIPTIVE slug over a bare version (v2, v1-1, 1.2): a descriptive
|
|
2740
|
+
# name keeps the milestones list legible. Advisory only — never blocks (matches the
|
|
2741
|
+
# engine's `note:` convention); a deliberate version slug still creates.
|
|
2742
|
+
if re.match(r"^v?\d+([._-]\d+)*$", slug, re.IGNORECASE):
|
|
2743
|
+
print(f"note: slug '{slug}' looks like a bare version — prefer a short "
|
|
2744
|
+
f"descriptive name (e.g. 'payment-retries'). Creating anyway.")
|
|
2427
2745
|
state.setdefault("milestones", {})
|
|
2428
2746
|
mdir = root / "milestones" / slug
|
|
2429
2747
|
mfile = mdir / MILESTONE_FILE
|
|
@@ -2431,17 +2749,24 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
|
|
|
2431
2749
|
_die("milestone_exists")
|
|
2432
2750
|
mdir.mkdir(parents=True, exist_ok=True)
|
|
2433
2751
|
title = args.title or slug.replace("-", " ").replace("_", " ").title()
|
|
2752
|
+
# One _now() instant feeds BOTH the MILESTONE.md render and the state record, so the
|
|
2753
|
+
# human-facing `created:` is a full ISO timestamp provably equal to state.json.
|
|
2754
|
+
now = _now()
|
|
2434
2755
|
_atomic_write(mfile, _render_template(
|
|
2435
2756
|
"MILESTONE.md", title=title, goal=args.goal or "<goal>",
|
|
2436
|
-
stage=args.stage, date=
|
|
2757
|
+
stage=args.stage, date=now))
|
|
2437
2758
|
# confirm-parent gate (OPT-IN, mirrors `init --await-lock`): `--await-confirm` seeds the
|
|
2438
2759
|
# milestone UNCONFIRMED so new-task is held until `add.py milestone-confirm`. WITHOUT the flag
|
|
2439
2760
|
# NO `confirmed` key is written → grandfathered-confirmed → no gate (so the existing engine
|
|
2440
2761
|
# tests stay byte-green). The guided skill flow passes the flag at the human-review point.
|
|
2441
2762
|
await_confirm = bool(getattr(args, "await_confirm", False))
|
|
2763
|
+
# --queued (OPT-IN): create the milestone non-active (status=queued) without stealing focus.
|
|
2764
|
+
# The active set is left UNCHANGED so the default path (no flag) stays byte-identical. Promote
|
|
2765
|
+
# later with `activate` (queued→active). Foundation for roadmap intake (1 active + N queued).
|
|
2766
|
+
queued = bool(getattr(args, "queued", False))
|
|
2442
2767
|
record = {
|
|
2443
2768
|
"title": title, "goal": args.goal or "", "stage": args.stage,
|
|
2444
|
-
"status": "active", "created":
|
|
2769
|
+
"status": "queued" if queued else "active", "created": now, "updated": now,
|
|
2445
2770
|
}
|
|
2446
2771
|
if await_confirm:
|
|
2447
2772
|
# `await_confirm` is the STABLE opt-in marker (set ONLY here, at creation). `confirmed`
|
|
@@ -2449,11 +2774,22 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
|
|
|
2449
2774
|
# milestone too, so a later build-entry gate must key on `await_confirm`, not `confirmed`.
|
|
2450
2775
|
record.update(confirmed=False, confirmed_at=None, confirmed_by=None, await_confirm=True)
|
|
2451
2776
|
state["milestones"][slug] = record
|
|
2452
|
-
|
|
2777
|
+
if not queued:
|
|
2778
|
+
# PRESERVE the active SET (new-milestone-add-focus): ADD this milestone + focus it, rather
|
|
2779
|
+
# than REPLACING the set and evicting the others. Single-active is identical ([] -> [slug]);
|
|
2780
|
+
# a user who already had P active now keeps P active alongside the new primary.
|
|
2781
|
+
_activate_milestone(state, slug)
|
|
2453
2782
|
save_state(root, state)
|
|
2454
2783
|
print(f"created milestone '{slug}' -> {mfile}")
|
|
2455
|
-
|
|
2456
|
-
|
|
2784
|
+
if queued:
|
|
2785
|
+
print(f"queued (not active) — promote it with: add.py activate {slug}")
|
|
2786
|
+
# surface the recorded confirm gate for a queued+await_confirm milestone (queued-await-confirm-hint):
|
|
2787
|
+
# additive — prints ONLY when await_confirm, so plain `--queued` output stays byte-identical.
|
|
2788
|
+
if await_confirm:
|
|
2789
|
+
print(f" (unconfirmed — after promote: add.py milestone-confirm {slug})")
|
|
2790
|
+
else:
|
|
2791
|
+
print("active milestone set." + ("" if not await_confirm else
|
|
2792
|
+
" (unconfirmed — show the MILESTONE.md, then: add.py milestone-confirm " + slug + ")"))
|
|
2457
2793
|
print(_next_footer(root, state)) # converges the old "Decompose it into tasks: …" hint
|
|
2458
2794
|
|
|
2459
2795
|
|
|
@@ -2542,25 +2878,34 @@ def cmd_ready(args: argparse.Namespace) -> None:
|
|
|
2542
2878
|
|
|
2543
2879
|
|
|
2544
2880
|
def _wave_schedule(state: dict, mslug: str) -> dict:
|
|
2545
|
-
"""
|
|
2546
|
-
|
|
2881
|
+
"""One-element wrapper: a single milestone's schedule is the merge over just [mslug].
|
|
2882
|
+
Output is byte-identical to the historical per-milestone scheduler (the suite is the oracle)."""
|
|
2883
|
+
return _wave_schedule_merged(state, [mslug])
|
|
2884
|
+
|
|
2885
|
+
|
|
2886
|
+
def _wave_schedule_merged(state: dict, mslugs: list[str]) -> dict:
|
|
2887
|
+
"""Pure, total: derive ONE DAG schedule over the UNION of open members across `mslugs`
|
|
2888
|
+
(a single-element list is the historical per-milestone case) — never mutates, never raises
|
|
2889
|
+
on dict input. Returns one of:
|
|
2547
2890
|
{"cycle": [slug, ...]} — unschedulable cycle
|
|
2548
2891
|
{"waves", "critical_path", "critical_path_len", "tiers", "blocked"} — a schedule
|
|
2549
2892
|
|
|
2550
2893
|
A dep is SATISFIED (does not block) if it is archived or `_task_done` — the SAME
|
|
2551
|
-
predicate cmd_ready uses. A not-done dep that is an OPEN MEMBER of
|
|
2552
|
-
forces a later wave
|
|
2553
|
-
is
|
|
2554
|
-
|
|
2555
|
-
Tier is advisory: `top` on the critical
|
|
2894
|
+
predicate cmd_ready uses. A not-done dep that is an OPEN MEMBER of ANY target milestone
|
|
2895
|
+
forces a later wave (so a cross-milestone dep ORDERS, it does not block). A not-done dep
|
|
2896
|
+
that is NOT an open member of any target (external/unknown) is UNSATISFIABLE here -> the
|
|
2897
|
+
task is `blocked`, never scheduled. Critical path is the longest chain (most tasks) through
|
|
2898
|
+
the scheduled sub-DAG; ties break by sorted slug. Tier is advisory: `top` on the critical
|
|
2899
|
+
path, `mid` elsewhere (scheduled tasks only)."""
|
|
2556
2900
|
tasks = state.get("tasks") or {}
|
|
2557
2901
|
archived = _archived_task_slugs(state)
|
|
2902
|
+
targetset = set(mslugs)
|
|
2558
2903
|
|
|
2559
2904
|
def _ok(d: str) -> bool: # satisfied externally / already done
|
|
2560
2905
|
return d in archived or (d in tasks and _task_done(tasks[d]))
|
|
2561
2906
|
|
|
2562
2907
|
open_members = {s: t for s, t in tasks.items()
|
|
2563
|
-
if t.get("milestone")
|
|
2908
|
+
if t.get("milestone") in targetset and not _task_done(t)}
|
|
2564
2909
|
|
|
2565
2910
|
# partition open members into blocked vs schedulable — to a FIXED POINT, so blocking
|
|
2566
2911
|
# propagates transitively: a task is blocked if any dep is unsatisfiable here, where
|
|
@@ -2661,16 +3006,71 @@ def _wave_block_lines(state: dict, mslug: str, sched: dict) -> list[str]:
|
|
|
2661
3006
|
return lines
|
|
2662
3007
|
|
|
2663
3008
|
|
|
3009
|
+
def _wave_block_lines_merged(state: dict, mslugs: list[str], sched: dict) -> list[str]:
|
|
3010
|
+
"""The text lines `waves --merge` renders for ONE unified schedule over the milestone SET:
|
|
3011
|
+
a `merged: …` header naming the set + each scheduled task tagged with its `[milestone]` so
|
|
3012
|
+
cross-milestone tasks are unambiguous. Critical-path / tier-hint / blocked lines mirror
|
|
3013
|
+
`_wave_block_lines`."""
|
|
3014
|
+
n = len(mslugs)
|
|
3015
|
+
lines = [f"merged: {' + '.join(mslugs)} ({n} milestone{'s' if n != 1 else ''})"]
|
|
3016
|
+
if not sched["waves"]:
|
|
3017
|
+
if sched["blocked"]:
|
|
3018
|
+
for s in sched["blocked"]:
|
|
3019
|
+
lines.append(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
|
|
3020
|
+
else:
|
|
3021
|
+
lines.append("all tasks done — nothing to schedule")
|
|
3022
|
+
return lines
|
|
3023
|
+
scheduled_set = {x for w in sched["waves"] for x in w}
|
|
3024
|
+
for i, wave in enumerate(sched["waves"], start=1):
|
|
3025
|
+
parts = []
|
|
3026
|
+
for s in wave:
|
|
3027
|
+
label = f"{s} [{state['tasks'][s].get('milestone')}]"
|
|
3028
|
+
md = sorted(d for d in (state["tasks"][s].get("depends_on") or [])
|
|
3029
|
+
if d in scheduled_set)
|
|
3030
|
+
parts.append(f"{label} (deps: {', '.join(md)})" if md else label)
|
|
3031
|
+
lines.append(f"wave {i}: {', '.join(parts)}")
|
|
3032
|
+
crit = sched["critical_path"]
|
|
3033
|
+
lines.append(f"critical path: {' → '.join(crit)} ({sched['critical_path_len']} tasks)")
|
|
3034
|
+
tops = [s for s, tier in sched["tiers"].items() if tier == "top"]
|
|
3035
|
+
mids = [s for s, tier in sched["tiers"].items() if tier == "mid"]
|
|
3036
|
+
lines.append(f"tier hint: top → {', '.join(tops)}; mid → {', '.join(mids) or '(none)'}")
|
|
3037
|
+
for s in sched["blocked"]:
|
|
3038
|
+
lines.append(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
|
|
3039
|
+
return lines
|
|
3040
|
+
|
|
3041
|
+
|
|
2664
3042
|
def cmd_waves(args: argparse.Namespace) -> None:
|
|
2665
3043
|
"""READ-ONLY DAG scheduler: print the topological waves, critical path, advisory tier hint,
|
|
2666
|
-
and blocked set. With no --milestone it spans EVERY active milestone (cross-active-waves)
|
|
2667
|
-
a single target / --milestone renders byte-identically.
|
|
3044
|
+
and blocked set. With no --milestone it spans EVERY active milestone (cross-active-waves)
|
|
3045
|
+
as SEPARATE streams; a single target / --milestone renders byte-identically. With --merge it
|
|
3046
|
+
unifies the active SET into ONE schedule so cross-milestone deps order, not block.
|
|
3047
|
+
Writes nothing; no `next:` footer."""
|
|
2668
3048
|
is_json = getattr(args, "json", False)
|
|
2669
3049
|
if is_json:
|
|
2670
3050
|
_, state = _load_state_for_json()
|
|
2671
3051
|
else:
|
|
2672
3052
|
state = load_state(_require_root())
|
|
2673
3053
|
mslug_arg = getattr(args, "milestone", None)
|
|
3054
|
+
if getattr(args, "merge", False):
|
|
3055
|
+
if mslug_arg: # explicit target → a 1-milestone merge (NOT a conflict)
|
|
3056
|
+
if mslug_arg not in (state.get("milestones") or {}):
|
|
3057
|
+
_die(f"unknown_milestone: '{mslug_arg}' is not a milestone in this project")
|
|
3058
|
+
targets = [mslug_arg]
|
|
3059
|
+
else:
|
|
3060
|
+
primary = _active_milestone(state)
|
|
3061
|
+
if not primary:
|
|
3062
|
+
_die("no_active_milestone: no active milestone and no --milestone given")
|
|
3063
|
+
targets = [primary] + [m for m in (state.get("active_milestones") or [])
|
|
3064
|
+
if m != primary]
|
|
3065
|
+
sched = _wave_schedule_merged(state, targets)
|
|
3066
|
+
if "cycle" in sched:
|
|
3067
|
+
_die(f"dependency_cycle: not-done deps form a cycle "
|
|
3068
|
+
f"({' -> '.join(sched['cycle'])}) — no valid schedule")
|
|
3069
|
+
if is_json:
|
|
3070
|
+
print(json.dumps({"merged": targets, **sched}))
|
|
3071
|
+
else:
|
|
3072
|
+
print("\n".join(_wave_block_lines_merged(state, targets, sched)))
|
|
3073
|
+
return
|
|
2674
3074
|
if mslug_arg:
|
|
2675
3075
|
targets = [mslug_arg] # explicit single target — unchanged
|
|
2676
3076
|
else:
|
|
@@ -2938,6 +3338,11 @@ def cmd_activate(args: argparse.Namespace) -> None:
|
|
|
2938
3338
|
_die("unknown_milestone")
|
|
2939
3339
|
if state["milestones"][slug].get("status") == "done":
|
|
2940
3340
|
_die("milestone_done")
|
|
3341
|
+
# PROMOTE a queued milestone: activating it flips queued→active (human-gated promotion —
|
|
3342
|
+
# the chosen verb, reusing `activate` rather than a separate `promote`). An already-active
|
|
3343
|
+
# milestone is just refocused (status unchanged), keeping the default path byte-identical.
|
|
3344
|
+
if state["milestones"][slug].get("status") == "queued":
|
|
3345
|
+
state["milestones"][slug]["status"] = "active"
|
|
2941
3346
|
_activate_milestone(state, slug)
|
|
2942
3347
|
save_state(root, state)
|
|
2943
3348
|
print(f"activated '{slug}' — active: {', '.join(state['active_milestones'])}")
|
|
@@ -4143,7 +4548,7 @@ _DELTA_STATUSES = ("open", "folded", "rejected")
|
|
|
4143
4548
|
# dismissed (dropped) — never consolidated into the foundation. _STATUS_SETS keys each
|
|
4144
4549
|
# tag to its legal status set so the ONE lint can reject a cross-set pairing
|
|
4145
4550
|
# ([SPEC · folded], [SDD · seeded]) without a parallel grammar.
|
|
4146
|
-
_SPEC_STATUSES = ("open", "seeded", "dropped")
|
|
4551
|
+
_SPEC_STATUSES = ("open", "seeded", "dropped", "carried")
|
|
4147
4552
|
_STATUS_SETS = {**{c: _DELTA_STATUSES for c in _COMPETENCY_ORDER}, "SPEC": _SPEC_STATUSES}
|
|
4148
4553
|
|
|
4149
4554
|
# Broad structural tag detector: finds ANY "- [tok · tok]" line (valid OR malformed).
|
|
@@ -4299,13 +4704,13 @@ def _collect_open_deltas(root: Path) -> dict[str, list[dict]]:
|
|
|
4299
4704
|
return by_comp
|
|
4300
4705
|
|
|
4301
4706
|
|
|
4302
|
-
def
|
|
4303
|
-
"""Scan every .add/tasks/*/TASK.md "### Spec delta" block for
|
|
4707
|
+
def _collect_spec_deltas(root: Path, status: str = "open") -> list[dict]:
|
|
4708
|
+
"""Scan every .add/tasks/*/TASK.md "### Spec delta" block for SPEC deltas of `status`.
|
|
4304
4709
|
|
|
4305
|
-
Returns a FLAT list of {task, text, evidence} dicts (SPEC is one tag, never
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4710
|
+
Returns a FLAT list of {task, text, evidence} dicts (SPEC is one tag, never bucketed by
|
|
4711
|
+
competency). A SPEC delta is a forward hand-off that resolves into a TASK (seeded), is
|
|
4712
|
+
dismissed (dropped), or is DEFERRED non-lossily (carried) — the open/carried VIEWS are this
|
|
4713
|
+
one scan keyed on `status`. READ-ONLY; never mutates any file."""
|
|
4309
4714
|
out: list[dict] = []
|
|
4310
4715
|
tasks_dir = root / "tasks"
|
|
4311
4716
|
if not tasks_dir.is_dir():
|
|
@@ -4318,7 +4723,7 @@ def _collect_open_spec_deltas(root: Path) -> list[dict]:
|
|
|
4318
4723
|
continue
|
|
4319
4724
|
for unit in _spec_delta_entries(text):
|
|
4320
4725
|
m = _SPEC_DELTA_RE.match(unit[0])
|
|
4321
|
-
if m.group(2) !=
|
|
4726
|
+
if m.group(2) != status: # other statuses are excluded from this view
|
|
4322
4727
|
continue
|
|
4323
4728
|
tail = " ".join([m.group(3).strip(), *unit[1:]]).strip()
|
|
4324
4729
|
em = _EVIDENCE_RE.match(tail)
|
|
@@ -4330,51 +4735,69 @@ def _collect_open_spec_deltas(root: Path) -> list[dict]:
|
|
|
4330
4735
|
return out
|
|
4331
4736
|
|
|
4332
4737
|
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4738
|
+
def _collect_open_spec_deltas(root: Path) -> list[dict]:
|
|
4739
|
+
"""Open SPEC deltas — the release-floor + `deltas` + `status` count source (a thin view)."""
|
|
4740
|
+
return _collect_spec_deltas(root, "open")
|
|
4741
|
+
|
|
4742
|
+
|
|
4743
|
+
def _collect_carried_spec_deltas(root: Path) -> list[dict]:
|
|
4744
|
+
"""Carried (deferred, non-lossy) SPEC deltas — the `deltas --carried` retrieval surface."""
|
|
4745
|
+
return _collect_spec_deltas(root, "carried")
|
|
4746
|
+
|
|
4747
|
+
|
|
4748
|
+
# The FIRST writer of the seeded/dropped/carried statuses (task 1 only TOLERATED them on read).
|
|
4749
|
+
# seed-and-drop's resolution verbs AND delta-drain's carry/reopen all route through here. The token
|
|
4750
|
+
# regex matches ANY current SPEC status, so the flip works in either direction (open->carried,
|
|
4751
|
+
# carried->open) — not only away from open.
|
|
4752
|
+
_SPEC_STATUS_TOKEN_RE = re.compile(r"(\[\s*SPEC\s*·\s*)(?:open|seeded|dropped|carried)(\s*\])")
|
|
4336
4753
|
|
|
4337
4754
|
|
|
4338
4755
|
def _resolve_spec_delta(text: str, new_status: str, pointer: str | None = None,
|
|
4339
|
-
line_index: int | None = None
|
|
4340
|
-
|
|
4756
|
+
line_index: int | None = None, *, from_status: str = "open",
|
|
4757
|
+
stamp: str | None = None) -> str | None:
|
|
4758
|
+
"""Flip ONE `[SPEC · <from_status>]` line in `text` to `new_status`; return the new text.
|
|
4341
4759
|
|
|
4342
4760
|
PURE — no IO. With `line_index` (a splitlines(keepends=True) index, as `_select_spec_delta`
|
|
4343
|
-
returns) flip THAT line; without it, flip the FIRST
|
|
4344
|
-
token changes (+ a trailing ` [→ <pointer>]`
|
|
4345
|
-
|
|
4761
|
+
returns) flip THAT line; without it, flip the FIRST `from_status` delta (back-compat;
|
|
4762
|
+
`from_status` defaults to "open"). Only the status token changes (+ a trailing ` [→ <pointer>]`
|
|
4763
|
+
seed stamp, or a free-form ` <stamp>` e.g. `[carried: <reason>]`); the entry's text and
|
|
4764
|
+
`(evidence: …)` are byte-preserved. Returns None when there is NO matching delta to flip —
|
|
4346
4765
|
the caller then refuses and writes nothing. Mirrors the `_autonomy_decl_line` pure-transform."""
|
|
4347
4766
|
lines = text.splitlines(keepends=True)
|
|
4348
4767
|
target = line_index
|
|
4349
|
-
if target is None: # back-compat: the FIRST
|
|
4768
|
+
if target is None: # back-compat: the FIRST from_status delta
|
|
4350
4769
|
for i, ln in enumerate(lines):
|
|
4351
4770
|
m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
|
|
4352
|
-
if m and m.group(2) ==
|
|
4771
|
+
if m and m.group(2) == from_status:
|
|
4353
4772
|
target = i
|
|
4354
4773
|
break
|
|
4355
4774
|
if target is None:
|
|
4356
4775
|
return None
|
|
4357
4776
|
ln = lines[target]
|
|
4358
4777
|
eol = ln[len(ln.rstrip("\n")):] # preserve the exact line ending
|
|
4359
|
-
body =
|
|
4778
|
+
body = _SPEC_STATUS_TOKEN_RE.sub(rf"\g<1>{new_status}\g<2>", ln.rstrip("\n"), count=1)
|
|
4360
4779
|
if pointer:
|
|
4361
4780
|
body = f"{body} [→ {pointer}]"
|
|
4781
|
+
if stamp:
|
|
4782
|
+
body = f"{body} {stamp}"
|
|
4362
4783
|
lines[target] = body + eol
|
|
4363
4784
|
return "".join(lines)
|
|
4364
4785
|
|
|
4365
4786
|
|
|
4366
|
-
def _select_spec_delta(text: str, match: str | None = None
|
|
4367
|
-
|
|
4787
|
+
def _select_spec_delta(text: str, match: str | None = None,
|
|
4788
|
+
status: str = "open") -> tuple[str, int | None, str | None]:
|
|
4789
|
+
"""Pick the SPEC delta (of `status`, default "open") to resolve (delta-match-selector). PURE.
|
|
4368
4790
|
|
|
4369
|
-
`match=None` -> the FIRST
|
|
4370
|
-
|
|
4371
|
-
(
|
|
4372
|
-
"no_open" (
|
|
4373
|
-
line_index is a splitlines(keepends=True) index, the same `_resolve_spec_delta` flips.
|
|
4791
|
+
`match=None` -> the FIRST such delta. `match=<substr>` -> the UNIQUE one whose display text
|
|
4792
|
+
(status token + `(evidence: …)` excluded) contains <substr>, case-insensitive. Returns
|
|
4793
|
+
(result, line_index, display_text) where result is one of: "ok" (line_index/display set),
|
|
4794
|
+
"no_open" (none of `status` at all), "no_match" (--match hit zero), "ambiguous" (--match hit >1).
|
|
4795
|
+
line_index is a splitlines(keepends=True) index, the same `_resolve_spec_delta` flips. (The
|
|
4796
|
+
"no_open" token is status-agnostic; the carried-track caller maps it to `no_carried_spec_delta`.)"""
|
|
4374
4797
|
opens: list[tuple[int, str]] = []
|
|
4375
4798
|
for i, ln in enumerate(text.splitlines(keepends=True)):
|
|
4376
4799
|
m = _SPEC_DELTA_RE.match(ln.rstrip("\n"))
|
|
4377
|
-
if not m or m.group(2) !=
|
|
4800
|
+
if not m or m.group(2) != status:
|
|
4378
4801
|
continue
|
|
4379
4802
|
tail = m.group(3).strip()
|
|
4380
4803
|
cut = tail.find("(evidence:") # exclude the evidence tail even if its paren is unclosed
|
|
@@ -4660,24 +5083,96 @@ def _audit_findings(root: Path, state: dict) -> tuple[int, list[dict]]:
|
|
|
4660
5083
|
for k in ("owner", "ticket", "expires")):
|
|
4661
5084
|
f(slug, "waiver_incomplete",
|
|
4662
5085
|
"RISK-ACCEPTED needs owner · ticket · expires")
|
|
5086
|
+
# adr-at-observe: a gated/done task whose §7 Decisions (ADR) block STILL holds its bare
|
|
5087
|
+
# <harvested…> placeholder never harvested its decision record. GRANDFATHER (INV-1): a §7
|
|
5088
|
+
# with NO block is legacy → skipped. BARE-LINE probe (INV-2, the same regex _stamp_adr_record
|
|
5089
|
+
# writes through) → harvested prose that merely quotes "<harvested at done" is not a false hit.
|
|
5090
|
+
s7 = raw.get(7, "")
|
|
5091
|
+
if "### Decisions (ADR)" in s7 and re.search(r"(?m)^<harvested at done[^\n]*>$", s7):
|
|
5092
|
+
f(slug, "adr_record_missing",
|
|
5093
|
+
"§7 Decisions (ADR) block still holds its <harvested…> placeholder (never harvested at gate)")
|
|
4663
5094
|
return checked, findings
|
|
4664
5095
|
|
|
4665
5096
|
|
|
5097
|
+
def _freeze_skip_notices(state: dict) -> list[dict]:
|
|
5098
|
+
"""The recorded `--skip-freeze` crossings (freeze-gate-universal): tasks that crossed
|
|
5099
|
+
tests->build on a DRAFT §3 via the explicit escape. SURFACED by `add.py audit` so no skip is
|
|
5100
|
+
silent — but NOT a finding: a recorded, sanctioned bypass never fails CI; the human judges it.
|
|
5101
|
+
PURE — reads only."""
|
|
5102
|
+
out = []
|
|
5103
|
+
for slug in sorted(state.get("tasks") or {}):
|
|
5104
|
+
mark = (state["tasks"][slug] or {}).get("freeze_skipped")
|
|
5105
|
+
if isinstance(mark, dict):
|
|
5106
|
+
out.append({"task": slug, "by": mark.get("by", "?"),
|
|
5107
|
+
"at": mark.get("at", "?"), "from_phase": mark.get("from_phase", "?")})
|
|
5108
|
+
return out
|
|
5109
|
+
|
|
5110
|
+
|
|
5111
|
+
# Any `risk:` declaration in the header (high|normal|low|…) — read from the `·`-delimited header
|
|
5112
|
+
# region only (mirrors _RISK_HIGH_RE's anchoring so a title substring can never look like one).
|
|
5113
|
+
_RISK_ANY_RE = re.compile(r"(?:^|·)[ \t]*risk:[ \t]*\S", re.MULTILINE)
|
|
5114
|
+
|
|
5115
|
+
|
|
5116
|
+
def _guarantee_lint_notices(root: Path, state: dict) -> dict:
|
|
5117
|
+
"""PRESENCE-ONLY, MEASURE-NOT-BLOCK lints SURFACED (never failed-on) by `add.py audit`
|
|
5118
|
+
(guarantee-audit-lints). For tasks that reached verify (phase ∈ {verify, observe, done}):
|
|
5119
|
+
shallow[] = §6 '### Deep checks' block present-but-unfilled (_section_unfilled; an
|
|
5120
|
+
ABSENT block grandfathers a legacy task — never retro-flagged);
|
|
5121
|
+
risk_unset[] = the header carries NO `risk:` token (an undeclared risk level at verify);
|
|
5122
|
+
refute_unrecorded[]= §6 '### Refute-read verdict' block present-but-unfilled (self-grading-
|
|
5123
|
+
refute-record, M4) — the earned-green verdict the AI must record under
|
|
5124
|
+
`auto`; ABSENT block grandfathers exactly like shallow. MEASURE-NOT-BLOCK:
|
|
5125
|
+
never auto-blocks a gate, only surfaced here for review + a human spot-audit.
|
|
5126
|
+
Honest visibility for three verify guarantees; NEVER a finding (audit stays exit 0). PURE — reads
|
|
5127
|
+
TASK.md + state only, writes nothing."""
|
|
5128
|
+
shallow, risk_unset, refute_unrecorded = [], [], []
|
|
5129
|
+
for slug in sorted(state.get("tasks") or {}):
|
|
5130
|
+
if (state["tasks"][slug] or {}).get("phase") not in ("verify", "observe", "done"):
|
|
5131
|
+
continue
|
|
5132
|
+
body6 = _raw_phase_bodies(root, slug).get(6, "")
|
|
5133
|
+
if _section_unfilled(body6, "### Deep checks"):
|
|
5134
|
+
shallow.append(slug)
|
|
5135
|
+
if _section_unfilled(body6, "### Refute-read verdict"):
|
|
5136
|
+
refute_unrecorded.append(slug)
|
|
5137
|
+
if not _RISK_ANY_RE.search(_task_header(root, slug)):
|
|
5138
|
+
risk_unset.append(slug)
|
|
5139
|
+
return {"shallow": shallow, "risk_unset": risk_unset, "refute_unrecorded": refute_unrecorded}
|
|
5140
|
+
|
|
5141
|
+
|
|
4666
5142
|
def cmd_audit(args: argparse.Namespace) -> None:
|
|
4667
5143
|
"""Read-only: audit recorded human decision points for well-formedness. Exit 0 clean,
|
|
4668
|
-
exit 1 with findings — the enforcement gate CI consumes (audit-ci).
|
|
4669
|
-
|
|
5144
|
+
exit 1 with findings — the enforcement gate CI consumes (audit-ci). Also SURFACES (never
|
|
5145
|
+
fails on) recorded `--skip-freeze` crossings, so a skipped freeze is visible in review.
|
|
5146
|
+
Writes NOTHING; every other command is byte-identical."""
|
|
4670
5147
|
root = _require_root()
|
|
4671
|
-
|
|
5148
|
+
state = load_state(root)
|
|
5149
|
+
checked, findings = _audit_findings(root, state)
|
|
5150
|
+
skips = _freeze_skip_notices(state)
|
|
5151
|
+
glints = _guarantee_lint_notices(root, state)
|
|
4672
5152
|
if getattr(args, "json", False):
|
|
4673
|
-
print(json.dumps({"checked": checked, "findings": findings
|
|
4674
|
-
|
|
5153
|
+
print(json.dumps({"checked": checked, "findings": findings, "freeze_skips": skips,
|
|
5154
|
+
"guarantee_lints": glints}, ensure_ascii=False, indent=2))
|
|
4675
5155
|
else:
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
5156
|
+
for x in findings:
|
|
5157
|
+
print(f"audit: {x['code']} {x['task']} — {x['detail']}")
|
|
5158
|
+
for s in skips:
|
|
5159
|
+
print(f"audit: freeze_skipped {s['task']} — crossed tests->build with a DRAFT §3 "
|
|
5160
|
+
f"(by {s['by']} at {s['at']})")
|
|
5161
|
+
for slug in glints["shallow"]:
|
|
5162
|
+
print(f"audit: shallow_deep_check {slug} — §6 Deep-checks block unfilled "
|
|
5163
|
+
f"(a shallow verify, not a pass)")
|
|
5164
|
+
if glints["risk_unset"]:
|
|
5165
|
+
rs = glints["risk_unset"]
|
|
5166
|
+
print(f"audit: risk_unset — {len(rs)} task(s) reached verify with no risk: "
|
|
5167
|
+
f"declaration: {', '.join(rs)}")
|
|
5168
|
+
if glints["refute_unrecorded"]:
|
|
5169
|
+
ru = glints["refute_unrecorded"]
|
|
5170
|
+
print(f"audit: refute_unrecorded — {len(ru)} task(s): {', '.join(ru)} "
|
|
5171
|
+
f"— record the earned-green refute verdict (§6); a spot-audit is the backstop")
|
|
5172
|
+
if not findings and not skips and not glints["shallow"] and not glints["risk_unset"] \
|
|
5173
|
+
and not glints["refute_unrecorded"]:
|
|
4680
5174
|
print(f"audit: clean ({checked} tasks checked)")
|
|
5175
|
+
# MEASURE-NOT-BLOCK: only real findings raise the exit code; notices never do.
|
|
4681
5176
|
if findings:
|
|
4682
5177
|
sys.exit(1)
|
|
4683
5178
|
|
|
@@ -4946,6 +5441,15 @@ def release_data(root: Path, state: dict) -> dict:
|
|
|
4946
5441
|
# loose — done milestone-free tasks not yet attributed (the cut's loose bundle, peer to releasable)
|
|
4947
5442
|
loose = _releasable_loose_tasks(root, state)
|
|
4948
5443
|
|
|
5444
|
+
# open_spec_deltas — unresolved SPEC deltas riding into the cut (the forceable floor's count
|
|
5445
|
+
# source; one record-set so the floor + release-report can never disagree). GATHER, never judge.
|
|
5446
|
+
# LIVE-only: a SPEC delta blocks the cut only while its task is in active state. Archived tasks
|
|
5447
|
+
# are PASS-done history (their deltas stay preserved + visible PROJECT-WIDE in `add.py deltas` /
|
|
5448
|
+
# the `status` cue, but never block a fresh release — they cannot be cleared by the live-scoped
|
|
5449
|
+
# carry/drop verbs). This makes the floor count == the set the CLI can actually drain.
|
|
5450
|
+
live_tasks = state.get("tasks") or {}
|
|
5451
|
+
open_deltas = [d for d in _collect_open_spec_deltas(root) if d["task"] in live_tasks]
|
|
5452
|
+
|
|
4949
5453
|
return {
|
|
4950
5454
|
"releasable": releasable,
|
|
4951
5455
|
"changed": changed,
|
|
@@ -4953,9 +5457,12 @@ def release_data(root: Path, state: dict) -> dict:
|
|
|
4953
5457
|
"blockers": blockers,
|
|
4954
5458
|
"monitors": monitors,
|
|
4955
5459
|
"loose": loose,
|
|
5460
|
+
"open_spec_deltas": {"count": len(open_deltas),
|
|
5461
|
+
"items": [{"task": d["task"], "text": d["text"]} for d in open_deltas]},
|
|
4956
5462
|
"summary": {
|
|
4957
5463
|
"releasable": len(releasable), "changed": len(changed), "waivers": len(waivers),
|
|
4958
5464
|
"blockers": len(blockers), "monitors": len(monitors), "loose": len(loose),
|
|
5465
|
+
"open_spec_deltas": len(open_deltas),
|
|
4959
5466
|
},
|
|
4960
5467
|
}
|
|
4961
5468
|
|
|
@@ -5031,8 +5538,8 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
5031
5538
|
"never shipped. Resolve it (a change request back to Specify) before releasing. "
|
|
5032
5539
|
"--force does NOT override this.")
|
|
5033
5540
|
if not forced and _build_in_flight(state):
|
|
5034
|
-
_die("
|
|
5035
|
-
"gate it first, or pass --force to override.")
|
|
5541
|
+
_die("release_build_in_flight: a build is in flight without a recorded green gate — finish "
|
|
5542
|
+
"and gate it first, or pass --force to override.")
|
|
5036
5543
|
bundle = _releasable(root, state)
|
|
5037
5544
|
loose_bundle = _releasable_loose_tasks(root, state)
|
|
5038
5545
|
if not forced and not bundle and not loose_bundle:
|
|
@@ -5042,6 +5549,12 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
5042
5549
|
if not forced and d["waivers"] and not disclosed:
|
|
5043
5550
|
_die("release_undisclosed_waiver: a RISK-ACCEPTED waiver rides into this release — pass "
|
|
5044
5551
|
"--with-waivers to disclose it in the notes, or --force to override.")
|
|
5552
|
+
open_spec = d["open_spec_deltas"]["count"]
|
|
5553
|
+
if not forced and open_spec > 0:
|
|
5554
|
+
_die(f"release_open_spec_deltas: {open_spec} open SPEC delta(s) unresolved — drain them "
|
|
5555
|
+
"first (carry-delta / new-task --from-delta / drop-delta; see `add.py deltas`), or "
|
|
5556
|
+
"pass --force to cut anyway (they ride into the release unresolved). Unlike "
|
|
5557
|
+
"release_security_open, this floor IS forceable.")
|
|
5045
5558
|
|
|
5046
5559
|
# ── RECORD — build both contents in memory, then write CHANGELOG, then RELEASES (commit) ──
|
|
5047
5560
|
day = date.today().isoformat()
|
|
@@ -5076,16 +5589,22 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
5076
5589
|
|
|
5077
5590
|
|
|
5078
5591
|
def cmd_deltas(args: argparse.Namespace) -> None:
|
|
5079
|
-
"""Read-only: report open competency lessons AND open SPEC deltas, SEPARATELY
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5592
|
+
"""Read-only: report open competency lessons AND open SPEC deltas, SEPARATELY — plus, with
|
|
5593
|
+
`--carried`/`--all`, the carried (deferred, non-lossy) SPEC deltas as a RETRIEVAL surface.
|
|
5594
|
+
|
|
5595
|
+
Scans every .add/tasks/*/TASK.md: '### Competency deltas' → open lessons grouped by competency
|
|
5596
|
+
(DDD·SDD·UDD·TDD·ADD), and '### Spec delta' → open forward hand-offs in their own section (a SPEC
|
|
5597
|
+
delta resolves into a task, never consolidates). `--carried` lists the carried deltas (re-activate
|
|
5598
|
+
via `reopen-delta`); `--all` shows open + carried. Bare output is BYTE-IDENTICAL to before.
|
|
5599
|
+
--json emits one object (carried keys only when requested). Exit 0 ALWAYS. Writes NOTHING."""
|
|
5085
5600
|
root = _require_root()
|
|
5086
5601
|
by_comp = _collect_open_deltas(root)
|
|
5087
5602
|
total = sum(len(v) for v in by_comp.values())
|
|
5088
5603
|
spec = _collect_open_spec_deltas(root)
|
|
5604
|
+
only_carried = getattr(args, "carried", False)
|
|
5605
|
+
want_carried = only_carried or getattr(args, "all", False)
|
|
5606
|
+
want_open = not only_carried # --carried hides open; --all keeps it
|
|
5607
|
+
carried = _collect_carried_spec_deltas(root) if want_carried else []
|
|
5089
5608
|
|
|
5090
5609
|
if getattr(args, "json", False):
|
|
5091
5610
|
payload: dict = {
|
|
@@ -5094,26 +5613,40 @@ def cmd_deltas(args: argparse.Namespace) -> None:
|
|
|
5094
5613
|
"spec": spec,
|
|
5095
5614
|
"spec_total": len(spec),
|
|
5096
5615
|
}
|
|
5616
|
+
if want_carried:
|
|
5617
|
+
payload["carried"] = carried
|
|
5618
|
+
payload["carried_total"] = len(carried)
|
|
5097
5619
|
print(json.dumps(payload, ensure_ascii=False))
|
|
5098
5620
|
return
|
|
5099
5621
|
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5622
|
+
printed = False
|
|
5623
|
+
if want_open:
|
|
5624
|
+
if total:
|
|
5625
|
+
print(f"open lessons learned ({total} total):")
|
|
5626
|
+
for comp in _COMPETENCY_ORDER:
|
|
5627
|
+
entries = by_comp[comp]
|
|
5628
|
+
if not entries:
|
|
5629
|
+
continue
|
|
5630
|
+
print(f" {comp} ({len(entries)}):")
|
|
5631
|
+
for e in entries:
|
|
5632
|
+
print(f" - {e['text']} [{e['task']}]")
|
|
5633
|
+
printed = True
|
|
5634
|
+
if spec:
|
|
5635
|
+
print(f"open spec deltas ({len(spec)} total):")
|
|
5636
|
+
for e in spec:
|
|
5637
|
+
print(f" - {e['text']} [{e['task']}]")
|
|
5638
|
+
printed = True
|
|
5639
|
+
if want_carried:
|
|
5640
|
+
if carried:
|
|
5641
|
+
print(f"carried spec deltas ({len(carried)} total — reopen via add.py reopen-delta):")
|
|
5642
|
+
for e in carried:
|
|
5112
5643
|
print(f" - {e['text']} [{e['task']}]")
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5644
|
+
printed = True
|
|
5645
|
+
elif only_carried: # --carried alone, nothing carried
|
|
5646
|
+
print("no carried spec deltas.")
|
|
5647
|
+
printed = True
|
|
5648
|
+
if not printed:
|
|
5649
|
+
print("no open deltas.")
|
|
5117
5650
|
|
|
5118
5651
|
|
|
5119
5652
|
def cmd_project(args: argparse.Namespace) -> None:
|
|
@@ -5242,6 +5775,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5242
5775
|
pl.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
|
|
5243
5776
|
pl.set_defaults(func=cmd_lock)
|
|
5244
5777
|
|
|
5778
|
+
pfz = sub.add_parser("freeze",
|
|
5779
|
+
help="freeze a task's §3 contract (the human approval) — stamps "
|
|
5780
|
+
"FROZEN @ vN + a structured actor on the task record")
|
|
5781
|
+
pfz.add_argument("slug", nargs="?", default=None,
|
|
5782
|
+
help="task to freeze (default: the active task)")
|
|
5783
|
+
pfz.add_argument("--by", default=None, help="approver name (default: the resolved actor)")
|
|
5784
|
+
pfz.set_defaults(func=cmd_freeze)
|
|
5785
|
+
|
|
5245
5786
|
pwho = sub.add_parser("whoami",
|
|
5246
5787
|
help="show / set the git-native actor (git config -> OS user; --name to override)")
|
|
5247
5788
|
# --name (set) and --unset (clear) are mutually exclusive — argparse rejects the
|
|
@@ -5295,12 +5836,37 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5295
5836
|
"(case-insensitive) instead of the first")
|
|
5296
5837
|
pdd.set_defaults(func=cmd_drop_delta)
|
|
5297
5838
|
|
|
5839
|
+
pcd = sub.add_parser("carry-delta",
|
|
5840
|
+
help="defer a task's open SPEC delta non-lossily -> [SPEC · carried]")
|
|
5841
|
+
pcd.add_argument("slug", help="task whose open SPEC delta to carry (defer)")
|
|
5842
|
+
pcd.add_argument("--reason", default=None, metavar="TEXT",
|
|
5843
|
+
help="REQUIRED — why it is deferred (the breadcrumb a future loop reads)")
|
|
5844
|
+
grp = pcd.add_mutually_exclusive_group()
|
|
5845
|
+
grp.add_argument("--match", default=None, metavar="SUBSTR",
|
|
5846
|
+
help="target the UNIQUE open SPEC delta whose text contains SUBSTR "
|
|
5847
|
+
"(case-insensitive) instead of the first")
|
|
5848
|
+
grp.add_argument("--all", action="store_true",
|
|
5849
|
+
help="carry EVERY open SPEC delta in the task")
|
|
5850
|
+
pcd.set_defaults(func=cmd_carry_delta)
|
|
5851
|
+
|
|
5852
|
+
prd = sub.add_parser("reopen-delta",
|
|
5853
|
+
help="re-activate a carried SPEC delta -> [SPEC · open]")
|
|
5854
|
+
prd.add_argument("slug", help="task whose carried SPEC delta to reopen")
|
|
5855
|
+
prd.add_argument("--match", default=None, metavar="SUBSTR",
|
|
5856
|
+
help="target the UNIQUE carried SPEC delta whose text contains SUBSTR "
|
|
5857
|
+
"(case-insensitive) instead of the first")
|
|
5858
|
+
prd.set_defaults(func=cmd_reopen_delta)
|
|
5859
|
+
|
|
5298
5860
|
pm = sub.add_parser("new-milestone", help="scaffold a milestone (SDD living doc)")
|
|
5299
5861
|
pm.add_argument("slug")
|
|
5300
5862
|
pm.add_argument("--title", default=None)
|
|
5301
5863
|
pm.add_argument("--goal", default=None, help="one-sentence outcome")
|
|
5302
5864
|
pm.add_argument("--stage", default="mvp", choices=STAGES)
|
|
5303
5865
|
pm.add_argument("--force", action="store_true", help="overwrite MILESTONE.md if present")
|
|
5866
|
+
pm.add_argument("--queued", action="store_true",
|
|
5867
|
+
help="create the milestone QUEUED (status=queued), not active: it is recorded "
|
|
5868
|
+
"and its MILESTONE.md written, but the active focus is unchanged. Promote it "
|
|
5869
|
+
"later with `activate <slug>`. Foundation for roadmap intake (1 active + N queued).")
|
|
5304
5870
|
pm.add_argument("--await-confirm", action="store_true",
|
|
5305
5871
|
help="opt into the confirm-parent gate: seed the milestone unconfirmed so "
|
|
5306
5872
|
"new-task is held until `milestone-confirm` (mirrors `init --await-lock`); "
|
|
@@ -5321,6 +5887,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5321
5887
|
"waves + critical path + advisory tier hint")
|
|
5322
5888
|
pwa.add_argument("--milestone", default=None,
|
|
5323
5889
|
help="milestone slug to schedule (default: the active milestone)")
|
|
5890
|
+
pwa.add_argument("--merge", action="store_true",
|
|
5891
|
+
help="unify the active SET into ONE schedule (cross-milestone deps order, not block)")
|
|
5324
5892
|
pwa.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
5325
5893
|
pwa.set_defaults(func=cmd_waves)
|
|
5326
5894
|
|
|
@@ -5364,10 +5932,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5364
5932
|
pp = sub.add_parser("phase", help="set a task's phase explicitly")
|
|
5365
5933
|
pp.add_argument("phase", choices=PHASES)
|
|
5366
5934
|
pp.add_argument("slug", nargs="?", default=None)
|
|
5935
|
+
pp.add_argument("--skip-freeze", action="store_true",
|
|
5936
|
+
help="cross tests->build on a DRAFT §3, recording an auditable freeze_skipped "
|
|
5937
|
+
"marker (the universal freeze gate's only bypass; never auto-freezes §3)")
|
|
5367
5938
|
pp.set_defaults(func=cmd_phase, _opt_positionals=("slug",))
|
|
5368
5939
|
|
|
5369
5940
|
pa = sub.add_parser("advance", help="move a task to the next phase")
|
|
5370
5941
|
pa.add_argument("slug", nargs="?", default=None)
|
|
5942
|
+
pa.add_argument("--skip-freeze", action="store_true",
|
|
5943
|
+
help="cross tests->build on a DRAFT §3, recording an auditable freeze_skipped "
|
|
5944
|
+
"marker (the universal freeze gate's only bypass; never auto-freezes §3)")
|
|
5371
5945
|
pa.set_defaults(func=cmd_advance, _opt_positionals=("slug",))
|
|
5372
5946
|
|
|
5373
5947
|
pg = sub.add_parser("gate", help="record a verify gate outcome")
|
|
@@ -5441,6 +6015,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5441
6015
|
pmine.add_argument("--actor", default=None, metavar="\"Name <email>\"",
|
|
5442
6016
|
help="inspect another actor's queue instead of your own")
|
|
5443
6017
|
pmine.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
|
|
6018
|
+
pmine.add_argument("--all", action="store_true",
|
|
6019
|
+
help="widen past the active SET: every milestone + loose tasks, not just active")
|
|
5444
6020
|
pmine.set_defaults(func=cmd_mine)
|
|
5445
6021
|
|
|
5446
6022
|
pwv = sub.add_parser("wave-verify",
|
|
@@ -5485,6 +6061,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5485
6061
|
pdt = sub.add_parser("deltas",
|
|
5486
6062
|
help="read-only report: open lessons learned grouped by competency")
|
|
5487
6063
|
pdt.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
6064
|
+
pdt_g = pdt.add_mutually_exclusive_group()
|
|
6065
|
+
pdt_g.add_argument("--carried", action="store_true",
|
|
6066
|
+
help="list the carried (deferred) SPEC deltas instead of the open ones")
|
|
6067
|
+
pdt_g.add_argument("--all", action="store_true",
|
|
6068
|
+
help="list open AND carried SPEC deltas")
|
|
5488
6069
|
pdt.set_defaults(func=cmd_deltas)
|
|
5489
6070
|
|
|
5490
6071
|
pfo = sub.add_parser(_FOLD_VERB,
|