mandrel 1.84.0 → 1.85.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 (33) hide show
  1. package/.agents/docs/agentrc-reference.json +8 -2
  2. package/.agents/docs/configuration.md +7 -2
  3. package/.agents/instructions.md +4 -0
  4. package/.agents/rules/ci-remediation.md +131 -0
  5. package/.agents/schemas/agentrc.schema.json +29 -6
  6. package/.agents/schemas/lifecycle/epic.watch.end.schema.json +2 -1
  7. package/.agents/scripts/git-pr-quality-gate.js +7 -5
  8. package/.agents/scripts/lib/config/ci.js +24 -3
  9. package/.agents/scripts/lib/config/explain.js +11 -3
  10. package/.agents/scripts/lib/config/github.js +11 -7
  11. package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
  12. package/.agents/scripts/lib/config-settings-schema.js +6 -6
  13. package/.agents/scripts/lib/orchestration/finalize/open-or-locate-pr.js +65 -0
  14. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +401 -84
  15. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +48 -3
  16. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +6 -1
  17. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +172 -58
  18. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +19 -0
  19. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -0
  20. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +17 -16
  21. package/.agents/scripts/lib/templates/decomposer-prompts.js +17 -3
  22. package/.agents/scripts/pr-watch-with-update.js +324 -37
  23. package/.agents/scripts/run-verify.js +18 -3
  24. package/.agents/scripts/single-story-confirm-merge.js +1 -1
  25. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +32 -1
  26. package/.agents/skills/core/scope-triage/SKILL.md +5 -4
  27. package/.agents/workflows/helpers/deliver-epic-reference.md +22 -8
  28. package/.agents/workflows/helpers/deliver-epic.md +123 -28
  29. package/.agents/workflows/helpers/deliver-stories.md +2 -2
  30. package/.agents/workflows/helpers/single-story-deliver-reference.md +3 -3
  31. package/.agents/workflows/helpers/single-story-deliver.md +56 -19
  32. package/docs/CHANGELOG.md +9 -0
  33. package/package.json +1 -1
@@ -4,27 +4,41 @@
4
4
  * PR is safe to auto-merge after the required-check watch settles.
5
5
  * Story #2256 / Task #2260 (Epic #2172); inlined from the now-deleted
6
6
  * legacy `automerge-predicate` module in Story #2415 (Epic #2307).
7
+ * Rewritten in Story #4361 (Epic #4355) so green required CI is the
8
+ * arming signal, gated by the `delivery.ci.autoMerge` policy.
7
9
  *
8
10
  * Subscribes to:
9
11
  * - `epic.automerge.start` (production path, Story #3901) → the
10
12
  * `/deliver` Phase 8.5 boundary that the `lifecycle-emit.js`
11
13
  * CLI actually fires. This event carries `prUrl` but NO
12
- * `checkOutcomes` (Phase 8's `pr-watch-with-update.js` has already
13
- * polled every required check to green before Phase 8.5 runs), so
14
- * the CI-freshness gate is skipped and the verdict is decided by
15
- * the structured-signal evaluator alone. Before Story #3901 the
16
- * listener subscribed ONLY to `epic.watch.end`, which no production
17
- * emitter fires making the entire Phase 8.5 auto-merge gate a
18
- * dead wire (epic-lifecycle-review.md §1.1).
14
+ * `checkOutcomes`. Before Story #4361 the listener trusted Phase 8
15
+ * (`pr-watch-with-update.js`) to have polled every required check to
16
+ * green and therefore skipped any CI probe on this path. That left a
17
+ * hole (Story #3901): if the Phase 8 watch was interrupted (host
18
+ * crash, `/loop` handoff, operator resume) the predicate could arm
19
+ * merge with red or pending required checks. Story #4361 closes it —
20
+ * the listener now runs a LIVE `gh pr checks --required` probe on
21
+ * `epic.automerge.start` and refuses to arm when any required check
22
+ * is not green, regardless of what Phase 8 believed.
19
23
  * - `epic.watch.end` (test-only `Watcher` path) → carries an
20
24
  * all-settled `checkOutcomes` map. Any non-passing required check
21
25
  * is a hard block evaluated BEFORE the structured-signal evaluator.
26
+ * On this path the pre-supplied `checkOutcomes` map IS the CI truth,
27
+ * so no live probe is issued (the map is authoritative).
22
28
  *
23
- * Either trigger evaluates the same verdict: if `evaluateAutoMergePredicate`
24
- * reports `clean: true` (no manual interventions, every Story done,
25
- * no story blockers, no critical/high review findings, machine-readable
26
- * "clean sprint" retro trailer), emit `epic.merge.ready`. Otherwise emit
27
- * `epic.merge.blocked` with a non-empty reason.
29
+ * Policy (`delivery.ci.autoMerge`, Story #4356 / #4361):
30
+ * - `"trust-ci"` (framework default) green required CI is the arming
31
+ * signal. The ONLY structured conditions that block arming are an
32
+ * unresolved 🔴 critical (red) code-review finding or an
33
+ * `agent::blocked` state (a story-level blocker recorded in
34
+ * run-state, or a missing run-state checkpoint we cannot certify).
35
+ * Manual interventions, 🟠 warning-level findings, and a non-clean
36
+ * retro are RECORDED for audit (surfaced on the verdict and the
37
+ * classification log) but no longer block the merge.
38
+ * - `"strict"` — restores the prior clean-sprint predicate EXACTLY:
39
+ * zero manual interventions, every Story done, no story blockers,
40
+ * no 🔴/🟠 review findings, and a machine-readable `cleanSprint`
41
+ * retro trailer. Any dirty signal blocks.
28
42
  *
29
43
  * Code-review parse-miss policy (Story #4222): a code-review comment that is
30
44
  * present but whose severity bullets cannot be parsed is treated as a DISTINCT
@@ -32,22 +46,8 @@
32
46
  * rather than blocking. Failing closed on a format miss is indistinguishable,
33
47
  * to the operator and to downstream telemetry, from a real disqualifying
34
48
  * finding; a parser miss must never masquerade as "the signal said no" inside
35
- * a generic `epic.merge.blocked`. Genuine critical/high findings still block,
36
- * because those require the counts to have parsed.
37
- *
38
- * Critical contract:
39
- * - The verdict for any given input set is byte-identical to the
40
- * pre-inlining legacy module's output — this file is its
41
- * replacement. The merge-gate-ordering invariant
42
- * (`epic.merge.armed` preceded by `epic.merge.ready`) depends on
43
- * this listener being the sole emitter of `epic.merge.ready`.
44
- *
45
- * - Required-check outcomes from `epic.watch.end` are a NEW input
46
- * not present in the legacy verdict: any check that is not
47
- * `'success'`, `'neutral'`, or `'skipped'` flips the verdict to
48
- * `blocked` BEFORE the legacy evaluator is even consulted (because
49
- * a red CI check is a hard block regardless of the structured
50
- * signals).
49
+ * a generic `epic.merge.blocked`. Genuine critical/high findings still block
50
+ * (per policy), because those require the counts to have parsed.
51
51
  *
52
52
  * Idempotency contract (AC-10): per-instance `Set<string>` of
53
53
  * `${event}:${seqId}` keys. A repeat `(event, seqId)` short-circuits
@@ -55,15 +55,21 @@
55
55
  * on GitHub state, so re-running it is safe; the seqId guard is the
56
56
  * defence against double-emit.
57
57
  *
58
- * Side-effect firewall: the listener calls the read-only evaluator and
59
- * emits on the bus. It does NOT mutate labels, post comments, or call
60
- * `notify`. Downstream consumers (`AutomergeArmer` on
61
- * `epic.merge.ready`; LabelTransitioner / StructuredCommentPoster on
62
- * `epic.merge.blocked`) own those side effects.
58
+ * Side-effect firewall: the listener calls the read-only evaluator, runs
59
+ * the read-only `gh pr checks` probe, and emits on the bus. It does NOT
60
+ * mutate labels, post comments, or call `notify`. Downstream consumers
61
+ * (`AutomergeArmer` on `epic.merge.ready`; LabelTransitioner /
62
+ * StructuredCommentPoster on `epic.merge.blocked`) own those side
63
+ * effects. AutomergeArmer remains the SOLE site that shells `gh pr merge`
64
+ * (the merge-lockout lint in `check-lifecycle-lint.js` enforces this).
63
65
  */
64
66
 
67
+ import { spawnSync } from 'node:child_process';
68
+
69
+ import { getCiDelivery } from '../../../config/ci.js';
65
70
  import * as epicRunStateStore from '../../epic-run-state-store.js';
66
71
  import { findStructuredComment } from '../../ticketing.js';
72
+ import { normalizeCheckState, RECOGNIZED_CHECK_STATES } from './watcher.js';
67
73
 
68
74
  /**
69
75
  * Outcomes that count as "this required check did not block the merge".
@@ -76,6 +82,41 @@ export const NON_FAILING_CHECK_OUTCOMES = Object.freeze(
76
82
  new Set(['success', 'neutral', 'skipped']),
77
83
  );
78
84
 
85
+ /**
86
+ * Reason categories emitted by the structured-signal evaluator. The
87
+ * policy layer (`applyAutoMergePolicy`) decides which categories block
88
+ * arming under each `delivery.ci.autoMerge` posture. Pure constant —
89
+ * exported so the taxonomy is reviewable as code.
90
+ *
91
+ * - `criticalReview` — an unresolved 🔴 critical (red) code-review
92
+ * finding. Blocks under BOTH policies.
93
+ * - `blockedState` — an `agent::blocked` state (story-level blocker
94
+ * recorded in run-state, non-done stories, or a missing run-state
95
+ * checkpoint). Blocks under BOTH policies.
96
+ * - `warningReview` — a 🟠 warning-level (high-risk) code-review
97
+ * finding. Blocks under `strict` only.
98
+ * - `intervention` — a recorded manual intervention. Blocks under
99
+ * `strict` only.
100
+ * - `retro` — a non-clean / missing retro verdict trailer.
101
+ * Blocks under `strict` only.
102
+ */
103
+ export const REASON_CATEGORY = Object.freeze({
104
+ CRITICAL_REVIEW: 'criticalReview',
105
+ BLOCKED_STATE: 'blockedState',
106
+ WARNING_REVIEW: 'warningReview',
107
+ INTERVENTION: 'intervention',
108
+ RETRO: 'retro',
109
+ });
110
+
111
+ /**
112
+ * Categories that block arming under the default `"trust-ci"` policy.
113
+ * Everything else is recorded for audit but does not block. Pure —
114
+ * exported for tests.
115
+ */
116
+ export const TRUST_CI_BLOCKING_CATEGORIES = Object.freeze(
117
+ new Set([REASON_CATEGORY.CRITICAL_REVIEW, REASON_CATEGORY.BLOCKED_STATE]),
118
+ );
119
+
79
120
  /**
80
121
  * Regex that extracts the machine-readable auto-merge verdict trailer
81
122
  * emitted by the retro body composer (`retro/phases/compose-body.js`,
@@ -140,6 +181,111 @@ export function formatCheckFailureReason(failures) {
140
181
  return `required checks not green: ${parts.join(', ')}${suffix}`;
141
182
  }
142
183
 
184
+ /**
185
+ * Default live `gh pr checks --required` probe. Mirrors the Watcher's
186
+ * spawn shape (`--json name,state,bucket,workflow`) so the required-set
187
+ * projection is identical. Pure-spawn helper — exported so tests can
188
+ * stub the shell-out. Returns the raw spawn envelope; the caller
189
+ * classifies the payload.
190
+ *
191
+ * @param {{ prUrl: string, cwd?: string, spawnFn?: typeof spawnSync }} opts
192
+ * @returns {{ status: number, stdout: string, stderr: string }}
193
+ */
194
+ export function probeRequiredChecks({ prUrl, cwd, spawnFn = spawnSync }) {
195
+ const result = spawnFn(
196
+ 'gh',
197
+ ['pr', 'checks', prUrl, '--required', '--json', 'name,state,bucket'],
198
+ { cwd, encoding: 'utf-8', shell: false },
199
+ );
200
+ return {
201
+ status: result.status ?? 1,
202
+ stdout: result.stdout ?? '',
203
+ stderr: result.stderr ?? '',
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Classify a live `gh pr checks --required` probe envelope into a
209
+ * `{ ok, reason, outcomes }` verdict. Pure — exported for tests.
210
+ *
211
+ * - `ok: true` → every required check is green (non-failing) AND the
212
+ * probe returned a parseable, non-empty required-check set.
213
+ * - `ok: false` → at least one required check is not green, the probe
214
+ * shelled out non-zero, or the payload could not be parsed. A probe
215
+ * we cannot read is a hard block: we NEVER arm merge on an
216
+ * unreadable CI signal (fail closed on the CI gate specifically —
217
+ * the opposite of the code-review parse-miss policy, because CI
218
+ * greenness is the whole point of the trust-ci arming signal).
219
+ *
220
+ * `gh pr checks --required` exits non-zero (status 8) when a required
221
+ * check has failed OR is still pending, so a non-zero status is treated
222
+ * as "not green" only when we cannot otherwise read a clean set from
223
+ * stdout. We parse stdout first (it is populated even on the non-zero
224
+ * exit) and classify from the outcomes.
225
+ *
226
+ * @param {{ status: number, stdout: string, stderr: string }} probe
227
+ * @returns {{ ok: boolean, reason: string|null, outcomes: Record<string, string> }}
228
+ */
229
+ export function classifyRequiredChecksProbe(probe) {
230
+ const stdout = String(probe?.stdout ?? '').trim();
231
+ // Empty stdout with a non-zero status → probe genuinely failed (auth,
232
+ // network, no PR). Fail closed.
233
+ if (stdout.length === 0) {
234
+ return {
235
+ ok: false,
236
+ reason:
237
+ `live required-check probe failed (status=${probe?.status ?? 'unknown'})` +
238
+ (probe?.stderr ? `: ${String(probe.stderr).trim().slice(0, 200)}` : ''),
239
+ outcomes: {},
240
+ };
241
+ }
242
+ let entries;
243
+ try {
244
+ const parsed = JSON.parse(stdout);
245
+ entries = Array.isArray(parsed) ? parsed : null;
246
+ } catch {
247
+ entries = null;
248
+ }
249
+ if (!entries) {
250
+ return {
251
+ ok: false,
252
+ reason: 'live required-check probe returned an unparseable payload',
253
+ outcomes: {},
254
+ };
255
+ }
256
+ const outcomes = {};
257
+ for (const e of entries) {
258
+ if (e && typeof e === 'object' && typeof e.name === 'string') {
259
+ const raw = String(e.state || e.bucket || '')
260
+ .trim()
261
+ .toLowerCase();
262
+ // Fail closed on the arming probe: a token we have not enumerated
263
+ // could be a genuinely-failing GitHub conclusion. normalizeCheckState
264
+ // collapses unknowns to 'skipped' (safe for the watch path, unsafe
265
+ // here), so map an unrecognized token to 'unknown' — which is not in
266
+ // NON_FAILING_CHECK_OUTCOMES and therefore blocks arming.
267
+ outcomes[e.name] = RECOGNIZED_CHECK_STATES.has(raw)
268
+ ? normalizeCheckState(raw)
269
+ : 'unknown';
270
+ }
271
+ }
272
+ // An empty required set (no required checks configured) is treated as
273
+ // green — there is nothing to gate on. This matches branch protection
274
+ // that requires no status checks.
275
+ const failing = [];
276
+ for (const [name, outcome] of Object.entries(outcomes)) {
277
+ // `pending` is NOT in NON_FAILING — a still-running required check
278
+ // blocks arming (the Phase 8 watch was interrupted before green).
279
+ if (!NON_FAILING_CHECK_OUTCOMES.has(outcome)) {
280
+ failing.push({ name, outcome });
281
+ }
282
+ }
283
+ if (failing.length > 0) {
284
+ return { ok: false, reason: formatCheckFailureReason(failing), outcomes };
285
+ }
286
+ return { ok: true, reason: null, outcomes };
287
+ }
288
+
143
289
  /**
144
290
  * Regex-parse the rendered severity bullets on the code-review markdown
145
291
  * body. Pure. Exported for tests.
@@ -165,16 +311,33 @@ export function parseSeverityCounts(body) {
165
311
  };
166
312
  }
167
313
 
314
+ /**
315
+ * Push a categorized reason onto the reasons array. Each reason carries
316
+ * a `category` (from `REASON_CATEGORY`) so the policy layer can decide
317
+ * whether it blocks under the active posture, plus a human-facing
318
+ * `message`. Pure helper.
319
+ */
320
+ function pushReason(reasons, category, message) {
321
+ reasons.push({ category, message });
322
+ }
323
+
168
324
  function evaluateStateSignals(state, reasons) {
169
325
  const interventionCount = Array.isArray(state?.manualInterventions)
170
326
  ? state.manualInterventions.length
171
327
  : 0;
172
328
  if (!state) {
173
- reasons.push(
329
+ // A missing checkpoint means we cannot certify the run at all — this
330
+ // is a blocked-state condition (we do not know whether a Story is
331
+ // blocked), so it blocks under BOTH policies.
332
+ pushReason(
333
+ reasons,
334
+ REASON_CATEGORY.BLOCKED_STATE,
174
335
  'epic-run-state checkpoint missing — cannot certify clean run',
175
336
  );
176
337
  } else if (interventionCount > 0) {
177
- reasons.push(
338
+ pushReason(
339
+ reasons,
340
+ REASON_CATEGORY.INTERVENTION,
178
341
  `manual interventions recorded (${interventionCount}): ${state.manualInterventions
179
342
  .map((i) => i.reason)
180
343
  .slice(0, 3)
@@ -193,13 +356,20 @@ function evaluateStateSignals(state, reasons) {
193
356
  );
194
357
  const nonDoneStatuses = storyStatuses.filter((s) => s !== 'done');
195
358
  if (nonDoneStatuses.length > 0) {
196
- reasons.push(
359
+ // A story not done is an unfinished / blocked run — blocks under both.
360
+ pushReason(
361
+ reasons,
362
+ REASON_CATEGORY.BLOCKED_STATE,
197
363
  `${nonDoneStatuses.length} story(ies) not done (statuses: ${nonDoneStatuses.join(', ')})`,
198
364
  );
199
365
  }
200
366
  const storyBlockers = countStoryBlockers(stories);
201
367
  if (storyBlockers > 0) {
202
- reasons.push(
368
+ // A recorded story-level blocker is the `agent::blocked` state — it
369
+ // blocks under both policies.
370
+ pushReason(
371
+ reasons,
372
+ REASON_CATEGORY.BLOCKED_STATE,
203
373
  `${storyBlockers} story-level blocker(s) recorded in run-state`,
204
374
  );
205
375
  }
@@ -237,7 +407,15 @@ function evaluateCodeReviewSignals(codeReview, reasons) {
237
407
  ? parseSeverityCounts(codeReview.body)
238
408
  : { critical: null, high: null, medium: null, suggestion: null };
239
409
  if (!codeReviewFound) {
240
- reasons.push('code-review structured comment not found on Epic');
410
+ // A missing code-review comment is a soft signal — it does not, on
411
+ // its own, prove a critical finding. Categorize it as a warning-level
412
+ // reason so it blocks under `strict` (which demands the clean gate)
413
+ // but not under `trust-ci` (which only blocks on a PARSED 🔴 count).
414
+ pushReason(
415
+ reasons,
416
+ REASON_CATEGORY.WARNING_REVIEW,
417
+ 'code-review structured comment not found on Epic',
418
+ );
241
419
  return { codeReviewFound, codeReviewUnparseable: false, severity };
242
420
  }
243
421
  // "Present but unparseable" is a DISTINCT condition from "present and says
@@ -264,10 +442,18 @@ function evaluateCodeReviewSignals(codeReview, reasons) {
264
442
  return { codeReviewFound, codeReviewUnparseable, severity };
265
443
  }
266
444
  if (severity.critical > 0) {
267
- reasons.push(`code-review has ${severity.critical} 🔴 Critical Blocker(s)`);
445
+ pushReason(
446
+ reasons,
447
+ REASON_CATEGORY.CRITICAL_REVIEW,
448
+ `code-review has ${severity.critical} 🔴 Critical Blocker(s)`,
449
+ );
268
450
  }
269
451
  if (severity.high > 0) {
270
- reasons.push(`code-review has ${severity.high} 🟠 High Risk finding(s)`);
452
+ pushReason(
453
+ reasons,
454
+ REASON_CATEGORY.WARNING_REVIEW,
455
+ `code-review has ${severity.high} 🟠 High Risk finding(s)`,
456
+ );
271
457
  }
272
458
  return { codeReviewFound, codeReviewUnparseable, severity };
273
459
  }
@@ -275,23 +461,31 @@ function evaluateCodeReviewSignals(codeReview, reasons) {
275
461
  function evaluateRetroSignals(retro, reasons) {
276
462
  const retroFound = !!retro && typeof retro.body === 'string';
277
463
  if (!retroFound) {
278
- reasons.push('retro structured comment not found on Epic');
464
+ pushReason(
465
+ reasons,
466
+ REASON_CATEGORY.RETRO,
467
+ 'retro structured comment not found on Epic',
468
+ );
279
469
  return { retroFound, retroCompact: false };
280
470
  }
281
471
  // Read the machine-readable verdict trailer instead of string-matching
282
472
  // the human-facing "🟢 Clean sprint" prose (Story #3901). A missing or
283
- // malformed trailer is a hard disqualifier — we never arm auto-merge on
284
- // a retro whose verdict we cannot read.
473
+ // malformed trailer is a hard disqualifier under `strict` — we never arm
474
+ // strict-policy auto-merge on a retro whose verdict we cannot read.
285
475
  const verdict = parseAutomergeVerdictTrailer(retro.body);
286
476
  if (!verdict) {
287
- reasons.push(
477
+ pushReason(
478
+ reasons,
479
+ REASON_CATEGORY.RETRO,
288
480
  'retro is missing the machine-readable automerge-verdict trailer (cannot certify clean sprint)',
289
481
  );
290
482
  return { retroFound, retroCompact: false };
291
483
  }
292
484
  const retroCompact = verdict.cleanSprint === true;
293
485
  if (!retroCompact) {
294
- reasons.push(
486
+ pushReason(
487
+ reasons,
488
+ REASON_CATEGORY.RETRO,
295
489
  'retro automerge-verdict trailer reports cleanSprint=false (full retro indicates friction / parked / interventions)',
296
490
  );
297
491
  }
@@ -300,7 +494,13 @@ function evaluateRetroSignals(retro, reasons) {
300
494
 
301
495
  /**
302
496
  * Pure verdict-from-signals function. Composes the three signal sources into
303
- * a single `{ clean, reasons[] }` envelope. Exported for tests.
497
+ * a single envelope. The `clean` boolean is the strict-policy verdict (true
498
+ * iff there are zero reasons of any category) — it is preserved so the
499
+ * `strict` policy restores the prior predicate EXACTLY. `reasons` is the
500
+ * flat string[] of human-facing messages (byte-identical to the pre-#4361
501
+ * output for the same inputs); `categorizedReasons` is the same list tagged
502
+ * with a `REASON_CATEGORY` so the policy filter (`applyAutoMergePolicy`) can
503
+ * narrow which reasons block per posture. Exported for tests.
304
504
  *
305
505
  * @param {{
306
506
  * state: object|null,
@@ -310,6 +510,7 @@ function evaluateRetroSignals(retro, reasons) {
310
510
  * @returns {{
311
511
  * clean: boolean,
312
512
  * reasons: string[],
513
+ * categorizedReasons: Array<{ category: string, message: string }>,
313
514
  * signals: {
314
515
  * manualInterventions: number,
315
516
  * storyStatuses: string[],
@@ -324,14 +525,18 @@ function evaluateRetroSignals(retro, reasons) {
324
525
  * }}
325
526
  */
326
527
  export function deriveAutoMergeVerdict({ state, codeReview, retro }) {
327
- const reasons = [];
328
- const stateSig = evaluateStateSignals(state, reasons);
329
- const reviewSig = evaluateCodeReviewSignals(codeReview, reasons);
330
- const retroSig = evaluateRetroSignals(retro, reasons);
528
+ const categorizedReasons = [];
529
+ const stateSig = evaluateStateSignals(state, categorizedReasons);
530
+ const reviewSig = evaluateCodeReviewSignals(codeReview, categorizedReasons);
531
+ const retroSig = evaluateRetroSignals(retro, categorizedReasons);
331
532
 
332
533
  return {
333
- clean: reasons.length === 0,
334
- reasons,
534
+ // `clean` is the STRICT verdict: no reason of any category. This is the
535
+ // exact pre-#4361 predicate, preserved so `strict` policy is unchanged.
536
+ clean: categorizedReasons.length === 0,
537
+ // Flat string[] — byte-identical to the pre-#4361 reason messages.
538
+ reasons: categorizedReasons.map((r) => r.message),
539
+ categorizedReasons,
335
540
  signals: {
336
541
  manualInterventions: stateSig.interventionCount,
337
542
  storyStatuses: stateSig.storyStatuses,
@@ -346,6 +551,67 @@ export function deriveAutoMergeVerdict({ state, codeReview, retro }) {
346
551
  };
347
552
  }
348
553
 
554
+ /**
555
+ * Apply the `delivery.ci.autoMerge` policy to a categorized verdict.
556
+ * Returns the EFFECTIVE arming decision plus the split of blocking vs.
557
+ * recorded-only reasons. Pure — exported for tests.
558
+ *
559
+ * - `"strict"` → every reason blocks (identical to the pre-#4361
560
+ * `clean` verdict). `recordedReasons` is empty.
561
+ * - `"trust-ci"` → only `criticalReview` / `blockedState` reasons
562
+ * block; the rest (interventions, warnings, retro) land in
563
+ * `recordedReasons` for audit and do NOT gate the merge.
564
+ *
565
+ * @param {{ clean: boolean, categorizedReasons: Array<{ category: string, message: string }> }} verdict
566
+ * @param {'trust-ci'|'strict'} policy
567
+ * @returns {{
568
+ * arm: boolean,
569
+ * policy: 'trust-ci'|'strict',
570
+ * blockingReasons: Array<{ category: string, message: string }>,
571
+ * recordedReasons: Array<{ category: string, message: string }>,
572
+ * }}
573
+ */
574
+ export function applyAutoMergePolicy(verdict, policy) {
575
+ const reasons = Array.isArray(verdict?.categorizedReasons)
576
+ ? verdict.categorizedReasons
577
+ : [];
578
+ if (policy === 'strict') {
579
+ return {
580
+ arm: reasons.length === 0,
581
+ policy: 'strict',
582
+ blockingReasons: reasons,
583
+ recordedReasons: [],
584
+ };
585
+ }
586
+ // trust-ci (default): only critical-review and blocked-state reasons gate.
587
+ const blockingReasons = [];
588
+ const recordedReasons = [];
589
+ for (const r of reasons) {
590
+ if (TRUST_CI_BLOCKING_CATEGORIES.has(r.category)) {
591
+ blockingReasons.push(r);
592
+ } else {
593
+ recordedReasons.push(r);
594
+ }
595
+ }
596
+ return {
597
+ arm: blockingReasons.length === 0,
598
+ policy: 'trust-ci',
599
+ blockingReasons,
600
+ recordedReasons,
601
+ };
602
+ }
603
+
604
+ /**
605
+ * Join a categorized reason list into a single-line `reason` string for
606
+ * the `epic.merge.blocked` emit / classification log. Pure.
607
+ */
608
+ function formatReasons(reasons, prefix = '') {
609
+ const messages = reasons.map((r) => r.message);
610
+ const head = messages.slice(0, 3).join('; ');
611
+ const suffix = messages.length > 3 ? `; +${messages.length - 3} more` : '';
612
+ return `${prefix}${head}${suffix}`;
613
+ }
614
+
349
615
  /**
350
616
  * IO-bound entry. Loads all three signal sources from the structured-comment
351
617
  * surface on the Epic ticket and hands them to `deriveAutoMergeVerdict`.
@@ -360,7 +626,7 @@ export function deriveAutoMergeVerdict({ state, codeReview, retro }) {
360
626
  * findCommentFn?: typeof findStructuredComment,
361
627
  * readRunStateFn?: typeof epicRunStateStore.read,
362
628
  * }} opts
363
- * @returns {Promise<{ clean: boolean, reasons: string[], signals: object }>}
629
+ * @returns {Promise<{ clean: boolean, reasons: object[], signals: object }>}
364
630
  */
365
631
  export async function evaluateAutoMergePredicate({
366
632
  provider,
@@ -402,8 +668,15 @@ export class AutomergePredicate {
402
668
  * @param {object} opts.provider GitHub provider (passed through to the
403
669
  * evaluator). Required for the read of run-state + structured
404
670
  * comments.
671
+ * @param {object} [opts.config] Resolved agent config. Read for the
672
+ * `delivery.ci.autoMerge` policy via `getCiDelivery`. Defaults to the
673
+ * framework default (`trust-ci`) when omitted.
674
+ * @param {string} [opts.cwd] Working directory for the live
675
+ * `gh pr checks --required` probe. Defaults to `process.cwd()`.
405
676
  * @param {Function} [opts.evaluatePredicateFn] override of
406
677
  * `evaluateAutoMergePredicate` for tests.
678
+ * @param {Function} [opts.probeRequiredChecksFn] override of
679
+ * `probeRequiredChecks` for tests.
407
680
  * @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
408
681
  */
409
682
  constructor(opts = {}) {
@@ -425,16 +698,21 @@ export class AutomergePredicate {
425
698
  this.bus = opts.bus;
426
699
  this.epicId = opts.epicId;
427
700
  this.provider = opts.provider;
701
+ this.cwd = opts.cwd ?? process.cwd();
702
+ // Resolve the merge posture once at construction. `getCiDelivery`
703
+ // applies the framework default (`trust-ci`) for any omitted field.
704
+ this.policy = getCiDelivery(opts.config ?? null).autoMerge;
428
705
  this.evaluatePredicateFn =
429
706
  opts.evaluatePredicateFn ?? evaluateAutoMergePredicate;
707
+ this.probeRequiredChecksFn =
708
+ opts.probeRequiredChecksFn ?? probeRequiredChecks;
430
709
  this.logger = opts.logger ?? console;
431
710
  /** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
432
711
  this._seen = new Set();
433
712
  /**
434
- * Classification log — every `epic.watch.end` we observe lands here
435
- * with the outcome (`ready`, `blocked`, `skipped-duplicate`,
436
- * `failed`). Mirrors the Finalizer / Reconciler "no silent skip"
437
- * surface.
713
+ * Classification log — every event we observe lands here with the
714
+ * outcome (`ready`, `blocked`, `skipped-duplicate`, `failed`).
715
+ * Mirrors the Finalizer / Reconciler "no silent skip" surface.
438
716
  */
439
717
  this.classifications = [];
440
718
  this.events = Object.freeze(['epic.automerge.start', 'epic.watch.end']);
@@ -472,18 +750,19 @@ export class AutomergePredicate {
472
750
  });
473
751
  return;
474
752
  }
475
- // Gate 1 — required-check freshness. Any non-passing required
476
- // check is a hard block: short-circuit before consulting the
477
- // structured-signal evaluator so the operator sees the CI failure
478
- // as the reason, not a downstream signal.
753
+ // Gate 1 — required-check freshness. Any non-passing required check is
754
+ // a hard block: short-circuit before consulting the structured-signal
755
+ // evaluator so the operator sees the CI failure as the reason, not a
756
+ // downstream signal.
479
757
  //
480
- // `checkOutcomes` is only present on the `epic.watch.end` payload
481
- // (the test-only `Watcher` path). The production `epic.automerge.start`
482
- // payload omits it because Phase 8 (`pr-watch-with-update.js`) has
483
- // already polled every required check to green before Phase 8.5 fires
484
- // (Story #3901). When the map is absent, skip this gate entirely
485
- // rather than treating an empty map as "all green by vacuous truth" —
486
- // CI greenness is proven upstream, not re-derived here.
758
+ // Two sources of CI truth:
759
+ // (a) `epic.watch.end` (test-only Watcher path) carries a settled
760
+ // `checkOutcomes` map that map is authoritative, so we classify
761
+ // it directly and issue NO live probe.
762
+ // (b) `epic.automerge.start` (production Phase 8.5) carries no map.
763
+ // Story #4361: we run a LIVE `gh pr checks --required` probe here
764
+ // so an interrupted Phase 8 watch cannot arm merge on red/pending
765
+ // required checks (closes the Story #3901 hole).
487
766
  if (payload?.checkOutcomes !== undefined) {
488
767
  const failures = listFailingChecks(payload.checkOutcomes);
489
768
  if (failures.length > 0) {
@@ -492,14 +771,29 @@ export class AutomergePredicate {
492
771
  await this._emitBlocked(prUrl, reason);
493
772
  return;
494
773
  }
774
+ } else {
775
+ // Live probe — production path. Fail closed on any non-green,
776
+ // pending, or unreadable required-check result.
777
+ let probeVerdict;
778
+ try {
779
+ const probe = this.probeRequiredChecksFn({ prUrl, cwd: this.cwd });
780
+ probeVerdict = classifyRequiredChecksProbe(probe);
781
+ } catch (err) {
782
+ probeVerdict = {
783
+ ok: false,
784
+ reason: `live required-check probe threw: ${err?.message ?? err}`,
785
+ outcomes: {},
786
+ };
787
+ }
788
+ if (!probeVerdict.ok) {
789
+ const reason = probeVerdict.reason ?? 'required checks not green';
790
+ this.classifications.push({ event, seqId, outcome: 'blocked', reason });
791
+ await this._emitBlocked(prUrl, reason);
792
+ return;
793
+ }
495
794
  }
496
795
 
497
- // Gate 2 — structured-signal verdict. Wraps
498
- // `evaluateAutoMergePredicate` so the verdict for any given input
499
- // set is IDENTICAL to what `epic-deliver-automerge.js` would have
500
- // produced before Wave 7. The classification surface logs the
501
- // first three reasons so operators don't have to dig into the
502
- // legacy CLI output to understand a block.
796
+ // Gate 2 — structured-signal verdict, filtered by the merge policy.
503
797
  let verdict;
504
798
  try {
505
799
  verdict = await this.evaluatePredicateFn({
@@ -518,17 +812,32 @@ export class AutomergePredicate {
518
812
  return;
519
813
  }
520
814
 
521
- if (verdict?.clean) {
815
+ const decision = applyAutoMergePolicy(verdict, this.policy);
816
+ // Surface recorded-but-non-blocking reasons for audit even when the
817
+ // trust-ci policy arms anyway (interventions / warnings / non-clean
818
+ // retro). These never gate the merge but must not be silently dropped.
819
+ const recorded = decision.recordedReasons ?? [];
820
+
821
+ if (decision.arm) {
522
822
  this.classifications.push({
523
823
  event,
524
824
  seqId,
525
825
  outcome: 'ready',
826
+ policy: decision.policy,
526
827
  signals: verdict.signals,
828
+ ...(recorded.length > 0
829
+ ? { recordedReasons: recorded.map((r) => r.message) }
830
+ : {}),
527
831
  });
832
+ if (recorded.length > 0) {
833
+ this.logger.info?.(
834
+ `[AutomergePredicate] arming under ${decision.policy}; recorded (non-blocking): ${formatReasons(recorded)}`,
835
+ );
836
+ }
528
837
  try {
529
838
  await this.bus.emit('epic.merge.ready', {
530
839
  prUrl,
531
- reason: 'all required checks green; structured signals clean',
840
+ reason: `all required checks green; ${decision.policy} policy signals clear`,
532
841
  });
533
842
  } catch (err) {
534
843
  this.logger.warn?.(
@@ -538,20 +847,28 @@ export class AutomergePredicate {
538
847
  return;
539
848
  }
540
849
 
541
- const reasons = Array.isArray(verdict?.reasons) ? verdict.reasons : [];
850
+ const blocking = decision.blockingReasons ?? [];
542
851
  const reason =
543
- reasons.length > 0
544
- ? reasons.slice(0, 3).join('; ') +
545
- (reasons.length > 3 ? `; +${reasons.length - 3} more` : '')
852
+ blocking.length > 0
853
+ ? formatReasons(blocking)
546
854
  : 'predicate dirty (no reasons reported)';
547
- this.classifications.push({ event, seqId, outcome: 'blocked', reason });
855
+ this.classifications.push({
856
+ event,
857
+ seqId,
858
+ outcome: 'blocked',
859
+ policy: decision.policy,
860
+ reason,
861
+ ...(recorded.length > 0
862
+ ? { recordedReasons: recorded.map((r) => r.message) }
863
+ : {}),
864
+ });
548
865
  await this._emitBlocked(prUrl, reason);
549
866
  }
550
867
 
551
868
  /**
552
- * Emit `epic.merge.blocked`. Helper carved out so the three blocking
553
- * paths (CI failure / predicate dirty / evaluator throw) share the
554
- * same emit shape.
869
+ * Emit `epic.merge.blocked`. Helper carved out so the blocking paths
870
+ * (CI failure / predicate dirty / evaluator throw) share the same emit
871
+ * shape.
555
872
  */
556
873
  async _emitBlocked(prUrl, reason) {
557
874
  try {