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.
- package/.agents/docs/agentrc-reference.json +3 -1
- package/.agents/docs/configuration.md +2 -0
- package/.agents/schemas/agentrc.schema.json +11 -0
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/check-doc-links.js +23 -2
- package/.agents/scripts/git-cleanup.js +2 -0
- package/.agents/scripts/lib/config/ci.js +18 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
- package/.agents/scripts/lib/observability/source-classifier.js +0 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
- package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
- package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
- package/.agents/scripts/notify.js +4 -10
- package/.agents/workflows/audit-documentation.md +5 -6
- package/docs/CHANGELOG.md +18 -0
- package/package.json +3 -3
- 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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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:
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
91
|
-
|
|
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.
|
|
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-
|
|
36
|
-
"docs:check": "node .agents/scripts/generate-config-docs.js --check && node .agents/scripts/generate-
|
|
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 .",
|