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