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
@@ -44,14 +44,15 @@ import {
44
44
  decideMergeWaitFailFast,
45
45
  deriveChecksStatus,
46
46
  deriveRedHeadRuns,
47
- deriveRequiredRunEvidence,
48
47
  isPrMerged,
49
48
  MERGE_WAIT_GH_TIMEOUT_MS,
50
49
  parseWorkflowRunId,
50
+ pollIntervalMs,
51
51
  readRunSummary,
52
52
  resolveAdvisoryGateVerdict,
53
53
  } from '../../merge-poll.js';
54
54
  import { readMergeQueueState } from '../../merge-queue.js';
55
+ import { readProbeRunEvidence } from '../../required-checks.js';
55
56
  import { NEXT_COMMANDS } from '../../story-deliver-terminal.js';
56
57
  import {
57
58
  postStructuredComment,
@@ -155,6 +156,7 @@ export async function readPrWaitProbe({
155
156
  gh = defaultGh,
156
157
  ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
157
158
  readMergeQueueStateFn = readMergeQueueState,
159
+ readRequiredCheckNamesFn,
158
160
  }) {
159
161
  try {
160
162
  const view = await withGhTimeout(
@@ -188,7 +190,14 @@ export async function readPrWaitProbe({
188
190
  }),
189
191
  // Head-anchored, so superseded/pending runs don't read as red; `null`
190
192
  // when the rollup is empty (the consecutive-probe fallback owns that).
191
- requiredRunEvidence: deriveRequiredRunEvidence(view?.statusCheckRollup),
193
+ requiredRunEvidence: await readProbeRunEvidence({
194
+ view,
195
+ checksStatus,
196
+ prNumber,
197
+ gh,
198
+ ghTimeoutMs,
199
+ readFn: readRequiredCheckNamesFn,
200
+ }),
192
201
  redHeadRuns: deriveRedHeadRuns(view?.statusCheckRollup),
193
202
  headSha: readString(view?.headRefOid),
194
203
  };
@@ -645,13 +654,11 @@ async function maybeRerunAdvisory({
645
654
  }
646
655
 
647
656
  /**
648
- * Advisory-gate terminal for one poll; passes a decided `unlanded` through so
649
- * the caller stays branch-free (`runMergePoll` is at the cyclomatic ceiling).
650
- * Skips runs already re-ran, enriches the rest, spends any rerun allowance,
651
- * and disarms BEFORE returning a block so the PR cannot merge out from under it.
657
+ * Advisory-gate terminal for one poll, or `null` to keep polling. Skips runs
658
+ * already re-ran, enriches the rest, spends any rerun allowance, and disarms
659
+ * BEFORE returning a block so the PR cannot merge out from under it.
652
660
  */
653
661
  async function resolveAdvisoryUnlanded({
654
- unlanded,
655
662
  probe,
656
663
  blockOnAdvisoryFailure,
657
664
  advisoryAllowlist,
@@ -663,7 +670,6 @@ async function resolveAdvisoryUnlanded({
663
670
  disarmAutoMergeFn,
664
671
  elapsedSeconds,
665
672
  }) {
666
- if (unlanded) return unlanded;
667
673
  const advisory = decideAdvisoryGateBlock({
668
674
  probe,
669
675
  blockOnAdvisoryFailure,
@@ -992,6 +998,308 @@ async function onMergeObserved({
992
998
  };
993
999
  }
994
1000
 
1001
+ /**
1002
+ * Everything one poll hands the next.
1003
+ *
1004
+ * @param {{ startedAtMs: number, intervalSeconds: number }} args
1005
+ * @returns {{ startedAtMs: number, polls: number, intervalMs: number,
1006
+ * updatesUsed: number, consecutiveRequiredFailSnapshots: number }}
1007
+ */
1008
+ export function createMergeWaitState({ startedAtMs, intervalSeconds }) {
1009
+ return {
1010
+ startedAtMs,
1011
+ polls: 0,
1012
+ intervalMs: intervalSeconds * 1000,
1013
+ updatesUsed: 0,
1014
+ consecutiveRequiredFailSnapshots: 0,
1015
+ };
1016
+ }
1017
+
1018
+ /** Budget first — it is the real give-up. */
1019
+ function provisionalVerdict(
1020
+ { polls, intervalMs, waitedMs, cumulativeMs },
1021
+ limits,
1022
+ ) {
1023
+ if (
1024
+ polls >= MIN_POLLS_BEFORE_BUDGET_BLOCK &&
1025
+ cumulativeMs + intervalMs > limits.maxBudgetSeconds * 1000
1026
+ ) {
1027
+ return 'budget-exhausted';
1028
+ }
1029
+ if (waitedMs + intervalMs > limits.maxWaitSeconds * 1000) return 'wait-bound';
1030
+ return 'continue';
1031
+ }
1032
+
1033
+ /**
1034
+ * Pure: state + probe + clock → next state and verdict. `merged`, `closed`
1035
+ * and `checks-failed` are definitive; `budget-exhausted`, `wait-bound` and
1036
+ * `continue` hold only if the loop's advisory check does not block first.
1037
+ * The two clock reads (wait bound, then budget) keep a stepping test clock's
1038
+ * call sequence.
1039
+ *
1040
+ * @param {{ state: object, probe: object, nowMs: number,
1041
+ * cumulativeNowMs?: number, limits: object }} args
1042
+ * @returns {{ state: object, verdict: string, waitedMs: number,
1043
+ * cumulativeMs: number, elapsedSeconds: number, waitBudget: object,
1044
+ * failFast?: object }}
1045
+ */
1046
+ export function decideMergeWaitPoll({
1047
+ state,
1048
+ probe,
1049
+ nowMs,
1050
+ cumulativeNowMs = nowMs,
1051
+ limits,
1052
+ }) {
1053
+ const anchorMs = resolveBudgetAnchorMs({
1054
+ createdAt: probe.createdAt,
1055
+ fallbackMs: state.startedAtMs,
1056
+ });
1057
+ const waitedMs = nowMs - state.startedAtMs;
1058
+ const cumulativeMs = Math.max(cumulativeNowMs - anchorMs, waitedMs);
1059
+ const polled = {
1060
+ ...state,
1061
+ polls: state.polls + 1,
1062
+ intervalMs: pollIntervalMs(probe.checksStatus, limits.intervalSeconds),
1063
+ };
1064
+ const timing = {
1065
+ waitedMs,
1066
+ cumulativeMs,
1067
+ elapsedSeconds: Math.round(waitedMs / 1000),
1068
+ waitBudget: {
1069
+ maxWaitSeconds: limits.maxWaitSeconds,
1070
+ waitedSeconds: Math.round(waitedMs / 1000),
1071
+ cumulativeSeconds: Math.round(cumulativeMs / 1000),
1072
+ maxBudgetSeconds: limits.maxBudgetSeconds,
1073
+ },
1074
+ };
1075
+ if (isPrMerged(probe)) return { state: polled, verdict: 'merged', ...timing };
1076
+ if (probe.state === 'CLOSED') {
1077
+ return { state: polled, verdict: 'closed', ...timing };
1078
+ }
1079
+ // No remaining budget turns a red required check green; fail fast.
1080
+ const failFast = decideMergeWaitFailFast({
1081
+ probe,
1082
+ consecutiveRequiredFailSnapshots: state.consecutiveRequiredFailSnapshots,
1083
+ });
1084
+ const next = {
1085
+ ...polled,
1086
+ consecutiveRequiredFailSnapshots: failFast.consecutiveRequiredFailSnapshots,
1087
+ };
1088
+ if (failFast.failFast) {
1089
+ return { state: next, verdict: 'checks-failed', failFast, ...timing };
1090
+ }
1091
+ return {
1092
+ state: next,
1093
+ verdict: provisionalVerdict({ ...next, waitedMs, cumulativeMs }, limits),
1094
+ ...timing,
1095
+ };
1096
+ }
1097
+
1098
+ function blockWith(ctx, unlanded) {
1099
+ return blockOnUnlanded({
1100
+ storyId: ctx.storyId,
1101
+ prNumber: ctx.prNumber,
1102
+ prUrl: ctx.prUrl,
1103
+ ...unlanded,
1104
+ provider: ctx.provider,
1105
+ progress: ctx.progress,
1106
+ classifyMergeBlockFn: ctx.classifyMergeBlockFn,
1107
+ emitMergeUnlandedFn: ctx.emitMergeUnlandedFn,
1108
+ });
1109
+ }
1110
+
1111
+ function settleMerged(ctx, decision, probe) {
1112
+ return onMergeObserved({
1113
+ ...ctx,
1114
+ prProbe: probe,
1115
+ elapsedSeconds: decision.elapsedSeconds,
1116
+ });
1117
+ }
1118
+
1119
+ function settleClosed(ctx, decision, probe) {
1120
+ return blockWith(ctx, {
1121
+ prProbe: probe,
1122
+ budget: { exhausted: true, elapsedSeconds: decision.elapsedSeconds },
1123
+ blockClassOverride: CLOSED_UNMERGED_BLOCK_CLASS,
1124
+ reasonOverride: CLOSED_UNMERGED_REASON,
1125
+ });
1126
+ }
1127
+
1128
+ async function settleChecksFailed(ctx, decision, probe) {
1129
+ const { failFast } = decision;
1130
+ ctx.progress?.(
1131
+ 'CONFIRM',
1132
+ failFast.evidencePath === 'per-run'
1133
+ ? `🛑 PR #${ctx.prNumber}: a required check concluded failure with none in flight — failing fast (evidence=per-run).`
1134
+ : `🛑 PR #${ctx.prNumber}: two consecutive failing check probes without per-run evidence — failing fast (evidence=consecutive-probe).`,
1135
+ );
1136
+ return blockWith(ctx, {
1137
+ prProbe: failFast.prProbe,
1138
+ budget: { exhausted: false, elapsedSeconds: decision.elapsedSeconds },
1139
+ blockClassOverride: failFast.blockClass,
1140
+ reasonOverride: failFast.reason,
1141
+ redRecord: await recordChecksFailedRed({
1142
+ storyId: ctx.storyId,
1143
+ prNumber: ctx.prNumber,
1144
+ probe,
1145
+ cwd: ctx.cwd,
1146
+ config: ctx.config,
1147
+ gh: ctx.injectedGh,
1148
+ progress: ctx.progress,
1149
+ disarmAutoMergeFn: ctx.disarmAutoMergeFn,
1150
+ recordRequiredRedFn: ctx.recordRequiredRedFn,
1151
+ }),
1152
+ });
1153
+ }
1154
+
1155
+ const DEFINITIVE_SETTLERS = Object.freeze({
1156
+ merged: settleMerged,
1157
+ closed: settleClosed,
1158
+ 'checks-failed': settleChecksFailed,
1159
+ });
1160
+
1161
+ /** Invocation bound reached: resumable `pending`, no mutation. */
1162
+ function pendingAtWaitBound(ctx, decision, probe) {
1163
+ const { waitBudget } = decision;
1164
+ const checks = probe.checksStatus ?? 'unknown';
1165
+ ctx.progress?.(
1166
+ 'CONFIRM',
1167
+ `⏸ Merge wait bound reached (${waitBudget.waitedSeconds}s of ${waitBudget.maxWaitSeconds}s this invocation; ` +
1168
+ `${waitBudget.cumulativeSeconds}s of ${waitBudget.maxBudgetSeconds}s cumulative). PR #${ctx.prNumber} still in flight ` +
1169
+ `(checks=${checks}). Story stays at agent::closing — resumable.`,
1170
+ );
1171
+ return {
1172
+ confirmed: false,
1173
+ terminal: 'pending',
1174
+ reason: `merge wait bound reached with the PR still in flight (checks=${checks})`,
1175
+ prProbe: probe,
1176
+ waitBudget,
1177
+ elapsedSeconds: waitBudget.waitedSeconds,
1178
+ };
1179
+ }
1180
+
1181
+ /**
1182
+ * Advisory gates are usually still QUEUED at arm time, so they redden here,
1183
+ * mid-wait, before auto-merge lands over them.
1184
+ *
1185
+ * @returns {Promise<{ state: object, outcome: object|null }>}
1186
+ */
1187
+ async function settleProvisional(ctx, decision, probe) {
1188
+ const advisory = await resolveAdvisoryUnlanded({
1189
+ probe,
1190
+ blockOnAdvisoryFailure: ctx.blockOnAdvisoryFailure,
1191
+ advisoryAllowlist: ctx.advisoryAllowlist,
1192
+ rerunState: ctx.rerunState,
1193
+ prNumber: ctx.prNumber,
1194
+ gh: ctx.injectedGh,
1195
+ ghTimeoutMs: ctx.ghTimeoutMs,
1196
+ progress: ctx.progress,
1197
+ disarmAutoMergeFn: ctx.disarmAutoMergeFn,
1198
+ elapsedSeconds: decision.elapsedSeconds,
1199
+ });
1200
+ if (advisory) {
1201
+ return { state: decision.state, outcome: await blockWith(ctx, advisory) };
1202
+ }
1203
+ const updated = await maybeUpdateBehindPr({
1204
+ probe,
1205
+ prNumber: ctx.prNumber,
1206
+ updatesUsed: decision.state.updatesUsed,
1207
+ updateAttempts: ctx.limits.updateAttempts,
1208
+ gh: ctx.injectedGh,
1209
+ ghTimeoutMs: ctx.ghTimeoutMs,
1210
+ progress: ctx.progress,
1211
+ });
1212
+ const state = updated
1213
+ ? { ...decision.state, updatesUsed: decision.state.updatesUsed + 1 }
1214
+ : decision.state;
1215
+ if (decision.verdict === 'budget-exhausted') {
1216
+ const outcome = await blockWith(ctx, {
1217
+ prProbe: probe,
1218
+ budget: {
1219
+ exhausted: true,
1220
+ elapsedSeconds: Math.round(decision.cumulativeMs / 1000),
1221
+ },
1222
+ ...queuedExhaustionVerdict(probe, decision.cumulativeMs),
1223
+ });
1224
+ return { state, outcome };
1225
+ }
1226
+ if (decision.verdict === 'wait-bound') {
1227
+ return { state, outcome: pendingAtWaitBound(ctx, decision, probe) };
1228
+ }
1229
+ return { state, outcome: null };
1230
+ }
1231
+
1232
+ /** @returns {Promise<{ state: object, done: boolean, outcome?: object }>} */
1233
+ async function runMergePoll(ctx, state) {
1234
+ const probe = await ctx.readPrWaitProbeFn({
1235
+ prNumber: ctx.prNumber,
1236
+ gh: ctx.injectedGh,
1237
+ ghTimeoutMs: ctx.ghTimeoutMs,
1238
+ });
1239
+ const decision = decideMergeWaitPoll({
1240
+ state,
1241
+ probe,
1242
+ nowMs: ctx.nowMsFn(),
1243
+ cumulativeNowMs: ctx.nowMsFn(),
1244
+ limits: ctx.limits,
1245
+ });
1246
+ // Heartbeat: a backgrounded close's output-file growth is its liveness signal.
1247
+ ctx.progress?.(
1248
+ 'CONFIRM',
1249
+ pollHeartbeat({
1250
+ polls: decision.state.polls,
1251
+ prNumber: ctx.prNumber,
1252
+ probe,
1253
+ waitBudget: decision.waitBudget,
1254
+ }),
1255
+ );
1256
+ const definitive = DEFINITIVE_SETTLERS[decision.verdict];
1257
+ const settled = definitive
1258
+ ? {
1259
+ state: decision.state,
1260
+ outcome: await definitive(ctx, decision, probe),
1261
+ }
1262
+ : await settleProvisional(ctx, decision, probe);
1263
+ return settled.outcome
1264
+ ? { state: settled.state, done: true, outcome: settled.outcome }
1265
+ : { state: settled.state, done: false };
1266
+ }
1267
+
1268
+ /** Carries the arm phase's advisory class rather than a generic `arm-failure`. */
1269
+ function blockNeverArmed({
1270
+ storyId,
1271
+ prNumber,
1272
+ prUrl,
1273
+ autoMergeReason,
1274
+ advisoryGate,
1275
+ provider,
1276
+ progress,
1277
+ classifyMergeBlockFn,
1278
+ emitMergeUnlandedFn,
1279
+ }) {
1280
+ progress?.(
1281
+ 'CONFIRM',
1282
+ `⚠️ Auto-merge not enabled (${autoMergeReason ?? 'unknown'}) — cannot wait for a merge that was never armed.`,
1283
+ );
1284
+ return blockOnUnlanded({
1285
+ storyId,
1286
+ prNumber,
1287
+ prUrl,
1288
+ armResult: { armed: false, reason: autoMergeReason },
1289
+ budget: { elapsedSeconds: 0 },
1290
+ provider,
1291
+ progress,
1292
+ classifyMergeBlockFn,
1293
+ emitMergeUnlandedFn,
1294
+ ...(autoMergeReason === 'advisory-gate-red'
1295
+ ? {
1296
+ blockClassOverride: advisoryGate?.blockClass ?? 'advisory-gate-red',
1297
+ reasonOverride: advisoryGate?.reason,
1298
+ }
1299
+ : {}),
1300
+ });
1301
+ }
1302
+
995
1303
  /**
996
1304
  * Poll an armed Story PR to merge confirmation, a resumable `pending`
997
1305
  * expiry, or a classified `agent::blocked` terminal.
@@ -1026,23 +1334,9 @@ async function onMergeObserved({
1026
1334
  * @returns {Promise<object>}
1027
1335
  */
1028
1336
  export async function runConfirmMergePhase({
1029
- cwd,
1030
- storyId,
1031
- storyBranch,
1032
- baseBranch,
1033
- prNumber,
1034
- prUrl,
1035
- autoMergeEnabled,
1036
- autoMergeReason,
1037
- advisoryGate,
1038
- provider,
1039
- config,
1040
1337
  maxWaitSeconds: maxWaitSecondsOverride,
1041
1338
  mergeWatchMode: mergeWatchModeOverride,
1042
1339
  rerunAdvisory: rerunAdvisoryOverride,
1043
- progress,
1044
- injectedGh,
1045
- injectedNotify,
1046
1340
  confirmStoryMergedFn = defaultConfirmStoryMerged,
1047
1341
  readPrWaitProbeFn = readPrWaitProbe,
1048
1342
  readPrMergeStateFn = defaultReadPrMergeState,
@@ -1055,282 +1349,90 @@ export async function runConfirmMergePhase({
1055
1349
  sleepFn = defaultSleep,
1056
1350
  nowMsFn = Date.now,
1057
1351
  ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
1352
+ ...args
1058
1353
  }) {
1059
- // Never armed: nothing to poll, but a terminal is still required.
1060
- if (!autoMergeEnabled) {
1061
- progress?.(
1062
- 'CONFIRM',
1063
- `⚠️ Auto-merge not enabled (${autoMergeReason ?? 'unknown'}) — cannot wait for a merge that was never armed.`,
1064
- );
1065
- return blockOnUnlanded({
1066
- storyId,
1067
- prNumber,
1068
- prUrl,
1069
- armResult: { armed: false, reason: autoMergeReason },
1070
- budget: { elapsedSeconds: 0 },
1071
- provider,
1072
- progress,
1354
+ if (!args.autoMergeEnabled) {
1355
+ return blockNeverArmed({
1356
+ ...args,
1073
1357
  classifyMergeBlockFn,
1074
1358
  emitMergeUnlandedFn,
1075
- // Carry the arm phase's advisory verdict and class (which may be
1076
- // `inconclusive`) rather than a generic `arm-failure`.
1077
- ...(autoMergeReason === 'advisory-gate-red'
1078
- ? {
1079
- blockClassOverride: advisoryGate?.blockClass ?? 'advisory-gate-red',
1080
- reasonOverride: advisoryGate?.reason,
1081
- }
1082
- : {}),
1083
1359
  });
1084
1360
  }
1085
1361
 
1086
- const {
1087
- mode,
1088
- intervalSeconds,
1089
- maxWaitSeconds,
1090
- maxBudgetSeconds,
1091
- updateAttempts,
1092
- } = resolveMergeWaitConfig(
1093
- config,
1362
+ const limits = resolveMergeWaitConfig(
1363
+ args.config,
1094
1364
  maxWaitSecondsOverride,
1095
1365
  mergeWatchModeOverride,
1096
1366
  );
1097
- const { blockOnAdvisoryFailure, advisoryAllowlist } = getCiDelivery(config);
1367
+ const { blockOnAdvisoryFailure, advisoryAllowlist } = getCiDelivery(
1368
+ args.config,
1369
+ );
1098
1370
  // Spent across the whole wait, not per poll, so `n` bounds CI minutes.
1099
1371
  const rerunAllowance = resolveAdvisoryRerunAllowance(
1100
- config,
1372
+ args.config,
1101
1373
  rerunAdvisoryOverride,
1102
1374
  );
1103
- const rerunState = {
1104
- allowance: rerunAllowance,
1105
- remaining: rerunAllowance,
1106
- issued: new Set(),
1375
+ const ctx = {
1376
+ ...args,
1377
+ limits,
1378
+ blockOnAdvisoryFailure,
1379
+ advisoryAllowlist,
1380
+ rerunState: {
1381
+ allowance: rerunAllowance,
1382
+ remaining: rerunAllowance,
1383
+ issued: new Set(),
1384
+ },
1385
+ confirmStoryMergedFn,
1386
+ readPrWaitProbeFn,
1387
+ readPrMergeStateFn,
1388
+ classifyMergeBlockFn,
1389
+ emitMergeUnlandedFn,
1390
+ emitMergeFlipFailedFn,
1391
+ runPostLandTailFn,
1392
+ disarmAutoMergeFn,
1393
+ recordRequiredRedFn,
1394
+ nowMsFn,
1395
+ ghTimeoutMs,
1107
1396
  };
1108
- const intervalMs = intervalSeconds * 1000;
1109
- const startedAtMs = nowMsFn();
1110
- let anchorMs = startedAtMs;
1111
- let updatesUsed = 0;
1112
- let polls = 0;
1113
- // Without per-run evidence, fail fast only after two consecutive failing
1114
- // probes an interval apart; reset on any other probe.
1115
- let consecutiveRequiredFailSnapshots = 0;
1397
+ let state = createMergeWaitState({
1398
+ startedAtMs: nowMsFn(),
1399
+ intervalSeconds: limits.intervalSeconds,
1400
+ });
1116
1401
 
1117
- progress?.(
1402
+ args.progress?.(
1118
1403
  'CONFIRM',
1119
- `⏳ Close-and-land: polling PR #${prNumber} for merge confirmation ` +
1120
- `(mode=${mode}, wait=${maxWaitSeconds}s this invocation, ` +
1121
- `cumulative budget=${maxBudgetSeconds}s)...`,
1404
+ `⏳ Close-and-land: polling PR #${args.prNumber} for merge confirmation ` +
1405
+ `(mode=${limits.mode}, wait=${limits.maxWaitSeconds}s this invocation, ` +
1406
+ `cumulative budget=${limits.maxBudgetSeconds}s)...`,
1122
1407
  );
1123
1408
 
1124
- /** One iteration: `{ done: false }` or `{ done: true, outcome }`. */
1125
- async function runMergePoll() {
1126
- const probe = await readPrWaitProbeFn({
1127
- prNumber,
1128
- gh: injectedGh,
1129
- ghTimeoutMs,
1130
- });
1131
- polls += 1;
1132
-
1133
- anchorMs = resolveBudgetAnchorMs({
1134
- createdAt: probe.createdAt,
1135
- fallbackMs: startedAtMs,
1136
- });
1137
-
1138
- const waitedMs = nowMsFn() - startedAtMs;
1139
- const cumulativeMs = Math.max(nowMsFn() - anchorMs, waitedMs);
1140
- const waitBudget = {
1141
- maxWaitSeconds,
1142
- waitedSeconds: Math.round(waitedMs / 1000),
1143
- cumulativeSeconds: Math.round(cumulativeMs / 1000),
1144
- maxBudgetSeconds,
1145
- };
1146
-
1147
- // Heartbeat: a backgrounded close's output-file growth is its liveness signal.
1148
- progress?.(
1149
- 'CONFIRM',
1150
- `⏱ poll ${polls}: PR #${prNumber} state=${probe.state ?? 'unknown'} ` +
1151
- `checks=${probe.checksStatus ?? 'unknown'} ` +
1152
- `mergeState=${probe.mergeStateStatus ?? 'unknown'} ` +
1153
- `(${waitBudget.waitedSeconds}s of ${maxWaitSeconds}s this invocation; ` +
1154
- `${waitBudget.cumulativeSeconds}s of ${maxBudgetSeconds}s cumulative)` +
1155
- (probe.error ? ` — probe error: ${probe.error}` : ''),
1156
- );
1157
-
1158
- if (isPrMerged(probe)) {
1159
- return doneWith(
1160
- await onMergeObserved({
1161
- storyId,
1162
- storyBranch,
1163
- baseBranch,
1164
- prNumber,
1165
- prUrl,
1166
- cwd,
1167
- config,
1168
- provider,
1169
- progress,
1170
- injectedGh,
1171
- injectedNotify,
1172
- readPrMergeStateFn,
1173
- confirmStoryMergedFn,
1174
- runPostLandTailFn,
1175
- emitMergeFlipFailedFn,
1176
- prProbe: probe,
1177
- elapsedSeconds: Math.round(waitedMs / 1000),
1178
- }),
1179
- );
1180
- }
1181
-
1182
- // Each definitive condition fills `unlanded`; one call site below blocks.
1183
- let unlanded = null;
1184
-
1185
- if (probe.state === 'CLOSED') {
1186
- unlanded = {
1187
- prProbe: probe,
1188
- budget: {
1189
- exhausted: true,
1190
- elapsedSeconds: Math.round(waitedMs / 1000),
1191
- },
1192
- blockClassOverride: CLOSED_UNMERGED_BLOCK_CLASS,
1193
- reasonOverride: CLOSED_UNMERGED_REASON,
1194
- };
1195
- } else {
1196
- // No remaining budget turns a red required check green; fail fast.
1197
- const decision = decideMergeWaitFailFast({
1198
- probe,
1199
- consecutiveRequiredFailSnapshots,
1200
- });
1201
- consecutiveRequiredFailSnapshots =
1202
- decision.consecutiveRequiredFailSnapshots;
1203
- if (decision.failFast) {
1204
- progress?.(
1205
- 'CONFIRM',
1206
- decision.evidencePath === 'per-run'
1207
- ? `🛑 PR #${prNumber}: a required check concluded failure with none in flight — failing fast (evidence=per-run).`
1208
- : `🛑 PR #${prNumber}: two consecutive failing check probes without per-run evidence — failing fast (evidence=consecutive-probe).`,
1209
- );
1210
- unlanded = {
1211
- prProbe: decision.prProbe,
1212
- budget: {
1213
- exhausted: false,
1214
- elapsedSeconds: Math.round(waitedMs / 1000),
1215
- },
1216
- blockClassOverride: decision.blockClass,
1217
- reasonOverride: decision.reason,
1218
- redRecord: await recordChecksFailedRed({
1219
- storyId,
1220
- prNumber,
1221
- probe,
1222
- cwd,
1223
- config,
1224
- gh: injectedGh,
1225
- progress,
1226
- disarmAutoMergeFn,
1227
- recordRequiredRedFn,
1228
- }),
1229
- };
1230
- }
1231
-
1232
- // Advisory gates are usually still QUEUED at arm time, so they redden
1233
- // here, mid-wait, before auto-merge lands over them.
1234
- unlanded = await resolveAdvisoryUnlanded({
1235
- unlanded,
1236
- probe,
1237
- blockOnAdvisoryFailure,
1238
- advisoryAllowlist,
1239
- rerunState,
1240
- prNumber,
1241
- gh: injectedGh,
1242
- ghTimeoutMs,
1243
- progress,
1244
- disarmAutoMergeFn,
1245
- elapsedSeconds: Math.round(waitedMs / 1000),
1246
- });
1247
- }
1248
-
1249
- if (!unlanded) {
1250
- if (
1251
- await maybeUpdateBehindPr({
1252
- probe,
1253
- prNumber,
1254
- updatesUsed,
1255
- updateAttempts,
1256
- gh: injectedGh,
1257
- ghTimeoutMs,
1258
- progress,
1259
- })
1260
- ) {
1261
- updatesUsed += 1;
1262
- }
1263
-
1264
- // Cumulative budget exhausted, behind the poll floor.
1265
- if (
1266
- polls >= MIN_POLLS_BEFORE_BUDGET_BLOCK &&
1267
- cumulativeMs + intervalMs > maxBudgetSeconds * 1000
1268
- ) {
1269
- unlanded = {
1270
- prProbe: probe,
1271
- budget: {
1272
- exhausted: true,
1273
- elapsedSeconds: Math.round(cumulativeMs / 1000),
1274
- },
1275
- ...queuedExhaustionVerdict(probe, cumulativeMs),
1276
- };
1277
- }
1278
- }
1279
-
1280
- if (unlanded) {
1281
- return doneWith(
1282
- await blockOnUnlanded({
1283
- storyId,
1284
- prNumber,
1285
- prUrl,
1286
- ...unlanded,
1287
- provider,
1288
- progress,
1289
- classifyMergeBlockFn,
1290
- emitMergeUnlandedFn,
1291
- }),
1292
- );
1293
- }
1294
-
1295
- // Invocation bound reached: resumable `pending`, no mutation.
1296
- if (waitedMs + intervalMs > maxWaitSeconds * 1000) {
1297
- progress?.(
1298
- 'CONFIRM',
1299
- `⏸ Merge wait bound reached (${waitBudget.waitedSeconds}s of ${maxWaitSeconds}s this invocation; ` +
1300
- `${waitBudget.cumulativeSeconds}s of ${maxBudgetSeconds}s cumulative). PR #${prNumber} still in flight ` +
1301
- `(checks=${probe.checksStatus ?? 'unknown'}). Story stays at agent::closing — resumable.`,
1302
- );
1303
- return doneWith({
1304
- confirmed: false,
1305
- terminal: 'pending',
1306
- reason: `merge wait bound reached with the PR still in flight (checks=${probe.checksStatus ?? 'unknown'})`,
1307
- prProbe: probe,
1308
- waitBudget,
1309
- elapsedSeconds: waitBudget.waitedSeconds,
1310
- });
1311
- }
1312
-
1313
- return { done: false };
1314
- }
1315
-
1316
1409
  const tick = await pollUntil({
1317
1410
  fn: async () => {
1318
1411
  try {
1319
- return await runMergePoll();
1412
+ const step = await runMergePoll(ctx, state);
1413
+ state = step.state;
1414
+ return step;
1320
1415
  } catch (err) {
1321
1416
  // pollUntil treats a throw as a non-match and would spin; carry it out.
1322
1417
  return { done: true, thrown: err };
1323
1418
  }
1324
1419
  },
1325
1420
  predicate: (result) => result?.done === true,
1326
- intervalMs,
1327
- // No pollUntil timeout: the tick owns both bounds and their classification.
1328
- sleepFn: (ms) => sleepFn(ms),
1421
+ intervalMs: state.intervalMs,
1422
+ // No pollUntil timeout: the tick owns both bounds and the cadence.
1423
+ sleepFn: () => sleepFn(state.intervalMs),
1329
1424
  });
1330
1425
  if (tick.thrown) throw tick.thrown;
1331
1426
  return tick.outcome;
1332
1427
  }
1333
1428
 
1334
- function doneWith(outcome) {
1335
- return { done: true, outcome };
1429
+ function pollHeartbeat({ polls, prNumber, probe, waitBudget }) {
1430
+ return (
1431
+ `⏱ poll ${polls}: PR #${prNumber} state=${probe.state ?? 'unknown'} ` +
1432
+ `checks=${probe.checksStatus ?? 'unknown'} ` +
1433
+ `mergeState=${probe.mergeStateStatus ?? 'unknown'} ` +
1434
+ `(${waitBudget.waitedSeconds}s of ${waitBudget.maxWaitSeconds}s this invocation; ` +
1435
+ `${waitBudget.cumulativeSeconds}s of ${waitBudget.maxBudgetSeconds}s cumulative)` +
1436
+ (probe.error ? ` — probe error: ${probe.error}` : '')
1437
+ );
1336
1438
  }