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
@@ -9,6 +9,11 @@ import { getCiDelivery } from '../../config/ci.js';
9
9
  import { resolveConfig } from '../../config-resolver.js';
10
10
  import { getStoryBranch, gitSpawn, gitSync } from '../../git-utils.js';
11
11
  import { Logger } from '../../Logger.js';
12
+ import {
13
+ emitReviewBlockedFriction,
14
+ recordCloseTelemetry,
15
+ resolveWorkerTokens,
16
+ } from '../../observability/close-telemetry.js';
12
17
  import { emitTerminalFriction } from '../../observability/runtime-friction.js';
13
18
  import { emitTerseResult } from '../../observability/terse-result.js';
14
19
  import { createProvider } from '../../provider-factory.js';
@@ -80,17 +85,20 @@ function createPhaseTimer(nowMs = Date.now) {
80
85
  const UNTIMED = Object.freeze({ stamp() {} });
81
86
 
82
87
  /**
83
- * The single terminal writer: the result summary, the envelope callers parse,
84
- * and terminal friction — so no ending can forget one. Must be awaited: the
85
- * CLI `process.exit`s as soon as `main` resolves.
88
+ * The single terminal writer: the retry signal, the result summary (with its
89
+ * `telemetry`), the envelope callers parse, and terminal friction — so no
90
+ * ending can forget one. Must be awaited: the CLI `process.exit`s as soon as
91
+ * `main` resolves.
86
92
  */
87
93
  async function emitTerminal({
88
94
  terminal,
89
95
  result,
90
96
  config,
91
97
  phaseTimer = UNTIMED,
98
+ workerTokens = null,
92
99
  }) {
93
100
  phaseTimer.stamp(terminal);
101
+ await recordCloseTelemetry({ terminal, result, config, workerTokens });
94
102
  if (result) {
95
103
  emitTerseResult({
96
104
  label: 'STORY CLOSE RESULT',
@@ -109,23 +117,32 @@ async function emitTerminal({
109
117
  await emitTerminalFriction({ envelope: terminal, config });
110
118
  }
111
119
 
120
+ /**
121
+ * @param {number} storyId
122
+ * @param {string} reason
123
+ * @returns {{ storyId: number, standalone: true, action: 'noop', reason: string }}
124
+ */
125
+ function noopResult(storyId, reason) {
126
+ return { storyId, standalone: true, action: 'noop', reason };
127
+ }
128
+
112
129
  /**
113
130
  * Terminal for an already-closed Story. `not_planned` means nothing merged,
114
131
  * so it fails rather than reporting `landed` (which would also unblock
115
132
  * dependents); `completed` or null (GitHub's default) reads as landed.
116
133
  */
117
- async function alreadyClosedResult(storyId, stateReason = null, config) {
134
+ async function alreadyClosedResult(
135
+ storyId,
136
+ stateReason = null,
137
+ config,
138
+ workerTokens = null,
139
+ ) {
118
140
  if (stateReason === 'not_planned') {
119
141
  progress(
120
142
  'NOOP',
121
143
  `Story #${storyId} is closed as not planned — nothing to land.`,
122
144
  );
123
- const result = {
124
- storyId,
125
- standalone: true,
126
- action: 'noop',
127
- reason: 'closed-not-planned',
128
- };
145
+ const result = noopResult(storyId, 'closed-not-planned');
129
146
  const terminal = buildTerminalEnvelope({
130
147
  storyId,
131
148
  status: 'failed',
@@ -138,17 +155,12 @@ async function alreadyClosedResult(storyId, stateReason = null, config) {
138
155
  nextCommand: null,
139
156
  elapsedSeconds: 0,
140
157
  });
141
- await emitTerminal({ terminal, result, config });
158
+ await emitTerminal({ terminal, result, config, workerTokens });
142
159
  return { success: false, result, terminal };
143
160
  }
144
161
 
145
162
  progress('NOOP', `Story #${storyId} is already closed. Nothing to do.`);
146
- const result = {
147
- storyId,
148
- standalone: true,
149
- action: 'noop',
150
- reason: 'already-closed',
151
- };
163
+ const result = noopResult(storyId, 'already-closed');
152
164
  const terminal = buildTerminalEnvelope({
153
165
  storyId,
154
166
  status: 'landed',
@@ -156,7 +168,7 @@ async function alreadyClosedResult(storyId, stateReason = null, config) {
156
168
  nextCommand: null,
157
169
  elapsedSeconds: 0,
158
170
  });
159
- await emitTerminal({ terminal, result, config });
171
+ await emitTerminal({ terminal, result, config, workerTokens });
160
172
  return { success: true, result, terminal };
161
173
  }
162
174
 
@@ -179,13 +191,9 @@ async function preflightBlockedResult({
179
191
  preflight,
180
192
  config,
181
193
  startedAtMs,
194
+ workerTokens = null,
182
195
  }) {
183
- const result = {
184
- storyId,
185
- standalone: true,
186
- action: 'noop',
187
- reason: `graphql-preflight-${preflight.verdict}`,
188
- };
196
+ const result = noopResult(storyId, `graphql-preflight-${preflight.verdict}`);
189
197
  const terminal = buildTerminalEnvelope({
190
198
  storyId,
191
199
  status: 'blocked',
@@ -198,7 +206,7 @@ async function preflightBlockedResult({
198
206
  nextCommand: NEXT_COMMANDS.close(storyId),
199
207
  elapsedSeconds: elapsedSecondsSince(startedAtMs),
200
208
  });
201
- await emitTerminal({ terminal, result, config });
209
+ await emitTerminal({ terminal, result, config, workerTokens });
202
210
  return { success: false, result, terminal };
203
211
  }
204
212
 
@@ -244,6 +252,8 @@ function resolveWorktreePath({ cwd, config, storyId }) {
244
252
  * the validated tree is the pushed tree (base-sync's merge commit included),
245
253
  * and a cheap conflict is found before the expensive gates.
246
254
  *
255
+ * @param {object} ctx
256
+ * @param {object} deps
247
257
  * @returns {Promise<{
248
258
  * validationGates: Record<string, string>|null,
249
259
  * lockWait: { waitedSeconds: number, expired: boolean }|null,
@@ -251,49 +261,37 @@ function resolveWorktreePath({ cwd, config, storyId }) {
251
261
  * }>} `validationGates` is null when skipped; `pending` means a full-suite
252
262
  * lock wait expired and the gates deferred.
253
263
  */
254
- async function runPrePushPhases({
255
- cwd,
256
- worktreePath,
257
- config,
258
- baseBranch,
259
- baseConfirmed,
260
- storyBranch,
261
- storyId,
262
- provider,
263
- skipValidation,
264
- skipSync,
265
- injectedSync,
266
- injectedGitSpawn,
267
- setPhase = () => {},
268
- setObservedGates = () => {},
269
- }) {
264
+ async function runPrePushPhases(ctx, deps) {
265
+ const { options, worktreePath, baseBranch, storyBranch, storyId, setPhase } =
266
+ ctx;
267
+ const { cwd } = options;
270
268
  setPhase('wrong-tree-guard');
271
269
  await runWrongTreeGuardPhase({
272
270
  cwd,
273
271
  worktreePath,
274
272
  baseBranch,
275
273
  storyId,
276
- provider,
274
+ provider: deps.provider,
277
275
  progress,
278
- gitSpawn: injectedGitSpawn,
276
+ gitSpawn: deps.gitSpawn,
279
277
  });
280
- if (!skipSync) {
278
+ if (!options.skipSync) {
281
279
  setPhase('base-sync');
282
280
  await runBaseSyncPhase({
283
281
  cwd,
284
282
  worktreePath,
285
283
  baseBranch,
286
- baseConfirmed,
284
+ baseConfirmed: ctx.baseConfirmed,
287
285
  storyBranch,
288
286
  storyId,
289
- provider,
290
- injectedSync,
287
+ provider: deps.provider,
288
+ injectedSync: deps.sync,
291
289
  progress,
292
290
  });
293
291
  } else {
294
292
  progress('SYNC', '⏭ Skipped (--skip-sync).');
295
293
  }
296
- if (skipValidation) {
294
+ if (options.skipValidation) {
297
295
  progress('VALIDATE', '⏭ Skipped (--skip-validation).');
298
296
  return { validationGates: null, lockWait: null, pending: false };
299
297
  }
@@ -303,7 +301,7 @@ async function runPrePushPhases({
303
301
  validation = await runCloseValidationPhase({
304
302
  cwd,
305
303
  worktreePath,
306
- config,
304
+ config: deps.config,
307
305
  baseBranch,
308
306
  storyBranch,
309
307
  storyId,
@@ -314,12 +312,12 @@ async function runPrePushPhases({
314
312
  } catch (err) {
315
313
  // The gate that died is all this run observed; the envelope claims no more.
316
314
  if (typeof err?.closeGate === 'string') {
317
- setObservedGates({ [err.closeGate]: 'failed' });
315
+ ctx.setObservedGates({ [err.closeGate]: 'failed' });
318
316
  }
319
317
  throw err;
320
318
  }
321
319
  const gates = validation?.gates ?? null;
322
- setObservedGates(gates);
320
+ ctx.setObservedGates(gates);
323
321
  return {
324
322
  validationGates: gates,
325
323
  lockWait: validation?.lockWait ?? null,
@@ -327,25 +325,64 @@ async function runPrePushPhases({
327
325
  };
328
326
  }
329
327
 
330
- async function openAndReviewPr({
331
- cwd,
332
- worktreePath,
333
- story,
334
- storyId,
335
- storyBranch,
336
- baseBranch,
337
- provider,
338
- config,
339
- overrideReviewBlock,
340
- injectedGh,
341
- injectedRunCodeReview,
342
- setPhase = () => {},
343
- }) {
344
- setPhase('push');
328
+ /**
329
+ * An overridden Story proceeds; anything else emits friction, blocks, throws.
330
+ *
331
+ * @param {object} ctx
332
+ * @param {object} deps
333
+ * @param {{ prUrl: string, prNumber: number|null, reviewOutcome: object }} pr
334
+ * @returns {Promise<object>}
335
+ */
336
+ async function resolveReviewHalt(
337
+ ctx,
338
+ deps,
339
+ { prUrl, prNumber, reviewOutcome },
340
+ ) {
341
+ const { storyId } = ctx;
342
+ const criticalCount = reviewOutcome.severity?.critical ?? 0;
343
+ // Checked before the blocked transition: an overridden Story proceeds.
344
+ if (ctx.options.overrideReviewBlock) {
345
+ return await handleOverriddenReviewBlock({
346
+ provider: deps.provider,
347
+ storyId,
348
+ prUrl,
349
+ prNumber,
350
+ criticalCount,
351
+ criticalByProvider: reviewOutcome.criticalByProvider,
352
+ reason: ctx.options.overrideReviewBlock,
353
+ config: deps.config,
354
+ });
355
+ }
356
+ await emitReviewBlockedFriction({
357
+ storyId,
358
+ prNumber,
359
+ criticalCount,
360
+ criticalByProvider: reviewOutcome.criticalByProvider,
361
+ config: deps.config,
362
+ });
363
+ await handleCriticalReviewBlock({
364
+ provider: deps.provider,
365
+ storyId,
366
+ prUrl,
367
+ criticalCount,
368
+ });
369
+ throw new Error(
370
+ `[single-story-close] Story-scope review reported ${criticalCount} critical blocker(s) on PR ${prUrl}. ` +
371
+ 'Auto-merge was not enabled. Remediate the findings posted to the PR and re-run `/mandrel-deliver`. ' +
372
+ 'If you have reviewed a finding and judged it wrong, re-run with ' +
373
+ '`--override-review-block "<reason>"` rather than merging by hand.',
374
+ );
375
+ }
376
+
377
+ async function openAndReviewPr(ctx, deps) {
378
+ const { options, worktreePath, story, storyId, storyBranch, baseBranch } =
379
+ ctx;
380
+ const { cwd } = options;
381
+ ctx.setPhase('push');
345
382
  // Push from the worktree so `pre-push` measures the tree being sent; the
346
383
  // ref-based reads below resolve identically from the shared `.git`.
347
384
  pushStoryBranch({ cwd, worktreePath, storyBranch, gitSync, progress });
348
- setPhase('pull-request');
385
+ ctx.setPhase('pull-request');
349
386
  const { url: prUrl, alreadyMerged } = await ensurePullRequestWith({
350
387
  cwd,
351
388
  storyId,
@@ -353,7 +390,7 @@ async function openAndReviewPr({
353
390
  storyBody: story.body,
354
391
  storyBranch,
355
392
  baseBranch,
356
- gh: injectedGh,
393
+ gh: deps.gh,
357
394
  progress,
358
395
  });
359
396
  const prNumber = parsePrNumber(prUrl);
@@ -361,7 +398,7 @@ async function openAndReviewPr({
361
398
  if (alreadyMerged) {
362
399
  return { prUrl, prNumber, alreadyMerged: true };
363
400
  }
364
- setPhase('code-review');
401
+ ctx.setPhase('code-review');
365
402
  const reviewOutcome = await runStoryScopeReview({
366
403
  cwd,
367
404
  storyId,
@@ -369,56 +406,24 @@ async function openAndReviewPr({
369
406
  baseBranch,
370
407
  prUrl,
371
408
  prNumber,
372
- provider,
373
- runCodeReviewFn: injectedRunCodeReview ?? runCodeReviewDefault,
409
+ provider: deps.provider,
410
+ runCodeReviewFn: deps.runCodeReview,
374
411
  gitSpawnFn: gitSpawn,
375
412
  progress,
376
413
  });
377
- if (reviewOutcome.halted) {
378
- const criticalCount = reviewOutcome.severity?.critical ?? 0;
379
- // Checked before the blocked transition: an overridden Story proceeds.
380
- if (overrideReviewBlock) {
381
- const override = await handleOverriddenReviewBlock({
382
- provider,
383
- storyId,
384
- prUrl,
385
- prNumber,
386
- criticalCount,
387
- reason: overrideReviewBlock,
388
- config,
389
- });
390
- return {
391
- prUrl,
392
- prNumber,
393
- alreadyMerged: false,
394
- reviewOverride: override,
395
- };
396
- }
397
- await handleCriticalReviewBlock({
398
- provider,
399
- storyId,
400
- prUrl,
401
- criticalCount,
402
- });
403
- throw new Error(
404
- `[single-story-close] Story-scope review reported ${criticalCount} critical blocker(s) on PR ${prUrl}. ` +
405
- 'Auto-merge was not enabled. Remediate the findings posted to the PR and re-run `/mandrel-deliver`. ' +
406
- 'If you have reviewed a finding and judged it wrong, re-run with ' +
407
- '`--override-review-block "<reason>"` rather than merging by hand.',
408
- );
409
- }
410
- return { prUrl, prNumber, alreadyMerged: false, reviewOverride: null };
414
+ const reviewOverride = reviewOutcome.halted
415
+ ? await resolveReviewHalt(ctx, deps, { prUrl, prNumber, reviewOutcome })
416
+ : null;
417
+ return { prUrl, prNumber, alreadyMerged: false, reviewOverride };
411
418
  }
412
419
 
413
- async function releaseLease({
414
- provider,
415
- storyId,
416
- config,
417
- injectedReleaseLease,
418
- }) {
420
+ async function releaseLease({ storyId }, deps) {
419
421
  try {
420
- const release = injectedReleaseLease ?? releaseStoryLease;
421
- const outcome = await release({ provider, storyId, config });
422
+ const outcome = await deps.releaseLease({
423
+ provider: deps.provider,
424
+ storyId,
425
+ config: deps.config,
426
+ });
422
427
  progress(
423
428
  'LEASE',
424
429
  outcome.released
@@ -442,14 +447,13 @@ async function releaseLease({
442
447
  *
443
448
  * @template T
444
449
  * @param {() => Promise<T>} run
445
- * @param {{ provider: object, storyId: number, config: object, injectedReleaseLease?: Function }} leaseArgs
446
450
  * @returns {Promise<T>}
447
451
  */
448
- async function releaseLeaseOnBlock(run, leaseArgs) {
452
+ async function releaseLeaseOnBlock(run, ctx, deps) {
449
453
  try {
450
454
  return await run();
451
455
  } catch (err) {
452
- await releaseLease(leaseArgs);
456
+ await releaseLease(ctx, deps);
453
457
  throw err;
454
458
  }
455
459
  }
@@ -513,6 +517,29 @@ function closeResult({
513
517
  };
514
518
  }
515
519
 
520
+ /**
521
+ * An injected double wins, else the real one; an absent optional seam stays
522
+ * `undefined` so its phase applies its own default.
523
+ *
524
+ * @param {{ cwd: string }} options
525
+ * @param {object} injected
526
+ * @returns {object}
527
+ */
528
+ function resolveCloseDeps(options, injected) {
529
+ const config = injected.injectedConfig || resolveConfig({ cwd: options.cwd });
530
+ return {
531
+ config,
532
+ provider: injected.injectedProvider || createProvider(config),
533
+ notify: injected.injectedNotify,
534
+ sync: injected.injectedSync,
535
+ runCodeReview: injected.injectedRunCodeReview ?? runCodeReviewDefault,
536
+ gh: injected.injectedGh,
537
+ gitSpawn: injected.injectedGitSpawn,
538
+ releaseLease: injected.injectedReleaseLease ?? releaseStoryLease,
539
+ graphqlProbe: injected.injectedGraphqlProbe,
540
+ };
541
+ }
542
+
516
543
  export async function runSingleStoryClose({
517
544
  storyId: storyIdParam,
518
545
  cwd: cwdParam,
@@ -525,15 +552,8 @@ export async function runSingleStoryClose({
525
552
  mergeWatchMode: mergeWatchModeParam,
526
553
  rerunAdvisory: rerunAdvisoryParam,
527
554
  overrideReviewBlock: overrideReviewBlockParam,
528
- injectedProvider,
529
- injectedConfig,
530
- injectedNotify,
531
- injectedSync,
532
- injectedRunCodeReview,
533
- injectedGh,
534
- injectedGitSpawn,
535
- injectedReleaseLease,
536
- injectedGraphqlProbe,
555
+ workerTokens: workerTokensParam,
556
+ ...injected
537
557
  } = {}) {
538
558
  const options = parseCloseOptions({
539
559
  storyIdParam,
@@ -547,10 +567,11 @@ export async function runSingleStoryClose({
547
567
  mergeWatchModeParam,
548
568
  rerunAdvisoryParam,
549
569
  overrideReviewBlockParam,
570
+ workerTokensParam,
550
571
  });
551
572
  if (!options.storyId) {
552
573
  throw new Error(
553
- 'Usage: node single-story-close.js --story <STORY_ID> [--cwd <main-repo>] [--skip-validation] [--skip-sync] [--no-auto-merge] [--wait-merge|--no-wait-merge] [--max-wait-seconds <n>] [--merge-watch-mode <sync|async>] [--rerun-advisory <n>] [--override-review-block <reason>]',
574
+ 'Usage: node single-story-close.js --story <STORY_ID> [--cwd <main-repo>] [--skip-validation] [--skip-sync] [--no-auto-merge] [--wait-merge|--no-wait-merge] [--max-wait-seconds <n>] [--merge-watch-mode <sync|async>] [--rerun-advisory <n>] [--override-review-block <reason>] [--worker-tokens <n>]',
554
575
  );
555
576
  }
556
577
 
@@ -568,21 +589,11 @@ export async function runSingleStoryClose({
568
589
  observedGates = gates;
569
590
  };
570
591
  try {
571
- return await runClosePipeline({
572
- options,
573
- setPhase,
574
- setObservedGates,
575
- phaseTimer,
576
- injectedProvider,
577
- injectedConfig,
578
- injectedNotify,
579
- injectedSync,
580
- injectedRunCodeReview,
581
- injectedGh,
582
- injectedGitSpawn,
583
- injectedReleaseLease,
584
- injectedGraphqlProbe,
585
- });
592
+ const startedAtMs = Date.now();
593
+ return await runClosePipeline(
594
+ { options, setPhase, setObservedGates, phaseTimer, startedAtMs },
595
+ resolveCloseDeps(options, injected),
596
+ );
586
597
  } catch (err) {
587
598
  if (err && typeof err === 'object') {
588
599
  err.closePhaseDurations = phaseTimer.finish();
@@ -706,6 +717,7 @@ async function finishWithMergeWait(prCtx, deps) {
706
717
  result,
707
718
  config: prCtx.config,
708
719
  phaseTimer: prCtx.phaseTimer,
720
+ workerTokens: prCtx.workerTokens,
709
721
  });
710
722
  reportWaitTerminal(terminal, { storyId: prCtx.storyId, prUrl: prCtx.prUrl });
711
723
  return { success: terminal.status === 'landed', result, terminal };
@@ -759,6 +771,7 @@ async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
759
771
  result,
760
772
  config: prCtx.config,
761
773
  phaseTimer: prCtx.phaseTimer,
774
+ workerTokens: prCtx.workerTokens,
762
775
  });
763
776
  progress(
764
777
  'DONE',
@@ -777,14 +790,14 @@ async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
777
790
  */
778
791
  async function finishDeferred(
779
792
  lockWait,
780
- { config, startedAtMs, phaseTimer, ...ids },
793
+ { config, startedAtMs, phaseTimer, workerTokens, ...ids },
781
794
  ) {
782
795
  const { result, terminal, note } = lockWaitPending({
783
796
  ...ids,
784
797
  lockWait,
785
798
  elapsedSeconds: elapsedSecondsSince(startedAtMs),
786
799
  });
787
- await emitTerminal({ terminal, result, config, phaseTimer });
800
+ await emitTerminal({ terminal, result, config, phaseTimer, workerTokens });
788
801
  progress('PENDING', note);
789
802
  return { success: false, result, terminal };
790
803
  }
@@ -819,212 +832,212 @@ function reportOperatorMergeSkip({
819
832
  );
820
833
  }
821
834
 
822
- async function runClosePipeline({
823
- options,
824
- setPhase,
825
- setObservedGates,
826
- phaseTimer,
827
- injectedProvider,
828
- injectedConfig,
829
- injectedNotify,
830
- injectedSync,
831
- injectedRunCodeReview,
832
- injectedGh,
833
- injectedGitSpawn,
834
- injectedReleaseLease,
835
- injectedGraphqlProbe,
836
- }) {
837
- const startedAtMs = Date.now();
838
- const config = injectedConfig || resolveConfig({ cwd: options.cwd });
839
- const provider = injectedProvider || createProvider(config);
840
- const storyBranch = getStoryBranch(options.storyId);
841
-
842
- progress('INIT', `Closing standalone Story #${options.storyId}...`);
843
- const story = await provider.getTicket(options.storyId);
844
- if (story.state === 'closed') {
845
- return await alreadyClosedResult(
846
- options.storyId,
847
- story.stateReason,
848
- config,
849
- );
850
- }
851
-
852
- const leaseArgs = {
853
- provider,
854
- storyId: options.storyId,
855
- config,
856
- injectedReleaseLease,
857
- };
835
+ async function loadStoryPhase(ctx, deps) {
836
+ progress('INIT', `Closing standalone Story #${ctx.storyId}...`);
837
+ ctx.story = await deps.provider.getTicket(ctx.storyId);
838
+ if (ctx.story.state !== 'closed') return null;
839
+ return await alreadyClosedResult(
840
+ ctx.storyId,
841
+ ctx.story.stateReason,
842
+ deps.config,
843
+ ctx.workerTokens,
844
+ );
845
+ }
858
846
 
859
- // `gh pr` needs GraphQL; one cheap read here beats the gate chain plus a push.
847
+ /** One cheap GraphQL read here beats the gate chain plus a push. */
848
+ async function graphqlPreflightPhase(ctx, deps) {
860
849
  const preflight = await runGraphqlPreflight({
861
- storyId: options.storyId,
862
- provider,
850
+ storyId: ctx.storyId,
851
+ provider: deps.provider,
863
852
  progress,
864
- ghFacade: injectedGh,
865
- probe: injectedGraphqlProbe,
853
+ ghFacade: deps.gh,
854
+ probe: deps.graphqlProbe,
866
855
  });
867
- if (preflight) {
868
- await releaseLease(leaseArgs);
869
- return await preflightBlockedResult({
870
- storyId: options.storyId,
871
- preflight,
872
- config,
873
- startedAtMs,
874
- });
875
- }
856
+ if (!preflight) return null;
857
+ await releaseLease(ctx, deps);
858
+ return await preflightBlockedResult({
859
+ storyId: ctx.storyId,
860
+ preflight,
861
+ config: deps.config,
862
+ startedAtMs: ctx.startedAtMs,
863
+ workerTokens: ctx.workerTokens,
864
+ });
865
+ }
876
866
 
877
- // The base the run was SEEDED from (init receipt); throws before any merge
878
- // when it disagrees with current config.
879
- const { values: runScoped, confirmed: baseConfirmed } =
880
- await resolveRunScopedConfig({
881
- storyId: options.storyId,
882
- config,
883
- progress,
884
- });
885
- const baseBranch = runScoped.baseBranch;
867
+ /**
868
+ * The base the run was SEEDED from (init receipt); throws before any merge
869
+ * when it disagrees with current config.
870
+ */
871
+ async function resolveBasePhase(ctx, deps) {
872
+ const { values, confirmed } = await resolveRunScopedConfig({
873
+ storyId: ctx.storyId,
874
+ config: deps.config,
875
+ progress,
876
+ });
877
+ ctx.baseBranch = values.baseBranch;
878
+ ctx.baseConfirmed = confirmed;
879
+ ctx.worktreePath = resolveWorktreePath({
880
+ cwd: ctx.options.cwd,
881
+ config: deps.config,
882
+ storyId: ctx.storyId,
883
+ });
884
+ return null;
885
+ }
886
886
 
887
- const worktreePath = resolveWorktreePath({
888
- cwd: options.cwd,
889
- config,
890
- storyId: options.storyId,
887
+ async function prePushPhase(ctx, deps) {
888
+ ctx.prePush = await releaseLeaseOnBlock(
889
+ () => runPrePushPhases(ctx, deps),
890
+ ctx,
891
+ deps,
892
+ );
893
+ if (!ctx.prePush.pending) return null;
894
+ return await finishDeferred(ctx.prePush.lockWait, {
895
+ storyId: ctx.storyId,
896
+ storyBranch: ctx.storyBranch,
897
+ baseBranch: ctx.baseBranch,
898
+ config: deps.config,
899
+ startedAtMs: ctx.startedAtMs,
900
+ phaseTimer: ctx.phaseTimer,
901
+ workerTokens: ctx.workerTokens,
891
902
  });
892
- const prePush = await releaseLeaseOnBlock(
893
- () =>
894
- runPrePushPhases({
895
- ...options,
896
- config,
897
- baseBranch,
898
- baseConfirmed,
899
- storyBranch,
900
- provider,
901
- worktreePath,
902
- injectedSync,
903
- injectedGitSpawn,
904
- setPhase,
905
- setObservedGates,
906
- }),
907
- leaseArgs,
903
+ }
904
+
905
+ async function openPrPhase(ctx, deps) {
906
+ ctx.pr = await releaseLeaseOnBlock(
907
+ () => openAndReviewPr(ctx, deps),
908
+ ctx,
909
+ deps,
908
910
  );
909
- if (prePush.pending) {
910
- return await finishDeferred(prePush.lockWait, {
911
- storyId: options.storyId,
912
- storyBranch,
913
- baseBranch,
914
- config,
915
- startedAtMs,
916
- phaseTimer,
917
- });
918
- }
911
+ return null;
912
+ }
919
913
 
920
- const { prUrl, prNumber, alreadyMerged, reviewOverride } =
921
- await releaseLeaseOnBlock(
922
- () =>
923
- openAndReviewPr({
924
- cwd: options.cwd,
925
- worktreePath,
926
- story,
927
- storyId: options.storyId,
928
- storyBranch,
929
- baseBranch,
930
- provider,
931
- config,
932
- overrideReviewBlock: options.overrideReviewBlock,
933
- injectedGh,
934
- injectedRunCodeReview,
935
- setPhase,
936
- }),
937
- leaseArgs,
938
- );
939
- // Reap BEFORE the arm: `gh pr merge --delete-branch` may merge at once and
940
- // then fail deleting a branch a live worktree holds, reading as a failed
941
- // arm. Safe — the work is pushed, and a dirty tree is still refused.
942
- const worktreeReaped = await reapWorktreePhase({
943
- cwd: options.cwd,
944
- storyId: options.storyId,
945
- worktreePath,
946
- wtIsolation: config.delivery?.worktreeIsolation,
914
+ /**
915
+ * Reap BEFORE the arm: `gh pr merge --delete-branch` may merge at once and
916
+ * then fail deleting a branch a live worktree holds, reading as a failed arm.
917
+ * Safe — the work is pushed, and a dirty tree is still refused.
918
+ */
919
+ async function reapWorktreePhaseStep(ctx, deps) {
920
+ ctx.worktreeReaped = await reapWorktreePhase({
921
+ cwd: ctx.options.cwd,
922
+ storyId: ctx.storyId,
923
+ worktreePath: ctx.worktreePath,
924
+ wtIsolation: deps.config.delivery?.worktreeIsolation,
947
925
  progress,
948
926
  WorktreeManager,
949
927
  });
950
- setPhase('auto-merge');
951
- const ciDelivery = getCiDelivery(config);
952
- const {
953
- autoMergeEnabled,
954
- autoMergeReason,
955
- localCleanupDeferred,
956
- directMerged,
957
- advisoryGate,
958
- } = await resolveAutoMergeOutcome({
928
+ return null;
929
+ }
930
+
931
+ /** No lease release here: only the post-land tail releases it. */
932
+ async function armPhase(ctx, deps) {
933
+ ctx.setPhase('auto-merge');
934
+ const ciDelivery = getCiDelivery(deps.config);
935
+ const { prUrl, prNumber, alreadyMerged } = ctx.pr;
936
+ ctx.arm = await resolveAutoMergeOutcome({
959
937
  alreadyMerged,
960
- cwd: options.cwd,
938
+ cwd: ctx.options.cwd,
961
939
  prNumber,
962
940
  prUrl,
963
- noAutoMerge: options.noAutoMerge,
941
+ noAutoMerge: ctx.options.noAutoMerge,
964
942
  autoMergePolicy: ciDelivery.autoMerge,
965
943
  blockOnAdvisoryFailure: ciDelivery.blockOnAdvisoryFailure,
966
944
  advisoryAllowlist: ciDelivery.advisoryAllowlist,
967
- gh: injectedGh,
945
+ gh: deps.gh,
968
946
  progress,
969
947
  });
970
948
  await flipLabelAndNotify({
971
- provider,
972
- notifyFn: injectedNotify,
973
- storyId: options.storyId,
974
- story,
949
+ provider: deps.provider,
950
+ notifyFn: deps.notify,
951
+ storyId: ctx.storyId,
952
+ story: ctx.story,
975
953
  prUrl,
976
- autoMergeEnabled,
977
- autoMergeReason,
978
- config,
954
+ autoMergeEnabled: ctx.arm.autoMergeEnabled,
955
+ autoMergeReason: ctx.arm.autoMergeReason,
956
+ config: deps.config,
979
957
  progress,
980
958
  });
981
- // No lease release here: only the post-land tail (confirmed merge) releases
982
- // it; every non-merged ending keeps the claim while the PR is open.
959
+ return null;
960
+ }
983
961
 
984
- // Resolved now, not at parse time: it needs the config and the arm outcome
985
- // (an un-armed PR rests at `agent::closing` rather than waiting).
962
+ /** Wait-for-merge needs the arm outcome, so it is resolved here. */
963
+ async function finishPhase(ctx, deps) {
964
+ const { options, arm } = ctx;
986
965
  const { waitForMerge, reason: waitForMergeReason } = resolveWaitForMerge({
987
966
  waitForMergeExplicit: options.waitForMergeExplicit,
988
967
  noWaitForMerge: options.noWaitForMerge,
989
- config,
990
- autoMergeReason,
968
+ config: deps.config,
969
+ autoMergeReason: arm.autoMergeReason,
991
970
  });
992
971
  reportOperatorMergeSkip({
993
972
  waitForMergeReason,
994
- autoMergeReason,
995
- storyId: options.storyId,
973
+ autoMergeReason: arm.autoMergeReason,
974
+ storyId: ctx.storyId,
996
975
  waitForMergeExplicit: options.waitForMergeExplicit,
997
976
  });
998
977
  const prCtx = {
999
- storyId: options.storyId,
1000
- storyBranch,
1001
- baseBranch,
1002
- prNumber,
1003
- prUrl,
1004
- autoMergeEnabled,
1005
- autoMergeReason,
1006
- advisoryGate,
1007
- worktreeReaped,
1008
- localCleanupDeferred,
1009
- directMerged,
1010
- config,
1011
- startedAtMs,
1012
- phaseTimer,
1013
- lockWait: prePush.lockWait,
1014
- gates: closeEnvelopeGates(options, prePush.validationGates, reviewOverride),
978
+ storyId: ctx.storyId,
979
+ storyBranch: ctx.storyBranch,
980
+ baseBranch: ctx.baseBranch,
981
+ prNumber: ctx.pr.prNumber,
982
+ prUrl: ctx.pr.prUrl,
983
+ autoMergeEnabled: arm.autoMergeEnabled,
984
+ autoMergeReason: arm.autoMergeReason,
985
+ advisoryGate: arm.advisoryGate,
986
+ worktreeReaped: ctx.worktreeReaped,
987
+ localCleanupDeferred: arm.localCleanupDeferred,
988
+ directMerged: arm.directMerged,
989
+ config: deps.config,
990
+ startedAtMs: ctx.startedAtMs,
991
+ phaseTimer: ctx.phaseTimer,
992
+ workerTokens: ctx.workerTokens,
993
+ lockWait: ctx.prePush.lockWait,
994
+ gates: closeEnvelopeGates(
995
+ options,
996
+ ctx.prePush.validationGates,
997
+ ctx.pr.reviewOverride,
998
+ ),
1015
999
  };
1000
+ if (!waitForMerge) {
1001
+ return await finishWithoutMergeWait(prCtx, waitForMergeReason);
1002
+ }
1003
+ return await finishWithMergeWait(prCtx, {
1004
+ cwd: options.cwd,
1005
+ provider: deps.provider,
1006
+ maxWaitSeconds: options.maxWaitSeconds,
1007
+ mergeWatchMode: options.mergeWatchMode,
1008
+ rerunAdvisory: options.rerunAdvisory,
1009
+ setPhase: ctx.setPhase,
1010
+ injectedGh: deps.gh,
1011
+ injectedNotify: deps.notify,
1012
+ });
1013
+ }
1016
1014
 
1017
- if (waitForMerge) {
1018
- return await finishWithMergeWait(prCtx, {
1019
- cwd: options.cwd,
1020
- provider,
1021
- maxWaitSeconds: options.maxWaitSeconds,
1022
- mergeWatchMode: options.mergeWatchMode,
1023
- rerunAdvisory: options.rerunAdvisory,
1024
- setPhase,
1025
- injectedGh,
1026
- injectedNotify,
1027
- });
1015
+ /** Each phase extends the shared context; a non-null return ends the run. */
1016
+ const CLOSE_PIPELINE = Object.freeze([
1017
+ loadStoryPhase,
1018
+ graphqlPreflightPhase,
1019
+ resolveBasePhase,
1020
+ prePushPhase,
1021
+ openPrPhase,
1022
+ reapWorktreePhaseStep,
1023
+ armPhase,
1024
+ finishPhase,
1025
+ ]);
1026
+
1027
+ /**
1028
+ * @param {object} run
1029
+ * @param {object} deps
1030
+ * @returns {Promise<{ success: boolean, result: object, terminal: object }>}
1031
+ */
1032
+ async function runClosePipeline(run, deps) {
1033
+ const ctx = {
1034
+ ...run,
1035
+ storyId: run.options.storyId,
1036
+ storyBranch: getStoryBranch(run.options.storyId),
1037
+ workerTokens: resolveWorkerTokens(run.options.workerTokens),
1038
+ };
1039
+ for (const phase of CLOSE_PIPELINE) {
1040
+ const ending = await phase(ctx, deps);
1041
+ if (ending) return ending;
1028
1042
  }
1029
- return await finishWithoutMergeWait(prCtx, waitForMergeReason);
1030
1043
  }