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.
Files changed (41) hide show
  1. package/.agents/README.md +51 -11
  2. package/.agents/agents/auditor.md +5 -0
  3. package/.agents/docs/SDLC.md +21 -12
  4. package/.agents/docs/agentrc-reference.json +1 -4
  5. package/.agents/docs/configuration.md +2 -2
  6. package/.agents/instructions.md +17 -16
  7. package/.agents/schemas/agentrc.schema.json +6 -7
  8. package/.agents/scripts/audit-to-stories.js +510 -66
  9. package/.agents/scripts/generate-skills-index.js +158 -75
  10. package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +39 -0
  11. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
  12. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
  13. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
  14. package/.agents/scripts/lib/changed-files.js +100 -9
  15. package/.agents/scripts/lib/config-settings-schema.js +25 -7
  16. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  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/epic-container.js +186 -0
  20. package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
  21. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +320 -0
  22. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +18 -0
  23. package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
  24. package/.agents/scripts/lib/qa/resolve-qa-contract.js +58 -6
  25. package/.agents/scripts/lib/skills/skills-index.js +168 -0
  26. package/.agents/scripts/lib/skills/walk-skill-files.js +133 -9
  27. package/.agents/scripts/plan-persist.js +39 -1
  28. package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
  29. package/.agents/scripts/quality-preview.js +50 -9
  30. package/.agents/scripts/resolve-stories.js +42 -2
  31. package/.agents/scripts/validate-skills.js +53 -66
  32. package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
  33. package/.agents/workflows/audit-to-stories.md +85 -7
  34. package/.agents/workflows/helpers/audit-lens-core.md +24 -4
  35. package/.agents/workflows/helpers/deliver-reference.md +8 -0
  36. package/.agents/workflows/helpers/plan-reference.md +28 -0
  37. package/.agents/workflows/mandrel-deliver.md +47 -43
  38. package/.agents/workflows/mandrel-plan.md +44 -38
  39. package/.agents/workflows/qa-run.md +13 -5
  40. package/docs/CHANGELOG.md +28 -0
  41. 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: object, allowWrites?: boolean }>, defaultEnvironment: string }} contract
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
- * @returns {{ name: string, baseUrl: string, signInSeam: object, allowWrites: boolean }}
269
- * @throws {Error} on an unknown name or unmatched URL.
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
  }