mandrel 2.39.0 → 2.41.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 (76) hide show
  1. package/.agents/README.md +8 -5
  2. package/.agents/agents/auditor.md +5 -0
  3. package/.agents/docs/SDLC.md +21 -12
  4. package/.agents/instructions.md +22 -22
  5. package/.agents/rules/api-conventions.md +43 -7
  6. package/.agents/rules/ci-remediation.md +3 -14
  7. package/.agents/rules/gherkin-standards.md +21 -6
  8. package/.agents/rules/git-conventions.md +6 -5
  9. package/.agents/rules/security-baseline.md +6 -7
  10. package/.agents/rules/testing-standards.md +75 -198
  11. package/.agents/scripts/audit-to-stories.js +510 -66
  12. package/.agents/scripts/install-matrix-assert.js +2 -2
  13. package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +45 -0
  14. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
  15. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
  16. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
  17. package/.agents/scripts/lib/label-constants.js +18 -0
  18. package/.agents/scripts/lib/label-taxonomy.js +18 -5
  19. package/.agents/scripts/lib/orchestration/dependency-candidates.js +139 -0
  20. package/.agents/scripts/lib/orchestration/epic-candidates.js +159 -0
  21. package/.agents/scripts/lib/orchestration/epic-checklist.js +103 -0
  22. package/.agents/scripts/lib/orchestration/epic-container.js +202 -0
  23. package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
  24. package/.agents/scripts/lib/orchestration/plan-context.js +97 -36
  25. package/.agents/scripts/lib/orchestration/plan-persist/cross-plan-links.js +80 -0
  26. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +192 -0
  27. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +324 -0
  28. package/.agents/scripts/lib/orchestration/plan-persist/external-deps.js +164 -0
  29. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +30 -0
  30. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +26 -5
  31. package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
  32. package/.agents/scripts/lib/orchestration/ticket-validator.js +11 -1
  33. package/.agents/scripts/plan-persist.js +98 -1
  34. package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
  35. package/.agents/scripts/resolve-stories.js +42 -2
  36. package/.agents/skills/core/browser-testing-with-devtools/SKILL.md +5 -10
  37. package/.agents/skills/core/browser-testing-with-devtools/reference.md +7 -46
  38. package/.agents/skills/core/code-review-and-quality/SKILL.md +0 -5
  39. package/.agents/skills/core/documentation-and-adrs/SKILL.md +0 -3
  40. package/.agents/skills/core/gates-and-baselines/SKILL.md +10 -137
  41. package/.agents/skills/core/gates-and-baselines/reference.md +103 -0
  42. package/.agents/skills/core/idea-refinement/SKILL.md +2 -18
  43. package/.agents/skills/core/security-and-hardening/SKILL.md +2 -4
  44. package/.agents/skills/core/security-and-hardening/reference.md +0 -70
  45. package/.agents/skills/skills.index.json +10 -70
  46. package/.agents/skills/stack/qa/gherkin-authoring/SKILL.md +4 -10
  47. package/.agents/skills/stack/qa/gherkin-authoring/reference.md +9 -113
  48. package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +29 -154
  49. package/.agents/skills/stack/qa/qa-harness/SKILL.md +157 -98
  50. package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
  51. package/.agents/workflows/audit-to-stories.md +85 -7
  52. package/.agents/workflows/git-cleanup.md +3 -2
  53. package/.agents/workflows/git-deliver.md +3 -2
  54. package/.agents/workflows/helpers/audit-lens-core.md +24 -4
  55. package/.agents/workflows/helpers/deliver-reference.md +8 -0
  56. package/.agents/workflows/helpers/plan-reference.md +109 -1
  57. package/.agents/workflows/mandrel-deliver.md +47 -43
  58. package/.agents/workflows/mandrel-plan.md +61 -55
  59. package/.agents/workflows/qa-assist.md +20 -17
  60. package/.agents/workflows/qa-explore.md +30 -29
  61. package/.agents/workflows/qa-run.md +2 -1
  62. package/docs/CHANGELOG.md +30 -0
  63. package/package.json +1 -1
  64. package/.agents/rules/changelog-style.md +0 -180
  65. package/.agents/rules/shell-conventions.md +0 -61
  66. package/.agents/scripts/lib/qa/coverage-verdict.js +0 -214
  67. package/.agents/skills/core/api-and-interface-design/SKILL.md +0 -55
  68. package/.agents/skills/core/api-and-interface-design/reference.md +0 -76
  69. package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +0 -45
  70. package/.agents/skills/core/debugging-and-error-recovery/reference.md +0 -56
  71. package/.agents/skills/core/git-workflow-and-versioning/SKILL.md +0 -54
  72. package/.agents/skills/core/idea-refinement/refinement-criteria.md +0 -155
  73. package/.agents/skills/core/idea-refinement/scripts/idea-refine.sh +0 -15
  74. package/.agents/skills/core/qa-coverage-mapping/SKILL.md +0 -105
  75. package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +0 -152
  76. package/.agents/skills/stack/qa/vitest/SKILL.md +0 -22
@@ -0,0 +1,324 @@
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
+ * Exported since Story #5155 so the adoption path (`epic-adoption.js`) links
154
+ * children exactly the way creation does — one mirroring rule, not two that
155
+ * drift.
156
+ *
157
+ * @param {{ provider: object, epicNumber: number, childIds: number[] }} opts
158
+ * @returns {Promise<{ added: number, skipped: number, failed: number }|null>}
159
+ */
160
+ export async function mirrorSubIssueEdges({ provider, epicNumber, childIds }) {
161
+ if (
162
+ typeof provider?.getDependencyWriteContext !== 'function' ||
163
+ typeof provider?.getTicket !== 'function'
164
+ ) {
165
+ Logger.warn(
166
+ '[plan-persist] provider exposes no getDependencyWriteContext/getTicket — ' +
167
+ 'skipping native sub-issue edges. The Epic body checklist still lists every child.',
168
+ );
169
+ return null;
170
+ }
171
+
172
+ try {
173
+ const { gh, owner, repo } = provider.getDependencyWriteContext();
174
+ const summary = await linkStoriesToEpic({
175
+ epicNumber,
176
+ childIssueNumbers: childIds,
177
+ getTicket: (issueNumber) => provider.getTicket(issueNumber),
178
+ owner,
179
+ repo,
180
+ gh,
181
+ });
182
+ if (summary.failed > 0) {
183
+ Logger.warn(
184
+ `[plan-persist] ${summary.failed} sub-issue edge(s) could not be written. ` +
185
+ 'The Epic body checklist still lists every child; add the links by hand ' +
186
+ 'if you want them nested in the GitHub UI.',
187
+ );
188
+ } else {
189
+ Logger.info(
190
+ `[plan-persist] sub-issue edges: ${summary.added} added, ` +
191
+ `${summary.skipped} already present.`,
192
+ );
193
+ }
194
+ return summary;
195
+ } catch (err) {
196
+ Logger.warn(
197
+ `[plan-persist] native sub-issue mirroring failed (${err.message}) — ` +
198
+ 'the Epic body checklist still lists every child.',
199
+ );
200
+ return null;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Create the container Epic for a persisted cohort.
206
+ *
207
+ * Returns `null` whenever no Epic was created — not requested, too few
208
+ * Stories, or the label could not be ensured. Callers treat `null` as the
209
+ * ordinary no-Epic outcome, never as a failure.
210
+ *
211
+ * **The Epic never receives an `agent::*` label.** Its labels are exactly
212
+ * `[type::epic]`. That absence is load-bearing: it keeps the container out
213
+ * of the bare `/mandrel-deliver` ready list and outside `lint-issue-body.js`,
214
+ * which scopes itself to `type::story`.
215
+ *
216
+ * @param {{
217
+ * provider: object,
218
+ * epic: { title: string, goal: string }|null,
219
+ * created: Array<{ id: number, title: string }>,
220
+ * opts?: { dryRun?: boolean, minStories?: number },
221
+ * }} args
222
+ * @returns {Promise<{
223
+ * id: number,
224
+ * title: string,
225
+ * url?: string,
226
+ * childIds: number[],
227
+ * adopted: boolean,
228
+ * edges: { added: number, skipped: number, failed: number }|null,
229
+ * }|null>}
230
+ */
231
+ export async function createContainerEpic({
232
+ provider,
233
+ epic,
234
+ created,
235
+ opts = {},
236
+ }) {
237
+ const { dryRun = false, minStories = EPIC_SUGGESTION_THRESHOLD } = opts;
238
+ if (!epic) return null;
239
+
240
+ const title = typeof epic.title === 'string' ? epic.title.trim() : '';
241
+ const goal = typeof epic.goal === 'string' ? epic.goal.trim() : '';
242
+ if (title === '' || goal === '') {
243
+ throw new Error(
244
+ '[plan-persist] A container Epic requires both a title and a goal.',
245
+ );
246
+ }
247
+
248
+ const childIds = (Array.isArray(created) ? created : [])
249
+ .map((s) => s.id)
250
+ .filter((id) => Number.isInteger(id) && id > 0);
251
+
252
+ // Dry-run reports the intended container write-free. `created` carries
253
+ // negative placeholder ids there, so `childIds` is empty by construction —
254
+ // report the count from `created` itself rather than from the filtered list.
255
+ if (dryRun) {
256
+ return {
257
+ id: -1,
258
+ title,
259
+ childIds: (Array.isArray(created) ? created : []).map((s) => s.id),
260
+ adopted: false,
261
+ edges: null,
262
+ };
263
+ }
264
+
265
+ if (childIds.length < minStories) {
266
+ Logger.info(
267
+ `[plan-persist] ${childIds.length} Story(ies) is below the ${minStories}-Story ` +
268
+ 'Epic threshold — no container created.',
269
+ );
270
+ return null;
271
+ }
272
+
273
+ if (!(await ensureEpicLabel({ provider }))) return null;
274
+
275
+ const fingerprint = epicFingerprint({ title, childIds });
276
+ const existing = await findExistingEpic({ provider, fingerprint });
277
+ if (existing) {
278
+ Logger.info(
279
+ `[plan-persist] resuming: container Epic #${existing.id} already groups ` +
280
+ 'this exact cohort — skipping create.',
281
+ );
282
+ const edges = await mirrorSubIssueEdges({
283
+ provider,
284
+ epicNumber: existing.id,
285
+ childIds,
286
+ });
287
+ return {
288
+ id: existing.id,
289
+ title,
290
+ url: existing.url,
291
+ childIds,
292
+ adopted: true,
293
+ edges,
294
+ };
295
+ }
296
+
297
+ const body = `${composeEpicBody({ goal, childIds })}\n${epicFingerprintMarker(fingerprint)}\n`;
298
+ const result = await provider.createIssue({
299
+ title,
300
+ body,
301
+ labels: [TYPE_LABELS.EPIC],
302
+ });
303
+
304
+ const epicNumber = result.number ?? result.id;
305
+ const edges = await mirrorSubIssueEdges({
306
+ provider,
307
+ epicNumber,
308
+ childIds,
309
+ });
310
+
311
+ Logger.info(
312
+ `[plan-persist] container Epic #${epicNumber} groups ${childIds.length} Story(ies): ` +
313
+ `deliver them all with /mandrel-deliver ${epicNumber}`,
314
+ );
315
+
316
+ return {
317
+ id: epicNumber,
318
+ title,
319
+ url: result.url,
320
+ childIds,
321
+ adopted: false,
322
+ edges,
323
+ };
324
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * external-deps.js — `depends_on` entries that point outside this plan run.
3
+ *
4
+ * Story #5155. A `depends_on[]` entry has always been a **sibling slug**: a
5
+ * name resolvable only inside the `stories.json` being persisted. That makes
6
+ * every ordering edge intra-plan by construction, and leaves the cross-plan
7
+ * case — a new Story that must wait for an open Story from an earlier run —
8
+ * expressible only by hand-editing the issue body after persist.
9
+ *
10
+ * An entry of the form `#1234` is that missing case: an **external** blocker,
11
+ * already live on the tracker. The two forms are distinguished lexically and
12
+ * totally, so nothing has to guess:
13
+ *
14
+ * - `some-slug` → a sibling, resolved against this run's slug map;
15
+ * - `#1234` → an existing issue, resolved against the tracker.
16
+ *
17
+ * External refs are excluded from sibling ordering and cycle detection. They
18
+ * cannot participate in either: a Story already open is not scheduled by this
19
+ * run, so it has no position in the topological sort, and it cannot close a
20
+ * cycle back into a Story that does not exist yet. Treating them as siblings
21
+ * is what would break — the unknown-slug guard would reject every one.
22
+ *
23
+ * They are validated strictly, and **before any create**: an unresolvable
24
+ * blocker that surfaced after the fact would leave a live Story gated on
25
+ * something that can never satisfy it, which the delivery engine reads as a
26
+ * permanent wedge rather than an error.
27
+ *
28
+ * @module lib/orchestration/plan-persist/external-deps
29
+ * @see Story #5155
30
+ */
31
+
32
+ import { TYPE_LABELS } from '../../label-constants.js';
33
+
34
+ /** A `depends_on` entry naming an existing issue: `#` followed by digits. */
35
+ const EXTERNAL_REF_RE = /^#(\d+)$/;
36
+
37
+ /**
38
+ * Is this `depends_on` entry an external issue reference?
39
+ *
40
+ * @param {unknown} entry
41
+ * @returns {boolean}
42
+ */
43
+ export function isExternalDependencyRef(entry) {
44
+ return typeof entry === 'string' && EXTERNAL_REF_RE.test(entry.trim());
45
+ }
46
+
47
+ /**
48
+ * The issue number an external ref names, or `null` for a sibling slug.
49
+ *
50
+ * @param {unknown} entry
51
+ * @returns {number|null}
52
+ */
53
+ export function externalDependencyId(entry) {
54
+ if (typeof entry !== 'string') return null;
55
+ const match = entry.trim().match(EXTERNAL_REF_RE);
56
+ if (!match) return null;
57
+ const id = Number.parseInt(match[1], 10);
58
+ return Number.isInteger(id) && id > 0 ? id : null;
59
+ }
60
+
61
+ /**
62
+ * Every distinct external id declared across a plan's Stories, in first-seen
63
+ * order.
64
+ *
65
+ * @param {Array<{ depends_on?: string[] }>} stories
66
+ * @returns {number[]}
67
+ */
68
+ export function collectExternalDependencyIds(stories) {
69
+ const seen = new Set();
70
+ const out = [];
71
+ for (const story of Array.isArray(stories) ? stories : []) {
72
+ for (const entry of story?.depends_on ?? []) {
73
+ const id = externalDependencyId(entry);
74
+ if (id === null || seen.has(id)) continue;
75
+ seen.add(id);
76
+ out.push(id);
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /**
83
+ * Normalize an issue's labels to plain strings.
84
+ *
85
+ * @param {unknown} raw
86
+ * @returns {string[]}
87
+ */
88
+ function labelNames(raw) {
89
+ if (!Array.isArray(raw)) return [];
90
+ return raw
91
+ .map((l) => (typeof l === 'string' ? l : l?.name))
92
+ .filter((n) => typeof n === 'string');
93
+ }
94
+
95
+ /**
96
+ * Explain why one external blocker is unusable, or `null` when it is fine.
97
+ *
98
+ * @param {number} id
99
+ * @param {object|null} issue
100
+ * @returns {string|null}
101
+ */
102
+ function rejectionReason(id, issue) {
103
+ if (!issue) return `#${id} does not exist`;
104
+ const state = String(issue.state ?? 'open').toLowerCase();
105
+ if (state !== 'open') {
106
+ return `#${id} is ${state} — a landed Story cannot gate new work, so the edge would never lift`;
107
+ }
108
+ const labels = labelNames(issue.labels);
109
+ if (labels.includes(TYPE_LABELS.EPIC)) {
110
+ return `#${id} is a container Epic — Epics are never delivered, so nothing would ever satisfy the edge`;
111
+ }
112
+ if (!labels.includes(TYPE_LABELS.STORY)) {
113
+ return `#${id} is not a ${TYPE_LABELS.STORY} — only a Story can be delivered and thereby unblock this one`;
114
+ }
115
+ return null;
116
+ }
117
+
118
+ /**
119
+ * Verify every external `depends_on` ref resolves to an open Story.
120
+ *
121
+ * Hard-errors listing **every** bad ref rather than the first, so an operator
122
+ * fixing a plan sees the whole set in one pass.
123
+ *
124
+ * @param {{ provider: object, stories: Array<{ slug: string, depends_on?: string[] }> }} args
125
+ * @returns {Promise<number[]>} The validated external ids (possibly empty).
126
+ * @throws {Error} When any ref is missing, closed, an Epic, or not a Story.
127
+ */
128
+ export async function assertExternalDependenciesResolvable({
129
+ provider,
130
+ stories,
131
+ }) {
132
+ const ids = collectExternalDependencyIds(stories);
133
+ if (ids.length === 0) return [];
134
+
135
+ if (typeof provider?.getTicket !== 'function') {
136
+ throw new Error(
137
+ '[plan-persist] provider exposes no getTicket — cannot verify the external ' +
138
+ `depends_on reference(s): ${ids.map((i) => `#${i}`).join(', ')}.`,
139
+ );
140
+ }
141
+
142
+ const problems = [];
143
+ for (const id of ids) {
144
+ let issue = null;
145
+ try {
146
+ issue = await provider.getTicket(id);
147
+ } catch (err) {
148
+ problems.push(`#${id} could not be read (${err?.message ?? err})`);
149
+ continue;
150
+ }
151
+ const reason = rejectionReason(id, issue);
152
+ if (reason) problems.push(reason);
153
+ }
154
+
155
+ if (problems.length > 0) {
156
+ throw new Error(
157
+ `[plan-persist] ${problems.length} external depends_on reference(s) cannot gate ` +
158
+ `this plan:\n - ${problems.join('\n - ')}\n\nEvery "#<id>" entry must name an ` +
159
+ `open ${TYPE_LABELS.STORY}. Drop the entry, or point it at a Story that is still open.`,
160
+ );
161
+ }
162
+
163
+ return ids;
164
+ }
@@ -68,6 +68,10 @@ import {
68
68
  renderHardConflictError,
69
69
  } from '../ticket-validator-conflicts.js';
70
70
  import { upsertStructuredComment } from '../ticketing.js';
71
+ import {
72
+ resolveContainerEpic,
73
+ resolveCrossPlanLinks,
74
+ } from './cross-plan-links.js';
71
75
  import {
72
76
  enforceFanOutGate,
73
77
  surfaceSoftConflictFindings,
@@ -711,6 +715,10 @@ export async function runPlanPersist({
711
715
  closeSuperseded = true,
712
716
  routeDowngradeReason = null,
713
717
  injectedRules = undefined,
718
+ // Story #5139 — the optional container Epic. `null` (the default) is the
719
+ // ordinary shape: no Epic is created unless `/mandrel-plan` offered one
720
+ // above the threshold and the operator confirmed it.
721
+ epic = null,
714
722
  } = opts;
715
723
 
716
724
  // Boundary for the plan-metrics summary below: everything this invocation
@@ -746,6 +754,14 @@ export async function runPlanPersist({
746
754
  });
747
755
  await enforceReachability(reachability, config);
748
756
 
757
+ // Story #5155 — the plan's outward references (`--epic <id>`, and any
758
+ // `#<id>` blocker) resolve BEFORE the first create, dry run included.
759
+ const adoptionTarget = await resolveCrossPlanLinks({
760
+ provider,
761
+ stories: rawStories,
762
+ epicId: opts.adoptEpicId ?? null,
763
+ });
764
+
749
765
  // Split policy + inline Spec fold (over-budget Specs fail closed — no docs/).
750
766
  const { stories } = assemblePlanStories(rawStories, {
751
767
  sharedSpec: techSpecContent,
@@ -840,6 +856,19 @@ export async function runPlanPersist({
840
856
  });
841
857
  }
842
858
 
859
+ // Story #5139 — the container Epic is created LAST among the writes: its
860
+ // body embeds the child issue numbers and its sub-issue edges need their
861
+ // database ids, neither of which exists until the Stories are live. It is
862
+ // never load-bearing, so a failure here degrades to "no container" and the
863
+ // Stories still deliver by id.
864
+ const containerEpic = await resolveContainerEpic({
865
+ provider,
866
+ adoptionTarget,
867
+ epic,
868
+ created,
869
+ opts: { dryRun },
870
+ });
871
+
843
872
  const supersede = await runSupersedePhase({
844
873
  provider,
845
874
  stories,
@@ -866,5 +895,6 @@ export async function runPlanPersist({
866
895
  freshness,
867
896
  waveTable,
868
897
  supersede,
898
+ epic: containerEpic,
869
899
  };
870
900
  }
@@ -34,6 +34,10 @@ import {
34
34
  } from '../../util/concurrent-map.js';
35
35
  import { assertSpecWithinBudget } from '../spec-spill.js';
36
36
  import { assertAcceptancePartition } from '../split-policy-validator.js';
37
+ import {
38
+ externalDependencyId,
39
+ isExternalDependencyRef,
40
+ } from './external-deps.js';
37
41
  import {
38
42
  assertSupersedePartition,
39
43
  normalizeSupersedes,
@@ -557,7 +561,9 @@ function orderStoriesByDependencies(stories) {
557
561
  const list = Array.isArray(stories) ? stories : [];
558
562
  const known = new Set(list.map((story) => story.slug));
559
563
  for (const story of list) {
560
- const unknown = story.depends_on.filter((slug) => !known.has(slug));
564
+ const unknown = story.depends_on.filter(
565
+ (slug) => !isExternalDependencyRef(slug) && !known.has(slug),
566
+ );
561
567
  if (unknown.length > 0) {
562
568
  throw new Error(
563
569
  `[plan-persist] Story "${story.slug}" depends on unknown sibling(s): ${unknown.join(', ')}`,
@@ -568,8 +574,13 @@ function orderStoriesByDependencies(stories) {
568
574
  const scheduled = new Set();
569
575
  const pending = [...list];
570
576
  while (pending.length > 0) {
577
+ // External refs gate delivery, never creation order: the blocker is
578
+ // already live, so it can never become "scheduled" in this run and would
579
+ // otherwise wedge the sort into a false cycle (Story #5155).
571
580
  const index = pending.findIndex((story) =>
572
- story.depends_on.every((slug) => scheduled.has(slug)),
581
+ story.depends_on
582
+ .filter((slug) => !isExternalDependencyRef(slug))
583
+ .every((slug) => scheduled.has(slug)),
573
584
  );
574
585
  if (index === -1) {
575
586
  throw new Error(
@@ -720,8 +731,8 @@ function warnOnDivergentSameTitleStory(story, idsByTitle) {
720
731
  * @returns {string}
721
732
  */
722
733
  function renderStoryBodyForCreate(story, idBySlug) {
723
- const dependencyRefs = story.depends_on.map(
724
- (slug) => `#${idBySlug.get(slug)}`,
734
+ const dependencyRefs = story.depends_on.map((slug) =>
735
+ isExternalDependencyRef(slug) ? slug.trim() : `#${idBySlug.get(slug)}`,
725
736
  );
726
737
  let base = story.body;
727
738
  if (dependencyRefs.length > 0) {
@@ -798,12 +809,22 @@ async function mirrorNativeDependencyEdges({ provider, stories, idBySlug }) {
798
809
 
799
810
  try {
800
811
  const { gh, owner, repo } = provider.getDependencyWriteContext();
812
+ // `applyBlockedByDependencies` resolves every entry through this one map,
813
+ // so an external `#<id>` ref only needs an identity entry to be mirrored
814
+ // by the same code path as a sibling (Story #5155).
815
+ const slugToIssueNumber = Object.fromEntries(idBySlug);
816
+ for (const story of stories) {
817
+ for (const entry of story.depends_on) {
818
+ const externalId = externalDependencyId(entry);
819
+ if (externalId !== null) slugToIssueNumber[entry.trim()] = externalId;
820
+ }
821
+ }
801
822
  const summary = await applyBlockedByDependencies({
802
823
  stories: stories.map((story) => ({
803
824
  slug: story.slug,
804
825
  dependsOn: story.depends_on,
805
826
  })),
806
- slugToIssueNumber: Object.fromEntries(idBySlug),
827
+ slugToIssueNumber,
807
828
  getTicket: (issueNumber) => provider.getTicket(issueNumber),
808
829
  owner,
809
830
  repo,