mandrel 2.63.0 → 2.64.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.
@@ -0,0 +1,73 @@
1
+ // .agents/scripts/lib/orchestration/ci-red-handling.js
2
+ /**
3
+ * ci-red-handling.js — the first-red half of the no-rerun guard in
4
+ * `rules/ci-remediation.md` § Verifier, shared by every path that observes a
5
+ * red required check: the watcher (`pr-watch-with-update.js`) and the
6
+ * close-and-land merge wait's `checks-failed` fail-fast. The digest itself
7
+ * lives in `ci-rerun-guard.js`.
8
+ */
9
+
10
+ import { Logger } from '../Logger.js';
11
+ import {
12
+ resolveDigestScope,
13
+ resolvePrHeadSha,
14
+ writeCiDigest,
15
+ } from './ci-rerun-guard.js';
16
+
17
+ /**
18
+ * The first-red handling every required-check red goes through — the
19
+ * watcher's red path and the close's `checks-failed` fail-fast alike: disarm
20
+ * auto-merge FIRST (the race-free moment), then write the digest keyed to the
21
+ * red head SHA. One implementation, so the guard cannot drift per path.
22
+ * Never throws: a digest-write failure is returned as `digestError`.
23
+ *
24
+ * @param {object} opts
25
+ * @param {number|string|null} [opts.storyId]
26
+ * @param {number} opts.prNumber
27
+ * @param {string} opts.prRef
28
+ * @param {Array<{name:string, outcome:string}>} opts.failures
29
+ * @param {string} opts.tempRoot
30
+ * @param {string} opts.cwd
31
+ * @param {(args: { prRef: string }) => Promise<{ disarmed: boolean, alreadyUnarmed?: boolean, detail: string }>} opts.disarmFn
32
+ * @param {string|null} [opts.headSha] Already-observed head SHA; probed when absent.
33
+ * @param {Function} [opts.headShaFn]
34
+ * @param {Function} [opts.writeDigestFn]
35
+ * @param {object} [opts.logger]
36
+ * @returns {Promise<{ headSha: string|null, disarm: object, digestPaths: { jsonPath: string, mdPath: string }|null, digestError: string|null }>}
37
+ */
38
+ export async function recordRequiredRed({
39
+ storyId = null,
40
+ prNumber,
41
+ prRef,
42
+ failures,
43
+ tempRoot,
44
+ cwd,
45
+ disarmFn,
46
+ headSha = null,
47
+ headShaFn = resolvePrHeadSha,
48
+ writeDigestFn = writeCiDigest,
49
+ logger = Logger,
50
+ }) {
51
+ const disarm = await disarmFn({ prRef });
52
+ const scope = resolveDigestScope({ storyId });
53
+ const redHeadSha = scope ? (headSha ?? headShaFn({ prRef, cwd })) : null;
54
+ let digestPaths = null;
55
+ let digestError = null;
56
+ try {
57
+ digestPaths = writeDigestFn({
58
+ storyId,
59
+ prNumber,
60
+ headSha: redHeadSha,
61
+ failures,
62
+ tempRoot,
63
+ cwd,
64
+ prRef,
65
+ });
66
+ } catch (err) {
67
+ digestError = String(err?.message ?? err);
68
+ logger?.warn?.(
69
+ `[ci-rerun-guard] failed to write CI digest (non-fatal): ${digestError}`,
70
+ );
71
+ }
72
+ return { headSha: redHeadSha, disarm, digestPaths, digestError };
73
+ }
@@ -13,7 +13,11 @@ import {
13
13
  } from '../config/temp-paths.js';
14
14
  import { gh as defaultGh } from '../gh-exec.js';
15
15
  import { gitSpawn as defaultGitSpawn, getStoryBranch } from '../git-utils.js';
16
- import { deriveChecksStatus, isPrMerged } from './merge-poll.js';
16
+ import {
17
+ CHECKS_FAILED_CLASS,
18
+ deriveChecksStatus,
19
+ isPrMerged,
20
+ } from './merge-poll.js';
17
21
  import { NEXT_COMMANDS } from './story-deliver-terminal.js';
18
22
  import { STATE_LABELS } from './ticketing.js';
19
23
 
@@ -302,6 +306,56 @@ function decideExecuting({ storyId, branch, pr, closeArtifacts, evidence }) {
302
306
  };
303
307
  }
304
308
 
309
+ /**
310
+ * A close that blocked on a red required check disarmed auto-merge and wrote
311
+ * the CI digest, so the next step is the ci-remediation loop — never this
312
+ * probe again. `null` for every other blocked class (or with no PR to watch).
313
+ *
314
+ * @returns {{ shape: string, nextCommand: string, detail: string, evidence: string[] } | null}
315
+ */
316
+ function decideBlockedChecksFailed({ storyId, pr, closeArtifacts, evidence }) {
317
+ const envelope = closeArtifacts?.envelope;
318
+ if (envelope?.blocked?.blockClass !== CHECKS_FAILED_CLASS) return null;
319
+ const prNumber = pr?.number ?? envelope?.pr?.number ?? null;
320
+ if (!prNumber) return null;
321
+ return {
322
+ shape: 'blocked-checks-failed',
323
+ nextCommand: NEXT_COMMANDS.watchCi(storyId, prNumber),
324
+ detail:
325
+ `Story is \`agent::blocked\` because a required check on PR #${prNumber} went red. ` +
326
+ `The close disarmed auto-merge and wrote the CI digest ` +
327
+ `(\`story-${storyId}-ci-digest.json\` under the configured tempRoot). Per ` +
328
+ `\`.agents/rules/ci-remediation.md\`, either fix the failure at source and push a new ` +
329
+ `commit on \`story-${storyId}\`, or — when the root cause is outside this delivery — run ` +
330
+ `\`node .agents/scripts/file-ci-gap.js --story ${storyId} --pr ${prNumber} --verdict <verdict> ` +
331
+ `--owner <consumer|framework|platform> --evidence "<proof reading>"\`. Then run the watcher: ` +
332
+ `a green on a new head SHA, or the one rerun a filed capacity / unreproducible-tier verdict ` +
333
+ `admits, re-arms auto-merge.`,
334
+ evidence,
335
+ };
336
+ }
337
+
338
+ /**
339
+ * A blocked Story: the checks-failed route when it applies, else the
340
+ * class-specific remediation the friction comment already carries.
341
+ *
342
+ * @returns {{ shape: string, nextCommand: string, detail: string, evidence: string[] }}
343
+ */
344
+ function decideBlocked({ storyId, pr, closeArtifacts, evidence }) {
345
+ return (
346
+ decideBlockedChecksFailed({ storyId, pr, closeArtifacts, evidence }) ?? {
347
+ shape: 'blocked',
348
+ nextCommand: NEXT_COMMANDS.recover(storyId),
349
+ detail:
350
+ `Story is at \`agent::blocked\`. The block was already classified when it was ` +
351
+ `filed — read the \`friction\` comment on #${storyId} for the class-specific ` +
352
+ `remediation, resolve it, then transition back to \`agent::executing\`. ` +
353
+ `Re-run this probe afterwards to confirm the strand cleared.`,
354
+ evidence,
355
+ }
356
+ );
357
+ }
358
+
305
359
  /**
306
360
  * The pure decision table: exactly one verdict, never a list.
307
361
  *
@@ -357,16 +411,7 @@ export function decideRecovery({
357
411
  }
358
412
 
359
413
  if (label === STATE_LABELS.BLOCKED) {
360
- return {
361
- shape: 'blocked',
362
- nextCommand: NEXT_COMMANDS.recover(storyId),
363
- detail:
364
- `Story is at \`agent::blocked\`. The block was already classified when it was ` +
365
- `filed — read the \`friction\` comment on #${storyId} for the class-specific ` +
366
- `remediation, resolve it, then transition back to \`agent::executing\`. ` +
367
- `Re-run this probe afterwards to confirm the strand cleared.`,
368
- evidence,
369
- };
414
+ return decideBlocked({ storyId, pr, closeArtifacts, evidence });
370
415
  }
371
416
 
372
417
  if (label === STATE_LABELS.DONE) {
@@ -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,6 +37,7 @@ 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,
@@ -297,14 +299,9 @@ function advisoryGateRemedy({ storyId, blockClass }) {
297
299
  * @param {{ storyId: number, prNumber: number|null, blockClass: string }} args
298
300
  * @returns {string}
299
301
  */
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
- );
302
+ function unlandedRemedy({ storyId, prNumber, blockClass, redRecord }) {
303
+ if (blockClass === CHECKS_FAILED_CLASS) {
304
+ return checksFailedRemedy({ storyId, prNumber, redRecord });
308
305
  }
309
306
  if (
310
307
  blockClass === ADVISORY_GATE_INCONCLUSIVE_CLASS ||
@@ -319,6 +316,35 @@ function unlandedRemedy({ storyId, prNumber, blockClass }) {
319
316
  );
320
317
  }
321
318
 
319
+ /**
320
+ * States what the red handling actually did — never claims a disarm or a
321
+ * digest that did not happen — then names both ci-remediation routes.
322
+ *
323
+ * @param {{ storyId: number, prNumber: number|null, redRecord?: object }} args
324
+ * @returns {string}
325
+ */
326
+ function checksFailedRemedy({ storyId, prNumber, redRecord }) {
327
+ const disarm = redRecord?.disarm;
328
+ const armLine = disarm?.disarmed
329
+ ? disarm.alreadyUnarmed
330
+ ? 'Auto-merge was already un-armed.'
331
+ : '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.'
332
+ : `⚠️ 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.`;
333
+ const watch = NEXT_COMMANDS.watchCi(storyId, prNumber);
334
+ const digestLine = redRecord?.digestPaths
335
+ ? `CI digest (run link + failure signature): \`${redRecord.digestPaths.jsonPath}\`.`
336
+ : `⚠️ No CI digest was written (${redRecord?.digestError ?? 'no Story scope'}); \`${watch}\` rewrites it.`;
337
+ return (
338
+ `A required check is **red**. ${armLine}\n\n${digestLine}\n\n` +
339
+ `Per \`.agents/rules/ci-remediation.md\`, either fix the failure and push a new commit on ` +
340
+ `\`story-${storyId}\` (re-running the failed job is forbidden), or — when the root cause is ` +
341
+ `outside this delivery — file it:\n\n` +
342
+ `\`\`\`bash\nnode .agents/scripts/file-ci-gap.js --story ${storyId} --pr ${prNumber} ` +
343
+ `--verdict <verdict> --owner <consumer|framework|platform> --evidence "<proof reading>"\n\`\`\`\n\n` +
344
+ `Then watch the checks with:\n\n\`\`\`bash\n${watch}\n\`\`\``
345
+ );
346
+ }
347
+
322
348
  function formatUnlandedFriction({
323
349
  storyId,
324
350
  prNumber,
@@ -326,12 +352,13 @@ function formatUnlandedFriction({
326
352
  blockClass,
327
353
  reason,
328
354
  elapsedSeconds,
355
+ redRecord,
329
356
  }) {
330
357
  const prLabel =
331
358
  Number.isInteger(prNumber) && prNumber > 0
332
359
  ? `PR #${prNumber}${prUrl ? ` (${prUrl})` : ''}`
333
360
  : (prUrl ?? 'the PR');
334
- const remedy = unlandedRemedy({ storyId, prNumber, blockClass });
361
+ const remedy = unlandedRemedy({ storyId, prNumber, blockClass, redRecord });
335
362
  return (
336
363
  `### close-and-land: merge did not land\n\n` +
337
364
  `Story #${storyId}: the close polled ${prLabel} for merge confirmation and ` +
@@ -691,6 +718,61 @@ function queuedExhaustionVerdict(probe, cumulativeMs) {
691
718
  };
692
719
  }
693
720
 
721
+ /**
722
+ * A `checks-failed` fail-fast is a required-check red, so it gets the same
723
+ * first-red handling as the watcher: disarm, then write the CI digest
724
+ * `file-ci-gap.js` reads. Never throws — the block stands regardless.
725
+ *
726
+ * @returns {Promise<object>} the `recordRequiredRed` outcome.
727
+ */
728
+ async function recordChecksFailedRed({
729
+ storyId,
730
+ prNumber,
731
+ probe,
732
+ cwd,
733
+ config,
734
+ gh,
735
+ progress,
736
+ disarmAutoMergeFn,
737
+ recordRequiredRedFn,
738
+ }) {
739
+ const failures = (probe?.redHeadRuns ?? []).map((run) => ({
740
+ name: run.name ?? 'unknown',
741
+ outcome: String(run.conclusion ?? 'failure').toLowerCase(),
742
+ }));
743
+ try {
744
+ const record = await recordRequiredRedFn({
745
+ storyId,
746
+ prNumber,
747
+ prRef: String(prNumber),
748
+ failures,
749
+ tempRoot: config?.project?.paths?.tempRoot ?? 'temp',
750
+ cwd,
751
+ headSha: probe?.headSha ?? null,
752
+ disarmFn: () => disarmAutoMergeFn({ prNumber, gh, progress }),
753
+ });
754
+ if (record.digestPaths) {
755
+ progress?.(
756
+ 'CONFIRM',
757
+ `🧾 CI failure digest → ${record.digestPaths.jsonPath}`,
758
+ );
759
+ }
760
+ return record;
761
+ } catch (err) {
762
+ const detail = String(err?.message ?? err);
763
+ progress?.(
764
+ 'CONFIRM',
765
+ `⚠️ Red-check handling failed (continuing to block): ${detail}`,
766
+ );
767
+ return {
768
+ headSha: probe?.headSha ?? null,
769
+ disarm: { disarmed: false, alreadyUnarmed: false, detail },
770
+ digestPaths: null,
771
+ digestError: detail,
772
+ };
773
+ }
774
+ }
775
+
694
776
  /** Classify, emit `merge.unlanded`, post friction, block — all best-effort. */
695
777
  async function blockOnUnlanded({
696
778
  storyId,
@@ -705,6 +787,7 @@ async function blockOnUnlanded({
705
787
  emitMergeUnlandedFn,
706
788
  blockClassOverride,
707
789
  reasonOverride,
790
+ redRecord,
708
791
  }) {
709
792
  // A verdict decided at detection is emitted as-is, never re-derived (the
710
793
  // classifier reads an advisory-gate PR as healthy); the classifier runs
@@ -757,6 +840,7 @@ async function blockOnUnlanded({
757
840
  blockClass,
758
841
  reason,
759
842
  elapsedSeconds,
843
+ redRecord,
760
844
  }),
761
845
  progress,
762
846
  });
@@ -781,6 +865,7 @@ async function blockOnUnlanded({
781
865
  reason,
782
866
  frictionCommentId,
783
867
  elapsedSeconds,
868
+ ...(redRecord ? { redRecord } : {}),
784
869
  // The envelope's `pr.state` / `pr.checksStatus` come from here.
785
870
  prProbe,
786
871
  };
@@ -933,6 +1018,8 @@ async function onMergeObserved({
933
1018
  * @param {Function} [args.classifyMergeBlockFn]
934
1019
  * @param {Function} [args.emitMergeUnlandedFn]
935
1020
  * @param {Function} [args.runPostLandTailFn]
1021
+ * @param {Function} [args.disarmAutoMergeFn]
1022
+ * @param {Function} [args.recordRequiredRedFn] The shared first-red handling (disarm + CI digest).
936
1023
  * @param {(ms: number) => Promise<void>} [args.sleepFn]
937
1024
  * @param {() => number} [args.nowMsFn]
938
1025
  * @param {number} [args.ghTimeoutMs] Test seam only, not config.
@@ -964,6 +1051,7 @@ export async function runConfirmMergePhase({
964
1051
  emitMergeFlipFailedFn = defaultEmitMergeFlipFailed,
965
1052
  runPostLandTailFn = defaultRunPostLandTail,
966
1053
  disarmAutoMergeFn = disarmAutoMerge,
1054
+ recordRequiredRedFn = defaultRecordRequiredRed,
967
1055
  sleepFn = defaultSleep,
968
1056
  nowMsFn = Date.now,
969
1057
  ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
@@ -1127,6 +1215,17 @@ export async function runConfirmMergePhase({
1127
1215
  },
1128
1216
  blockClassOverride: decision.blockClass,
1129
1217
  reasonOverride: decision.reason,
1218
+ redRecord: await recordChecksFailedRed({
1219
+ storyId,
1220
+ prNumber,
1221
+ probe,
1222
+ cwd,
1223
+ config,
1224
+ gh: injectedGh,
1225
+ progress,
1226
+ disarmAutoMergeFn,
1227
+ recordRequiredRedFn,
1228
+ }),
1130
1229
  };
1131
1230
  }
1132
1231
 
@@ -20,6 +20,7 @@ import { runAsCli } from './lib/cli-utils.js';
20
20
  import { resolveConfig } from './lib/config-resolver.js';
21
21
  import { gh as defaultGh } from './lib/gh-exec.js';
22
22
  import { Logger } from './lib/Logger.js';
23
+ import { recordRequiredRed } from './lib/orchestration/ci-red-handling.js';
23
24
  import {
24
25
  blockStoryDelivery,
25
26
  classifyFailure,
@@ -229,25 +230,18 @@ async function handleRedWatch({
229
230
  blockFn,
230
231
  logger,
231
232
  }) {
232
- const disarm = await disarmFn({ prRef });
233
- const scope = resolveDigestScope({ storyId });
234
- const headSha = scope ? headShaFn({ prRef, cwd }) : null;
235
- let digestPaths = null;
236
- try {
237
- digestPaths = writeDigestFn({
238
- storyId,
239
- prNumber,
240
- headSha,
241
- failures,
242
- tempRoot,
243
- cwd,
244
- prRef,
245
- });
246
- } catch (err) {
247
- logger.warn?.(
248
- `[pr-watch] failed to write CI digest (non-fatal): ${err?.message ?? err}`,
249
- );
250
- }
233
+ const { headSha, disarm, digestPaths } = await recordRequiredRed({
234
+ storyId,
235
+ prNumber,
236
+ prRef,
237
+ failures,
238
+ tempRoot,
239
+ cwd,
240
+ disarmFn,
241
+ headShaFn,
242
+ writeDigestFn,
243
+ logger,
244
+ });
251
245
  let blocked = false;
252
246
  if (!disarm.disarmed) {
253
247
  logger.error?.(
package/docs/CHANGELOG.md CHANGED
@@ -15,6 +15,13 @@ All notable changes to this project will be documented in this file.
15
15
  -->
16
16
  <!-- markdownlint-disable-file MD004 MD012 MD037 -->
17
17
 
18
+ ## [2.64.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.63.0...mandrel-v2.64.0) (2026-09-19)
19
+
20
+
21
+ ### Fixed
22
+
23
+ * close-and-land: a checks-failed stop disarms auto-merge and writes the CI digest ([#5405](https://github.com/dsj1984/mandrel/issues/5405)) ([#5406](https://github.com/dsj1984/mandrel/issues/5406)) ([5819d52](https://github.com/dsj1984/mandrel/commit/5819d52a834a6973b3e138961d9735e477f6cb48))
24
+
18
25
  ## [2.63.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.62.0...mandrel-v2.63.0) (2026-09-18)
19
26
 
20
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.63.0",
3
+ "version": "2.64.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",