mandrel 2.35.0 → 2.36.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 (24) hide show
  1. package/.agents/docs/agentrc-reference.json +3 -1
  2. package/.agents/docs/configuration.md +2 -0
  3. package/.agents/schemas/agentrc.schema.json +11 -0
  4. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  5. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  6. package/.agents/scripts/check-doc-links.js +23 -2
  7. package/.agents/scripts/git-cleanup.js +2 -0
  8. package/.agents/scripts/lib/config/ci.js +18 -0
  9. package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
  10. package/.agents/scripts/lib/observability/source-classifier.js +0 -1
  11. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
  12. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
  13. package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
  14. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
  15. package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
  16. package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
  17. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
  18. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
  19. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
  20. package/.agents/scripts/notify.js +4 -10
  21. package/.agents/workflows/audit-documentation.md +5 -6
  22. package/docs/CHANGELOG.md +18 -0
  23. package/package.json +3 -3
  24. package/.agents/scripts/generate-lifecycle-docs.js +0 -237
@@ -243,6 +243,170 @@ export function requiredCheckFailedBlocksMerge(prProbe) {
243
243
  );
244
244
  }
245
245
 
246
+ /**
247
+ * The one `mergeStateStatus` value that means the PR is mergeable **despite**
248
+ * red runs — GitHub's own words are "mergeable with non-passing commit
249
+ * status". It is the required-vs-advisory discriminator the rollup itself
250
+ * cannot supply: under `UNSTABLE` the red runs are, by definition, not
251
+ * required, so native auto-merge will land the PR over them.
252
+ *
253
+ * The exact complement of {@link MERGE_GATED_STATE}: `BLOCKED` means the red
254
+ * run gates the merge (`failingChecksBlockMerge`), `UNSTABLE` means it does
255
+ * not and only mandrel can stop the landing.
256
+ */
257
+ const MERGE_ADVISORY_STATE = 'UNSTABLE';
258
+
259
+ /**
260
+ * Pure: project the HEAD-ANCHORED runs that genuinely concluded red, naming
261
+ * each one (Story #5096).
262
+ *
263
+ * Same red-ness test as {@link deriveRequiredRunEvidence} — `FAILURE` /
264
+ * `ERROR` only, never `CANCELLED` / `TIMED_OUT` / `SKIPPED`, which are the
265
+ * superseded-push and sibling-invalidated runs a bare rollup read miscounts
266
+ * (the #4695 / #4710 trap) — but it returns the runs rather than a boolean, so
267
+ * a block summary can name the offending job and the advisory allowlist can
268
+ * match on it.
269
+ *
270
+ * `name` is the CheckRun's `name`, falling back to a legacy StatusContext's
271
+ * `context`, and is `null` when the projection carries neither. A run with no
272
+ * readable name can never match an allowlist entry, so it always blocks — the
273
+ * conservative direction for a gate whose whole purpose is to stop a silent
274
+ * landing.
275
+ *
276
+ * @param {Array<{name?: string, context?: string, status?: string, conclusion?: string, state?: string}>} statusCheckRollup
277
+ * @returns {Array<{ name: string|null, conclusion: string }>}
278
+ */
279
+ export function deriveRedHeadRuns(statusCheckRollup) {
280
+ if (!Array.isArray(statusCheckRollup)) return [];
281
+ const red = [];
282
+ for (const check of statusCheckRollup) {
283
+ const conclusion = String(check?.conclusion ?? '').toUpperCase();
284
+ const state = String(check?.state ?? '').toUpperCase();
285
+ const isRed =
286
+ conclusion === 'FAILURE' ||
287
+ conclusion === 'ERROR' ||
288
+ state === 'FAILURE' ||
289
+ state === 'ERROR';
290
+ if (!isRed) continue;
291
+ const name =
292
+ typeof check?.name === 'string' && check.name
293
+ ? check.name
294
+ : typeof check?.context === 'string' && check.context
295
+ ? check.context
296
+ : null;
297
+ red.push({ name, conclusion: conclusion || state });
298
+ }
299
+ return red;
300
+ }
301
+
302
+ /**
303
+ * Pure: drop the red runs a consumer has exempted via
304
+ * `delivery.ci.advisoryAllowlist`, returning the ones that still block.
305
+ *
306
+ * Matching is exact on the run name. An unnamed run never matches (see
307
+ * {@link deriveRedHeadRuns}).
308
+ *
309
+ * @param {Array<{ name: string|null, conclusion: string }>} redHeadRuns
310
+ * @param {string[]} [allowlist]
311
+ * @returns {Array<{ name: string|null, conclusion: string }>}
312
+ */
313
+ export function selectBlockingRedRuns(redHeadRuns, allowlist = []) {
314
+ if (!Array.isArray(redHeadRuns) || redHeadRuns.length === 0) return [];
315
+ const exempt = new Set(
316
+ (Array.isArray(allowlist) ? allowlist : [])
317
+ .filter((entry) => typeof entry === 'string' && entry)
318
+ .map((entry) => entry),
319
+ );
320
+ if (exempt.size === 0) return [...redHeadRuns];
321
+ return redHeadRuns.filter((run) => !(run?.name && exempt.has(run.name)));
322
+ }
323
+
324
+ /**
325
+ * Pure: does this PR carry a genuinely red ADVISORY run — one that will NOT
326
+ * stop GitHub from landing the PR, and therefore one only mandrel can act on?
327
+ * (Story #5096.)
328
+ *
329
+ * The complement of {@link requiredCheckFailedBlocksMerge}. That predicate
330
+ * answers "is a red REQUIRED check gating the merge" (`BLOCKED`); this one
331
+ * answers "is a red NON-required check about to be merged straight past"
332
+ * (`UNSTABLE`). The two are mutually exclusive by construction, so a red
333
+ * required check keeps its existing `checks-failed` treatment untouched.
334
+ *
335
+ * **Fails OPEN by design.** `UNKNOWN`, `CLEAN`, `BEHIND`, or an absent
336
+ * `mergeStateStatus` all return `false`. The asymmetry is the same one
337
+ * {@link failingChecksBlockMerge} documents and is deliberate: failing to
338
+ * block costs an unattended landing the operator can still revert, whereas
339
+ * blocking wrongly strands a mergeable PR at `agent::blocked` that only an
340
+ * operator can unpick. A transient `UNKNOWN` must never do the latter.
341
+ *
342
+ * @param {{ mergeStateStatus?: string, redHeadRuns?: Array<{name: string|null, conclusion: string}> }} [prProbe]
343
+ * @param {string[]} [allowlist] `delivery.ci.advisoryAllowlist`.
344
+ * @returns {boolean}
345
+ */
346
+ export function advisoryCheckFailedBlocksArm(prProbe, allowlist = []) {
347
+ if (
348
+ String(prProbe?.mergeStateStatus ?? '').toUpperCase() !==
349
+ MERGE_ADVISORY_STATE
350
+ ) {
351
+ return false;
352
+ }
353
+ return selectBlockingRedRuns(prProbe?.redHeadRuns, allowlist).length > 0;
354
+ }
355
+
356
+ /**
357
+ * Pure: the merge wait's advisory-gate decision, the sibling of
358
+ * {@link decideMergeWaitFailFast} (Story #5096).
359
+ *
360
+ * Encapsulates the whole policy — the knob, the predicate, and the allowlist
361
+ * projection — so the poll body carries a single assignment rather than three
362
+ * decision points. `runMergePoll` sits above `check-cyclomatic`'s ceiling
363
+ * already; every branch added inline there is a real regression, and this
364
+ * policy has a natural home beside the predicate it consumes.
365
+ *
366
+ * Returns `null` when the wait should keep polling — the knob is off, the PR
367
+ * is not in the advisory-red state, or every red run is allowlisted.
368
+ *
369
+ * @param {object} args
370
+ * @returns {{ blockingRuns: Array<object>, reason: string } | null}
371
+ */
372
+ export function decideAdvisoryGateBlock({
373
+ probe,
374
+ blockOnAdvisoryFailure,
375
+ advisoryAllowlist,
376
+ }) {
377
+ if (!blockOnAdvisoryFailure) return null;
378
+ if (!advisoryCheckFailedBlocksArm(probe, advisoryAllowlist)) return null;
379
+ const blockingRuns = selectBlockingRedRuns(
380
+ probe?.redHeadRuns,
381
+ advisoryAllowlist,
382
+ );
383
+ return { blockingRuns, reason: formatAdvisoryGateReason(blockingRuns) };
384
+ }
385
+
386
+ /**
387
+ * Format the one-line reason a `merge.unlanded` record and the operator-facing
388
+ * block carry for an `advisory-gate-red` verdict, naming each offending job
389
+ * and its conclusion.
390
+ *
391
+ * @param {Array<{ name: string|null, conclusion: string }>} blockingRuns
392
+ * @returns {string}
393
+ */
394
+ export function formatAdvisoryGateReason(blockingRuns) {
395
+ const named = (Array.isArray(blockingRuns) ? blockingRuns : [])
396
+ .map(
397
+ (run) =>
398
+ `${run?.name ?? '(unnamed run)'} → ${run?.conclusion ?? 'FAILURE'}`,
399
+ )
400
+ .join(', ');
401
+ return (
402
+ 'A non-required (advisory) check concluded red on the PR head, and GitHub ' +
403
+ 'reports the PR mergeable anyway (mergeStateStatus=UNSTABLE) — native ' +
404
+ 'auto-merge would land it over the failure. Red advisory job(s): ' +
405
+ `${named || '(none named)'}. Merge by hand to land over it deliberately, ` +
406
+ 'or exempt the job via delivery.ci.advisoryAllowlist.'
407
+ );
408
+ }
409
+
246
410
  /**
247
411
  * Pure: the merge wait's single fail-fast decision (Story #4710 — extracted
248
412
  * from the two near-verbatim inline blocks in `runConfirmMergePhase`'s poll
@@ -63,6 +63,12 @@
63
63
 
64
64
  import { gh as defaultGh } from '../../../gh-exec.js';
65
65
  import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
66
+ import {
67
+ advisoryCheckFailedBlocksArm,
68
+ deriveRedHeadRuns,
69
+ formatAdvisoryGateReason,
70
+ selectBlockingRedRuns,
71
+ } from '../../merge-poll.js';
66
72
 
67
73
  /**
68
74
  * Arm reasons that mean **the operator deliberately owns the merge** — the PR
@@ -321,6 +327,116 @@ function makeDefaultGhAutoMergeRunner(gh) {
321
327
  };
322
328
  }
323
329
 
330
+ /**
331
+ * Evaluate the pre-arm advisory-gate verdict (Story #5096).
332
+ *
333
+ * GitHub native auto-merge is defined to wait on REQUIRED contexts only, so a
334
+ * red ADVISORY quality gate — a coverage, mutation, duplication, a11y or
335
+ * bundle-size ratchet a consumer deliberately left non-required — is merged
336
+ * straight past once the required contexts go green. The arming decision is
337
+ * mandrel's, not GitHub's, so this is where it has to be made.
338
+ *
339
+ * Reads the SAME head-anchored derivation the merge wait uses
340
+ * (`readPrWaitProbe` → `deriveRedHeadRuns`), so a `CANCELLED` superseded-push
341
+ * run or a sibling-invalidated run is never mistaken for a red gate (the
342
+ * #4695 / #4710 trap), and `mergeStateStatus: UNSTABLE` supplies the
343
+ * required-vs-advisory discrimination the rollup itself lacks.
344
+ *
345
+ * **Non-fatal and fail-open in every degraded case.** A probe error, an absent
346
+ * or `UNKNOWN` merge state, or a disabled knob all return "do not block" — the
347
+ * arm proceeds exactly as it did before this Story. Only a positive verdict
348
+ * refuses.
349
+ *
350
+ * @param {object} args
351
+ * @returns {Promise<{ blocked: boolean, blockingRuns?: Array<object>, reason?: string }>}
352
+ */
353
+ async function readAdvisoryProbe({ prNumber, gh }) {
354
+ const view = await (gh ?? defaultGh).pr.view(prNumber, [
355
+ 'mergeStateStatus',
356
+ 'statusCheckRollup',
357
+ ]);
358
+ return {
359
+ mergeStateStatus:
360
+ typeof view?.mergeStateStatus === 'string'
361
+ ? view.mergeStateStatus
362
+ : undefined,
363
+ redHeadRuns: deriveRedHeadRuns(view?.statusCheckRollup),
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Disarm GitHub native auto-merge on a PR (Story #5096).
369
+ *
370
+ * Lives here, beside the arm, because `lifecycle-lint`'s merge-lockout rule
371
+ * confines every `gh pr merge` invocation to this module: auto-merge
372
+ * enablement — and therefore its reversal — must flow through the Story close
373
+ * path rather than being spelled out wherever a caller happens to need it.
374
+ * The merge wait imports this rather than shelling out itself.
375
+ *
376
+ * Best-effort by contract: the caller blocks the Story either way, and a
377
+ * failed disarm is reported rather than thrown, because the one thing it
378
+ * cannot do is stop GitHub from landing the PR.
379
+ *
380
+ * @returns {Promise<boolean>} whether the disarm actually took.
381
+ */
382
+ export async function disarmAutoMerge({ prNumber, gh, progress }) {
383
+ try {
384
+ await (gh ?? defaultGh).pr.merge(String(prNumber), ['--disable-auto']);
385
+ progress?.(
386
+ 'CONFIRM',
387
+ `🔓 Auto-merge DISARMED on PR #${prNumber} — the PR stays open and hand-mergeable.`,
388
+ );
389
+ return true;
390
+ } catch (err) {
391
+ progress?.(
392
+ 'CONFIRM',
393
+ `⚠️ Could not disarm auto-merge on PR #${prNumber} (${err?.message ?? err}) — ` +
394
+ 'GitHub may still land it when the required checks pass. Disarm by hand.',
395
+ );
396
+ return false;
397
+ }
398
+ }
399
+
400
+ async function evaluateAdvisoryGate({
401
+ prNumber,
402
+ gh,
403
+ blockOnAdvisoryFailure,
404
+ advisoryAllowlist,
405
+ readPrWaitProbeFn,
406
+ progress,
407
+ }) {
408
+ if (!blockOnAdvisoryFailure) return { blocked: false };
409
+ let probe;
410
+ try {
411
+ probe = await readPrWaitProbeFn({ prNumber, gh });
412
+ } catch (err) {
413
+ progress?.(
414
+ 'PR',
415
+ `⚠️ Advisory-gate probe failed (${err?.message ?? err}) — arming anyway.`,
416
+ );
417
+ return { blocked: false };
418
+ }
419
+ if (probe?.error) {
420
+ progress?.(
421
+ 'PR',
422
+ `⚠️ Advisory-gate probe unavailable (${probe.error}) — arming anyway.`,
423
+ );
424
+ return { blocked: false };
425
+ }
426
+ if (!advisoryCheckFailedBlocksArm(probe, advisoryAllowlist)) {
427
+ return { blocked: false };
428
+ }
429
+ const blockingRuns = selectBlockingRedRuns(
430
+ probe.redHeadRuns,
431
+ advisoryAllowlist,
432
+ );
433
+ return {
434
+ blocked: true,
435
+ blockingRuns,
436
+ reason: formatAdvisoryGateReason(blockingRuns),
437
+ };
438
+ }
439
+
324
440
  /**
325
441
  * Dispatch auto-merge enablement based on `--no-auto-merge`, an
326
442
  * unparseable PR number, or a `gh` failure. Returns the structured
@@ -347,8 +463,11 @@ export async function runAutoMergePhase({
347
463
  prUrl,
348
464
  noAutoMerge,
349
465
  autoMergePolicy = 'trust-ci',
466
+ blockOnAdvisoryFailure = true,
467
+ advisoryAllowlist = [],
350
468
  gh,
351
469
  progress,
470
+ readPrWaitProbeFn = readAdvisoryProbe,
352
471
  }) {
353
472
  if (noAutoMerge) {
354
473
  progress('PR', '⏭ Auto-merge disabled (--no-auto-merge).');
@@ -381,6 +500,32 @@ export async function runAutoMergePhase({
381
500
  autoMergeReason: 'pr-number-unparseable',
382
501
  };
383
502
  }
503
+ // Story #5096 — refuse to arm over a genuinely red ADVISORY check. This
504
+ // gate covers the narrow case where the gate is ALREADY red at close time
505
+ // (a re-close, or a gate carried over from an earlier push); the merge
506
+ // wait owns the common case, where the gate reddens after arming.
507
+ const advisory = await evaluateAdvisoryGate({
508
+ prNumber,
509
+ gh,
510
+ blockOnAdvisoryFailure,
511
+ advisoryAllowlist,
512
+ readPrWaitProbeFn,
513
+ progress,
514
+ });
515
+ if (advisory.blocked) {
516
+ progress(
517
+ 'PR',
518
+ `🛑 Auto-merge NOT armed on PR #${prNumber}: ${advisory.reason}`,
519
+ );
520
+ return {
521
+ autoMergeEnabled: false,
522
+ autoMergeReason: 'advisory-gate-red',
523
+ advisoryGate: {
524
+ blockingRuns: advisory.blockingRuns,
525
+ reason: advisory.reason,
526
+ },
527
+ };
528
+ }
384
529
  const result = await enableAutoMergeWith({ cwd, prNumber, gh });
385
530
  if (result.enabled) {
386
531
  if (result.directMerged) {
@@ -90,6 +90,7 @@
90
90
  * confirm.
91
91
  */
92
92
 
93
+ import { getCiDelivery } from '../../../config/ci.js';
93
94
  import { createGh } from '../../../gh-exec.js';
94
95
  import {
95
96
  confirmStoryMerged as defaultConfirmStoryMerged,
@@ -106,8 +107,10 @@ import { classifyMergeBlock as defaultClassifyMergeBlock } from '../../merge-blo
106
107
  import {
107
108
  DEFAULT_INTERVAL_SECONDS,
108
109
  DEFAULT_MAX_BUDGET_SECONDS,
110
+ decideAdvisoryGateBlock,
109
111
  decideMergeWaitFailFast,
110
112
  deriveChecksStatus,
113
+ deriveRedHeadRuns,
111
114
  deriveRequiredRunEvidence,
112
115
  MERGE_WAIT_GH_TIMEOUT_MS,
113
116
  } from '../../merge-poll.js';
@@ -117,6 +120,7 @@ import {
117
120
  STATE_LABELS,
118
121
  transitionTicketState,
119
122
  } from '../../ticketing.js';
123
+ import { disarmAutoMerge } from './auto-merge.js';
120
124
  import { runPostLandTail as defaultRunPostLandTail } from './post-land.js';
121
125
 
122
126
  /**
@@ -256,6 +260,10 @@ export async function readPrWaitProbe({
256
260
  // the aggregate `checksStatus` folds together. `null` when the rollup is
257
261
  // absent/empty — the loop's consecutive-probe fallback owns that path.
258
262
  requiredRunEvidence: deriveRequiredRunEvidence(view?.statusCheckRollup),
263
+ // The named red head runs (Story #5096), so an `advisory-gate-red`
264
+ // verdict can name the offending job and match the allowlist. Same
265
+ // red-ness test as `requiredRunEvidence`, so the two cannot disagree.
266
+ redHeadRuns: deriveRedHeadRuns(view?.statusCheckRollup),
259
267
  };
260
268
  } catch (err) {
261
269
  return {
@@ -263,6 +271,7 @@ export async function readPrWaitProbe({
263
271
  mergedAt: null,
264
272
  createdAt: null,
265
273
  checksStatus: 'pending',
274
+ redHeadRuns: [],
266
275
  error: `PR probe failed: ${err?.message ?? err}`,
267
276
  };
268
277
  }
@@ -541,6 +550,46 @@ async function blockOnFlipFailed({
541
550
  * is best-effort logged rather than thrown — the caller owns surfacing the
542
551
  * non-zero exit once this returns.
543
552
  */
553
+ /**
554
+ * Story #5096 — resolve the advisory-gate terminal for one poll.
555
+ *
556
+ * Takes the poll's current `unlanded` and returns it unchanged when a terminal
557
+ * is already decided, so the caller is a single assignment with NO added
558
+ * branch. `runMergePoll` is already above `check-cyclomatic`'s ceiling; the
559
+ * three decision points this would otherwise cost inline are a real gate
560
+ * regression, and they belong with the policy either way.
561
+ *
562
+ * Disarms BEFORE returning the terminal: an armed PR can merge out from under
563
+ * the block the caller is about to record.
564
+ */
565
+ async function resolveAdvisoryUnlanded({
566
+ unlanded,
567
+ probe,
568
+ blockOnAdvisoryFailure,
569
+ advisoryAllowlist,
570
+ prNumber,
571
+ gh,
572
+ progress,
573
+ disarmAutoMergeFn,
574
+ elapsedSeconds,
575
+ }) {
576
+ if (unlanded) return unlanded;
577
+ const advisory = decideAdvisoryGateBlock({
578
+ probe,
579
+ blockOnAdvisoryFailure,
580
+ advisoryAllowlist,
581
+ });
582
+ if (!advisory) return null;
583
+ progress?.('CONFIRM', `🛑 PR #${prNumber}: ${advisory.reason}`);
584
+ await disarmAutoMergeFn({ prNumber, gh, progress });
585
+ return {
586
+ prProbe: probe,
587
+ budget: { exhausted: false, elapsedSeconds },
588
+ blockClassOverride: 'advisory-gate-red',
589
+ reasonOverride: advisory.reason,
590
+ };
591
+ }
592
+
544
593
  async function blockOnUnlanded({
545
594
  storyId,
546
595
  prNumber,
@@ -552,12 +601,24 @@ async function blockOnUnlanded({
552
601
  progress,
553
602
  classifyMergeBlockFn,
554
603
  emitMergeUnlandedFn,
604
+ blockClassOverride,
605
+ reasonOverride,
555
606
  }) {
556
- const { blockClass, reason } = classifyMergeBlockFn({
557
- armResult,
558
- prProbe,
559
- budget,
560
- });
607
+ // Story #5096 `advisory-gate-red` is emitted DIRECTLY, never derived.
608
+ // `classifyMergeBlock` cannot produce it: by construction GitHub is NOT
609
+ // gating this merge (`mergeStateStatus: UNSTABLE`), which is the entire
610
+ // condition the class names, so every classifier heuristic reads the PR as
611
+ // healthy.
612
+ const { blockClass, reason } = blockClassOverride
613
+ ? {
614
+ blockClass: blockClassOverride,
615
+ reason: reasonOverride ?? blockClassOverride,
616
+ }
617
+ : classifyMergeBlockFn({
618
+ armResult,
619
+ prProbe,
620
+ budget,
621
+ });
561
622
  const elapsedSeconds = budget?.elapsedSeconds ?? 0;
562
623
  // Which evidence path produced a `checks-failed` verdict (Story #4695):
563
624
  // `per-run` (head-anchored required-run evidence) or `consecutive-probe`
@@ -820,6 +881,7 @@ export async function runConfirmMergePhase({
820
881
  prUrl,
821
882
  autoMergeEnabled,
822
883
  autoMergeReason,
884
+ advisoryGate,
823
885
  provider,
824
886
  config,
825
887
  maxWaitSeconds: maxWaitSecondsOverride,
@@ -834,6 +896,7 @@ export async function runConfirmMergePhase({
834
896
  emitMergeUnlandedFn = defaultEmitMergeUnlanded,
835
897
  emitMergeFlipFailedFn = defaultEmitMergeFlipFailed,
836
898
  runPostLandTailFn = defaultRunPostLandTail,
899
+ disarmAutoMergeFn = disarmAutoMerge,
837
900
  sleepFn = defaultSleep,
838
901
  nowMsFn = Date.now,
839
902
  ghTimeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
@@ -857,6 +920,15 @@ export async function runConfirmMergePhase({
857
920
  progress,
858
921
  classifyMergeBlockFn,
859
922
  emitMergeUnlandedFn,
923
+ // Story #5096 — the arm phase already refused over a red advisory gate;
924
+ // carry its verdict through instead of letting the classifier read this
925
+ // as a generic `arm-failure`.
926
+ ...(autoMergeReason === 'advisory-gate-red'
927
+ ? {
928
+ blockClassOverride: 'advisory-gate-red',
929
+ reasonOverride: advisoryGate?.reason,
930
+ }
931
+ : {}),
860
932
  });
861
933
  }
862
934
 
@@ -871,6 +943,8 @@ export async function runConfirmMergePhase({
871
943
  maxWaitSecondsOverride,
872
944
  mergeWatchModeOverride,
873
945
  );
946
+ // Story #5096 — the advisory-gate knobs, read once for the whole wait.
947
+ const { blockOnAdvisoryFailure, advisoryAllowlist } = getCiDelivery(config);
874
948
  const intervalMs = intervalSeconds * 1000;
875
949
  const startedAtMs = nowMsFn();
876
950
  let anchorMs = startedAtMs;
@@ -1020,6 +1094,23 @@ export async function runConfirmMergePhase({
1020
1094
  },
1021
1095
  };
1022
1096
  }
1097
+
1098
+ // Story #5096 — the ADVISORY counterpart, and the half that catches the
1099
+ // common shape. Close arms immediately after opening the PR, while the
1100
+ // gate is still QUEUED, so the pre-arm refusal in `auto-merge.js` sees
1101
+ // nothing; the gate reddens here, mid-wait, and native auto-merge would
1102
+ // land the PR the moment the REQUIRED contexts go green.
1103
+ unlanded = await resolveAdvisoryUnlanded({
1104
+ unlanded,
1105
+ probe,
1106
+ blockOnAdvisoryFailure,
1107
+ advisoryAllowlist,
1108
+ prNumber,
1109
+ gh: injectedGh,
1110
+ progress,
1111
+ disarmAutoMergeFn,
1112
+ elapsedSeconds: Math.round(waitedMs / 1000),
1113
+ });
1023
1114
  }
1024
1115
 
1025
1116
  if (!unlanded) {
@@ -485,6 +485,7 @@ async function resolveAutoMergeOutcome({ alreadyMerged, ...phaseArgs }) {
485
485
  autoMergeReason: null,
486
486
  localCleanupDeferred: false,
487
487
  directMerged: false,
488
+ advisoryGate: null,
488
489
  };
489
490
  }
490
491
  return await runAutoMergePhase(phaseArgs);
@@ -541,6 +542,7 @@ async function finishWithMergeWait(prCtx, deps) {
541
542
  prUrl: prCtx.prUrl,
542
543
  autoMergeEnabled: prCtx.autoMergeEnabled,
543
544
  autoMergeReason: prCtx.autoMergeReason,
545
+ advisoryGate: prCtx.advisoryGate,
544
546
  provider: deps.provider,
545
547
  config: prCtx.config,
546
548
  maxWaitSeconds: deps.maxWaitSeconds,
@@ -796,18 +798,23 @@ async function runClosePipeline({
796
798
  WorktreeManager,
797
799
  });
798
800
  setPhase('auto-merge');
801
+ const ciDelivery = getCiDelivery(config);
799
802
  const {
800
803
  autoMergeEnabled,
801
804
  autoMergeReason,
802
805
  localCleanupDeferred,
803
806
  directMerged,
807
+ advisoryGate,
804
808
  } = await resolveAutoMergeOutcome({
805
809
  alreadyMerged,
806
810
  cwd: options.cwd,
807
811
  prNumber,
808
812
  prUrl,
809
813
  noAutoMerge: options.noAutoMerge,
810
- autoMergePolicy: getCiDelivery(config).autoMerge,
814
+ autoMergePolicy: ciDelivery.autoMerge,
815
+ // Story #5096 — the pre-arm advisory-gate refusal.
816
+ blockOnAdvisoryFailure: ciDelivery.blockOnAdvisoryFailure,
817
+ advisoryAllowlist: ciDelivery.advisoryAllowlist,
811
818
  gh: injectedGh,
812
819
  progress,
813
820
  });
@@ -866,6 +873,7 @@ async function runClosePipeline({
866
873
  prUrl,
867
874
  autoMergeEnabled,
868
875
  autoMergeReason,
876
+ advisoryGate,
869
877
  worktreeReaped,
870
878
  localCleanupDeferred,
871
879
  directMerged,
@@ -7,16 +7,10 @@
7
7
  * Single dispatch entry point for runtime notifications across two
8
8
  * independent channels.
9
9
  *
10
- * Lifecycle-bus integration (Epic #2172): under the Wave-7+ runtime,
11
- * `notify()` is invoked from the `NotifyDispatcher` listener
12
- * (`lib/orchestration/lifecycle/listeners/notify-dispatcher.js`),
13
- * which subscribes to lifecycle events and maps `event.severity` →
14
- * `notify()` payload. Direct inline calls at phase boundaries are no
15
- * longer the canonical path — listeners on the bus are. See
16
- * [`docs/LIFECYCLE.md`](../docs/LIFECYCLE.md) for the bus contract,
17
- * event taxonomy, and the dispatcher's wiring. Direct CLI / library
18
- * invocations remain supported for one-shot operator commands and the
19
- * structured-comment back-channel.
10
+ * Direct inline calls at phase boundaries are the only path: `notify()`
11
+ * is invoked from the caller that has something to say. Direct CLI /
12
+ * library invocations are equally supported, for one-shot operator
13
+ * commands and the structured-comment back-channel.
20
14
  *
21
15
  * Channels:
22
16
  *
@@ -38,9 +38,9 @@ union of:
38
38
  key for it.
39
39
 
40
40
  **Generated docs are excluded from per-doc semantic review.** The output of
41
- `generate-config-docs.js`, `generate-lifecycle-docs.js`, and
42
- `generate-workflows-doc.js`, and the synced `.claude/commands/` mirrors, are
43
- generator-owned: hand-editing them is never the remediation. Instead, Step 1
41
+ `generate-config-docs.js` and `generate-workflows-doc.js`, and the synced
42
+ `.claude/commands/` mirrors, are generator-owned: hand-editing them is never
43
+ the remediation. Instead, Step 1
44
44
  runs the generators' `--check` mode and emits a **single** "generator output
45
45
  dirty" finding when their output is stale — the remediation is "rerun the
46
46
  generator", not "edit the doc". Auto-generated changelog files
@@ -79,7 +79,6 @@ are cheap, exact, and de-duplicate the easy findings:
79
79
  ```bash
80
80
  node .agents/scripts/check-doc-links.js
81
81
  node .agents/scripts/generate-config-docs.js --check
82
- node .agents/scripts/generate-lifecycle-docs.js --check
83
82
  node .agents/scripts/generate-workflows-doc.js --check
84
83
  node .agents/scripts/resolve-doc-tiers.js --json
85
84
  ```
@@ -87,8 +86,8 @@ node .agents/scripts/resolve-doc-tiers.js --json
87
86
  Fold the results in as findings:
88
87
 
89
88
  - **Checker failures** (broken links, generator drift) become individual
90
- findings with `Category: Link Integrity` (or `Generator Drift` for the
91
- lifecycle gate), citing the checker output verbatim.
89
+ findings with `Category: Link Integrity`, citing the checker output
90
+ verbatim.
92
91
  - **Generator dirtiness** (any `--check` reporting stale output, including
93
92
  a stale `.claude/commands/` mirror) becomes **one single finding** with
94
93
  `Category: Generator Drift` — never per-line findings — whose
package/docs/CHANGELOG.md CHANGED
@@ -15,6 +15,24 @@ 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.36.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.35.0...mandrel-v2.36.0) (2026-08-29)
19
+
20
+
21
+ ### ⚠ BREAKING CHANGES
22
+
23
+ * `.agents/scripts/generate-lifecycle-docs.js` is removed from the published payload, and the framework no longer requires a consumer `docs/LIFECYCLE.md`. A consumer that created that file only to stop the generator aborting can delete it as residue — nothing else reads it. A consumer invoking the generator by hand, or chaining it in its own `docs:gen` / `docs:check`, should drop the call. The lifecycle schemas under `.agents/schemas/lifecycle/` still ship unchanged and the emit path is untouched.
24
+
25
+ ### Fixed
26
+
27
+ * **check-doc-links:** percent-decode relative link targets before the existence check (refs [#5090](https://github.com/dsj1984/mandrel/issues/5090)) ([#5092](https://github.com/dsj1984/mandrel/issues/5092)) ([024afff](https://github.com/dsj1984/mandrel/commit/024afff5aa000ace8e4f8ee2df6bf246acd0ae40))
28
+ * gate auto-merge arming and the merge wait on head-anchored advisory check conclusions ([#5096](https://github.com/dsj1984/mandrel/issues/5096)) ([#5097](https://github.com/dsj1984/mandrel/issues/5097)) ([a6ae194](https://github.com/dsj1984/mandrel/commit/a6ae194349951ab8cc459f9a4d21a46785014ffd))
29
+ * **git-cleanup:** branch the merged-tip skip taxonomy on ancestry instead of SHA inequality ([#5086](https://github.com/dsj1984/mandrel/issues/5086)) ([#5087](https://github.com/dsj1984/mandrel/issues/5087)) ([caf41b0](https://github.com/dsj1984/mandrel/commit/caf41b0a973866ba1035c5c10178eb46e7c1b078))
30
+
31
+
32
+ ### Changed
33
+
34
+ * stop requiring consumers to carry docs/LIFECYCLE.md — retire the consumer-side lifecycle doc mirror ([#5089](https://github.com/dsj1984/mandrel/issues/5089)) ([#5091](https://github.com/dsj1984/mandrel/issues/5091)) ([ad16df8](https://github.com/dsj1984/mandrel/commit/ad16df881692fc5dd169bc4f0bcf73191a872345))
35
+
18
36
  ## [2.35.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.34.0...mandrel-v2.35.0) (2026-08-28)
19
37
 
20
38
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.35.0",
3
+ "version": "2.36.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/",
@@ -32,8 +32,8 @@
32
32
  "baselines:prune": "node .agents/scripts/prune-baseline-orphans.js",
33
33
  "lint:md": "markdownlint-cli2 \".agents/**/*.md\" \"*.md\" \"!node_modules/**\" \"!.worktrees/**\"",
34
34
  "lint": "node .agents/scripts/run-lint.js && npm run docs:check",
35
- "docs:gen": "node .agents/scripts/generate-config-docs.js && node .agents/scripts/generate-lifecycle-docs.js && node .agents/scripts/generate-workflows-doc.js && node .agents/scripts/generate-lens-checklists.js",
36
- "docs:check": "node .agents/scripts/generate-config-docs.js --check && node .agents/scripts/generate-lifecycle-docs.js --check && node .agents/scripts/generate-workflows-doc.js --check && node .agents/scripts/generate-lens-checklists.js --check && node .agents/scripts/check-doc-links.js && npm run skills:check",
35
+ "docs:gen": "node .agents/scripts/generate-config-docs.js && node .agents/scripts/generate-workflows-doc.js && node .agents/scripts/generate-lens-checklists.js",
36
+ "docs:check": "node .agents/scripts/generate-config-docs.js --check && node .agents/scripts/generate-workflows-doc.js --check && node .agents/scripts/generate-lens-checklists.js --check && node .agents/scripts/check-doc-links.js && npm run skills:check",
37
37
  "skills:index": "node .agents/scripts/generate-skills-index.js",
38
38
  "skills:check": "node .agents/scripts/generate-skills-index.js --check",
39
39
  "format": "biome format --write .",