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
@@ -12,13 +12,11 @@ import {
12
12
  runArtifactPath,
13
13
  tempRootFrom,
14
14
  } from '../config/temp-paths.js';
15
- import { Logger } from '../Logger.js';
16
15
 
17
16
  export const PLAN_METRICS_BASENAME = 'plan-metrics.json';
18
17
  export const PLAN_METRICS_SCHEMA_VERSION = 1;
19
18
 
20
19
  /** Evidence for dropping a lens that stays at zero findings. */
21
- const PLAN_METRICS_KIND_FINDINGS_YIELD = 'findings-yield';
22
20
 
23
21
  /** ~5000 records; rotation only fires on pathological accumulation. */
24
22
  export const MAX_LEDGER_BYTES = 1024 * 1024;
@@ -85,73 +83,3 @@ export async function appendLedgerRecord(record, opts = {}) {
85
83
  );
86
84
  await fs.appendFile(filePath, line, 'utf8');
87
85
  }
88
-
89
- /**
90
- * Append one findings-yield record. Never throws.
91
- *
92
- * @param {{
93
- * storyId: number,
94
- * lenses: Array<{ lens: string, findings?: number, skippedByFloor?: boolean }>,
95
- * cli?: string,
96
- * epicId?: number|null,
97
- * diffFloor?: object|null,
98
- * }} entry
99
- * @param {object} [config]
100
- * @param {{ maxBytes?: number }} [opts]
101
- * @returns {Promise<boolean>} true when the line was written.
102
- */
103
- export async function appendFindingsYield(entry, config, opts = {}) {
104
- try {
105
- if (!entry || typeof entry !== 'object') {
106
- throw new TypeError('appendFindingsYield requires an entry object');
107
- }
108
- const storyId = Number(entry.storyId);
109
- if (!Number.isInteger(storyId) || storyId <= 0) {
110
- throw new TypeError(
111
- 'appendFindingsYield requires a positive integer entry.storyId',
112
- );
113
- }
114
- if (!Array.isArray(entry.lenses) || entry.lenses.length === 0) {
115
- throw new TypeError(
116
- 'appendFindingsYield requires a non-empty entry.lenses array',
117
- );
118
- }
119
- const epicId = entry.epicId ?? null;
120
- const record = {
121
- v: PLAN_METRICS_SCHEMA_VERSION,
122
- kind: PLAN_METRICS_KIND_FINDINGS_YIELD,
123
- cli:
124
- typeof entry.cli === 'string' && entry.cli.length > 0
125
- ? entry.cli
126
- : 'story-close-review',
127
- storyId,
128
- epicId,
129
- lenses: entry.lenses
130
- .filter((l) => l && typeof l.lens === 'string' && l.lens.length > 0)
131
- .map((l) => ({
132
- lens: l.lens,
133
- findings:
134
- typeof l.findings === 'number' && Number.isFinite(l.findings)
135
- ? l.findings
136
- : 0,
137
- skippedByFloor: l.skippedByFloor === true,
138
- })),
139
- diffFloor:
140
- entry.diffFloor && typeof entry.diffFloor === 'object'
141
- ? entry.diffFloor
142
- : null,
143
- at: new Date().toISOString(),
144
- };
145
- await appendLedgerRecord(record, {
146
- epicId,
147
- config,
148
- maxBytes: opts.maxBytes,
149
- });
150
- return true;
151
- } catch (err) {
152
- Logger.warn(
153
- `[plan-metrics] findings-yield append failed (non-fatal): ${err?.message ?? err}`,
154
- );
155
- return false;
156
- }
157
- }
@@ -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
+ }
@@ -116,12 +116,17 @@ function resolveScopeEnvelope(opts, config) {
116
116
  * blockerReason: string|null,
117
117
  * }>}
118
118
  */
119
- /** Display name: the single entry's name, `chain[a,b]`, or `'native'`. */
120
- function resolveProviderName(codeReviewConfig) {
119
+ /**
120
+ * Display name: the single entry's name, `chain[a,b]`, or `'native'`. An
121
+ * unset chain names the entries the factory actually built, so a skipped
122
+ * optional provider is not reported as having run.
123
+ */
124
+ function resolveProviderName(codeReviewConfig, reviewProvider) {
125
+ const configured = Array.isArray(codeReviewConfig?.providers)
126
+ ? codeReviewConfig.providers
127
+ : [];
121
128
  const providers =
122
- codeReviewConfig && Array.isArray(codeReviewConfig.providers)
123
- ? codeReviewConfig.providers
124
- : [];
129
+ configured.length > 0 ? configured : (reviewProvider?.chain?.inline ?? []);
125
130
  if (providers.length === 1) {
126
131
  return providers[0]?.name ?? 'native';
127
132
  }
@@ -236,9 +241,9 @@ async function executeReviewPipeline({ opts, config, envelope }) {
236
241
  const { scope, ticketId, baseRef, headRef, commentTargetId } = envelope;
237
242
 
238
243
  const codeReviewConfig = config?.delivery?.codeReview ?? null;
239
- const providerName = resolveProviderName(codeReviewConfig);
240
244
  const reviewProvider =
241
245
  injectedReviewProvider ?? createReviewProviderFn(codeReviewConfig);
246
+ const providerName = resolveProviderName(codeReviewConfig, reviewProvider);
242
247
 
243
248
  logger?.info?.(
244
249
  `[code-review] Running ${providerName} adapter for Story #${ticketId} (${baseRef}...${headRef})...`,
@@ -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) {
@@ -24,6 +24,7 @@ import { ColumnSync, LABEL_TO_COLUMN } from './column-sync.js';
24
24
  import {
25
25
  isEpicTicket,
26
26
  nativeChildReader,
27
+ readEpicChildIds,
27
28
  readEpicChildIdsFrom,
28
29
  } from './epic-container.js';
29
30
  import { resolveOperatorFromCandidates } from './lease-guard-shared.js';
@@ -302,26 +303,29 @@ async function rollUpOneEpic({
302
303
 
303
304
  /**
304
305
  * Resolve this Story's container in one request via the native parent edge.
305
- * `null` (never throws) means "no answer here" — linkage may still exist as
306
- * a body checklist row, which the caller's scan covers.
306
+ * `authoritative`: the lookup answered, so no parent means no native edge.
307
307
  *
308
308
  * @param {{ storyId: number, provider: object }} opts
309
- * @returns {Promise<object|null>} Mapped parent Epic, or null.
309
+ * @returns {Promise<{ parent: object|null, authoritative: boolean }>}
310
310
  */
311
311
  async function parentEpicFor({ storyId, provider }) {
312
- if (typeof provider?.getParentIssue !== 'function') return null;
312
+ if (typeof provider?.getParentIssue !== 'function') {
313
+ return { parent: null, authoritative: false };
314
+ }
313
315
  let parent;
314
316
  try {
315
317
  parent = await provider.getParentIssue(storyId);
316
318
  } catch (err) {
317
319
  Logger.warn(
318
320
  `[epic-rollup] Parent lookup for Story #${storyId} degraded ` +
319
- `(${err?.message ?? err}); falling back to the label scan.`,
321
+ `(${err?.message ?? err}); falling back to the full label scan.`,
320
322
  );
321
- return null;
323
+ return { parent: null, authoritative: false };
322
324
  }
323
- if (!parent || !isEpicTicket(parent)) return null;
324
- return parent;
325
+ return {
326
+ parent: parent && isEpicTicket(parent) ? parent : null,
327
+ authoritative: true,
328
+ };
325
329
  }
326
330
 
327
331
  /**
@@ -349,6 +353,18 @@ async function scanContainerEpics({ provider }) {
349
353
  return (Array.isArray(epics) ? epics : []).filter(isEpicTicket);
350
354
  }
351
355
 
356
+ /**
357
+ * `bodyOnly` keeps only Epics whose checklist names the Story (no requests).
358
+ *
359
+ * @param {{ storyId: number, provider: object, bodyOnly: boolean }} opts
360
+ * @returns {Promise<object[]>}
361
+ */
362
+ async function candidateEpics({ storyId, provider, bodyOnly }) {
363
+ const epics = await scanContainerEpics({ provider });
364
+ if (!bodyOnly) return epics;
365
+ return epics.filter((epic) => readEpicChildIds(epic?.body).includes(storyId));
366
+ }
367
+
352
368
  /**
353
369
  * Find the container Epics holding a Story (Story bodies carry no parent
354
370
  * pointer). Native edge first, scan otherwise; a scanned Epic must prove it
@@ -358,9 +374,10 @@ async function scanContainerEpics({ provider }) {
358
374
  * @returns {Promise<Array<{ epic: object, childIds: number[], nativeReadFailed: boolean, bodyOnlyIds: number[] }>>}
359
375
  */
360
376
  async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
361
- const parent = await parentEpicFor({ storyId, provider });
362
- const epics = parent ? [parent] : await scanContainerEpics({ provider });
363
- const authoritative = parent !== null;
377
+ const { parent, authoritative } = await parentEpicFor({ storyId, provider });
378
+ const epics = parent
379
+ ? [parent]
380
+ : await candidateEpics({ storyId, provider, bodyOnly: authoritative });
364
381
 
365
382
  const matches = [];
366
383
  for (const epic of epics) {
@@ -378,7 +395,7 @@ async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
378
395
  readNativeChildIds: nativeChildReader(provider),
379
396
  onWarn: (message) => Logger.warn(message),
380
397
  });
381
- if (!authoritative && !childIds.includes(storyId)) {
398
+ if (!parent && !childIds.includes(storyId)) {
382
399
  // A degraded read may have truncated this Story out; skip, but say so.
383
400
  if (nativeReadFailed) {
384
401
  Logger.warn(
@@ -77,6 +77,25 @@ function describeApiRaceFallback(prProbe, budget) {
77
77
  return 'no definitive block signal observed; classified as a transient API race or other condition';
78
78
  }
79
79
 
80
+ /**
81
+ * Positive evidence a check is still running. Attributed evidence scopes
82
+ * `requiredRunInFlight` to re-runs; `runInFlight` still says "something is
83
+ * running".
84
+ *
85
+ * @param {object} [prProbe]
86
+ * @returns {boolean}
87
+ */
88
+ function hasChecksPendingEvidence(prProbe) {
89
+ const status = prProbe?.checksStatus;
90
+ const evidence = prProbe?.requiredRunEvidence;
91
+ return (
92
+ status === 'pending' ||
93
+ status === 'still-running' ||
94
+ evidence?.requiredRunInFlight === true ||
95
+ evidence?.runInFlight === true
96
+ );
97
+ }
98
+
80
99
  /**
81
100
  * Classify why a delivery run finished without a confirmed merge. First match
82
101
  * wins: (1) arm failure (a protection rejection at arm time still routes to
@@ -123,10 +142,7 @@ export function classifyMergeBlock(input) {
123
142
  // Positive evidence only: `unknown` routes to the fallback; `undefined`
124
143
  // (no probe) keeps the step-2 mapping.
125
144
  const checksStatus = prProbe?.checksStatus;
126
- const checksPendingEvidence =
127
- checksStatus === 'pending' ||
128
- checksStatus === 'still-running' ||
129
- prProbe?.requiredRunEvidence?.requiredRunInFlight === true;
145
+ const checksPendingEvidence = hasChecksPendingEvidence(prProbe);
130
146
 
131
147
  // 1b. Definitive, and before step 3 since it also presents as BLOCKED.
132
148
  // Head-anchored evidence, not the raw rollup (optional/superseded runs).
@@ -8,13 +8,29 @@ import { checkVerdict, classifyRollupEntry } from './check-state.js';
8
8
 
9
9
  /** Fixed poll interval; default for `delivery.mergeWatch.maxBudgetSeconds`. */
10
10
  export const DEFAULT_INTERVAL_SECONDS = 30;
11
+
12
+ /** Checks green, PR unmerged: the merge is imminent, so observe it sooner. */
13
+ const GREEN_INTERVAL_SECONDS = 10;
14
+
15
+ /**
16
+ * @param {string|undefined} checksStatus
17
+ * @param {number} intervalSeconds The in-flight cadence.
18
+ * @returns {number} Milliseconds until the next poll.
19
+ */
20
+ export function pollIntervalMs(checksStatus, intervalSeconds) {
21
+ const seconds =
22
+ checksStatus === 'success'
23
+ ? Math.min(GREEN_INTERVAL_SECONDS, intervalSeconds)
24
+ : intervalSeconds;
25
+ return seconds * 1000;
26
+ }
11
27
  export const DEFAULT_MAX_BUDGET_SECONDS = 3600;
12
28
 
13
29
  /** Bounds every `gh` spawn so a hang degrades to the probe-error path. */
14
30
  export const MERGE_WAIT_GH_TIMEOUT_MS = 60_000;
15
31
 
16
32
  /**
17
- * Aggregate over EVERY check (the rollup has no `isRequired`): `failure`
33
+ * Aggregate over EVERY check (the view rollup has no `isRequired`): `failure`
18
34
  * means "something is red", not "blocked" — see {@link failingChecksBlockMerge}.
19
35
  */
20
36
  export function deriveChecksStatus(statusCheckRollup) {
@@ -47,7 +63,7 @@ export function isPrMerged(pr) {
47
63
  * @param {{ conclusion?: string, state?: string }} [check]
48
64
  * @returns {string|null}
49
65
  */
50
- function redConclusionOf(check) {
66
+ export function redConclusionOf(check) {
51
67
  const conclusion = String(check?.conclusion ?? '').toUpperCase();
52
68
  if (conclusion === 'FAILURE' || conclusion === 'ERROR') return conclusion;
53
69
  const state = String(check?.state ?? '').toUpperCase();
@@ -61,17 +77,29 @@ function redConclusionOf(check) {
61
77
  * @param {{ name?: string, context?: string }} [check]
62
78
  * @returns {string|null}
63
79
  */
64
- function readRunName(check) {
80
+ export function readRunName(check) {
65
81
  for (const value of [check?.name, check?.context]) {
66
82
  if (typeof value === 'string' && value) return value;
67
83
  }
68
84
  return null;
69
85
  }
70
86
 
87
+ /**
88
+ * @param {{ status?: string, state?: string }} [check]
89
+ * @returns {boolean}
90
+ */
91
+ export function isRunInFlight(check) {
92
+ const status = String(check?.status ?? '').toUpperCase();
93
+ // `status` is empty on a StatusContext, so it falls to the `state` branch.
94
+ if (status) return status !== 'COMPLETED';
95
+ const state = String(check?.state ?? '').toUpperCase();
96
+ return state === 'PENDING' || state === 'EXPECTED';
97
+ }
98
+
71
99
  /**
72
100
  * Head-anchored evidence; `null` on an empty rollup (the caller falls back
73
- * to consecutive probes). Reads EVERY run despite the names — required-ness
74
- * is attributed via `BLOCKED` in {@link requiredCheckFailedBlocksMerge}.
101
+ * to consecutive probes). Reads EVERY run — the unscoped rule used when
102
+ * GitHub's required attribution is unavailable (see `required-checks.js`).
75
103
  *
76
104
  * @param {Array<{status?: string, conclusion?: string, state?: string}>} statusCheckRollup
77
105
  * @returns {{ requiredRunFailed: boolean, requiredRunInFlight: boolean } | null}
@@ -80,22 +108,12 @@ export function deriveRequiredRunEvidence(statusCheckRollup) {
80
108
  if (!Array.isArray(statusCheckRollup) || statusCheckRollup.length === 0) {
81
109
  return null;
82
110
  }
83
- let requiredRunFailed = false;
84
- let requiredRunInFlight = false;
85
- for (const check of statusCheckRollup) {
86
- const status = String(check?.status ?? '').toUpperCase();
87
- const state = String(check?.state ?? '').toUpperCase();
88
- // `status` is empty on a StatusContext, so it falls to the `state` branch.
89
- if (status && status !== 'COMPLETED') {
90
- requiredRunInFlight = true;
91
- } else if (state === 'PENDING' || state === 'EXPECTED') {
92
- requiredRunInFlight = true;
93
- }
94
- if (redConclusionOf(check)) {
95
- requiredRunFailed = true;
96
- }
97
- }
98
- return { requiredRunFailed, requiredRunInFlight };
111
+ return {
112
+ requiredRunFailed: statusCheckRollup.some(
113
+ (c) => redConclusionOf(c) !== null,
114
+ ),
115
+ requiredRunInFlight: statusCheckRollup.some(isRunInFlight),
116
+ };
99
117
  }
100
118
 
101
119
  /** The `mergeStateStatus` meaning GitHub itself gates the merge. */
@@ -139,7 +157,8 @@ export function formatChecksFailedReason(prProbe, evidencePath) {
139
157
 
140
158
  /**
141
159
  * A genuinely red REQUIRED check: gated, no review owns `BLOCKED`, a run is
142
- * red and none in flight. No evidence → false (consecutive-probe path).
160
+ * red and none in flight — with GitHub attribution, a required run is red and
161
+ * no re-run of it is in flight. No evidence → false (consecutive-probe path).
143
162
  *
144
163
  * @param {{ checksStatus?: string, mergeStateStatus?: string,
145
164
  * reviewDecision?: string,
@@ -0,0 +1,147 @@
1
+ /**
2
+ * GitHub's per-PR required-check attribution (GraphQL `isRequired`), not
3
+ * `.agentrc` requiredChecks, which are local command names.
4
+ */
5
+
6
+ import {
7
+ deriveRequiredRunEvidence,
8
+ failingChecksBlockMerge,
9
+ isRunInFlight,
10
+ MERGE_WAIT_GH_TIMEOUT_MS,
11
+ readRunName,
12
+ redConclusionOf,
13
+ } from './merge-poll.js';
14
+
15
+ /**
16
+ * A required run is red and no re-run of that same check is in flight.
17
+ *
18
+ * @param {Array<object>} statusCheckRollup non-empty
19
+ * @param {Set<string>} requiredNames
20
+ */
21
+ function deriveAttributedEvidence(statusCheckRollup, requiredNames) {
22
+ const failedRequired = new Set();
23
+ for (const check of statusCheckRollup) {
24
+ const name = readRunName(check);
25
+ if (name && requiredNames.has(name) && redConclusionOf(check)) {
26
+ failedRequired.add(name);
27
+ }
28
+ }
29
+ let requiredRunInFlight = false;
30
+ let runInFlight = false;
31
+ for (const check of statusCheckRollup) {
32
+ if (!isRunInFlight(check)) continue;
33
+ runInFlight = true;
34
+ if (failedRequired.has(readRunName(check))) requiredRunInFlight = true;
35
+ }
36
+ return {
37
+ requiredRunFailed: failedRequired.size > 0,
38
+ requiredRunInFlight,
39
+ runInFlight,
40
+ attribution: 'github',
41
+ };
42
+ }
43
+
44
+ const REQUIRED_CHECKS_QUERY =
45
+ 'query($id: ID!, $n: Int!) { node(id: $id) { ... on PullRequest { ' +
46
+ 'commits(last: 1) { nodes { commit { statusCheckRollup { ' +
47
+ 'contexts(first: 100) { nodes { __typename ' +
48
+ '... on CheckRun { name isRequired(pullRequestNumber: $n) } ' +
49
+ '... on StatusContext { context isRequired(pullRequestNumber: $n) } ' +
50
+ '} } } } } } } } }';
51
+
52
+ /**
53
+ * @param {object|string} result `gh api graphql` output
54
+ * @returns {Set<string>}
55
+ */
56
+ function parseRequiredNames(result) {
57
+ const text = typeof result === 'string' ? result : result?.stdout;
58
+ const parsed = JSON.parse(String(text ?? ''));
59
+ if (Array.isArray(parsed?.errors) && parsed.errors.length > 0) {
60
+ throw new Error('graphql errors reading required checks');
61
+ }
62
+ const nodes =
63
+ parsed?.data?.node?.commits?.nodes?.[0]?.commit?.statusCheckRollup?.contexts
64
+ ?.nodes;
65
+ if (!Array.isArray(nodes)) throw new Error('required-check contexts absent');
66
+ const names = new Set();
67
+ for (const node of nodes) {
68
+ const name = readRunName(node);
69
+ if (node?.isRequired === true && name) names.add(name);
70
+ }
71
+ return names;
72
+ }
73
+
74
+ /** Per-head cache: required attribution is read at most once per PR head. */
75
+ const requiredNamesCache = new Map();
76
+
77
+ /**
78
+ * Required check names, cached per PR head. `null` on any failure.
79
+ *
80
+ * @param {{ prNodeId?: string, prNumber: number|string, headSha?: string,
81
+ * gh: { api: Function }, timeoutMs?: number }} args
82
+ * @returns {Promise<Set<string>|null>}
83
+ */
84
+ async function readRequiredCheckNames({
85
+ prNodeId,
86
+ prNumber,
87
+ headSha,
88
+ gh,
89
+ timeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
90
+ }) {
91
+ if (!prNodeId || !headSha) return null;
92
+ const key = `${prNodeId}@${headSha}`;
93
+ if (requiredNamesCache.has(key)) return requiredNamesCache.get(key);
94
+ try {
95
+ const names = parseRequiredNames(
96
+ await gh.api({
97
+ method: 'POST',
98
+ endpoint: 'graphql',
99
+ body: {
100
+ query: REQUIRED_CHECKS_QUERY,
101
+ variables: { id: prNodeId, n: Number(prNumber) },
102
+ },
103
+ execOpts: { timeoutMs },
104
+ }),
105
+ );
106
+ requiredNamesCache.set(key, names);
107
+ return names;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Scoped evidence when a red gates the merge and attribution reads; else
115
+ * the unscoped rule.
116
+ *
117
+ * @param {{ view?: object, checksStatus?: string, prNumber: number|string,
118
+ * gh: object, ghTimeoutMs?: number, readFn?: Function }} args
119
+ * @returns {Promise<object|null>}
120
+ */
121
+ export async function readProbeRunEvidence({
122
+ view,
123
+ checksStatus,
124
+ prNumber,
125
+ gh,
126
+ ghTimeoutMs,
127
+ readFn = readRequiredCheckNames,
128
+ }) {
129
+ const rollup = view?.statusCheckRollup;
130
+ const gated = failingChecksBlockMerge({
131
+ checksStatus,
132
+ mergeStateStatus: view?.mergeStateStatus,
133
+ });
134
+ const names = gated
135
+ ? await readFn({
136
+ prNodeId: view?.id,
137
+ prNumber,
138
+ headSha: view?.headRefOid,
139
+ gh,
140
+ timeoutMs: ghTimeoutMs,
141
+ })
142
+ : null;
143
+ if (names instanceof Set && Array.isArray(rollup) && rollup.length > 0) {
144
+ return deriveAttributedEvidence(rollup, names);
145
+ }
146
+ return deriveRequiredRunEvidence(rollup);
147
+ }