mandrel 2.64.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 (105) hide show
  1. package/.agents/agents/acceptance-critic.md +8 -7
  2. package/.agents/agents/auditor.md +20 -20
  3. package/.agents/agents/plan-critic.md +8 -7
  4. package/.agents/agents/story-worker.md +7 -7
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +1 -9
  7. package/.agents/docs/configuration.md +8 -7
  8. package/.agents/docs/execution-reference.md +27 -5
  9. package/.agents/instructions.md +10 -12
  10. package/.agents/rules/ci-remediation.md +3 -3
  11. package/.agents/rules/gherkin-standards.md +3 -2
  12. package/.agents/rules/git-conventions-reference.md +12 -3
  13. package/.agents/rules/git-conventions.md +9 -7
  14. package/.agents/rules/testing-standards.md +8 -7
  15. package/.agents/runtime-deps.json +1 -1
  16. package/.agents/schemas/agentrc.schema.json +6 -13
  17. package/.agents/schemas/audit-rules.schema.json +1 -1
  18. package/.agents/schemas/story-deliver-terminal.schema.json +5 -0
  19. package/.agents/scripts/bootstrap.js +102 -91
  20. package/.agents/scripts/check-context-budget.js +1 -1
  21. package/.agents/scripts/lib/ITicketingProvider.js +1 -3
  22. package/.agents/scripts/lib/audit-suite/findings.js +1 -17
  23. package/.agents/scripts/lib/audit-suite/frontmatter.js +0 -28
  24. package/.agents/scripts/lib/audit-suite/index.js +0 -6
  25. package/.agents/scripts/lib/audit-suite/selector.js +0 -31
  26. package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
  27. package/.agents/scripts/lib/bootstrap/agents-md-fold.js +156 -0
  28. package/.agents/scripts/lib/bootstrap/commit-push.js +1 -1
  29. package/.agents/scripts/lib/bootstrap/manifest.js +2 -2
  30. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +91 -107
  31. package/.agents/scripts/lib/cli/standard-args.js +60 -76
  32. package/.agents/scripts/lib/cli-args.js +26 -0
  33. package/.agents/scripts/lib/config/gates/shared.js +3 -3
  34. package/.agents/scripts/lib/config/review-chain-default.js +13 -0
  35. package/.agents/scripts/lib/config-settings-schema-delivery.js +2 -2
  36. package/.agents/scripts/lib/config-settings-schema-quality.js +11 -13
  37. package/.agents/scripts/lib/doc-tiers.js +25 -6
  38. package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
  39. package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
  40. package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
  41. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  42. package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
  43. package/.agents/scripts/lib/observability/metrics-ledger.js +0 -72
  44. package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
  45. package/.agents/scripts/lib/observability/signal-validator.js +17 -5
  46. package/.agents/scripts/lib/orchestration/code-review.js +33 -6
  47. package/.agents/scripts/lib/orchestration/epic-rollup.js +29 -12
  48. package/.agents/scripts/lib/orchestration/merge-block-class.js +20 -4
  49. package/.agents/scripts/lib/orchestration/merge-poll.js +41 -22
  50. package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
  51. package/.agents/scripts/lib/orchestration/required-checks.js +147 -0
  52. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +203 -0
  53. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +29 -4
  54. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -2
  55. package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
  56. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +1 -0
  57. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -12
  58. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +370 -268
  59. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
  60. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +112 -82
  61. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
  62. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +393 -313
  63. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +12 -87
  64. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +3 -0
  65. package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
  66. package/.agents/scripts/lib/signals/detectors/common.js +63 -51
  67. package/.agents/scripts/lib/templates/decomposer-prompts.js +5 -24
  68. package/.agents/scripts/lib/transpile.js +28 -3
  69. package/.agents/scripts/providers/github/issues.js +14 -23
  70. package/.agents/scripts/single-story-close.js +10 -2
  71. package/.agents/scripts/single-story-confirm-merge.js +267 -238
  72. package/.agents/scripts/sync-claude-agents.js +1 -1
  73. package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
  74. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
  75. package/.agents/workflows/audit-architecture.md +5 -4
  76. package/.agents/workflows/audit-documentation.md +5 -5
  77. package/.agents/workflows/audit-performance.md +10 -10
  78. package/.agents/workflows/audit-quality.md +42 -7
  79. package/.agents/workflows/helpers/acceptance-self-eval.md +9 -9
  80. package/.agents/workflows/helpers/audit-lens-core.md +30 -57
  81. package/.agents/workflows/helpers/code-review.md +15 -38
  82. package/.agents/workflows/helpers/deliver-digest.md +2 -2
  83. package/.agents/workflows/helpers/deliver-reference.md +7 -3
  84. package/.agents/workflows/helpers/deliver-story.md +9 -1
  85. package/.agents/workflows/helpers/parallel-tooling.md +16 -18
  86. package/.agents/workflows/helpers/plan-reference.md +9 -8
  87. package/.agents/workflows/mandrel-deliver.md +3 -2
  88. package/.agents/workflows/mandrel-plan.md +11 -7
  89. package/.agents/workflows/mandrel-update.md +5 -3
  90. package/docs/CHANGELOG.md +57 -0
  91. package/lib/cli/claude-code-version.js +73 -0
  92. package/lib/cli/doctor.js +2 -2
  93. package/lib/cli/guarded-sync.js +87 -0
  94. package/lib/cli/registry.js +9 -0
  95. package/lib/cli/sync-agents.js +9 -92
  96. package/lib/cli/sync-commands.js +9 -101
  97. package/lib/cli/uninstall.js +37 -9
  98. package/lib/migrations/index.js +2 -0
  99. package/lib/migrations/steps/2.65.0-fold-claude-md-into-agents-md.js +38 -0
  100. package/package.json +3 -2
  101. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +0 -99
  102. package/.agents/scripts/lib/audit-suite/runner.js +0 -205
  103. package/.agents/scripts/lib/audit-suite/substitutions.js +0 -96
  104. package/.agents/scripts/lib/audit-suite/workflow-loader.js +0 -37
  105. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +0 -234
@@ -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';
@@ -33,6 +38,7 @@ import { runConfirmMergePhase } from './phases/confirm-merge.js';
33
38
  import { runGraphqlPreflight } from './phases/graphql-preflight.js';
34
39
  import { lockWaitPending } from './phases/lock-wait-pending.js';
35
40
  import { parseCloseOptions, resolveWaitForMerge } from './phases/options.js';
41
+ import { runPostLandTail } from './phases/post-land.js';
36
42
  import { ensurePullRequestWith } from './phases/pull-request.js';
37
43
  import { pushStoryBranch } from './phases/push.js';
38
44
  import { handleCriticalReviewBlock } from './phases/review-block.js';
@@ -43,11 +49,56 @@ import { runWrongTreeGuardPhase } from './phases/wrong-tree-guard.js';
43
49
  const progress = Logger.createProgress('single-story-close', { stderr: true });
44
50
 
45
51
  /**
46
- * The single terminal writer: the result summary, the envelope callers parse,
47
- * and terminal friction — so no ending can forget one. Must be awaited: the
48
- * CLI `process.exit`s as soon as `main` resolves.
52
+ * Wall-clock seconds per named phase; each transition logs the phase it ends.
53
+ *
54
+ * @param {() => number} [nowMs]
49
55
  */
50
- async function emitTerminal({ terminal, result, config }) {
56
+ function createPhaseTimer(nowMs = Date.now) {
57
+ const durations = {};
58
+ let current = null;
59
+ let since = 0;
60
+ const end = () => {
61
+ if (current === null) return;
62
+ const seconds = Math.round((nowMs() - since) / 100) / 10;
63
+ durations[current] = (durations[current] ?? 0) + seconds;
64
+ progress('TIMING', `⏱ ${current}: ${seconds}s`);
65
+ current = null;
66
+ };
67
+ return {
68
+ enter(phase) {
69
+ end();
70
+ if (phase === 'init') return;
71
+ current = phase;
72
+ since = nowMs();
73
+ },
74
+ finish() {
75
+ end();
76
+ return Object.keys(durations).length > 0 ? { ...durations } : null;
77
+ },
78
+ stamp(terminal) {
79
+ const phaseDurations = this.finish();
80
+ if (phaseDurations) terminal.phaseDurations = phaseDurations;
81
+ },
82
+ };
83
+ }
84
+
85
+ const UNTIMED = Object.freeze({ stamp() {} });
86
+
87
+ /**
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.
92
+ */
93
+ async function emitTerminal({
94
+ terminal,
95
+ result,
96
+ config,
97
+ phaseTimer = UNTIMED,
98
+ workerTokens = null,
99
+ }) {
100
+ phaseTimer.stamp(terminal);
101
+ await recordCloseTelemetry({ terminal, result, config, workerTokens });
51
102
  if (result) {
52
103
  emitTerseResult({
53
104
  label: 'STORY CLOSE RESULT',
@@ -66,23 +117,32 @@ async function emitTerminal({ terminal, result, config }) {
66
117
  await emitTerminalFriction({ envelope: terminal, config });
67
118
  }
68
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
+
69
129
  /**
70
130
  * Terminal for an already-closed Story. `not_planned` means nothing merged,
71
131
  * so it fails rather than reporting `landed` (which would also unblock
72
132
  * dependents); `completed` or null (GitHub's default) reads as landed.
73
133
  */
74
- async function alreadyClosedResult(storyId, stateReason = null, config) {
134
+ async function alreadyClosedResult(
135
+ storyId,
136
+ stateReason = null,
137
+ config,
138
+ workerTokens = null,
139
+ ) {
75
140
  if (stateReason === 'not_planned') {
76
141
  progress(
77
142
  'NOOP',
78
143
  `Story #${storyId} is closed as not planned — nothing to land.`,
79
144
  );
80
- const result = {
81
- storyId,
82
- standalone: true,
83
- action: 'noop',
84
- reason: 'closed-not-planned',
85
- };
145
+ const result = noopResult(storyId, 'closed-not-planned');
86
146
  const terminal = buildTerminalEnvelope({
87
147
  storyId,
88
148
  status: 'failed',
@@ -95,17 +155,12 @@ async function alreadyClosedResult(storyId, stateReason = null, config) {
95
155
  nextCommand: null,
96
156
  elapsedSeconds: 0,
97
157
  });
98
- await emitTerminal({ terminal, result, config });
158
+ await emitTerminal({ terminal, result, config, workerTokens });
99
159
  return { success: false, result, terminal };
100
160
  }
101
161
 
102
162
  progress('NOOP', `Story #${storyId} is already closed. Nothing to do.`);
103
- const result = {
104
- storyId,
105
- standalone: true,
106
- action: 'noop',
107
- reason: 'already-closed',
108
- };
163
+ const result = noopResult(storyId, 'already-closed');
109
164
  const terminal = buildTerminalEnvelope({
110
165
  storyId,
111
166
  status: 'landed',
@@ -113,7 +168,7 @@ async function alreadyClosedResult(storyId, stateReason = null, config) {
113
168
  nextCommand: null,
114
169
  elapsedSeconds: 0,
115
170
  });
116
- await emitTerminal({ terminal, result, config });
171
+ await emitTerminal({ terminal, result, config, workerTokens });
117
172
  return { success: true, result, terminal };
118
173
  }
119
174
 
@@ -136,13 +191,9 @@ async function preflightBlockedResult({
136
191
  preflight,
137
192
  config,
138
193
  startedAtMs,
194
+ workerTokens = null,
139
195
  }) {
140
- const result = {
141
- storyId,
142
- standalone: true,
143
- action: 'noop',
144
- reason: `graphql-preflight-${preflight.verdict}`,
145
- };
196
+ const result = noopResult(storyId, `graphql-preflight-${preflight.verdict}`);
146
197
  const terminal = buildTerminalEnvelope({
147
198
  storyId,
148
199
  status: 'blocked',
@@ -155,7 +206,7 @@ async function preflightBlockedResult({
155
206
  nextCommand: NEXT_COMMANDS.close(storyId),
156
207
  elapsedSeconds: elapsedSecondsSince(startedAtMs),
157
208
  });
158
- await emitTerminal({ terminal, result, config });
209
+ await emitTerminal({ terminal, result, config, workerTokens });
159
210
  return { success: false, result, terminal };
160
211
  }
161
212
 
@@ -201,6 +252,8 @@ function resolveWorktreePath({ cwd, config, storyId }) {
201
252
  * the validated tree is the pushed tree (base-sync's merge commit included),
202
253
  * and a cheap conflict is found before the expensive gates.
203
254
  *
255
+ * @param {object} ctx
256
+ * @param {object} deps
204
257
  * @returns {Promise<{
205
258
  * validationGates: Record<string, string>|null,
206
259
  * lockWait: { waitedSeconds: number, expired: boolean }|null,
@@ -208,49 +261,37 @@ function resolveWorktreePath({ cwd, config, storyId }) {
208
261
  * }>} `validationGates` is null when skipped; `pending` means a full-suite
209
262
  * lock wait expired and the gates deferred.
210
263
  */
211
- async function runPrePushPhases({
212
- cwd,
213
- worktreePath,
214
- config,
215
- baseBranch,
216
- baseConfirmed,
217
- storyBranch,
218
- storyId,
219
- provider,
220
- skipValidation,
221
- skipSync,
222
- injectedSync,
223
- injectedGitSpawn,
224
- setPhase = () => {},
225
- setObservedGates = () => {},
226
- }) {
264
+ async function runPrePushPhases(ctx, deps) {
265
+ const { options, worktreePath, baseBranch, storyBranch, storyId, setPhase } =
266
+ ctx;
267
+ const { cwd } = options;
227
268
  setPhase('wrong-tree-guard');
228
269
  await runWrongTreeGuardPhase({
229
270
  cwd,
230
271
  worktreePath,
231
272
  baseBranch,
232
273
  storyId,
233
- provider,
274
+ provider: deps.provider,
234
275
  progress,
235
- gitSpawn: injectedGitSpawn,
276
+ gitSpawn: deps.gitSpawn,
236
277
  });
237
- if (!skipSync) {
278
+ if (!options.skipSync) {
238
279
  setPhase('base-sync');
239
280
  await runBaseSyncPhase({
240
281
  cwd,
241
282
  worktreePath,
242
283
  baseBranch,
243
- baseConfirmed,
284
+ baseConfirmed: ctx.baseConfirmed,
244
285
  storyBranch,
245
286
  storyId,
246
- provider,
247
- injectedSync,
287
+ provider: deps.provider,
288
+ injectedSync: deps.sync,
248
289
  progress,
249
290
  });
250
291
  } else {
251
292
  progress('SYNC', '⏭ Skipped (--skip-sync).');
252
293
  }
253
- if (skipValidation) {
294
+ if (options.skipValidation) {
254
295
  progress('VALIDATE', '⏭ Skipped (--skip-validation).');
255
296
  return { validationGates: null, lockWait: null, pending: false };
256
297
  }
@@ -260,7 +301,7 @@ async function runPrePushPhases({
260
301
  validation = await runCloseValidationPhase({
261
302
  cwd,
262
303
  worktreePath,
263
- config,
304
+ config: deps.config,
264
305
  baseBranch,
265
306
  storyBranch,
266
307
  storyId,
@@ -271,12 +312,12 @@ async function runPrePushPhases({
271
312
  } catch (err) {
272
313
  // The gate that died is all this run observed; the envelope claims no more.
273
314
  if (typeof err?.closeGate === 'string') {
274
- setObservedGates({ [err.closeGate]: 'failed' });
315
+ ctx.setObservedGates({ [err.closeGate]: 'failed' });
275
316
  }
276
317
  throw err;
277
318
  }
278
319
  const gates = validation?.gates ?? null;
279
- setObservedGates(gates);
320
+ ctx.setObservedGates(gates);
280
321
  return {
281
322
  validationGates: gates,
282
323
  lockWait: validation?.lockWait ?? null,
@@ -284,25 +325,64 @@ async function runPrePushPhases({
284
325
  };
285
326
  }
286
327
 
287
- async function openAndReviewPr({
288
- cwd,
289
- worktreePath,
290
- story,
291
- storyId,
292
- storyBranch,
293
- baseBranch,
294
- provider,
295
- config,
296
- overrideReviewBlock,
297
- injectedGh,
298
- injectedRunCodeReview,
299
- setPhase = () => {},
300
- }) {
301
- 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');
302
382
  // Push from the worktree so `pre-push` measures the tree being sent; the
303
383
  // ref-based reads below resolve identically from the shared `.git`.
304
384
  pushStoryBranch({ cwd, worktreePath, storyBranch, gitSync, progress });
305
- setPhase('pull-request');
385
+ ctx.setPhase('pull-request');
306
386
  const { url: prUrl, alreadyMerged } = await ensurePullRequestWith({
307
387
  cwd,
308
388
  storyId,
@@ -310,7 +390,7 @@ async function openAndReviewPr({
310
390
  storyBody: story.body,
311
391
  storyBranch,
312
392
  baseBranch,
313
- gh: injectedGh,
393
+ gh: deps.gh,
314
394
  progress,
315
395
  });
316
396
  const prNumber = parsePrNumber(prUrl);
@@ -318,7 +398,7 @@ async function openAndReviewPr({
318
398
  if (alreadyMerged) {
319
399
  return { prUrl, prNumber, alreadyMerged: true };
320
400
  }
321
- setPhase('code-review');
401
+ ctx.setPhase('code-review');
322
402
  const reviewOutcome = await runStoryScopeReview({
323
403
  cwd,
324
404
  storyId,
@@ -326,56 +406,24 @@ async function openAndReviewPr({
326
406
  baseBranch,
327
407
  prUrl,
328
408
  prNumber,
329
- provider,
330
- runCodeReviewFn: injectedRunCodeReview ?? runCodeReviewDefault,
409
+ provider: deps.provider,
410
+ runCodeReviewFn: deps.runCodeReview,
331
411
  gitSpawnFn: gitSpawn,
332
412
  progress,
333
413
  });
334
- if (reviewOutcome.halted) {
335
- const criticalCount = reviewOutcome.severity?.critical ?? 0;
336
- // Checked before the blocked transition: an overridden Story proceeds.
337
- if (overrideReviewBlock) {
338
- const override = await handleOverriddenReviewBlock({
339
- provider,
340
- storyId,
341
- prUrl,
342
- prNumber,
343
- criticalCount,
344
- reason: overrideReviewBlock,
345
- config,
346
- });
347
- return {
348
- prUrl,
349
- prNumber,
350
- alreadyMerged: false,
351
- reviewOverride: override,
352
- };
353
- }
354
- await handleCriticalReviewBlock({
355
- provider,
356
- storyId,
357
- prUrl,
358
- criticalCount,
359
- });
360
- throw new Error(
361
- `[single-story-close] Story-scope review reported ${criticalCount} critical blocker(s) on PR ${prUrl}. ` +
362
- 'Auto-merge was not enabled. Remediate the findings posted to the PR and re-run `/mandrel-deliver`. ' +
363
- 'If you have reviewed a finding and judged it wrong, re-run with ' +
364
- '`--override-review-block "<reason>"` rather than merging by hand.',
365
- );
366
- }
367
- 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 };
368
418
  }
369
419
 
370
- async function releaseLease({
371
- provider,
372
- storyId,
373
- config,
374
- injectedReleaseLease,
375
- }) {
420
+ async function releaseLease({ storyId }, deps) {
376
421
  try {
377
- const release = injectedReleaseLease ?? releaseStoryLease;
378
- const outcome = await release({ provider, storyId, config });
422
+ const outcome = await deps.releaseLease({
423
+ provider: deps.provider,
424
+ storyId,
425
+ config: deps.config,
426
+ });
379
427
  progress(
380
428
  'LEASE',
381
429
  outcome.released
@@ -399,14 +447,13 @@ async function releaseLease({
399
447
  *
400
448
  * @template T
401
449
  * @param {() => Promise<T>} run
402
- * @param {{ provider: object, storyId: number, config: object, injectedReleaseLease?: Function }} leaseArgs
403
450
  * @returns {Promise<T>}
404
451
  */
405
- async function releaseLeaseOnBlock(run, leaseArgs) {
452
+ async function releaseLeaseOnBlock(run, ctx, deps) {
406
453
  try {
407
454
  return await run();
408
455
  } catch (err) {
409
- await releaseLease(leaseArgs);
456
+ await releaseLease(ctx, deps);
410
457
  throw err;
411
458
  }
412
459
  }
@@ -470,6 +517,29 @@ function closeResult({
470
517
  };
471
518
  }
472
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
+
473
543
  export async function runSingleStoryClose({
474
544
  storyId: storyIdParam,
475
545
  cwd: cwdParam,
@@ -482,15 +552,8 @@ export async function runSingleStoryClose({
482
552
  mergeWatchMode: mergeWatchModeParam,
483
553
  rerunAdvisory: rerunAdvisoryParam,
484
554
  overrideReviewBlock: overrideReviewBlockParam,
485
- injectedProvider,
486
- injectedConfig,
487
- injectedNotify,
488
- injectedSync,
489
- injectedRunCodeReview,
490
- injectedGh,
491
- injectedGitSpawn,
492
- injectedReleaseLease,
493
- injectedGraphqlProbe,
555
+ workerTokens: workerTokensParam,
556
+ ...injected
494
557
  } = {}) {
495
558
  const options = parseCloseOptions({
496
559
  storyIdParam,
@@ -504,10 +567,11 @@ export async function runSingleStoryClose({
504
567
  mergeWatchModeParam,
505
568
  rerunAdvisoryParam,
506
569
  overrideReviewBlockParam,
570
+ workerTokensParam,
507
571
  });
508
572
  if (!options.storyId) {
509
573
  throw new Error(
510
- '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>]',
511
575
  );
512
576
  }
513
577
 
@@ -516,29 +580,23 @@ export async function runSingleStoryClose({
516
580
  // builds the `failed` envelope from those tags.
517
581
  let phase = 'init';
518
582
  let observedGates = null;
583
+ const phaseTimer = createPhaseTimer();
519
584
  const setPhase = (next) => {
520
585
  phase = next;
586
+ phaseTimer.enter(next);
521
587
  };
522
588
  const setObservedGates = (gates) => {
523
589
  observedGates = gates;
524
590
  };
525
591
  try {
526
- return await runClosePipeline({
527
- options,
528
- setPhase,
529
- setObservedGates,
530
- injectedProvider,
531
- injectedConfig,
532
- injectedNotify,
533
- injectedSync,
534
- injectedRunCodeReview,
535
- injectedGh,
536
- injectedGitSpawn,
537
- injectedReleaseLease,
538
- injectedGraphqlProbe,
539
- });
592
+ const startedAtMs = Date.now();
593
+ return await runClosePipeline(
594
+ { options, setPhase, setObservedGates, phaseTimer, startedAtMs },
595
+ resolveCloseDeps(options, injected),
596
+ );
540
597
  } catch (err) {
541
598
  if (err && typeof err === 'object') {
599
+ err.closePhaseDurations = phaseTimer.finish();
542
600
  if (!err.closePhase) err.closePhase = phase;
543
601
  if (!err.closeGates && observedGates) err.closeGates = observedGates;
544
602
  }
@@ -617,6 +675,10 @@ async function finishWithMergeWait(prCtx, deps) {
617
675
  progress,
618
676
  injectedGh: deps.injectedGh,
619
677
  injectedNotify: deps.injectedNotify,
678
+ runPostLandTailFn: (args) => {
679
+ deps.setPhase('post-land');
680
+ return runPostLandTail(args);
681
+ },
620
682
  });
621
683
  const terminal = terminalFromWaitOutcome({
622
684
  waitOutcome,
@@ -650,7 +712,13 @@ async function finishWithMergeWait(prCtx, deps) {
650
712
  }),
651
713
  landCompleted: waitOutcome.confirmed === true,
652
714
  });
653
- await emitTerminal({ terminal, result, config: prCtx.config });
715
+ await emitTerminal({
716
+ terminal,
717
+ result,
718
+ config: prCtx.config,
719
+ phaseTimer: prCtx.phaseTimer,
720
+ workerTokens: prCtx.workerTokens,
721
+ });
654
722
  reportWaitTerminal(terminal, { storyId: prCtx.storyId, prUrl: prCtx.prUrl });
655
723
  return { success: terminal.status === 'landed', result, terminal };
656
724
  }
@@ -698,7 +766,13 @@ async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
698
766
  nextCommand: NEXT_COMMANDS.confirmMerge(prCtx.storyId),
699
767
  elapsedSeconds: elapsedSecondsSince(prCtx.startedAtMs),
700
768
  });
701
- await emitTerminal({ terminal, result, config: prCtx.config });
769
+ await emitTerminal({
770
+ terminal,
771
+ result,
772
+ config: prCtx.config,
773
+ phaseTimer: prCtx.phaseTimer,
774
+ workerTokens: prCtx.workerTokens,
775
+ });
702
776
  progress(
703
777
  'DONE',
704
778
  `✅ Story #${prCtx.storyId}: PR ready → ${prCtx.prUrl} (${waitForMergeReason})`,
@@ -714,13 +788,16 @@ async function finishWithoutMergeWait(prCtx, waitForMergeReason) {
714
788
  * config: object, startedAtMs: number }} ctx
715
789
  * @returns {Promise<{ success: false, result: object, terminal: object }>}
716
790
  */
717
- async function finishDeferred(lockWait, { config, startedAtMs, ...ids }) {
791
+ async function finishDeferred(
792
+ lockWait,
793
+ { config, startedAtMs, phaseTimer, workerTokens, ...ids },
794
+ ) {
718
795
  const { result, terminal, note } = lockWaitPending({
719
796
  ...ids,
720
797
  lockWait,
721
798
  elapsedSeconds: elapsedSecondsSince(startedAtMs),
722
799
  });
723
- await emitTerminal({ terminal, result, config });
800
+ await emitTerminal({ terminal, result, config, phaseTimer, workerTokens });
724
801
  progress('PENDING', note);
725
802
  return { success: false, result, terminal };
726
803
  }
@@ -755,209 +832,212 @@ function reportOperatorMergeSkip({
755
832
  );
756
833
  }
757
834
 
758
- async function runClosePipeline({
759
- options,
760
- setPhase,
761
- setObservedGates,
762
- injectedProvider,
763
- injectedConfig,
764
- injectedNotify,
765
- injectedSync,
766
- injectedRunCodeReview,
767
- injectedGh,
768
- injectedGitSpawn,
769
- injectedReleaseLease,
770
- injectedGraphqlProbe,
771
- }) {
772
- const startedAtMs = Date.now();
773
- const config = injectedConfig || resolveConfig({ cwd: options.cwd });
774
- const provider = injectedProvider || createProvider(config);
775
- const storyBranch = getStoryBranch(options.storyId);
776
-
777
- progress('INIT', `Closing standalone Story #${options.storyId}...`);
778
- const story = await provider.getTicket(options.storyId);
779
- if (story.state === 'closed') {
780
- return await alreadyClosedResult(
781
- options.storyId,
782
- story.stateReason,
783
- config,
784
- );
785
- }
786
-
787
- const leaseArgs = {
788
- provider,
789
- storyId: options.storyId,
790
- config,
791
- injectedReleaseLease,
792
- };
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
+ }
793
846
 
794
- // `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) {
795
849
  const preflight = await runGraphqlPreflight({
796
- storyId: options.storyId,
797
- provider,
850
+ storyId: ctx.storyId,
851
+ provider: deps.provider,
798
852
  progress,
799
- ghFacade: injectedGh,
800
- probe: injectedGraphqlProbe,
853
+ ghFacade: deps.gh,
854
+ probe: deps.graphqlProbe,
801
855
  });
802
- if (preflight) {
803
- await releaseLease(leaseArgs);
804
- return await preflightBlockedResult({
805
- storyId: options.storyId,
806
- preflight,
807
- config,
808
- startedAtMs,
809
- });
810
- }
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
+ }
811
866
 
812
- // The base the run was SEEDED from (init receipt); throws before any merge
813
- // when it disagrees with current config.
814
- const { values: runScoped, confirmed: baseConfirmed } =
815
- await resolveRunScopedConfig({
816
- storyId: options.storyId,
817
- config,
818
- progress,
819
- });
820
- 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
+ }
821
886
 
822
- const worktreePath = resolveWorktreePath({
823
- cwd: options.cwd,
824
- config,
825
- 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,
826
902
  });
827
- const prePush = await releaseLeaseOnBlock(
828
- () =>
829
- runPrePushPhases({
830
- ...options,
831
- config,
832
- baseBranch,
833
- baseConfirmed,
834
- storyBranch,
835
- provider,
836
- worktreePath,
837
- injectedSync,
838
- injectedGitSpawn,
839
- setPhase,
840
- setObservedGates,
841
- }),
842
- leaseArgs,
903
+ }
904
+
905
+ async function openPrPhase(ctx, deps) {
906
+ ctx.pr = await releaseLeaseOnBlock(
907
+ () => openAndReviewPr(ctx, deps),
908
+ ctx,
909
+ deps,
843
910
  );
844
- if (prePush.pending) {
845
- return await finishDeferred(prePush.lockWait, {
846
- storyId: options.storyId,
847
- storyBranch,
848
- baseBranch,
849
- config,
850
- startedAtMs,
851
- });
852
- }
911
+ return null;
912
+ }
853
913
 
854
- const { prUrl, prNumber, alreadyMerged, reviewOverride } =
855
- await releaseLeaseOnBlock(
856
- () =>
857
- openAndReviewPr({
858
- cwd: options.cwd,
859
- worktreePath,
860
- story,
861
- storyId: options.storyId,
862
- storyBranch,
863
- baseBranch,
864
- provider,
865
- config,
866
- overrideReviewBlock: options.overrideReviewBlock,
867
- injectedGh,
868
- injectedRunCodeReview,
869
- setPhase,
870
- }),
871
- leaseArgs,
872
- );
873
- // Reap BEFORE the arm: `gh pr merge --delete-branch` may merge at once and
874
- // then fail deleting a branch a live worktree holds, reading as a failed
875
- // arm. Safe — the work is pushed, and a dirty tree is still refused.
876
- const worktreeReaped = await reapWorktreePhase({
877
- cwd: options.cwd,
878
- storyId: options.storyId,
879
- worktreePath,
880
- 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,
881
925
  progress,
882
926
  WorktreeManager,
883
927
  });
884
- setPhase('auto-merge');
885
- const ciDelivery = getCiDelivery(config);
886
- const {
887
- autoMergeEnabled,
888
- autoMergeReason,
889
- localCleanupDeferred,
890
- directMerged,
891
- advisoryGate,
892
- } = 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({
893
937
  alreadyMerged,
894
- cwd: options.cwd,
938
+ cwd: ctx.options.cwd,
895
939
  prNumber,
896
940
  prUrl,
897
- noAutoMerge: options.noAutoMerge,
941
+ noAutoMerge: ctx.options.noAutoMerge,
898
942
  autoMergePolicy: ciDelivery.autoMerge,
899
943
  blockOnAdvisoryFailure: ciDelivery.blockOnAdvisoryFailure,
900
944
  advisoryAllowlist: ciDelivery.advisoryAllowlist,
901
- gh: injectedGh,
945
+ gh: deps.gh,
902
946
  progress,
903
947
  });
904
948
  await flipLabelAndNotify({
905
- provider,
906
- notifyFn: injectedNotify,
907
- storyId: options.storyId,
908
- story,
949
+ provider: deps.provider,
950
+ notifyFn: deps.notify,
951
+ storyId: ctx.storyId,
952
+ story: ctx.story,
909
953
  prUrl,
910
- autoMergeEnabled,
911
- autoMergeReason,
912
- config,
954
+ autoMergeEnabled: ctx.arm.autoMergeEnabled,
955
+ autoMergeReason: ctx.arm.autoMergeReason,
956
+ config: deps.config,
913
957
  progress,
914
958
  });
915
- // No lease release here: only the post-land tail (confirmed merge) releases
916
- // it; every non-merged ending keeps the claim while the PR is open.
959
+ return null;
960
+ }
917
961
 
918
- // Resolved now, not at parse time: it needs the config and the arm outcome
919
- // (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;
920
965
  const { waitForMerge, reason: waitForMergeReason } = resolveWaitForMerge({
921
966
  waitForMergeExplicit: options.waitForMergeExplicit,
922
967
  noWaitForMerge: options.noWaitForMerge,
923
- config,
924
- autoMergeReason,
968
+ config: deps.config,
969
+ autoMergeReason: arm.autoMergeReason,
925
970
  });
926
971
  reportOperatorMergeSkip({
927
972
  waitForMergeReason,
928
- autoMergeReason,
929
- storyId: options.storyId,
973
+ autoMergeReason: arm.autoMergeReason,
974
+ storyId: ctx.storyId,
930
975
  waitForMergeExplicit: options.waitForMergeExplicit,
931
976
  });
932
977
  const prCtx = {
933
- storyId: options.storyId,
934
- storyBranch,
935
- baseBranch,
936
- prNumber,
937
- prUrl,
938
- autoMergeEnabled,
939
- autoMergeReason,
940
- advisoryGate,
941
- worktreeReaped,
942
- localCleanupDeferred,
943
- directMerged,
944
- config,
945
- startedAtMs,
946
- lockWait: prePush.lockWait,
947
- 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
+ ),
948
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
+ }
949
1014
 
950
- if (waitForMerge) {
951
- return await finishWithMergeWait(prCtx, {
952
- cwd: options.cwd,
953
- provider,
954
- maxWaitSeconds: options.maxWaitSeconds,
955
- mergeWatchMode: options.mergeWatchMode,
956
- rerunAdvisory: options.rerunAdvisory,
957
- setPhase,
958
- injectedGh,
959
- injectedNotify,
960
- });
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;
961
1042
  }
962
- return await finishWithoutMergeWait(prCtx, waitForMergeReason);
963
1043
  }