mandrel 2.39.0 → 2.40.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.
Files changed (28) hide show
  1. package/.agents/README.md +6 -3
  2. package/.agents/agents/auditor.md +5 -0
  3. package/.agents/docs/SDLC.md +21 -12
  4. package/.agents/instructions.md +17 -16
  5. package/.agents/scripts/audit-to-stories.js +510 -66
  6. package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +39 -0
  7. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
  8. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
  9. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
  10. package/.agents/scripts/lib/label-constants.js +18 -0
  11. package/.agents/scripts/lib/label-taxonomy.js +18 -5
  12. package/.agents/scripts/lib/orchestration/epic-container.js +186 -0
  13. package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
  14. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +320 -0
  15. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +18 -0
  16. package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
  17. package/.agents/scripts/plan-persist.js +39 -1
  18. package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
  19. package/.agents/scripts/resolve-stories.js +42 -2
  20. package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
  21. package/.agents/workflows/audit-to-stories.md +85 -7
  22. package/.agents/workflows/helpers/audit-lens-core.md +24 -4
  23. package/.agents/workflows/helpers/deliver-reference.md +8 -0
  24. package/.agents/workflows/helpers/plan-reference.md +28 -0
  25. package/.agents/workflows/mandrel-deliver.md +47 -43
  26. package/.agents/workflows/mandrel-plan.md +44 -38
  27. package/docs/CHANGELOG.md +16 -0
  28. package/package.json +1 -1
@@ -0,0 +1,169 @@
1
+ # Audit sweep runbook
2
+
3
+ > **Template generated by Mandrel.** Copy it into your own docs tree (e.g.
4
+ > `docs/audit-sweep-runbook.md`), then localise every bracketed placeholder:
5
+ > the cadence, the lens list, who reviews the ledger PR, and the label
6
+ > conventions your repository actually uses. The steps themselves are the
7
+ > contract — the values around them are yours.
8
+ >
9
+ > The workflow this runbook drives is
10
+ > [`/audit-to-stories`](../../workflows/audit-to-stories.md); the CLI it calls
11
+ > is [`audit-to-stories.js`](../../scripts/audit-to-stories.js). Run the CLI
12
+ > with `--help` for the authoritative flag list.
13
+
14
+ ## What this runbook is for
15
+
16
+ A maintenance **sweep** runs the `audit-*` lenses full-scope, folds their
17
+ findings onto the cross-run ledger, and turns what is genuinely new into
18
+ Stories. It is the unattended sibling of an interactive `/audit-to-stories`
19
+ run: no HITL gates, so every judgement call the interactive path asks a human
20
+ has to be settled here instead.
21
+
22
+ | Setting | Value for this repository |
23
+ | --- | --- |
24
+ | Cadence | _e.g. weekly, Sunday 02:00_ |
25
+ | Lenses in scope | _e.g. security, clean-code, quality, dependencies_ |
26
+ | Severity floor | _`delivery.auditToStories.severityFloor`, default `high`_ |
27
+ | Ledger reviewer | _e.g. @your-handle_ |
28
+ | Story triage owner | _e.g. the on-call maintainer_ |
29
+
30
+ ## Step 1 — Run the lenses full-scope
31
+
32
+ Run each `audit-*` workflow with **no** `--paths` and no change-set filter, so
33
+ the whole target-set union is audited rather than whatever a recent branch
34
+ happened to touch. Each lens writes its report to
35
+ `temp/audits/audit-<lens>-results.md`.
36
+
37
+ A sweep scoped to a change set is not a sweep: it re-reports the same recent
38
+ files every cycle and never reaches the code nobody has touched in a year,
39
+ which is exactly where audit findings accumulate.
40
+
41
+ ## Step 2 — Cross-check the severity tally
42
+
43
+ Every lens report ends its executive summary with a machine-readable line:
44
+
45
+ ```text
46
+ Severity tally: Critical 0 / High 3 / Medium 7 / Low 2
47
+ ```
48
+
49
+ The CLI re-counts the `### Finding` blocks it actually parsed and compares them
50
+ to that declared tally. A mismatch — or a missing tally line — means the report
51
+ is not trustworthy: a finding was malformed, a severity did not resolve onto the
52
+ canonical scale, or the lens truncated its own output.
53
+
54
+ `--auto` **fails closed** on any such failure. It exits non-zero having opened
55
+ no Issue and written no ledger, and names the offending report in
56
+ `summary.reportFailures[]`. `--allow-missing-tally` is a `--scan` affordance
57
+ only; `--auto` ignores it by design, because an unattended run has no operator
58
+ to read a warning.
59
+
60
+ **When the sweep goes red here, re-run the lens.** Do not reach for
61
+ `--allow-missing-tally` and do not hand-edit the report to make the numbers
62
+ agree — the tally is the only signal that the parse saw what the lens wrote.
63
+
64
+ ## Step 3 — Dry-run the first cycles
65
+
66
+ Start every new sweep in report-only mode, and stay there until the tallies
67
+ stop surprising you:
68
+
69
+ ```bash
70
+ node .agents/scripts/audit-to-stories.js --auto --dry-run
71
+ ```
72
+
73
+ `--dry-run` performs zero GitHub writes and skips the ledger write, printing
74
+ only the run summary. Read `totals.create` before you let the sweep file
75
+ anything: a first full-scope run over an un-audited repository can propose more
76
+ Stories than your team can triage in a quarter. Raise `--severity` (or
77
+ `delivery.auditToStories.severityFloor`) until the create count is a batch you
78
+ would actually take on, then go live.
79
+
80
+ ## Step 4 — Go live, and persist the ledger
81
+
82
+ ```bash
83
+ node .agents/scripts/audit-to-stories.js --auto --ledger-commit
84
+ ```
85
+
86
+ The cross-run ledger (`baselines/audit-ledger.json`) is **consumer state, not
87
+ scratch output**. It is what lets the next sweep tell a re-detection from a
88
+ fresh finding, and a deliberately-rejected finding from an unseen one. A
89
+ scheduled job normally runs on an ephemeral checkout, so unless the ledger is
90
+ committed back it dies with the clone and every later sweep starts amnesiac —
91
+ re-proposing findings already filed and re-surfacing findings a human already
92
+ rejected.
93
+
94
+ `--ledger-commit` closes that loop. After the run summary has printed, and only
95
+ when the ledger actually changed, it:
96
+
97
+ 1. creates `chore/audit-ledger-<YYYY-MM-DD>` from the current HEAD,
98
+ 2. commits **only** the ledger file, subject
99
+ `chore(audit): reconcile audit ledger <date>`,
100
+ 3. pushes the branch, and
101
+ 4. opens a PR against your base branch.
102
+
103
+ **Auto-merge is never requested.** The ledger records machine-derived lifecycle
104
+ state, so a human glance before it lands is the point — nominate that reviewer
105
+ in the table above. Review the PR for entries flipping to `accepted-risk` or
106
+ `regressed`; those two are the ledger telling you something about your backlog,
107
+ not about itself.
108
+
109
+ Any git or `gh` failure in that sequence is fatal and names the step it broke
110
+ on — but it happens _after_ the summary is printed, so a broken remote never
111
+ costs you the sweep's findings.
112
+
113
+ Without the flag, a run whose ledger changed on a checkout that cannot persist
114
+ it — no `origin` remote, or HEAD parked off the base branch — sets
115
+ `ledger.unpersisted: true` in the summary and warns on stderr naming the file.
116
+ Treat that warning as a red sweep: the findings are fine, but the memory is
117
+ about to be thrown away.
118
+
119
+ ## Step 5 — Enrich before you deliver
120
+
121
+ `--emit-stories` renders `{ title, body, labels }` payloads from audit findings.
122
+ Those bodies are **audit prose**, not delivery-ready Specs: they describe a
123
+ symptom and a recommendation, not a scoped change with acceptance criteria a
124
+ worker can verify against.
125
+
126
+ Do not point `/mandrel-deliver` at a freshly-filed audit Story. Route it through
127
+ `/mandrel-plan` first — the planning pass is where the finding becomes a
128
+ capability slice with a `## Spec`, real `acceptance[]` items and runnable
129
+ `verify[]` lines. Planning is deliberately not automated here: deciding what a
130
+ finding is worth, and how far the fix should reach, is the judgement the sweep
131
+ exists to surface rather than to make.
132
+
133
+ The wiring pass is the exception — it _is_ mechanical and it is **required**.
134
+ After opening the Issues, replay their numbers through `--wire-edges` so the
135
+ cohort's declared ordering exists as `blocked by #N` footers and native
136
+ `blocked_by` relations. An unwired cohort is genuinely unordered, and
137
+ `/mandrel-deliver` will co-dispatch Stories the edges say must follow one
138
+ another.
139
+
140
+ ## Step 6 — Label convention
141
+
142
+ Audit-sourced Stories carry a closed label set, so they can be filtered out of
143
+ (or into) ordinary planning at a glance:
144
+
145
+ | Label | Meaning |
146
+ | --- | --- |
147
+ | `type::story` | Every emitted Story — the sweep never opens Epics or tasks. |
148
+ | `agent::ready` | Filed and available for pickup. |
149
+ | `audit::<lens>` | One per lens represented in the group; a cross-audit merge carries several. |
150
+ | `risk::high` | Added when any finding in the group is Critical. |
151
+
152
+ The lens labels are a **closed taxonomy**: only the canonical `audit::<lens>`
153
+ names are valid, and the filer refuses to emit a label the repository has never
154
+ created. Create them once with `audit-labels-bootstrap.js` before the first live
155
+ sweep — a generated label that does not exist makes every Issue create fail.
156
+
157
+ Do not invent per-finding labels. If you need another axis (a team, a
158
+ component), add it in triage on top of this set rather than teaching the sweep
159
+ to mint labels no taxonomy defines.
160
+
161
+ ## Failure triage
162
+
163
+ | Symptom | Cause | Action |
164
+ | --- | --- | --- |
165
+ | Non-zero exit, `summary.reportFailures[]` populated | A lens report's tally is missing or disagrees with the parse | Re-run that lens; never downgrade with `--allow-missing-tally` |
166
+ | `ledger.unpersisted: true` in the summary | No `origin`, or HEAD off the base branch | Re-run with `--ledger-commit`, or commit the ledger by hand |
167
+ | `--ledger-commit failed at step "..."` | git or `gh` failed at the named step | Fix the remote/auth and re-run; the summary above it is still valid |
168
+ | Same findings re-proposed every cycle | The ledger is not being committed | Adopt Step 4 |
169
+ | `totals.create` far larger than the team can absorb | Severity floor too low for a first full-scope run | Raise `--severity` and re-dry-run |
@@ -44,10 +44,12 @@ They remain read-only emitters of audit reports.
44
44
  ## Phase 1 — Discover & parse
45
45
 
46
46
  Run the CLI in `--scan` mode against the resolved glob. It parses every
47
- `### Finding` block, normalises the fields (`Severity` / `Impact` are
47
+ finding block, normalises the fields (`Severity` / `Impact` are
48
48
  both recognised; `Dimension` / `Category` likewise), and extracts file
49
- paths mentioned in the body. It then stamps each finding with a stable
50
- sha1 fingerprint via the shared
49
+ paths mentioned in the body. A `###` heading that carries no severity axis and
50
+ holds `####` blocks is read as a **grouping header**: its `####` children are
51
+ the findings, and the header itself never becomes one. It then stamps each
52
+ finding with a stable sha1 fingerprint via the shared
51
53
  [`lib/findings/route-finding.js`](../scripts/lib/findings/route-finding.js)
52
54
  helper (`fingerprintFinding`) — the single dedup/route implementation
53
55
  shared with `qa-explore`. The workflow carries **no** separate inline
@@ -64,6 +66,19 @@ The emitted plan envelope carries `findings`, `groups`, `edges`,
64
66
  `classifications`, and `summary`. Subsequent phases consume the file
65
67
  rather than re-parsing the reports.
66
68
 
69
+ **The tally cross-check is automatic.** Every report declares
70
+ `Severity tally: Critical <n> / High <n> / Medium <n> / Low <n>` in its
71
+ Executive Summary; the scan compares that line with what it parsed and carries
72
+ each disagreement on `summary.reportFailures[]` as
73
+ `{ sourceReport, kind, reported, parsed }`. The kinds are `missing-tally` (no
74
+ line), `tally-mismatch` (line and parse disagree), and `unresolved-severity` (a
75
+ finding whose severity did not resolve — dropped from grouping, never filed as
76
+ an `unknown` group). They print to stderr before `--scan` returns its plan, so
77
+ a mis-parsed report is never read as a clean audit: re-run the lens rather than
78
+ file from it. Over older reports predating the mandate,
79
+ `--scan --allow-missing-tally` downgrades **only** `missing-tally` to a
80
+ warning.
81
+
67
82
  ## Phase 2 — HITL: severity gate
68
83
 
69
84
  Read the plan envelope's `summary.tally`. Present the operator with the
@@ -117,6 +132,11 @@ Ask:
117
132
  > default-single policy.
118
133
  > - **Individual standalone Stories** — opens one GitHub Issue per
119
134
  > group directly (no plan ceremony).
135
+ >
136
+ > Either way, if the sweep proposes **more than 2** Stories they are grouped
137
+ > under a **container Epic** by default — a title, a one-paragraph goal and a
138
+ > child checklist, carrying nothing a child does not already carry. Say so if
139
+ > you would rather file them flat.
120
140
 
121
141
  **STOP** until the operator picks.
122
142
 
@@ -133,7 +153,14 @@ node .agents/scripts/audit-to-stories.js --emit-plan-seed \
133
153
  The seed renders the canonical one-pager sections — Problem Statement,
134
154
  Recommended Direction, Key Assumptions (with links to every source
135
155
  report), MVP Scope (the M proposed Stories), Key Files (so `/mandrel-plan`'s
136
- authoring step has concrete anchors), Not Doing.
156
+ authoring step has concrete anchors), Grouping, Not Doing.
157
+
158
+ **Grouping is the container-Epic directive.** Above 2 proposed Stories the
159
+ seed instructs `/mandrel-plan` to group them under one Epic — a sweep is the
160
+ clearest case for a container, since every Story shares a provenance and an
161
+ operator usually delivers them together. It is a directive in the text, not an
162
+ automatic write: Phase 4 above is where an operator declines it. Below the
163
+ threshold the section says so and asks for nothing.
137
164
 
138
165
  Chain into the existing planning entrypoint:
139
166
 
@@ -215,6 +242,14 @@ its footprint guard ignores the shared provenance footers, so an unwired cohort
215
242
  is genuinely unordered and `/mandrel-deliver` will co-dispatch Stories the edges say
216
243
  must follow one another.
217
244
 
245
+ **Preconditions.** The pass writes through the configured provider, so it needs
246
+ `github.owner` **and** `github.repo` in `.agentrc.json` plus working `gh` auth
247
+ (`GH_TOKEN`/`gh auth status`) — the same two things Phase 1's dedup needs. When
248
+ either is missing the command refuses and names which one; fix that and re-run
249
+ the exact command above. Do not transcribe the footers by hand: `/mandrel-deliver`
250
+ reads them, but the native `blocked_by` relations only exist if this pass wrote
251
+ them.
252
+
218
253
  ## Phase 6 — Idempotency (folded into Phase 1 scan)
219
254
 
220
255
  The `--scan` step routes each group's findings through the shared
@@ -289,6 +324,8 @@ summarising the run:
289
324
 
290
325
  When the single-plan path ran, link the Story (or plan-run) the chained
291
326
  `/mandrel-plan` opened. When the Standalone-Stories path ran, list every Issue URL.
327
+ Either way, name the container Epic if one was created — it is the single id
328
+ that delivers the whole sweep (`/mandrel-deliver <epicId>`).
292
329
 
293
330
  ## Constraints
294
331
 
@@ -330,18 +367,59 @@ writing their `temp/audits/audit-*-results.md` reports, then (2) invokes the
330
367
  CLI's **`--auto` mode** over those results:
331
368
 
332
369
  ```bash
333
- node .agents/scripts/audit-to-stories.js --auto [--dry-run] \
370
+ node .agents/scripts/audit-to-stories.js --auto [--dry-run] [--ledger-commit] \
334
371
  [--glob "temp/audits/audit-*-results.md"] [--severity <floor>]
335
372
  ```
336
373
 
374
+ The routine shape is **lenses full-scope → dry-run → live with a ledger PR**:
375
+
376
+ 1. Run the `audit-*` lenses with no `--paths` and no change-set filter. A
377
+ sweep scoped to a change set re-reports the same recent files every cycle
378
+ and never reaches the untouched code where findings accumulate.
379
+ 2. `--auto --dry-run` for the first cycles — zero writes, summary only. Read
380
+ `totals.create` and raise the severity floor until it is a batch the team
381
+ would actually take on.
382
+ 3. `--auto --ledger-commit` once the tallies stop surprising you.
383
+
337
384
  `--auto` runs with **no interactive gates**: it resolves the severity floor
338
385
  from `delivery.auditToStories.severityFloor` (default `high`, overridable with
339
386
  `--severity`), applies the two-stage dedup, reconciles the cross-run ledger,
340
387
  and prints a run-summary JSON (create / skip-open / skip-reoccurring /
341
388
  suppressed-by-ledger tallies, plus the re-detected open Issue numbers an
342
389
  operator may want a "re-detected" comment on). `--dry-run` performs zero GitHub
343
- writes and skips the ledger write, emitting only the summary. The host
344
- scheduler owns the cadence; this workflow owns the routing.
390
+ writes and skips the ledger write, emitting only the summary.
391
+
392
+ `--auto` **fails closed on any `summary.reportFailures[]` entry** (Phase 1): an
393
+ unattended sweep has no operator to read a warning, so a missing or mismatched
394
+ `Severity tally:` line — or a finding whose severity did not resolve — exits
395
+ non-zero having opened no Issue and written no ledger. `--allow-missing-tally`
396
+ is a `--scan` affordance that `--auto` ignores. A red sweep means the report is
397
+ untrustworthy: re-run the lens. The host scheduler owns the cadence; this
398
+ workflow owns the routing.
399
+
400
+ ### The ledger is consumer state — commit it
401
+
402
+ `baselines/audit-ledger.json` is **committed consumer state, not scratch
403
+ output**. A scheduled sweep normally runs on an ephemeral checkout, so unless
404
+ the reconciled ledger is committed back it dies with the clone: every later
405
+ sweep starts amnesiac, re-proposing findings already filed and re-surfacing
406
+ findings a human already rejected.
407
+
408
+ `--ledger-commit` closes that loop. After the summary prints — and only when
409
+ the ledger changed — it creates `chore/audit-ledger-<YYYY-MM-DD>` from HEAD,
410
+ commits **only** the ledger file, pushes it, and opens a PR against
411
+ `project.baseBranch`. **Auto-merge is never requested**: a human glance at the
412
+ `accepted-risk` / `regressed` flips before it lands is the point. A git or `gh`
413
+ failure is fatal and names its step, but only after the summary is printed, so
414
+ a broken remote never costs the operator the run's findings. `--dry-run` skips
415
+ the tail. Without the flag, a changed ledger on a checkout that cannot persist
416
+ it — no `origin`, or HEAD off the base branch — sets `ledger.unpersisted: true`
417
+ in the summary and warns on stderr naming the file.
418
+
419
+ The full sweep procedure — tally cross-check, the ledger PR, the
420
+ enrich-before-deliver step and the label convention — ships as a
421
+ consumer-copyable template at
422
+ [`templates/docs/audit-sweep-runbook.md`](../templates/docs/audit-sweep-runbook.md).
345
423
 
346
424
  ## See also
347
425
 
@@ -51,7 +51,8 @@ path). The report MUST include every section its lens template mandates — writ
51
51
  `_No findings._` rather than omitting a section — and always an
52
52
  `## Executive Summary` and a `## Detailed Findings` section. The Executive
53
53
  Summary carries the self-cross-check `kept <k> / dropped <d>` line
54
- ([below](#self-cross-check)).
54
+ ([below](#self-cross-check)) and, beside it, the machine-readable severity
55
+ tally ([below](#severity-tally)).
55
56
 
56
57
  Every finding under `## Detailed Findings` uses the shared 7-field skeleton
57
58
  below. A lens may **add** fields (e.g. a WCAG success criterion, a CWE ID, a
@@ -64,9 +65,11 @@ drops or renames a shared field.
64
65
  ```markdown
65
66
  ## Executive Summary
66
67
 
67
- [The lens's headline read plus the self-cross-check `kept <k> / dropped <d>`
68
- line. A lens may mandate additional report sections between here and the
69
- findings — its own body names them.]
68
+ [The lens's headline read, the self-cross-check `kept <k> / dropped <d>` line,
69
+ and the severity tally line below. A lens may mandate additional report
70
+ sections between here and the findings — its own body names them.]
71
+
72
+ Severity tally: Critical <n> / High <n> / Medium <n> / Low <n>
70
73
 
71
74
  ## Detailed Findings
72
75
 
@@ -188,6 +191,23 @@ that rests on one of them:
188
191
  A lens that keeps every finding still records `dropped 0` — the line's absence
189
192
  is itself a defect (it means the pass did not run).
190
193
 
194
+ ## Severity tally (mandatory, machine-readable) {#severity-tally}
195
+
196
+ The Executive Summary MUST also carry exactly one tally line, counting the
197
+ findings you kept:
198
+
199
+ ```text
200
+ Severity tally: Critical <n> / High <n> / Medium <n> / Low <n>
201
+ ```
202
+
203
+ Write every bucket, zeros included, and never count `Info` — the scale already
204
+ excludes it from scheduled work. This line is the report's own checksum:
205
+ `audit-to-stories --scan` cross-checks it against the findings its parser
206
+ extracted and names any disagreement as a **report failure**
207
+ (`summary.reportFailures[]`), and `--auto` refuses to file anything from a
208
+ report whose line is missing or wrong. A parse that silently drops findings is
209
+ otherwise indistinguishable from a clean audit.
210
+
191
211
  ## Execution strategy {#execution-strategy}
192
212
 
193
213
  A lens is a self-contained, read-only unit of work — exactly the shape a
@@ -292,6 +292,14 @@ This executes, in order:
292
292
  (files issues when auto-file is on; posts `follow-ups`).
293
293
  - `sibling-coherence` — Spec/Acceptance coherence check across sibling bodies
294
294
  (`plan-run-sibling-coherence`).
295
+ - `epic-close` — closes a container Epic once **every** child Story is
296
+ `agent::done`, as `completed`. This is the only completion cascade v2 has:
297
+ it closes the container and nothing else — no child status roll-up, no label
298
+ inheritance, no reopening. Because linkage is parent→child only, the parent
299
+ is found by scanning open `type::epic` issues, and only an Epic containing
300
+ one of *this run's* Stories is considered, so an unrelated container is
301
+ never swept. An Epic with an outstanding child is reported `pending` and
302
+ left open.
295
303
 
296
304
  A single-Story run skips the epilogue — follow-ups are captured on merge
297
305
  confirm instead (`captureStoryFollowUps`).
@@ -414,6 +414,34 @@ That is the whole point of running it first: a dry run that comes back clean
414
414
  has already paid for every deterministic refusal, so the real persist has
415
415
  nothing left to discover except network failure.
416
416
 
417
+ ## The container Epic (Gate #3, N>2)
418
+
419
+ Above two Stories, `/mandrel-plan` offers to group them under one `type::epic`
420
+ container. Confirmed, persist opens it **after** the Stories — its body embeds
421
+ their issue numbers and its sub-issue edges need their database ids — and
422
+ links every created Story both ways it can: a `- [ ] #N` body checklist and a
423
+ native GitHub sub-issue edge. Both are written because each survives what the
424
+ other does not; the delivery-side reader unions them.
425
+
426
+ What the Epic must never carry: an `agent::*` label (that absence keeps it out
427
+ of the bare `/mandrel-deliver` ready list and outside the `type::story`-scoped
428
+ body lint), a `## Spec`, an `acceptance[]` / `verify[]`, or any path, finding
429
+ or rationale a child does not already hold. It is a container; unique content
430
+ here is content no delivering agent reads.
431
+
432
+ What the **Stories** never gain is an `Epic: #N` footer. Linkage is
433
+ parent→child only, which is exactly why every existing refusal of that footer
434
+ still stands and each Story stays independently deliverable (ADR
435
+ `20260905-5139`).
436
+
437
+ Degradation is deliberate: an unensurable `type::epic` label skips the Epic
438
+ entirely (an unlabelled container is not a container), while a failed
439
+ sub-issue edge only warns — the checklist still lists every child. Either way
440
+ the Stories are untouched and deliver by id. A resumed persist adopts an
441
+ existing Epic carrying the same fingerprint, which is keyed on the title **and
442
+ the exact child set**, so a run grouping different Stories never adopts the
443
+ wrong container.
444
+
417
445
  ## Ready means fully persisted
418
446
 
419
447
  `agent::ready` is the **terminal** step, not part of the creating POST.
@@ -7,8 +7,8 @@ description:
7
7
 
8
8
  # /mandrel-deliver
9
9
 
10
- > **Lean spine.** Happy path + gate list. Sequencing, dispatch mechanics,
11
- > intent phrases, ceremony and the epilogue live in on-demand
10
+ > **Lean spine.** Happy path + gate list. Sequencing, dispatch mechanics, intent
11
+ > phrases, ceremony and the epilogue live in on-demand
12
12
  > [`helpers/deliver-reference.md`](helpers/deliver-reference.md) ("reference"
13
13
  > below); the unplanned path in
14
14
  > [`helpers/deliver-light.md`](helpers/deliver-light.md). Every delivery reads
@@ -21,9 +21,8 @@ close-and-land tail; Stories are implemented via
21
21
  [`helpers/deliver-story.md`](helpers/deliver-story.md).
22
22
 
23
23
  The dependency graph is **discovered, not declared** — `resolve-stories.js`
24
- reads it from live state, so you can deliver Stories **across plan runs and
25
- over time**. `plan-run::<id>` is filter metadata, never a resolution input;
26
- `route::lite` a body-derived hint only.
24
+ reads it from live state, so you can deliver Stories **across plan runs and over
25
+ time**. `plan-run::<id>` is filter metadata, never a resolution input.
27
26
 
28
27
  ## Inputs
29
28
 
@@ -36,25 +35,28 @@ you read:
36
35
  | `/mandrel-deliver 4712` | ids | One Story via `helpers/deliver-story.md`, **inline in this session** — no `story-worker` spawn. |
37
36
  | `/mandrel-deliver 4712 4713 …` | ids | Resolve the set, sequence by the discovered graph via `stories-wave-tick.js`, dispatch sub-agents. |
38
37
  | `/mandrel-deliver 4712 - 4716` | ids | A **range** — every id in the inclusive span. |
38
+ | `/mandrel-deliver 4700` (a `type::epic`) | ids | The Epic's **open** child Stories. Mixes with Story ids. |
39
39
  | `/mandrel-deliver add a --json flag to doctor` | prompt | Unplanned work: gate, author a receipt Story, land it — [`helpers/deliver-light.md`](helpers/deliver-light.md). |
40
40
 
41
41
  **The discriminator is lexical and total.** An argument matching `^#?\d+$` is an
42
42
  id, and `^#?\d+\s*[-–—]\s*#?\d+$` an inclusive **range** — pass one on as a
43
- single unspaced token, never hand-expanded (reference § Ranges). Either shape
43
+ single unspaced token, never hand-expanded (reference). Either shape
44
44
  means ids; anything else means a prompt. A **mixed** invocation (ids *and*
45
- prose) is a **hard error** — refuse it and ask which was meant. A ticket not
46
- `type::story`, or carrying an `Epic: #N` footer, is a hard error too.
45
+ prose) is a **hard error** — refuse it and ask which was meant. A ticket that
46
+ is neither `type::story` nor `type::epic`, or that carries an `Epic: #N`
47
+ footer, is a hard error too — container Epics link parent→child only, so that
48
+ footer stays refused.
47
49
 
48
50
  ## Saying what you want
49
51
 
50
- No flags to remember: state intent — *"…but I'll merge it myself"*, *"…one at
51
- a time"* — and announce what you read. Phrasings and the flags they fill in:
52
+ No flags to remember: state intent — *"…but I'll merge it myself"*, *"…one at a
53
+ time"* — and announce what you read. Phrasings and the flags they fill in:
52
54
  reference § Intent phrases.
53
55
 
54
56
  `--yes` is **runner-set, never operator-typed**: cron, `/loop` and headless
55
57
  dispatch set it to mean *nobody is at the keyboard*, which fails the unplanned
56
- path's over-scope stop closed to an envelope instead of a question. Never offer
57
- it to an operator or add it to an attended run.
58
+ path's over-scope stop closed to an envelope instead of a question. Never add it
59
+ to an attended run.
58
60
 
59
61
  ## Procedure
60
62
 
@@ -66,9 +68,11 @@ it to an operator or add it to an attended run.
66
68
  `node .agents/scripts/resolve-stories.js --ids <id,id,...>`. It validates the
67
69
  set and shows what will run: read `stories[]`, `dag[]` and `done[]` to
68
70
  present the order in step 2, but do **not** thread them into step 3 — the
69
- tick re-resolves the graph every beat. It hard-errors (exit 1) on an id that
70
- is not a Story, carries an `Epic: #N` footer, or whose edges cannot be
71
- read a missing gate would co-dispatch against an unlanded blocker.
71
+ tick re-resolves the graph every beat. An Epic id expands to its open child
72
+ Stories first **announce it**. It hard-errors (exit 1) on an id that is
73
+ neither a Story nor an Epic, on an `Epic: #N` footer, on an Epic with no
74
+ open children, or on edges it cannot read — a missing gate would co-dispatch
75
+ against an unlanded blocker.
72
76
 
73
77
  2. **Confirm (N>1).** Present the order; wait unless `--yes`.
74
78
 
@@ -87,62 +91,62 @@ it to an operator or add it to an attended run.
87
91
  Each beat re-probes live state to derive done / in-flight itself; you never
88
92
  compute them. `--dispatched` is the one thing you must supply — the
89
93
  append-only list of every id you spawned this run. Cross-run de-confliction
90
- via the assignee lease is automatic (reference §§ Sequencing edge cases,
91
- Dispatch mechanics). Branch on the exit code:
94
+ via the assignee lease is automatic. Branch on the exit code:
92
95
  - **0** — dispatch each `ready` id (already capped and overlap-free); an
93
96
  empty `ready` with work in flight means "waiting", so keep looping;
94
97
  `epilogueDue: true` means every Story is done — step 4.
95
98
  - **2 / 3 / 4** — `cycleError` / `wedged` / `blocked`: stop the loop and
96
- route per reference § Sequencing edge cases. **4** is the protocol's HITL
97
- pause ([`instructions.md` § 1.J](../instructions.md)) — surface it and
98
- wait for the operator; never poll.
99
+ route per reference. **4** is the protocol's HITL pause
100
+ ([`instructions.md` § 1.J](../instructions.md)) — surface it and wait for
101
+ the operator; never poll.
99
102
 
100
103
  4. **Close each hand-off** (§ Closing what the workers hand back), then, with
101
104
  every Story landed, run the **per-run epilogue (N>1)**:
102
- `node .agents/scripts/plan-run-epilogue.js --stories 101,102`. N=1 skips it
103
- ([reference § Per-run epilogue](helpers/deliver-reference.md)).
105
+ `node .agents/scripts/plan-run-epilogue.js --stories 101,102`, which also
106
+ closes a container Epic whose children all landed. N=1 skips it
107
+ ([reference](helpers/deliver-reference.md)).
104
108
 
105
109
  5. **Correct what the change invalidated.** If a memory you recalled this
106
- session is now wrong — a trap this landed, a budget it moved — fix that
107
- entry now, while both the old belief and the new fact are in context, and
108
- say so when you report. No memory substrate → skip silently. Sweeping the
109
- whole pool is [`/memory-consolidate`](memory-consolidate.md), not this step.
110
+ session is now wrong — a trap this landed, a budget it moved — fix that entry
111
+ now, while both the old belief and the new fact are in context, and say so
112
+ when you report. No memory substrate → skip silently. Sweeping the whole pool
113
+ is [`/memory-consolidate`](memory-consolidate.md), not this step.
110
114
 
111
115
  ## Closing what the workers hand back {#tail}
112
116
 
113
117
  **The tail is the orchestrator's, not the worker's.** A dispatched
114
118
  `story-worker` stops at a pushed branch and returns a hand-off; **you** run
115
119
  [`helpers/deliver-story.md`](helpers/deliver-story.md) Step 3
116
- (`single-story-close.js`) for it, foreground, and relay the envelope.
120
+ (`single-story-close.js`) for it, foreground, and relay its envelope.
117
121
 
118
- **Serialize the tail.** Implementation runs in parallel; closing does not. Close
119
- one Story at a time — closes contend on the base branch, the merge queue and
120
- the checkout. A worker handing back mid-close waits its turn.
122
+ **Serialize the tail.** Implementation runs in parallel; closing does not.
123
+ Close one Story at a time — closes contend on the base branch, the merge queue
124
+ and the checkout. A worker handing back mid-close waits its turn.
121
125
 
122
- **A worker returning no terminal envelope is the expected shape**, not a failure
123
- to answer with a re-dispatch: only close mints one. Close the pushed branch, or
124
- probe read-only with `node .agents/scripts/deliver-recover.js --story <storyId>`
125
- and resume the worker or close it names.
126
+ **A worker returning no terminal envelope is expected**, not a failure to answer
127
+ with a re-dispatch: only close mints one. Close the pushed branch, or probe
128
+ read-only with `node .agents/scripts/deliver-recover.js --story <storyId>` and
129
+ resume what it names.
126
130
 
127
131
  **Reading the outcome.** Each close ends the Story in one schema-validated
128
132
  envelope — `landed` | `pending` | `blocked` | `failed`; statuses, exits and
129
- fields are digest § 5. `pending` is **not** a failure — `nextCommand` resumes
130
- it; run that, do not re-dispatch.
133
+ fields are digest § 5. `pending` is **not** a failure — run its `nextCommand`.
131
134
 
132
135
  **Branch model (authoritative).** `story-<id>` → PR → `main` (squash +
133
136
  required checks), per digest § 2; dependent Stories land sequentially. Ceremony
134
- depth (profiles + derived level via `ceremony-routing.js`, review depth reading
135
- it): reference § Ceremony.
137
+ depth (profiles + the derived level via `ceremony-routing.js`, which review
138
+ depth reads): reference § Ceremony.
136
139
 
137
140
  ## Constraints
138
141
 
139
142
  - **Land or block — never a silent local build** (digest § 2). Attended delivers
140
143
  default to close-and-land (`delivery.routing.closeAndLand: true`); rest at
141
- `agent::closing` only when a human owns it.
142
- - **`/mandrel-deliver` never plans.** Planned tickets come from [`/mandrel-plan`](mandrel-plan.md), and
143
- an over-scope prompt **escalates and ends** — never invoke `/mandrel-plan` in this
144
- session to rescue it ([`helpers/deliver-light.md`](helpers/deliver-light.md)
145
- § Escalation is terminal).
144
+ `agent::closing` only when a human owns the merge.
145
+ - **`/mandrel-deliver` never plans.** Planned tickets come from
146
+ [`/mandrel-plan`](mandrel-plan.md), and an over-scope prompt **escalates and
147
+ ends** — never invoke `/mandrel-plan` in this session to rescue it
148
+ ([`helpers/deliver-light.md`](helpers/deliver-light.md) § Escalation is
149
+ terminal).
146
150
 
147
151
  ## See also
148
152