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.
Files changed (71) hide show
  1. package/README.md +25 -23
  2. package/dist/index.js +9226 -0
  3. package/dist/index.js.map +65 -0
  4. package/dist/scripts/status.js +280 -0
  5. package/dist/scripts/status.js.map +10 -0
  6. package/package.json +22 -8
  7. package/src/built-in-reviews/code-quality.md +0 -25
  8. package/src/built-in-reviews/index.ts +0 -28
  9. package/src/bun-plugins.d.ts +0 -4
  10. package/src/cli-adapters/claude.ts +0 -327
  11. package/src/cli-adapters/codex.ts +0 -290
  12. package/src/cli-adapters/cursor.ts +0 -128
  13. package/src/cli-adapters/gemini.ts +0 -510
  14. package/src/cli-adapters/github-copilot.ts +0 -141
  15. package/src/cli-adapters/index.ts +0 -250
  16. package/src/cli-adapters/thinking-budget.ts +0 -23
  17. package/src/commands/check.ts +0 -311
  18. package/src/commands/ci/index.ts +0 -15
  19. package/src/commands/ci/init.ts +0 -96
  20. package/src/commands/ci/list-jobs.ts +0 -90
  21. package/src/commands/clean.ts +0 -54
  22. package/src/commands/detect.ts +0 -173
  23. package/src/commands/health.ts +0 -169
  24. package/src/commands/help.ts +0 -34
  25. package/src/commands/index.ts +0 -13
  26. package/src/commands/init.ts +0 -1878
  27. package/src/commands/list.ts +0 -33
  28. package/src/commands/review.ts +0 -311
  29. package/src/commands/run.ts +0 -29
  30. package/src/commands/shared.ts +0 -267
  31. package/src/commands/stop-hook.ts +0 -567
  32. package/src/commands/validate.ts +0 -20
  33. package/src/commands/wait-ci.ts +0 -518
  34. package/src/config/ci-loader.ts +0 -33
  35. package/src/config/ci-schema.ts +0 -28
  36. package/src/config/global.ts +0 -87
  37. package/src/config/loader.ts +0 -301
  38. package/src/config/schema.ts +0 -165
  39. package/src/config/stop-hook-config.ts +0 -130
  40. package/src/config/types.ts +0 -65
  41. package/src/config/validator.ts +0 -592
  42. package/src/core/change-detector.ts +0 -137
  43. package/src/core/diff-stats.ts +0 -442
  44. package/src/core/entry-point.ts +0 -190
  45. package/src/core/job.ts +0 -96
  46. package/src/core/run-executor.ts +0 -621
  47. package/src/core/runner.ts +0 -290
  48. package/src/gates/check.ts +0 -118
  49. package/src/gates/resolve-check-command.ts +0 -21
  50. package/src/gates/result.ts +0 -54
  51. package/src/gates/review.ts +0 -1333
  52. package/src/hooks/adapters/claude-stop-hook.ts +0 -99
  53. package/src/hooks/adapters/cursor-stop-hook.ts +0 -122
  54. package/src/hooks/adapters/types.ts +0 -94
  55. package/src/hooks/stop-hook-handler.ts +0 -748
  56. package/src/index.ts +0 -47
  57. package/src/output/app-logger.ts +0 -214
  58. package/src/output/console-log.ts +0 -168
  59. package/src/output/console.ts +0 -359
  60. package/src/output/logger.ts +0 -126
  61. package/src/output/sinks/console-sink.ts +0 -59
  62. package/src/output/sinks/file-sink.ts +0 -110
  63. package/src/scripts/status.ts +0 -433
  64. package/src/templates/workflow.yml +0 -79
  65. package/src/types/gauntlet-status.ts +0 -79
  66. package/src/utils/debug-log.ts +0 -392
  67. package/src/utils/diff-parser.ts +0 -103
  68. package/src/utils/execution-state.ts +0 -472
  69. package/src/utils/log-parser.ts +0 -696
  70. package/src/utils/sanitizer.ts +0 -3
  71. package/src/utils/session-ref.ts +0 -91
@@ -1,621 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- import {
4
- cleanLogs,
5
- hasExistingLogs,
6
- performAutoClean,
7
- releaseLock,
8
- shouldAutoClean,
9
- } from "../commands/shared.js";
10
- import { loadGlobalConfig } from "../config/global.js";
11
- import { loadConfig } from "../config/loader.js";
12
- import { resolveStopHookConfig } from "../config/stop-hook-config.js";
13
- import {
14
- getCategoryLogger,
15
- initLogger,
16
- isLoggerConfigured,
17
- resetLogger,
18
- } from "../output/app-logger.js";
19
- import { ConsoleReporter } from "../output/console.js";
20
- import {
21
- type ConsoleLogHandle,
22
- startConsoleLog,
23
- } from "../output/console-log.js";
24
- import { Logger } from "../output/logger.js";
25
- import type { GauntletStatus, RunResult } from "../types/gauntlet-status.js";
26
- import {
27
- getDebugLogger,
28
- initDebugLogger,
29
- mergeDebugLogConfig,
30
- } from "../utils/debug-log.js";
31
- import {
32
- readExecutionState,
33
- resolveFixBase,
34
- writeExecutionState,
35
- } from "../utils/execution-state.js";
36
- import {
37
- findPreviousFailures,
38
- type PassedSlot,
39
- type PreviousViolation,
40
- } from "../utils/log-parser.js";
41
- import { ChangeDetector } from "./change-detector.js";
42
- import { computeDiffStats } from "./diff-stats.js";
43
- import { EntryPointExpander } from "./entry-point.js";
44
- import { JobGenerator } from "./job.js";
45
- import { Runner } from "./runner.js";
46
-
47
- const LOCK_FILENAME = ".gauntlet-run.lock";
48
-
49
- export interface ExecuteRunOptions {
50
- baseBranch?: string;
51
- gate?: string;
52
- commit?: string;
53
- uncommitted?: boolean;
54
- /** Working directory for config loading (defaults to process.cwd()) */
55
- cwd?: string;
56
- /**
57
- * When true, check if run interval has elapsed before proceeding.
58
- * Only stop-hook uses this; CLI commands (run, check, review) always run immediately.
59
- * If interval hasn't elapsed, returns { status: "interval_not_elapsed", ... }.
60
- */
61
- checkInterval?: boolean;
62
- }
63
-
64
- /**
65
- * Maximum age for a lock file before it's considered stale (10 minutes).
66
- * Matches the stale marker threshold in stop-hook.ts.
67
- */
68
- const STALE_LOCK_MS = 10 * 60 * 1000;
69
-
70
- /**
71
- * Check if a process with the given PID is still alive.
72
- */
73
- function isProcessAlive(pid: number): boolean {
74
- try {
75
- process.kill(pid, 0); // Signal 0 = check existence without killing
76
- return true;
77
- } catch (err: unknown) {
78
- // EPERM means the process exists but we lack permission to signal it
79
- if (
80
- typeof err === "object" &&
81
- err !== null &&
82
- "code" in err &&
83
- (err as { code: string }).code === "EPERM"
84
- ) {
85
- return true;
86
- }
87
- // ESRCH or other errors mean the process doesn't exist
88
- return false;
89
- }
90
- }
91
-
92
- /**
93
- * Acquire the lock file. Returns true if successful, false if lock exists.
94
- * Unlike acquireLock() in shared.ts, this doesn't call process.exit().
95
- *
96
- * If the lock file already exists, checks for staleness:
97
- * - If the PID in the lock file is no longer alive, removes the lock and retries.
98
- * - If the lock file is older than STALE_LOCK_MS, removes the lock and retries.
99
- * This prevents zombie processes from holding locks indefinitely.
100
- */
101
- async function tryAcquireLock(logDir: string): Promise<boolean> {
102
- await fs.mkdir(logDir, { recursive: true });
103
- const lockPath = path.resolve(logDir, LOCK_FILENAME);
104
- try {
105
- await fs.writeFile(lockPath, String(process.pid), { flag: "wx" });
106
- return true;
107
- } catch (err: unknown) {
108
- if (
109
- typeof err === "object" &&
110
- err !== null &&
111
- "code" in err &&
112
- (err as { code: string }).code === "EEXIST"
113
- ) {
114
- // Lock exists — check if the holding process is still alive
115
- try {
116
- const lockContent = await fs.readFile(lockPath, "utf-8");
117
- const lockPid = parseInt(lockContent.trim(), 10);
118
- const lockStat = await fs.stat(lockPath);
119
- const lockAgeMs = Date.now() - lockStat.mtimeMs;
120
-
121
- const pidValid = !Number.isNaN(lockPid);
122
- const pidDead = pidValid && !isProcessAlive(lockPid);
123
- // Only use time-based staleness when we can't determine the PID
124
- // (e.g. lock file is empty or contains non-numeric content).
125
- // If the PID is valid and alive, never steal the lock regardless of age.
126
- const lockStale = !pidValid && lockAgeMs > STALE_LOCK_MS;
127
-
128
- if (pidDead || lockStale) {
129
- // Stale lock — remove and retry once
130
- await fs.rm(lockPath, { force: true });
131
- try {
132
- await fs.writeFile(lockPath, String(process.pid), {
133
- flag: "wx",
134
- });
135
- return true;
136
- } catch {
137
- // Another process beat us to it
138
- return false;
139
- }
140
- }
141
- } catch {
142
- // Can't read/stat lock file — treat as active lock
143
- }
144
- return false;
145
- }
146
- throw err;
147
- }
148
- }
149
-
150
- /**
151
- * Find the latest console.N.log file in the log directory.
152
- */
153
- async function findLatestConsoleLog(logDir: string): Promise<string | null> {
154
- try {
155
- const files = await fs.readdir(logDir);
156
- let maxNum = -1;
157
- let latestFile: string | null = null;
158
-
159
- for (const file of files) {
160
- if (!file.startsWith("console.") || !file.endsWith(".log")) {
161
- continue;
162
- }
163
- const middle = file.slice("console.".length, file.length - ".log".length);
164
- if (/^\d+$/.test(middle)) {
165
- const n = parseInt(middle, 10);
166
- if (n > maxNum) {
167
- maxNum = n;
168
- latestFile = file;
169
- }
170
- }
171
- }
172
-
173
- return latestFile ? path.join(logDir, latestFile) : null;
174
- } catch {
175
- return null;
176
- }
177
- }
178
-
179
- /**
180
- * Check if the run interval has elapsed since the last gauntlet run.
181
- * Returns true if gauntlet should run, false if interval hasn't elapsed.
182
- */
183
- async function shouldRunBasedOnInterval(
184
- logDir: string,
185
- intervalMinutes: number,
186
- ): Promise<boolean> {
187
- const state = await readExecutionState(logDir);
188
- if (!state) {
189
- // No execution state = always run
190
- return true;
191
- }
192
-
193
- const lastRun = new Date(state.last_run_completed_at);
194
- // Handle invalid date (corrupted state) - treat as needing to run
195
- if (Number.isNaN(lastRun.getTime())) {
196
- return true;
197
- }
198
-
199
- const now = new Date();
200
- const elapsedMinutes = (now.getTime() - lastRun.getTime()) / (1000 * 60);
201
-
202
- return elapsedMinutes >= intervalMinutes;
203
- }
204
-
205
- /**
206
- * Get status message for a given status.
207
- */
208
- const statusMessages: Record<GauntletStatus, string> = {
209
- passed: "All gates passed.",
210
- passed_with_warnings: "Passed with warnings — some issues were skipped.",
211
- no_applicable_gates: "No applicable gates for these changes.",
212
- no_changes: "No changes detected.",
213
- failed: "Gates failed — issues must be fixed.",
214
- retry_limit_exceeded:
215
- "Retry limit exceeded — logs have been automatically archived.",
216
- lock_conflict: "Another gauntlet run is already in progress.",
217
- error: "Unexpected error occurred.",
218
- no_config: "No .gauntlet/config.yml found.",
219
- stop_hook_active: "Stop hook already active.",
220
- interval_not_elapsed: "Run interval not elapsed.",
221
- invalid_input: "Invalid input.",
222
- stop_hook_disabled: "Stop hook is disabled via configuration.",
223
- pr_push_required: "Gates passed — PR needs to be created/updated.",
224
- ci_pending: "CI checks still running.",
225
- ci_failed: "CI checks failed or review changes requested.",
226
- ci_passed: "CI checks passed, no blocking reviews.",
227
- ci_timeout: "CI wait attempts exhausted.",
228
- };
229
-
230
- function getStatusMessage(status: GauntletStatus): string {
231
- return statusMessages[status] || "Unknown status";
232
- }
233
-
234
- /**
235
- * Get the run executor logger.
236
- */
237
- function getRunLogger() {
238
- return getCategoryLogger("run");
239
- }
240
-
241
- /**
242
- * Execute the gauntlet run logic. Returns a structured RunResult.
243
- * This function never calls process.exit() - the caller is responsible for that.
244
- */
245
- export async function executeRun(
246
- options: ExecuteRunOptions = {},
247
- ): Promise<RunResult> {
248
- const { cwd } = options;
249
- let config: Awaited<ReturnType<typeof loadConfig>> | undefined;
250
- let lockAcquired = false;
251
- let consoleLogHandle: ConsoleLogHandle | undefined;
252
- let loggerInitializedHere = false;
253
- const log = getRunLogger();
254
-
255
- try {
256
- config = await loadConfig(cwd);
257
-
258
- // Initialize app logger if not already configured (e.g., by stop-hook)
259
- if (!isLoggerConfigured()) {
260
- await initLogger({
261
- mode: "interactive",
262
- logDir: config.project.log_dir,
263
- });
264
- loggerInitializedHere = true;
265
- }
266
-
267
- // Initialize debug logger
268
- const globalConfig = await loadGlobalConfig();
269
- const debugLogConfig = mergeDebugLogConfig(
270
- config.project.debug_log,
271
- globalConfig.debug_log,
272
- );
273
- initDebugLogger(config.project.log_dir, debugLogConfig);
274
-
275
- // Log the command invocation
276
- const debugLogger = getDebugLogger();
277
- const args = [
278
- options.baseBranch ? `-b ${options.baseBranch}` : "",
279
- options.gate ? `-g ${options.gate}` : "",
280
- options.commit ? `-c ${options.commit}` : "",
281
- options.uncommitted ? "-u" : "",
282
- options.checkInterval ? "--check-interval" : "",
283
- ].filter(Boolean);
284
- await debugLogger?.logCommand("run", args);
285
-
286
- // Interval check: only stop-hook passes checkInterval: true
287
- // CLI commands (run, check, review) always run immediately
288
- if (options.checkInterval) {
289
- // Resolve stop hook config from env > project > global
290
- const stopHookConfig = resolveStopHookConfig(
291
- config.project.stop_hook,
292
- globalConfig,
293
- );
294
-
295
- // Check if stop hook is disabled
296
- if (!stopHookConfig.enabled) {
297
- log.debug("Stop hook is disabled via configuration, skipping");
298
- // Clean up logger if we initialized it
299
- if (loggerInitializedHere) {
300
- await resetLogger();
301
- }
302
- return {
303
- status: "stop_hook_disabled",
304
- message: getStatusMessage("stop_hook_disabled"),
305
- };
306
- }
307
-
308
- const logsExist = await hasExistingLogs(config.project.log_dir);
309
- // Only check interval if there are no existing logs (not in rerun mode)
310
- // and interval > 0 (interval 0 means always run)
311
- if (!logsExist && stopHookConfig.run_interval_minutes > 0) {
312
- const intervalMinutes = stopHookConfig.run_interval_minutes;
313
- const shouldRun = await shouldRunBasedOnInterval(
314
- config.project.log_dir,
315
- intervalMinutes,
316
- );
317
- if (!shouldRun) {
318
- log.debug(
319
- `Run interval (${intervalMinutes} min) not elapsed, skipping`,
320
- );
321
- // Clean up logger if we initialized it
322
- if (loggerInitializedHere) {
323
- await resetLogger();
324
- }
325
- return {
326
- status: "interval_not_elapsed",
327
- message: `Run interval (${intervalMinutes} min) not elapsed.`,
328
- intervalMinutes,
329
- };
330
- }
331
- }
332
- }
333
-
334
- // Determine effective base branch first (needed for auto-clean)
335
- const effectiveBaseBranch =
336
- options.baseBranch ||
337
- (process.env.GITHUB_BASE_REF &&
338
- (process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true")
339
- ? process.env.GITHUB_BASE_REF
340
- : null) ||
341
- config.project.base_branch;
342
-
343
- // Auto-clean on context change (branch changed, commit merged)
344
- const autoCleanResult = await shouldAutoClean(
345
- config.project.log_dir,
346
- effectiveBaseBranch,
347
- );
348
- if (autoCleanResult.clean) {
349
- log.debug(`Auto-cleaning logs (${autoCleanResult.reason})...`);
350
- await debugLogger?.logClean("auto", autoCleanResult.reason || "unknown");
351
- await performAutoClean(
352
- config.project.log_dir,
353
- autoCleanResult,
354
- config.project.max_previous_logs,
355
- );
356
- }
357
-
358
- // Detect rerun mode after auto-clean (clean may have removed logs)
359
- const logsExist = await hasExistingLogs(config.project.log_dir);
360
- const isRerun = logsExist && !options.commit;
361
-
362
- // Try to acquire lock (non-exiting version)
363
- lockAcquired = await tryAcquireLock(config.project.log_dir);
364
- if (!lockAcquired) {
365
- // Clean up logger if we initialized it
366
- if (loggerInitializedHere) {
367
- await resetLogger();
368
- }
369
- return {
370
- status: "lock_conflict",
371
- message: getStatusMessage("lock_conflict"),
372
- };
373
- }
374
-
375
- // Lock acquired — wrap in try/finally to guarantee release on all paths
376
- try {
377
- // Initialize Logger early to get unified run number for console log
378
- const logger = new Logger(config.project.log_dir);
379
- await logger.init();
380
- const runNumber = logger.getRunNumber();
381
-
382
- consoleLogHandle = await startConsoleLog(
383
- config.project.log_dir,
384
- runNumber,
385
- );
386
-
387
- let failuresMap:
388
- | Map<string, Map<string, PreviousViolation[]>>
389
- | undefined;
390
- let changeOptions:
391
- | { commit?: string; uncommitted?: boolean; fixBase?: string }
392
- | undefined;
393
-
394
- let passedSlotsMap: Map<string, Map<number, PassedSlot>> | undefined;
395
-
396
- if (isRerun) {
397
- log.debug("Existing logs detected — running in verification mode...");
398
- const { failures: previousFailures, passedSlots } =
399
- await findPreviousFailures(
400
- config.project.log_dir,
401
- options.gate,
402
- true,
403
- );
404
-
405
- failuresMap = new Map();
406
- for (const gateFailure of previousFailures) {
407
- const adapterMap = new Map<string, PreviousViolation[]>();
408
- for (const af of gateFailure.adapterFailures) {
409
- const key = af.reviewIndex
410
- ? String(af.reviewIndex)
411
- : af.adapterName;
412
- adapterMap.set(key, af.violations);
413
- }
414
- failuresMap.set(gateFailure.jobId, adapterMap);
415
- }
416
-
417
- passedSlotsMap = passedSlots;
418
-
419
- if (previousFailures.length > 0) {
420
- const totalViolations = previousFailures.reduce(
421
- (sum, gf) =>
422
- sum +
423
- gf.adapterFailures.reduce((s, af) => s + af.violations.length, 0),
424
- 0,
425
- );
426
- log.warn(
427
- `Found ${previousFailures.length} gate(s) with ${totalViolations} previous violation(s)`,
428
- );
429
- }
430
-
431
- changeOptions = { uncommitted: true };
432
- const executionState = await readExecutionState(config.project.log_dir);
433
- if (executionState?.working_tree_ref) {
434
- changeOptions.fixBase = executionState.working_tree_ref;
435
- }
436
- } else if (!logsExist) {
437
- const executionState = await readExecutionState(config.project.log_dir);
438
- if (executionState) {
439
- const resolved = await resolveFixBase(
440
- executionState,
441
- effectiveBaseBranch,
442
- );
443
- if (resolved.warning) {
444
- log.warn(`Warning: ${resolved.warning}`);
445
- }
446
- if (resolved.fixBase) {
447
- changeOptions = { fixBase: resolved.fixBase };
448
- }
449
- }
450
- }
451
-
452
- // Allow explicit commit or uncommitted options to override fixBase
453
- if (options.commit || options.uncommitted) {
454
- changeOptions = {
455
- commit: options.commit,
456
- uncommitted: options.uncommitted,
457
- fixBase: changeOptions?.fixBase,
458
- };
459
- }
460
-
461
- const changeDetector = new ChangeDetector(
462
- effectiveBaseBranch,
463
- changeOptions || {
464
- commit: options.commit,
465
- uncommitted: options.uncommitted,
466
- },
467
- );
468
- const expander = new EntryPointExpander();
469
- const jobGen = new JobGenerator(config);
470
-
471
- log.debug("Detecting changes...");
472
- const changes = await changeDetector.getChangedFiles();
473
-
474
- if (changes.length === 0) {
475
- log.info("No changes detected.");
476
- // Do not write execution state - no gates ran
477
- consoleLogHandle?.restore();
478
- if (loggerInitializedHere) {
479
- await resetLogger();
480
- }
481
- return {
482
- status: "no_changes",
483
- message: getStatusMessage("no_changes"),
484
- gatesRun: 0,
485
- };
486
- }
487
-
488
- log.debug(`Found ${changes.length} changed files.`);
489
-
490
- const entryPoints = await expander.expand(
491
- config.project.entry_points,
492
- changes,
493
- );
494
- let jobs = jobGen.generateJobs(entryPoints);
495
-
496
- if (options.gate) {
497
- jobs = jobs.filter((j) => j.name === options.gate);
498
- }
499
-
500
- if (jobs.length === 0) {
501
- log.warn("No applicable gates for these changes.");
502
- // Do not write execution state - no gates ran
503
- consoleLogHandle?.restore();
504
- if (loggerInitializedHere) {
505
- await resetLogger();
506
- }
507
- return {
508
- status: "no_applicable_gates",
509
- message: getStatusMessage("no_applicable_gates"),
510
- gatesRun: 0,
511
- };
512
- }
513
-
514
- log.debug(`Running ${jobs.length} gates...`);
515
-
516
- // Compute diff stats and log run start
517
- const runMode = isRerun ? "verification" : "full";
518
- const diffStats = await computeDiffStats(
519
- effectiveBaseBranch,
520
- changeOptions || {
521
- commit: options.commit,
522
- uncommitted: options.uncommitted,
523
- },
524
- );
525
- await debugLogger?.logRunStartWithDiff(runMode, diffStats, jobs.length);
526
-
527
- const reporter = new ConsoleReporter();
528
- const runner = new Runner(
529
- config,
530
- logger,
531
- reporter,
532
- failuresMap,
533
- changeOptions,
534
- effectiveBaseBranch,
535
- passedSlotsMap,
536
- debugLogger ?? undefined,
537
- isRerun,
538
- );
539
-
540
- const outcome = await runner.run(jobs);
541
-
542
- // Log run end with actual statistics from runner
543
- await debugLogger?.logRunEnd(
544
- outcome.allPassed ? "pass" : "fail",
545
- outcome.stats.fixed,
546
- outcome.stats.skipped,
547
- outcome.stats.failed,
548
- logger.getRunNumber(),
549
- );
550
-
551
- // Write execution state before releasing lock
552
- await writeExecutionState(config.project.log_dir);
553
-
554
- const consoleLogPath = await findLatestConsoleLog(config.project.log_dir);
555
-
556
- // Determine the correct status based on runner outcome
557
- let status: GauntletStatus;
558
- if (outcome.retryLimitExceeded) {
559
- status = "retry_limit_exceeded";
560
- } else if (outcome.allPassed && outcome.anySkipped) {
561
- status = "passed_with_warnings";
562
- } else if (outcome.allPassed) {
563
- status = "passed";
564
- } else {
565
- status = "failed";
566
- }
567
-
568
- // Clean logs on success or retry limit exceeded
569
- if (status === "passed") {
570
- await debugLogger?.logClean("auto", "all_passed");
571
- await cleanLogs(
572
- config.project.log_dir,
573
- config.project.max_previous_logs,
574
- );
575
- } else if (status === "retry_limit_exceeded") {
576
- await debugLogger?.logClean("auto", "retry_limit_exceeded");
577
- await cleanLogs(
578
- config.project.log_dir,
579
- config.project.max_previous_logs,
580
- );
581
- }
582
-
583
- consoleLogHandle?.restore();
584
-
585
- // Clean up logger if we initialized it
586
- if (loggerInitializedHere) {
587
- await resetLogger();
588
- }
589
-
590
- return {
591
- status,
592
- message: getStatusMessage(status),
593
- gatesRun: jobs.length,
594
- gatesFailed: outcome.allPassed ? 0 : jobs.length,
595
- consoleLogPath: consoleLogPath ?? undefined,
596
- gateResults: outcome.gateResults,
597
- };
598
- } finally {
599
- // Guarantee lock release regardless of how we exit the post-lock section
600
- await releaseLock(config.project.log_dir);
601
- }
602
- } catch (error: unknown) {
603
- // Do not write execution state on error - no gates completed successfully
604
- // Lock release is handled by the inner finally block if lock was acquired.
605
- // If error occurred before lock acquisition, no release needed.
606
- consoleLogHandle?.restore();
607
-
608
- // Clean up logger if we initialized it
609
- if (loggerInitializedHere) {
610
- await resetLogger();
611
- }
612
-
613
- const err = error as { message?: string };
614
- const errorMessage = err.message || "unknown error";
615
- return {
616
- status: "error",
617
- message: getStatusMessage("error"),
618
- errorMessage,
619
- };
620
- }
621
- }