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,39 @@
1
+ /**
2
+ * epic-grouping-directive.js — the container-Epic directive an audit sweep
3
+ * emits (Story #5139).
4
+ *
5
+ * Lives on its own because **both** `/audit-to-stories` output paths carry it:
6
+ * the `/mandrel-plan` seed one-pager (`seed-from-findings.js`) and the
7
+ * standalone Story-draft transcript (`audit-to-stories.js`). A sweep is the
8
+ * clearest case for a container — every Story shares a provenance and the
9
+ * operator almost always wants them delivered together — so the Epic is the
10
+ * **default** here, unlike the offer `/mandrel-plan` makes on an ad-hoc plan.
11
+ *
12
+ * It stays a directive in the text rather than an automatic write: the
13
+ * workflow's Phase 4 HITL stop is where an operator declines it.
14
+ *
15
+ * @module lib/audit-to-stories/epic-grouping-directive
16
+ */
17
+
18
+ import { EPIC_SUGGESTION_THRESHOLD } from '../orchestration/plan-persist/epic-ops.js';
19
+
20
+ /**
21
+ * Render the grouping directive for a proposed Story set.
22
+ *
23
+ * @param {unknown[]} groups The proposed Stories (only the count is read).
24
+ * @returns {string} Markdown paragraph(s).
25
+ */
26
+ export function formatEpicGrouping(groups) {
27
+ const count = Array.isArray(groups) ? groups.length : 0;
28
+ if (count < EPIC_SUGGESTION_THRESHOLD) {
29
+ const noun = count === 1 ? 'Story' : 'Stories';
30
+ return `This sweep proposes ${count} ${noun} — below the ${EPIC_SUGGESTION_THRESHOLD}-Story threshold, so no container Epic is needed.`;
31
+ }
32
+ return [
33
+ `**Group these under a container Epic.** This sweep proposes ${count} Stories from one audit pass, which is exactly the case a container earns: they share a provenance and an operator will want to deliver them as a unit.`,
34
+ '',
35
+ 'The Epic is a **pure container** — a title, a one-paragraph goal, and the child checklist. It must carry no finding, no path and no rationale that is not already in a child Story, or that information ends up somewhere no delivering agent reads.',
36
+ '',
37
+ 'Decline it and file the Stories flat if the operator prefers.',
38
+ ].join('\n');
39
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * lib/audit-to-stories/ledger-commit.js — persist the cross-run audit ledger.
3
+ *
4
+ * The `--auto` sweep's whole value is memory: `baselines/audit-ledger.json`
5
+ * is what lets the next run tell a re-detection from a fresh finding and an
6
+ * accepted risk from an unseen one. A scheduled sweep, though, typically runs
7
+ * on an ephemeral checkout — a fresh clone that is deleted when the job ends —
8
+ * so the ledger `--auto` writes is discarded and every later sweep starts from
9
+ * an empty memory. The sweep is then permanently amnesiac, and the ledger's
10
+ * suppression and regression signals never fire.
11
+ *
12
+ * This module closes that hole from both ends:
13
+ *
14
+ * - {@link runLedgerCommit} (`--auto --ledger-commit`) commits the changed
15
+ * ledger onto a dated `chore/audit-ledger-<YYYY-MM-DD>` branch, pushes it,
16
+ * and opens a PR against `project.baseBranch` through the `gh` wrapper.
17
+ * Auto-merge is never requested: a ledger PR records machine-derived state
18
+ * a human should glance at, so landing it stays an operator decision.
19
+ * - {@link resolveLedgerSummary} answers the question the *unflagged* sweep
20
+ * needs — "would this ledger survive?" — so a run that cannot persist (no
21
+ * `origin`, or HEAD parked off the base branch) says so in its summary,
22
+ * and on stderr, instead of silently discarding the state.
23
+ *
24
+ * Both take injectable `git` / `gh` seams (`.agents/rules/test-seams.md`) so
25
+ * the branch/commit/push/PR argv shape is assertable without a live remote.
26
+ * The logic lives here rather than in `audit-to-stories.js` so the CLI file's
27
+ * complexity budget does not absorb a git driver.
28
+ */
29
+
30
+ import { gh as defaultGh } from '../gh-exec.js';
31
+ import { gitSync } from '../git-utils.js';
32
+ import { DEFAULT_LEDGER_PATH } from './ledger.js';
33
+
34
+ /** Fallback base branch when config carries no `project.baseBranch`. */
35
+ const DEFAULT_BASE_BRANCH = 'main';
36
+
37
+ /**
38
+ * Render the `YYYY-MM-DD` stamp both the branch name and the commit subject
39
+ * carry, so one sweep produces one identifiable ledger branch per day.
40
+ * @param {Date|string|number} [now]
41
+ * @returns {string}
42
+ */
43
+ function isoDate(now) {
44
+ const date = now instanceof Date ? now : new Date(now ?? Date.now());
45
+ return date.toISOString().slice(0, 10);
46
+ }
47
+
48
+ /**
49
+ * Resolve `project.baseBranch` defensively: an explicit value wins, then
50
+ * config, then `main`. A failed config resolve must never break a sweep that
51
+ * has already done its real work.
52
+ * @param {string} [explicit]
53
+ * @returns {Promise<string>}
54
+ */
55
+ async function resolveBaseBranch(explicit) {
56
+ if (typeof explicit === 'string' && explicit.length > 0) return explicit;
57
+ try {
58
+ const { resolveConfig } = await import('../config-resolver.js');
59
+ const branch = resolveConfig()?.project?.baseBranch;
60
+ if (typeof branch === 'string' && branch.length > 0) return branch;
61
+ } catch (_) {
62
+ // fall through to the default
63
+ }
64
+ return DEFAULT_BASE_BRANCH;
65
+ }
66
+
67
+ /**
68
+ * Run a read-only git probe that must never throw: a checkout with no commits
69
+ * (or no repository at all) is a legitimate answer of "nothing to report",
70
+ * not a crash. The write path below uses {@link runStep} instead, where a
71
+ * failure IS fatal.
72
+ * @param {(cwd: string, ...args: string[]) => string} git
73
+ * @param {string} cwd
74
+ * @param {string[]} args
75
+ * @returns {string} trimmed stdout, or `''` when git failed.
76
+ */
77
+ function probeGit(git, cwd, args) {
78
+ try {
79
+ const out = git(cwd, ...args);
80
+ return typeof out === 'string' ? out.trim() : '';
81
+ } catch (_) {
82
+ return '';
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Wrap one write step so a git or `gh` failure surfaces as a fatal error that
88
+ * names the step that broke. Accepts sync and async steps alike.
89
+ * @param {string} name
90
+ * @param {() => unknown} fn
91
+ * @returns {Promise<unknown>}
92
+ */
93
+ async function runStep(name, fn) {
94
+ try {
95
+ return await fn();
96
+ } catch (error) {
97
+ throw new Error(
98
+ `--ledger-commit failed at step "${name}": ${error?.message ?? error}`,
99
+ { cause: error },
100
+ );
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Inspect whether the ledger changed and whether this checkout could persist
106
+ * it at all. Module-local: the two exported entry points below are the whole
107
+ * public surface, so a probe helper never becomes a second way in.
108
+ *
109
+ * `unpersisted` is the signal the unflagged `--auto` summary carries: the
110
+ * sweep produced new memory, and this checkout has nowhere to put it — either
111
+ * there is no `origin` to push to or HEAD is not on the base branch, so a
112
+ * commit here would not reach the repository's shared state.
113
+ *
114
+ * @param {object} [params]
115
+ * @param {string} [params.ledgerPath] — defaults to `baselines/audit-ledger.json`.
116
+ * @param {string} [params.baseBranch] — defaults to resolved `project.baseBranch`.
117
+ * @param {string} [params.cwd]
118
+ * @param {(cwd: string, ...args: string[]) => string} [params.git]
119
+ * @returns {Promise<{ ledgerPath: string, baseBranch: string, changed: boolean,
120
+ * hasOrigin: boolean, headBranch: string, onBaseBranch: boolean,
121
+ * unpersisted: boolean }>}
122
+ */
123
+ async function assessLedgerPersistence({
124
+ ledgerPath = DEFAULT_LEDGER_PATH,
125
+ baseBranch,
126
+ cwd = process.cwd(),
127
+ git = gitSync,
128
+ } = {}) {
129
+ const base = await resolveBaseBranch(baseBranch);
130
+ const changed =
131
+ probeGit(git, cwd, ['status', '--porcelain', '--', ledgerPath]).length > 0;
132
+ const hasOrigin = probeGit(git, cwd, ['remote'])
133
+ .split('\n')
134
+ .map((line) => line.trim())
135
+ .includes('origin');
136
+ const headBranch = probeGit(git, cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
137
+ const onBaseBranch = headBranch === base;
138
+
139
+ return {
140
+ ledgerPath,
141
+ baseBranch: base,
142
+ changed,
143
+ hasOrigin,
144
+ headBranch,
145
+ onBaseBranch,
146
+ unpersisted: changed && (!hasOrigin || !onBaseBranch),
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Warn that the reconciled ledger has nowhere to go. Names the file, because
152
+ * "state will be lost" is unactionable without knowing which state.
153
+ * @param {{ ledgerPath: string, baseBranch: string, hasOrigin: boolean, headBranch: string }} state
154
+ * @returns {string}
155
+ */
156
+ function unpersistedWarning(state) {
157
+ const cause = state.hasOrigin
158
+ ? `HEAD is on "${state.headBranch || '(detached)'}", not the base branch "${state.baseBranch}"`
159
+ : 'this checkout has no "origin" remote';
160
+ return `ledger not persisted: ${state.ledgerPath} changed but ${cause}, so this sweep's memory will be lost when the checkout goes away. Re-run with --ledger-commit to open a PR for it, or commit ${state.ledgerPath} by hand.`;
161
+ }
162
+
163
+ /**
164
+ * Resolve the `--auto` summary's `ledger` field, annotating it with
165
+ * `unpersisted: true` (and warning on stderr) when the sweep produced memory
166
+ * this checkout cannot keep.
167
+ *
168
+ * The whole decision lives here rather than in the CLI so `runAuto` stays a
169
+ * straight-line assembly of its summary: `dryRun` and `ledgerCommit` are
170
+ * passed through raw and branched on once, in one place.
171
+ *
172
+ * @param {object} [params]
173
+ * @param {object|null} [params.ledger] — the plan's ledger summary, or null.
174
+ * @param {string} [params.ledgerPath]
175
+ * @param {boolean} [params.dryRun] — nothing was written, so nothing is at risk.
176
+ * @param {boolean} [params.ledgerCommit] — a PR is about to persist it.
177
+ * @param {string} [params.cwd]
178
+ * @param {(cwd: string, ...args: string[]) => string} [params.git]
179
+ * @param {{ warn: Function }} [params.logger]
180
+ * @returns {Promise<object|null>} the (possibly annotated) ledger summary.
181
+ */
182
+ export async function resolveLedgerSummary({
183
+ ledger = null,
184
+ ledgerPath = DEFAULT_LEDGER_PATH,
185
+ dryRun,
186
+ ledgerCommit,
187
+ cwd,
188
+ git,
189
+ logger,
190
+ } = {}) {
191
+ if (dryRun || ledgerCommit) return ledger;
192
+ const state = await assessLedgerPersistence({ ledgerPath, cwd, git });
193
+ if (!state.unpersisted) return ledger;
194
+ logger?.warn?.(unpersistedWarning(state));
195
+ return { ...(ledger ?? { path: ledgerPath }), unpersisted: true };
196
+ }
197
+
198
+ /**
199
+ * Compose the ledger PR body. Kept separate so the step sequence below reads
200
+ * as a sequence and not as a string-building exercise.
201
+ * @param {string} ledgerPath
202
+ * @param {string} date
203
+ * @returns {string}
204
+ */
205
+ function pullRequestBody(ledgerPath, date) {
206
+ return [
207
+ `Reconciles the cross-run audit ledger (\`${ledgerPath}\`) written by the`,
208
+ `unattended \`audit-to-stories --auto\` sweep on ${date}.`,
209
+ '',
210
+ 'Ledger-only change — no source, workflow or documentation file is touched.',
211
+ 'Merging it is what gives the next sweep a memory: without it the ledger',
212
+ 'dies with the checkout and every later run re-proposes findings this one',
213
+ 'already filed, and re-surfaces findings a human already rejected.',
214
+ '',
215
+ 'Auto-merge is deliberately not requested: the ledger records machine-derived',
216
+ 'lifecycle state, and a human glance before it lands is the point.',
217
+ ].join('\n');
218
+ }
219
+
220
+ /**
221
+ * Commit the changed ledger onto a dated branch and open a PR for it.
222
+ *
223
+ * Skipped — returning `{ committed: false }` with a `reason` — when the ledger
224
+ * did not change. Every git/`gh` failure is fatal and names its step; the
225
+ * caller runs this *after* printing the run summary, so a broken remote never
226
+ * costs the operator the sweep's findings.
227
+ *
228
+ * @param {object} [params]
229
+ * @param {string} [params.ledgerPath]
230
+ * @param {string} [params.baseBranch]
231
+ * @param {string} [params.cwd]
232
+ * @param {(cwd: string, ...args: string[]) => string} [params.git]
233
+ * @param {{ pr: { create: (flags: string[]) => Promise<unknown> } }} [params.gh]
234
+ * @param {Date|string|number} [params.now]
235
+ * @returns {Promise<{ committed: boolean, reason?: string, branch?: string,
236
+ * subject?: string, baseBranch?: string, ledgerPath: string }>}
237
+ */
238
+ export async function runLedgerCommit({
239
+ ledgerPath = DEFAULT_LEDGER_PATH,
240
+ baseBranch,
241
+ cwd = process.cwd(),
242
+ git = gitSync,
243
+ gh = defaultGh,
244
+ now,
245
+ } = {}) {
246
+ const state = await assessLedgerPersistence({
247
+ ledgerPath,
248
+ baseBranch,
249
+ cwd,
250
+ git,
251
+ });
252
+ if (!state.changed) {
253
+ return { committed: false, reason: 'ledger-unchanged', ledgerPath };
254
+ }
255
+
256
+ const date = isoDate(now);
257
+ const branch = `chore/audit-ledger-${date}`;
258
+ const subject = `chore(audit): reconcile audit ledger ${date}`;
259
+
260
+ await runStep('create-branch', () => git(cwd, 'checkout', '-b', branch));
261
+ await runStep('stage-ledger', () => git(cwd, 'add', '--', ledgerPath));
262
+ // The `-- <path>` pathspec is what keeps the commit ledger-only even when
263
+ // the sweep's checkout carries unrelated dirt.
264
+ await runStep('commit-ledger', () =>
265
+ git(cwd, 'commit', '-m', subject, '--', ledgerPath),
266
+ );
267
+ await runStep('push-branch', () =>
268
+ git(cwd, 'push', '--set-upstream', 'origin', branch),
269
+ );
270
+ await runStep('open-pull-request', () =>
271
+ gh.pr.create([
272
+ '--base',
273
+ state.baseBranch,
274
+ '--head',
275
+ branch,
276
+ '--title',
277
+ subject,
278
+ '--body',
279
+ pullRequestBody(ledgerPath, date),
280
+ ]),
281
+ );
282
+
283
+ return {
284
+ committed: true,
285
+ branch,
286
+ subject,
287
+ baseBranch: state.baseBranch,
288
+ ledgerPath,
289
+ };
290
+ }
@@ -20,7 +20,10 @@ import path from 'node:path';
20
20
  import { normalizeSeverity } from '../findings/severity.js';
21
21
 
22
22
  const KEY_LINE = /^\s*-\s*\*\*([^:*]+):\*\*\s*(.*)$/;
23
- const HEADING_FINDING = /^###\s+(.+?)\s*$/;
23
+ const HEADING_FINDING = /^(#{3,4})\s+(.+?)\s*$/;
24
+ const SEVERITY_KEY_LINE = /^\s*-\s*\*\*(?:severity|impact)\s*:\*\*/i;
25
+ const TALLY_LINE =
26
+ /severity\s+tally\s*:?\**\s*critical\s+(\d+)\s*\/\s*high\s+(\d+)\s*\/\s*medium\s+(\d+)\s*\/\s*low\s+(\d+)/i;
24
27
  const HEADING_SECTION = /^##\s+(.+?)\s*$/;
25
28
  const PATH_HINT =
26
29
  /(?<![\w/])([A-Za-z0-9_./\\@-]+\.(?:js|ts|tsx|jsx|mjs|cjs|md|json|yaml|yml|css|scss|html|py|go|rs|java|kt|rb|sh|ps1|tf|env))(?![\w])/g;
@@ -238,7 +241,11 @@ function splitFindingBlocks(reportText) {
238
241
  const findingMatch = HEADING_FINDING.exec(line);
239
242
  if (findingMatch) {
240
243
  if (current) blocks.push(current);
241
- current = { title: findingMatch[1].trim(), bodyLines: [] };
244
+ current = {
245
+ level: findingMatch[1].length,
246
+ title: findingMatch[2].trim(),
247
+ bodyLines: [],
248
+ };
242
249
  continue;
243
250
  }
244
251
 
@@ -249,6 +256,88 @@ function splitFindingBlocks(reportText) {
249
256
  return blocks;
250
257
  }
251
258
 
259
+ /**
260
+ * Does this block carry the axis line that makes it a finding rather than a
261
+ * section header? The skeleton mandates `Severity` (or its `Impact` alias), so
262
+ * its absence is the signal that a heading is organisational.
263
+ *
264
+ * @param {{ bodyLines: string[] }} block
265
+ * @returns {boolean}
266
+ */
267
+ function carriesSeverity(block) {
268
+ return block.bodyLines.some((line) => SEVERITY_KEY_LINE.test(line));
269
+ }
270
+
271
+ /**
272
+ * Resolve `###` headings that are **grouping headers** rather than findings.
273
+ *
274
+ * Several lenses nest their findings one level deeper — a `###` per dimension
275
+ * (`### Perceivable`), each holding `####` finding blocks. Read flat, that
276
+ * report parsed as one severity-less finding per dimension with no files and
277
+ * no recommendation, and `--auto` filed those empties (Story #5144). The rule
278
+ * this applies: a `###` heading that carries no `Severity:`/`Impact:` line and
279
+ * is followed by `####` headings is a grouping header — its `####` children
280
+ * are emitted as findings and the header itself never is.
281
+ *
282
+ * A `###` heading that DOES carry the axis line keeps the previous behaviour:
283
+ * its `####` sub-sections fold back into its own body rather than splitting
284
+ * into phantom findings, so existing flat reports parse exactly as before.
285
+ *
286
+ * @param {Array<{ level: number, title: string, bodyLines: string[] }>} blocks
287
+ * @returns {Array<{ level: number, title: string, bodyLines: string[] }>}
288
+ */
289
+ function foldGroupingHeaders(blocks) {
290
+ const out = [];
291
+ let parent = null;
292
+ for (const block of blocks) {
293
+ if (block.level <= 3) {
294
+ parent = block;
295
+ out.push(block);
296
+ continue;
297
+ }
298
+ if (!parent) {
299
+ out.push(block);
300
+ continue;
301
+ }
302
+ if (carriesSeverity(parent)) {
303
+ parent.bodyLines.push(`#### ${block.title}`, ...block.bodyLines);
304
+ continue;
305
+ }
306
+ parent.isGroupingHeader = true;
307
+ out.push(block);
308
+ }
309
+ return out.filter((block) => !block.isGroupingHeader);
310
+ }
311
+
312
+ /**
313
+ * Read the machine-readable severity tally the report envelope mandates in its
314
+ * `## Executive Summary`:
315
+ *
316
+ * ```text
317
+ * Severity tally: Critical 0 / High 2 / Medium 1 / Low 0
318
+ * ```
319
+ *
320
+ * The line is what lets a consumer cross-check what the lens says it found
321
+ * against what the parser actually extracted — a parse that silently drops
322
+ * findings is otherwise indistinguishable from a clean report. `Info` is never
323
+ * counted (the severity scale already excludes it from scheduled work).
324
+ *
325
+ * @param {string} markdown — full report text.
326
+ * @returns {{ critical: number, high: number, medium: number, low: number }|null}
327
+ * `null` when the report declares no tally at all.
328
+ */
329
+ export function parseSeverityTally(markdown) {
330
+ if (typeof markdown !== 'string') return null;
331
+ const match = TALLY_LINE.exec(markdown);
332
+ if (!match) return null;
333
+ return {
334
+ critical: Number(match[1]),
335
+ high: Number(match[2]),
336
+ medium: Number(match[3]),
337
+ low: Number(match[4]),
338
+ };
339
+ }
340
+
252
341
  function parseBlockFields(bodyLines) {
253
342
  const fields = {};
254
343
  let activeKey = null;
@@ -302,7 +391,7 @@ export function parseAuditReport({ markdown, sourceReport, repoRoot }) {
302
391
  }
303
392
 
304
393
  const fallbackDimension = inferDimensionFromReportName(sourceReport);
305
- const blocks = splitFindingBlocks(markdown);
394
+ const blocks = foldGroupingHeaders(splitFindingBlocks(markdown));
306
395
 
307
396
  return blocks.map((block) => {
308
397
  const fields = parseBlockFields(block.bodyLines);
@@ -357,6 +446,8 @@ export function parseAuditReports(reports, { repoRoot } = {}) {
357
446
  }
358
447
 
359
448
  export const __testing = {
449
+ carriesSeverity,
450
+ foldGroupingHeaders,
360
451
  normaliseSeverity,
361
452
  extractFilePaths,
362
453
  normaliseTitle,
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { SEVERITIES } from '../findings/severity.js';
21
+ import { formatEpicGrouping } from './epic-grouping-directive.js';
21
22
  import {
22
23
  renderFingerprintFooter,
23
24
  renderSemanticKeyFooter,
@@ -137,6 +138,10 @@ function formatKeyAssumptions(sourceReports) {
137
138
  * @param {string[]} params.sourceReports — list of source report paths.
138
139
  * @returns {string}
139
140
  */
141
+ /**
142
+ * @param {{ groups: object[], findings: object[], sourceReports: object[] }} opts
143
+ * @returns {string} The `/mandrel-plan` seed one-pager.
144
+ */
140
145
  export function buildPlanSeedMarkdown({ groups, findings, sourceReports }) {
141
146
  if (
142
147
  !Array.isArray(groups) ||
@@ -152,6 +157,7 @@ export function buildPlanSeedMarkdown({ groups, findings, sourceReports }) {
152
157
  const scope = formatMVPScope(groups);
153
158
  const files = formatKeyFiles(groups);
154
159
  const assumptions = formatKeyAssumptions(sourceReports);
160
+ const grouping = formatEpicGrouping(groups);
155
161
 
156
162
  return [
157
163
  '# Idea Seed: Audit Remediation',
@@ -176,6 +182,10 @@ export function buildPlanSeedMarkdown({ groups, findings, sourceReports }) {
176
182
  '',
177
183
  files,
178
184
  '',
185
+ '## Grouping',
186
+ '',
187
+ grouping,
188
+ '',
179
189
  '## Not Doing',
180
190
  '',
181
191
  '- Findings with severity below the operator-selected threshold.',
@@ -74,8 +74,26 @@ export function isValidTransition(fromState, toState) {
74
74
  return allowed.includes(toState);
75
75
  }
76
76
 
77
+ /**
78
+ * Ticket-type axis.
79
+ *
80
+ * `STORY` is the only type carrying an execution payload — it is what
81
+ * `/mandrel-deliver` branches, implements and lands.
82
+ *
83
+ * `EPIC` (Story #5139) is a **pure container**: a grouping ticket that holds
84
+ * a `## Goal` paragraph and a child checklist and nothing else. It is never
85
+ * branched, never implemented, and never carries an `agent::*` label — that
86
+ * absence is what keeps it out of the bare `/mandrel-deliver` ready list and
87
+ * outside `lint-issue-body.js`, which is `type::story`-scoped.
88
+ *
89
+ * This is deliberately NOT a revival of the v1 Epic tier. Linkage runs
90
+ * parent→child only (the Epic body's checklist plus native sub-issue edges),
91
+ * so Story bodies stay untouched and the `Epic: #N` footer stays retired and
92
+ * refused. See ADR `20260905-container-epic` in `docs/decisions.md`.
93
+ */
77
94
  export const TYPE_LABELS = {
78
95
  STORY: 'type::story',
96
+ EPIC: 'type::epic',
79
97
  };
80
98
 
81
99
  export const STATUS_LABELS = {
@@ -20,14 +20,27 @@ import {
20
20
  TYPE_LABELS,
21
21
  } from './label-constants.js';
22
22
 
23
+ /**
24
+ * The ticket-type axis. Both rows share one colour, so they are derived from
25
+ * `[name, description]` pairs rather than restated as full literals — Story
26
+ * #5139 added `type::epic` here, and the derived form absorbs it without
27
+ * growing the file's structural weight.
28
+ *
29
+ * @type {Array<{ name: string, color: string, description: string }>}
30
+ */
31
+ const TYPE_LABEL_ROWS = [
32
+ [TYPE_LABELS.STORY, 'Story work item'],
33
+ [TYPE_LABELS.EPIC, 'Container-only grouping ticket for child Stories'],
34
+ ].map(([name, description]) => ({
35
+ name,
36
+ color: LABEL_COLORS.TYPE,
37
+ description,
38
+ }));
39
+
23
40
  /** @type {Array<{ name: string, color: string, description: string }>} */
24
41
  export const LABEL_TAXONOMY = [
25
42
  // Type
26
- {
27
- name: TYPE_LABELS.STORY,
28
- color: LABEL_COLORS.TYPE,
29
- description: 'Story work item',
30
- },
43
+ ...TYPE_LABEL_ROWS,
31
44
 
32
45
  // Agent State
33
46
  {