mandrel 1.89.0 → 1.90.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.
@@ -300,6 +300,7 @@ top-level keys are validation errors.
300
300
  | `ci.watch.maxPolls` | No | `integer` | — | — |
301
301
  | `ci.watch.maxResumes` | No | `integer` | — | — |
302
302
  | `ci.autoMerge` | No | `"trust-ci"` \| `"strict"` | — | Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate. |
303
+ | `ci.requireChecks` | No | `boolean` | — | Story #4472. Fail-closed-without-checks policy. When true, the AutomergePredicate refuses to arm merge in a repo that reports zero required checks ('no checks reported'), treating the absent CI gate as a hard block. Defaults to false so a checks-less repo with green close-validation gates lands headlessly instead of parking on the operator-merges path. |
303
304
  | `preflight` | No | `object` | — | Story #2899 (Epic #2880, F13). Thresholds consumed by `.agents/scripts/epic-deliver-preflight.js`. When any value is exceeded the preflight envelope flags a breach and /deliver Phase 1 surfaces it via agent::blocked. |
304
305
  | `preflight.maxStories` | No | `integer` | — | — |
305
306
  | `preflight.maxWaves` | No | `integer` | — | — |
@@ -1456,6 +1456,10 @@
1456
1456
  "type": "string",
1457
1457
  "enum": ["trust-ci", "strict"],
1458
1458
  "description": "Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate."
1459
+ },
1460
+ "requireChecks": {
1461
+ "type": "boolean",
1462
+ "description": "Story #4472. Fail-closed-without-checks policy. When true, the AutomergePredicate refuses to arm merge in a repo that reports zero required checks ('no checks reported'), treating the absent CI gate as a hard block. Defaults to false so a checks-less repo with green close-validation gates lands headlessly instead of parking on the operator-merges path."
1459
1463
  }
1460
1464
  },
1461
1465
  "additionalProperties": false
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://github.com/dsj1984/mandrel/blob/main/.agents/schemas/lifecycle/epic.blocked.schema.json",
4
4
  "title": "epic.blocked",
5
- "description": "Emitted by AcceptanceReconciler (on a failed acceptance reconciliation) and MergeWatcher (on a merge-watch timeout) when the Epic transitions to agent::blocked. Subscribed by NotifyDispatcher, which fans the blocker out to the curated webhook channel. The reason field carries either a typed marker (timeout:<event>, waiver, …) or a free-form summary; sourceStoryId scopes the blocker to a child Story when applicable.",
5
+ "description": "Emitted by AcceptanceReconciler (on a failed acceptance reconciliation), MergeWatcher (on a merge-watch timeout), and — in headless runs (Story #4472) — AutomergePredicate (on a predicate refusal that would otherwise silently park) and AutomergeArmer (on a genuine arm failure) when the Epic transitions to agent::blocked. Subscribed by NotifyDispatcher, which fans the blocker out to the curated webhook channel. The reason field carries either a typed marker (timeout:<event>, merge-predicate:refused, merge-arm:failed, waiver, …) or a free-form summary; sourceStoryId scopes the blocker to a child Story when applicable.",
6
6
  "type": "object",
7
7
  "required": ["reason"],
8
8
  "properties": {
@@ -27,7 +27,8 @@
27
27
  "checks-pending-timeout",
28
28
  "branch-protection-human-required",
29
29
  "arm-failure",
30
- "api-race-other"
30
+ "api-race-other",
31
+ "predicate-refused"
31
32
  ]
32
33
  },
33
34
  "reason": { "type": "string", "minLength": 1 },
@@ -33,6 +33,7 @@ import {
33
33
  } from './lib/coverage-capture.js';
34
34
 
35
35
  import { Logger } from './lib/Logger.js';
36
+ import { hasNpmScript, readPackageScripts } from './lib/npm-scripts.js';
36
37
 
37
38
  function parseArgs(argv) {
38
39
  const out = {
@@ -59,6 +60,22 @@ function main() {
59
60
  return 0;
60
61
  }
61
62
 
63
+ // Story #4473 — detect the "missing npm script" misconfiguration
64
+ // distinctly. `close-validation/gates.js` already declines to register
65
+ // this gate when `test:coverage` is absent, so reaching here without the
66
+ // script means a direct/pre-push invocation in a consumer that never
67
+ // defined it. Surface a one-line, fix-naming diagnostic instead of
68
+ // spawning `npm run test:coverage` only to propagate npm's opaque
69
+ // "Missing script" exit code.
70
+ if (!hasNpmScript(readPackageScripts(args.cwd), 'test:coverage')) {
71
+ Logger.error(
72
+ '[coverage-capture] ✖ No "test:coverage" script in package.json. ' +
73
+ 'Add one (e.g. "test:coverage": "node --test --experimental-test-coverage") ' +
74
+ 'or disable the CRAP gate via delivery.quality.gates.crap.enabled=false.',
75
+ );
76
+ return 1;
77
+ }
78
+
62
79
  if (args.skipWhenNoCrapFiles) {
63
80
  let changed;
64
81
  try {
@@ -6,6 +6,7 @@
6
6
  * runner (`INDEPENDENT_GATE_NAMES` / `partitionGates`).
7
7
  */
8
8
 
9
+ import { hasNpmScript, readPackageScripts } from '../npm-scripts.js';
9
10
  import {
10
11
  buildFormatHint,
11
12
  FORMAT_CHECK_FALLBACK,
@@ -87,17 +88,30 @@ function isCrapGateEnabled(config) {
87
88
  }
88
89
 
89
90
  /**
90
- * Conditionally produce the standalone `test` gate entry. Returns an empty
91
- * array when the CRAP gate is enabled (Story #1798: coverage-capture is the
92
- * canonical test runner in that mode); returns the legacy single-entry
93
- * gate otherwise. Splitting this out keeps `buildDefaultGates` flat for
94
- * the CRAP-cyclomatic gate.
91
+ * The gates run in the Story worktree, whose `package.json` is the committed
92
+ * one the consumer ships the presence of a `test:coverage` script is a
93
+ * committed fact, so probing at the gate cwd is authoritative. See
94
+ * `lib/npm-scripts.js` for the shared reader.
95
+ */
96
+
97
+ /**
98
+ * Conditionally produce the standalone `test` gate entry.
95
99
  *
96
- * @param {object|undefined|null} config - Canonical resolved config.
100
+ * The plain `test` gate is the canonical test runner UNLESS the
101
+ * coverage-capture gate is taking that role — which happens only when the
102
+ * CRAP gate is enabled (Story #1798) AND the consumer actually ships a
103
+ * `test:coverage` script for coverage-capture to run (#4473). When CRAP is
104
+ * enabled but `test:coverage` is absent, coverage-capture is dropped from
105
+ * the gate list, so the `test` gate MUST come back — otherwise the consumer
106
+ * has NO working test gate at all. Splitting this out keeps
107
+ * `buildDefaultGates` flat for the CRAP-cyclomatic gate.
108
+ *
109
+ * @param {boolean} coverageCaptureActive - Whether the coverage-capture gate
110
+ * is registered as the test runner for this build.
97
111
  * @returns {Gate[]}
98
112
  */
99
- function buildTestGateEntry(config) {
100
- if (isCrapGateEnabled(config)) return [];
113
+ function buildTestGateEntry(coverageCaptureActive) {
114
+ if (coverageCaptureActive) return [];
101
115
  return [{ name: 'test', cmd: 'npm', args: ['test'] }];
102
116
  }
103
117
 
@@ -105,10 +119,13 @@ function buildTestGateEntry(config) {
105
119
  * Build the canonical close-validation gate list.
106
120
  *
107
121
  * Ordering (cheapest fast-fail first): typecheck → lint → [test] →
108
- * format → coverage-capture → check-baselines. The standalone `test`
109
- * gate is dropped when `crap.enabled === true` (Story #1798) because
110
- * coverage-capture carries test-failure signalling under c8 in that
111
- * mode.
122
+ * format → [coverage-capture] → check-baselines. The standalone `test`
123
+ * gate is dropped when coverage-capture is the active test runner — i.e.
124
+ * `crap.enabled === true` (Story #1798) AND a `test:coverage` script
125
+ * exists (Story #4473) — because coverage-capture then carries
126
+ * test-failure signalling under c8. When CRAP is on but `test:coverage` is
127
+ * absent, coverage-capture is dropped and the `test` gate is restored so
128
+ * there is always a working test gate.
112
129
  *
113
130
  * `typecheck` is mandatory; consumers may customise the command via
114
131
  * `project.commands.typecheck` (default `npm run typecheck`).
@@ -127,15 +144,34 @@ function buildTestGateEntry(config) {
127
144
  * re-discovered inherited main-vs-epic drift in untouched files as phantom
128
145
  * regressions and worked around it by hand-setting `BASELINE_REF`.
129
146
  *
130
- * @param {{ config?: object, epicBranch?: string }} [opts] - `config` is the
131
- * canonical resolved config (`{ project, delivery, ... }`); gate commands
132
- * resolve from `project.commands` and the CRAP toggle from
147
+ * Story #4473 the coverage-capture gate spawns `npm run test:coverage`,
148
+ * so it is registered ONLY when the consumer actually ships that script.
149
+ * When CRAP is enabled but `test:coverage` is absent, coverage-capture is
150
+ * dropped and the plain `test` gate is restored (see `buildTestGateEntry`),
151
+ * so a consumer without a coverage script gets a working degraded test gate
152
+ * instead of a deterministic close failure with no test gate at all. The
153
+ * probe reads `package.json` at `cwd` (the gate execution directory).
154
+ *
155
+ * @param {{ config?: object, epicBranch?: string, cwd?: string, packageScripts?: Record<string, string> }} [opts]
156
+ * `config` is the canonical resolved config (`{ project, delivery, ... }`);
157
+ * gate commands resolve from `project.commands` and the CRAP toggle from
133
158
  * `delivery.quality.gates.crap.enabled`. `epicBranch` is the close run's
134
159
  * integration branch (`epic/<id>` for Epic-attached Stories, the base
135
- * branch for standalone Stories).
160
+ * branch for standalone Stories). `cwd` is where the `package.json`
161
+ * coverage-script probe reads from (defaults to `process.cwd()`);
162
+ * `packageScripts` injects the scripts map directly (tests) and short-
163
+ * circuits the disk read.
136
164
  * @returns {Gate[]}
137
165
  */
138
- export function buildDefaultGates({ config, epicBranch } = {}) {
166
+ export function buildDefaultGates({
167
+ config,
168
+ epicBranch,
169
+ cwd,
170
+ packageScripts,
171
+ } = {}) {
172
+ const scripts = packageScripts ?? readPackageScripts(cwd);
173
+ const coverageCaptureActive =
174
+ isCrapGateEnabled(config) && hasNpmScript(scripts, 'test:coverage');
139
175
  const typecheckCmdString = resolveTypecheckCommand(config);
140
176
  const [typecheckCmd, ...typecheckArgs] = typecheckCmdString
141
177
  .split(/\s+/)
@@ -158,7 +194,7 @@ export function buildDefaultGates({ config, epicBranch } = {}) {
158
194
  hint: TYPECHECK_HINT,
159
195
  },
160
196
  { name: 'lint', cmd: 'npm', args: ['run', 'lint'] },
161
- ...buildTestGateEntry(config),
197
+ ...buildTestGateEntry(coverageCaptureActive),
162
198
  {
163
199
  // Gate name kept generic ("format") so the close-orchestrator log line
164
200
  // and the per-gate phase-timer key don't shift when a repo swaps biome
@@ -172,12 +208,16 @@ export function buildDefaultGates({ config, epicBranch } = {}) {
172
208
  ? { changedFileScope: formatChangedFileScope }
173
209
  : {}),
174
210
  },
175
- {
176
- name: 'coverage-capture',
177
- cmd: 'node',
178
- args: ['.agents/scripts/coverage-capture.js'],
179
- hint: 'Coverage capture failed — `npm run test:coverage` exited non-zero. Fix failing tests or coverage-threshold breaches, then re-run close.',
180
- },
211
+ ...(coverageCaptureActive
212
+ ? [
213
+ {
214
+ name: 'coverage-capture',
215
+ cmd: 'node',
216
+ args: ['.agents/scripts/coverage-capture.js'],
217
+ hint: 'Coverage capture failed — `npm run test:coverage` exited non-zero. Fix failing tests or coverage-threshold breaches, then re-run close.',
218
+ },
219
+ ]
220
+ : []),
181
221
  {
182
222
  // Story #2210 — unified `check-baselines` gate is the only path for
183
223
  // per-kind regression enforcement. The legacy per-kind in-process
@@ -12,12 +12,19 @@
12
12
  * waves run; `watch` tunes the merge/CI watch poll loop; and `autoMerge`
13
13
  * (default `"trust-ci"`) selects the merge posture — `"trust-ci"` merges once
14
14
  * required checks pass, `"strict"` additionally requires a clean review gate.
15
+ *
16
+ * Story #4472 adds `requireChecks` (default `false`): when `true` the
17
+ * AutomergePredicate treats a checks-less repo ("no checks reported") as a
18
+ * hard block rather than green, so a consumer that wants fail-closed-without-
19
+ * checks as policy opts into it explicitly instead of the framework blocking
20
+ * implicitly.
15
21
  */
16
22
 
17
23
  export const CI_DELIVERY_DEFAULTS = Object.freeze({
18
24
  skipForStoryPushes: true,
19
25
  earlyPr: true,
20
26
  autoMerge: 'trust-ci',
27
+ requireChecks: false,
21
28
  });
22
29
 
23
30
  /**
@@ -28,7 +35,7 @@ export const CI_DELIVERY_DEFAULTS = Object.freeze({
28
35
  * defaults; only the scalar knobs carry framework defaults here.
29
36
  *
30
37
  * @param {object | null | undefined} config
31
- * @returns {{ skipForStoryPushes: boolean, earlyPr: boolean, autoMerge: 'trust-ci' | 'strict', watch: object | undefined }}
38
+ * @returns {{ skipForStoryPushes: boolean, earlyPr: boolean, autoMerge: 'trust-ci' | 'strict', requireChecks: boolean, watch: object | undefined }}
32
39
  */
33
40
  export function getCiDelivery(config) {
34
41
  const ci = config?.delivery?.ci ?? config?.ci ?? config ?? {};
@@ -45,6 +52,10 @@ export function getCiDelivery(config) {
45
52
  ci.autoMerge === 'trust-ci' || ci.autoMerge === 'strict'
46
53
  ? ci.autoMerge
47
54
  : CI_DELIVERY_DEFAULTS.autoMerge,
55
+ requireChecks:
56
+ typeof ci.requireChecks === 'boolean'
57
+ ? ci.requireChecks
58
+ : CI_DELIVERY_DEFAULTS.requireChecks,
48
59
  watch:
49
60
  ci.watch && typeof ci.watch === 'object' ? { ...ci.watch } : undefined,
50
61
  };
@@ -264,6 +264,13 @@ const CI_DELIVERY_SCHEMA = {
264
264
  earlyPr: { type: 'boolean' },
265
265
  watch: CI_WATCH_SCHEMA,
266
266
  autoMerge: { type: 'string', enum: ['trust-ci', 'strict'] },
267
+ // Story #4472 — fail-closed-without-checks policy. When `true`, the
268
+ // AutomergePredicate refuses to arm merge in a repo that reports zero
269
+ // required checks ("no checks reported"), treating the absence of a CI
270
+ // gate as a hard block instead of green. Defaults to `false` so a
271
+ // checks-less repo with green close-validation gates lands headlessly
272
+ // rather than parking on the operator-merges path.
273
+ requireChecks: { type: 'boolean' },
267
274
  },
268
275
  additionalProperties: false,
269
276
  };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * npm-scripts.js — shared `package.json` scripts probe (Story #4473).
3
+ *
4
+ * A single, dependency-free reader used wherever the framework must decide
5
+ * whether a consumer actually ships a given npm script before spawning
6
+ * `npm run <name>`. Two call sites depend on it:
7
+ * - `close-validation/gates.js` — only registers the coverage-capture gate
8
+ * when a `test:coverage` script exists (otherwise a consumer without it
9
+ * turns the gate into a guaranteed first-try close failure).
10
+ * - `coverage-capture.js` — fails fast with a one-line, fix-naming
11
+ * diagnostic instead of surfacing npm's opaque "Missing script" exit when
12
+ * invoked without the script.
13
+ *
14
+ * The reader is deliberately forgiving: any failure (missing file,
15
+ * unreadable, unparseable, or no `scripts` object) resolves to an empty map
16
+ * so callers treat "cannot prove the script exists" as "absent" without
17
+ * throwing.
18
+ */
19
+
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import path from 'node:path';
22
+
23
+ /**
24
+ * Read the `scripts` map from the `package.json` at `cwd`.
25
+ *
26
+ * @param {string|undefined|null} cwd - Directory containing `package.json`.
27
+ * Defaults to `process.cwd()`.
28
+ * @returns {Record<string, string>} The scripts map, or `{}` on any failure.
29
+ */
30
+ export function readPackageScripts(cwd) {
31
+ try {
32
+ const pkgPath = path.join(cwd || process.cwd(), 'package.json');
33
+ if (!existsSync(pkgPath)) return {};
34
+ const parsed = JSON.parse(readFileSync(pkgPath, 'utf8'));
35
+ return parsed && typeof parsed.scripts === 'object' && parsed.scripts
36
+ ? parsed.scripts
37
+ : {};
38
+ } catch {
39
+ return {};
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Does the consumer define a runnable npm script by this name? A script is
45
+ * "runnable" when it is a present, non-empty string.
46
+ *
47
+ * @param {Record<string, string>} scripts - A scripts map (from
48
+ * `readPackageScripts`).
49
+ * @param {string} name - The script name to check (e.g. `test:coverage`).
50
+ * @returns {boolean}
51
+ */
52
+ export function hasNpmScript(scripts, name) {
53
+ const s = scripts?.[name];
54
+ return typeof s === 'string' && s.trim().length > 0;
55
+ }
@@ -48,9 +48,13 @@
48
48
  *
49
49
  * The schema declares `additionalProperties: false`, so this emitter's
50
50
  * signature is deliberately narrow: only the schema-allowed fields are
51
- * accepted. `blockClass` MUST be one of the four classes named in
52
- * `merge-block-class.js` — pass the classifier's verdict straight
53
- * through (`classifyMergeBlock(...)` returns `{ blockClass, reason }`).
51
+ * accepted. `blockClass` MUST be a valid `merge.unlanded` attribution from
52
+ * `merge-block-class.js` (`MERGE_UNLANDED_BLOCK_CLASSES` — the four
53
+ * `classifyMergeBlock` outputs plus the directly-emitted `predicate-refused`,
54
+ * Story #4472). For a post-arm poll-exhaustion block, pass the classifier's
55
+ * verdict straight through (`classifyMergeBlock(...)` returns
56
+ * `{ blockClass, reason }`); the predicate/armer refusal paths pass
57
+ * `predicate-refused` / a classified arm failure directly.
54
58
  */
55
59
 
56
60
  import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
@@ -98,8 +102,9 @@ function getValidator() {
98
102
  * @param {number} opts.ticketId epicId when `scope === 'epic'`,
99
103
  * storyId when `scope === 'story'`.
100
104
  * @param {number} opts.prNumber The PR number that did not land.
101
- * @param {string} opts.blockClass One of the four
102
- * `merge-block-class.js` classes.
105
+ * @param {string} opts.blockClass A valid `merge.unlanded` attribution
106
+ * (`MERGE_UNLANDED_BLOCK_CLASSES` in
107
+ * `merge-block-class.js`).
103
108
  * @param {string} opts.reason Free-form diagnosis detail — pass
104
109
  * the classifier's `reason`.
105
110
  * @param {number} opts.elapsedSeconds Elapsed watch/poll time when the
@@ -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() {
@@ -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);
@@ -125,7 +125,11 @@ export async function runCloseValidationPhase({
125
125
  const validation = await runCloseValidation({
126
126
  cwd,
127
127
  worktreePath,
128
- gates: buildDefaultGates({ config, epicBranch: baseBranch }),
128
+ gates: buildDefaultGates({
129
+ config,
130
+ epicBranch: baseBranch,
131
+ cwd: worktreePath || cwd,
132
+ }),
129
133
  log: (m) => Logger.info(m),
130
134
  storyId,
131
135
  // Story #4250 — standalone storyId-anchored evidence keyspace. No
@@ -133,7 +133,14 @@ export async function runPreMergeGates({
133
133
  // `buildDefaultGates` reads the canonical resolved config directly:
134
134
  // gate commands resolve from `project.commands` and the CRAP toggle
135
135
  // from `delivery.quality.gates.crap.enabled`.
136
- const gates = buildDefaultGates({ config, epicBranch });
136
+ // Probe the coverage script from the gate execution directory (the Story
137
+ // worktree when present) so coverage-capture is only registered when the
138
+ // consumer ships `test:coverage` (#4473).
139
+ const gates = buildDefaultGates({
140
+ config,
141
+ epicBranch,
142
+ cwd: worktreePath || cwd,
143
+ });
137
144
  const gateCount = Array.isArray(gates) ? gates.length : 0;
138
145
  // Story #2250 — emit `close-validate.start` only when both an epicId
139
146
  // and a storyId are present; the schema requires both, and unit
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.90.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.89.0...mandrel-v1.90.0) (2026-07-12)
6
+
7
+
8
+ ### Fixed
9
+
10
+ * **delivery:** land headless PRs in checks-less repos + degrade coverage gate without test:coverage ([#4480](https://github.com/dsj1984/mandrel/issues/4480)) ([9836f1e](https://github.com/dsj1984/mandrel/commit/9836f1ec9beb4b2798bd72e869214e5705296522)), closes [#4472](https://github.com/dsj1984/mandrel/issues/4472) [#4473](https://github.com/dsj1984/mandrel/issues/4473)
11
+
5
12
  ## [1.89.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.88.0...mandrel-v1.89.0) (2026-07-11)
6
13
 
7
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "1.89.0",
3
+ "version": "1.90.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, personas, skills, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",