agent-gauntlet 0.10.0 → 0.11.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/README.md +25 -23
- package/dist/index.js +9226 -0
- package/dist/index.js.map +65 -0
- package/dist/scripts/status.js +280 -0
- package/dist/scripts/status.js.map +10 -0
- package/package.json +22 -8
- package/src/built-in-reviews/code-quality.md +0 -25
- package/src/built-in-reviews/index.ts +0 -28
- package/src/bun-plugins.d.ts +0 -4
- package/src/cli-adapters/claude.ts +0 -327
- package/src/cli-adapters/codex.ts +0 -290
- package/src/cli-adapters/cursor.ts +0 -128
- package/src/cli-adapters/gemini.ts +0 -510
- package/src/cli-adapters/github-copilot.ts +0 -141
- package/src/cli-adapters/index.ts +0 -250
- package/src/cli-adapters/thinking-budget.ts +0 -23
- package/src/commands/check.ts +0 -311
- package/src/commands/ci/index.ts +0 -15
- package/src/commands/ci/init.ts +0 -96
- package/src/commands/ci/list-jobs.ts +0 -90
- package/src/commands/clean.ts +0 -54
- package/src/commands/detect.ts +0 -173
- package/src/commands/health.ts +0 -169
- package/src/commands/help.ts +0 -34
- package/src/commands/index.ts +0 -13
- package/src/commands/init.ts +0 -1878
- package/src/commands/list.ts +0 -33
- package/src/commands/review.ts +0 -311
- package/src/commands/run.ts +0 -29
- package/src/commands/shared.ts +0 -267
- package/src/commands/stop-hook.ts +0 -567
- package/src/commands/validate.ts +0 -20
- package/src/commands/wait-ci.ts +0 -518
- package/src/config/ci-loader.ts +0 -33
- package/src/config/ci-schema.ts +0 -28
- package/src/config/global.ts +0 -87
- package/src/config/loader.ts +0 -301
- package/src/config/schema.ts +0 -165
- package/src/config/stop-hook-config.ts +0 -130
- package/src/config/types.ts +0 -65
- package/src/config/validator.ts +0 -592
- package/src/core/change-detector.ts +0 -137
- package/src/core/diff-stats.ts +0 -442
- package/src/core/entry-point.ts +0 -190
- package/src/core/job.ts +0 -96
- package/src/core/run-executor.ts +0 -621
- package/src/core/runner.ts +0 -290
- package/src/gates/check.ts +0 -118
- package/src/gates/resolve-check-command.ts +0 -21
- package/src/gates/result.ts +0 -54
- package/src/gates/review.ts +0 -1333
- package/src/hooks/adapters/claude-stop-hook.ts +0 -99
- package/src/hooks/adapters/cursor-stop-hook.ts +0 -122
- package/src/hooks/adapters/types.ts +0 -94
- package/src/hooks/stop-hook-handler.ts +0 -748
- package/src/index.ts +0 -47
- package/src/output/app-logger.ts +0 -214
- package/src/output/console-log.ts +0 -168
- package/src/output/console.ts +0 -359
- package/src/output/logger.ts +0 -126
- package/src/output/sinks/console-sink.ts +0 -59
- package/src/output/sinks/file-sink.ts +0 -110
- package/src/scripts/status.ts +0 -433
- package/src/templates/workflow.yml +0 -79
- package/src/types/gauntlet-status.ts +0 -79
- package/src/utils/debug-log.ts +0 -392
- package/src/utils/diff-parser.ts +0 -103
- package/src/utils/execution-state.ts +0 -472
- package/src/utils/log-parser.ts +0 -696
- package/src/utils/sanitizer.ts +0 -3
- package/src/utils/session-ref.ts +0 -91
|
@@ -1,748 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import fs from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { promisify } from "node:util";
|
|
5
|
-
import YAML from "yaml";
|
|
6
|
-
import type { WaitCIResult } from "../commands/wait-ci.js";
|
|
7
|
-
import { loadGlobalConfig } from "../config/global.js";
|
|
8
|
-
import type { StopHookConfig } from "../config/stop-hook-config.js";
|
|
9
|
-
import { resolveStopHookConfig } from "../config/stop-hook-config.js";
|
|
10
|
-
import { executeRun } from "../core/run-executor.js";
|
|
11
|
-
import { getCategoryLogger } from "../output/app-logger.js";
|
|
12
|
-
import {
|
|
13
|
-
type GauntletStatus,
|
|
14
|
-
isBlockingStatus,
|
|
15
|
-
type RunResult,
|
|
16
|
-
} from "../types/gauntlet-status.js";
|
|
17
|
-
import type { DebugLogger } from "../utils/debug-log.js";
|
|
18
|
-
import { writeExecutionState } from "../utils/execution-state.js";
|
|
19
|
-
import type {
|
|
20
|
-
PRStatusResult,
|
|
21
|
-
StopHookContext,
|
|
22
|
-
StopHookResult,
|
|
23
|
-
} from "./adapters/types.js";
|
|
24
|
-
|
|
25
|
-
const execFileAsync = promisify(execFile);
|
|
26
|
-
|
|
27
|
-
interface MinimalConfig {
|
|
28
|
-
log_dir?: string;
|
|
29
|
-
debug_log?: {
|
|
30
|
-
enabled?: boolean;
|
|
31
|
-
max_size_mb?: number;
|
|
32
|
-
};
|
|
33
|
-
stop_hook?: {
|
|
34
|
-
auto_push_pr?: boolean;
|
|
35
|
-
auto_fix_pr?: boolean;
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Marker file for tracking CI wait attempts.
|
|
41
|
-
*/
|
|
42
|
-
const CI_WAIT_ATTEMPTS_FILE = ".ci-wait-attempts";
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Maximum number of CI wait attempts before giving up.
|
|
46
|
-
*/
|
|
47
|
-
const MAX_CI_WAIT_ATTEMPTS = 3;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Default log directory when config doesn't specify one.
|
|
51
|
-
*/
|
|
52
|
-
const DEFAULT_LOG_DIR = "gauntlet_logs";
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Read and parse the project config file.
|
|
56
|
-
* Returns undefined if the file doesn't exist or can't be parsed.
|
|
57
|
-
*/
|
|
58
|
-
async function readProjectConfig(
|
|
59
|
-
projectCwd: string,
|
|
60
|
-
): Promise<MinimalConfig | undefined> {
|
|
61
|
-
try {
|
|
62
|
-
const configPath = path.join(projectCwd, ".gauntlet", "config.yml");
|
|
63
|
-
const content = await fs.readFile(configPath, "utf-8");
|
|
64
|
-
return YAML.parse(content) as MinimalConfig;
|
|
65
|
-
} catch {
|
|
66
|
-
return undefined;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
interface FailedGateLog {
|
|
71
|
-
/** Log file paths for failed check gates */
|
|
72
|
-
checkLogs: string[];
|
|
73
|
-
/** JSON file paths for failed review gates */
|
|
74
|
-
reviewJsons: string[];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Get a logger for stop-hook operations.
|
|
79
|
-
*/
|
|
80
|
-
function getStopHookLogger() {
|
|
81
|
-
return getCategoryLogger("stop-hook");
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Extract failed gate log paths from gate results.
|
|
86
|
-
*/
|
|
87
|
-
function getFailedGateLogs(
|
|
88
|
-
gateResults?: RunResult["gateResults"],
|
|
89
|
-
): FailedGateLog {
|
|
90
|
-
const checkLogs: string[] = [];
|
|
91
|
-
const reviewJsons: string[] = [];
|
|
92
|
-
|
|
93
|
-
if (!gateResults) return { checkLogs, reviewJsons };
|
|
94
|
-
|
|
95
|
-
for (const gate of gateResults) {
|
|
96
|
-
if (gate.status === "pass") continue;
|
|
97
|
-
|
|
98
|
-
const isReview = gate.jobId.startsWith("review:");
|
|
99
|
-
|
|
100
|
-
if (gate.subResults) {
|
|
101
|
-
for (const sub of gate.subResults) {
|
|
102
|
-
if (sub.status === "pass" || !sub.logPath) continue;
|
|
103
|
-
if (isReview) {
|
|
104
|
-
reviewJsons.push(sub.logPath);
|
|
105
|
-
} else {
|
|
106
|
-
checkLogs.push(sub.logPath);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
} else if (isReview) {
|
|
110
|
-
// Review gate without subResults — check logPaths then logPath
|
|
111
|
-
const paths = gate.logPaths ?? (gate.logPath ? [gate.logPath] : []);
|
|
112
|
-
for (const p of paths) {
|
|
113
|
-
if (p.endsWith(".json")) {
|
|
114
|
-
reviewJsons.push(p);
|
|
115
|
-
} else {
|
|
116
|
-
checkLogs.push(p);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
} else {
|
|
120
|
-
// Check gate
|
|
121
|
-
const logPath = gate.logPath ?? gate.logPaths?.[0];
|
|
122
|
-
if (logPath) {
|
|
123
|
-
checkLogs.push(logPath);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return { checkLogs, reviewJsons };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Get the enhanced stop reason instructions for the agent.
|
|
133
|
-
* Includes trust level guidance (when reviews fail), violation handling,
|
|
134
|
-
* termination conditions, and paths to failed gate log files.
|
|
135
|
-
*/
|
|
136
|
-
export function getStopReasonInstructions(
|
|
137
|
-
gateResults?: RunResult["gateResults"],
|
|
138
|
-
): string {
|
|
139
|
-
const { checkLogs, reviewJsons } = getFailedGateLogs(gateResults);
|
|
140
|
-
const hasReviewFailures = reviewJsons.length > 0;
|
|
141
|
-
|
|
142
|
-
const trustLevelSection = hasReviewFailures
|
|
143
|
-
? `\n**Review trust level: medium** — Fix issues you reasonably agree with or believe the human wants fixed. Skip issues that are purely stylistic, subjective, or that you believe the human would not want changed.\n`
|
|
144
|
-
: "";
|
|
145
|
-
|
|
146
|
-
let failedLogsSection = "";
|
|
147
|
-
if (checkLogs.length > 0 || reviewJsons.length > 0) {
|
|
148
|
-
failedLogsSection = "\n\n**Failed gate logs:**";
|
|
149
|
-
for (const logPath of checkLogs) {
|
|
150
|
-
failedLogsSection += `\n- Check: \`${logPath}\``;
|
|
151
|
-
}
|
|
152
|
-
for (const jsonPath of reviewJsons) {
|
|
153
|
-
failedLogsSection += `\n- Review: \`${jsonPath}\``;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const hasCheckFailures = checkLogs.length > 0;
|
|
158
|
-
|
|
159
|
-
// Build failure instructions conditionally based on what types of failures exist
|
|
160
|
-
const failureSteps: string[] = [];
|
|
161
|
-
if (hasCheckFailures) {
|
|
162
|
-
failureSteps.push(
|
|
163
|
-
"For CHECK failures: Read the `.log` file path listed below.",
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
if (hasReviewFailures) {
|
|
167
|
-
failureSteps.push(
|
|
168
|
-
"For REVIEW failures: Read the `.json` file path listed below.",
|
|
169
|
-
);
|
|
170
|
-
failureSteps.push(
|
|
171
|
-
'For REVIEW violations: Update the `"status"` and `"result"` fields in the JSON file:\n - Set `"status": "fixed"` with a brief description in `"result"` for issues you fix.\n - Set `"status": "skipped"` with a brief reason in `"result"` for issues you skip.',
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const addressSection =
|
|
176
|
-
failureSteps.length > 0
|
|
177
|
-
? `\n**To address failures:**\n${failureSteps.map((s, i) => `${i + 1}. ${s}`).join("\n")}\n`
|
|
178
|
-
: "";
|
|
179
|
-
|
|
180
|
-
return `**GAUNTLET FAILED — YOU MUST FIX ISSUES NOW**
|
|
181
|
-
|
|
182
|
-
You cannot stop until the gauntlet passes or a termination condition is met. The stop hook will automatically re-run to verify your fixes.
|
|
183
|
-
${trustLevelSection}${addressSection}
|
|
184
|
-
**Termination conditions:**
|
|
185
|
-
- "Status: Passed" — All gates passed
|
|
186
|
-
- "Status: Passed with warnings" — Remaining issues were skipped
|
|
187
|
-
- "Status: Retry limit exceeded" — Run \`agent-gauntlet clean\` to archive the session and stop. This is the only case requiring manual clean; it signals unresolvable issues that need human review.${failedLogsSection}`;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Static status messages for statuses that don't need dynamic context.
|
|
192
|
-
*/
|
|
193
|
-
const STATUS_MESSAGES: Record<string, string> = {
|
|
194
|
-
passed: "✓ Gauntlet passed — all gates completed successfully.",
|
|
195
|
-
passed_with_warnings:
|
|
196
|
-
"✓ Gauntlet completed — passed with warnings (some issues were skipped).",
|
|
197
|
-
no_applicable_gates:
|
|
198
|
-
"✓ Gauntlet passed — no applicable gates matched current changes.",
|
|
199
|
-
no_changes: "✓ Gauntlet passed — no changes detected.",
|
|
200
|
-
retry_limit_exceeded:
|
|
201
|
-
"⚠ Gauntlet terminated — retry limit exceeded. Run `agent-gauntlet clean` to archive and continue.",
|
|
202
|
-
lock_conflict:
|
|
203
|
-
"⏭ Gauntlet skipped — another gauntlet run is already in progress.",
|
|
204
|
-
failed: "✗ Gauntlet failed — issues must be fixed before stopping.",
|
|
205
|
-
pr_push_required:
|
|
206
|
-
"✓ Gauntlet passed — PR needs to be created or updated before stopping.",
|
|
207
|
-
ci_pending: "⏳ CI checks still running — waiting for completion.",
|
|
208
|
-
ci_failed: "✗ CI failed or review changes requested — fix issues and push.",
|
|
209
|
-
ci_passed: "✓ CI passed — all checks completed and no blocking reviews.",
|
|
210
|
-
ci_timeout:
|
|
211
|
-
"⚠ CI wait exhausted — max attempts reached, allowing stop for manual review.",
|
|
212
|
-
no_config: "○ Not a gauntlet project — no .gauntlet/config.yml found.",
|
|
213
|
-
stop_hook_active:
|
|
214
|
-
"↺ Stop hook cycle detected — allowing stop to prevent infinite loop.",
|
|
215
|
-
stop_hook_disabled: "○ Stop hook is disabled via configuration.",
|
|
216
|
-
invalid_input: "⚠ Invalid hook input — could not parse JSON, allowing stop.",
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Get a human-friendly message for each status code.
|
|
221
|
-
* These messages explain why the stop was approved or blocked.
|
|
222
|
-
*/
|
|
223
|
-
export function getStatusMessage(
|
|
224
|
-
status: GauntletStatus,
|
|
225
|
-
context?: { intervalMinutes?: number; errorMessage?: string },
|
|
226
|
-
): string {
|
|
227
|
-
// Handle statuses that need dynamic context
|
|
228
|
-
if (status === "interval_not_elapsed") {
|
|
229
|
-
return context?.intervalMinutes
|
|
230
|
-
? `⏭ Gauntlet skipped — run interval (${context.intervalMinutes} min) not elapsed since last run.`
|
|
231
|
-
: "⏭ Gauntlet skipped — run interval not elapsed since last run.";
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (status === "error") {
|
|
235
|
-
return context?.errorMessage
|
|
236
|
-
? `⚠ Stop hook error — ${context.errorMessage}`
|
|
237
|
-
: "⚠ Stop hook error — unexpected error occurred.";
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Use static lookup for all other statuses
|
|
241
|
-
return STATUS_MESSAGES[status] ?? `Unknown status: ${status}`;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Generate push-PR instructions for the agent.
|
|
246
|
-
*/
|
|
247
|
-
export function getPushPRInstructions(options?: {
|
|
248
|
-
hasWarnings?: boolean;
|
|
249
|
-
}): string {
|
|
250
|
-
const warningGuidance = options?.hasWarnings
|
|
251
|
-
? "\n\n**Note:** Some issues were skipped during the gauntlet. Include a summary of skipped issues in the PR description so reviewers are aware."
|
|
252
|
-
: "";
|
|
253
|
-
|
|
254
|
-
return `**GAUNTLET PASSED — CREATE OR UPDATE YOUR PULL REQUEST**
|
|
255
|
-
|
|
256
|
-
All local quality gates have passed. Before you can stop, you need to commit your changes, push to remote, and create or update a pull request for the current branch.
|
|
257
|
-
|
|
258
|
-
After the PR is created or updated, try to stop again. The stop hook will verify the PR exists and is up to date.${warningGuidance}`;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** Format a single failed check with optional log output */
|
|
262
|
-
function formatFailedCheck(c: WaitCIResult["failed_checks"][0]): string[] {
|
|
263
|
-
const lines: string[] = [`- ${c.name}: ${c.details_url}`];
|
|
264
|
-
if (!c.log_output) return lines;
|
|
265
|
-
|
|
266
|
-
// Use dynamic fence to avoid markdown injection if logs contain backticks
|
|
267
|
-
const fence = c.log_output.includes("```") ? "````" : "```";
|
|
268
|
-
lines.push(fence, c.log_output, fence);
|
|
269
|
-
return lines;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/** Format a review comment with location info */
|
|
273
|
-
function formatReviewComment(r: WaitCIResult["review_comments"][0]): string {
|
|
274
|
-
const location = r.path ? ` (${r.path}${r.line ? `:${r.line}` : ""})` : "";
|
|
275
|
-
return `- ${r.author}: ${r.body}${location}`;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Generate CI fix instructions for the agent.
|
|
280
|
-
*/
|
|
281
|
-
export function getCIFixInstructions(ciResult: WaitCIResult): string {
|
|
282
|
-
const sections: string[] = [];
|
|
283
|
-
|
|
284
|
-
if (ciResult.failed_checks.length > 0) {
|
|
285
|
-
const checkLines = ciResult.failed_checks.flatMap(formatFailedCheck);
|
|
286
|
-
sections.push(`**Failed checks:**\n${checkLines.join("\n")}`);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// review_comments already contains only blocking reviews (from wait-ci.ts)
|
|
290
|
-
const blockingReviews = ciResult.review_comments.filter((r) =>
|
|
291
|
-
r.body?.trim(),
|
|
292
|
-
);
|
|
293
|
-
if (blockingReviews.length > 0) {
|
|
294
|
-
const reviewLines = blockingReviews.map(formatReviewComment);
|
|
295
|
-
sections.push(
|
|
296
|
-
`**Review comments requiring changes:**\n${reviewLines.join("\n")}`,
|
|
297
|
-
);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const detailsSection =
|
|
301
|
-
sections.length > 0 ? `\n\n${sections.join("\n\n")}` : "";
|
|
302
|
-
|
|
303
|
-
return `**CI FAILED OR REVIEW CHANGES REQUESTED — FIX AND PUSH**${detailsSection}
|
|
304
|
-
|
|
305
|
-
Fix the issues above, commit, and push your changes. After pushing, try to stop again.`;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Generate CI pending instructions for the agent.
|
|
310
|
-
*/
|
|
311
|
-
export function getCIPendingInstructions(
|
|
312
|
-
attemptNumber: number,
|
|
313
|
-
maxAttempts: number,
|
|
314
|
-
): string {
|
|
315
|
-
return `**CI CHECKS STILL RUNNING — WAITING (attempt ${attemptNumber} of ${maxAttempts})**
|
|
316
|
-
|
|
317
|
-
CI checks are still in progress. Wait approximately 30 seconds, then try to stop again.`;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Read CI wait attempts from marker file.
|
|
322
|
-
*/
|
|
323
|
-
export async function readCIWaitAttempts(logDir: string): Promise<number> {
|
|
324
|
-
try {
|
|
325
|
-
const markerPath = path.join(logDir, CI_WAIT_ATTEMPTS_FILE);
|
|
326
|
-
const content = await fs.readFile(markerPath, "utf-8");
|
|
327
|
-
const data = JSON.parse(content);
|
|
328
|
-
return typeof data.count === "number" ? data.count : 0;
|
|
329
|
-
} catch {
|
|
330
|
-
return 0;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Write CI wait attempts to marker file.
|
|
336
|
-
*/
|
|
337
|
-
export async function writeCIWaitAttempts(
|
|
338
|
-
logDir: string,
|
|
339
|
-
count: number,
|
|
340
|
-
): Promise<void> {
|
|
341
|
-
const markerPath = path.join(logDir, CI_WAIT_ATTEMPTS_FILE);
|
|
342
|
-
await fs.writeFile(markerPath, JSON.stringify({ count }), "utf-8");
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Clean up CI wait attempts marker file.
|
|
347
|
-
*/
|
|
348
|
-
export async function cleanCIWaitAttempts(logDir: string): Promise<void> {
|
|
349
|
-
try {
|
|
350
|
-
const markerPath = path.join(logDir, CI_WAIT_ATTEMPTS_FILE);
|
|
351
|
-
await fs.rm(markerPath, { force: true });
|
|
352
|
-
} catch {
|
|
353
|
-
// Ignore errors - file may not exist
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* Default timeout for CI wait (just under 5-minute stop hook budget).
|
|
359
|
-
*/
|
|
360
|
-
const DEFAULT_CI_WAIT_TIMEOUT = 270;
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Default poll interval for CI checks.
|
|
364
|
-
*/
|
|
365
|
-
const DEFAULT_CI_POLL_INTERVAL = 15;
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* Run the wait-ci logic and return the result.
|
|
369
|
-
* Calls waitForCI directly instead of spawning a subprocess.
|
|
370
|
-
*/
|
|
371
|
-
export async function runWaitCI(cwd: string): Promise<WaitCIResult> {
|
|
372
|
-
// Import and call waitForCI directly to avoid subprocess spawning issues
|
|
373
|
-
const { waitForCI } = await import("../commands/wait-ci.js");
|
|
374
|
-
return waitForCI(DEFAULT_CI_WAIT_TIMEOUT, DEFAULT_CI_POLL_INTERVAL, cwd);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Read the log_dir from project config without full validation.
|
|
379
|
-
*/
|
|
380
|
-
export async function getLogDir(projectCwd: string): Promise<string> {
|
|
381
|
-
const config = await readProjectConfig(projectCwd);
|
|
382
|
-
return config?.log_dir || DEFAULT_LOG_DIR;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Read the debug_log config from project config without full validation.
|
|
387
|
-
*/
|
|
388
|
-
export async function getDebugLogConfig(
|
|
389
|
-
projectCwd: string,
|
|
390
|
-
): Promise<MinimalConfig["debug_log"]> {
|
|
391
|
-
const config = await readProjectConfig(projectCwd);
|
|
392
|
-
return config?.debug_log;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Get resolved stop hook config with 3-tier precedence.
|
|
397
|
-
*/
|
|
398
|
-
async function getResolvedStopHookConfig(
|
|
399
|
-
projectCwd: string,
|
|
400
|
-
): Promise<StopHookConfig | null> {
|
|
401
|
-
try {
|
|
402
|
-
const configPath = path.join(projectCwd, ".gauntlet", "config.yml");
|
|
403
|
-
const content = await fs.readFile(configPath, "utf-8");
|
|
404
|
-
const raw = YAML.parse(content) as { stop_hook?: Record<string, unknown> };
|
|
405
|
-
const projectStopHookConfig = raw?.stop_hook as
|
|
406
|
-
| { auto_push_pr?: boolean; auto_fix_pr?: boolean }
|
|
407
|
-
| undefined;
|
|
408
|
-
const globalConfig = await loadGlobalConfig();
|
|
409
|
-
return resolveStopHookConfig(projectStopHookConfig, globalConfig);
|
|
410
|
-
} catch {
|
|
411
|
-
return null;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* Check if we should verify PR status after gates pass.
|
|
417
|
-
* Loads stop hook config with 3-tier precedence and checks auto_push_pr.
|
|
418
|
-
*/
|
|
419
|
-
async function shouldCheckPR(projectCwd: string): Promise<boolean> {
|
|
420
|
-
const config = await getResolvedStopHookConfig(projectCwd);
|
|
421
|
-
return config?.auto_push_pr ?? false;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* Check PR existence and whether local commits have been pushed.
|
|
426
|
-
*
|
|
427
|
-
* Uses `gh pr view` to get PR info and compares head SHA with local HEAD.
|
|
428
|
-
* Gracefully degrades if `gh` is not installed or any error occurs.
|
|
429
|
-
*/
|
|
430
|
-
async function checkPRStatus(cwd: string): Promise<PRStatusResult> {
|
|
431
|
-
try {
|
|
432
|
-
// Check if gh CLI is available
|
|
433
|
-
try {
|
|
434
|
-
await execFileAsync("gh", ["--version"], { cwd });
|
|
435
|
-
} catch {
|
|
436
|
-
return {
|
|
437
|
-
prExists: false,
|
|
438
|
-
upToDate: false,
|
|
439
|
-
error: "gh CLI not installed",
|
|
440
|
-
};
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// Get PR info for current branch
|
|
444
|
-
let prInfo: { number: number; state: string; headRefOid: string };
|
|
445
|
-
try {
|
|
446
|
-
const { stdout } = await execFileAsync(
|
|
447
|
-
"gh",
|
|
448
|
-
["pr", "view", "--json", "number,state,headRefOid"],
|
|
449
|
-
{ cwd },
|
|
450
|
-
);
|
|
451
|
-
prInfo = JSON.parse(stdout.trim());
|
|
452
|
-
} catch (e: unknown) {
|
|
453
|
-
const errMsg = (e as { message?: string }).message ?? "unknown";
|
|
454
|
-
// gh pr view exits with code 1 and specific message when no PR exists
|
|
455
|
-
if (
|
|
456
|
-
errMsg.includes("no pull requests found") ||
|
|
457
|
-
errMsg.includes("Could not resolve")
|
|
458
|
-
) {
|
|
459
|
-
return { prExists: false, upToDate: false };
|
|
460
|
-
}
|
|
461
|
-
// Other failures (network, auth, etc.) — return error for graceful degradation
|
|
462
|
-
return {
|
|
463
|
-
prExists: false,
|
|
464
|
-
upToDate: false,
|
|
465
|
-
error: `gh pr view failed: ${errMsg}`,
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// Only consider OPEN PRs - closed/merged PRs should not block stop
|
|
470
|
-
if (prInfo.state !== "OPEN") {
|
|
471
|
-
return { prExists: false, upToDate: false };
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// Get local HEAD SHA
|
|
475
|
-
const { stdout: localHead } = await execFileAsync(
|
|
476
|
-
"git",
|
|
477
|
-
["rev-parse", "HEAD"],
|
|
478
|
-
{ cwd },
|
|
479
|
-
);
|
|
480
|
-
const localSha = localHead.trim();
|
|
481
|
-
|
|
482
|
-
const upToDate = prInfo.headRefOid === localSha;
|
|
483
|
-
return {
|
|
484
|
-
prExists: true,
|
|
485
|
-
upToDate,
|
|
486
|
-
prNumber: prInfo.number,
|
|
487
|
-
};
|
|
488
|
-
} catch (error: unknown) {
|
|
489
|
-
const errMsg = (error as { message?: string }).message ?? "unknown";
|
|
490
|
-
return {
|
|
491
|
-
prExists: false,
|
|
492
|
-
upToDate: false,
|
|
493
|
-
error: `PR status check failed: ${errMsg}`,
|
|
494
|
-
};
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* Check if the gauntlet status indicates a passing state that should trigger PR check.
|
|
500
|
-
*/
|
|
501
|
-
function isPassingStatus(status: GauntletStatus): boolean {
|
|
502
|
-
return status === "passed" || status === "passed_with_warnings";
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
/**
|
|
506
|
-
* Check if the gauntlet status indicates "nothing to do locally" — gates were not
|
|
507
|
-
* re-run but there's no failure. These statuses should still trigger PR/CI checks
|
|
508
|
-
* when auto_push_pr is enabled, since the previous run may have passed.
|
|
509
|
-
*/
|
|
510
|
-
function isIdleStatus(status: GauntletStatus): boolean {
|
|
511
|
-
return status === "interval_not_elapsed" || status === "no_changes";
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
/**
|
|
515
|
-
* Refresh execution state (non-fatal on error).
|
|
516
|
-
*/
|
|
517
|
-
async function refreshExecutionState(logDir?: string): Promise<void> {
|
|
518
|
-
if (!logDir) return;
|
|
519
|
-
try {
|
|
520
|
-
await writeExecutionState(logDir);
|
|
521
|
-
} catch {
|
|
522
|
-
// Non-fatal; stale state won't block the next run
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/**
|
|
527
|
-
* Result from post-gauntlet checks (PR and CI).
|
|
528
|
-
*/
|
|
529
|
-
interface PostGauntletResult {
|
|
530
|
-
finalStatus: GauntletStatus;
|
|
531
|
-
pushPRReason?: string;
|
|
532
|
-
ciFixReason?: string;
|
|
533
|
-
ciPendingReason?: string;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* Handle CI wait workflow after PR is confirmed up-to-date.
|
|
538
|
-
*/
|
|
539
|
-
async function handleCIWaitWorkflow(
|
|
540
|
-
projectCwd: string,
|
|
541
|
-
logDir: string,
|
|
542
|
-
gauntletStatus: GauntletStatus,
|
|
543
|
-
): Promise<PostGauntletResult> {
|
|
544
|
-
const log = getStopHookLogger();
|
|
545
|
-
const attempts = await readCIWaitAttempts(logDir);
|
|
546
|
-
|
|
547
|
-
// Check if we've exceeded max attempts
|
|
548
|
-
if (attempts >= MAX_CI_WAIT_ATTEMPTS) {
|
|
549
|
-
log.info(
|
|
550
|
-
`CI wait attempts exhausted (${attempts}/${MAX_CI_WAIT_ATTEMPTS})`,
|
|
551
|
-
);
|
|
552
|
-
await cleanCIWaitAttempts(logDir);
|
|
553
|
-
return { finalStatus: "ci_timeout" };
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
log.info(
|
|
557
|
-
`Running wait-ci (attempt ${attempts + 1}/${MAX_CI_WAIT_ATTEMPTS})...`,
|
|
558
|
-
);
|
|
559
|
-
const ciResult = await runWaitCI(projectCwd);
|
|
560
|
-
|
|
561
|
-
switch (ciResult.ci_status) {
|
|
562
|
-
case "passed":
|
|
563
|
-
await cleanCIWaitAttempts(logDir);
|
|
564
|
-
return { finalStatus: "ci_passed" };
|
|
565
|
-
|
|
566
|
-
case "failed":
|
|
567
|
-
await cleanCIWaitAttempts(logDir);
|
|
568
|
-
await refreshExecutionState(logDir);
|
|
569
|
-
return {
|
|
570
|
-
finalStatus: "ci_failed",
|
|
571
|
-
ciFixReason: getCIFixInstructions(ciResult),
|
|
572
|
-
};
|
|
573
|
-
|
|
574
|
-
case "pending":
|
|
575
|
-
await writeCIWaitAttempts(logDir, attempts + 1);
|
|
576
|
-
await refreshExecutionState(logDir);
|
|
577
|
-
return {
|
|
578
|
-
finalStatus: "ci_pending",
|
|
579
|
-
ciPendingReason: getCIPendingInstructions(
|
|
580
|
-
attempts + 1,
|
|
581
|
-
MAX_CI_WAIT_ATTEMPTS,
|
|
582
|
-
),
|
|
583
|
-
};
|
|
584
|
-
|
|
585
|
-
default:
|
|
586
|
-
log.warn(`wait-ci error: ${ciResult.error_message}`);
|
|
587
|
-
await cleanCIWaitAttempts(logDir);
|
|
588
|
-
return { finalStatus: gauntletStatus };
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/**
|
|
593
|
-
* Check PR status after gauntlet passes and determine if the stop should be blocked.
|
|
594
|
-
* Also handles CI wait workflow when auto_fix_pr is enabled.
|
|
595
|
-
*/
|
|
596
|
-
async function postGauntletPRCheck(
|
|
597
|
-
projectCwd: string,
|
|
598
|
-
gauntletStatus: GauntletStatus,
|
|
599
|
-
options?: { logDir?: string },
|
|
600
|
-
): Promise<PostGauntletResult> {
|
|
601
|
-
const idle = isIdleStatus(gauntletStatus);
|
|
602
|
-
if (!isPassingStatus(gauntletStatus) && !idle) {
|
|
603
|
-
return { finalStatus: gauntletStatus };
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
const config = await getResolvedStopHookConfig(projectCwd);
|
|
607
|
-
if (!config?.auto_push_pr) {
|
|
608
|
-
return { finalStatus: gauntletStatus };
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
const prStatus = await checkPRStatus(projectCwd);
|
|
612
|
-
if (prStatus.error) {
|
|
613
|
-
getStopHookLogger().warn(`PR status check failed: ${prStatus.error}`);
|
|
614
|
-
return { finalStatus: gauntletStatus };
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
const prReady = prStatus.prExists && prStatus.upToDate;
|
|
618
|
-
|
|
619
|
-
// PR missing or outdated: block fresh passes, allow idle statuses
|
|
620
|
-
if (!prReady) {
|
|
621
|
-
return idle
|
|
622
|
-
? { finalStatus: gauntletStatus }
|
|
623
|
-
: handlePRMissing(gauntletStatus, options?.logDir);
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
// PR exists and is up to date — enter CI wait if configured
|
|
627
|
-
return handleCIWaitIfEnabled(
|
|
628
|
-
config,
|
|
629
|
-
projectCwd,
|
|
630
|
-
gauntletStatus,
|
|
631
|
-
options?.logDir,
|
|
632
|
-
);
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
async function handlePRMissing(
|
|
636
|
-
gauntletStatus: GauntletStatus,
|
|
637
|
-
logDir?: string,
|
|
638
|
-
): Promise<PostGauntletResult> {
|
|
639
|
-
await refreshExecutionState(logDir);
|
|
640
|
-
return {
|
|
641
|
-
finalStatus: "pr_push_required",
|
|
642
|
-
pushPRReason: getPushPRInstructions({
|
|
643
|
-
hasWarnings: gauntletStatus === "passed_with_warnings",
|
|
644
|
-
}),
|
|
645
|
-
};
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
async function handleCIWaitIfEnabled(
|
|
649
|
-
config: StopHookConfig,
|
|
650
|
-
projectCwd: string,
|
|
651
|
-
gauntletStatus: GauntletStatus,
|
|
652
|
-
logDir?: string,
|
|
653
|
-
): Promise<PostGauntletResult> {
|
|
654
|
-
if (!config.auto_fix_pr) {
|
|
655
|
-
return { finalStatus: gauntletStatus };
|
|
656
|
-
}
|
|
657
|
-
if (!logDir) {
|
|
658
|
-
getStopHookLogger().warn("No logDir provided for CI wait workflow");
|
|
659
|
-
return { finalStatus: gauntletStatus };
|
|
660
|
-
}
|
|
661
|
-
return handleCIWaitWorkflow(projectCwd, logDir, gauntletStatus);
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
/**
|
|
665
|
-
* Core stop hook handler that executes the gauntlet and determines the result.
|
|
666
|
-
* Protocol-agnostic: works with any adapter that provides a StopHookContext.
|
|
667
|
-
*/
|
|
668
|
-
export class StopHookHandler {
|
|
669
|
-
private debugLogger?: DebugLogger;
|
|
670
|
-
private logDir?: string;
|
|
671
|
-
|
|
672
|
-
constructor(debugLogger?: DebugLogger) {
|
|
673
|
-
this.debugLogger = debugLogger;
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
/**
|
|
677
|
-
* Set the debug logger (can be updated after construction).
|
|
678
|
-
*/
|
|
679
|
-
setDebugLogger(debugLogger: DebugLogger): void {
|
|
680
|
-
this.debugLogger = debugLogger;
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
/**
|
|
684
|
-
* Set the log directory (needed for execution state refresh).
|
|
685
|
-
*/
|
|
686
|
-
setLogDir(logDir: string): void {
|
|
687
|
-
this.logDir = logDir;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
/**
|
|
691
|
-
* Execute the gauntlet and return a protocol-agnostic result.
|
|
692
|
-
*/
|
|
693
|
-
async execute(ctx: StopHookContext): Promise<StopHookResult> {
|
|
694
|
-
const log = getStopHookLogger();
|
|
695
|
-
|
|
696
|
-
log.info("Running gauntlet gates...");
|
|
697
|
-
const result = await executeRun({
|
|
698
|
-
cwd: ctx.cwd,
|
|
699
|
-
checkInterval: true,
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
log.info(`Gauntlet completed with status: ${result.status}`);
|
|
703
|
-
|
|
704
|
-
// Post-gauntlet PR check: when gates pass and auto_push_pr is enabled,
|
|
705
|
-
// verify a PR exists and is up to date before allowing stop.
|
|
706
|
-
// Also handles CI wait workflow when auto_fix_pr is enabled.
|
|
707
|
-
const { finalStatus, pushPRReason, ciFixReason, ciPendingReason } =
|
|
708
|
-
await postGauntletPRCheck(ctx.cwd, result.status, {
|
|
709
|
-
logDir: this.logDir,
|
|
710
|
-
});
|
|
711
|
-
|
|
712
|
-
await this.debugLogger?.logStopHook(
|
|
713
|
-
isBlockingStatus(finalStatus) ? "block" : "allow",
|
|
714
|
-
finalStatus,
|
|
715
|
-
);
|
|
716
|
-
|
|
717
|
-
const shouldBlock = isBlockingStatus(finalStatus);
|
|
718
|
-
const message = getStatusMessage(finalStatus, {
|
|
719
|
-
intervalMinutes: result.intervalMinutes,
|
|
720
|
-
errorMessage: result.errorMessage,
|
|
721
|
-
});
|
|
722
|
-
|
|
723
|
-
return {
|
|
724
|
-
status: finalStatus,
|
|
725
|
-
shouldBlock,
|
|
726
|
-
instructions:
|
|
727
|
-
finalStatus === "failed"
|
|
728
|
-
? getStopReasonInstructions(result.gateResults)
|
|
729
|
-
: undefined,
|
|
730
|
-
pushPRReason,
|
|
731
|
-
ciFixReason,
|
|
732
|
-
ciPendingReason,
|
|
733
|
-
message,
|
|
734
|
-
intervalMinutes: result.intervalMinutes,
|
|
735
|
-
gateResults: result.gateResults,
|
|
736
|
-
};
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// Re-export types and functions for backward compatibility
|
|
741
|
-
export type { PRStatusResult };
|
|
742
|
-
export { checkPRStatus, shouldCheckPR };
|
|
743
|
-
|
|
744
|
-
// Export CI helpers for testing
|
|
745
|
-
export { MAX_CI_WAIT_ATTEMPTS };
|
|
746
|
-
|
|
747
|
-
// Export status helpers for testing
|
|
748
|
-
export { isIdleStatus, isPassingStatus };
|