mandrel 2.65.0 → 2.66.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 (56) hide show
  1. package/.agents/agents/acceptance-critic.md +5 -5
  2. package/.agents/agents/auditor.md +17 -18
  3. package/.agents/agents/plan-critic.md +5 -5
  4. package/.agents/agents/story-worker.md +5 -5
  5. package/.agents/docs/execution-reference.md +27 -5
  6. package/.agents/instructions.md +10 -12
  7. package/.agents/rules/ci-remediation.md +3 -3
  8. package/.agents/rules/gherkin-standards.md +3 -2
  9. package/.agents/rules/git-conventions-reference.md +12 -3
  10. package/.agents/rules/git-conventions.md +9 -7
  11. package/.agents/rules/testing-standards.md +8 -7
  12. package/.agents/runtime-deps.json +1 -1
  13. package/.agents/scripts/bootstrap.js +94 -89
  14. package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
  15. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +78 -78
  16. package/.agents/scripts/lib/cli/standard-args.js +60 -76
  17. package/.agents/scripts/lib/cli-args.js +26 -0
  18. package/.agents/scripts/lib/config/gates/shared.js +3 -3
  19. package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
  20. package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
  21. package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
  22. package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
  23. package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
  24. package/.agents/scripts/lib/observability/signal-validator.js +17 -5
  25. package/.agents/scripts/lib/orchestration/code-review.js +22 -0
  26. package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
  27. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +23 -0
  28. package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
  29. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -0
  30. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +349 -263
  31. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
  32. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
  33. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +327 -314
  34. package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
  35. package/.agents/scripts/lib/signals/detectors/common.js +63 -51
  36. package/.agents/scripts/lib/transpile.js +28 -3
  37. package/.agents/scripts/single-story-close.js +10 -2
  38. package/.agents/scripts/single-story-confirm-merge.js +267 -238
  39. package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
  40. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
  41. package/.agents/workflows/audit-architecture.md +5 -4
  42. package/.agents/workflows/audit-documentation.md +5 -5
  43. package/.agents/workflows/audit-performance.md +10 -10
  44. package/.agents/workflows/helpers/acceptance-self-eval.md +8 -8
  45. package/.agents/workflows/helpers/audit-lens-core.md +30 -57
  46. package/.agents/workflows/helpers/deliver-digest.md +2 -2
  47. package/.agents/workflows/helpers/deliver-reference.md +3 -1
  48. package/.agents/workflows/helpers/deliver-story.md +6 -1
  49. package/.agents/workflows/helpers/parallel-tooling.md +16 -18
  50. package/.agents/workflows/mandrel-deliver.md +1 -1
  51. package/.agents/workflows/mandrel-plan.md +6 -5
  52. package/docs/CHANGELOG.md +26 -0
  53. package/lib/cli/guarded-sync.js +87 -0
  54. package/lib/cli/sync-agents.js +9 -92
  55. package/lib/cli/sync-commands.js +9 -101
  56. package/package.json +2 -2
@@ -16,8 +16,9 @@
16
16
  * @see .agents/workflows/helpers/deliver-story.md
17
17
  */
18
18
 
19
- import { parseArgs } from 'node:util';
20
- import { parseSprintArgsTolerant } from './lib/cli-args.js';
19
+ import path from 'node:path';
20
+ import { parseStandardCliArgs } from './lib/cli/standard-args.js';
21
+ import { parseSprintArgs, parseSprintArgsTolerant } from './lib/cli-args.js';
21
22
  import { runAsCli } from './lib/cli-utils.js';
22
23
  import { resolveConfig } from './lib/config-resolver.js';
23
24
  import { formatCliError } from './lib/error-redactor.js';
@@ -30,7 +31,7 @@ import { MERGED_FLIP_FAILED_BLOCK_CLASS } from './lib/orchestration/lifecycle/em
30
31
  import { MERGE_WAIT_GH_TIMEOUT_MS } from './lib/orchestration/merge-poll.js';
31
32
  import { parsePrNumber } from './lib/orchestration/single-story-close/phases/code-review.js';
32
33
  import { runConfirmMergePhase as defaultRunConfirmMergePhase } from './lib/orchestration/single-story-close/phases/confirm-merge.js';
33
- import { parseCloseOptions } from './lib/orchestration/single-story-close/phases/options.js';
34
+ import { assertNoRetiredFlags } from './lib/orchestration/single-story-close/phases/options.js';
34
35
  import { runPostLandTail } from './lib/orchestration/single-story-close/phases/post-land.js';
35
36
  import {
36
37
  buildTerminalEnvelope,
@@ -39,6 +40,7 @@ import {
39
40
  NEXT_COMMANDS,
40
41
  terminalFromWaitOutcome,
41
42
  } from './lib/orchestration/story-deliver-terminal.js';
43
+ import { PROJECT_ROOT } from './lib/project-root.js';
42
44
  import { createProvider } from './lib/provider-factory.js';
43
45
  import { confirmStoryMerged } from './lib/single-story/confirm-merge.js';
44
46
 
@@ -67,56 +69,42 @@ const USAGE =
67
69
  ' over delivery.mergeWatch.maxWaitSeconds and the async\n' +
68
70
  ' probe-window cap; only meaningful with --wait)';
69
71
 
70
- /**
71
- * @returns {string|undefined}
72
- */
73
- function readPrFlag() {
74
- try {
75
- const { values } = parseArgs({
76
- args: process.argv.slice(2),
77
- options: { pr: { type: 'string' } },
78
- strict: false,
79
- });
80
- return values.pr;
81
- } catch {
82
- return undefined;
83
- }
84
- }
72
+ const CONFIRM_MERGE_FLAGS = Object.freeze({
73
+ pr: { type: 'string' },
74
+ // Resume the bounded wait; without it the CLI probes once.
75
+ wait: { type: 'boolean' },
76
+ 'max-wait-seconds': { type: 'string' },
77
+ cwd: { type: 'string' },
78
+ });
85
79
 
86
- /**
87
- * `--wait` resumes the bounded merge wait (`resumeLand`); without it the CLI
88
- * probes once, a fast flip for a merge that already happened.
89
- */
90
- function readWaitFlag() {
91
- try {
92
- const { values } = parseArgs({
93
- args: process.argv.slice(2),
94
- options: { wait: { type: 'boolean', default: false } },
95
- strict: false,
96
- });
97
- return values.wait === true;
98
- } catch {
99
- return false;
100
- }
80
+ /** @returns {number|undefined} */
81
+ function positiveIntOrUndefined(value) {
82
+ const parsed = Number.parseInt(String(value), 10);
83
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
101
84
  }
102
85
 
103
86
  /**
104
- * Per-run wait bound for `--wait`; `undefined` unless a positive integer.
87
+ * The close's vocabulary is validated first, so a malformed close flag fails
88
+ * `init` with the close's own message.
105
89
  *
106
- * @returns {number|undefined}
90
+ * @param {string[]} fullArgv
91
+ * @returns {object}
107
92
  */
108
- function readMaxWaitSecondsFlag() {
109
- try {
110
- const { values } = parseArgs({
111
- args: process.argv.slice(2),
112
- options: { 'max-wait-seconds': { type: 'string' } },
113
- strict: false,
114
- });
115
- const parsed = Number.parseInt(String(values['max-wait-seconds']), 10);
116
- return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
117
- } catch {
118
- return undefined;
119
- }
93
+ function parseConfirmMergeArgv(fullArgv) {
94
+ const argv = fullArgv.slice(2);
95
+ assertNoRetiredFlags(argv);
96
+ parseSprintArgs(fullArgv);
97
+ const { values } = parseStandardCliArgs({
98
+ argv,
99
+ extras: CONFIRM_MERGE_FLAGS,
100
+ });
101
+ return {
102
+ storyId: values.storyId,
103
+ cwd: values.cwd,
104
+ pr: values.pr,
105
+ wait: values.wait,
106
+ maxWaitSeconds: positiveIntOrUndefined(values.maxWaitSeconds),
107
+ };
120
108
  }
121
109
 
122
110
  /**
@@ -164,199 +152,175 @@ async function logConfirmResult(result, terminal, config) {
164
152
  return { success: terminal.status !== 'failed', result, terminal };
165
153
  }
166
154
 
155
+ /** @returns {'MERGED'|'CLOSED'|'OPEN'} */
156
+ function confirmPrState(confirmation) {
157
+ if (confirmation.merged) return 'MERGED';
158
+ return confirmation.reason === 'pr-not-merged' ? 'CLOSED' : 'OPEN';
159
+ }
160
+
161
+ /** @returns {{ number: number, state: string }|null} */
162
+ function confirmPr(prNumber, confirmation) {
163
+ if (!Number.isInteger(prNumber) || prNumber <= 0) return null;
164
+ return { number: prNumber, state: confirmPrState(confirmation) };
165
+ }
166
+
167
+ function confirmBlocked(blockClass, reason, nextCommand) {
168
+ return {
169
+ status: 'blocked',
170
+ phase: 'confirm-merge',
171
+ blocked: { blockClass, reason, frictionCommentId: null },
172
+ nextCommand,
173
+ };
174
+ }
175
+
167
176
  /**
168
- * Map a confirmation onto the shared terminal envelope: done/noop → landed;
169
- * flip-failed → blocked (re-run this command); pr-not-merged → blocked (needs
170
- * a human); otherwise pending.
177
+ * done/noop → landed; flip-failed → blocked (re-run this command);
178
+ * pr-not-merged → blocked (needs a human); otherwise pending.
171
179
  */
172
- function buildConfirmTerminal({
173
- storyId,
174
- storyBranch,
175
- baseBranch,
176
- prNumber,
177
- confirmation,
178
- tail,
179
- elapsedSeconds,
180
- }) {
181
- const prState = confirmation.merged
182
- ? 'MERGED'
183
- : confirmation.reason === 'pr-not-merged'
184
- ? 'CLOSED'
185
- : 'OPEN';
186
- const pr =
187
- Number.isInteger(prNumber) && prNumber > 0
188
- ? { number: prNumber, state: prState }
189
- : null;
190
- const common = { storyId, storyBranch, baseBranch, pr, elapsedSeconds };
191
-
180
+ function confirmOutcomeFields({ storyId, confirmation, tail }) {
192
181
  if (confirmation.action === 'done' || confirmation.action === 'noop') {
193
- return buildTerminalEnvelope({
194
- ...common,
182
+ return {
195
183
  status: 'landed',
196
184
  phase: tail ? 'post-land' : 'done',
197
185
  tail,
198
186
  nextCommand: null,
199
- });
187
+ };
200
188
  }
201
189
  if (confirmation.action === 'flip-failed') {
202
- return buildTerminalEnvelope({
203
- ...common,
204
- status: 'blocked',
205
- phase: 'confirm-merge',
206
- blocked: {
207
- blockClass: MERGED_FLIP_FAILED_BLOCK_CLASS,
208
- reason:
209
- 'merge confirmed but the agent::closing → agent::done label write failed',
210
- frictionCommentId: null,
211
- },
212
- nextCommand: NEXT_COMMANDS.confirmMerge(storyId),
213
- });
190
+ return confirmBlocked(
191
+ MERGED_FLIP_FAILED_BLOCK_CLASS,
192
+ 'merge confirmed but the agent::closing → agent::done label write failed',
193
+ NEXT_COMMANDS.confirmMerge(storyId),
194
+ );
214
195
  }
215
196
  if (confirmation.reason === 'pr-not-merged') {
216
- return buildTerminalEnvelope({
217
- ...common,
218
- status: 'blocked',
219
- phase: 'confirm-merge',
220
- blocked: {
221
- blockClass: 'api-race-other',
222
- reason: 'the PR was closed without merging (state=CLOSED)',
223
- frictionCommentId: null,
224
- },
225
- nextCommand: NEXT_COMMANDS.recover(storyId),
226
- });
197
+ return confirmBlocked(
198
+ 'api-race-other',
199
+ 'the PR was closed without merging (state=CLOSED)',
200
+ NEXT_COMMANDS.recover(storyId),
201
+ );
227
202
  }
228
- return buildTerminalEnvelope({
229
- ...common,
203
+ return {
230
204
  status: 'pending',
231
205
  phase: 'confirm-merge',
232
206
  nextCommand:
233
207
  confirmation.reason === 'no-pr'
234
208
  ? NEXT_COMMANDS.recover(storyId)
235
209
  : NEXT_COMMANDS.confirmMerge(storyId),
210
+ };
211
+ }
212
+
213
+ function buildConfirmTerminal({
214
+ storyId,
215
+ storyBranch,
216
+ baseBranch,
217
+ prNumber,
218
+ confirmation,
219
+ tail,
220
+ elapsedSeconds,
221
+ }) {
222
+ return buildTerminalEnvelope({
223
+ storyId,
224
+ storyBranch,
225
+ baseBranch,
226
+ pr: confirmPr(prNumber, confirmation),
227
+ elapsedSeconds,
228
+ ...confirmOutcomeFields({ storyId, confirmation, tail }),
236
229
  });
237
230
  }
238
231
 
239
- async function resolveConfirmPrNumber({ prParam, storyBranch, gh }) {
240
- const rawPr = prParam ?? readPrFlag();
241
- let prNumber = Number.parseInt(String(rawPr ?? ''), 10);
232
+ async function resolveConfirmPrNumber({ pr, storyBranch, gh }) {
233
+ let prNumber = Number.parseInt(String(pr ?? ''), 10);
242
234
  if (!Number.isInteger(prNumber) || prNumber <= 0) {
243
235
  prNumber = await resolvePrNumber({ storyBranch, gh });
244
236
  }
245
237
  return Number.isInteger(prNumber) && prNumber > 0 ? prNumber : null;
246
238
  }
247
239
 
248
- export async function runConfirmMerge({
249
- storyId: storyIdParam,
250
- cwd: cwdParam,
251
- pr: prParam,
252
- wait: waitParam,
253
- maxWaitSeconds: maxWaitSecondsParam,
254
- injectedProvider,
255
- injectedConfig,
256
- injectedGh,
257
- injectedNotify,
258
- injectedReadPrMergeState,
259
- runConfirmMergePhaseFn = defaultRunConfirmMergePhase,
260
- } = {}) {
261
- const { storyId, cwd } = parseCloseOptions({
262
- storyIdParam,
263
- cwdParam,
264
- });
265
-
266
- if (!storyId) {
267
- throw new Error(USAGE);
268
- }
269
- const wait = waitParam ?? readWaitFlag();
270
- const maxWaitSeconds = maxWaitSecondsParam ?? readMaxWaitSecondsFlag();
271
-
272
- const startedAtMs = Date.now();
273
- const config = injectedConfig || resolveConfig({ cwd });
274
- const provider = injectedProvider || createProvider(config);
275
- const gh = injectedGh ?? defaultGh;
276
- const storyBranch = getStoryBranch(storyId);
277
- const baseBranch = config.project?.baseBranch ?? 'main';
240
+ function elapsedSeconds(ctx) {
241
+ return Math.round((Date.now() - ctx.startedAtMs) / 1000);
242
+ }
278
243
 
279
- progress('INIT', `Confirming merge for standalone Story #${storyId}...`);
244
+ async function confirmWithoutPr(ctx) {
245
+ progress(
246
+ 'CONFIRM',
247
+ `⚠️ No PR found for ${ctx.storyBranch}; cannot confirm merge. Story stays at agent::closing.`,
248
+ );
249
+ const noPr = {
250
+ storyId: ctx.storyId,
251
+ standalone: true,
252
+ action: 'pending',
253
+ reason: 'no-pr',
254
+ merged: false,
255
+ };
256
+ return await logConfirmResult(
257
+ noPr,
258
+ buildConfirmTerminal({
259
+ storyId: ctx.storyId,
260
+ storyBranch: ctx.storyBranch,
261
+ baseBranch: ctx.baseBranch,
262
+ prNumber: null,
263
+ confirmation: noPr,
264
+ tail: null,
265
+ elapsedSeconds: elapsedSeconds(ctx),
266
+ }),
267
+ ctx.config,
268
+ );
269
+ }
280
270
 
281
- const prNumber = await resolveConfirmPrNumber({
282
- prParam,
271
+ /**
272
+ * `--wait` runs the SAME phase as close so the cumulative budget give-up
273
+ * (the only path to `merge.unlanded` / `agent::blocked`) is reachable from a
274
+ * resume. The budget is anchored at the PR's `createdAt`, so resuming does
275
+ * not restart the clock.
276
+ */
277
+ async function resumeMergeWait(ctx) {
278
+ const { storyId, storyBranch, baseBranch, prNumber } = ctx;
279
+ const waitOutcome = await ctx.runConfirmMergePhaseFn({
280
+ cwd: ctx.cwd,
281
+ storyId,
282
+ storyBranch,
283
+ baseBranch,
284
+ prNumber,
285
+ prUrl: `${storyBranch} PR #${prNumber}`,
286
+ // The close already armed it.
287
+ autoMergeEnabled: true,
288
+ maxWaitSeconds: ctx.maxWaitSeconds,
289
+ provider: ctx.provider,
290
+ config: ctx.config,
291
+ progress,
292
+ injectedGh: ctx.gh,
293
+ injectedNotify: ctx.injectedNotify,
294
+ readPrMergeStateFn: ctx.injectedReadPrMergeState,
295
+ });
296
+ const terminal = terminalFromWaitOutcome({
297
+ waitOutcome,
298
+ storyId,
283
299
  storyBranch,
284
- gh,
300
+ baseBranch,
301
+ prNumber,
302
+ prUrl: null,
303
+ autoMergeEnabled: true,
304
+ // This CLI runs no close gates.
305
+ gates: undefined,
306
+ elapsedSeconds: elapsedSeconds(ctx),
285
307
  });
286
- if (prNumber == null) {
287
- progress(
288
- 'CONFIRM',
289
- `⚠️ No PR found for ${storyBranch}; cannot confirm merge. Story stays at agent::closing.`,
290
- );
291
- const noPr = {
308
+ return await logConfirmResult(
309
+ {
292
310
  storyId,
293
311
  standalone: true,
294
- action: 'pending',
295
- reason: 'no-pr',
296
- merged: false,
297
- };
298
- return await logConfirmResult(
299
- noPr,
300
- buildConfirmTerminal({
301
- storyId,
302
- storyBranch,
303
- baseBranch,
304
- prNumber: null,
305
- confirmation: noPr,
306
- tail: null,
307
- elapsedSeconds: Math.round((Date.now() - startedAtMs) / 1000),
308
- }),
309
- config,
310
- );
311
- }
312
-
313
- // `--wait` runs the SAME phase as close so the cumulative budget give-up
314
- // (the only path to `merge.unlanded` / `agent::blocked`) is reachable from a
315
- // resume. The budget is anchored at the PR's `createdAt`, so resuming does
316
- // not restart the clock.
317
- if (wait) {
318
- const waitOutcome = await runConfirmMergePhaseFn({
319
- cwd,
320
- storyId,
321
- storyBranch,
322
- baseBranch,
323
- prNumber,
324
- prUrl: `${storyBranch} PR #${prNumber}`,
325
- // The close already armed it.
326
- autoMergeEnabled: true,
327
- maxWaitSeconds,
328
- provider,
329
- config,
330
- progress,
331
- injectedGh: gh,
332
- injectedNotify,
333
- readPrMergeStateFn: injectedReadPrMergeState,
334
- });
335
- const terminal = terminalFromWaitOutcome({
336
- waitOutcome,
337
- storyId,
338
- storyBranch,
339
- baseBranch,
340
- prNumber,
341
- prUrl: null,
342
- autoMergeEnabled: true,
343
- // This CLI runs no close gates.
344
- gates: undefined,
345
- elapsedSeconds: Math.round((Date.now() - startedAtMs) / 1000),
346
- });
347
- return await logConfirmResult(
348
- {
349
- storyId,
350
- standalone: true,
351
- action: waitOutcome.terminal,
352
- resumed: true,
353
- tail: waitOutcome.tail ?? null,
354
- },
355
- terminal,
356
- config,
357
- );
358
- }
312
+ action: waitOutcome.terminal,
313
+ resumed: true,
314
+ tail: waitOutcome.tail ?? null,
315
+ },
316
+ terminal,
317
+ ctx.config,
318
+ );
319
+ }
359
320
 
321
+ async function probeMergeOnce(ctx) {
322
+ const { storyId, storyBranch, baseBranch, prNumber, cwd, provider, config } =
323
+ ctx;
360
324
  const confirmation = await confirmStoryMerged({
361
325
  provider,
362
326
  storyId,
@@ -365,9 +329,9 @@ export async function runConfirmMerge({
365
329
  cwd,
366
330
  config,
367
331
  progress,
368
- injectedGh: gh,
369
- injectedNotify,
370
- readPrMergeStateFn: injectedReadPrMergeState,
332
+ injectedGh: ctx.gh,
333
+ injectedNotify: ctx.injectedNotify,
334
+ readPrMergeStateFn: ctx.injectedReadPrMergeState,
371
335
  });
372
336
 
373
337
  // The same shared land tail close runs. Gated on `merged`, not
@@ -393,7 +357,7 @@ export async function runConfirmMerge({
393
357
  prNumber,
394
358
  confirmation,
395
359
  tail,
396
- elapsedSeconds: Math.round((Date.now() - startedAtMs) / 1000),
360
+ elapsedSeconds: elapsedSeconds(ctx),
397
361
  });
398
362
  if (confirmation.action === 'done') {
399
363
  progress('DONE', `✅ Story #${storyId} → agent::done (merged).`);
@@ -405,43 +369,108 @@ export async function runConfirmMerge({
405
369
  );
406
370
  }
407
371
 
372
+ /** Reads no argv: `main()` parses it and passes the values in. */
373
+ export async function runConfirmMerge({
374
+ storyId,
375
+ cwd,
376
+ pr,
377
+ wait = false,
378
+ maxWaitSeconds,
379
+ injectedProvider,
380
+ injectedConfig,
381
+ injectedGh,
382
+ injectedNotify,
383
+ injectedReadPrMergeState,
384
+ runConfirmMergePhaseFn = defaultRunConfirmMergePhase,
385
+ } = {}) {
386
+ if (!storyId) {
387
+ throw new Error(USAGE);
388
+ }
389
+ const startedAtMs = Date.now();
390
+ const resolvedCwd = path.resolve(cwd ?? PROJECT_ROOT);
391
+ const config = injectedConfig || resolveConfig({ cwd: resolvedCwd });
392
+ const storyBranch = getStoryBranch(storyId);
393
+ const ctx = {
394
+ storyId,
395
+ cwd: resolvedCwd,
396
+ startedAtMs,
397
+ config,
398
+ provider: injectedProvider || createProvider(config),
399
+ gh: injectedGh ?? defaultGh,
400
+ storyBranch,
401
+ baseBranch: config.project?.baseBranch ?? 'main',
402
+ maxWaitSeconds,
403
+ injectedNotify,
404
+ injectedReadPrMergeState,
405
+ runConfirmMergePhaseFn,
406
+ };
407
+
408
+ progress('INIT', `Confirming merge for standalone Story #${storyId}...`);
409
+
410
+ const prNumber = await resolveConfirmPrNumber({
411
+ pr,
412
+ storyBranch,
413
+ gh: ctx.gh,
414
+ });
415
+ if (prNumber == null) return await confirmWithoutPr(ctx);
416
+ const withPr = { ...ctx, prNumber };
417
+ return wait ? await resumeMergeWait(withPr) : await probeMergeOnce(withPr);
418
+ }
419
+
420
+ /**
421
+ * A throw still emits a `failed` envelope: it is the landing surface's
422
+ * contract.
423
+ *
424
+ * @returns {Promise<number>}
425
+ */
426
+ async function failWithEnvelope(err, phase, fullArgv) {
427
+ // Tolerant parse: a strict one would re-throw an argv rejection here.
428
+ const { args, error: argvError } = parseSprintArgsTolerant(fullArgv);
429
+ const storyId = Number(args.storyId);
430
+ // No story id: nothing to report an envelope about.
431
+ if (!Number.isInteger(storyId) || storyId <= 0) throw err;
432
+ const terminal = buildTerminalEnvelope({
433
+ storyId,
434
+ status: 'failed',
435
+ // An argv rejection precedes every phase.
436
+ phase: argvError ? 'init' : phase,
437
+ failure: { reason: String(err?.message ?? err) },
438
+ nextCommand: NEXT_COMMANDS.recover(storyId),
439
+ elapsedSeconds: 0,
440
+ });
441
+ Logger.error(
442
+ `[single-story-confirm-merge] Fatal error: ${formatCliError(err)}`,
443
+ );
444
+ emitTerminalEnvelope(terminal);
445
+ await emitTerminalFriction({ envelope: terminal, tool: CLI_SOURCE });
446
+ return exitCodeForTerminal(terminal);
447
+ }
448
+
408
449
  /**
409
- * Exit code comes from the terminal envelope's status. A throw (e.g. a
410
- * transient `gh` error) still emits a `failed` envelope: the envelope is the
411
- * landing surface's contract.
450
+ * Exit code comes from the envelope's status; `runAsCli` answers `--help`.
451
+ *
452
+ * @param {string[]} fullArgv
453
+ * @returns {Promise<number>}
412
454
  */
413
- async function main() {
414
- if (process.argv.includes('--help')) {
415
- process.stdout.write(`${USAGE}\n`);
416
- return 0;
455
+ export async function runConfirmMergeCli(fullArgv) {
456
+ let options;
457
+ try {
458
+ options = parseConfirmMergeArgv(fullArgv);
459
+ } catch (err) {
460
+ return await failWithEnvelope(err, 'init', fullArgv);
417
461
  }
418
462
  try {
419
- const outcome = await runConfirmMerge();
463
+ const outcome = await runConfirmMerge(options);
420
464
  return exitCodeForTerminal(outcome?.terminal ?? { status: 'failed' });
421
465
  } catch (err) {
422
- // Tolerant parse: a strict one would re-throw an argv rejection here.
423
- const { args, error: argvError } = parseSprintArgsTolerant();
424
- const storyId = Number(args.storyId);
425
- // No story id: nothing to report an envelope about.
426
- if (!Number.isInteger(storyId) || storyId <= 0) throw err;
427
- const terminal = buildTerminalEnvelope({
428
- storyId,
429
- status: 'failed',
430
- // An argv rejection precedes every phase.
431
- phase: argvError ? 'init' : 'confirm-merge',
432
- failure: { reason: String(err?.message ?? err) },
433
- nextCommand: NEXT_COMMANDS.recover(storyId),
434
- elapsedSeconds: 0,
435
- });
436
- Logger.error(
437
- `[single-story-confirm-merge] Fatal error: ${formatCliError(err)}`,
438
- );
439
- emitTerminalEnvelope(terminal);
440
- await emitTerminalFriction({ envelope: terminal, tool: CLI_SOURCE });
441
- return exitCodeForTerminal(terminal);
466
+ return await failWithEnvelope(err, 'confirm-merge', fullArgv);
442
467
  }
443
468
  }
444
469
 
470
+ function main() {
471
+ return runConfirmMergeCli(process.argv);
472
+ }
473
+
445
474
  runAsCli(import.meta.url, main, {
446
475
  source: CLI_SOURCE,
447
476
  propagateExitCode: true,
@@ -22,12 +22,12 @@ description:
22
22
 
23
23
  ## Activation
24
24
 
25
- Called from [`/mandrel-plan`](../../../workflows/mandrel-plan.md) during ideation when the
26
- operator supplies `--seed "<text>"` (or runs ideation with no seed and the host
27
- collects one interactively). The skill sharpens freeform intent into the
28
- canonical planning sections that `/mandrel-plan` then folds into a Story. There is no
29
- separate Epic Clarity Gate path in v2 — N=1 Story authoring with a folded
30
- `## Spec` is the lean default.
25
+ Invoked directly by the operator (`idea-refine` / `ideate`), ahead of
26
+ planning — it is not a step of
27
+ [`/mandrel-plan`](../../../workflows/mandrel-plan.md), whose Gate #1 stops only
28
+ for a HITL unknown or a duplicate. The skill sharpens freeform intent into the
29
+ canonical planning sections, so a saved one-pager feeds
30
+ `/mandrel-plan <path>` (seed-file mode) directly.
31
31
 
32
32
  ## Detailed Instructions
33
33
 
@@ -109,8 +109,7 @@ not a soft preference.
109
109
  convenience to work around. Confirm authenticated state with a
110
110
  `take_snapshot` before driving.
111
111
  - **No headless fallback.** The chrome-devtools MCP surface is a host-provided
112
- runtime dependency. If it is unavailable, degrade with a clear error and stop
113
- — never fall back to the retired headless BDD runner.
112
+ runtime dependency. If it is unavailable, report a clear error and stop.
114
113
 
115
114
  ## 4. Mode — Known-Scenario Sweep (`/qa-run`)
116
115
 
@@ -23,10 +23,11 @@ Per the core's Scope interpretation:
23
23
 
24
24
  ## Execution strategy
25
25
 
26
- This is a **heavyweight lens**: dispatch it as a single `subagent_type: auditor`
27
- call, or fan its dimensions out per-dimension across parallel `auditor`
28
- subagents (parallel-tooling Rule 3) and merge under the self-cross-check.
29
- Sequential inline execution is the fallback (see the core's Execution strategy).
26
+ Dispatch this lens as one `subagent_type: auditor` call. Fan its dimensions
27
+ out across parallel `auditor` subagents (parallel-tooling Rule 3), merging
28
+ under the self-cross-check, only when the operator explicitly asks for
29
+ per-dimension fan-out. Sequential inline execution is the fallback (see the
30
+ core's Execution strategy).
30
31
 
31
32
  ## Step 0: Tool-first detection (mandatory — run before any LLM dimension)
32
33
 
@@ -62,11 +62,11 @@ with the config-driven target set above.
62
62
 
63
63
  ## Execution strategy
64
64
 
65
- This is a **heavyweight lens**: dispatch it as a single `subagent_type: auditor`
66
- call, or fan its per-doc / per-dimension verification out across parallel
67
- `auditor` subagents (parallel-tooling Rule 3) and merge under the
68
- self-cross-check. Sequential inline execution is the fallback (see the core's
69
- Execution strategy).
65
+ Dispatch this lens as one `subagent_type: auditor` call. Fan its per-doc /
66
+ per-dimension verification out across parallel `auditor` subagents
67
+ (parallel-tooling Rule 3), merging under the self-cross-check, only when the
68
+ operator explicitly asks for per-dimension fan-out. Sequential inline
69
+ execution is the fallback (see the core's Execution strategy).
70
70
 
71
71
  ## Step 1: Deterministic Signal First
72
72