mandrel 2.24.0 → 2.25.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 (88) hide show
  1. package/.agents/agents/acceptance-critic.md +13 -19
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +2 -1
  7. package/.agents/docs/configuration.md +2 -1
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/rules/ci-remediation.md +68 -3
  10. package/.agents/schemas/agentrc.schema.json +6 -1
  11. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  12. package/.agents/schemas/baselines/crap.schema.json +5 -1
  13. package/.agents/schemas/crap-report.schema.json +37 -0
  14. package/.agents/scripts/acceptance-eval.js +35 -9
  15. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  16. package/.agents/scripts/audit-to-stories.js +33 -3
  17. package/.agents/scripts/check-context-budget.js +95 -14
  18. package/.agents/scripts/check-test-temp-hygiene.js +83 -0
  19. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  20. package/.agents/scripts/deliver-light.js +3 -4
  21. package/.agents/scripts/deliver-recover.js +13 -0
  22. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  23. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  24. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  25. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  26. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  27. package/.agents/scripts/lib/baseline-loader.js +0 -0
  28. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  29. package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
  30. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  31. package/.agents/scripts/lib/baselines/reader.js +10 -0
  32. package/.agents/scripts/lib/baselines/writer.js +7 -3
  33. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  34. package/.agents/scripts/lib/crap-engine.js +97 -19
  35. package/.agents/scripts/lib/crap-utils.js +73 -13
  36. package/.agents/scripts/lib/env-loader.js +46 -16
  37. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  38. package/.agents/scripts/lib/findings/severity.js +80 -2
  39. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  40. package/.agents/scripts/lib/label-constants.js +17 -0
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  42. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  43. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  44. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  45. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  46. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  47. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  48. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
  49. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
  50. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  51. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  52. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  53. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
  54. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  55. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +50 -13
  56. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  57. package/.agents/scripts/lib/story-plan.js +137 -42
  58. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  59. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  60. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  61. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  62. package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
  63. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
  64. package/.agents/scripts/lib/workers/crap-worker.js +6 -3
  65. package/.agents/scripts/pr-watch-with-update.js +568 -213
  66. package/.agents/scripts/quality-preview.js +25 -0
  67. package/.agents/scripts/run-tests.js +10 -0
  68. package/.agents/scripts/single-story-close.js +6 -0
  69. package/.agents/scripts/single-story-init.js +7 -0
  70. package/.agents/scripts/stories-wave-tick.js +95 -10
  71. package/.agents/scripts/story-plan.js +65 -9
  72. package/.agents/scripts/update-crap-baseline.js +42 -4
  73. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  74. package/.agents/templates/single-story-body.md +16 -8
  75. package/.agents/workflows/audit-architecture.md +69 -13
  76. package/.agents/workflows/audit-quality.md +26 -0
  77. package/.agents/workflows/audit-to-stories.md +30 -4
  78. package/.agents/workflows/deliver.md +81 -95
  79. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  80. package/.agents/workflows/helpers/deliver-reference.md +18 -3
  81. package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
  82. package/.agents/workflows/helpers/deliver-story.md +104 -119
  83. package/.agents/workflows/helpers/plan-reference.md +34 -0
  84. package/.agents/workflows/plan.md +60 -76
  85. package/docs/CHANGELOG.md +29 -0
  86. package/lib/cli/update.js +14 -11
  87. package/lib/cli/version-check.js +9 -1
  88. package/package.json +1 -1
@@ -0,0 +1,276 @@
1
+ /**
2
+ * run-scoped-config.js — pin the config values a delivery run was seeded
3
+ * from, and fail closed when the file they came from changed underneath it.
4
+ *
5
+ * The problem this exists for: `single-story-init.js` resolves config and
6
+ * seeds `story-<id>` from `project.baseBranch`; `single-story-close` used to
7
+ * re-resolve the SAME key and run close-validation, format-autofix, the gate
8
+ * baseline and base-sync against its own second answer. Nothing pinned the
9
+ * first value and nothing checked the two agreed — and the window between
10
+ * them is however long implementation takes. A concurrent session editing
11
+ * `.agentrc.json` / `.agentrc.local.json` mid-run therefore base-synced a
12
+ * Story against a base it was never seeded from, which surfaced only as an
13
+ * ordinary content conflict with nothing pointing at config.
14
+ *
15
+ * The base a Story was seeded from is a property of **that run**, not of
16
+ * whatever the config file says later. So init pins it and close reads the
17
+ * pin back.
18
+ *
19
+ * ## Where the pin lives
20
+ *
21
+ * The `story-init` structured comment, not a temp log: the post-land tail
22
+ * purges the temp tree, while the ticket comment survives every close re-run
23
+ * and every recovery path. `single-story-init.js` already upserts that
24
+ * comment; `pinRunScopedConfig` supplies the block it records, and
25
+ * `resolveRunScopedConfig` reads it back through the existing
26
+ * `findStructuredComment` seam.
27
+ *
28
+ * ## Adding another run-scoped key
29
+ *
30
+ * Add one row to `RUN_SCOPED_CONFIG_KEYS`. Both halves — the pin init writes
31
+ * and the comparison close makes — enumerate that registry, so a new key
32
+ * needs no second mechanism, no reader change and no writer change. Only
33
+ * `baseBranch` is enforced today because only its corruption is destructive
34
+ * (a wrong base merged into the Story branch permanently contaminates the
35
+ * branch and its PR diff); ceremony profile, quality floors and the
36
+ * concurrency cap are deliberately NOT pinned here.
37
+ */
38
+
39
+ import { parseFencedJsonComment } from './structured-comment-parser.js';
40
+ import { findStructuredComment } from './ticketing.js';
41
+
42
+ /**
43
+ * The run-scoped config registry. One row per key whose value belongs to the
44
+ * run that seeded the Story rather than to the current contents of the config
45
+ * file. `read` resolves the key from a resolved config (including its
46
+ * default); `label` is the dotted config path the operator has to go fix.
47
+ *
48
+ * @type {Record<string, { read: (config: object) => unknown, label: string }>}
49
+ */
50
+ const RUN_SCOPED_CONFIG_KEYS = {
51
+ baseBranch: {
52
+ read: (config) => config?.project?.baseBranch ?? 'main',
53
+ label: 'project.baseBranch',
54
+ },
55
+ };
56
+
57
+ /**
58
+ * Snapshot the run-scoped config values from a resolved config. This is the
59
+ * write half of the pin: `single-story-init.js` records the returned object
60
+ * in the `story-init` receipt so close can compare against it later.
61
+ *
62
+ * @param {object} config Resolved config (`resolveConfig` output).
63
+ * @param {typeof RUN_SCOPED_CONFIG_KEYS} [keys] Registry override — the
64
+ * default is the module registry; supplying one is how a caller (or a test)
65
+ * exercises the registry-driven property without a second mechanism.
66
+ * @returns {Record<string, unknown>} the pinned values, keyed by config key.
67
+ */
68
+ export function pinRunScopedConfig(config, keys = RUN_SCOPED_CONFIG_KEYS) {
69
+ const pinned = {};
70
+ for (const [key, spec] of Object.entries(keys)) {
71
+ pinned[key] = spec.read(config);
72
+ }
73
+ return pinned;
74
+ }
75
+
76
+ /**
77
+ * Read the pinned block out of the run's `story-init` receipt.
78
+ *
79
+ * Three distinct non-success states, all reported rather than collapsed —
80
+ * the whole point of this module is that a fallback is never silent:
81
+ * - `absent`: no `story-init` comment on the ticket. Real and expected —
82
+ * the upsert is best-effort (init logs and continues on failure), and a
83
+ * recovery path may close a Story whose init predates this receipt.
84
+ * - `unreadable`: the comment exists but carries no parseable JSON payload.
85
+ * - `provider-error`: the comment read itself failed.
86
+ *
87
+ * @param {{
88
+ * provider: object,
89
+ * storyId: number,
90
+ * findCommentFn?: typeof findStructuredComment,
91
+ * }} args
92
+ * @returns {Promise<{ status: string, values: Record<string, unknown>|null, detail: string|null }>}
93
+ */
94
+ async function readRunScopedConfigReceipt({
95
+ provider,
96
+ storyId,
97
+ findCommentFn = findStructuredComment,
98
+ }) {
99
+ let comment;
100
+ try {
101
+ comment = await findCommentFn(provider, Number(storyId), 'story-init');
102
+ } catch (err) {
103
+ return {
104
+ status: 'provider-error',
105
+ values: null,
106
+ detail: `story-init comment could not be read (${err?.message ?? err})`,
107
+ };
108
+ }
109
+ if (!comment) {
110
+ return {
111
+ status: 'absent',
112
+ values: null,
113
+ detail: 'no story-init comment on the ticket',
114
+ };
115
+ }
116
+ const payload = parseFencedJsonComment(comment);
117
+ if (!payload || typeof payload !== 'object') {
118
+ return {
119
+ status: 'unreadable',
120
+ values: null,
121
+ detail: 'story-init comment carries no parseable JSON payload',
122
+ };
123
+ }
124
+ // Receipts written before the `runScopedConfig` block existed carry the
125
+ // pinned values as top-level payload fields (`baseBranch` has been recorded
126
+ // there since Story #831). Reading the payload itself as the fallback block
127
+ // is what lets a Story initialized by an older init still close against its
128
+ // own pinned base instead of degrading to the fallback warning.
129
+ const block =
130
+ payload.runScopedConfig && typeof payload.runScopedConfig === 'object'
131
+ ? payload.runScopedConfig
132
+ : payload;
133
+ return { status: 'found', values: block, detail: null };
134
+ }
135
+
136
+ /**
137
+ * Build the refusal message for a mid-run config change. Names BOTH values
138
+ * for every conflicting key — the pinned one and the one config resolves to
139
+ * now — because "which base was this branch actually seeded from" is the
140
+ * question the operator could not previously answer.
141
+ *
142
+ * @param {{ storyId: number, conflicts: Array<{ label: string, pinned: unknown, current: unknown }> }} args
143
+ * @returns {string}
144
+ */
145
+ function formatRunScopedConflict({ storyId, conflicts }) {
146
+ const rows = conflicts.map(
147
+ ({ label, pinned, current }) =>
148
+ `${label}: pinned at init = \`${String(pinned)}\`, currently resolves to \`${String(current)}\``,
149
+ );
150
+ return (
151
+ `[single-story-close] Refusing to close Story #${storyId}: run-scoped config changed mid-run. ` +
152
+ `${rows.join('; ')}. ` +
153
+ 'The Story branch was seeded from the pinned value, so closing against the current one would ' +
154
+ 'base-sync it onto a base it was never seeded from. No base-sync, format-autofix or gate run was ' +
155
+ 'performed. A concurrent session most likely edited `.agentrc.json` / `.agentrc.local.json` during ' +
156
+ 'the implementation window: restore the pinned value and re-run close, or re-init the Story against ' +
157
+ 'the new value deliberately.'
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Resolve the run-scoped config for a close, preferring the run's pin over a
163
+ * fresh resolution of the config file.
164
+ *
165
+ * Throws on disagreement — fail closed, before any gate, format-autofix or
166
+ * base-sync has run, with both values named. Never silently prefers either
167
+ * side: preferring the pin would base-sync correctly but hide a config change
168
+ * the operator needs to know about, and preferring current config is the bug
169
+ * this module exists to close.
170
+ *
171
+ * @param {{
172
+ * provider: object,
173
+ * storyId: number,
174
+ * config: object,
175
+ * keys?: typeof RUN_SCOPED_CONFIG_KEYS,
176
+ * findCommentFn?: typeof findStructuredComment,
177
+ * progress?: (tag: string, msg: string) => void,
178
+ * }} args
179
+ * @returns {Promise<{
180
+ * values: Record<string, unknown>,
181
+ * confirmed: boolean,
182
+ * receiptStatus: string,
183
+ * warning: string|null,
184
+ * }>} `confirmed` is true only when every key came from the receipt and
185
+ * agreed with current config — i.e. when downstream remediation may safely
186
+ * assume the pinned value is the one in play.
187
+ */
188
+ export async function resolveRunScopedConfig({
189
+ provider,
190
+ storyId,
191
+ config,
192
+ keys = RUN_SCOPED_CONFIG_KEYS,
193
+ findCommentFn,
194
+ progress,
195
+ }) {
196
+ const current = pinRunScopedConfig(config, keys);
197
+ const receipt = await readRunScopedConfigReceipt({
198
+ provider,
199
+ storyId,
200
+ findCommentFn,
201
+ });
202
+
203
+ if (receipt.status !== 'found') {
204
+ // A missing receipt is a real state, not an error — but the fallback is
205
+ // announced, because a silent one reintroduces exactly the bug above.
206
+ const warning =
207
+ `Run-scoped config could not be read from the run's init receipt ` +
208
+ `(${receipt.detail}); falling back to the currently-resolved config ` +
209
+ `(${describeValues(current, keys)}). This close cannot confirm the Story ` +
210
+ 'was seeded from these values.';
211
+ progress?.('PIN', `⚠️ ${warning}`);
212
+ return {
213
+ values: current,
214
+ confirmed: false,
215
+ receiptStatus: receipt.status,
216
+ warning,
217
+ };
218
+ }
219
+
220
+ const values = {};
221
+ const conflicts = [];
222
+ const unpinned = [];
223
+ for (const [key, spec] of Object.entries(keys)) {
224
+ const pinned = receipt.values?.[key];
225
+ if (pinned === undefined || pinned === null) {
226
+ unpinned.push(spec.label);
227
+ values[key] = current[key];
228
+ continue;
229
+ }
230
+ values[key] = pinned;
231
+ if (pinned !== current[key]) {
232
+ conflicts.push({ label: spec.label, pinned, current: current[key] });
233
+ }
234
+ }
235
+
236
+ if (conflicts.length > 0) {
237
+ throw new Error(formatRunScopedConflict({ storyId, conflicts }));
238
+ }
239
+
240
+ if (unpinned.length > 0) {
241
+ const warning =
242
+ `The run's init receipt pins no value for ${unpinned.join(', ')}; ` +
243
+ `falling back to the currently-resolved config (${describeValues(values, keys)}).`;
244
+ progress?.('PIN', `⚠️ ${warning}`);
245
+ return {
246
+ values,
247
+ confirmed: false,
248
+ receiptStatus: receipt.status,
249
+ warning,
250
+ };
251
+ }
252
+
253
+ progress?.(
254
+ 'PIN',
255
+ `📌 Run-scoped config pinned by the story-init receipt (${describeValues(values, keys)}).`,
256
+ );
257
+ return {
258
+ values,
259
+ confirmed: true,
260
+ receiptStatus: receipt.status,
261
+ warning: null,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Render `label=value` pairs for the operator-facing lines above.
267
+ *
268
+ * @param {Record<string, unknown>} values
269
+ * @param {typeof RUN_SCOPED_CONFIG_KEYS} keys
270
+ * @returns {string}
271
+ */
272
+ function describeValues(values, keys) {
273
+ return Object.entries(keys)
274
+ .map(([key, spec]) => `${spec.label}=\`${String(values[key])}\``)
275
+ .join(', ');
276
+ }
@@ -35,6 +35,7 @@ import {
35
35
  * cwd: string,
36
36
  * worktreePath: string|null,
37
37
  * baseBranch: string,
38
+ * baseConfirmed?: boolean,
38
39
  * storyBranch: string,
39
40
  * storyId: number,
40
41
  * provider: object,
@@ -46,6 +47,7 @@ export async function runBaseSyncPhase({
46
47
  cwd,
47
48
  worktreePath,
48
49
  baseBranch,
50
+ baseConfirmed = false,
49
51
  storyBranch,
50
52
  storyId,
51
53
  provider,
@@ -69,6 +71,7 @@ export async function runBaseSyncPhase({
69
71
  storyId,
70
72
  syncCwd,
71
73
  baseBranch,
74
+ baseConfirmed,
72
75
  storyBranch,
73
76
  result: syncResult,
74
77
  progress,
@@ -95,6 +98,7 @@ export async function runBaseSyncPhase({
95
98
  * storyId: number,
96
99
  * syncCwd: string,
97
100
  * baseBranch: string,
101
+ * baseConfirmed?: boolean,
98
102
  * storyBranch: string,
99
103
  * result: { kind: string, conflictFiles?: string[], stderr?: string },
100
104
  * progress: (tag: string, msg: string) => void,
@@ -105,6 +109,7 @@ export async function handleSyncFailure({
105
109
  storyId,
106
110
  syncCwd,
107
111
  baseBranch,
112
+ baseConfirmed = false,
108
113
  storyBranch,
109
114
  result,
110
115
  progress,
@@ -113,6 +118,7 @@ export async function handleSyncFailure({
113
118
  storyId,
114
119
  storyBranch,
115
120
  baseBranch,
121
+ baseConfirmed,
116
122
  syncCwd,
117
123
  result,
118
124
  });
@@ -147,13 +153,23 @@ export async function handleSyncFailure({
147
153
  * Build the markdown body posted on a base-sync failure. Pure; exported
148
154
  * for tests so the operator-recoverable surface stays reviewable.
149
155
  *
150
- * @param {{ storyId: number, storyBranch: string, baseBranch: string, syncCwd: string, result: { kind: string, conflictFiles?: string[], stderr?: string } }} args
156
+ * Story #4891 `baseConfirmed` gates the merge-the-base recovery block, and
157
+ * defaults to `false` so it fails closed. Telling the operator to run
158
+ * `git merge origin/<baseBranch>` is actively harmful when `<baseBranch>` is
159
+ * not the base the Story was seeded from: it permanently contaminates the
160
+ * branch and its PR diff with an unrelated base. Close confirms the base
161
+ * against the run's `story-init` receipt before that advice is emitted; when
162
+ * it could not (receipt absent, unreadable, or unpinned), the operator is
163
+ * told to establish the real base first instead.
164
+ *
165
+ * @param {{ storyId: number, storyBranch: string, baseBranch: string, baseConfirmed?: boolean, syncCwd: string, result: { kind: string, conflictFiles?: string[], stderr?: string } }} args
151
166
  * @returns {string}
152
167
  */
153
168
  export function buildSyncFailureCommentBody({
154
169
  storyId,
155
170
  storyBranch,
156
171
  baseBranch,
172
+ baseConfirmed = false,
157
173
  syncCwd,
158
174
  result,
159
175
  }) {
@@ -170,15 +186,30 @@ export function buildSyncFailureCommentBody({
170
186
  `sync against \`origin/${baseBranch}\` could not complete. The Story has`,
171
187
  `been transitioned to \`agent::blocked\`. To resume:`,
172
188
  '',
173
- '```bash',
174
- `cd ${syncCwd}`,
175
- `git fetch origin ${baseBranch}`,
176
- `git merge --no-edit origin/${baseBranch}`,
177
- '# resolve any conflicts, then:',
178
- `git add -A ; git commit --no-edit`,
179
- '# re-run close:',
180
- `node .agents/scripts/single-story-close.js --story ${storyId}`,
181
- '```',
189
+ ...(baseConfirmed
190
+ ? [
191
+ '```bash',
192
+ `cd ${syncCwd}`,
193
+ `git fetch origin ${baseBranch}`,
194
+ `git merge --no-edit origin/${baseBranch}`,
195
+ '# resolve any conflicts, then:',
196
+ `git add -A ; git commit --no-edit`,
197
+ '# re-run close:',
198
+ `node .agents/scripts/single-story-close.js --story ${storyId}`,
199
+ '```',
200
+ ]
201
+ : [
202
+ `⚠️ **No merge advice: \`${baseBranch}\` is unconfirmed.** This close could not`,
203
+ `read the base branch \`${storyBranch}\` was seeded from off the run's`,
204
+ '`story-init` receipt, so merging that base in could contaminate the branch',
205
+ 'and its PR diff with an unrelated base. Establish the real base first —',
206
+ `check the \`story-init\` comment on this issue and \`project.baseBranch\` in`,
207
+ '`.agentrc.json` / `.agentrc.local.json` — then merge that base and re-run:',
208
+ '',
209
+ '```bash',
210
+ `node .agents/scripts/single-story-close.js --story ${storyId}`,
211
+ '```',
212
+ ]),
182
213
  ];
183
214
  if (kind === 'conflict' && fileList.length > 0) {
184
215
  lines.push('', '**Conflicting files:**', '', ...fileList);
@@ -95,6 +95,7 @@ import {
95
95
  confirmStoryMerged as defaultConfirmStoryMerged,
96
96
  readPrMergeState as defaultReadPrMergeState,
97
97
  } from '../../../single-story/confirm-merge.js';
98
+ import { pollUntil } from '../../../util/poll-loop.js';
98
99
  import {
99
100
  emitMergeFlipFailed as defaultEmitMergeFlipFailed,
100
101
  MERGED_FLIP_FAILED_BLOCK_CLASS,
@@ -363,7 +364,8 @@ function formatUnlandedFriction({
363
364
  const remedy =
364
365
  blockClass === 'checks-failed'
365
366
  ? `A required check is **red**. Fix the failure and push a new commit on \`story-${storyId}\`; ` +
366
- `auto-merge stays armed across retries. Watch the checks with:\n\n` +
367
+ `the red disarms auto-merge, and only a green on a new head SHA re-arms it — ` +
368
+ `re-running the failed job is forbidden. Watch the checks with:\n\n` +
367
369
  `\`\`\`bash\n${NEXT_COMMANDS.watchCi(storyId, prNumber)}\n\`\`\``
368
370
  : `Resolve the underlying condition (branch protection, required checks, ` +
369
371
  `or a manual merge), then resume the land:\n\n` +
@@ -851,7 +853,20 @@ export async function runConfirmMergePhase({
851
853
  `cumulative budget=${maxBudgetSeconds}s)...`,
852
854
  );
853
855
 
854
- while (true) {
856
+ /**
857
+ * One poll iteration. Returns `{ done: false }` to keep polling, or
858
+ * `{ done: true, outcome }` with the phase's terminal. Story #4873 lifted
859
+ * this body out of a bespoke unbounded loop so the cadence is owned by the
860
+ * shared {@link pollUntil} primitive — the loop below sleeps, aborts, and
861
+ * counts ticks in exactly one place for every wait in the codebase. Every
862
+ * budget, floor, and classification decision is unchanged; only who owns the
863
+ * `await sleep(...)` moved.
864
+ *
865
+ * A throw from any of the terminal handlers is captured rather than allowed
866
+ * to escape into `pollUntil` (which treats a throwing `fn` as a non-match
867
+ * and would spin forever on it); the caller re-throws it after the loop.
868
+ */
869
+ async function runMergePoll() {
855
870
  const probe = await readPrWaitProbeFn({
856
871
  prNumber,
857
872
  gh: injectedGh,
@@ -875,26 +890,44 @@ export async function runConfirmMergePhase({
875
890
  maxBudgetSeconds,
876
891
  };
877
892
 
893
+ // Heartbeat (Story #4873). A backgrounded close writes this phase's
894
+ // progress to its own output file, and between the opening banner and the
895
+ // terminal there used to be NOTHING for minutes at a time — so an
896
+ // orchestrator watching that file could not tell a healthy in-flight wait
897
+ // from a wedged process without going back to GitHub itself. One line per
898
+ // poll makes the file's own growth the liveness signal.
899
+ progress?.(
900
+ 'CONFIRM',
901
+ `⏱ poll ${polls}: PR #${prNumber} state=${probe.state ?? 'unknown'} ` +
902
+ `checks=${probe.checksStatus ?? 'unknown'} ` +
903
+ `mergeState=${probe.mergeStateStatus ?? 'unknown'} ` +
904
+ `(${waitBudget.waitedSeconds}s of ${maxWaitSeconds}s this invocation; ` +
905
+ `${waitBudget.cumulativeSeconds}s of ${maxBudgetSeconds}s cumulative)` +
906
+ (probe.error ? ` — probe error: ${probe.error}` : ''),
907
+ );
908
+
878
909
  if (probe.state === 'MERGED' || probe.mergedAt) {
879
- return onMergeObserved({
880
- storyId,
881
- storyBranch,
882
- baseBranch,
883
- prNumber,
884
- prUrl,
885
- cwd,
886
- config,
887
- provider,
888
- progress,
889
- injectedGh,
890
- injectedNotify,
891
- readPrMergeStateFn,
892
- confirmStoryMergedFn,
893
- runPostLandTailFn,
894
- emitMergeFlipFailedFn,
895
- prProbe: probe,
896
- elapsedSeconds: Math.round(waitedMs / 1000),
897
- });
910
+ return doneWith(
911
+ await onMergeObserved({
912
+ storyId,
913
+ storyBranch,
914
+ baseBranch,
915
+ prNumber,
916
+ prUrl,
917
+ cwd,
918
+ config,
919
+ provider,
920
+ progress,
921
+ injectedGh,
922
+ injectedNotify,
923
+ readPrMergeStateFn,
924
+ confirmStoryMergedFn,
925
+ runPostLandTailFn,
926
+ emitMergeFlipFailedFn,
927
+ prProbe: probe,
928
+ elapsedSeconds: Math.round(waitedMs / 1000),
929
+ }),
930
+ );
898
931
  }
899
932
 
900
933
  // Everything below funnels into ONE terminal exit (Story #4710): each
@@ -987,16 +1020,18 @@ export async function runConfirmMergePhase({
987
1020
  }
988
1021
 
989
1022
  if (unlanded) {
990
- return blockOnUnlanded({
991
- storyId,
992
- prNumber,
993
- prUrl,
994
- ...unlanded,
995
- provider,
996
- progress,
997
- classifyMergeBlockFn,
998
- emitMergeUnlandedFn,
999
- });
1023
+ return doneWith(
1024
+ await blockOnUnlanded({
1025
+ storyId,
1026
+ prNumber,
1027
+ prUrl,
1028
+ ...unlanded,
1029
+ provider,
1030
+ progress,
1031
+ classifyMergeBlockFn,
1032
+ emitMergeUnlandedFn,
1033
+ }),
1034
+ );
1000
1035
  }
1001
1036
 
1002
1037
  // This invocation's bound expired → PENDING. Deliberately NOT a block:
@@ -1011,16 +1046,43 @@ export async function runConfirmMergePhase({
1011
1046
  `${waitBudget.cumulativeSeconds}s of ${maxBudgetSeconds}s cumulative). PR #${prNumber} still in flight ` +
1012
1047
  `(checks=${probe.checksStatus ?? 'unknown'}). Story stays at agent::closing — resumable.`,
1013
1048
  );
1014
- return {
1049
+ return doneWith({
1015
1050
  confirmed: false,
1016
1051
  terminal: 'pending',
1017
1052
  reason: `merge wait bound reached with the PR still in flight (checks=${probe.checksStatus ?? 'unknown'})`,
1018
1053
  prProbe: probe,
1019
1054
  waitBudget,
1020
1055
  elapsedSeconds: waitBudget.waitedSeconds,
1021
- };
1056
+ });
1022
1057
  }
1023
1058
 
1024
- await sleepFn(intervalMs);
1059
+ return { done: false };
1025
1060
  }
1061
+
1062
+ const tick = await pollUntil({
1063
+ fn: async () => {
1064
+ try {
1065
+ return await runMergePoll();
1066
+ } catch (err) {
1067
+ // A terminal handler threw. `pollUntil` treats a throwing `fn` as a
1068
+ // non-match and would poll forever on it, so the throw is carried out
1069
+ // as a match and re-raised below.
1070
+ return { done: true, thrown: err };
1071
+ }
1072
+ },
1073
+ predicate: (result) => result?.done === true,
1074
+ intervalMs,
1075
+ // The wait owns its own bounds (`maxWaitSeconds` → `pending`,
1076
+ // `maxBudgetSeconds` → blocked), and both are decided from the probe
1077
+ // inside the tick. A second, cruder wall-clock timeout here would throw
1078
+ // past those classifications.
1079
+ sleepFn: (ms) => sleepFn(ms),
1080
+ });
1081
+ if (tick.thrown) throw tick.thrown;
1082
+ return tick.outcome;
1083
+ }
1084
+
1085
+ /** Wrap a phase terminal as the poll loop's match. */
1086
+ function doneWith(outcome) {
1087
+ return { done: true, outcome };
1026
1088
  }