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.
- package/.agents/README.md +8 -5
- package/.agents/agents/auditor.md +5 -0
- package/.agents/docs/SDLC.md +21 -12
- package/.agents/instructions.md +22 -22
- package/.agents/rules/api-conventions.md +43 -7
- package/.agents/rules/ci-remediation.md +3 -14
- package/.agents/rules/gherkin-standards.md +21 -6
- package/.agents/rules/git-conventions.md +6 -5
- package/.agents/rules/security-baseline.md +6 -7
- package/.agents/rules/testing-standards.md +75 -198
- package/.agents/scripts/audit-to-stories.js +510 -66
- package/.agents/scripts/install-matrix-assert.js +2 -2
- package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +45 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
- package/.agents/scripts/lib/label-constants.js +18 -0
- package/.agents/scripts/lib/label-taxonomy.js +18 -5
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +139 -0
- package/.agents/scripts/lib/orchestration/epic-candidates.js +159 -0
- package/.agents/scripts/lib/orchestration/epic-checklist.js +103 -0
- package/.agents/scripts/lib/orchestration/epic-container.js +202 -0
- package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +97 -36
- package/.agents/scripts/lib/orchestration/plan-persist/cross-plan-links.js +80 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +192 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +324 -0
- package/.agents/scripts/lib/orchestration/plan-persist/external-deps.js +164 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +30 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +26 -5
- package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +11 -1
- package/.agents/scripts/plan-persist.js +98 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
- package/.agents/scripts/resolve-stories.js +42 -2
- package/.agents/skills/core/browser-testing-with-devtools/SKILL.md +5 -10
- package/.agents/skills/core/browser-testing-with-devtools/reference.md +7 -46
- package/.agents/skills/core/code-review-and-quality/SKILL.md +0 -5
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +0 -3
- package/.agents/skills/core/gates-and-baselines/SKILL.md +10 -137
- package/.agents/skills/core/gates-and-baselines/reference.md +103 -0
- package/.agents/skills/core/idea-refinement/SKILL.md +2 -18
- package/.agents/skills/core/security-and-hardening/SKILL.md +2 -4
- package/.agents/skills/core/security-and-hardening/reference.md +0 -70
- package/.agents/skills/skills.index.json +10 -70
- package/.agents/skills/stack/qa/gherkin-authoring/SKILL.md +4 -10
- package/.agents/skills/stack/qa/gherkin-authoring/reference.md +9 -113
- package/.agents/skills/stack/qa/playwright-bdd/SKILL.md +29 -154
- package/.agents/skills/stack/qa/qa-harness/SKILL.md +157 -98
- package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
- package/.agents/workflows/audit-to-stories.md +85 -7
- package/.agents/workflows/git-cleanup.md +3 -2
- package/.agents/workflows/git-deliver.md +3 -2
- package/.agents/workflows/helpers/audit-lens-core.md +24 -4
- package/.agents/workflows/helpers/deliver-reference.md +8 -0
- package/.agents/workflows/helpers/plan-reference.md +109 -1
- package/.agents/workflows/mandrel-deliver.md +47 -43
- package/.agents/workflows/mandrel-plan.md +61 -55
- package/.agents/workflows/qa-assist.md +20 -17
- package/.agents/workflows/qa-explore.md +30 -29
- package/.agents/workflows/qa-run.md +2 -1
- package/docs/CHANGELOG.md +30 -0
- package/package.json +1 -1
- package/.agents/rules/changelog-style.md +0 -180
- package/.agents/rules/shell-conventions.md +0 -61
- package/.agents/scripts/lib/qa/coverage-verdict.js +0 -214
- package/.agents/skills/core/api-and-interface-design/SKILL.md +0 -55
- package/.agents/skills/core/api-and-interface-design/reference.md +0 -76
- package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +0 -45
- package/.agents/skills/core/debugging-and-error-recovery/reference.md +0 -56
- package/.agents/skills/core/git-workflow-and-versioning/SKILL.md +0 -54
- package/.agents/skills/core/idea-refinement/refinement-criteria.md +0 -155
- package/.agents/skills/core/idea-refinement/scripts/idea-refine.sh +0 -15
- package/.agents/skills/core/qa-coverage-mapping/SKILL.md +0 -105
- package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +0 -152
- 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(
|
|
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
|
|
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)
|
|
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
|
|
827
|
+
slugToIssueNumber,
|
|
807
828
|
getTicket: (issueNumber) => provider.getTicket(issueNumber),
|
|
808
829
|
owner,
|
|
809
830
|
repo,
|