mandrel 1.61.0 → 1.63.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 (35) hide show
  1. package/.agents/docs/SDLC.md +10 -3
  2. package/.agents/docs/workflows.md +1 -1
  3. package/.agents/scripts/check-action-pinning.js +260 -0
  4. package/.agents/scripts/check-arch-cycles.js +38 -14
  5. package/.agents/scripts/epic-deliver-prepare.js +149 -104
  6. package/.agents/scripts/lib/baseline-snapshot.js +245 -141
  7. package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
  8. package/.agents/scripts/lib/orchestration/code-review.js +206 -168
  9. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
  10. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
  11. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
  12. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
  13. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
  14. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
  15. package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
  16. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
  17. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
  18. package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
  19. package/.agents/scripts/lib/signals/detectors/common.js +107 -0
  20. package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
  21. package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
  22. package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
  23. package/.agents/scripts/lib/story-body/story-body.js +102 -76
  24. package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
  25. package/.agents/scripts/single-story-init.js +16 -3
  26. package/.agents/workflows/audit-architecture.md +9 -0
  27. package/.agents/workflows/deliver.md +87 -26
  28. package/.agents/workflows/helpers/deliver-epic.md +12 -5
  29. package/.agents/workflows/helpers/deliver-stories.md +13 -7
  30. package/.agents/workflows/plan.md +3 -1
  31. package/README.md +1 -1
  32. package/docs/CHANGELOG.md +40 -0
  33. package/lib/cli/registry.js +1 -1
  34. package/lib/cli/update.js +114 -8
  35. package/package.json +1 -1
@@ -355,6 +355,96 @@ function splitSections(markdown) {
355
355
  return { sections, footer, preamble };
356
356
  }
357
357
 
358
+ // ---------------------------------------------------------------------------
359
+ // Parser — per-section sub-parsers
360
+ // ---------------------------------------------------------------------------
361
+
362
+ /**
363
+ * Build the minimal {@link ParseResult} returned for a legacy string body —
364
+ * markdown that carries no recognised structured section. The goal falls
365
+ * back to the preamble text (or the whole trimmed input), `depends_on` is
366
+ * still recovered from the footer, and all section arrays are empty.
367
+ *
368
+ * @param {string} input - The original markdown string.
369
+ * @param {string} preamble - Text before the first heading (from splitSections).
370
+ * @param {string} footer - Footer block text (from splitSections).
371
+ * @returns {ParseResult}
372
+ */
373
+ function parseLegacyStringBody(input, preamble, footer) {
374
+ const warnings = [
375
+ 'legacy-string-body: no structured sections found; returning minimal body from preamble text.',
376
+ 'test-surface-unestimated: estimated_test_files not present.',
377
+ ];
378
+ const body = {
379
+ goal: preamble || input.trim(),
380
+ changes: [],
381
+ acceptance: [],
382
+ verify: [],
383
+ references: [],
384
+ wide: null,
385
+ depends_on: extractBlockedBy(footer),
386
+ estimated_test_files: null,
387
+ };
388
+ return {
389
+ body,
390
+ warnings,
391
+ info: {
392
+ hasGoalSection: false,
393
+ hasChangesSection: false,
394
+ hasAcceptanceSection: false,
395
+ hasVerifySection: false,
396
+ hasReferencesSection: false,
397
+ isLegacyStringBody: true,
398
+ },
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Parse the `## Goal` section: join its non-empty content lines into a
404
+ * single one-line goal string.
405
+ *
406
+ * @param {string[]} lines - Raw content lines under the heading.
407
+ * @returns {string}
408
+ */
409
+ function parseGoalSection(lines) {
410
+ return lines
411
+ .map((l) => l.trim())
412
+ .filter(Boolean)
413
+ .join(' ');
414
+ }
415
+
416
+ /**
417
+ * Parse a `## Changes` / `## References` section into a list of
418
+ * `PathEntry | string` entries. List markers are stripped, blank entries are
419
+ * dropped, and each surviving entry is normalized via {@link parsePathEntry}
420
+ * (which appends `legacy-path-entry` warnings for bare-string bullets).
421
+ *
422
+ * @param {string[]} lines - Raw content lines under the heading.
423
+ * @param {string[]} warnings - Mutable warnings sink.
424
+ * @returns {Array<PathEntry|string>}
425
+ */
426
+ function parsePathEntrySection(lines, warnings) {
427
+ const entries = [];
428
+ for (const line of lines) {
429
+ const stripped = stripListMarker(line);
430
+ if (!stripped) continue;
431
+ const entry = parsePathEntry(stripped, warnings);
432
+ if (entry !== null) entries.push(entry);
433
+ }
434
+ return entries;
435
+ }
436
+
437
+ /**
438
+ * Parse a plain bullet-list section (`## Acceptance` / `## Verify`) into a
439
+ * list of trimmed strings, dropping blank entries.
440
+ *
441
+ * @param {string[]} lines - Raw content lines under the heading.
442
+ * @returns {string[]}
443
+ */
444
+ function parseTextListSection(lines) {
445
+ return lines.map((l) => stripListMarker(l)).filter(Boolean);
446
+ }
447
+
358
448
  // ---------------------------------------------------------------------------
359
449
  // Parser
360
450
  // ---------------------------------------------------------------------------
@@ -414,84 +504,20 @@ export function parse(input) {
414
504
  !hasVerifySection;
415
505
 
416
506
  if (isLegacyStringBody) {
417
- // Extract depends_on from footer even for legacy bodies.
418
- const dependsOn = extractBlockedBy(footer);
419
- warnings.push(
420
- 'legacy-string-body: no structured sections found; returning minimal body from preamble text.',
421
- );
422
- warnings.push(
423
- 'test-surface-unestimated: estimated_test_files not present.',
424
- );
425
- const body = {
426
- goal: preamble || input.trim(),
427
- changes: [],
428
- acceptance: [],
429
- verify: [],
430
- references: [],
431
- wide: null,
432
- depends_on: dependsOn,
433
- estimated_test_files: null,
434
- };
435
- return {
436
- body,
437
- warnings,
438
- info: {
439
- hasGoalSection: false,
440
- hasChangesSection: false,
441
- hasAcceptanceSection: false,
442
- hasVerifySection: false,
443
- hasReferencesSection: false,
444
- isLegacyStringBody: true,
445
- },
446
- };
507
+ return parseLegacyStringBody(input, preamble, footer);
447
508
  }
448
509
 
449
- // --- Parse goal ---
450
- const goalLines = sections.get('goal') ?? [];
451
- const goal = goalLines
452
- .map((l) => l.trim())
453
- .filter(Boolean)
454
- .join(' ');
455
-
456
- // --- Parse changes ---
457
- const changeLines = sections.get('changes') ?? [];
458
- const changes = [];
459
- for (const line of changeLines) {
460
- const stripped = stripListMarker(line);
461
- if (!stripped) continue;
462
- const entry = parsePathEntry(stripped, warnings);
463
- if (entry !== null) changes.push(entry);
464
- }
465
-
466
- // --- Parse acceptance ---
467
- const acceptanceLines = sections.get('acceptance') ?? [];
468
- const acceptance = acceptanceLines
469
- .map((l) => stripListMarker(l))
470
- .filter(Boolean);
471
-
472
- // --- Parse verify ---
473
- const verifyLines = sections.get('verify') ?? [];
474
- const verify = verifyLines.map((l) => stripListMarker(l)).filter(Boolean);
475
-
476
- // --- Parse references (optional) ---
477
- const referenceLines = sections.get('references') ?? [];
478
- const references = [];
479
- for (const line of referenceLines) {
480
- const stripped = stripListMarker(line);
481
- if (!stripped) continue;
482
- const entry = parsePathEntry(stripped, warnings);
483
- if (entry !== null) {
484
- // References MUST be object form (canonical).
485
- if (typeof entry === 'string') {
486
- // Already warned as legacy-path-entry; keep as string for now.
487
- references.push(entry);
488
- } else {
489
- references.push(entry);
490
- }
491
- }
492
- }
493
-
494
- // --- Parse footer ---
510
+ const goal = parseGoalSection(sections.get('goal') ?? []);
511
+ const changes = parsePathEntrySection(
512
+ sections.get('changes') ?? [],
513
+ warnings,
514
+ );
515
+ const acceptance = parseTextListSection(sections.get('acceptance') ?? []);
516
+ const verify = parseTextListSection(sections.get('verify') ?? []);
517
+ const references = parsePathEntrySection(
518
+ sections.get('references') ?? [],
519
+ warnings,
520
+ );
495
521
  const dependsOn = extractBlockedBy(footer);
496
522
 
497
523
  // --- Recover wide / estimated_test_files from the meta block ---
@@ -0,0 +1,252 @@
1
+ /**
2
+ * GitHub Provider — shared "set native blocked-by dependency" helper.
3
+ *
4
+ * Story #4067 — after issue creation during Phase 8 decomposition, each
5
+ * Story's `depends_on` graph is known as a set of sibling slugs. This
6
+ * helper translates those slug-to-issueNumber pairs into native GitHub
7
+ * "blocked by" dependency edges so maintainers can see blocking
8
+ * relationships directly in the GitHub UI.
9
+ *
10
+ * API surface used:
11
+ * Read: GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by
12
+ * Write: POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by
13
+ * body: { "issue_id": <integer db id of the blocking issue> }
14
+ *
15
+ * Contract:
16
+ * - **Idempotent** — reads existing edges first; only POSTs missing ones.
17
+ * - **Non-fatal** — catches all errors per edge, warns, and continues.
18
+ * The overall function never throws; errors are returned in a summary.
19
+ * - **No-op on empty input** — returns immediately when no depends_on
20
+ * edges are present or the slug→issueNumber map is empty.
21
+ */
22
+
23
+ import { Logger } from '../../lib/Logger.js';
24
+ import { concurrentMap } from '../../lib/util/concurrent-map.js';
25
+ import { parseApiJson } from './request-helpers.js';
26
+
27
+ /**
28
+ * Bounded concurrency for the GitHub dependency-edge round-trips. Kept modest
29
+ * to respect GitHub's secondary rate limits while still collapsing the wall-
30
+ * clock latency from `sum(round-trips)` toward `sum(round-trips) / concurrency`.
31
+ */
32
+ const EDGE_CONCURRENCY = 5;
33
+
34
+ /**
35
+ * Fetch the existing blocked-by issue numbers for a given issue.
36
+ *
37
+ * Returns `[]` on any error so the caller falls back to posting the full
38
+ * set of missing edges (worst case: a duplicate POST, which GitHub
39
+ * handles idempotently).
40
+ *
41
+ * @param {{ gh: object, owner: string, repo: string, issueNumber: number }} opts
42
+ * @returns {Promise<number[]>} Database ids of the issues that currently block `issueNumber`.
43
+ */
44
+ async function fetchExistingBlockedBy({ gh, owner, repo, issueNumber }) {
45
+ try {
46
+ const result = await gh.api({
47
+ method: 'GET',
48
+ endpoint: `/repos/${owner}/${repo}/issues/${issueNumber}/dependencies/blocked_by`,
49
+ });
50
+ const data = parseApiJson(result);
51
+ if (!Array.isArray(data)) return [];
52
+ return data.map((item) => item?.id).filter((id) => typeof id === 'number');
53
+ } catch (err) {
54
+ Logger.warn(
55
+ `[blocked-by-add] Could not fetch existing blocked-by for #${issueNumber}: ${err.message}`,
56
+ );
57
+ return [];
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Set native GitHub "blocked by" dependency edges for a single issue.
63
+ *
64
+ * For each entry in `blockerInternalIds`, checks whether the edge already
65
+ * exists and POSTs only the missing ones. Every individual POST failure is
66
+ * caught, logged as a warning, and counted — the function never throws.
67
+ *
68
+ * @param {{
69
+ * gh: object,
70
+ * owner: string,
71
+ * repo: string,
72
+ * issueNumber: number,
73
+ * blockerInternalIds: number[],
74
+ * }} opts
75
+ * @returns {Promise<{ added: number, skipped: number, failed: number }>}
76
+ */
77
+ async function addBlockedByEdges({
78
+ gh,
79
+ owner,
80
+ repo,
81
+ issueNumber,
82
+ blockerInternalIds,
83
+ }) {
84
+ if (blockerInternalIds.length === 0) {
85
+ return { added: 0, skipped: 0, failed: 0 };
86
+ }
87
+
88
+ const existing = await fetchExistingBlockedBy({
89
+ gh,
90
+ owner,
91
+ repo,
92
+ issueNumber,
93
+ });
94
+ const existingSet = new Set(existing);
95
+
96
+ // Partition up front so the skip count is deterministic regardless of the
97
+ // concurrent POST dispatch order, then POST only the missing edges in
98
+ // parallel under a modest cap.
99
+ const missing = blockerInternalIds.filter((id) => !existingSet.has(id));
100
+ const skipped = blockerInternalIds.length - missing.length;
101
+
102
+ const perEdge = await concurrentMap(
103
+ missing,
104
+ async (blockerId) => {
105
+ try {
106
+ await gh.api({
107
+ method: 'POST',
108
+ endpoint: `/repos/${owner}/${repo}/issues/${issueNumber}/dependencies/blocked_by`,
109
+ body: { issue_id: blockerId },
110
+ });
111
+ return { added: 1, failed: 0 };
112
+ } catch (err) {
113
+ Logger.warn(
114
+ `[blocked-by-add] Failed to add blocked-by edge #${issueNumber} ← blocker(id=${blockerId}): ${err.message}`,
115
+ );
116
+ return { added: 0, failed: 1 };
117
+ }
118
+ },
119
+ { concurrency: EDGE_CONCURRENCY },
120
+ );
121
+
122
+ let added = 0;
123
+ let failed = 0;
124
+ for (const r of perEdge) {
125
+ added += r.added;
126
+ failed += r.failed;
127
+ }
128
+
129
+ return { added, skipped, failed };
130
+ }
131
+
132
+ /**
133
+ * Translate a Story's `depends_on` slug list into native GitHub "blocked
134
+ * by" dependency edges, given the slug→issueNumber map from the reconciler
135
+ * state and a `getTicket` hook to resolve database ids.
136
+ *
137
+ * Iterates every Story that has a non-empty `dependsOn` array; for each
138
+ * depended-on slug, resolves the blocker's issue number (via the slug map),
139
+ * then resolves the blocker's database id via `getTicket`, and calls
140
+ * `addBlockedByEdges`. Any failure at any step is caught, logged as a
141
+ * warning, and reflected in the returned summary — the function never
142
+ * throws.
143
+ *
144
+ * @param {{
145
+ * stories: Array<{ slug: string, dependsOn?: string[] }>,
146
+ * slugToIssueNumber: Record<string, number>,
147
+ * getTicket: (issueNumber: number) => Promise<{ internalId: number }>,
148
+ * owner: string,
149
+ * repo: string,
150
+ * gh: object,
151
+ * }} opts
152
+ * @returns {Promise<{
153
+ * edgesAdded: number,
154
+ * edgesSkipped: number,
155
+ * edgesFailed: number,
156
+ * storiesProcessed: number,
157
+ * }>}
158
+ */
159
+ export async function applyBlockedByDependencies({
160
+ stories,
161
+ slugToIssueNumber,
162
+ getTicket,
163
+ owner,
164
+ repo,
165
+ gh,
166
+ }) {
167
+ let edgesAdded = 0;
168
+ let edgesSkipped = 0;
169
+ let edgesFailed = 0;
170
+ let storiesProcessed = 0;
171
+
172
+ // Process each story's GET + its POST batch under one bounded concurrentMap
173
+ // so the per-story round-trips overlap. Each mapper returns its own counter
174
+ // contribution; we sum them after the pass. The bodies swallow their own
175
+ // errors (per-edge try/catch + the non-fatal contract), so concurrentMap
176
+ // never sees a rejection and the no-throw guarantee is preserved.
177
+ const perStory = await concurrentMap(
178
+ stories,
179
+ async (story) => {
180
+ const deps = Array.isArray(story.dependsOn) ? story.dependsOn : [];
181
+ if (deps.length === 0) {
182
+ return { added: 0, skipped: 0, failed: 0, processed: 0 };
183
+ }
184
+
185
+ const storyIssueNumber = slugToIssueNumber[story.slug];
186
+ if (typeof storyIssueNumber !== 'number') {
187
+ Logger.warn(
188
+ `[blocked-by-add] No issue number for story slug "${story.slug}"; skipping depends_on edges.`,
189
+ );
190
+ return { added: 0, skipped: 0, failed: 0, processed: 0 };
191
+ }
192
+
193
+ let failed = 0;
194
+ const blockerInternalIds = [];
195
+
196
+ for (const depSlug of deps) {
197
+ const blockerIssueNumber = slugToIssueNumber[depSlug];
198
+ if (typeof blockerIssueNumber !== 'number') {
199
+ Logger.warn(
200
+ `[blocked-by-add] depends_on slug "${depSlug}" (from "${story.slug}") has no mapped issue number; skipping edge.`,
201
+ );
202
+ failed++;
203
+ continue;
204
+ }
205
+ try {
206
+ const blocker = await getTicket(blockerIssueNumber);
207
+ if (typeof blocker?.internalId !== 'number') {
208
+ Logger.warn(
209
+ `[blocked-by-add] Blocker #${blockerIssueNumber} ("${depSlug}") has no internalId; skipping edge.`,
210
+ );
211
+ failed++;
212
+ continue;
213
+ }
214
+ blockerInternalIds.push(blocker.internalId);
215
+ } catch (err) {
216
+ Logger.warn(
217
+ `[blocked-by-add] Could not resolve blocker "${depSlug}" (#${blockerIssueNumber}): ${err.message}`,
218
+ );
219
+ failed++;
220
+ }
221
+ }
222
+
223
+ if (blockerInternalIds.length === 0) {
224
+ return { added: 0, skipped: 0, failed, processed: 1 };
225
+ }
226
+
227
+ const result = await addBlockedByEdges({
228
+ gh,
229
+ owner,
230
+ repo,
231
+ issueNumber: storyIssueNumber,
232
+ blockerInternalIds,
233
+ });
234
+ return {
235
+ added: result.added,
236
+ skipped: result.skipped,
237
+ failed: failed + result.failed,
238
+ processed: 1,
239
+ };
240
+ },
241
+ { concurrency: EDGE_CONCURRENCY },
242
+ );
243
+
244
+ for (const r of perStory) {
245
+ edgesAdded += r.added;
246
+ edgesSkipped += r.skipped;
247
+ edgesFailed += r.failed;
248
+ storiesProcessed += r.processed;
249
+ }
250
+
251
+ return { edgesAdded, edgesSkipped, edgesFailed, storiesProcessed };
252
+ }
@@ -36,7 +36,7 @@
36
36
  * @see .agents/workflows/helpers/single-story-deliver.md
37
37
  */
38
38
 
39
- import { spawnSync } from 'node:child_process';
39
+ import { spawnSync as defaultSpawnSync } from 'node:child_process';
40
40
  import { existsSync } from 'node:fs';
41
41
  import path from 'node:path';
42
42
  import { parseSprintArgs } from './lib/cli-args.js';
@@ -109,12 +109,25 @@ const progress = Logger.createProgress('single-story-init', { stderr: true });
109
109
  * ripple into the single-story-sweep planner, which is intentionally out
110
110
  * of scope for the callers-only provider migration.
111
111
  *
112
+ * Story #4073: the `spawnImpl` seam injects the `spawnSync` boundary so the
113
+ * runner's success/error handling can be unit-tested without a live `gh`
114
+ * binary. It defaults to `child_process.spawnSync` (mirroring the
115
+ * `spawnImpl` seam in `lib/gh-exec.js` and the `runner` seam in
116
+ * `lib/bootstrap/gh-preflight.js`), so the production CLI path is unchanged.
117
+ * The synchronous `spawnSync` shape is preserved deliberately — the
118
+ * candidate-filter loop in `executeCleanup` is synchronous, so converting
119
+ * this to the async `lib/gh-exec.js` facade would ripple into the
120
+ * single-story-sweep planner.
121
+ *
112
122
  * @param {string} cwd Repo root used as the default spawn cwd.
123
+ * @param {typeof defaultSpawnSync} [spawnImpl] Injectable spawn boundary —
124
+ * defaults to `child_process.spawnSync`. Tests pass a fake to assert the
125
+ * error/exit-code handling without spawning a real child process.
113
126
  * @returns {(args: string[], opts?: { cwd?: string }) => string}
114
127
  */
115
- export function makeGhRunner(cwd) {
128
+ export function makeGhRunner(cwd, spawnImpl = defaultSpawnSync) {
116
129
  return (args, opts) => {
117
- const result = spawnSync('gh', args, {
130
+ const result = spawnImpl('gh', args, {
118
131
  cwd: opts?.cwd ?? cwd,
119
132
  encoding: 'utf-8',
120
133
  shell: false,
@@ -129,6 +129,14 @@ legitimately have no layered architecture to guard.
129
129
 
130
130
  ## Step 2: Analysis Dimensions
131
131
 
132
+ For every finding you surface, grade **Impact** on a High / Medium / Low axis
133
+ reflecting the severity of the architectural risk (how much correctness,
134
+ maintainability, or testability the gap erodes), independent of the
135
+ **Category** effort axis — a Quick Win can still be High Impact, and a
136
+ Structural Change can be Medium. As a loose default, Quick Wins typically land
137
+ High (cheap to fix, real payoff) and Structural Changes Medium/High, but grade
138
+ Impact on the risk itself rather than deriving it mechanically from Category.
139
+
132
140
  Evaluate the gathered context against the following clean code dimensions:
133
141
 
134
142
  1. **Over-Engineering & Abstractions:** Identify "dry-run" complexity, premature
@@ -294,6 +302,7 @@ marked `n/a`.]
294
302
 
295
303
  ### [Short Title of the Issue]
296
304
 
305
+ - **Impact:** [High | Medium | Low]
297
306
  - **Category:** [Quick Win | Structural Change]
298
307
  - **Dimension:** [e.g., Cognitive Load & Nesting | Testable Surface (Humble-Object Boundary) | Automated Architecture Guardrails]
299
308
  - **Current State:** [The specific file/function and why it is problematic]
@@ -1,17 +1,19 @@
1
1
  ---
2
2
  description:
3
3
  Unified delivery entry point. Inspects the ticket type(s) and
4
- Epic-reference state of the supplied IDs, then routes to the Epic wave
5
- loop or the standalone multi-Story fan-out preserving every flag and
6
- the parallel-delivery contract of the retired commands.
4
+ Epic-reference state of the supplied IDs, composes a sequential segment
5
+ plan over any mix of Epics and standalone Stories, then delegates each
6
+ segment to the Epic wave loop or the standalone multi-Story fan-out —
7
+ preserving every flag and the parallel-delivery contract of the retired
8
+ commands.
7
9
  ---
8
10
 
9
- # /deliver [Epic ID] | [Story IDs...]
11
+ # /deliver [Epic IDs...] | [Story IDs...]
10
12
 
11
13
  ## Role
12
14
 
13
- Router. `/deliver` owns input classification and path selection only — all
14
- phase content lives in the two path helpers:
15
+ Router. `/deliver` owns input classification, segment-plan composition, and
16
+ path selection only — all phase content lives in the two path helpers:
15
17
 
16
18
  - [`helpers/deliver-epic.md`](helpers/deliver-epic.md) — the full Epic
17
19
  delivery loop (preflight, wave loop fanning out
@@ -30,20 +32,67 @@ reference) before routing:
30
32
 
31
33
  | Input | Route |
32
34
  | --- | --- |
33
- | Exactly one `type::epic` ID | **Epic path** — run [`helpers/deliver-epic.md`](helpers/deliver-epic.md) Phases 1–9 unchanged. |
34
- | One or more `type::story` IDs, none carrying an `Epic: #N` reference | **Standalone path** — run [`helpers/deliver-stories.md`](helpers/deliver-stories.md) Phases 0–3. |
35
- | Any Story carrying an `Epic: #N` reference | **Error**, naming the fix: `Story #<id> belongs to Epic #<n> run /deliver <n>`. |
36
- | Mixed Epic + Story IDs, or more than one Epic | **Error**: separate invocations one `/deliver <epicId>` per Epic, one `/deliver <id> [<id>...]` for the standalone set. |
37
-
38
- ## Flags (forwarded per path)
35
+ | Exactly one `type::epic` ID | **Epic path** — run [`helpers/deliver-epic.md`](helpers/deliver-epic.md) Phases 1–9 unchanged (single-segment plan; no confirmation prompt). |
36
+ | One or more `type::story` IDs, none carrying an `Epic: #N` reference | **Standalone path** — run [`helpers/deliver-stories.md`](helpers/deliver-stories.md) Phases 0–3 (single-segment plan; no confirmation prompt). |
37
+ | Any combination of ≥1 `type::epic` IDs and ≥0 standalone `type::story` IDs | **Segment plan** compose and execute the sequential segment plan below. |
38
+ | Any Story carrying an `Epic: #N` reference (alone or mixed into an otherwise-valid set) | **Error**, naming every affected ID and the fix: `Story #<id> belongs to Epic #<n> run /deliver <n>`. |
39
+
40
+ Per-ID classification is unchanged: fetch the `type::*` label and probe the
41
+ body for an `Epic: #N` reference before routing. Never guess a route.
42
+
43
+ ## Segment plan (mixed / multi-Epic input)
44
+
45
+ When the supplied IDs span more than one Epic, or mix Epics with standalone
46
+ Stories, the router composes a **segment plan** and executes the segments
47
+ **strictly sequentially**:
48
+
49
+ 1. **Standalone segment first** (when any standalone Story IDs are
50
+ present): the full standalone-Story set forms **one** segment,
51
+ delivered via [`helpers/deliver-stories.md`](helpers/deliver-stories.md)
52
+ Phases 0–3 unchanged. It runs first because it is fast, each Story
53
+ merges to `main` independently, and each subsequent Epic segment's
54
+ Phase 7.0 base-sync then integrates those merges naturally instead of
55
+ the Epic PR opening behind base.
56
+ 2. **Epic segments in input order**: each `type::epic` ID forms its own
57
+ segment, delivered via
58
+ [`helpers/deliver-epic.md`](helpers/deliver-epic.md) Phases 1–9
59
+ unchanged.
60
+
61
+ Sequential execution is a deliberate design decision: the Epic path assumes
62
+ a single main checkout (prepare's checkout guard, Phase 7.0
63
+ `git checkout epic/<id>`), holds a per-Epic lease, serializes same-machine
64
+ sessions via `epic-merge-lock.js`, and constrains dispatch to one wave at a
65
+ time. Segments are never interleaved or parallelized; running them one at a
66
+ time keeps both helpers' machinery untouched.
67
+
68
+ **Confirmation gate.** When the composed plan has more than one segment,
69
+ present it to the operator before dispatching — the segments, the IDs in
70
+ each, and the execution order — and wait for confirmation. `--yes`
71
+ suppresses this prompt. Single-segment plans route directly with today's
72
+ behavior (no new prompt; the standalone path's own Phase 1 confirmation
73
+ still applies as before).
74
+
75
+ **Failure policy.** A segment that ends non-complete (blocked, failed, or
76
+ halted at a gate) **stops the run** — no subsequent segment dispatches.
77
+ Report the terminal state: which segments completed, which segment halted
78
+ (and why), and which segments never started. Name the resume command:
79
+ re-running `/deliver` with the same IDs — both path helpers short-circuit
80
+ already-done work (the Epic path resumes idempotently from its checkpoint;
81
+ merged standalone Stories no-op).
82
+
83
+ ## Flags (scoped per segment)
39
84
 
40
85
  | Path | Flags |
41
86
  | --- | --- |
42
87
  | Epic | `--skip-epic-audit`, `--skip-code-review`, `--skip-retro`, `--full-retro`, `--steal`, `--as <handle>` |
43
88
  | Story | `--dep <from>:<to>`, `--yes`, `--concurrency <n>` |
44
89
 
45
- A flag passed to the wrong path is reported once as a no-op warning and
46
- ignored never an error.
90
+ In a segment plan, Epic-path flags apply to **every** Epic segment;
91
+ Story-path flags apply to the standalone segment. `--yes` additionally
92
+ suppresses the router's segment-plan confirmation gate above. A flag with
93
+ no applicable segment in the plan is reported once as a no-op warning and
94
+ ignored — never an error (the existing convention, restated for segment
95
+ plans).
47
96
 
48
97
  **Multi-Story parallel contract (preserved verbatim).**
49
98
 
@@ -55,31 +104,43 @@ behaves exactly as the retired multi-Story command did: the same
55
104
  `stories-wave-tick.js` wave plan, the same operator confirmation gate
56
105
  (suppressed by `--yes`), and the same parallel fan-out — one Agent call per
57
106
  Story per wave, capped by the resolved `concurrencyCap` — to
58
- [`helpers/single-story-deliver`](helpers/single-story-deliver.md).
107
+ [`helpers/single-story-deliver`](helpers/single-story-deliver.md). The
108
+ parallelism lives **inside** the standalone segment; segments themselves
109
+ remain strictly sequential.
59
110
 
60
111
  ## Procedure
61
112
 
62
113
  1. **Parse args.** At least one positive-integer ID is required.
63
114
  2. **Classify.** Fetch each ticket's labels + body and apply the input
64
- matrix above. Refuse ambiguous input with the matrix's error messages
65
- never guess a route.
66
- 3. **Delegate.** Read the selected path helper **in full** and execute it
67
- from its entry phase, forwarding the absorbed flags. The helper's phase
68
- numbering, watchdogs, gates, and scripts are unchanged — this router
69
- adds no phase content.
115
+ matrix above. Any Epic-attached Story ID is a hard error naming the
116
+ affected IDs and the fix — never guess a route.
117
+ 3. **Compose the segment plan.** Standalone-Story set (when present) as
118
+ one segment, then one segment per Epic ID in input order. For a
119
+ multi-segment plan, present it and wait for operator confirmation
120
+ (`--yes` suppresses).
121
+ 4. **Execute segments sequentially.** For each segment in order, read the
122
+ selected path helper **in full** and execute it from its entry phase,
123
+ forwarding the segment's scoped flags. The helper's phase numbering,
124
+ watchdogs, gates, and scripts are unchanged — this router adds no phase
125
+ content. Stop on the first non-complete segment per the failure policy.
126
+ 5. **Report.** On completion (or halt), summarize per-segment outcomes and,
127
+ when halted, the resume command.
70
128
 
71
129
  ## Constraints
72
130
 
73
- - `/deliver` requires a planned ticket: an Epic at `agent::ready` (the
74
- Epic helper's preflight enforces this) or well-formed standalone Stories.
75
- Planning happens in [`/plan`](plan.md); the plan-review gate between the
76
- two commands is a hard boundary.
131
+ - `/deliver` requires planned tickets: Epics at `agent::ready` (the
132
+ Epic helper's preflight enforces this, per segment) or well-formed
133
+ standalone Stories. Planning happens in [`/plan`](plan.md); the
134
+ plan-review gate between the two commands is a hard boundary.
77
135
  - The router performs no git or label mutations itself; the path helpers
78
136
  own every script invocation.
137
+ - Segments execute strictly sequentially — never interleave a standalone
138
+ Story fan-out with an Epic wave loop, and never run two Epic segments
139
+ concurrently.
79
140
 
80
141
  ## See also
81
142
 
82
143
  - [`/plan`](plan.md) — the unified planning entry point.
83
144
  - [`helpers/deliver-epic.md`](helpers/deliver-epic.md) /
84
145
  [`helpers/deliver-stories.md`](helpers/deliver-stories.md) — the path
85
- helpers.
146
+ helpers, delegated to per segment.