mandrel 2.63.0 → 2.65.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 (70) hide show
  1. package/.agents/agents/acceptance-critic.md +3 -2
  2. package/.agents/agents/auditor.md +3 -2
  3. package/.agents/agents/plan-critic.md +3 -2
  4. package/.agents/agents/story-worker.md +2 -2
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +1 -9
  7. package/.agents/docs/configuration.md +8 -7
  8. package/.agents/schemas/agentrc.schema.json +6 -13
  9. package/.agents/schemas/audit-rules.schema.json +1 -1
  10. package/.agents/schemas/story-deliver-terminal.schema.json +5 -0
  11. package/.agents/scripts/bootstrap.js +8 -2
  12. package/.agents/scripts/check-context-budget.js +1 -1
  13. package/.agents/scripts/lib/ITicketingProvider.js +1 -3
  14. package/.agents/scripts/lib/audit-suite/findings.js +1 -17
  15. package/.agents/scripts/lib/audit-suite/frontmatter.js +0 -28
  16. package/.agents/scripts/lib/audit-suite/index.js +0 -6
  17. package/.agents/scripts/lib/audit-suite/selector.js +0 -31
  18. package/.agents/scripts/lib/bootstrap/agents-md-fold.js +156 -0
  19. package/.agents/scripts/lib/bootstrap/commit-push.js +1 -1
  20. package/.agents/scripts/lib/bootstrap/manifest.js +2 -2
  21. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +13 -29
  22. package/.agents/scripts/lib/config/review-chain-default.js +13 -0
  23. package/.agents/scripts/lib/config-settings-schema-delivery.js +2 -2
  24. package/.agents/scripts/lib/config-settings-schema-quality.js +11 -13
  25. package/.agents/scripts/lib/doc-tiers.js +25 -6
  26. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  27. package/.agents/scripts/lib/observability/metrics-ledger.js +0 -72
  28. package/.agents/scripts/lib/orchestration/ci-red-handling.js +73 -0
  29. package/.agents/scripts/lib/orchestration/code-review.js +11 -6
  30. package/.agents/scripts/lib/orchestration/deliver-recover.js +56 -11
  31. package/.agents/scripts/lib/orchestration/epic-rollup.js +29 -12
  32. package/.agents/scripts/lib/orchestration/merge-block-class.js +20 -4
  33. package/.agents/scripts/lib/orchestration/merge-poll.js +41 -22
  34. package/.agents/scripts/lib/orchestration/required-checks.js +147 -0
  35. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +203 -0
  36. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +6 -4
  37. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -2
  38. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +1 -0
  39. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +0 -12
  40. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +135 -20
  41. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +112 -82
  42. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +72 -5
  43. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +12 -87
  44. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +3 -0
  45. package/.agents/scripts/lib/templates/decomposer-prompts.js +5 -24
  46. package/.agents/scripts/pr-watch-with-update.js +13 -19
  47. package/.agents/scripts/providers/github/issues.js +14 -23
  48. package/.agents/scripts/sync-claude-agents.js +1 -1
  49. package/.agents/workflows/audit-quality.md +42 -7
  50. package/.agents/workflows/helpers/acceptance-self-eval.md +1 -1
  51. package/.agents/workflows/helpers/code-review.md +15 -38
  52. package/.agents/workflows/helpers/deliver-reference.md +4 -2
  53. package/.agents/workflows/helpers/deliver-story.md +3 -0
  54. package/.agents/workflows/helpers/plan-reference.md +9 -8
  55. package/.agents/workflows/mandrel-deliver.md +2 -1
  56. package/.agents/workflows/mandrel-plan.md +10 -7
  57. package/.agents/workflows/mandrel-update.md +5 -3
  58. package/docs/CHANGELOG.md +38 -0
  59. package/lib/cli/claude-code-version.js +73 -0
  60. package/lib/cli/doctor.js +2 -2
  61. package/lib/cli/registry.js +9 -0
  62. package/lib/cli/uninstall.js +37 -9
  63. package/lib/migrations/index.js +2 -0
  64. package/lib/migrations/steps/2.65.0-fold-claude-md-into-agents-md.js +38 -0
  65. package/package.json +2 -1
  66. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +0 -99
  67. package/.agents/scripts/lib/audit-suite/runner.js +0 -205
  68. package/.agents/scripts/lib/audit-suite/substitutions.js +0 -96
  69. package/.agents/scripts/lib/audit-suite/workflow-loader.js +0 -37
  70. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +0 -234
@@ -0,0 +1,203 @@
1
+ /**
2
+ * review-providers/code-review.js — a low-effort model bug review of the
3
+ * Story diff through `claude --print --effort low`. It asks only for
4
+ * merge-blocking problems, so every parsed finding is `critical` and halts
5
+ * close before auto-merge. The reviewer is handed the diff range, the Story
6
+ * id and the diff text — never `acceptance[]` or the self-eval verdict: this
7
+ * is a bug review, not a second acceptance scoring.
8
+ *
9
+ * A missing CLI throws at construction (the default chain entry is
10
+ * `optional: true`, so hosts without it skip). A review that cannot run or
11
+ * whose output does not parse degrades to one non-halting `suggestion`.
12
+ *
13
+ * @typedef {import('./types.js').Finding} Finding
14
+ * @typedef {import('./types.js').ReviewInput} ReviewInput
15
+ * @typedef {import('./types.js').ReviewProvider} ReviewProvider
16
+ */
17
+
18
+ import { spawnCapture } from '../../child-exec.js';
19
+ import { gitSpawn } from '../../git-utils.js';
20
+ import { PROJECT_ROOT } from '../../project-root.js';
21
+ import { parseProviderFindings } from './parse-findings.js';
22
+ import { renderDepthDirective } from './review-depth.js';
23
+ import { probeClaudeCli } from './security-review.js';
24
+
25
+ const CLAUDE_ARGS = Object.freeze(['--print', '--effort', 'low']);
26
+ const INVOKE_TIMEOUT_MS = 10 * 60 * 1000;
27
+ /** Larger diffs are truncated; the reviewer is told so. */
28
+ const MAX_DIFF_CHARS = 200_000;
29
+
30
+ const PROMPT_HEAD =
31
+ 'You are reviewing the diff `{baseRef}...{headRef}` for Story #{ticketId} ' +
32
+ 'before it merges. {depthDirective}\n\n' +
33
+ 'Report ONLY problems you would block this merge for: a bug that makes ' +
34
+ 'the change behave incorrectly, crash, lose data, or break an existing ' +
35
+ 'caller. Do not report style, naming, refactoring ideas, missing tests or ' +
36
+ 'anything you would merge anyway. For each problem give the file, the ' +
37
+ 'line in the new version, why it is wrong, and how to show it fails (an ' +
38
+ 'input, command or test that exposes it).\n\n' +
39
+ 'The diff below is authoritative — files on disk may not reflect it. ' +
40
+ 'Emit ONLY a JSON array on stdout with this exact shape, no prose around ' +
41
+ 'it:\n\n' +
42
+ '[{"title":"...","body":"Why it is wrong: ... How to show it fails: ...",' +
43
+ '"file":"...","line":1,"category":"bug"}]\n\n' +
44
+ 'Emit [] if there is nothing you would block the merge for.\n\n';
45
+
46
+ /**
47
+ * @param {ReviewInput} input
48
+ * @param {string} diff
49
+ * @returns {string}
50
+ */
51
+ function buildPrompt(input, diff) {
52
+ const truncated = diff.length > MAX_DIFF_CHARS;
53
+ const body = truncated ? diff.slice(0, MAX_DIFF_CHARS) : diff;
54
+ const note = truncated
55
+ ? `\n[diff truncated at ${MAX_DIFF_CHARS} characters]\n`
56
+ : '';
57
+ const head = PROMPT_HEAD.replace('{baseRef}', input.baseRef)
58
+ .replace('{headRef}', input.headRef)
59
+ .replace('{ticketId}', String(input.ticketId))
60
+ .replace('{depthDirective}', renderDepthDirective(input.depth));
61
+ return `${head}<diff>\n${body}${note}\n</diff>\n`;
62
+ }
63
+
64
+ /**
65
+ * The prompt rides stdin so no shell ever quotes it.
66
+ *
67
+ * @param {string} prompt
68
+ * @param {Function} [run] - `spawnSync`-shaped seam for tests.
69
+ * @returns {{ status: number, stdout: string, stderr: string }}
70
+ */
71
+ function invokeClaude(prompt, run) {
72
+ return spawnCapture('claude', [...CLAUDE_ARGS], {
73
+ cwd: PROJECT_ROOT,
74
+ input: prompt,
75
+ shell: process.platform === 'win32',
76
+ timeout: INVOKE_TIMEOUT_MS,
77
+ ...(run ? { run } : {}),
78
+ });
79
+ }
80
+
81
+ /** A model often fences its JSON; strip one surrounding fence. */
82
+ function stripFence(text) {
83
+ const match = /^\s*```[a-z]*\s*\n([\s\S]*?)\n\s*```\s*$/i.exec(text ?? '');
84
+ return match ? match[1] : (text ?? '');
85
+ }
86
+
87
+ /**
88
+ * @param {string} title
89
+ * @param {string} detail
90
+ * @returns {Finding}
91
+ */
92
+ function advisory(title, detail) {
93
+ return {
94
+ severity: 'suggestion',
95
+ title,
96
+ body:
97
+ `${detail} The model bug review did not produce a verdict — inspect ` +
98
+ 'the diff manually before merging. Advisory only; the chain did not halt.',
99
+ category: 'bug',
100
+ };
101
+ }
102
+
103
+ /**
104
+ * @param {string} stdout
105
+ * @returns {Finding[]}
106
+ */
107
+ function parseFindings(stdout) {
108
+ try {
109
+ return parseProviderFindings(stripFence(stdout), {
110
+ errorPrefix: '[code-review] Failed to parse reviewer stdout as JSON',
111
+ mapSeverity: () => 'critical',
112
+ defaultCategory: 'bug',
113
+ });
114
+ } catch (err) {
115
+ return [
116
+ advisory(
117
+ 'Code review output not parseable as JSON',
118
+ `The reviewer returned text that did not parse as a JSON findings array (${err.message}).`,
119
+ ),
120
+ ];
121
+ }
122
+ }
123
+
124
+ /**
125
+ * @param {ReviewInput} input
126
+ */
127
+ function assertInput(input) {
128
+ const { baseRef, headRef, ticketId } = input ?? {};
129
+ if (!baseRef || !headRef) {
130
+ throw new TypeError(
131
+ '[code-review] runReview requires baseRef and headRef.',
132
+ );
133
+ }
134
+ if (!Number.isInteger(ticketId) || ticketId <= 0) {
135
+ throw new TypeError(
136
+ '[code-review] runReview requires a positive integer ticketId.',
137
+ );
138
+ }
139
+ }
140
+
141
+ /**
142
+ * @param {{
143
+ * probeFn?: () => boolean,
144
+ * gitSpawnFn?: typeof gitSpawn,
145
+ * spawnFn?: Function,
146
+ * logger?: { info?: Function, warn?: Function },
147
+ * }} [deps] - `spawnFn` replaces `spawnSync` for the `claude` call.
148
+ * @returns {ReviewProvider}
149
+ */
150
+ export function createCodeReviewProviderForRegistry(deps = {}) {
151
+ const probeFn = deps.probeFn ?? probeClaudeCli;
152
+ if (!probeFn()) {
153
+ throw new Error(
154
+ '[ReviewProviderFactory] codeReview provider "code-review" requires ' +
155
+ 'the `claude` CLI on PATH but it was not detected. Install the ' +
156
+ 'Claude Code CLI, or keep the entry `optional: true` so hosts ' +
157
+ 'without it skip the model bug review.',
158
+ );
159
+ }
160
+ const gitSpawnFn = deps.gitSpawnFn ?? gitSpawn;
161
+ const { spawnFn, logger } = deps;
162
+
163
+ return {
164
+ async runReview(input) {
165
+ assertInput(input);
166
+ const { baseRef, headRef, ticketId } = input;
167
+ const diff = gitSpawnFn(
168
+ PROJECT_ROOT,
169
+ 'diff',
170
+ '--no-color',
171
+ `${baseRef}...${headRef}`,
172
+ );
173
+ if (diff.status !== 0) {
174
+ return [
175
+ advisory(
176
+ 'Code review could not read the diff',
177
+ `\`git diff ${baseRef}...${headRef}\` failed: ${diff.stderr || '<no output>'}.`,
178
+ ),
179
+ ];
180
+ }
181
+ if (diff.stdout.trim().length === 0) return [];
182
+
183
+ logger?.info?.(
184
+ `[code-review] Invoking claude --print --effort low for Story #${ticketId} (${baseRef}...${headRef})...`,
185
+ );
186
+ const result = invokeClaude(buildPrompt(input, diff.stdout), spawnFn);
187
+ if (result.status !== 0) {
188
+ logger?.warn?.(
189
+ `[code-review] claude exited ${result.status}; emitting advisory.`,
190
+ );
191
+ return [
192
+ advisory(
193
+ 'Code review did not complete',
194
+ `\`claude --print\` exited with status ${result.status}: ${
195
+ result.stderr || result.stdout || '<no output>'
196
+ }.`,
197
+ ),
198
+ ];
199
+ }
200
+ return parseFindings(result.stdout);
201
+ },
202
+ };
203
+ }
@@ -14,6 +14,8 @@
14
14
  * @typedef {import('./types.js').ProviderChain} ProviderChain
15
15
  */
16
16
 
17
+ import { DEFAULT_REVIEW_PROVIDERS } from '../../config/review-chain-default.js';
18
+ import { createCodeReviewProviderForRegistry } from './code-review.js';
17
19
  import { createCodexProviderForRegistry } from './codex.js';
18
20
  import { mergeChainDegradations } from './degraded-gates.js';
19
21
  import { createNativeProviderForRegistry } from './native.js';
@@ -22,6 +24,7 @@ import { createUltrareviewProviderForRegistry } from './ultrareview.js';
22
24
 
23
25
  /** @type {Readonly<Record<string, () => ReviewProvider>>} */
24
26
  const INLINE_PROVIDERS = Object.freeze({
27
+ 'code-review': createCodeReviewProviderForRegistry,
25
28
  codex: createCodexProviderForRegistry,
26
29
  native: createNativeProviderForRegistry,
27
30
  'security-review': createSecurityReviewProviderForRegistry,
@@ -36,8 +39,6 @@ const PROMPT_PROVIDERS = Object.freeze({
36
39
  ultrareview: createUltrareviewProviderForRegistry,
37
40
  });
38
41
 
39
- export const DEFAULT_PROVIDER_NAME = 'native';
40
-
41
42
  /**
42
43
  * Gate predicate from a `when` clause (`label` / `labelAny`); absent → always true.
43
44
  *
@@ -79,7 +80,8 @@ export function isScopeApplicable(declaredScopes, currentScope) {
79
80
  }
80
81
 
81
82
  /**
82
- * Takes the `codeReview` sub-object; unset/empty `providers` defaults to native.
83
+ * Takes the `codeReview` sub-object; unset/empty `providers` falls back to
84
+ * `DEFAULT_REVIEW_PROVIDERS`.
83
85
  *
84
86
  * @param {{
85
87
  * providers?: Array<object>,
@@ -105,7 +107,7 @@ export function createReviewProvider(codeReviewConfig, opts = {}) {
105
107
  Array.isArray(codeReviewConfig.providers) &&
106
108
  codeReviewConfig.providers.length > 0
107
109
  ? codeReviewConfig.providers
108
- : [{ name: DEFAULT_PROVIDER_NAME }];
110
+ : DEFAULT_REVIEW_PROVIDERS;
109
111
 
110
112
  const chain = buildProviderChain(entries, {
111
113
  inlineRegistry,
@@ -24,11 +24,12 @@ export const SECURITY_REVIEW_REMEDIATIONS = Object.freeze({
24
24
 
25
25
  /**
26
26
  * Synchronous so a missing CLI surfaces at construction, not mid-review.
27
+ * Shared by every `claude --print` provider.
27
28
  *
28
29
  * @param {{ spawnFn?: typeof spawnSync }} [opts]
29
30
  * @returns {boolean}
30
31
  */
31
- function defaultProbeClaudeCli(opts = {}) {
32
+ export function probeClaudeCli(opts = {}) {
32
33
  const spawnFn = opts.spawnFn ?? spawnSync;
33
34
  try {
34
35
  const result = spawnFn('claude', ['--version'], {
@@ -173,7 +174,7 @@ export function buildUnparseableFallbackFinding() {
173
174
  * @returns {ReviewProvider}
174
175
  */
175
176
  export function createSecurityReviewProvider(deps = {}) {
176
- const probeFn = deps.probeFn ?? defaultProbeClaudeCli;
177
+ const probeFn = deps.probeFn ?? probeClaudeCli;
177
178
  if (!probeFn()) {
178
179
  throw buildSecurityReviewUnavailableError();
179
180
  }
@@ -107,6 +107,7 @@ export function failedTerminalFor(err, args = {}) {
107
107
  failure: { reason: String(err?.message ?? err) },
108
108
  nextCommand: NEXT_COMMANDS.recover(storyId),
109
109
  elapsedSeconds: 0,
110
+ phaseDurations: err?.closePhaseDurations ?? undefined,
110
111
  });
111
112
  } catch (buildErr) {
112
113
  Logger.error(
@@ -56,8 +56,6 @@ async function invokeStoryReviewCore({
56
56
  prNumber,
57
57
  provider,
58
58
  runCodeReviewFn,
59
- runLocalLensReviewFn,
60
- appendFindingsYieldFn,
61
59
  gitSpawnFn,
62
60
  progress,
63
61
  }) {
@@ -71,8 +69,6 @@ async function invokeStoryReviewCore({
71
69
  progressTag: 'REVIEW',
72
70
  runCodeReviewFn,
73
71
  gitSpawnFn,
74
- ...(runLocalLensReviewFn ? { runLocalLensReviewFn } : {}),
75
- ...(appendFindingsYieldFn ? { appendFindingsYieldFn } : {}),
76
72
  });
77
73
  }
78
74
 
@@ -131,8 +127,6 @@ async function postStoryReviewCrossRef({
131
127
  * prNumber: number|null,
132
128
  * provider: object,
133
129
  * runCodeReviewFn: Function,
134
- * runLocalLensReviewFn?: Function,
135
- * appendFindingsYieldFn?: Function,
136
130
  * gitSpawnFn?: Function,
137
131
  * progress: (tag: string, msg: string) => void,
138
132
  * }} args
@@ -145,7 +139,6 @@ async function postStoryReviewCrossRef({
145
139
  * degraded?: boolean,
146
140
  * degradations?: Array<object>,
147
141
  * crossRefPosted?: boolean,
148
- * localLensReview?: object,
149
142
  * }>}
150
143
  */
151
144
  export async function runStoryScopeReview({
@@ -157,8 +150,6 @@ export async function runStoryScopeReview({
157
150
  prNumber,
158
151
  provider,
159
152
  runCodeReviewFn,
160
- runLocalLensReviewFn,
161
- appendFindingsYieldFn,
162
153
  gitSpawnFn,
163
154
  progress,
164
155
  }) {
@@ -192,8 +183,6 @@ export async function runStoryScopeReview({
192
183
  prNumber,
193
184
  provider,
194
185
  runCodeReviewFn,
195
- runLocalLensReviewFn,
196
- appendFindingsYieldFn,
197
186
  gitSpawnFn,
198
187
  progress,
199
188
  });
@@ -229,6 +218,5 @@ export async function runStoryScopeReview({
229
218
  postedCommentId: result.postedCommentId ?? null,
230
219
  ...degradationEnvelope(result.degradations),
231
220
  crossRefPosted,
232
- localLensReview: result.localLensReview,
233
221
  };
234
222
  }
@@ -27,6 +27,7 @@ import {
27
27
  import { pollUntil } from '../../../util/poll-loop.js';
28
28
  import { applyBehindUpdate } from '../../behind-recovery.js';
29
29
  import { isRerunPermitted } from '../../check-state.js';
30
+ import { recordRequiredRed as defaultRecordRequiredRed } from '../../ci-red-handling.js';
30
31
  import {
31
32
  emitMergeFlipFailed as defaultEmitMergeFlipFailed,
32
33
  MERGED_FLIP_FAILED_BLOCK_CLASS,
@@ -36,20 +37,22 @@ import { classifyMergeBlock as defaultClassifyMergeBlock } from '../../merge-blo
36
37
  import {
37
38
  ADVISORY_GATE_INCONCLUSIVE_CLASS,
38
39
  ADVISORY_GATE_RED_CLASS,
40
+ CHECKS_FAILED_CLASS,
39
41
  DEFAULT_INTERVAL_SECONDS,
40
42
  DEFAULT_MAX_BUDGET_SECONDS,
41
43
  decideAdvisoryGateBlock,
42
44
  decideMergeWaitFailFast,
43
45
  deriveChecksStatus,
44
46
  deriveRedHeadRuns,
45
- deriveRequiredRunEvidence,
46
47
  isPrMerged,
47
48
  MERGE_WAIT_GH_TIMEOUT_MS,
48
49
  parseWorkflowRunId,
50
+ pollIntervalMs,
49
51
  readRunSummary,
50
52
  resolveAdvisoryGateVerdict,
51
53
  } from '../../merge-poll.js';
52
54
  import { readMergeQueueState } from '../../merge-queue.js';
55
+ import { readProbeRunEvidence } from '../../required-checks.js';
53
56
  import { NEXT_COMMANDS } from '../../story-deliver-terminal.js';
54
57
  import {
55
58
  postStructuredComment,
@@ -153,6 +156,7 @@ export async function readPrWaitProbe({
153
156
  gh = defaultGh,
154
157
  ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
155
158
  readMergeQueueStateFn = readMergeQueueState,
159
+ readRequiredCheckNamesFn,
156
160
  }) {
157
161
  try {
158
162
  const view = await withGhTimeout(
@@ -186,7 +190,14 @@ export async function readPrWaitProbe({
186
190
  }),
187
191
  // Head-anchored, so superseded/pending runs don't read as red; `null`
188
192
  // when the rollup is empty (the consecutive-probe fallback owns that).
189
- requiredRunEvidence: deriveRequiredRunEvidence(view?.statusCheckRollup),
193
+ requiredRunEvidence: await readProbeRunEvidence({
194
+ view,
195
+ checksStatus,
196
+ prNumber,
197
+ gh,
198
+ ghTimeoutMs,
199
+ readFn: readRequiredCheckNamesFn,
200
+ }),
190
201
  redHeadRuns: deriveRedHeadRuns(view?.statusCheckRollup),
191
202
  headSha: readString(view?.headRefOid),
192
203
  };
@@ -297,14 +308,9 @@ function advisoryGateRemedy({ storyId, blockClass }) {
297
308
  * @param {{ storyId: number, prNumber: number|null, blockClass: string }} args
298
309
  * @returns {string}
299
310
  */
300
- function unlandedRemedy({ storyId, prNumber, blockClass }) {
301
- if (blockClass === 'checks-failed') {
302
- return (
303
- `A required check is **red**. Fix the failure and push a new commit on \`story-${storyId}\`; ` +
304
- `the red disarms auto-merge, and only a green on a new head SHA re-arms it — ` +
305
- `re-running the failed job is forbidden. Watch the checks with:\n\n` +
306
- `\`\`\`bash\n${NEXT_COMMANDS.watchCi(storyId, prNumber)}\n\`\`\``
307
- );
311
+ function unlandedRemedy({ storyId, prNumber, blockClass, redRecord }) {
312
+ if (blockClass === CHECKS_FAILED_CLASS) {
313
+ return checksFailedRemedy({ storyId, prNumber, redRecord });
308
314
  }
309
315
  if (
310
316
  blockClass === ADVISORY_GATE_INCONCLUSIVE_CLASS ||
@@ -319,6 +325,35 @@ function unlandedRemedy({ storyId, prNumber, blockClass }) {
319
325
  );
320
326
  }
321
327
 
328
+ /**
329
+ * States what the red handling actually did — never claims a disarm or a
330
+ * digest that did not happen — then names both ci-remediation routes.
331
+ *
332
+ * @param {{ storyId: number, prNumber: number|null, redRecord?: object }} args
333
+ * @returns {string}
334
+ */
335
+ function checksFailedRemedy({ storyId, prNumber, redRecord }) {
336
+ const disarm = redRecord?.disarm;
337
+ const armLine = disarm?.disarmed
338
+ ? disarm.alreadyUnarmed
339
+ ? 'Auto-merge was already un-armed.'
340
+ : 'Auto-merge was **disarmed**; only a green on a new head SHA, or the one rerun a filed `capacity` / `unreproducible-tier` verdict admits, re-arms it.'
341
+ : `⚠️ Auto-merge could **not** be disarmed (${disarm?.detail ?? 'the red handling did not run'}) — GitHub may still land the PR when the checks read green. Disarm it by hand.`;
342
+ const watch = NEXT_COMMANDS.watchCi(storyId, prNumber);
343
+ const digestLine = redRecord?.digestPaths
344
+ ? `CI digest (run link + failure signature): \`${redRecord.digestPaths.jsonPath}\`.`
345
+ : `⚠️ No CI digest was written (${redRecord?.digestError ?? 'no Story scope'}); \`${watch}\` rewrites it.`;
346
+ return (
347
+ `A required check is **red**. ${armLine}\n\n${digestLine}\n\n` +
348
+ `Per \`.agents/rules/ci-remediation.md\`, either fix the failure and push a new commit on ` +
349
+ `\`story-${storyId}\` (re-running the failed job is forbidden), or — when the root cause is ` +
350
+ `outside this delivery — file it:\n\n` +
351
+ `\`\`\`bash\nnode .agents/scripts/file-ci-gap.js --story ${storyId} --pr ${prNumber} ` +
352
+ `--verdict <verdict> --owner <consumer|framework|platform> --evidence "<proof reading>"\n\`\`\`\n\n` +
353
+ `Then watch the checks with:\n\n\`\`\`bash\n${watch}\n\`\`\``
354
+ );
355
+ }
356
+
322
357
  function formatUnlandedFriction({
323
358
  storyId,
324
359
  prNumber,
@@ -326,12 +361,13 @@ function formatUnlandedFriction({
326
361
  blockClass,
327
362
  reason,
328
363
  elapsedSeconds,
364
+ redRecord,
329
365
  }) {
330
366
  const prLabel =
331
367
  Number.isInteger(prNumber) && prNumber > 0
332
368
  ? `PR #${prNumber}${prUrl ? ` (${prUrl})` : ''}`
333
369
  : (prUrl ?? 'the PR');
334
- const remedy = unlandedRemedy({ storyId, prNumber, blockClass });
370
+ const remedy = unlandedRemedy({ storyId, prNumber, blockClass, redRecord });
335
371
  return (
336
372
  `### close-and-land: merge did not land\n\n` +
337
373
  `Story #${storyId}: the close polled ${prLabel} for merge confirmation and ` +
@@ -691,6 +727,61 @@ function queuedExhaustionVerdict(probe, cumulativeMs) {
691
727
  };
692
728
  }
693
729
 
730
+ /**
731
+ * A `checks-failed` fail-fast is a required-check red, so it gets the same
732
+ * first-red handling as the watcher: disarm, then write the CI digest
733
+ * `file-ci-gap.js` reads. Never throws — the block stands regardless.
734
+ *
735
+ * @returns {Promise<object>} the `recordRequiredRed` outcome.
736
+ */
737
+ async function recordChecksFailedRed({
738
+ storyId,
739
+ prNumber,
740
+ probe,
741
+ cwd,
742
+ config,
743
+ gh,
744
+ progress,
745
+ disarmAutoMergeFn,
746
+ recordRequiredRedFn,
747
+ }) {
748
+ const failures = (probe?.redHeadRuns ?? []).map((run) => ({
749
+ name: run.name ?? 'unknown',
750
+ outcome: String(run.conclusion ?? 'failure').toLowerCase(),
751
+ }));
752
+ try {
753
+ const record = await recordRequiredRedFn({
754
+ storyId,
755
+ prNumber,
756
+ prRef: String(prNumber),
757
+ failures,
758
+ tempRoot: config?.project?.paths?.tempRoot ?? 'temp',
759
+ cwd,
760
+ headSha: probe?.headSha ?? null,
761
+ disarmFn: () => disarmAutoMergeFn({ prNumber, gh, progress }),
762
+ });
763
+ if (record.digestPaths) {
764
+ progress?.(
765
+ 'CONFIRM',
766
+ `🧾 CI failure digest → ${record.digestPaths.jsonPath}`,
767
+ );
768
+ }
769
+ return record;
770
+ } catch (err) {
771
+ const detail = String(err?.message ?? err);
772
+ progress?.(
773
+ 'CONFIRM',
774
+ `⚠️ Red-check handling failed (continuing to block): ${detail}`,
775
+ );
776
+ return {
777
+ headSha: probe?.headSha ?? null,
778
+ disarm: { disarmed: false, alreadyUnarmed: false, detail },
779
+ digestPaths: null,
780
+ digestError: detail,
781
+ };
782
+ }
783
+ }
784
+
694
785
  /** Classify, emit `merge.unlanded`, post friction, block — all best-effort. */
695
786
  async function blockOnUnlanded({
696
787
  storyId,
@@ -705,6 +796,7 @@ async function blockOnUnlanded({
705
796
  emitMergeUnlandedFn,
706
797
  blockClassOverride,
707
798
  reasonOverride,
799
+ redRecord,
708
800
  }) {
709
801
  // A verdict decided at detection is emitted as-is, never re-derived (the
710
802
  // classifier reads an advisory-gate PR as healthy); the classifier runs
@@ -757,6 +849,7 @@ async function blockOnUnlanded({
757
849
  blockClass,
758
850
  reason,
759
851
  elapsedSeconds,
852
+ redRecord,
760
853
  }),
761
854
  progress,
762
855
  });
@@ -781,6 +874,7 @@ async function blockOnUnlanded({
781
874
  reason,
782
875
  frictionCommentId,
783
876
  elapsedSeconds,
877
+ ...(redRecord ? { redRecord } : {}),
784
878
  // The envelope's `pr.state` / `pr.checksStatus` come from here.
785
879
  prProbe,
786
880
  };
@@ -933,6 +1027,8 @@ async function onMergeObserved({
933
1027
  * @param {Function} [args.classifyMergeBlockFn]
934
1028
  * @param {Function} [args.emitMergeUnlandedFn]
935
1029
  * @param {Function} [args.runPostLandTailFn]
1030
+ * @param {Function} [args.disarmAutoMergeFn]
1031
+ * @param {Function} [args.recordRequiredRedFn] The shared first-red handling (disarm + CI digest).
936
1032
  * @param {(ms: number) => Promise<void>} [args.sleepFn]
937
1033
  * @param {() => number} [args.nowMsFn]
938
1034
  * @param {number} [args.ghTimeoutMs] Test seam only, not config.
@@ -964,6 +1060,7 @@ export async function runConfirmMergePhase({
964
1060
  emitMergeFlipFailedFn = defaultEmitMergeFlipFailed,
965
1061
  runPostLandTailFn = defaultRunPostLandTail,
966
1062
  disarmAutoMergeFn = disarmAutoMerge,
1063
+ recordRequiredRedFn = defaultRecordRequiredRed,
967
1064
  sleepFn = defaultSleep,
968
1065
  nowMsFn = Date.now,
969
1066
  ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
@@ -1017,7 +1114,7 @@ export async function runConfirmMergePhase({
1017
1114
  remaining: rerunAllowance,
1018
1115
  issued: new Set(),
1019
1116
  };
1020
- const intervalMs = intervalSeconds * 1000;
1117
+ let intervalMs = intervalSeconds * 1000;
1021
1118
  const startedAtMs = nowMsFn();
1022
1119
  let anchorMs = startedAtMs;
1023
1120
  let updatesUsed = 0;
@@ -1041,6 +1138,7 @@ export async function runConfirmMergePhase({
1041
1138
  ghTimeoutMs,
1042
1139
  });
1043
1140
  polls += 1;
1141
+ intervalMs = pollIntervalMs(probe.checksStatus, intervalSeconds);
1044
1142
 
1045
1143
  anchorMs = resolveBudgetAnchorMs({
1046
1144
  createdAt: probe.createdAt,
@@ -1059,12 +1157,7 @@ export async function runConfirmMergePhase({
1059
1157
  // Heartbeat: a backgrounded close's output-file growth is its liveness signal.
1060
1158
  progress?.(
1061
1159
  'CONFIRM',
1062
- `⏱ poll ${polls}: PR #${prNumber} state=${probe.state ?? 'unknown'} ` +
1063
- `checks=${probe.checksStatus ?? 'unknown'} ` +
1064
- `mergeState=${probe.mergeStateStatus ?? 'unknown'} ` +
1065
- `(${waitBudget.waitedSeconds}s of ${maxWaitSeconds}s this invocation; ` +
1066
- `${waitBudget.cumulativeSeconds}s of ${maxBudgetSeconds}s cumulative)` +
1067
- (probe.error ? ` — probe error: ${probe.error}` : ''),
1160
+ pollHeartbeat({ polls, prNumber, probe, waitBudget }),
1068
1161
  );
1069
1162
 
1070
1163
  if (isPrMerged(probe)) {
@@ -1127,6 +1220,17 @@ export async function runConfirmMergePhase({
1127
1220
  },
1128
1221
  blockClassOverride: decision.blockClass,
1129
1222
  reasonOverride: decision.reason,
1223
+ redRecord: await recordChecksFailedRed({
1224
+ storyId,
1225
+ prNumber,
1226
+ probe,
1227
+ cwd,
1228
+ config,
1229
+ gh: injectedGh,
1230
+ progress,
1231
+ disarmAutoMergeFn,
1232
+ recordRequiredRedFn,
1233
+ }),
1130
1234
  };
1131
1235
  }
1132
1236
 
@@ -1225,13 +1329,24 @@ export async function runConfirmMergePhase({
1225
1329
  },
1226
1330
  predicate: (result) => result?.done === true,
1227
1331
  intervalMs,
1228
- // No pollUntil timeout: the tick owns both bounds and their classification.
1229
- sleepFn: (ms) => sleepFn(ms),
1332
+ // No pollUntil timeout: the tick owns both bounds and the cadence.
1333
+ sleepFn: () => sleepFn(intervalMs),
1230
1334
  });
1231
1335
  if (tick.thrown) throw tick.thrown;
1232
1336
  return tick.outcome;
1233
1337
  }
1234
1338
 
1339
+ function pollHeartbeat({ polls, prNumber, probe, waitBudget }) {
1340
+ return (
1341
+ `⏱ poll ${polls}: PR #${prNumber} state=${probe.state ?? 'unknown'} ` +
1342
+ `checks=${probe.checksStatus ?? 'unknown'} ` +
1343
+ `mergeState=${probe.mergeStateStatus ?? 'unknown'} ` +
1344
+ `(${waitBudget.waitedSeconds}s of ${waitBudget.maxWaitSeconds}s this invocation; ` +
1345
+ `${waitBudget.cumulativeSeconds}s of ${waitBudget.maxBudgetSeconds}s cumulative)` +
1346
+ (probe.error ? ` — probe error: ${probe.error}` : '')
1347
+ );
1348
+ }
1349
+
1235
1350
  function doneWith(outcome) {
1236
1351
  return { done: true, outcome };
1237
1352
  }