mandrel 1.87.0 → 1.89.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 (140) hide show
  1. package/.agents/README.md +18 -13
  2. package/.agents/audit-checklists/architecture.md +24 -0
  3. package/.agents/audit-checklists/clean-code.md +24 -0
  4. package/.agents/audit-checklists/dependencies.md +14 -0
  5. package/.agents/audit-checklists/devops.md +17 -0
  6. package/.agents/audit-checklists/documentation.md +22 -0
  7. package/.agents/audit-checklists/lighthouse.md +15 -0
  8. package/.agents/audit-checklists/navigability.md +14 -0
  9. package/.agents/audit-checklists/performance.md +22 -0
  10. package/.agents/audit-checklists/privacy.md +21 -0
  11. package/.agents/audit-checklists/quality.md +18 -0
  12. package/.agents/audit-checklists/security.md +22 -0
  13. package/.agents/audit-checklists/seo.md +16 -0
  14. package/.agents/audit-checklists/sre.md +24 -0
  15. package/.agents/audit-checklists/ux-ui.md +21 -0
  16. package/.agents/docs/SDLC.md +63 -16
  17. package/.agents/docs/configuration.md +5 -3
  18. package/.agents/instructions.md +51 -21
  19. package/.agents/personas/architect.md +10 -7
  20. package/.agents/personas/engineer.md +4 -3
  21. package/.agents/personas/project-manager.md +5 -2
  22. package/.agents/personas/refactorer.md +5 -3
  23. package/.agents/rules/git-conventions.md +77 -0
  24. package/.agents/schemas/agentrc.schema.json +16 -4
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
  28. package/.agents/schemas/signal-event.schema.json +28 -13
  29. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  30. package/.agents/scripts/check-context-budget.js +320 -0
  31. package/.agents/scripts/diagnose-friction.js +4 -4
  32. package/.agents/scripts/epic-audit-prepare.js +30 -2
  33. package/.agents/scripts/epic-audit-recheck.js +46 -13
  34. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  35. package/.agents/scripts/epic-plan-spec.js +4 -8
  36. package/.agents/scripts/generate-lens-checklists.js +180 -0
  37. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  38. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  39. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  40. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  41. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  42. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  43. package/.agents/scripts/lib/cli-args.js +8 -0
  44. package/.agents/scripts/lib/config/explain.js +4 -0
  45. package/.agents/scripts/lib/config/runners.js +21 -2
  46. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  47. package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
  48. package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
  49. package/.agents/scripts/lib/doc-tiers.js +291 -0
  50. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  51. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  52. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  53. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
  54. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  55. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  56. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  57. package/.agents/scripts/lib/gates/friction.js +15 -5
  58. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  59. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  60. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  61. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  62. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  63. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  64. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  65. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  66. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  67. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  68. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  69. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  70. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
  71. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  72. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
  73. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  79. package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
  80. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  81. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  82. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  83. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  84. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  85. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  86. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  91. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  92. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  93. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  94. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  95. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  96. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  97. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  98. package/.agents/scripts/lib/planning-corpus.js +306 -0
  99. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  100. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  101. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  102. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  103. package/.agents/scripts/lib/signals/schema.js +56 -81
  104. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  105. package/.agents/scripts/lib/story-plan.js +3 -0
  106. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  107. package/.agents/scripts/lifecycle-emit.js +39 -8
  108. package/.agents/scripts/providers/github/issues.js +12 -1
  109. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  110. package/.agents/scripts/retro-run.js +51 -0
  111. package/.agents/scripts/signals-view.js +1 -1
  112. package/.agents/scripts/single-story-close.js +20 -1
  113. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  114. package/.agents/scripts/story-close.js +48 -0
  115. package/.agents/scripts/story-plan.js +51 -12
  116. package/.agents/scripts/validate-docs-freshness.js +69 -15
  117. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  118. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  119. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  120. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  121. package/.agents/skills/skills.index.json +3 -3
  122. package/.agents/workflows/audit-documentation.md +82 -2
  123. package/.agents/workflows/helpers/code-review.md +193 -44
  124. package/.agents/workflows/helpers/deliver-epic.md +128 -39
  125. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  126. package/.agents/workflows/helpers/epic-audit.md +116 -283
  127. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  128. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  129. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  130. package/.agents/workflows/helpers/plan-epic.md +141 -105
  131. package/.agents/workflows/helpers/plan-story.md +32 -0
  132. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  133. package/.agents/workflows/loops/nightly-audit.md +9 -7
  134. package/docs/CHANGELOG.md +29 -0
  135. package/lib/cli/doctor.js +44 -0
  136. package/package.json +4 -3
  137. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  138. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
  139. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  140. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -0,0 +1,419 @@
1
+ /**
2
+ * phases/confirm-merge.js — headless must-land terminal step (Story #4428,
3
+ * Epic #4425 slice 3: standalone-path must-land terminal step).
4
+ *
5
+ * `runSingleStoryClose` (`../runner.js`) arms GitHub native auto-merge and
6
+ * historically rests the Story at `agent::closing` with the issue OPEN —
7
+ * merge confirmation is a separate manual step
8
+ * (`single-story-confirm-merge.js`) a headless (unattended) run never
9
+ * executes. A hung check or a failed arm then leaves the PR silently open
10
+ * forever with no operator watching.
11
+ *
12
+ * This phase closes that gap for **headless** runs (`--wait-merge`, threaded
13
+ * from `phases/options.js`): instead of returning at `agent::closing`, it
14
+ * polls the armed PR to merge confirmation — reusing the SAME
15
+ * `confirmStoryMerged` flip logic `single-story-confirm-merge.js` calls
16
+ * (Story #4428 AC4: exactly one merged/`agent::done` implementation) — on
17
+ * the `delivery.mergeWatch.intervalSeconds` / `maxBudgetSeconds` cadence
18
+ * (mirroring `MergeWatcher`'s poll/budget shape rather than forking it).
19
+ *
20
+ * Terminal outcomes:
21
+ * - `{ confirmed: true }` — the PR merged; `confirmStoryMerged` already
22
+ * flipped `agent::closing → agent::done` and closed the issue.
23
+ * - `{ confirmed: false, blockClass, reason }` — the arm failed outright,
24
+ * the PR closed without merging, or the poll budget was exhausted
25
+ * first. The block is classified via the shared
26
+ * `classifyMergeBlock` (`../../merge-block-class.js`), a
27
+ * `merge.unlanded` lifecycle event is emitted (`scope: 'story'`), a
28
+ * `friction` comment is posted, and the Story is transitioned to
29
+ * `agent::blocked`. The caller (`runSingleStoryClose`) throws so the
30
+ * CLI process exits non-zero — never a silent `agent::closing` rest.
31
+ *
32
+ * Attended (non-headless) runs never call this phase — `runner.js` only
33
+ * invokes it when `options.waitForMerge` is `true`.
34
+ */
35
+
36
+ import { gh as defaultGh } from '../../../gh-exec.js';
37
+ import {
38
+ confirmStoryMerged as defaultConfirmStoryMerged,
39
+ readPrMergeState as defaultReadPrMergeState,
40
+ } from '../../../single-story/confirm-merge.js';
41
+ import { emitMergeUnlanded as defaultEmitMergeUnlanded } from '../../lifecycle/emit-merge-unlanded.js';
42
+ import {
43
+ DEFAULT_INTERVAL_SECONDS,
44
+ DEFAULT_MAX_BUDGET_SECONDS,
45
+ deriveChecksStatus,
46
+ } from '../../lifecycle/listeners/merge-watcher.js';
47
+ import { classifyMergeBlock as defaultClassifyMergeBlock } from '../../merge-block-class.js';
48
+ import {
49
+ postStructuredComment,
50
+ STATE_LABELS,
51
+ transitionTicketState,
52
+ } from '../../ticketing.js';
53
+
54
+ function defaultSleep(ms) {
55
+ return new Promise((resolve) => setTimeout(resolve, ms));
56
+ }
57
+
58
+ /**
59
+ * Fresh PR probe for terminal classification. Fetches the fields
60
+ * `classifyMergeBlock` keys on (`mergeStateStatus`, `reviewDecision`,
61
+ * `statusCheckRollup` → derived `checksStatus`) so a budget exhaustion
62
+ * is classified from the REAL PR state instead of a hardcoded
63
+ * `checksStatus: 'pending'` stamp (which mislabeled every timeout as
64
+ * `checks-pending-timeout` — a review-required block was never
65
+ * diagnosable). Returns a degraded `{ checksStatus: 'pending', error }`
66
+ * probe when the read itself fails, preserving the prior conservative
67
+ * classification on probe errors.
68
+ */
69
+ async function readPrClassificationProbe({ prNumber, gh = defaultGh }) {
70
+ try {
71
+ const view = await gh.pr.view(prNumber, [
72
+ 'mergeStateStatus',
73
+ 'reviewDecision',
74
+ 'statusCheckRollup',
75
+ ]);
76
+ return {
77
+ mergeStateStatus:
78
+ typeof view?.mergeStateStatus === 'string'
79
+ ? view.mergeStateStatus
80
+ : undefined,
81
+ reviewDecision:
82
+ typeof view?.reviewDecision === 'string'
83
+ ? view.reviewDecision
84
+ : undefined,
85
+ checksStatus: deriveChecksStatus(view?.statusCheckRollup),
86
+ };
87
+ } catch (err) {
88
+ return {
89
+ checksStatus: 'pending',
90
+ error: `classification probe failed: ${err?.message ?? err}`,
91
+ };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Resolve the poll cadence from `delivery.mergeWatch.*`, falling back to
97
+ * the same defaults `MergeWatcher` uses when the config key is absent.
98
+ *
99
+ * @param {object} [config]
100
+ * @returns {{ intervalSeconds: number, maxBudgetSeconds: number }}
101
+ */
102
+ function resolveMergeWatchCadence(config) {
103
+ const mergeWatch = config?.delivery?.mergeWatch ?? {};
104
+ const intervalSeconds =
105
+ Number.isInteger(mergeWatch.intervalSeconds) &&
106
+ mergeWatch.intervalSeconds >= 1
107
+ ? mergeWatch.intervalSeconds
108
+ : DEFAULT_INTERVAL_SECONDS;
109
+ const maxBudgetSeconds =
110
+ Number.isInteger(mergeWatch.maxBudgetSeconds) &&
111
+ mergeWatch.maxBudgetSeconds >= 1
112
+ ? mergeWatch.maxBudgetSeconds
113
+ : DEFAULT_MAX_BUDGET_SECONDS;
114
+ return { intervalSeconds, maxBudgetSeconds };
115
+ }
116
+
117
+ /**
118
+ * Format the `friction` comment body posted alongside the `agent::blocked`
119
+ * transition when a headless close gives up without a confirmed merge.
120
+ */
121
+ function formatUnlandedFriction({
122
+ storyId,
123
+ prNumber,
124
+ prUrl,
125
+ blockClass,
126
+ reason,
127
+ elapsedSeconds,
128
+ }) {
129
+ const prLabel =
130
+ Number.isInteger(prNumber) && prNumber > 0
131
+ ? `PR #${prNumber}${prUrl ? ` (${prUrl})` : ''}`
132
+ : (prUrl ?? 'the PR');
133
+ return (
134
+ `### headless must-land: merge did not land\n\n` +
135
+ `Story #${storyId}: the headless close polled ${prLabel} for merge ` +
136
+ `confirmation and gave up after ${elapsedSeconds}s without observing a ` +
137
+ `confirmed merge.\n\n` +
138
+ `**Block class:** \`${blockClass}\`\n\n` +
139
+ `**Reason:** ${reason}\n\n` +
140
+ `Story transitioned to \`agent::blocked\`. Resolve the underlying ` +
141
+ `condition (branch protection, required checks, or a manual merge), ` +
142
+ `then re-run \`single-story-confirm-merge.js\` or resume delivery.`
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Classify the unlanded merge, emit `merge.unlanded`, post a `friction`
148
+ * comment, and transition the Story to `agent::blocked`. Every side effect
149
+ * is best-effort logged rather than thrown — the caller (`runSingleStoryClose`)
150
+ * owns surfacing the non-zero exit via its own throw once this returns.
151
+ *
152
+ * @returns {Promise<{ confirmed: false, blockClass: string, reason: string, elapsedSeconds: number }>}
153
+ */
154
+ async function blockOnUnlanded({
155
+ storyId,
156
+ prNumber,
157
+ prUrl,
158
+ armResult,
159
+ prProbe,
160
+ budget,
161
+ provider,
162
+ progress,
163
+ classifyMergeBlockFn,
164
+ emitMergeUnlandedFn,
165
+ }) {
166
+ const { blockClass, reason } = classifyMergeBlockFn({
167
+ armResult,
168
+ prProbe,
169
+ budget,
170
+ });
171
+ const elapsedSeconds = budget?.elapsedSeconds ?? 0;
172
+
173
+ if (Number.isInteger(prNumber) && prNumber > 0) {
174
+ try {
175
+ emitMergeUnlandedFn({
176
+ scope: 'story',
177
+ ticketId: storyId,
178
+ prNumber,
179
+ blockClass,
180
+ reason,
181
+ elapsedSeconds,
182
+ });
183
+ } catch (err) {
184
+ progress?.(
185
+ 'CONFIRM',
186
+ `⚠️ merge.unlanded emit failed (continuing): ${err?.message ?? err}`,
187
+ );
188
+ }
189
+ } else {
190
+ progress?.(
191
+ 'CONFIRM',
192
+ '⚠️ No parseable PR number — skipping merge.unlanded emit (schema requires prNumber).',
193
+ );
194
+ }
195
+
196
+ const body = formatUnlandedFriction({
197
+ storyId,
198
+ prNumber,
199
+ prUrl,
200
+ blockClass,
201
+ reason,
202
+ elapsedSeconds,
203
+ });
204
+ try {
205
+ await postStructuredComment(provider, storyId, 'friction', body);
206
+ } catch (err) {
207
+ progress?.(
208
+ 'CONFIRM',
209
+ `⚠️ Failed to post merge.unlanded friction comment: ${err?.message ?? err}`,
210
+ );
211
+ }
212
+
213
+ try {
214
+ await transitionTicketState(provider, storyId, STATE_LABELS.BLOCKED, {});
215
+ progress?.(
216
+ 'CONFIRM',
217
+ `🛑 Story #${storyId} → agent::blocked (${blockClass}).`,
218
+ );
219
+ } catch (err) {
220
+ progress?.(
221
+ 'CONFIRM',
222
+ `⚠️ Failed to flip Story #${storyId} to agent::blocked: ${err?.message ?? err}`,
223
+ );
224
+ }
225
+
226
+ return { confirmed: false, blockClass, reason, elapsedSeconds };
227
+ }
228
+
229
+ /**
230
+ * Poll an armed standalone-Story PR to merge confirmation, or terminate
231
+ * `agent::blocked` with a classified `merge.unlanded` event.
232
+ *
233
+ * @param {object} args
234
+ * @param {string} args.cwd
235
+ * @param {number} args.storyId
236
+ * @param {number|null} args.prNumber
237
+ * @param {string} args.prUrl
238
+ * @param {boolean} args.autoMergeEnabled
239
+ * @param {string|null} args.autoMergeReason
240
+ * @param {object} args.provider
241
+ * @param {object} [args.config]
242
+ * @param {(tag: string, msg: string) => void} [args.progress]
243
+ * @param {object} [args.injectedGh]
244
+ * @param {Function} [args.injectedNotify]
245
+ * @param {Function} [args.confirmStoryMergedFn] Test seam — defaults to the
246
+ * SAME `confirmStoryMerged` export `single-story-confirm-merge.js` calls
247
+ * (Story #4428 AC4: one merged/`agent::done` implementation).
248
+ * @param {Function} [args.readPrMergeStateFn] Test seam for the PR-state reader.
249
+ * @param {Function} [args.classifyMergeBlockFn] Test seam for the classifier.
250
+ * @param {Function} [args.emitMergeUnlandedFn] Test seam for the lifecycle emitter.
251
+ * @param {(ms: number) => Promise<void>} [args.sleepFn] Test seam so the
252
+ * suite does not actually wait.
253
+ * @param {() => number} [args.nowMsFn] Test seam; returns epoch ms.
254
+ * @returns {Promise<{ confirmed: boolean, action?: string, blockClass?: string, reason?: string, elapsedSeconds?: number }>}
255
+ */
256
+ export async function runConfirmMergePhase({
257
+ cwd,
258
+ storyId,
259
+ prNumber,
260
+ prUrl,
261
+ autoMergeEnabled,
262
+ autoMergeReason,
263
+ provider,
264
+ config,
265
+ progress,
266
+ injectedGh,
267
+ injectedNotify,
268
+ confirmStoryMergedFn = defaultConfirmStoryMerged,
269
+ readPrMergeStateFn = defaultReadPrMergeState,
270
+ readPrClassificationProbeFn = readPrClassificationProbe,
271
+ classifyMergeBlockFn = defaultClassifyMergeBlock,
272
+ emitMergeUnlandedFn = defaultEmitMergeUnlanded,
273
+ sleepFn = defaultSleep,
274
+ nowMsFn = Date.now,
275
+ }) {
276
+ // The arm itself never succeeded (gh failure, unparseable PR number, or a
277
+ // deliberate disablement) — there is no "armed but unconfirmed" PR to
278
+ // poll. Headless mode still requires an explicit terminal state, so
279
+ // classify and block immediately rather than resting silently.
280
+ if (!autoMergeEnabled) {
281
+ progress?.(
282
+ 'CONFIRM',
283
+ `⚠️ Auto-merge not enabled (${autoMergeReason ?? 'unknown'}) — headless close cannot wait for a merge that was never armed.`,
284
+ );
285
+ return blockOnUnlanded({
286
+ storyId,
287
+ prNumber,
288
+ prUrl,
289
+ armResult: { armed: false, reason: autoMergeReason },
290
+ budget: { elapsedSeconds: 0 },
291
+ provider,
292
+ progress,
293
+ classifyMergeBlockFn,
294
+ emitMergeUnlandedFn,
295
+ });
296
+ }
297
+
298
+ const { intervalSeconds, maxBudgetSeconds } =
299
+ resolveMergeWatchCadence(config);
300
+ const intervalMs = intervalSeconds * 1000;
301
+ const budgetMs = maxBudgetSeconds * 1000;
302
+ const startedAtMs = nowMsFn();
303
+
304
+ progress?.(
305
+ 'CONFIRM',
306
+ `⏳ Headless must-land: polling PR #${prNumber} for merge confirmation (budget=${maxBudgetSeconds}s)...`,
307
+ );
308
+
309
+ while (true) {
310
+ const confirmation = await confirmStoryMergedFn({
311
+ provider,
312
+ storyId,
313
+ prNumber,
314
+ prUrl,
315
+ cwd,
316
+ config,
317
+ progress,
318
+ injectedGh,
319
+ injectedNotify,
320
+ readPrMergeStateFn,
321
+ });
322
+
323
+ if (confirmation.merged && confirmation.action !== 'flip-failed') {
324
+ progress?.(
325
+ 'CONFIRM',
326
+ `✅ Story #${storyId} merge confirmed — agent::done.`,
327
+ );
328
+ return { confirmed: true, action: confirmation.action };
329
+ }
330
+
331
+ if (confirmation.merged && confirmation.action === 'flip-failed') {
332
+ // The PR merged but the agent::closing → agent::done label flip
333
+ // itself threw — reporting confirmed:true here would strand the
334
+ // Story at agent::closing with no notification and no block, the
335
+ // silent-terminal-state gap the Epic exists to close (audit-quality
336
+ // Critical finding, Epic #4425). Route through the same
337
+ // blockOnUnlanded path as an unlanded merge so the run still
338
+ // terminates in an explicit agent::blocked state with a diagnosis.
339
+ progress?.(
340
+ 'CONFIRM',
341
+ `⚠️ Story #${storyId} merge confirmed but the agent::done flip failed — blocking explicitly.`,
342
+ );
343
+ return blockOnUnlanded({
344
+ storyId,
345
+ prNumber,
346
+ prUrl,
347
+ prProbe: {
348
+ error: 'merge confirmed but agent::done label flip failed',
349
+ },
350
+ budget: {
351
+ exhausted: true,
352
+ elapsedSeconds: Math.round((nowMsFn() - startedAtMs) / 1000),
353
+ },
354
+ provider,
355
+ progress,
356
+ classifyMergeBlockFn,
357
+ emitMergeUnlandedFn,
358
+ });
359
+ }
360
+
361
+ if (confirmation.reason === 'pr-not-merged') {
362
+ // The PR was closed without merging — a definitive terminal state,
363
+ // not a "still pending" condition the budget should keep waiting
364
+ // on. checksStatus MUST be a non-pending, non-undefined value here
365
+ // (audit-clean-code finding, Epic #4425): classifyMergeBlock's
366
+ // budget-exhausted branch treats an undefined checksStatus as
367
+ // "still pending", which would misclassify this definitive
368
+ // closed-without-merging case as checks-pending-timeout instead
369
+ // of falling through to the api-race-other reason built from
370
+ // prProbe.error below.
371
+ return blockOnUnlanded({
372
+ storyId,
373
+ prNumber,
374
+ prUrl,
375
+ prProbe: {
376
+ checksStatus: 'closed',
377
+ error: 'PR closed without merging (state=CLOSED)',
378
+ },
379
+ budget: {
380
+ exhausted: true,
381
+ elapsedSeconds: Math.round((nowMsFn() - startedAtMs) / 1000),
382
+ },
383
+ provider,
384
+ progress,
385
+ classifyMergeBlockFn,
386
+ emitMergeUnlandedFn,
387
+ });
388
+ }
389
+
390
+ const elapsedMs = nowMsFn() - startedAtMs;
391
+ if (elapsedMs + intervalMs > budgetMs) {
392
+ // Terminal classification from the REAL PR state — one fresh probe
393
+ // of the fields classifyMergeBlock keys on, instead of stamping
394
+ // every timeout `checksStatus: 'pending'` (which made a
395
+ // review-required block undiagnosable). The probe degrades to the
396
+ // prior conservative pending stamp when the read itself fails.
397
+ const prProbe = await readPrClassificationProbeFn({
398
+ prNumber,
399
+ gh: injectedGh,
400
+ });
401
+ return blockOnUnlanded({
402
+ storyId,
403
+ prNumber,
404
+ prUrl,
405
+ prProbe,
406
+ budget: {
407
+ exhausted: true,
408
+ elapsedSeconds: Math.round(elapsedMs / 1000),
409
+ },
410
+ provider,
411
+ progress,
412
+ classifyMergeBlockFn,
413
+ emitMergeUnlandedFn,
414
+ });
415
+ }
416
+
417
+ await sleepFn(intervalMs);
418
+ }
419
+ }
@@ -26,11 +26,35 @@ function resolveFlag(paramValue, parsedValue, defaultValue) {
26
26
  return paramValue ?? parsedValue ?? defaultValue;
27
27
  }
28
28
 
29
+ /**
30
+ * Resolve the `waitForMerge` option: an explicit `--no-wait-merge` opt-out
31
+ * always wins (preserves the pre-Story-#4428 exit shape even if a future
32
+ * headless wrapper defaults to waiting); otherwise the explicit
33
+ * `--wait-merge` / injected value governs, defaulting to `false` so
34
+ * attended (non-headless) invocations are byte-identical to before.
35
+ *
36
+ * @param {{ waitForMergeParam, noWaitForMergeParam, parsed }} raw
37
+ * @returns {boolean}
38
+ */
39
+ function resolveWaitForMerge({
40
+ waitForMergeParam,
41
+ noWaitForMergeParam,
42
+ parsed,
43
+ }) {
44
+ const optedOut = resolveFlag(
45
+ noWaitForMergeParam,
46
+ parsed.noWaitForMerge,
47
+ false,
48
+ );
49
+ if (optedOut) return false;
50
+ return resolveFlag(waitForMergeParam, parsed.waitForMerge, false);
51
+ }
52
+
29
53
  /**
30
54
  * Parse and resolve all CLI / injection options for `runSingleStoryClose`.
31
55
  *
32
- * @param {{ storyIdParam, cwdParam, skipValidationParam, skipSyncParam, noAutoMergeParam, noFullScopeCrapParam }} raw
33
- * @returns {{ storyId, cwd, skipValidation, skipSync, noAutoMerge, noFullScopeCrap }}
56
+ * @param {{ storyIdParam, cwdParam, skipValidationParam, skipSyncParam, noAutoMergeParam, noFullScopeCrapParam, waitForMergeParam, noWaitForMergeParam }} raw
57
+ * @returns {{ storyId, cwd, skipValidation, skipSync, noAutoMerge, noFullScopeCrap, waitForMerge }}
34
58
  */
35
59
  export function parseCloseOptions({
36
60
  storyIdParam,
@@ -39,6 +63,8 @@ export function parseCloseOptions({
39
63
  skipSyncParam,
40
64
  noAutoMergeParam,
41
65
  noFullScopeCrapParam,
66
+ waitForMergeParam,
67
+ noWaitForMergeParam,
42
68
  }) {
43
69
  const parsed =
44
70
  storyIdParam !== undefined
@@ -49,6 +75,8 @@ export function parseCloseOptions({
49
75
  skipSync: !!skipSyncParam,
50
76
  noAutoMerge: !!noAutoMergeParam,
51
77
  noFullScopeCrap: !!noFullScopeCrapParam,
78
+ waitForMerge: !!waitForMergeParam,
79
+ noWaitForMerge: !!noWaitForMergeParam,
52
80
  }
53
81
  : parseSprintArgs();
54
82
  return {
@@ -66,5 +94,10 @@ export function parseCloseOptions({
66
94
  parsed.noFullScopeCrap,
67
95
  false,
68
96
  ),
97
+ waitForMerge: resolveWaitForMerge({
98
+ waitForMergeParam,
99
+ noWaitForMergeParam,
100
+ parsed,
101
+ }),
69
102
  };
70
103
  }