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,186 @@
1
+ /**
2
+ * epic-container.js — the one module describing a container Epic.
3
+ *
4
+ * An Epic here is a **pure container**: a `type::epic` issue whose body is a
5
+ * short `## Goal` paragraph and a `- [ ] #N` child checklist, and nothing
6
+ * else. It carries no `## Spec`, no `acceptance[]` / `verify[]`, and no
7
+ * `agent::*` label. It is never branched, never implemented and never
8
+ * delivered — `/mandrel-deliver <epicId>` expands it to its open children and
9
+ * delivers those.
10
+ *
11
+ * **Linkage is parent→child only.** The Epic holds every edge; Story bodies
12
+ * are never touched. That is the whole reason this can exist without
13
+ * reversing ADR `20260726-v2-story-collapse`: the `Epic: #N` footer stays
14
+ * retired and every refusal that reads it still fires, so each Story remains
15
+ * independently deliverable and the delivery engine stays Story-only.
16
+ *
17
+ * Both consumers — `plan-persist` (which writes an Epic) and
18
+ * `resolve-stories` (which expands one) — import from here so the written
19
+ * shape and the read shape cannot drift apart.
20
+ *
21
+ * @module lib/orchestration/epic-container
22
+ * @see Story #5139
23
+ */
24
+
25
+ import { TYPE_LABELS } from '../label-constants.js';
26
+
27
+ /**
28
+ * The checklist grammar. `getSubTickets` (`providers/github/issues.js`)
29
+ * already parses this exact form as its strategy-2 child source, so the
30
+ * checklist is a durable mirror of the native sub-issue edges rather than a
31
+ * second, competing representation: when the sub-issues API is unavailable
32
+ * — an older GHES, a revoked scope, a partial write — the children are still
33
+ * discoverable from the body alone.
34
+ *
35
+ * Kept in sync with `_getChecklistChildren` deliberately; a divergence here
36
+ * would strand children the writer believes it linked.
37
+ */
38
+ const CHECKLIST_ITEM_RE = /^-\s*\[[ xX]\]\s+#(\d+)\s*$/gm;
39
+
40
+ /** Heading the container's one prose section renders under. */
41
+ const GOAL_HEADING = '## Goal';
42
+
43
+ /** Heading the child checklist renders under. */
44
+ const CHILDREN_HEADING = '## Stories';
45
+
46
+ /**
47
+ * Normalize an issue's labels to plain strings. GitHub hands labels back
48
+ * either as objects (`{ name }`) or, once mapped, as bare strings; callers
49
+ * should not have to care which shape they hold.
50
+ *
51
+ * @param {unknown} raw
52
+ * @returns {string[]}
53
+ */
54
+ function normalizeLabels(raw) {
55
+ if (!Array.isArray(raw)) return [];
56
+ return raw
57
+ .map((l) => (typeof l === 'string' ? l : l?.name))
58
+ .filter((n) => typeof n === 'string' && n.length > 0);
59
+ }
60
+
61
+ /**
62
+ * Is this issue a container Epic?
63
+ *
64
+ * Reads the `type::epic` label and nothing else — the label is the
65
+ * authoritative marker. Body shape is deliberately NOT part of the test: a
66
+ * hand-edited Epic whose checklist an operator reordered or annotated is
67
+ * still an Epic, and treating it otherwise would silently reclassify it as
68
+ * an ordinary non-Story and hard-error the delivery.
69
+ *
70
+ * @param {{ labels?: unknown }} issue
71
+ * @returns {boolean}
72
+ */
73
+ export function isEpicTicket(issue) {
74
+ return normalizeLabels(issue?.labels).includes(TYPE_LABELS.EPIC);
75
+ }
76
+
77
+ /**
78
+ * Render a container Epic's body.
79
+ *
80
+ * The output is intentionally minimal — a goal paragraph and a checklist.
81
+ * The Epic must carry **no information a child does not already carry**: it
82
+ * is a container, so anything unique living here would be a fact with no
83
+ * home in the tickets that actually get executed, invisible to every agent
84
+ * delivering them.
85
+ *
86
+ * @param {{ goal: string, childIds?: number[] }} opts
87
+ * @returns {string} Canonical Epic body markdown.
88
+ */
89
+ export function composeEpicBody({ goal, childIds = [] } = {}) {
90
+ const text = typeof goal === 'string' ? goal.trim() : '';
91
+ if (text === '') {
92
+ throw new Error('[epic-container] composeEpicBody requires a goal.');
93
+ }
94
+
95
+ const ids = normalizeChildIds(childIds);
96
+ const lines = [GOAL_HEADING, '', text, '', CHILDREN_HEADING, ''];
97
+ if (ids.length === 0) {
98
+ lines.push('_No child Stories linked._');
99
+ } else {
100
+ for (const id of ids) lines.push(`- [ ] #${id}`);
101
+ }
102
+ return `${lines.join('\n')}\n`;
103
+ }
104
+
105
+ /**
106
+ * Coerce a child-id list to positive integers, deduped, order-preserving.
107
+ *
108
+ * @param {unknown} raw
109
+ * @returns {number[]}
110
+ */
111
+ export function normalizeChildIds(raw) {
112
+ if (!Array.isArray(raw)) return [];
113
+ const seen = new Set();
114
+ const out = [];
115
+ for (const entry of raw) {
116
+ const id = Number(entry);
117
+ if (!Number.isInteger(id) || id <= 0) continue;
118
+ if (seen.has(id)) continue;
119
+ seen.add(id);
120
+ out.push(id);
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * Read the child issue numbers an Epic body declares.
127
+ *
128
+ * Body-only, by design: this is the fallback that works with nothing but the
129
+ * issue text. Callers that can reach the API should union this with the
130
+ * native sub-issue edges (`readEpicChildIdsFrom`), because an operator can
131
+ * link a child in the GitHub UI without touching the checklist.
132
+ *
133
+ * @param {string|null|undefined} body
134
+ * @returns {number[]}
135
+ */
136
+ export function readEpicChildIds(body) {
137
+ if (typeof body !== 'string' || body === '') return [];
138
+ // `matchAll` on a /g regex starts from lastIndex; the literal is
139
+ // module-scoped, so reset it rather than leaking state across calls.
140
+ CHECKLIST_ITEM_RE.lastIndex = 0;
141
+ return normalizeChildIds(
142
+ [...body.matchAll(CHECKLIST_ITEM_RE)].map((m) => Number.parseInt(m[1], 10)),
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Resolve an Epic's children from **both** sources — the body checklist and
148
+ * the native sub-issue edges — as one deduped list.
149
+ *
150
+ * The two are unioned rather than ranked because each can hold a child the
151
+ * other misses: the API is authoritative for links made in the GitHub UI,
152
+ * and the checklist survives an API that is unavailable or was never
153
+ * written. A child present in either is a child.
154
+ *
155
+ * `readNativeChildIds` is injected and may be absent or throw; a failure
156
+ * degrades to the checklist rather than propagating, since a body-derived
157
+ * child list is a strictly better answer than an error.
158
+ *
159
+ * @param {{
160
+ * epic: { number?: number, id?: number, body?: string, nodeId?: string },
161
+ * readNativeChildIds?: (epic: object) => Promise<number[]>,
162
+ * onWarn?: (message: string) => void,
163
+ * }} opts
164
+ * @returns {Promise<number[]>}
165
+ */
166
+ export async function readEpicChildIdsFrom({
167
+ epic,
168
+ readNativeChildIds,
169
+ onWarn,
170
+ } = {}) {
171
+ const fromBody = readEpicChildIds(epic?.body);
172
+ if (typeof readNativeChildIds !== 'function') return fromBody;
173
+
174
+ let native = [];
175
+ try {
176
+ native = normalizeChildIds(await readNativeChildIds(epic));
177
+ } catch (err) {
178
+ const id = epic?.number ?? epic?.id ?? '?';
179
+ onWarn?.(
180
+ `[epic-container] native sub-issue read failed for Epic #${id} ` +
181
+ `(${err?.message ?? String(err)}); using the body checklist alone.`,
182
+ );
183
+ }
184
+
185
+ return normalizeChildIds([...native, ...fromBody]);
186
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * epic-expansion.js — turn a container-Epic id into the open Story ids under it.
3
+ *
4
+ * Split out of `resolve-stories.js` (Story #5139): Epic expansion is a
5
+ * distinct concern from Story resolution — it runs strictly *before* it and
6
+ * hands it an ordinary id list — and folding it into that already-dense module
7
+ * cost 4.19 maintainability points for no cohesion gain.
8
+ *
9
+ * @module lib/orchestration/epic-expansion
10
+ */
11
+
12
+ import { TYPE_LABELS } from '../label-constants.js';
13
+ import { isEpicTicket, readEpicChildIdsFrom } from './epic-container.js';
14
+ import { isSatisfiedBlocker } from './resolve-stories.js';
15
+
16
+ /**
17
+ * Does this issue carry the Story type label?
18
+ *
19
+ * @param {{ labels?: unknown }} issue
20
+ * @returns {boolean}
21
+ */
22
+ function isStoryTicket(issue) {
23
+ const raw = issue?.labels;
24
+ if (!Array.isArray(raw)) return false;
25
+ return raw
26
+ .map((l) => (typeof l === 'string' ? l : l?.name))
27
+ .includes(TYPE_LABELS.STORY);
28
+ }
29
+
30
+ /**
31
+ * Expand any container-Epic id in the requested set to its open child
32
+ * Stories, leaving every other id untouched.
33
+ *
34
+ * `/mandrel-deliver <epicId>` means "deliver everything under this Epic". The
35
+ * expansion happens **here, before resolution**, so everything downstream —
36
+ * the DAG, the ready set, the wave tick, the close tail — sees an ordinary
37
+ * list of Story ids and needs no Epic concept at all. That is the whole
38
+ * reason the Epic can exist without touching the delivery engine.
39
+ *
40
+ * Expansion is **per id**, so Epic and Story ids may be mixed freely in one
41
+ * invocation and the result is the deduped union in first-seen order.
42
+ *
43
+ * Two filters apply to children, and they are deliberately quieter than the
44
+ * treatment of a *named* id:
45
+ *
46
+ * - **A closed / `agent::done` child is dropped.** Delivering an Epic means
47
+ * delivering what is left of it. A dependent's edge onto a landed sibling
48
+ * still resolves: the sibling becomes a *foreign* blocker, and foreign
49
+ * blockers are checked against live state and enter `done[]`.
50
+ * - **A child that is not a `type::story` is dropped with a warning.** A
51
+ * named non-Story is an error because the operator asserted it was
52
+ * deliverable; a linked one is the Epic's assertion, and someone attaching
53
+ * a stray issue in the GitHub UI must not wedge the whole run.
54
+ *
55
+ * An Epic that expands to nothing is an **error**, not an empty success: a
56
+ * silent empty envelope would report a clean no-op for a delivery the
57
+ * operator asked for and never got.
58
+ *
59
+ * @param {{
60
+ * ids: number[],
61
+ * getTicket: (id: number) => Promise<object|null>,
62
+ * readNativeChildIds?: (epic: object) => Promise<number[]>,
63
+ * warn?: (msg: string) => void,
64
+ * }} opts
65
+ * @returns {Promise<{ ids: number[], expansions: Array<{ epicId: number, childIds: number[] }> }>}
66
+ */
67
+ export async function expandEpicIds({
68
+ ids,
69
+ getTicket,
70
+ readNativeChildIds,
71
+ warn,
72
+ }) {
73
+ const out = [];
74
+ const seen = new Set();
75
+ const expansions = [];
76
+
77
+ const push = (id) => {
78
+ if (seen.has(id)) return;
79
+ seen.add(id);
80
+ out.push(id);
81
+ };
82
+
83
+ for (const id of ids) {
84
+ const issue = await getTicket(id);
85
+ if (!issue) {
86
+ throw new Error(`[resolve-stories] Issue #${id} was not found.`);
87
+ }
88
+ if (!isEpicTicket(issue)) {
89
+ push(id);
90
+ continue;
91
+ }
92
+
93
+ const childIds = await readEpicChildIdsFrom({
94
+ epic: issue,
95
+ readNativeChildIds,
96
+ onWarn: warn,
97
+ });
98
+ if (childIds.length === 0) {
99
+ throw new Error(
100
+ `[resolve-stories] Epic #${id} lists no child Stories. An Epic is a container: ` +
101
+ `link its Stories (a "- [ ] #N" checklist line or a GitHub sub-issue) ` +
102
+ `or deliver the Story ids directly.`,
103
+ );
104
+ }
105
+
106
+ const open = [];
107
+ for (const childId of childIds) {
108
+ let child;
109
+ try {
110
+ child = await getTicket(childId);
111
+ } catch (err) {
112
+ warn?.(
113
+ `[resolve-stories] Epic #${id}: could not read child #${childId} ` +
114
+ `(${err?.message ?? err}) — skipping it.`,
115
+ );
116
+ continue;
117
+ }
118
+ if (!child) {
119
+ warn?.(
120
+ `[resolve-stories] Epic #${id}: child #${childId} was not found — skipping it.`,
121
+ );
122
+ continue;
123
+ }
124
+ if (!isStoryTicket(child)) {
125
+ warn?.(
126
+ `[resolve-stories] Epic #${id}: child #${childId} is not a ${TYPE_LABELS.STORY} ` +
127
+ `— skipping it. Only Stories are deliverable.`,
128
+ );
129
+ continue;
130
+ }
131
+ if (isSatisfiedBlocker(child)) continue;
132
+ open.push(childId);
133
+ }
134
+
135
+ if (open.length === 0) {
136
+ throw new Error(
137
+ `[resolve-stories] Epic #${id} has ${childIds.length} child Story(ies), ` +
138
+ `but none are still open — every one is closed or agent::done. ` +
139
+ `There is nothing left to deliver.`,
140
+ );
141
+ }
142
+
143
+ expansions.push({ epicId: id, childIds: open });
144
+ for (const childId of open) push(childId);
145
+ }
146
+
147
+ return { ids: out, expansions };
148
+ }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * epic-ops.js — create the optional container Epic for a plan-persist run.
3
+ *
4
+ * Story #5139. When `/mandrel-plan` authors more than two Stories it offers to
5
+ * group them under one container Epic. The Epic is **not** a work item: it
6
+ * holds a `## Goal` paragraph and a child checklist, carries `type::epic` and
7
+ * nothing else, and is never branched, implemented or delivered.
8
+ *
9
+ * Ordering matters — the Epic is created **after** the Stories, because its
10
+ * body embeds their issue numbers and its sub-issue edges need their database
11
+ * ids. A container that exists before its contents would have to be written
12
+ * twice.
13
+ *
14
+ * @module lib/orchestration/plan-persist/epic-ops
15
+ */
16
+
17
+ import { createHash } from 'node:crypto';
18
+ import { linkStoriesToEpic } from '../../../providers/github/sub-issue-add.js';
19
+ import { Logger } from '../../Logger.js';
20
+ import { LABEL_COLORS, TYPE_LABELS } from '../../label-constants.js';
21
+ import { composeEpicBody } from '../epic-container.js';
22
+
23
+ /**
24
+ * The Story count at or above which `/mandrel-plan` offers a container Epic.
25
+ *
26
+ * Three, i.e. "more than two" — at two Stories a pair of ids is as easy to
27
+ * carry as one, and the container earns nothing.
28
+ */
29
+ export const EPIC_SUGGESTION_THRESHOLD = 3;
30
+
31
+ /** Length of the truncated hex digest stamped into the Epic marker. */
32
+ const EPIC_FINGERPRINT_LENGTH = 8;
33
+
34
+ /** Marker prefix identifying a persist-authored Epic in an issue body. */
35
+ const EPIC_FINGERPRINT_MARKER_PREFIX = 'mandrel-epic-fingerprint';
36
+
37
+ /**
38
+ * Derive the Epic's resume identity from its title and the exact child set.
39
+ *
40
+ * Keyed on the children, not just the title: two runs that group *different*
41
+ * Stories are different containers even under the same title, and adopting
42
+ * one for the other would silently leave a cohort unlinked.
43
+ *
44
+ * Fields join on NUL, written as the `\u0000` escape and never as a raw byte
45
+ * — a literal NUL makes git classify the file binary and drop its diffs.
46
+ *
47
+ * @param {{ title: string, childIds: number[] }} opts
48
+ * @returns {string} Hex digest.
49
+ */
50
+ function epicFingerprint({ title, childIds }) {
51
+ const ids = [...childIds].sort((a, b) => a - b).join(',');
52
+ return createHash('sha256')
53
+ .update(`${title}\u0000${ids}`)
54
+ .digest('hex')
55
+ .slice(0, EPIC_FINGERPRINT_LENGTH);
56
+ }
57
+
58
+ /**
59
+ * Render the invisible HTML-comment marker carrying the Epic's fingerprint.
60
+ *
61
+ * @param {string} fingerprint
62
+ * @returns {string}
63
+ */
64
+ function epicFingerprintMarker(fingerprint) {
65
+ return `<!-- ${EPIC_FINGERPRINT_MARKER_PREFIX} ${fingerprint} -->`;
66
+ }
67
+
68
+ /**
69
+ * Ensure the `type::epic` label exists, **failing closed**.
70
+ *
71
+ * This is deliberately the opposite posture to the cohort and route labels
72
+ * (`ensurePersistLabel` in `story-ops.js`), which degrade to "create without
73
+ * the label" because they are cosmetic. `type::epic` is not cosmetic: it is
74
+ * the sole marker `isEpicTicket` reads, so an Epic created without it is not
75
+ * an Epic — it is a stray issue that `/mandrel-deliver` will hard-error on and
76
+ * no expansion will ever find. Skipping creation leaves the Stories, which
77
+ * are the part that matters, perfectly deliverable by id.
78
+ *
79
+ * @param {{ provider: object }} opts
80
+ * @returns {Promise<boolean>} Whether creation may proceed.
81
+ */
82
+ async function ensureEpicLabel({ provider }) {
83
+ if (typeof provider?.ensureLabels !== 'function') return true;
84
+ try {
85
+ const result = await provider.ensureLabels([
86
+ {
87
+ name: TYPE_LABELS.EPIC,
88
+ color: LABEL_COLORS.TYPE,
89
+ description:
90
+ 'Container-only grouping ticket — holds child Stories, carries no execution payload',
91
+ },
92
+ ]);
93
+ if (
94
+ Array.isArray(result?.missing) &&
95
+ result.missing.includes(TYPE_LABELS.EPIC)
96
+ ) {
97
+ Logger.warn(
98
+ `[plan-persist] "${TYPE_LABELS.EPIC}" could not be verified on the remote — ` +
99
+ 'skipping the container Epic. The Stories are unaffected and deliver by id.',
100
+ );
101
+ return false;
102
+ }
103
+ return true;
104
+ } catch (err) {
105
+ Logger.warn(
106
+ `[plan-persist] "${TYPE_LABELS.EPIC}" label ensure failed (${err.message}) — ` +
107
+ 'skipping the container Epic. The Stories are unaffected and deliver by id.',
108
+ );
109
+ return false;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Find an already-created Epic carrying this fingerprint, so a resumed
115
+ * persist adopts it instead of opening a second container.
116
+ *
117
+ * Non-fatal: a search failure returns `null` and the caller creates. A
118
+ * duplicate Epic is cosmetic; a crash mid-persist is not.
119
+ *
120
+ * @param {{ provider: object, fingerprint: string }} opts
121
+ * @returns {Promise<{ id: number, url?: string }|null>}
122
+ */
123
+ async function findExistingEpic({ provider, fingerprint }) {
124
+ if (typeof provider?.listIssuesByLabel !== 'function') return null;
125
+ try {
126
+ const marker = epicFingerprintMarker(fingerprint);
127
+ const found = await provider.listIssuesByLabel({
128
+ state: 'open',
129
+ labels: TYPE_LABELS.EPIC,
130
+ });
131
+ const hit = (Array.isArray(found) ? found : []).find((issue) =>
132
+ String(issue?.body ?? '').includes(marker),
133
+ );
134
+ if (!hit) return null;
135
+ const id = Number(hit.number ?? hit.id);
136
+ if (!Number.isInteger(id) || id <= 0) return null;
137
+ return { id, url: hit.html_url ?? hit.url ?? undefined };
138
+ } catch (err) {
139
+ Logger.warn(
140
+ `[plan-persist] Epic resume lookup failed (${err.message}); creating a new container.`,
141
+ );
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Link the created Stories under the Epic as native sub-issue edges.
148
+ *
149
+ * Non-fatal by design — the body checklist is the durable mirror, and
150
+ * `getSubTickets` reads it as a first-class child source. A lost edge costs
151
+ * the GitHub UI's nesting, not the grouping itself.
152
+ *
153
+ * @param {{ provider: object, epicNumber: number, childIds: number[] }} opts
154
+ * @returns {Promise<{ added: number, skipped: number, failed: number }|null>}
155
+ */
156
+ async function mirrorSubIssueEdges({ provider, epicNumber, childIds }) {
157
+ if (
158
+ typeof provider?.getDependencyWriteContext !== 'function' ||
159
+ typeof provider?.getTicket !== 'function'
160
+ ) {
161
+ Logger.warn(
162
+ '[plan-persist] provider exposes no getDependencyWriteContext/getTicket — ' +
163
+ 'skipping native sub-issue edges. The Epic body checklist still lists every child.',
164
+ );
165
+ return null;
166
+ }
167
+
168
+ try {
169
+ const { gh, owner, repo } = provider.getDependencyWriteContext();
170
+ const summary = await linkStoriesToEpic({
171
+ epicNumber,
172
+ childIssueNumbers: childIds,
173
+ getTicket: (issueNumber) => provider.getTicket(issueNumber),
174
+ owner,
175
+ repo,
176
+ gh,
177
+ });
178
+ if (summary.failed > 0) {
179
+ Logger.warn(
180
+ `[plan-persist] ${summary.failed} sub-issue edge(s) could not be written. ` +
181
+ 'The Epic body checklist still lists every child; add the links by hand ' +
182
+ 'if you want them nested in the GitHub UI.',
183
+ );
184
+ } else {
185
+ Logger.info(
186
+ `[plan-persist] sub-issue edges: ${summary.added} added, ` +
187
+ `${summary.skipped} already present.`,
188
+ );
189
+ }
190
+ return summary;
191
+ } catch (err) {
192
+ Logger.warn(
193
+ `[plan-persist] native sub-issue mirroring failed (${err.message}) — ` +
194
+ 'the Epic body checklist still lists every child.',
195
+ );
196
+ return null;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Create the container Epic for a persisted cohort.
202
+ *
203
+ * Returns `null` whenever no Epic was created — not requested, too few
204
+ * Stories, or the label could not be ensured. Callers treat `null` as the
205
+ * ordinary no-Epic outcome, never as a failure.
206
+ *
207
+ * **The Epic never receives an `agent::*` label.** Its labels are exactly
208
+ * `[type::epic]`. That absence is load-bearing: it keeps the container out
209
+ * of the bare `/mandrel-deliver` ready list and outside `lint-issue-body.js`,
210
+ * which scopes itself to `type::story`.
211
+ *
212
+ * @param {{
213
+ * provider: object,
214
+ * epic: { title: string, goal: string }|null,
215
+ * created: Array<{ id: number, title: string }>,
216
+ * opts?: { dryRun?: boolean, minStories?: number },
217
+ * }} args
218
+ * @returns {Promise<{
219
+ * id: number,
220
+ * title: string,
221
+ * url?: string,
222
+ * childIds: number[],
223
+ * adopted: boolean,
224
+ * edges: { added: number, skipped: number, failed: number }|null,
225
+ * }|null>}
226
+ */
227
+ export async function createContainerEpic({
228
+ provider,
229
+ epic,
230
+ created,
231
+ opts = {},
232
+ }) {
233
+ const { dryRun = false, minStories = EPIC_SUGGESTION_THRESHOLD } = opts;
234
+ if (!epic) return null;
235
+
236
+ const title = typeof epic.title === 'string' ? epic.title.trim() : '';
237
+ const goal = typeof epic.goal === 'string' ? epic.goal.trim() : '';
238
+ if (title === '' || goal === '') {
239
+ throw new Error(
240
+ '[plan-persist] A container Epic requires both a title and a goal.',
241
+ );
242
+ }
243
+
244
+ const childIds = (Array.isArray(created) ? created : [])
245
+ .map((s) => s.id)
246
+ .filter((id) => Number.isInteger(id) && id > 0);
247
+
248
+ // Dry-run reports the intended container write-free. `created` carries
249
+ // negative placeholder ids there, so `childIds` is empty by construction —
250
+ // report the count from `created` itself rather than from the filtered list.
251
+ if (dryRun) {
252
+ return {
253
+ id: -1,
254
+ title,
255
+ childIds: (Array.isArray(created) ? created : []).map((s) => s.id),
256
+ adopted: false,
257
+ edges: null,
258
+ };
259
+ }
260
+
261
+ if (childIds.length < minStories) {
262
+ Logger.info(
263
+ `[plan-persist] ${childIds.length} Story(ies) is below the ${minStories}-Story ` +
264
+ 'Epic threshold — no container created.',
265
+ );
266
+ return null;
267
+ }
268
+
269
+ if (!(await ensureEpicLabel({ provider }))) return null;
270
+
271
+ const fingerprint = epicFingerprint({ title, childIds });
272
+ const existing = await findExistingEpic({ provider, fingerprint });
273
+ if (existing) {
274
+ Logger.info(
275
+ `[plan-persist] resuming: container Epic #${existing.id} already groups ` +
276
+ 'this exact cohort — skipping create.',
277
+ );
278
+ const edges = await mirrorSubIssueEdges({
279
+ provider,
280
+ epicNumber: existing.id,
281
+ childIds,
282
+ });
283
+ return {
284
+ id: existing.id,
285
+ title,
286
+ url: existing.url,
287
+ childIds,
288
+ adopted: true,
289
+ edges,
290
+ };
291
+ }
292
+
293
+ const body = `${composeEpicBody({ goal, childIds })}\n${epicFingerprintMarker(fingerprint)}\n`;
294
+ const result = await provider.createIssue({
295
+ title,
296
+ body,
297
+ labels: [TYPE_LABELS.EPIC],
298
+ });
299
+
300
+ const epicNumber = result.number ?? result.id;
301
+ const edges = await mirrorSubIssueEdges({
302
+ provider,
303
+ epicNumber,
304
+ childIds,
305
+ });
306
+
307
+ Logger.info(
308
+ `[plan-persist] container Epic #${epicNumber} groups ${childIds.length} Story(ies): ` +
309
+ `deliver them all with /mandrel-deliver ${epicNumber}`,
310
+ );
311
+
312
+ return {
313
+ id: epicNumber,
314
+ title,
315
+ url: result.url,
316
+ childIds,
317
+ adopted: false,
318
+ edges,
319
+ };
320
+ }
@@ -68,6 +68,7 @@ import {
68
68
  renderHardConflictError,
69
69
  } from '../ticket-validator-conflicts.js';
70
70
  import { upsertStructuredComment } from '../ticketing.js';
71
+ import { createContainerEpic } from './epic-ops.js';
71
72
  import {
72
73
  enforceFanOutGate,
73
74
  surfaceSoftConflictFindings,
@@ -711,6 +712,10 @@ export async function runPlanPersist({
711
712
  closeSuperseded = true,
712
713
  routeDowngradeReason = null,
713
714
  injectedRules = undefined,
715
+ // Story #5139 — the optional container Epic. `null` (the default) is the
716
+ // ordinary shape: no Epic is created unless `/mandrel-plan` offered one
717
+ // above the threshold and the operator confirmed it.
718
+ epic = null,
714
719
  } = opts;
715
720
 
716
721
  // Boundary for the plan-metrics summary below: everything this invocation
@@ -840,6 +845,18 @@ export async function runPlanPersist({
840
845
  });
841
846
  }
842
847
 
848
+ // Story #5139 — the container Epic is created LAST among the writes: its
849
+ // body embeds the child issue numbers and its sub-issue edges need their
850
+ // database ids, neither of which exists until the Stories are live. It is
851
+ // never load-bearing, so a failure here degrades to "no container" and the
852
+ // Stories still deliver by id.
853
+ const containerEpic = await createContainerEpic({
854
+ provider,
855
+ epic,
856
+ created,
857
+ opts: { dryRun },
858
+ });
859
+
843
860
  const supersede = await runSupersedePhase({
844
861
  provider,
845
862
  stories,
@@ -866,5 +883,6 @@ export async function runPlanPersist({
866
883
  freshness,
867
884
  waveTable,
868
885
  supersede,
886
+ epic: containerEpic,
869
887
  };
870
888
  }