mandrel 2.38.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.
- package/.agents/README.md +51 -11
- package/.agents/agents/auditor.md +5 -0
- package/.agents/docs/SDLC.md +21 -12
- package/.agents/docs/agentrc-reference.json +1 -4
- package/.agents/docs/configuration.md +2 -2
- package/.agents/instructions.md +17 -16
- package/.agents/schemas/agentrc.schema.json +6 -7
- package/.agents/scripts/audit-to-stories.js +510 -66
- package/.agents/scripts/generate-skills-index.js +158 -75
- package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +39 -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/changed-files.js +100 -9
- package/.agents/scripts/lib/config-settings-schema.js +25 -7
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/label-constants.js +18 -0
- package/.agents/scripts/lib/label-taxonomy.js +18 -5
- package/.agents/scripts/lib/orchestration/epic-container.js +186 -0
- package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +320 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +18 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +58 -6
- package/.agents/scripts/lib/skills/skills-index.js +168 -0
- package/.agents/scripts/lib/skills/walk-skill-files.js +133 -9
- package/.agents/scripts/plan-persist.js +39 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
- package/.agents/scripts/quality-preview.js +50 -9
- package/.agents/scripts/resolve-stories.js +42 -2
- package/.agents/scripts/validate-skills.js +53 -66
- package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
- package/.agents/workflows/audit-to-stories.md +85 -7
- 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 +28 -0
- package/.agents/workflows/mandrel-deliver.md +47 -43
- package/.agents/workflows/mandrel-plan.md +44 -38
- package/.agents/workflows/qa-run.md +13 -5
- package/docs/CHANGELOG.md +28 -0
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* 2. Rolls up friction follow-ups across every Story in the run and
|
|
8
8
|
* files/posts them on the primary Story.
|
|
9
9
|
* 3. Checks sibling Spec/acceptance coherence across Story bodies.
|
|
10
|
+
* 4. Closes any container Epic whose children all landed (Story #5139) —
|
|
11
|
+
* the only completion cascade v2 reintroduces.
|
|
10
12
|
*
|
|
11
13
|
* There is no inert planner-only path: `planRunEpilogue` enumerates steps
|
|
12
14
|
* and `runPlanRunEpilogue` executes them. Single-Story runs skip the
|
|
@@ -19,6 +21,8 @@ import { selectAudits } from '../audit-suite/index.js';
|
|
|
19
21
|
import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
|
|
20
22
|
import { gitSpawn } from '../git-utils.js';
|
|
21
23
|
import { Logger } from '../Logger.js';
|
|
24
|
+
import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
|
|
25
|
+
import { isEpicTicket, readEpicChildIds } from './epic-container.js';
|
|
22
26
|
import { composeRoutedProposals } from './retro-proposals.js';
|
|
23
27
|
import {
|
|
24
28
|
assessRollupOutcome,
|
|
@@ -31,14 +35,130 @@ import { upsertStructuredComment } from './ticketing.js';
|
|
|
31
35
|
|
|
32
36
|
/**
|
|
33
37
|
* Canonical epilogue step kinds, in execution order.
|
|
34
|
-
* @type {readonly ['audit-roster', 'follow-up-rollup', 'sibling-coherence']}
|
|
38
|
+
* @type {readonly ['audit-roster', 'follow-up-rollup', 'sibling-coherence', 'epic-close']}
|
|
35
39
|
*/
|
|
36
40
|
export const RUN_EPILOGUE_STEP_KINDS = Object.freeze([
|
|
37
41
|
'audit-roster',
|
|
38
42
|
'follow-up-rollup',
|
|
39
43
|
'sibling-coherence',
|
|
44
|
+
'epic-close',
|
|
40
45
|
]);
|
|
41
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Close a container Epic once every child Story has landed.
|
|
49
|
+
*
|
|
50
|
+
* This is the **only** completion cascade v2 reintroduces (Story #5139), and
|
|
51
|
+
* it is deliberately one-directional: closing the container, never touching a
|
|
52
|
+
* child's state, never reopening.
|
|
53
|
+
*
|
|
54
|
+
* The lookup runs child→parent by scanning open Epics, because linkage is
|
|
55
|
+
* parent→child only — a Story body carries no pointer back. That is the
|
|
56
|
+
* price of leaving Story bodies untouched, and it is cheap: open Epics are
|
|
57
|
+
* few, and the scan is scoped to Epics that actually contain one of this
|
|
58
|
+
* run's delivered Stories, so an unrelated Epic is never swept.
|
|
59
|
+
*
|
|
60
|
+
* Non-fatal throughout: the epilogue is a reporting tail, and a container
|
|
61
|
+
* left open costs tidiness, not correctness.
|
|
62
|
+
*
|
|
63
|
+
* @param {{ stories: string[], provider: object }} opts
|
|
64
|
+
* @returns {Promise<{ kind: string, closed: number[], pending: number[] }>}
|
|
65
|
+
*/
|
|
66
|
+
async function executeEpicClose({ stories, provider }) {
|
|
67
|
+
const result = { kind: 'epic-close', closed: [], pending: [] };
|
|
68
|
+
if (
|
|
69
|
+
typeof provider?.listIssuesByLabel !== 'function' ||
|
|
70
|
+
typeof provider?.updateTicket !== 'function'
|
|
71
|
+
) {
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const delivered = new Set(stories.map((id) => Number(id)));
|
|
76
|
+
let epics;
|
|
77
|
+
try {
|
|
78
|
+
epics = await provider.listIssuesByLabel({
|
|
79
|
+
state: 'open',
|
|
80
|
+
labels: TYPE_LABELS.EPIC,
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
Logger.warn(
|
|
84
|
+
`[run-epilogue] Could not list open Epics (${err?.message ?? err}); skipping the Epic close.`,
|
|
85
|
+
);
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const epic of Array.isArray(epics) ? epics : []) {
|
|
90
|
+
if (!isEpicTicket(epic)) continue;
|
|
91
|
+
const epicId = Number(epic?.number ?? epic?.id);
|
|
92
|
+
if (!Number.isInteger(epicId)) continue;
|
|
93
|
+
|
|
94
|
+
const childIds = readEpicChildIds(epic?.body);
|
|
95
|
+
if (childIds.length === 0) continue;
|
|
96
|
+
// Only Epics this run actually advanced. Sweeping every open Epic would
|
|
97
|
+
// make a delivery close containers it had nothing to do with.
|
|
98
|
+
if (!childIds.some((c) => delivered.has(c))) continue;
|
|
99
|
+
|
|
100
|
+
let allLanded = true;
|
|
101
|
+
for (const childId of childIds) {
|
|
102
|
+
try {
|
|
103
|
+
const child = await provider.getTicket(childId);
|
|
104
|
+
if (!isSatisfiedChild(child)) {
|
|
105
|
+
allLanded = false;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
Logger.warn(
|
|
110
|
+
`[run-epilogue] Epic #${epicId}: could not read child #${childId} ` +
|
|
111
|
+
`(${err?.message ?? err}) — leaving the Epic open.`,
|
|
112
|
+
);
|
|
113
|
+
allLanded = false;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!allLanded) {
|
|
119
|
+
result.pending.push(epicId);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await provider.updateTicket(epicId, {
|
|
125
|
+
state: 'closed',
|
|
126
|
+
state_reason: 'completed',
|
|
127
|
+
});
|
|
128
|
+
Logger.info(
|
|
129
|
+
`[run-epilogue] Closed container Epic #${epicId} — all ${childIds.length} child Story(ies) landed.`,
|
|
130
|
+
);
|
|
131
|
+
result.closed.push(epicId);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
Logger.warn(
|
|
134
|
+
`[run-epilogue] Could not close Epic #${epicId} (${err?.message ?? err}).`,
|
|
135
|
+
);
|
|
136
|
+
result.pending.push(epicId);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A child no longer holds its Epic open once it is closed or `agent::done`.
|
|
145
|
+
*
|
|
146
|
+
* Mirrors `isSatisfiedBlocker` in `lib/orchestration/resolve-stories.js`
|
|
147
|
+
* rather than importing it: that module is the delivery-resolution path and
|
|
148
|
+
* pulling it in here would drag the whole story-body parser into the
|
|
149
|
+
* epilogue for a two-line predicate.
|
|
150
|
+
*
|
|
151
|
+
* @param {{ state?: string, labels?: unknown }} issue
|
|
152
|
+
* @returns {boolean}
|
|
153
|
+
*/
|
|
154
|
+
function isSatisfiedChild(issue) {
|
|
155
|
+
if (String(issue?.state ?? '').toLowerCase() === 'closed') return true;
|
|
156
|
+
const labels = Array.isArray(issue?.labels)
|
|
157
|
+
? issue.labels.map((l) => (typeof l === 'string' ? l : l?.name))
|
|
158
|
+
: [];
|
|
159
|
+
return labels.includes(AGENT_LABELS.DONE);
|
|
160
|
+
}
|
|
161
|
+
|
|
42
162
|
/**
|
|
43
163
|
* @param {string|number|{ id?: string|number, slug?: string }} entry
|
|
44
164
|
* @returns {string|null}
|
|
@@ -123,6 +243,11 @@ export function planRunEpilogue({ planRunId, stories } = {}) {
|
|
|
123
243
|
description: `Sibling-coherence check across the ${ids.length} Story specs of run ${effectiveRunId}`,
|
|
124
244
|
stories: ids,
|
|
125
245
|
},
|
|
246
|
+
{
|
|
247
|
+
kind: 'epic-close',
|
|
248
|
+
description: `Close any container Epic whose children all landed in run ${effectiveRunId}`,
|
|
249
|
+
stories: ids,
|
|
250
|
+
},
|
|
126
251
|
];
|
|
127
252
|
|
|
128
253
|
return {
|
|
@@ -825,6 +950,10 @@ export async function runPlanRunEpilogue({
|
|
|
825
950
|
provider,
|
|
826
951
|
}),
|
|
827
952
|
);
|
|
953
|
+
} else if (step.kind === 'epic-close') {
|
|
954
|
+
results.push(
|
|
955
|
+
await executeEpicClose({ stories: plan.stories, provider }),
|
|
956
|
+
);
|
|
828
957
|
}
|
|
829
958
|
} catch (err) {
|
|
830
959
|
const message = err?.message ?? String(err);
|
|
@@ -26,11 +26,25 @@
|
|
|
26
26
|
* harness invocation — by exact name or by raw-URL origin match against each
|
|
27
27
|
* environment's `baseUrl` — and throws loudly (naming the known environments)
|
|
28
28
|
* on an unknown name or unmatched URL.
|
|
29
|
+
*
|
|
30
|
+
* It also **resolves the selected environment's `signInSeam`** (Story #5135).
|
|
31
|
+
* A `{ skill }` seam naming an id that resolves to no readable `SKILL.md`
|
|
32
|
+
* under either skills root used to fail silently: the contract validated, the
|
|
33
|
+
* seam was returned unread, and the dangling pointer only surfaced much later
|
|
34
|
+
* when a sweep reached its sign-in step — after the harness had already
|
|
35
|
+
* driven a browser. Resolution now happens here, at config-resolution time,
|
|
36
|
+
* so the failure lands where the operator can fix `.agentrc.json`. A seam
|
|
37
|
+
* that is absent entirely is a legitimate, declarable state (the workflows
|
|
38
|
+
* drive the unauthenticated surface and record the gap), not an error.
|
|
29
39
|
*/
|
|
30
40
|
|
|
31
41
|
import Ajv from 'ajv';
|
|
32
|
-
|
|
33
42
|
import { QA_SCHEMA } from '../config-settings-schema.js';
|
|
43
|
+
import { PROJECT_ROOT } from '../project-root.js';
|
|
44
|
+
import {
|
|
45
|
+
resolveSkillFile,
|
|
46
|
+
SKILL_SEARCH_ROOTS,
|
|
47
|
+
} from '../skills/walk-skill-files.js';
|
|
34
48
|
|
|
35
49
|
/**
|
|
36
50
|
* The harness-required fields. The AJV `QA_SCHEMA` keeps these optional so
|
|
@@ -242,6 +256,40 @@ function toOrigin(value) {
|
|
|
242
256
|
}
|
|
243
257
|
}
|
|
244
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Normalize and verify one environment's `signInSeam`.
|
|
261
|
+
*
|
|
262
|
+
* An absent seam normalizes to `null` — a declarable state, not an error.
|
|
263
|
+
* A `{ skill }` seam is resolved against both skills roots and throws when
|
|
264
|
+
* it resolves under neither, so a dangling pointer is caught here rather
|
|
265
|
+
* than mid-sweep. The resolved `SKILL.md` path is attached as
|
|
266
|
+
* `skillPath` so the harness reads the file the check actually found.
|
|
267
|
+
*
|
|
268
|
+
* @param {object | undefined} seam
|
|
269
|
+
* @param {string} envName Environment name, for the error message.
|
|
270
|
+
* @param {{ repoRoot?: string }} options
|
|
271
|
+
* @returns {object | null}
|
|
272
|
+
*/
|
|
273
|
+
function resolveSignInSeam(seam, envName, options) {
|
|
274
|
+
if (seam == null) return null;
|
|
275
|
+
if (typeof seam.skill !== 'string') return seam;
|
|
276
|
+
|
|
277
|
+
const repoRoot = options.repoRoot ?? PROJECT_ROOT;
|
|
278
|
+
const found = resolveSkillFile(repoRoot, seam.skill);
|
|
279
|
+
if (found === null) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`qa: environment \`${envName}\` declares signInSeam.skill ` +
|
|
282
|
+
`\`${seam.skill}\`, which resolves to no readable SKILL.md. ` +
|
|
283
|
+
`Searched ${SKILL_SEARCH_ROOTS.map((r) => `\`${r}/<skill>/SKILL.md\``).join(' and ')}. ` +
|
|
284
|
+
'Author the skill under the consumer-writable `.agents/local/skills/` ' +
|
|
285
|
+
'zone (it is never pruned by `mandrel sync` and never flagged as ' +
|
|
286
|
+
'payload drift), correct the id, or omit `signInSeam` entirely if ' +
|
|
287
|
+
'this target genuinely has no sign-in seam.',
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
return { ...seam, skillPath: found.path };
|
|
291
|
+
}
|
|
292
|
+
|
|
245
293
|
/**
|
|
246
294
|
* Resolve a single QA environment for one harness invocation.
|
|
247
295
|
*
|
|
@@ -262,13 +310,17 @@ function toOrigin(value) {
|
|
|
262
310
|
* Fails **loudly**: an unknown name or an unmatched URL throws an error that
|
|
263
311
|
* names the known environments so the operator can correct the invocation.
|
|
264
312
|
*
|
|
265
|
-
* @param {{ environments: Record<string, { baseUrl: string, signInSeam
|
|
313
|
+
* @param {{ environments: Record<string, { baseUrl: string, signInSeam?: object, allowWrites?: boolean }>, defaultEnvironment: string }} contract
|
|
266
314
|
* A contract returned by `resolveQaContract`.
|
|
267
315
|
* @param {string} [target] Environment name or raw URL. Omit for the default.
|
|
268
|
-
* @
|
|
269
|
-
*
|
|
316
|
+
* @param {{ repoRoot?: string }} [options] `repoRoot` roots skill-seam
|
|
317
|
+
* resolution; defaults to the project root. Injected by tests.
|
|
318
|
+
* @returns {{ name: string, baseUrl: string, signInSeam: object | null, allowWrites: boolean }}
|
|
319
|
+
* `signInSeam` is `null` when the environment declares none.
|
|
320
|
+
* @throws {Error} on an unknown name, an unmatched URL, or a `{ skill }` seam
|
|
321
|
+
* that resolves under no skills root.
|
|
270
322
|
*/
|
|
271
|
-
export function resolveQaEnvironment(contract, target) {
|
|
323
|
+
export function resolveQaEnvironment(contract, target, options = {}) {
|
|
272
324
|
const environments = contract?.environments;
|
|
273
325
|
if (
|
|
274
326
|
environments == null ||
|
|
@@ -320,7 +372,7 @@ export function resolveQaEnvironment(contract, target) {
|
|
|
320
372
|
return {
|
|
321
373
|
name: resolvedName,
|
|
322
374
|
baseUrl: env.baseUrl,
|
|
323
|
-
signInSeam: env.signInSeam,
|
|
375
|
+
signInSeam: resolveSignInSeam(env.signInSeam, resolvedName, options),
|
|
324
376
|
allowWrites,
|
|
325
377
|
};
|
|
326
378
|
}
|