@pilotspace/add 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,68 @@ All notable changes to the ADD method (`@pilotspace/add` on npm,
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.12.0] — 2026-06-26
10
+
11
+ Multi-milestone polish release — the intake/roadmap front grows a real **queue**,
12
+ the parallel-front residuals close, and the UDD loop gains a design-intake beat.
13
+ Backward-compatible throughout; a single-active project sees no behavior change.
14
+
15
+ ### Added (multi-milestone-intake — a request that is several milestones)
16
+ - **`queued` milestone status.** The milestone status enum becomes
17
+ **active · queued · done** (was active · done). A milestone can now exist
18
+ non-active, awaiting promotion — created with `new-milestone --queued` and made
19
+ active with an explicit `activate`. Migration-safe: an old state with no `queued`
20
+ milestones reads byte-identically.
21
+ - **Roadmap intake.** `intake.md` now guides decomposing a request that is *several*
22
+ milestones into a roadmap that creates **all N — 1 active + N−1 queued** — instead
23
+ of only the first. Promotion stays human-gated; the whole set is never auto-activated.
24
+ - **Queued-backlog resume cue.** `status` surfaces the queued backlog (active
25
+ milestone + what's queued next), so a multi-milestone session resumes cleanly. The
26
+ cue is present-only — byte-identical output when nothing is queued.
27
+
28
+ ### Added (multi-active-polish — close the parallel-front residuals)
29
+ - **`waves --merge`** folds the active SET into one cross-milestone DAG and critical
30
+ path (today's `waves` is per-milestone only).
31
+ - **`mine --all`** widens the ownership lens past the active milestones — every
32
+ not-done task you own or are assigned, across all milestones plus loose, with an
33
+ email-OR-name match. Plain `mine` (active-only) stays byte-identical.
34
+ - **`doctor` value-domain checks.** `doctor` now flags a bad `gate` enum
35
+ (∉ {PASS, RISK-ACCEPTED, HARD-STOP}), a bad `phase`, archived inconsistency, or a
36
+ malformed owner/assignee — beyond the existing referential pointers. It **adds
37
+ findings only**; it never auto-fails or retro-reds grandfathered history.
38
+ - **`new-milestone` add-and-focus.** Creating a milestone while one is active now
39
+ **preserves the active SET** (adds the new one and focuses it) instead of replacing
40
+ the set and evicting the others. `--queued` and single-active stay byte-identical.
41
+
42
+ ### Added (udd-design-intake)
43
+ - **`design-intake` beat.** The UDD design loop opens with a new front beat that
44
+ interviews the human on four axes — **FIDELITY · CONCEPT · LAYOUT · VISUAL DESIGN**
45
+ — recorded in `DESIGN.md` before review-capture-confirm. Convention-only (the engine
46
+ never renders); identity values (brand color/type) stay human-owned — surfaced to
47
+ decide, never auto-picked.
48
+
49
+ ### Added / Changed (loose tasks since 1.11.0)
50
+ - **Descriptive-slug nudge (`new-milestone`).** A milestone slug that is a bare
51
+ version number (`v2`, `v1-1`, `1.2` — matched by `^v?\d+([._-]\d+)*$`) prints an
52
+ advisory `note:` suggesting a short **descriptive** name (e.g. `payment-retries`) and
53
+ **still creates the milestone** — never blocks.
54
+ - **Full-ISO `created:` stamp.** MILESTONE.md's `created:` line is the full UTC ISO
55
+ timestamp (`2026-06-26T03:47:28+00:00`); a single `_now()` instant feeds both the
56
+ rendered file and the `state.json` record.
57
+ - **`add.py freeze`** — an engine-stamped human-approval write at the §3 contract
58
+ freeze (DRAFT → FROZEN @ vN with a structured actor line). A security finding remains
59
+ an un-forceable HARD-STOP.
60
+ - **Queued + await-confirm reminder.** A milestone that is both `queued` and
61
+ `--await-confirm` now surfaces its `milestone-confirm` reminder at resume.
62
+ - **Flow docs.** Book ch.02 gains a subsection explaining the milestone-scale
63
+ composition rule — tasks are **listed breadth-first up front** (the DAG), then each
64
+ **specified just-in-time**.
65
+
66
+ This release bundles **3 closed milestones** (`udd-design-intake`,
67
+ `multi-milestone-intake`, `multi-active-polish`) and the loose tasks above since
68
+ 1.11.0. Every milestone was built end-to-end through ADD's own
69
+ spec→tests→build→verify flow.
70
+
9
71
  ## [1.11.0] — 2026-06-26
10
72
 
11
73
  Engine-modularization + audit-hardening release — the 7k-line monolith becomes a
@@ -52,6 +52,16 @@ The shape is deliberate: the human-led steps establish direction, a frozen contr
52
52
 
53
53
  > **What changed in v7 (the diagrams above show the structural flow, which is unchanged).** The *steps* and their order are exactly as drawn — only **who resolves them** moved. The AI now drafts the whole specification bundle (steps 1–4) and a person approves it **once**, at the contract freeze (not a sign-off at each step); and **Verify is auto-gated on evidence** under `autonomy: auto` (the default), escalating security — always a `HARD-STOP` — and other residue to a person. Lower the autonomy level to `conservative` to keep a human at the Verify gate. See [11 Governance](./11-governance.md).
54
54
 
55
+ ## Many features, one at a time — listed up front, specified just-in-time
56
+
57
+ The flow above runs *one* feature. A milestone holds many, and they compose by one rule: **list every task up front, specify each just-in-time.**
58
+
59
+ - **Listed up front (breadth-first).** When a milestone is created it is decomposed **breadth-first** into a task *list* — `slug · depends-on · one line` each. That is the whole plan and its dependency DAG (`add.py waves` views the order and the critical path). The milestone living-doc (`MILESTONE.md`) holds this list plus the shared decisions and the exit criteria, and it stays **thin** — no per-task detail lives there.
60
+ - **Specified just-in-time.** Each listed task runs the full seven-step flow (0 Ground … 6 Verify) only when work reaches it. Its specification bundle — §1–§4 — is written then, **not** bundled for every task before any build begins.
61
+ - **Why just-in-time.** A later task's spec absorbs what earlier tasks' Observe step taught (a sharper contract, a convention that emerged, a delta fed back), and a bundle written too early rots before you arrive at it. This is the same backward-correction principle — Observe → Specify — applied at milestone scale rather than within one feature. So: list all the tasks, then specify each in turn.
62
+
63
+ The sequence is therefore **decompose the milestone → schedule the task DAG → run each task's seven-step flow just-in-time → close the milestone** — breadth planned once, depth earned one task at a time.
64
+
55
65
  ## Why the order is the order
56
66
 
57
67
  Each step produces exactly one artifact, and each artifact is the input to the next step. The order is not a preference; it is a dependency chain.
@@ -49,10 +49,12 @@ the method already names, made explicit as three concerns.
49
49
 
50
50
  When a milestone has screens, UDD is not only a static `DESIGN.md` — it runs a
51
51
  **design-definition loop** that turns the domain into a screen the human has *seen
52
- and confirmed before build*. Its four beats are `review-domain → research-components
53
- → wireframe → render-capture-confirm`: read the domain into screens and regions,
54
- research and reuse components before inventing them, wireframe the structure low-fi,
55
- then render a real screen and **capture** it. That capture is the **design-confirm**
52
+ and confirmed before build*. Its five beats are `design-intake → review-domain →
53
+ research-components → wireframe → render-capture-confirm`: interview the human on the
54
+ four design axes (fidelity · concept · layout · visual design) so the look is directed
55
+ not guessed, read the domain into screens and regions, research and reuse components
56
+ before inventing them, wireframe the structure low-fi, then render a real screen and
57
+ **capture** it. That capture is the **design-confirm**
56
58
  evidence — a real image the person approves *before* implementation, so the build
57
59
  matches the layout instead of discovering it. The book keeps the *why*; the
58
60
  operational recipe (the wireframe format, the token-bound mock, the capture engines)
@@ -84,6 +84,10 @@
84
84
 
85
85
  **Baseline approval** (formerly "the lock-down") — the single human gate ending autonomous setup: an explicit yes that freezes the foundation, first scope, and first contract together; runs as `add.py lock --by <name>`.
86
86
 
87
+ **Queued milestone** — a milestone created non-active: its status is `queued` (the milestone status enum is **active · queued · done**). It is recorded with its `MILESTONE.md` on disk but does not hold the active focus; `add.py new-milestone --queued <slug>` creates it, and `add.py activate <slug>` promotes it queued→active one at a time. A queued milestone is not `done`, so it does not satisfy the graduation gate.
88
+
89
+ **Roadmap** — the multi-milestone intake artifact: when a request decomposes into N>1 milestones of the same line, the AI proposes the ordered roadmap and, on human confirm, creates all N — the first active, the rest **queued** — then promotes each with `activate` as it is started. Distinct from `split_required` (which spans different buckets). Lives in the `add` skill's `intake.md`.
90
+
87
91
  **Scope level** (formerly "altitude") — the granularity a decision lives at, as one ordered ladder of five: **setup/foundation** · **intake** (request → versioned scope) · **the milestone loop** (the task is its inner unit) · **stage graduation** (changes rigor, not version; see **Stage graduation**) · **release** (≥1 closed milestone → a versioned, watched cut; see **Release scope level**). One ⚠-assumption notation is shared across every scope level.
88
92
 
89
93
  **Autonomy level** (formerly "autonomy dial") — the explicit per-task setting (`autonomy: manual | conservative | auto`, an ordered ladder manual < conservative < auto) choosing who resolves Verify: `auto` auto-PASSes on complete evidence, `conservative` keeps a human at the gate, `manual` is the strict floor (the human owns the gate; nothing auto-resolves). A high-risk scope refuses an unguarded `auto` — it must be lowered to `manual` or `conservative`. New tasks seed a visible, overridable `autonomy: auto`; a live task with no level warns (`implicit_autonomy`), a token outside the set is rejected (`unknown_autonomy_level`).
@@ -134,13 +138,15 @@
134
138
 
135
139
  **Newest-first append-only** — every append-only foundation sequence prepends the newest record at the top; the rolled-up settled line anchors at the bottom (the oldest end), so compaction collapses upward.
136
140
 
141
+ **Design intake** — beat 0 of the UDD **design-definition loop**: the agent interviews the human on four design **axes** *before* reading the domain, so the look is directed not guessed. **Fidelity** (lo-fi wireframe · hi-fi mockup · production — how far the design renders), **Concept** (idea · mood · direction), **Layout** (structure · grid · hierarchy), and **Visual design** (color · type · spacing · imagery — surfaced for the human, never auto-picked). Recorded as project defaults in `DESIGN.md`'s `## Design intake` and per-screen overrides in the prototype note. Fidelity is recorded intent, not an engine gate.
142
+
137
143
  **Wireframe** — the Stage-A low-fidelity, *structural* map of one screen: its regions and the component **slots** inside them, derived from `prototypes/<name>.json` *before* any color, type, or spacing — it answers "what goes where", not "what it looks like". Beat 3 of the UDD **design-definition loop**; the low-fi half of the two-stage fidelity that ends in a confirmed capture. See the `udd-wireframe.md` template (`tooling/templates/`, Stage A).
138
144
 
139
145
  **Design mock** — the Stage-B high-fidelity, **self-contained** HTML render of a screen: the `catalog.json` components as a reusable token-bound kit, bound to `tokens.json` and populated with mock data, openable offline and screenshot-able. The human-facing *visible* evidence the human confirms (the frozen `prototypes/<name>.json` tree is its machine-checkable twin). Beat 4's hi-fi artifact; the recipe lives in the `udd-wireframe.md` template (`tooling/templates/`, Stage B).
140
146
 
141
147
  **Capture** — the real rendered image (PNG/SVG) of a design mock: the **design-confirm evidence** artifact. Captures live at `.add/design/captures/<name>.<ext>` (one per prototype) and are attached or mentioned in the feature's `TASK.md`; `@json-render/image` (Satori → PNG/SVG, no browser) is the named default capture engine, otherwise the self-contained mock is screenshot headless. The engine never renders — it only MEASURES presence: `add.py check` raises a never-red `missing_capture` WARN for a prototype with no capture.
142
148
 
143
- **Design-confirm** — the human touchpoint closing the UDD **design-definition loop** (`review-domain → research-components → wireframe → render-capture-confirm`, beat 4 of the `add` skill's `design.md`): approving the captured screen image **before build**, show-before-ask, so the implementation matches the layout the human has already seen instead of discovering it.
149
+ **Design-confirm** — the human touchpoint closing the UDD **design-definition loop** (`design-intake → review-domain → research-components → wireframe → render-capture-confirm`, the final beat of the `add` skill's `design.md`): approving the captured screen image **before build**, show-before-ask, so the implementation matches the layout the human has already seen instead of discovering it.
144
150
 
145
151
  ---
146
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotspace/add",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "ADD (AI-Driven Development). One skill. Eight steps. Five disciplines. Every feature ships through the loop — a minimal, state-tracked Claude Code skill that ships the AIDD book as its trust layer.",
5
5
  "bin": {
6
6
  "add": "bin/cli.js"
@@ -123,8 +123,8 @@ once the human confirms, rewrites `SOUL.md` (the human is the only writer) — `
123
123
  - **Small, low-risk task**, less ceremony → the **fast lane**: `new-task --fast` scaffolds the minimal
124
124
  `TASK.fast.md`, bundle approved in one freeze — `phases/fast-lane.md`. Floor held (frozen contract ·
125
125
  red test · verify gate; `--fast` is freeze-gated under any milestone). Collapse, never skip; opt-in.
126
- - **UI feature** at specify → the **design-definition loop** (UDD): review the domainresearch and
127
- reuse components → wireframe → a real captured screen the human confirms **before** build — `design.md`.
126
+ - **UI feature** at specify → the **design-definition loop** (UDD): intake the design axes review the
127
+ domain → research and reuse components → wireframe → a captured screen the human confirms **before** build — `design.md`.
128
128
  Tool-agnostic; the engine never renders.
129
129
  - Tasks all done but the milestone **goal** unmet → `milestone-done` holds it open; the loop turns open
130
130
  deltas + extras into the next tasks (you propose, the human confirms) until the goal is met — `loop.md`.
@@ -10,14 +10,29 @@ draws from already exists — `tokens.json` (the compact-DTCG dialect, `udd-toke
10
10
  `catalog.json` + `prototypes/<name>.json` (`udd-catalog.md`). This loop fills that foundation
11
11
  for a feature and earns the human's sign-off on the look before build.
12
12
 
13
- ## The loop — four beats
13
+ ## The loop — five beats
14
14
 
15
15
  ```
16
- review-domain → research-components → wireframe → render-capture-confirm
16
+ design-intake → review-domain → research-components → wireframe → render-capture-confirm
17
17
  ```
18
18
 
19
19
  Run the beats in order. Each feeds the next; the last ends at a human design-confirm.
20
20
 
21
+ ### 0 · design-intake
22
+ Before you read the domain, interview the human on **four design axes** — so the look is
23
+ *directed*, not guessed. Ask each, show the options, record the pick:
24
+
25
+ - **FIDELITY** — how far this design goes: *lo-fi wireframe* · *hi-fi mockup* · *production*.
26
+ Recorded **intent** that **informs** how far the later beats render (it is not an engine gate).
27
+ - **CONCEPT** — the design *idea / mood / direction* in a line.
28
+ - **LAYOUT** — the *structure / grid / hierarchy* the screens lean on.
29
+ - **VISUAL DESIGN** — *color · type · spacing · imagery*. **Surface** identity values for the
30
+ human to choose — never auto-pick a brand value (identity stays **human-owned**, `udd-tokens.md`).
31
+
32
+ Record the answers **before** review-domain, at both levels: project **defaults** in DESIGN.md's
33
+ `## Design intake` section; per-screen **overrides** (only the deltas) in the per-feature design
34
+ note (the `prototypes/<name>.json` companion). Show-before-ask — confirm the picks, never assume.
35
+
21
36
  ### 1 · review-domain
22
37
  Start from the **domain**, not a blank canvas. Read the domain model — entities, flows, the
23
38
  ubiquitous language in `PROJECT.md` / `GLOSSARY.md` — and derive **which screens** the feature
@@ -70,6 +85,8 @@ human to decide, never auto-pick a brand value (`udd-tokens.md`).
70
85
  ## The hard rules
71
86
 
72
87
  <constraints>
88
+ - **Intake before domain.** The four axes (FIDELITY · CONCEPT · LAYOUT · VISUAL DESIGN) are
89
+ interviewed and recorded — DESIGN.md defaults + per-screen overrides — before beat 1.
73
90
  - **Domain first.** A screen is derived from the domain (beat 1), never sketched blind.
74
91
  - **Reuse before invent.** Beat 2 checks the catalog first; a new component is a justified,
75
92
  cited exception — never the reflex.
@@ -56,6 +56,22 @@ request, emit ONE of:
56
56
  When confirmed, record the `rationale` in the artifact you create or affect — the new MILESTONE.md
57
57
  goal/body, the new TASK.md, or a note in the affected TASK.md — never in state.json.
58
58
 
59
+ ## Roadmap — a request that is several milestones
60
+
61
+ Some requests decompose into **N>1 milestones of the same line** — a roadmap, not one milestone.
62
+ Don't create only the first and lose the rest. Instead:
63
+
64
+ 1. **Propose** the roadmap — the ordered milestone list, each with a one-line goal. (AI proposes.)
65
+ 2. **Confirm** — the human confirms the roadmap before anything is created. Never auto-create N
66
+ milestones unprompted — the intake floor (`ask_human`) still holds.
67
+ 3. **Create** all N on confirm — the first with `add.py new-milestone <slug>` (active), the rest
68
+ with `add.py new-milestone --queued <slug>` (status `queued`, not focused).
69
+ 4. **Promote** one at a time — as each is started, `add.py activate <slug>` flips it queued→active.
70
+ You work one active milestone at a time; the queue is the agreed backlog, surfaced at resume.
71
+
72
+ This is NOT `split_required`: that code is for a request spanning **different buckets** (propose the
73
+ smallest correctly-sized set). A roadmap is several milestones of the **same line**, created queued.
74
+
59
75
  ## Worked examples (from this project's own history)
60
76
 
61
77
  | request | bucket | rationale |
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): across ALL active milestones, the NOT-done tasks
169
- whose owner OR assignee is `me`. Returns ordered rows {slug, milestone, phase, role} with
170
- role in {owner, assignee, both}, sorted by active-milestone order then slug. PURE · no I/O."""
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 t.get("milestone") not in active_set or _task_done(t):
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)
@@ -461,6 +465,74 @@ def cmd_drop_delta(args: argparse.Namespace) -> None:
461
465
  print(_next_footer(root, state))
462
466
 
463
467
 
468
+ # a §3 still carrying this template placeholder is NOT a drafted contract yet
469
+ _CONTRACT_TEMPLATE_RE = re.compile(r"<METHOD>")
470
+
471
+
472
+ def _next_freeze_version(state: dict, slug: str) -> str:
473
+ """v1 on the first freeze; N+1 of the highest prior freeze version recorded on the
474
+ task's state record on a re-freeze (after a change request). PURE — reads state only."""
475
+ prior = ((state.get("tasks") or {}).get(slug) or {}).get("freeze") or {}
476
+ m = re.fullmatch(r"v(\d+)", str(prior.get("version", "")))
477
+ return f"v{int(m.group(1)) + 1}" if m else "v1"
478
+
479
+
480
+ def cmd_freeze(args: argparse.Namespace) -> None:
481
+ """The §3 contract-freeze write command — the 5th engine-WRITTEN human approval (task
482
+ freeze-actor-stamp), joining lock · gate · milestone-done · release. Flips the target
483
+ task's §3 `Status: DRAFT` -> `FROZEN @ vN — approved by <name>` AND records a structured
484
+ actor on the task's state record (mirrors cmd_lock's `setup.actor`), so the audit trail
485
+ has no hole at freeze. The human RUNS it as their approval — never pre-stamped.
486
+
487
+ validate-then-write: every refusal fires before any write. Writes TASK.md first, then
488
+ state; a crash between degrades to today's legacy text-only freeze (never corrupt state),
489
+ design-for-failure."""
490
+ root = _require_root()
491
+ state = load_state(root)
492
+ raw_slug = getattr(args, "slug", None)
493
+ if not raw_slug and not _active_task(state):
494
+ _die("no_active_task: no task given and no active task is set")
495
+ slug = _resolve_task(state, raw_slug) # unknown slug -> _die
496
+ task_md = root / "tasks" / slug / "TASK.md"
497
+ text = task_md.read_text(encoding="utf-8")
498
+ raw3 = _phase_spans(text).get(3, "")
499
+ phase = (state["tasks"].get(slug) or {}).get("phase", "specify")
500
+ # --- validate (no writes); error precedence: frozen -> not-drafted -> unflagged ---
501
+ if _contract_frozen(raw3):
502
+ _die(f"already_frozen: {slug}'s §3 is already FROZEN — re-freeze only via a change "
503
+ f"request back to SPECIFY")
504
+ if _phase_index(phase) < _phase_index("contract") or _CONTRACT_TEMPLATE_RE.search(raw3):
505
+ _die(f"contract_not_drafted: {slug}'s §3 is not a drafted contract yet — reach the "
506
+ f"`contract` phase and replace the template before freezing")
507
+ if not _flag_well_formed(raw3):
508
+ _die(f"unflagged_freeze: {slug}'s §3 must surface a well-formed lowest-confidence flag "
509
+ f"('Least-sure flag surfaced at freeze:' + a [part] tag) before it freezes")
510
+ # --- write ---
511
+ ver = _next_freeze_version(state, slug)
512
+ who = args.by or identity._actor_stamp(state)["name"]
513
+ # flip the `Status: DRAFT` line WITHIN the §3 region only — a bare `Status: DRAFT` in
514
+ # §1/§2 prose must never be frozen by mistake (refute-read finding). §3 span runs from
515
+ # its `## 3 ·` heading to the next `## `/`---`/EOF (same boundary as _phase_spans).
516
+ h3 = re.search(r"(?m)^##\s*3\s*·.*$", text)
517
+ if not h3:
518
+ _die(f"contract_not_drafted: {slug}'s TASK.md has no §3 CONTRACT section")
519
+ seg_start = h3.end()
520
+ nxt = re.search(r"(?m)^(?:##\s|---\s*$)", text[seg_start:])
521
+ seg_end = seg_start + (nxt.start() if nxt else len(text) - seg_start)
522
+ new_seg, n = re.subn(r"(?m)^(\s*)Status:\s*DRAFT\s*$",
523
+ lambda m: f"{m.group(1)}Status: FROZEN @ {ver} — approved by {who}",
524
+ text[seg_start:seg_end], count=1)
525
+ if n == 0:
526
+ _die(f"contract_not_drafted: {slug}'s §3 has no 'Status: DRAFT' line to freeze")
527
+ new_text = text[:seg_start] + new_seg + text[seg_end:]
528
+ _atomic_write(task_md, new_text) # TASK.md first (audit source of truth)
529
+ state["tasks"][slug]["freeze"] = {"version": ver, "frozen_at": _now(),
530
+ "approved_by": who, "actor": identity._actor_stamp(state)}
531
+ save_state(root, state)
532
+ print(f"froze §3 of {slug} @ {ver} — approved by {who}")
533
+ print(_next_footer(root, state))
534
+
535
+
464
536
  def _parse_deps(raw: str | None) -> list[str]:
465
537
  if not raw:
466
538
  return []
@@ -1303,6 +1375,14 @@ def cmd_status(args: argparse.Namespace) -> None:
1303
1375
  _so = _fmt_actor(_owner_rec) if _owner_rec.get("name") else ""
1304
1376
  _own_frag = f" · owner: {_so}" if _so else ""
1305
1377
  print(f" {_mk} {_m:<20} task={_tk or '(none)'} phase={_ph}{_tag}{_own_frag}")
1378
+ # queued backlog (queue-resume-surface): surface milestones awaiting promotion so a
1379
+ # multi-milestone session resumes cleanly — `active` is what you're on, `queued` is what's
1380
+ # next. ADDITIVE + present-only: silent when zero queued (byte-identical), exactly like the
1381
+ # release/loose/streams cues; reads state, writes nothing, changes no command decision.
1382
+ _queued = [ms for ms, m in milestones.items() if m.get("status") == "queued"]
1383
+ if _queued:
1384
+ print(f"queued : {len(_queued)} milestone(s) next — {', '.join(_queued)}")
1385
+ print(f" promote next: add.py activate {_queued[0]}")
1306
1386
  # surface the active task's autonomy level (task explicit-autonomy-dial) so the human
1307
1387
  # reads the throttle every session; "unset" when no explicit `autonomy:` line is present.
1308
1388
  if active and active in tasks:
@@ -2239,6 +2319,39 @@ def _doctor_findings(root: Path) -> list[str]:
2239
2319
  if m is not None and m not in milestones:
2240
2320
  findings.append(f"task '{slug}' references missing milestone '{m}' — fix: set its "
2241
2321
  "milestone to a real one (or none)")
2322
+ # value-domain checks (doctor-value-checks): gate/phase enum (required) · owner/assignee
2323
+ # shape (optional) · archived consistency. Present-but-invalid + missing-required only;
2324
+ # absent owner/assignee is fine. Pure/total — .get-guarded, isinstance-checked, never raises.
2325
+ for slug, t in tasks.items():
2326
+ if not isinstance(t, dict):
2327
+ continue
2328
+ g = t.get("gate")
2329
+ if g is None:
2330
+ findings.append(f"task '{slug}' is missing its gate — fix: one of {', '.join(GATES)}")
2331
+ elif g not in GATES:
2332
+ findings.append(f"task '{slug}' has invalid gate '{g}' — fix: one of {', '.join(GATES)}")
2333
+ p = t.get("phase")
2334
+ if p is None:
2335
+ findings.append(f"task '{slug}' is missing its phase — fix: one of {', '.join(PHASES)}")
2336
+ elif p not in PHASES:
2337
+ findings.append(f"task '{slug}' has invalid phase '{p}' — fix: one of {', '.join(PHASES)}")
2338
+ for role in ("owner", "assignee"):
2339
+ v = t.get(role)
2340
+ if v is not None and not (isinstance(v, dict) and isinstance(v.get("name"), str) and v.get("name")):
2341
+ findings.append(f"task '{slug}' has a malformed {role} — fix: an actor object "
2342
+ "{name, email, source} or remove it")
2343
+ archived = state.get("archived") if isinstance(state.get("archived"), list) else []
2344
+ for a in archived:
2345
+ if not isinstance(a, dict):
2346
+ continue
2347
+ aslug = a.get("slug")
2348
+ if aslug is not None and aslug in milestones:
2349
+ findings.append(f"archived milestone '{aslug}' is also a live milestone — fix: remove "
2350
+ "the live duplicate or the archived entry")
2351
+ ts = a.get("task_slugs")
2352
+ if isinstance(ts, list) and isinstance(a.get("tasks"), int) and a.get("tasks") != len(ts):
2353
+ findings.append(f"archived milestone '{aslug}' task count {a.get('tasks')} ≠ {len(ts)} "
2354
+ "listed — fix: reconcile its task_slugs")
2242
2355
  return findings
2243
2356
 
2244
2357
 
@@ -2259,25 +2372,29 @@ def cmd_doctor(args: argparse.Namespace) -> None:
2259
2372
 
2260
2373
 
2261
2374
  def cmd_mine(args: argparse.Namespace) -> None:
2262
- """Read-only `add.py mine`: across all active milestones, the not-done tasks owned-by or
2263
- assigned-to the resolved actor (`_whoami`, or `--actor "Name <email>"`). Text or `--json`.
2264
- An empty queue is a plain exit-0 line, not an error. NEVER writes state."""
2375
+ """Read-only `add.py mine`: the not-done tasks owned-by or assigned-to the resolved actor
2376
+ (`_whoami`, or `--actor "Name <email>"`). Default lens is the active SET; `--all` widens it
2377
+ to EVERY milestone plus loose (milestone-less) tasks. Text or `--json`. An empty queue is a
2378
+ plain exit-0 line, not an error. NEVER writes state."""
2265
2379
  root = find_root()
2266
2380
  if root is None:
2267
2381
  _die("no_project")
2268
2382
  state = load_state(root)
2269
2383
  me = identity._parse_actor_arg(args.actor) if getattr(args, "actor", None) else identity._whoami(state)
2270
- rows = _my_work(state, me)
2384
+ scope_all = getattr(args, "all", False)
2385
+ rows = _my_work(state, me, scope_all=scope_all)
2271
2386
  if getattr(args, "json", False):
2272
2387
  print(json.dumps({"actor": me, "tasks": rows}))
2273
2388
  return
2389
+ scope = "all" if scope_all else "active"
2274
2390
  who = _fmt_actor(me) or me.get("name", "you")
2275
2391
  if not rows:
2276
- print(f"mine: no open tasks for {who} across active milestones")
2392
+ print(f"mine: no open tasks for {who} across {scope} milestones")
2277
2393
  return
2278
- print(f"mine: {who} — {len(rows)} open task(s) across active milestones:")
2394
+ print(f"mine: {who} — {len(rows)} open task(s) across {scope} milestones:")
2279
2395
  for r in rows:
2280
- print(f" {r['slug']:<24} [{r['milestone']}] phase={r['phase']} ({r['role']})")
2396
+ loc = f"[{r['milestone']}]" if r["milestone"] else "[loose]"
2397
+ print(f" {r['slug']:<24} {loc} phase={r['phase']} ({r['role']})")
2281
2398
 
2282
2399
 
2283
2400
  # ---------------------------------------------------------------------------
@@ -2424,6 +2541,12 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
2424
2541
  slug = args.slug
2425
2542
  if not slug.replace("-", "").replace("_", "").isalnum():
2426
2543
  _die("bad_slug")
2544
+ # Prefer a short DESCRIPTIVE slug over a bare version (v2, v1-1, 1.2): a descriptive
2545
+ # name keeps the milestones list legible. Advisory only — never blocks (matches the
2546
+ # engine's `note:` convention); a deliberate version slug still creates.
2547
+ if re.match(r"^v?\d+([._-]\d+)*$", slug, re.IGNORECASE):
2548
+ print(f"note: slug '{slug}' looks like a bare version — prefer a short "
2549
+ f"descriptive name (e.g. 'payment-retries'). Creating anyway.")
2427
2550
  state.setdefault("milestones", {})
2428
2551
  mdir = root / "milestones" / slug
2429
2552
  mfile = mdir / MILESTONE_FILE
@@ -2431,17 +2554,24 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
2431
2554
  _die("milestone_exists")
2432
2555
  mdir.mkdir(parents=True, exist_ok=True)
2433
2556
  title = args.title or slug.replace("-", " ").replace("_", " ").title()
2557
+ # One _now() instant feeds BOTH the MILESTONE.md render and the state record, so the
2558
+ # human-facing `created:` is a full ISO timestamp provably equal to state.json.
2559
+ now = _now()
2434
2560
  _atomic_write(mfile, _render_template(
2435
2561
  "MILESTONE.md", title=title, goal=args.goal or "<goal>",
2436
- stage=args.stage, date=date.today().isoformat()))
2562
+ stage=args.stage, date=now))
2437
2563
  # confirm-parent gate (OPT-IN, mirrors `init --await-lock`): `--await-confirm` seeds the
2438
2564
  # milestone UNCONFIRMED so new-task is held until `add.py milestone-confirm`. WITHOUT the flag
2439
2565
  # NO `confirmed` key is written → grandfathered-confirmed → no gate (so the existing engine
2440
2566
  # tests stay byte-green). The guided skill flow passes the flag at the human-review point.
2441
2567
  await_confirm = bool(getattr(args, "await_confirm", False))
2568
+ # --queued (OPT-IN): create the milestone non-active (status=queued) without stealing focus.
2569
+ # The active set is left UNCHANGED so the default path (no flag) stays byte-identical. Promote
2570
+ # later with `activate` (queued→active). Foundation for roadmap intake (1 active + N queued).
2571
+ queued = bool(getattr(args, "queued", False))
2442
2572
  record = {
2443
2573
  "title": title, "goal": args.goal or "", "stage": args.stage,
2444
- "status": "active", "created": _now(), "updated": _now(),
2574
+ "status": "queued" if queued else "active", "created": now, "updated": now,
2445
2575
  }
2446
2576
  if await_confirm:
2447
2577
  # `await_confirm` is the STABLE opt-in marker (set ONLY here, at creation). `confirmed`
@@ -2449,11 +2579,22 @@ def cmd_new_milestone(args: argparse.Namespace) -> None:
2449
2579
  # milestone too, so a later build-entry gate must key on `await_confirm`, not `confirmed`.
2450
2580
  record.update(confirmed=False, confirmed_at=None, confirmed_by=None, await_confirm=True)
2451
2581
  state["milestones"][slug] = record
2452
- _set_active_milestone(state, slug)
2582
+ if not queued:
2583
+ # PRESERVE the active SET (new-milestone-add-focus): ADD this milestone + focus it, rather
2584
+ # than REPLACING the set and evicting the others. Single-active is identical ([] -> [slug]);
2585
+ # a user who already had P active now keeps P active alongside the new primary.
2586
+ _activate_milestone(state, slug)
2453
2587
  save_state(root, state)
2454
2588
  print(f"created milestone '{slug}' -> {mfile}")
2455
- print("active milestone set." + ("" if not await_confirm else
2456
- " (unconfirmedshow the MILESTONE.md, then: add.py milestone-confirm " + slug + ")"))
2589
+ if queued:
2590
+ print(f"queued (not active) promote it with: add.py activate {slug}")
2591
+ # surface the recorded confirm gate for a queued+await_confirm milestone (queued-await-confirm-hint):
2592
+ # additive — prints ONLY when await_confirm, so plain `--queued` output stays byte-identical.
2593
+ if await_confirm:
2594
+ print(f" (unconfirmed — after promote: add.py milestone-confirm {slug})")
2595
+ else:
2596
+ print("active milestone set." + ("" if not await_confirm else
2597
+ " (unconfirmed — show the MILESTONE.md, then: add.py milestone-confirm " + slug + ")"))
2457
2598
  print(_next_footer(root, state)) # converges the old "Decompose it into tasks: …" hint
2458
2599
 
2459
2600
 
@@ -2542,25 +2683,34 @@ def cmd_ready(args: argparse.Namespace) -> None:
2542
2683
 
2543
2684
 
2544
2685
  def _wave_schedule(state: dict, mslug: str) -> dict:
2545
- """Pure, total: derive the DAG schedule for milestone `mslug` from state — never
2546
- mutates, never raises on dict input. Returns one of:
2686
+ """One-element wrapper: a single milestone's schedule is the merge over just [mslug].
2687
+ Output is byte-identical to the historical per-milestone scheduler (the suite is the oracle)."""
2688
+ return _wave_schedule_merged(state, [mslug])
2689
+
2690
+
2691
+ def _wave_schedule_merged(state: dict, mslugs: list[str]) -> dict:
2692
+ """Pure, total: derive ONE DAG schedule over the UNION of open members across `mslugs`
2693
+ (a single-element list is the historical per-milestone case) — never mutates, never raises
2694
+ on dict input. Returns one of:
2547
2695
  {"cycle": [slug, ...]} — unschedulable cycle
2548
2696
  {"waves", "critical_path", "critical_path_len", "tiers", "blocked"} — a schedule
2549
2697
 
2550
2698
  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 this milestone
2552
- forces a later wave. A not-done dep that is NOT an open member (external/unknown)
2553
- is UNSATISFIABLE here -> the task is `blocked`, never scheduled. Critical path is the
2554
- longest chain (most tasks) through the scheduled sub-DAG; ties break by sorted slug.
2555
- Tier is advisory: `top` on the critical path, `mid` elsewhere (scheduled tasks only)."""
2699
+ predicate cmd_ready uses. A not-done dep that is an OPEN MEMBER of ANY target milestone
2700
+ forces a later wave (so a cross-milestone dep ORDERS, it does not block). A not-done dep
2701
+ that is NOT an open member of any target (external/unknown) is UNSATISFIABLE here -> the
2702
+ task is `blocked`, never scheduled. Critical path is the longest chain (most tasks) through
2703
+ the scheduled sub-DAG; ties break by sorted slug. Tier is advisory: `top` on the critical
2704
+ path, `mid` elsewhere (scheduled tasks only)."""
2556
2705
  tasks = state.get("tasks") or {}
2557
2706
  archived = _archived_task_slugs(state)
2707
+ targetset = set(mslugs)
2558
2708
 
2559
2709
  def _ok(d: str) -> bool: # satisfied externally / already done
2560
2710
  return d in archived or (d in tasks and _task_done(tasks[d]))
2561
2711
 
2562
2712
  open_members = {s: t for s, t in tasks.items()
2563
- if t.get("milestone") == mslug and not _task_done(t)}
2713
+ if t.get("milestone") in targetset and not _task_done(t)}
2564
2714
 
2565
2715
  # partition open members into blocked vs schedulable — to a FIXED POINT, so blocking
2566
2716
  # propagates transitively: a task is blocked if any dep is unsatisfiable here, where
@@ -2661,16 +2811,71 @@ def _wave_block_lines(state: dict, mslug: str, sched: dict) -> list[str]:
2661
2811
  return lines
2662
2812
 
2663
2813
 
2814
+ def _wave_block_lines_merged(state: dict, mslugs: list[str], sched: dict) -> list[str]:
2815
+ """The text lines `waves --merge` renders for ONE unified schedule over the milestone SET:
2816
+ a `merged: …` header naming the set + each scheduled task tagged with its `[milestone]` so
2817
+ cross-milestone tasks are unambiguous. Critical-path / tier-hint / blocked lines mirror
2818
+ `_wave_block_lines`."""
2819
+ n = len(mslugs)
2820
+ lines = [f"merged: {' + '.join(mslugs)} ({n} milestone{'s' if n != 1 else ''})"]
2821
+ if not sched["waves"]:
2822
+ if sched["blocked"]:
2823
+ for s in sched["blocked"]:
2824
+ lines.append(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2825
+ else:
2826
+ lines.append("all tasks done — nothing to schedule")
2827
+ return lines
2828
+ scheduled_set = {x for w in sched["waves"] for x in w}
2829
+ for i, wave in enumerate(sched["waves"], start=1):
2830
+ parts = []
2831
+ for s in wave:
2832
+ label = f"{s} [{state['tasks'][s].get('milestone')}]"
2833
+ md = sorted(d for d in (state["tasks"][s].get("depends_on") or [])
2834
+ if d in scheduled_set)
2835
+ parts.append(f"{label} (deps: {', '.join(md)})" if md else label)
2836
+ lines.append(f"wave {i}: {', '.join(parts)}")
2837
+ crit = sched["critical_path"]
2838
+ lines.append(f"critical path: {' → '.join(crit)} ({sched['critical_path_len']} tasks)")
2839
+ tops = [s for s, tier in sched["tiers"].items() if tier == "top"]
2840
+ mids = [s for s, tier in sched["tiers"].items() if tier == "mid"]
2841
+ lines.append(f"tier hint: top → {', '.join(tops)}; mid → {', '.join(mids) or '(none)'}")
2842
+ for s in sched["blocked"]:
2843
+ lines.append(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
2844
+ return lines
2845
+
2846
+
2664
2847
  def cmd_waves(args: argparse.Namespace) -> None:
2665
2848
  """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. Writes nothing; no `next:` footer."""
2849
+ and blocked set. With no --milestone it spans EVERY active milestone (cross-active-waves)
2850
+ as SEPARATE streams; a single target / --milestone renders byte-identically. With --merge it
2851
+ unifies the active SET into ONE schedule so cross-milestone deps order, not block.
2852
+ Writes nothing; no `next:` footer."""
2668
2853
  is_json = getattr(args, "json", False)
2669
2854
  if is_json:
2670
2855
  _, state = _load_state_for_json()
2671
2856
  else:
2672
2857
  state = load_state(_require_root())
2673
2858
  mslug_arg = getattr(args, "milestone", None)
2859
+ if getattr(args, "merge", False):
2860
+ if mslug_arg: # explicit target → a 1-milestone merge (NOT a conflict)
2861
+ if mslug_arg not in (state.get("milestones") or {}):
2862
+ _die(f"unknown_milestone: '{mslug_arg}' is not a milestone in this project")
2863
+ targets = [mslug_arg]
2864
+ else:
2865
+ primary = _active_milestone(state)
2866
+ if not primary:
2867
+ _die("no_active_milestone: no active milestone and no --milestone given")
2868
+ targets = [primary] + [m for m in (state.get("active_milestones") or [])
2869
+ if m != primary]
2870
+ sched = _wave_schedule_merged(state, targets)
2871
+ if "cycle" in sched:
2872
+ _die(f"dependency_cycle: not-done deps form a cycle "
2873
+ f"({' -> '.join(sched['cycle'])}) — no valid schedule")
2874
+ if is_json:
2875
+ print(json.dumps({"merged": targets, **sched}))
2876
+ else:
2877
+ print("\n".join(_wave_block_lines_merged(state, targets, sched)))
2878
+ return
2674
2879
  if mslug_arg:
2675
2880
  targets = [mslug_arg] # explicit single target — unchanged
2676
2881
  else:
@@ -2938,6 +3143,11 @@ def cmd_activate(args: argparse.Namespace) -> None:
2938
3143
  _die("unknown_milestone")
2939
3144
  if state["milestones"][slug].get("status") == "done":
2940
3145
  _die("milestone_done")
3146
+ # PROMOTE a queued milestone: activating it flips queued→active (human-gated promotion —
3147
+ # the chosen verb, reusing `activate` rather than a separate `promote`). An already-active
3148
+ # milestone is just refocused (status unchanged), keeping the default path byte-identical.
3149
+ if state["milestones"][slug].get("status") == "queued":
3150
+ state["milestones"][slug]["status"] = "active"
2941
3151
  _activate_milestone(state, slug)
2942
3152
  save_state(root, state)
2943
3153
  print(f"activated '{slug}' — active: {', '.join(state['active_milestones'])}")
@@ -5242,6 +5452,14 @@ def build_parser() -> argparse.ArgumentParser:
5242
5452
  pl.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
5243
5453
  pl.set_defaults(func=cmd_lock)
5244
5454
 
5455
+ pfz = sub.add_parser("freeze",
5456
+ help="freeze a task's §3 contract (the human approval) — stamps "
5457
+ "FROZEN @ vN + a structured actor on the task record")
5458
+ pfz.add_argument("slug", nargs="?", default=None,
5459
+ help="task to freeze (default: the active task)")
5460
+ pfz.add_argument("--by", default=None, help="approver name (default: the resolved actor)")
5461
+ pfz.set_defaults(func=cmd_freeze)
5462
+
5245
5463
  pwho = sub.add_parser("whoami",
5246
5464
  help="show / set the git-native actor (git config -> OS user; --name to override)")
5247
5465
  # --name (set) and --unset (clear) are mutually exclusive — argparse rejects the
@@ -5301,6 +5519,10 @@ def build_parser() -> argparse.ArgumentParser:
5301
5519
  pm.add_argument("--goal", default=None, help="one-sentence outcome")
5302
5520
  pm.add_argument("--stage", default="mvp", choices=STAGES)
5303
5521
  pm.add_argument("--force", action="store_true", help="overwrite MILESTONE.md if present")
5522
+ pm.add_argument("--queued", action="store_true",
5523
+ help="create the milestone QUEUED (status=queued), not active: it is recorded "
5524
+ "and its MILESTONE.md written, but the active focus is unchanged. Promote it "
5525
+ "later with `activate <slug>`. Foundation for roadmap intake (1 active + N queued).")
5304
5526
  pm.add_argument("--await-confirm", action="store_true",
5305
5527
  help="opt into the confirm-parent gate: seed the milestone unconfirmed so "
5306
5528
  "new-task is held until `milestone-confirm` (mirrors `init --await-lock`); "
@@ -5321,6 +5543,8 @@ def build_parser() -> argparse.ArgumentParser:
5321
5543
  "waves + critical path + advisory tier hint")
5322
5544
  pwa.add_argument("--milestone", default=None,
5323
5545
  help="milestone slug to schedule (default: the active milestone)")
5546
+ pwa.add_argument("--merge", action="store_true",
5547
+ help="unify the active SET into ONE schedule (cross-milestone deps order, not block)")
5324
5548
  pwa.add_argument("--json", action="store_true", help="machine-readable JSON output")
5325
5549
  pwa.set_defaults(func=cmd_waves)
5326
5550
 
@@ -5441,6 +5665,8 @@ def build_parser() -> argparse.ArgumentParser:
5441
5665
  pmine.add_argument("--actor", default=None, metavar="\"Name <email>\"",
5442
5666
  help="inspect another actor's queue instead of your own")
5443
5667
  pmine.add_argument("--json", action="store_true", help="emit one JSON object instead of text")
5668
+ pmine.add_argument("--all", action="store_true",
5669
+ help="widen past the active SET: every milestone + loose tasks, not just active")
5444
5670
  pmine.set_defaults(func=cmd_mine)
5445
5671
 
5446
5672
  pwv = sub.add_parser("wave-verify",
@@ -31,6 +31,17 @@ The design intent the AI drafts UI against — the persona, the one job, the fee
31
31
  - **Design principles** — <!-- 3–5 lines, e.g. "one primary action per screen" · "never hide state" -->
32
32
  - **Accessibility floor** — <!-- e.g. WCAG AA contrast · focus-visible · hit-target ≥ 44px -->
33
33
 
34
+ ## Design intake
35
+
36
+ The per-design picks the `design-intake` beat collects (see the `add` skill's `design.md`).
37
+ These are project **defaults**; a screen records only its **overrides** in its prototype note.
38
+ VISUAL DESIGN draws its color/type from **## Identity** above — don't restate the brand here.
39
+
40
+ - **Fidelity** — <!-- lo-fi wireframe | hi-fi mockup | production — how far this design renders -->
41
+ - **Concept** — <!-- the design idea / mood / direction, in a line -->
42
+ - **Layout** — <!-- the structure / grid / hierarchy the screens lean on -->
43
+ - **Visual design** — <!-- spacing scale · imagery style (color · type come from ## Identity) -->
44
+
34
45
  ## Screens
35
46
 
36
47
  An index of the app's screens — one `design/prototypes/<name>.json` per screen (a flat