mandrel 1.89.0 → 1.91.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 (26) hide show
  1. package/.agents/docs/configuration.md +1 -0
  2. package/.agents/schemas/agentrc.schema.json +4 -0
  3. package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
  4. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  5. package/.agents/scripts/coverage-capture.js +17 -0
  6. package/.agents/scripts/epic-deliver-preflight.js +37 -1
  7. package/.agents/scripts/lib/close-validation/gates.js +64 -24
  8. package/.agents/scripts/lib/config/ci.js +12 -1
  9. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  10. package/.agents/scripts/lib/npm-scripts.js +55 -0
  11. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +10 -5
  12. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +179 -5
  13. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +98 -10
  14. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +32 -0
  15. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +7 -1
  16. package/.agents/scripts/lib/orchestration/merge-block-class.js +32 -4
  17. package/.agents/scripts/lib/orchestration/remote-verifier.js +165 -0
  18. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
  19. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +10 -0
  20. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
  21. package/.agents/scripts/single-story-init.js +22 -0
  22. package/.agents/workflows/deliver.md +8 -0
  23. package/.agents/workflows/helpers/deliver-epic.md +11 -0
  24. package/.agents/workflows/helpers/single-story-deliver.md +8 -0
  25. package/docs/CHANGELOG.md +14 -0
  26. package/package.json +1 -1
@@ -54,7 +54,10 @@
54
54
 
55
55
  import { spawnSync } from 'node:child_process';
56
56
 
57
+ import { parsePrNumberFromUrl } from '../../../github-url.js';
57
58
  import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
59
+ import { classifyMergeBlock } from '../../merge-block-class.js';
60
+ import { emitMergeUnlanded } from '../emit-merge-unlanded.js';
58
61
 
59
62
  /**
60
63
  * Default `gh pr view --json autoMergeRequest` probe. Pure-spawn helper
@@ -109,6 +112,64 @@ export function ghPrMergeAuto({
109
112
  };
110
113
  }
111
114
 
115
+ /**
116
+ * Story #4472 — direct (non-`--auto`) squash-merge fallback.
117
+ *
118
+ * GitHub's native auto-merge (`gh pr merge --auto`) can only be QUEUED on a
119
+ * repository that has the "Allow auto-merge" setting enabled — which in
120
+ * practice requires branch protection. A repo with zero required checks and
121
+ * no branch protection (every mandrel-bench sandbox, many real consumer
122
+ * repos) rejects the `--auto` arm outright with `Auto merge is not allowed
123
+ * for this repository`. The AutomergePredicate has already cleared the merge
124
+ * (green/absent required checks + a clean structured-signal verdict) by the
125
+ * time the armer runs, so the safe, must-land-satisfying fallback is a
126
+ * direct immediate squash-merge — the epic path's observed de-facto manual
127
+ * fallback, made legal and kept inside the sole authorized `gh pr merge`
128
+ * call site.
129
+ *
130
+ * Same `--squash --delete-branch` shape and same `resolveArmCwd` re-point as
131
+ * `ghPrMergeAuto` (so the trailing local `--delete-branch` housekeeping runs
132
+ * from the primary worktree, not a head-branch worktree). Omitting `--auto`
133
+ * makes `gh` merge synchronously.
134
+ */
135
+ export function ghPrMergeDirect({
136
+ prUrl,
137
+ cwd,
138
+ spawnFn = spawnSync,
139
+ resolveArmCwd = resolveAutoMergeArmCwd,
140
+ }) {
141
+ const armCwd = resolveArmCwd(cwd);
142
+ const result = spawnFn(
143
+ 'gh',
144
+ ['pr', 'merge', prUrl, '--squash', '--delete-branch'],
145
+ { cwd: armCwd, encoding: 'utf-8', shell: false },
146
+ );
147
+ return {
148
+ status: result.status ?? 1,
149
+ stdout: result.stdout ?? '',
150
+ stderr: result.stderr ?? '',
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Pure: does this `gh pr merge --auto` stderr indicate that native
156
+ * auto-merge is unavailable on the repository (as opposed to a genuine arm
157
+ * failure — auth, a merge conflict, an already-merged race)? Only this
158
+ * specific class of failure is safe to retry as a direct merge; everything
159
+ * else must surface as a real failure. Matched case-insensitively.
160
+ *
161
+ * Exported so the marker set is reviewable and testable in isolation.
162
+ */
163
+ export function isAutoMergeUnavailable(stderr) {
164
+ const text = String(stderr ?? '').toLowerCase();
165
+ return (
166
+ text.includes('auto merge is not allowed') ||
167
+ text.includes('auto-merge is not allowed') ||
168
+ text.includes('enablepullrequestautomerge') ||
169
+ (text.includes('auto') && text.includes('not enabled'))
170
+ );
171
+ }
172
+
112
173
  /**
113
174
  * Pure: parse `gh pr view --json autoMergeRequest,mergeCommit` output.
114
175
  * `autoMergeRequest` is `null` when auto-merge is NOT armed; a non-null
@@ -172,9 +233,17 @@ export class AutomergeArmer {
172
233
  /**
173
234
  * @param {object} opts
174
235
  * @param {object} opts.bus
236
+ * @param {number} [opts.epicId] Epic id — required for the headless
237
+ * `merge.unlanded` attribution on a genuine arm failure (Story #4472).
238
+ * @param {boolean} [opts.headless] When true (a `/deliver --yes` run), a
239
+ * genuine (non-fallback) arm failure escalates to an explicit
240
+ * `merge.unlanded` + `epic.blocked` terminal instead of returning
241
+ * silently (Story #4472). Defaults to `false` (attended).
175
242
  * @param {string} [opts.cwd]
176
243
  * @param {Function} [opts.ghPrViewAutoMergeFn] override for tests.
177
244
  * @param {Function} [opts.ghPrMergeAutoFn] override for tests.
245
+ * @param {Function} [opts.ghPrMergeDirectFn] override for tests.
246
+ * @param {Function} [opts.emitMergeUnlandedFn] override for tests.
178
247
  * @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
179
248
  */
180
249
  constructor(opts = {}) {
@@ -186,9 +255,13 @@ export class AutomergeArmer {
186
255
  throw new TypeError('AutomergeArmer requires a bus with on() and emit()');
187
256
  }
188
257
  this.bus = opts.bus;
258
+ this.epicId = Number.isInteger(opts.epicId) ? opts.epicId : null;
259
+ this.headless = opts.headless === true;
189
260
  this.cwd = opts.cwd ?? process.cwd();
190
261
  this.ghPrViewAutoMergeFn = opts.ghPrViewAutoMergeFn ?? ghPrViewAutoMerge;
191
262
  this.ghPrMergeAutoFn = opts.ghPrMergeAutoFn ?? ghPrMergeAuto;
263
+ this.ghPrMergeDirectFn = opts.ghPrMergeDirectFn ?? ghPrMergeDirect;
264
+ this.emitMergeUnlandedFn = opts.emitMergeUnlandedFn ?? emitMergeUnlanded;
192
265
  this.logger = opts.logger ?? console;
193
266
  /** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
194
267
  this._seen = new Set();
@@ -293,16 +366,26 @@ export class AutomergeArmer {
293
366
  await this._emitArmed(prUrl);
294
367
  return;
295
368
  }
296
- this.classifications.push({
369
+
370
+ // Story #4472 — native auto-merge is unavailable on this repository
371
+ // (no branch protection / "Allow auto-merge" disabled). The predicate
372
+ // already cleared the merge, so fall back to a direct immediate
373
+ // squash-merge instead of stranding a landable PR on the
374
+ // operator-merges path.
375
+ if (isAutoMergeUnavailable(arm.stderr)) {
376
+ const armed = await this._tryDirectMerge({ event, seqId, prUrl, arm });
377
+ if (armed) return;
378
+ }
379
+
380
+ // Genuine arm failure (auth, conflict, an unresolved direct-merge
381
+ // fallback, …). Classify + escalate.
382
+ await this._emitArmFailure({
297
383
  event,
298
384
  seqId,
299
- outcome: 'failed',
385
+ prUrl,
300
386
  reason: `arm-failed:status=${arm.status}`,
301
387
  ghStderr: arm.stderr,
302
388
  });
303
- this.logger.warn?.(
304
- `[AutomergeArmer] gh pr merge --auto failed (status=${arm.status}): ${arm.stderr}`,
305
- );
306
389
  return;
307
390
  }
308
391
 
@@ -320,6 +403,97 @@ export class AutomergeArmer {
320
403
  }
321
404
  }
322
405
 
406
+ /**
407
+ * Story #4472 — direct-merge fallback when native auto-merge is
408
+ * unavailable. Runs an immediate `gh pr merge --squash --delete-branch`
409
+ * (no `--auto`) then re-probes; on a confirmed merge (or the same
410
+ * post-merge `--delete-branch` housekeeping grumble the `--auto` path
411
+ * already tolerates) it emits `epic.merge.armed` so the
412
+ * MergeWatcher → Cleaner → LabelTransitioner chain engages and confirms
413
+ * the merge on its first poll.
414
+ *
415
+ * @returns {Promise<boolean>} `true` when the fallback landed the PR (an
416
+ * `epic.merge.armed` was emitted); `false` when the direct merge did
417
+ * not land, so the caller escalates the original arm failure.
418
+ */
419
+ async _tryDirectMerge({ event, seqId, prUrl, arm }) {
420
+ this.logger.info?.(
421
+ `[AutomergeArmer] native auto-merge unavailable (${arm.stderr?.trim?.() ?? arm.stderr}); falling back to a direct squash-merge on ${prUrl}.`,
422
+ );
423
+ const direct = this.ghPrMergeDirectFn({ prUrl, cwd: this.cwd });
424
+ const recheck = this.ghPrViewAutoMergeFn({ prUrl, cwd: this.cwd });
425
+ const merged =
426
+ recheck.status === 0 &&
427
+ (parsePrMerged(recheck.stdout) || parseAutoMergeArmed(recheck.stdout));
428
+ if (direct.status === 0 || merged) {
429
+ this.classifications.push({
430
+ event,
431
+ seqId,
432
+ outcome: 'armed',
433
+ prUrl,
434
+ note: `direct-merge fallback (native auto-merge unavailable); direct exit ${direct.status}${direct.status !== 0 ? ` but re-probe shows merged/armed (housekeeping stderr: ${direct.stderr})` : ''}`,
435
+ });
436
+ await this._emitArmed(prUrl);
437
+ return true;
438
+ }
439
+ this.logger.warn?.(
440
+ `[AutomergeArmer] direct-merge fallback failed (status=${direct.status}): ${direct.stderr}`,
441
+ );
442
+ return false;
443
+ }
444
+
445
+ /**
446
+ * Classify + (in headless) escalate a genuine arm failure. The `--auto`
447
+ * path historically returned silently here; a `/deliver --yes` run has no
448
+ * operator to notice, so we mirror the MergeWatcher's terminal:
449
+ * `merge.unlanded` ledger attribution + an explicit `epic.blocked`
450
+ * transition. Attended runs keep the classify-and-return behaviour.
451
+ */
452
+ async _emitArmFailure({ event, seqId, prUrl, reason, ghStderr }) {
453
+ this.classifications.push({
454
+ event,
455
+ seqId,
456
+ outcome: 'failed',
457
+ reason,
458
+ ghStderr,
459
+ });
460
+ this.logger.warn?.(
461
+ `[AutomergeArmer] gh pr merge --auto failed (${reason}): ${ghStderr}`,
462
+ );
463
+ if (!this.headless) return;
464
+ const classification = classifyMergeBlock({
465
+ armResult: { armed: false, reason: ghStderr },
466
+ });
467
+ const prNumber = parsePrNumberFromUrl(prUrl);
468
+ if (
469
+ Number.isInteger(this.epicId) &&
470
+ Number.isInteger(prNumber) &&
471
+ prNumber > 0
472
+ ) {
473
+ try {
474
+ this.emitMergeUnlandedFn({
475
+ scope: 'epic',
476
+ ticketId: this.epicId,
477
+ prNumber,
478
+ blockClass: classification.blockClass,
479
+ reason: classification.reason,
480
+ elapsedSeconds: 0,
481
+ });
482
+ } catch (err) {
483
+ this.logger.warn?.(
484
+ `[AutomergeArmer] emitMergeUnlanded failed (swallowed): ${err?.message ?? err}`,
485
+ );
486
+ }
487
+ }
488
+ try {
489
+ await this.bus.emit('epic.blocked', { reason: `merge-arm:failed` });
490
+ } catch (err) {
491
+ this.logger.warn?.(
492
+ `[AutomergeArmer] epic.blocked emit on arm failure failed (swallowed): ${err?.message ?? err}`,
493
+ );
494
+ }
495
+ }
496
+
323
497
  reset() {
324
498
  this._seen.clear();
325
499
  this.classifications = [];
@@ -68,8 +68,10 @@ import { spawnSync } from 'node:child_process';
68
68
 
69
69
  import { hasSurvivingCritical } from '../../../audit-suite/findings.js';
70
70
  import { getCiDelivery } from '../../../config/ci.js';
71
+ import { parsePrNumberFromUrl } from '../../../github-url.js';
71
72
  import * as epicRunStateStore from '../../epic-run-state-store.js';
72
73
  import { findStructuredComment } from '../../ticketing.js';
74
+ import { emitMergeUnlanded } from '../emit-merge-unlanded.js';
73
75
  import { normalizeCheckState, RECOGNIZED_CHECK_STATES } from './watcher.js';
74
76
 
75
77
  /**
@@ -224,19 +226,51 @@ export function probeRequiredChecks({ prUrl, cwd, spawnFn = spawnSync }) {
224
226
  * stdout. We parse stdout first (it is populated even on the non-zero
225
227
  * exit) and classify from the outcomes.
226
228
  *
229
+ * Story #4472 — checks-less repos. In a repo with zero required checks
230
+ * (no branch protection, or protection that requires no status checks),
231
+ * `gh pr checks --required` writes NOTHING to stdout and reports
232
+ * `no checks reported on the <branch> branch` to stderr with a non-zero
233
+ * exit. That is the SAME empty-parsed-set condition the outcomes loop
234
+ * below already treats as green — there is simply nothing to gate on — so
235
+ * we must not conflate it with a genuine probe failure (auth, network, no
236
+ * PR). We detect the `no checks reported` stderr signature and return
237
+ * green, UNLESS the consumer opted into `delivery.ci.requireChecks`, in
238
+ * which case the absent CI gate is a deliberate hard block.
239
+ *
227
240
  * @param {{ status: number, stdout: string, stderr: string }} probe
241
+ * @param {{ requireChecks?: boolean }} [opts] When `requireChecks` is
242
+ * true, a checks-less repo fails closed instead of arming.
228
243
  * @returns {{ ok: boolean, reason: string|null, outcomes: Record<string, string> }}
229
244
  */
230
- export function classifyRequiredChecksProbe(probe) {
245
+ export function classifyRequiredChecksProbe(
246
+ probe,
247
+ { requireChecks = false } = {},
248
+ ) {
231
249
  const stdout = String(probe?.stdout ?? '').trim();
232
- // Empty stdout with a non-zero status → probe genuinely failed (auth,
233
- // network, no PR). Fail closed.
250
+ const stderr = String(probe?.stderr ?? '').trim();
251
+ // Empty stdout: either a checks-less repo (green, nothing to gate on) or
252
+ // a genuine probe failure. The `no checks reported` stderr signature
253
+ // distinguishes them.
234
254
  if (stdout.length === 0) {
255
+ const noChecksReported = /no checks reported/i.test(stderr);
256
+ if (noChecksReported && !requireChecks) {
257
+ // Zero required checks configured — matches the empty-parsed-set
258
+ // "treated as green" branch below. Nothing to gate on.
259
+ return { ok: true, reason: null, outcomes: {} };
260
+ }
261
+ if (noChecksReported && requireChecks) {
262
+ return {
263
+ ok: false,
264
+ reason:
265
+ 'no required checks reported and delivery.ci.requireChecks is set — failing closed per policy',
266
+ outcomes: {},
267
+ };
268
+ }
235
269
  return {
236
270
  ok: false,
237
271
  reason:
238
272
  `live required-check probe failed (status=${probe?.status ?? 'unknown'})` +
239
- (probe?.stderr ? `: ${String(probe.stderr).trim().slice(0, 200)}` : ''),
273
+ (stderr ? `: ${stderr.slice(0, 200)}` : ''),
240
274
  outcomes: {},
241
275
  };
242
276
  }
@@ -678,8 +712,13 @@ export class AutomergePredicate {
678
712
  * evaluator). Required for the read of run-state + structured
679
713
  * comments.
680
714
  * @param {object} [opts.config] Resolved agent config. Read for the
681
- * `delivery.ci.autoMerge` policy via `getCiDelivery`. Defaults to the
682
- * framework default (`trust-ci`) when omitted.
715
+ * `delivery.ci.autoMerge` policy and the `delivery.ci.requireChecks`
716
+ * fail-closed-without-checks policy via `getCiDelivery`. Defaults to the
717
+ * framework defaults (`trust-ci` / `requireChecks: false`) when omitted.
718
+ * @param {boolean} [opts.headless] When true (a `/deliver --yes` run), a
719
+ * predicate refusal escalates to an explicit `merge.unlanded` +
720
+ * `epic.blocked` terminal instead of silently parking on the
721
+ * operator-merges path (Story #4472). Defaults to `false` (attended).
683
722
  * @param {string} [opts.cwd] Working directory for the live
684
723
  * `gh pr checks --required` probe. Defaults to `process.cwd()`.
685
724
  * @param {Function} [opts.evaluatePredicateFn] override of
@@ -708,13 +747,20 @@ export class AutomergePredicate {
708
747
  this.epicId = opts.epicId;
709
748
  this.provider = opts.provider;
710
749
  this.cwd = opts.cwd ?? process.cwd();
711
- // Resolve the merge posture once at construction. `getCiDelivery`
712
- // applies the framework default (`trust-ci`) for any omitted field.
713
- this.policy = getCiDelivery(opts.config ?? null).autoMerge;
750
+ // Resolve the merge posture + fail-closed policy once at construction.
751
+ // `getCiDelivery` applies the framework defaults (`trust-ci` /
752
+ // `requireChecks: false`) for any omitted field.
753
+ const ci = getCiDelivery(opts.config ?? null);
754
+ this.policy = ci.autoMerge;
755
+ this.requireChecks = ci.requireChecks;
756
+ this.headless = opts.headless === true;
714
757
  this.evaluatePredicateFn =
715
758
  opts.evaluatePredicateFn ?? evaluateAutoMergePredicate;
716
759
  this.probeRequiredChecksFn =
717
760
  opts.probeRequiredChecksFn ?? probeRequiredChecks;
761
+ // Injected for tests so the headless terminal escalation can be
762
+ // observed without touching disk.
763
+ this.emitMergeUnlandedFn = opts.emitMergeUnlandedFn ?? emitMergeUnlanded;
718
764
  this.logger = opts.logger ?? console;
719
765
  /** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
720
766
  this._seen = new Set();
@@ -786,7 +832,9 @@ export class AutomergePredicate {
786
832
  let probeVerdict;
787
833
  try {
788
834
  const probe = this.probeRequiredChecksFn({ prUrl, cwd: this.cwd });
789
- probeVerdict = classifyRequiredChecksProbe(probe);
835
+ probeVerdict = classifyRequiredChecksProbe(probe, {
836
+ requireChecks: this.requireChecks,
837
+ });
790
838
  } catch (err) {
791
839
  probeVerdict = {
792
840
  ok: false,
@@ -878,6 +926,17 @@ export class AutomergePredicate {
878
926
  * Emit `epic.merge.blocked`. Helper carved out so the blocking paths
879
927
  * (CI failure / predicate dirty / evaluator throw) share the same emit
880
928
  * shape.
929
+ *
930
+ * Story #4472 — must-land coverage of predicate refusal. In a headless
931
+ * (`/deliver --yes`) run there is no operator to act on a bare
932
+ * `epic.merge.blocked` (nothing in the listener chain consumes it), so
933
+ * the run would silently park on the operator-merges path. When
934
+ * `this.headless`, we additionally attribute the refusal to the
935
+ * lifecycle ledger via `merge.unlanded` (blockClass `predicate-refused`)
936
+ * and drive the explicit `epic.blocked` terminal — the same
937
+ * escalation the MergeWatcher performs on post-arm budget exhaustion —
938
+ * so the Epic transitions to `agent::blocked` with an operator-visible
939
+ * reason instead of stalling.
881
940
  */
882
941
  async _emitBlocked(prUrl, reason) {
883
942
  try {
@@ -887,6 +946,35 @@ export class AutomergePredicate {
887
946
  `[AutomergePredicate] epic.merge.blocked emit failed (swallowed): ${err?.message ?? err}`,
888
947
  );
889
948
  }
949
+ if (!this.headless) return;
950
+ // Ledger attribution — best-effort; a failed append must NOT mask the
951
+ // epic.blocked transition below.
952
+ try {
953
+ const prNumber = parsePrNumberFromUrl(prUrl);
954
+ if (Number.isInteger(prNumber) && prNumber > 0) {
955
+ this.emitMergeUnlandedFn({
956
+ scope: 'epic',
957
+ ticketId: this.epicId,
958
+ prNumber,
959
+ blockClass: 'predicate-refused',
960
+ reason,
961
+ elapsedSeconds: 0,
962
+ });
963
+ }
964
+ } catch (err) {
965
+ this.logger.warn?.(
966
+ `[AutomergePredicate] emitMergeUnlanded failed (swallowed): ${err?.message ?? err}`,
967
+ );
968
+ }
969
+ try {
970
+ await this.bus.emit('epic.blocked', {
971
+ reason: `merge-predicate:refused`,
972
+ });
973
+ } catch (err) {
974
+ this.logger.warn?.(
975
+ `[AutomergePredicate] epic.blocked emit on predicate refusal failed (swallowed): ${err?.message ?? err}`,
976
+ );
977
+ }
890
978
  }
891
979
 
892
980
  reset() {
@@ -82,6 +82,7 @@ import {
82
82
  openOrLocatePr as defaultOpenOrLocatePr,
83
83
  } from '../../finalize/open-or-locate-pr.js';
84
84
  import { postHandoffComment as defaultPostHandoffComment } from '../../finalize/post-handoff-comment.js';
85
+ import { probeRemoteBranch as defaultProbeRemoteBranch } from '../../remote-verifier.js';
85
86
 
86
87
  /**
87
88
  * Build the production default `runFinalizeFn` that composes the
@@ -103,12 +104,22 @@ import { postHandoffComment as defaultPostHandoffComment } from '../../finalize/
103
104
  * contract is identical and `markPrReady` is a no-op on an already-ready
104
105
  * PR, so replay stays idempotent.
105
106
  *
107
+ * Issue #4483 — deterministic land-or-block backstop. Before opening (or
108
+ * readying) the PR, finalize asserts the delivery branch `epic/<id>`
109
+ * actually exists on origin. A delivery that was never pushed — e.g. an
110
+ * agent that built the Epic inline on local `main` and skipped the
111
+ * orchestration — MUST surface as an explicit
112
+ * `delivery-branch-missing-on-origin` blocker (which keeps the Epic at
113
+ * `agent::blocked`), never a declared success. The probe is bounded
114
+ * (timeout + SIGKILL) so a hung remote degrades to a blocker too.
115
+ *
106
116
  * @param {{
107
117
  * provider?: object|null,
108
118
  * earlyPr?: boolean,
109
119
  * openOrLocatePrFn?: typeof defaultOpenOrLocatePr,
110
120
  * markPrReadyFn?: typeof defaultMarkPrReady,
111
121
  * postHandoffCommentFn?: typeof defaultPostHandoffComment,
122
+ * probeRemoteBranchFn?: typeof defaultProbeRemoteBranch,
112
123
  * }} deps
113
124
  */
114
125
  export function composeBusOwnedFinalize(deps = {}) {
@@ -116,6 +127,8 @@ export function composeBusOwnedFinalize(deps = {}) {
116
127
  const markPrReadyFn = deps.markPrReadyFn ?? defaultMarkPrReady;
117
128
  const postHandoffCommentFn =
118
129
  deps.postHandoffCommentFn ?? defaultPostHandoffComment;
130
+ const probeRemoteBranchFn =
131
+ deps.probeRemoteBranchFn ?? defaultProbeRemoteBranch;
119
132
  const provider = deps.provider ?? null;
120
133
  const earlyPr = deps.earlyPr !== false;
121
134
 
@@ -128,6 +141,25 @@ export function composeBusOwnedFinalize(deps = {}) {
128
141
  },
129
142
  };
130
143
  }
144
+
145
+ // Issue #4483 backstop — the delivery branch MUST be on origin before
146
+ // finalize declares any success. A never-pushed branch is the silent
147
+ // local-main failure shape; block explicitly with the probe detail.
148
+ let branchProbe;
149
+ try {
150
+ branchProbe = probeRemoteBranchFn({ branch: `epic/${epicId}`, cwd });
151
+ } catch (err) {
152
+ branchProbe = { exists: false, detail: err?.message ?? String(err) };
153
+ }
154
+ if (!branchProbe.exists) {
155
+ return {
156
+ blocker: {
157
+ reason: 'delivery-branch-missing-on-origin',
158
+ detail: `epic/${epicId} is not on origin — the delivery was never pushed; refusing to finalize (issue #4483). Probe: ${branchProbe.detail}`,
159
+ },
160
+ };
161
+ }
162
+
131
163
  let openResult;
132
164
  try {
133
165
  openResult = await openOrLocatePrFn({
@@ -226,10 +226,15 @@ export async function buildDefaultListenerChain(opts = {}) {
226
226
  order.push('Finalizer');
227
227
 
228
228
  // 4. AutomergeArmer — arms `gh pr merge --auto --squash --delete-branch`
229
- // on epic.merge.ready.
229
+ // on epic.merge.ready. Story #4472: `headless` gates the direct-merge
230
+ // fallback's terminal escalation (a genuine arm failure emits
231
+ // `merge.unlanded` + `epic.blocked` in a `--yes` run instead of
232
+ // returning silently); `epicId` scopes the `merge.unlanded` ledger row.
230
233
  const automergeArmer = new AutomergeArmer({
231
234
  bus,
235
+ epicId,
232
236
  cwd: repoRoot,
237
+ headless,
233
238
  logger,
234
239
  });
235
240
  automergeArmer.register();
@@ -250,6 +255,7 @@ export async function buildDefaultListenerChain(opts = {}) {
250
255
  provider,
251
256
  config,
252
257
  cwd: repoRoot,
258
+ headless,
253
259
  logger,
254
260
  });
255
261
  automergePredicate.register();
@@ -10,7 +10,7 @@
10
10
  * one of four classes from the SAME decision logic, instead of each path
11
11
  * inventing its own ad hoc diagnosis.
12
12
  *
13
- * Block classes (Epic #4425 Goal):
13
+ * Block classes (Epic #4425 Goal; `predicate-refused` added by #4472):
14
14
  * - `checks-pending-timeout` The watch/poll budget was
15
15
  * exhausted while required checks
16
16
  * were still pending/running — not
@@ -33,6 +33,18 @@
33
33
  * a transient GraphQL/API error, an
34
34
  * ambiguous probe result, or a
35
35
  * genuinely novel condition.
36
+ * - `predicate-refused` The AutomergePredicate refused to
37
+ * arm merge BEFORE any arm attempt —
38
+ * a red/pending required check, an
39
+ * unreadable check probe, a dirty
40
+ * structured-signal verdict, or a
41
+ * `delivery.ci.requireChecks` policy
42
+ * block on a checks-less repo (#4472).
43
+ * The must-land contract previously
44
+ * only covered post-arm poll
45
+ * exhaustion, so a predicate refusal
46
+ * in headless mode silently parked;
47
+ * this class makes it attributable.
36
48
  *
37
49
  * Pure function, no I/O: callers pass in the already-observed
38
50
  * arm-result / PR-probe / budget signals (from `AutomergeArmer`,
@@ -55,12 +67,28 @@ export const BLOCK_CLASSES = Object.freeze([
55
67
  'api-race-other',
56
68
  ]);
57
69
 
58
- const BLOCK_CLASS_SET = new Set(BLOCK_CLASSES);
70
+ /**
71
+ * The full set of block-class values a `merge.unlanded` record may carry.
72
+ * This is the classifier's four outputs PLUS `predicate-refused` (#4472),
73
+ * which is emitted DIRECTLY by the AutomergePredicate / AutomergeArmer for a
74
+ * headless refusal that never reached the poll-exhaustion classifier — so it
75
+ * is a valid attribution value even though `classifyMergeBlock` never
76
+ * produces it. `isValidBlockClass` (and the `merge.unlanded` schema enum)
77
+ * validate against this broader set; the classifier's own reachability
78
+ * invariant stays scoped to `BLOCK_CLASSES`.
79
+ */
80
+ export const MERGE_UNLANDED_BLOCK_CLASSES = Object.freeze([
81
+ ...BLOCK_CLASSES,
82
+ 'predicate-refused',
83
+ ]);
84
+
85
+ const BLOCK_CLASS_SET = new Set(MERGE_UNLANDED_BLOCK_CLASSES);
59
86
 
60
87
  /**
61
88
  * @param {string} value
62
- * @returns {boolean} `true` iff `value` is one of the four canonical
63
- * block classes.
89
+ * @returns {boolean} `true` iff `value` is a valid `merge.unlanded`
90
+ * block-class attribution (the four classifier outputs plus the directly-
91
+ * emitted `predicate-refused`).
64
92
  */
65
93
  export function isValidBlockClass(value) {
66
94
  return BLOCK_CLASS_SET.has(value);