agent-gauntlet 0.1.8 → 0.1.9
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/package.json +1 -1
- package/src/commands/check.ts +5 -0
- package/src/commands/help.ts +1 -0
- package/src/commands/init.test.ts +58 -46
- package/src/commands/init.ts +278 -100
- package/src/commands/rerun.ts +5 -1
- package/src/commands/review.ts +5 -0
- package/src/commands/run.ts +5 -0
- package/src/commands/shared.ts +34 -0
package/package.json
CHANGED
package/src/commands/check.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { JobGenerator } from '../core/job.js';
|
|
|
7
7
|
import { Runner } from '../core/runner.js';
|
|
8
8
|
import { Logger } from '../output/logger.js';
|
|
9
9
|
import { ConsoleReporter } from '../output/console.js';
|
|
10
|
+
import { rotateLogs } from './shared.js';
|
|
10
11
|
|
|
11
12
|
export function registerCheckCommand(program: Command): void {
|
|
12
13
|
program
|
|
@@ -18,6 +19,10 @@ export function registerCheckCommand(program: Command): void {
|
|
|
18
19
|
.action(async (options) => {
|
|
19
20
|
try {
|
|
20
21
|
const config = await loadConfig();
|
|
22
|
+
|
|
23
|
+
// Rotate logs before starting
|
|
24
|
+
await rotateLogs(config.project.log_dir);
|
|
25
|
+
|
|
21
26
|
const changeDetector = new ChangeDetector(config.project.base_branch, {
|
|
22
27
|
commit: options.commit,
|
|
23
28
|
uncommitted: options.uncommitted
|
package/src/commands/help.ts
CHANGED
|
@@ -11,6 +11,7 @@ export function registerHelpCommand(program: Command): void {
|
|
|
11
11
|
console.log('of your repo that changed, based on a configurable set of entry points.\n');
|
|
12
12
|
console.log(chalk.bold('Commands:\n'));
|
|
13
13
|
console.log(' run Run gates for detected changes');
|
|
14
|
+
console.log(' rerun Rerun gates with previous failure context');
|
|
14
15
|
console.log(' check Run only applicable checks');
|
|
15
16
|
console.log(' review Run only applicable reviews');
|
|
16
17
|
console.log(' detect Show what gates would run (without executing them)');
|
|
@@ -1,11 +1,41 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'bun:test';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, mock } from 'bun:test';
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
-
import { registerInitCommand } from './init.js';
|
|
4
3
|
import fs from 'node:fs/promises';
|
|
5
4
|
import path from 'node:path';
|
|
6
5
|
|
|
7
6
|
const TEST_DIR = path.join(process.cwd(), 'test-init-' + Date.now());
|
|
8
7
|
|
|
8
|
+
// Mock adapters
|
|
9
|
+
const mockAdapters = [
|
|
10
|
+
{
|
|
11
|
+
name: 'mock-cli-1',
|
|
12
|
+
isAvailable: async () => true,
|
|
13
|
+
getProjectCommandDir: () => '.mock1',
|
|
14
|
+
getUserCommandDir: () => null,
|
|
15
|
+
getCommandExtension: () => '.sh',
|
|
16
|
+
canUseSymlink: () => false,
|
|
17
|
+
transformCommand: (content: string) => content,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'mock-cli-2',
|
|
21
|
+
isAvailable: async () => false, // Not available
|
|
22
|
+
getProjectCommandDir: () => '.mock2',
|
|
23
|
+
getUserCommandDir: () => null,
|
|
24
|
+
getCommandExtension: () => '.sh',
|
|
25
|
+
canUseSymlink: () => false,
|
|
26
|
+
transformCommand: (content: string) => content,
|
|
27
|
+
}
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
mock.module('../cli-adapters/index.js', () => ({
|
|
31
|
+
getAllAdapters: () => mockAdapters,
|
|
32
|
+
getProjectCommandAdapters: () => mockAdapters,
|
|
33
|
+
getUserCommandAdapters: () => [],
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// Import after mocking
|
|
37
|
+
const { registerInitCommand } = await import('./init.js');
|
|
38
|
+
|
|
9
39
|
describe('Init Command', () => {
|
|
10
40
|
let program: Command;
|
|
11
41
|
const originalConsoleLog = console.log;
|
|
@@ -45,50 +75,33 @@ describe('Init Command', () => {
|
|
|
45
75
|
});
|
|
46
76
|
|
|
47
77
|
it('should create .gauntlet directory structure with --yes flag', async () => {
|
|
48
|
-
|
|
78
|
+
// We expect it to use the available mock-cli-1
|
|
79
|
+
await program.parseAsync(['node', 'test', 'init', '--yes']);
|
|
80
|
+
|
|
81
|
+
// Check that files were created
|
|
82
|
+
const gauntletDir = path.join(TEST_DIR, '.gauntlet');
|
|
83
|
+
const configFile = path.join(gauntletDir, 'config.yml');
|
|
84
|
+
const reviewsDir = path.join(gauntletDir, 'reviews');
|
|
85
|
+
const checksDir = path.join(gauntletDir, 'checks');
|
|
86
|
+
const runGauntletFile = path.join(gauntletDir, 'run_gauntlet.md');
|
|
49
87
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
});
|
|
88
|
+
expect(await fs.stat(gauntletDir)).toBeDefined();
|
|
89
|
+
expect(await fs.stat(configFile)).toBeDefined();
|
|
90
|
+
expect(await fs.stat(reviewsDir)).toBeDefined();
|
|
91
|
+
expect(await fs.stat(checksDir)).toBeDefined();
|
|
92
|
+
expect(await fs.stat(runGauntletFile)).toBeDefined();
|
|
56
93
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
expect(await fs.stat(configFile)).toBeDefined();
|
|
69
|
-
expect(await fs.stat(reviewsDir)).toBeDefined();
|
|
70
|
-
expect(await fs.stat(checksDir)).toBeDefined();
|
|
71
|
-
expect(await fs.stat(runGauntletFile)).toBeDefined();
|
|
72
|
-
|
|
73
|
-
// Verify config content
|
|
74
|
-
const configContent = await fs.readFile(configFile, 'utf-8');
|
|
75
|
-
expect(configContent).toContain('base_branch');
|
|
76
|
-
expect(configContent).toContain('log_dir');
|
|
77
|
-
|
|
78
|
-
// Verify review file content
|
|
79
|
-
const reviewFile = path.join(reviewsDir, 'code-quality.md');
|
|
80
|
-
const reviewContent = await fs.readFile(reviewFile, 'utf-8');
|
|
81
|
-
expect(reviewContent).toContain('cli_preference');
|
|
82
|
-
} catch (error: any) {
|
|
83
|
-
// If it times out, skip this test for now - the command installation part may need more complex mocking
|
|
84
|
-
if (error.message.includes('timed out')) {
|
|
85
|
-
console.log('Skipping test due to interactive prompt - command installation requires manual testing');
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
throw error;
|
|
89
|
-
} finally {
|
|
90
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
91
|
-
}
|
|
94
|
+
// Verify config content
|
|
95
|
+
const configContent = await fs.readFile(configFile, 'utf-8');
|
|
96
|
+
expect(configContent).toContain('base_branch');
|
|
97
|
+
expect(configContent).toContain('log_dir');
|
|
98
|
+
expect(configContent).toContain('mock-cli-1'); // Should be present
|
|
99
|
+
expect(configContent).not.toContain('mock-cli-2'); // Should not be present (unavailable)
|
|
100
|
+
|
|
101
|
+
// Verify review file content
|
|
102
|
+
const reviewFile = path.join(reviewsDir, 'code-quality.md');
|
|
103
|
+
const reviewContent = await fs.readFile(reviewFile, 'utf-8');
|
|
104
|
+
expect(reviewContent).toContain('mock-cli-1');
|
|
92
105
|
});
|
|
93
106
|
|
|
94
107
|
it('should not create directory if .gauntlet already exists', async () => {
|
|
@@ -96,8 +109,7 @@ describe('Init Command', () => {
|
|
|
96
109
|
const gauntletDir = path.join(TEST_DIR, '.gauntlet');
|
|
97
110
|
await fs.mkdir(gauntletDir, { recursive: true });
|
|
98
111
|
|
|
99
|
-
|
|
100
|
-
await initCmd?.parseAsync(['init', '--yes']);
|
|
112
|
+
await program.parseAsync(['node', 'test', 'init', '--yes']);
|
|
101
113
|
|
|
102
114
|
const output = logs.join('\n');
|
|
103
115
|
expect(output).toContain('.gauntlet directory already exists');
|
package/src/commands/init.ts
CHANGED
|
@@ -4,7 +4,9 @@ import fs from 'node:fs/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import readline from 'node:readline';
|
|
6
6
|
import { exists } from './shared.js';
|
|
7
|
-
import { getAllAdapters, getProjectCommandAdapters, getUserCommandAdapters } from '../cli-adapters/index.js';
|
|
7
|
+
import { getAllAdapters, getProjectCommandAdapters, getUserCommandAdapters, type CLIAdapter } from '../cli-adapters/index.js';
|
|
8
|
+
|
|
9
|
+
const MAX_PROMPT_ATTEMPTS = 10;
|
|
8
10
|
|
|
9
11
|
const GAUNTLET_COMMAND_CONTENT = `---
|
|
10
12
|
description: Run the full verification gauntlet
|
|
@@ -31,11 +33,18 @@ interface InitOptions {
|
|
|
31
33
|
yes?: boolean;
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
interface InitConfig {
|
|
37
|
+
sourceDir: string;
|
|
38
|
+
lintCmd: string | null; // null means not selected, empty string means selected but blank (TODO)
|
|
39
|
+
testCmd: string | null; // null means not selected, empty string means selected but blank (TODO)
|
|
40
|
+
selectedAdapters: CLIAdapter[];
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
export function registerInitCommand(program: Command): void {
|
|
35
44
|
program
|
|
36
45
|
.command('init')
|
|
37
46
|
.description('Initialize .gauntlet configuration')
|
|
38
|
-
.option('-y, --yes', 'Skip prompts and use defaults (
|
|
47
|
+
.option('-y, --yes', 'Skip prompts and use defaults (all available CLIs, source: ., no extra checks)')
|
|
39
48
|
.action(async (options: InitOptions) => {
|
|
40
49
|
const projectRoot = process.cwd();
|
|
41
50
|
const targetDir = path.join(projectRoot, '.gauntlet');
|
|
@@ -45,39 +54,90 @@ export function registerInitCommand(program: Command): void {
|
|
|
45
54
|
return;
|
|
46
55
|
}
|
|
47
56
|
|
|
57
|
+
// 1. CLI Detection
|
|
58
|
+
console.log('Detecting available CLI agents...');
|
|
59
|
+
const availableAdapters = await detectAvailableCLIs();
|
|
60
|
+
|
|
61
|
+
if (availableAdapters.length === 0) {
|
|
62
|
+
console.log();
|
|
63
|
+
console.log(chalk.red('Error: No CLI agents found. Install at least one:'));
|
|
64
|
+
console.log(' - Claude: https://docs.anthropic.com/en/docs/claude-code');
|
|
65
|
+
console.log(' - Gemini: https://github.com/google-gemini/gemini-cli');
|
|
66
|
+
console.log(' - Codex: https://github.com/openai/codex');
|
|
67
|
+
console.log();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let config: InitConfig;
|
|
72
|
+
|
|
73
|
+
if (options.yes) {
|
|
74
|
+
config = {
|
|
75
|
+
sourceDir: '.',
|
|
76
|
+
lintCmd: null,
|
|
77
|
+
testCmd: null,
|
|
78
|
+
selectedAdapters: availableAdapters,
|
|
79
|
+
};
|
|
80
|
+
} else {
|
|
81
|
+
config = await promptForConfig(availableAdapters);
|
|
82
|
+
}
|
|
83
|
+
|
|
48
84
|
// Create base config structure
|
|
49
85
|
await fs.mkdir(targetDir);
|
|
50
86
|
await fs.mkdir(path.join(targetDir, 'checks'));
|
|
51
87
|
await fs.mkdir(path.join(targetDir, 'reviews'));
|
|
52
88
|
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
default_preference:
|
|
58
|
-
- gemini
|
|
59
|
-
- codex
|
|
60
|
-
- claude
|
|
61
|
-
check_usage_limit: false
|
|
62
|
-
entry_points:
|
|
63
|
-
- path: "."
|
|
64
|
-
reviews:
|
|
65
|
-
- code-quality
|
|
66
|
-
`;
|
|
67
|
-
await fs.writeFile(path.join(targetDir, 'config.yml'), sampleConfig);
|
|
89
|
+
// 4. Commented Config Templates
|
|
90
|
+
// Generate config.yml
|
|
91
|
+
const configContent = generateConfigYml(config);
|
|
92
|
+
await fs.writeFile(path.join(targetDir, 'config.yml'), configContent);
|
|
68
93
|
console.log(chalk.green('Created .gauntlet/config.yml'));
|
|
69
94
|
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
95
|
+
// Generate check files if selected
|
|
96
|
+
if (config.lintCmd !== null) {
|
|
97
|
+
const lintContent = `name: lint
|
|
98
|
+
command: ${config.lintCmd || '# command: TODO - add your lint command (e.g., npm run lint)'}
|
|
99
|
+
# parallel: false
|
|
100
|
+
# run_in_ci: true
|
|
101
|
+
# run_locally: true
|
|
102
|
+
# timeout: 300
|
|
103
|
+
`;
|
|
104
|
+
await fs.writeFile(path.join(targetDir, 'checks', 'lint.yml'), lintContent);
|
|
105
|
+
console.log(chalk.green('Created .gauntlet/checks/lint.yml'));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (config.testCmd !== null) {
|
|
109
|
+
const testContent = `name: unit-tests
|
|
110
|
+
command: ${config.testCmd || '# command: TODO - add your test command (e.g., npm test)'}
|
|
111
|
+
# parallel: false
|
|
112
|
+
# run_in_ci: true
|
|
113
|
+
# run_locally: true
|
|
114
|
+
# timeout: 300
|
|
115
|
+
`;
|
|
116
|
+
await fs.writeFile(path.join(targetDir, 'checks', 'unit-tests.yml'), testContent);
|
|
117
|
+
console.log(chalk.green('Created .gauntlet/checks/unit-tests.yml'));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 5. Improved Default Code Review Prompt
|
|
121
|
+
const reviewContent = `---
|
|
122
|
+
num_reviews: 1
|
|
123
|
+
# parallel: true
|
|
124
|
+
# timeout: 300
|
|
125
|
+
# cli_preference:
|
|
126
|
+
# - ${config.selectedAdapters[0]?.name || 'claude'}
|
|
75
127
|
---
|
|
76
128
|
|
|
77
129
|
# Code Review
|
|
78
|
-
|
|
130
|
+
|
|
131
|
+
Review the diff for quality issues:
|
|
132
|
+
|
|
133
|
+
- **Bugs**: Logic errors, null handling, edge cases, race conditions
|
|
134
|
+
- **Security**: Input validation, secrets exposure, injection risks
|
|
135
|
+
- **Maintainability**: Unclear code, missing error handling, duplication
|
|
136
|
+
- **Performance**: Unnecessary work, N+1 queries, missing optimizations
|
|
137
|
+
|
|
138
|
+
For each issue: cite file:line, explain the problem, suggest a fix.
|
|
79
139
|
`;
|
|
80
|
-
await fs.writeFile(path.join(targetDir, 'reviews', 'code-quality.md'),
|
|
140
|
+
await fs.writeFile(path.join(targetDir, 'reviews', 'code-quality.md'), reviewContent);
|
|
81
141
|
console.log(chalk.green('Created .gauntlet/reviews/code-quality.md'));
|
|
82
142
|
|
|
83
143
|
// Write the canonical gauntlet command file
|
|
@@ -87,52 +147,181 @@ Review this code.
|
|
|
87
147
|
|
|
88
148
|
// Handle command installation
|
|
89
149
|
if (options.yes) {
|
|
90
|
-
// Default: install at project level for all agents
|
|
91
|
-
const
|
|
92
|
-
|
|
150
|
+
// Default: install at project level for all selected agents (if they support it)
|
|
151
|
+
const adaptersToInstall = config.selectedAdapters.filter(a => a.getProjectCommandDir() !== null);
|
|
152
|
+
if (adaptersToInstall.length > 0) {
|
|
153
|
+
await installCommands('project', adaptersToInstall.map(a => a.name), projectRoot, canonicalCommandPath);
|
|
154
|
+
}
|
|
93
155
|
} else {
|
|
94
|
-
// Interactive prompts
|
|
95
|
-
await promptAndInstallCommands(projectRoot, canonicalCommandPath);
|
|
156
|
+
// Interactive prompts - passing available adapters to avoid re-checking or offering unavailable ones
|
|
157
|
+
await promptAndInstallCommands(projectRoot, canonicalCommandPath, availableAdapters);
|
|
96
158
|
}
|
|
97
159
|
});
|
|
98
160
|
}
|
|
99
161
|
|
|
100
|
-
async function
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
162
|
+
async function detectAvailableCLIs(): Promise<CLIAdapter[]> {
|
|
163
|
+
const allAdapters = getAllAdapters();
|
|
164
|
+
const available: CLIAdapter[] = [];
|
|
165
|
+
|
|
166
|
+
for (const adapter of allAdapters) {
|
|
167
|
+
const isAvailable = await adapter.isAvailable();
|
|
168
|
+
if (isAvailable) {
|
|
169
|
+
console.log(chalk.green(` ✓ ${adapter.name}`));
|
|
170
|
+
available.push(adapter);
|
|
171
|
+
} else {
|
|
172
|
+
console.log(chalk.dim(` ✗ ${adapter.name} (not installed)`));
|
|
111
173
|
}
|
|
112
|
-
const input = Buffer.concat(chunks).toString('utf-8');
|
|
113
|
-
inputLines = input.split('\n').map(l => l.trim());
|
|
114
174
|
}
|
|
175
|
+
return available;
|
|
176
|
+
}
|
|
115
177
|
|
|
116
|
-
|
|
178
|
+
async function promptForConfig(availableAdapters: CLIAdapter[]): Promise<InitConfig> {
|
|
179
|
+
const rl = readline.createInterface({
|
|
117
180
|
input: process.stdin,
|
|
118
181
|
output: process.stdout
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
const question =
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
resolve(answer?.trim() ?? '');
|
|
126
|
-
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const question = (prompt: string): Promise<string> => {
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
rl.question(prompt, (answer) => {
|
|
187
|
+
resolve(answer?.trim() ?? '');
|
|
127
188
|
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
// CLI Selection
|
|
194
|
+
console.log();
|
|
195
|
+
console.log('Which CLIs would you like to use?');
|
|
196
|
+
availableAdapters.forEach((adapter, i) => {
|
|
197
|
+
console.log(` ${i + 1}) ${adapter.name}`);
|
|
198
|
+
});
|
|
199
|
+
console.log(` ${availableAdapters.length + 1}) All`);
|
|
200
|
+
|
|
201
|
+
let selectedAdapters: CLIAdapter[] = [];
|
|
202
|
+
let attempts = 0;
|
|
203
|
+
while (true) {
|
|
204
|
+
attempts++;
|
|
205
|
+
if (attempts > MAX_PROMPT_ATTEMPTS) throw new Error('Too many invalid attempts');
|
|
206
|
+
const answer = await question(`(comma-separated, e.g., 1,2): `);
|
|
207
|
+
const selections = answer.split(',').map(s => s.trim()).filter(s => s);
|
|
208
|
+
|
|
209
|
+
if (selections.length === 0) {
|
|
210
|
+
// Default to all if empty? Or force selection? Plan says "Which CLIs...".
|
|
211
|
+
// Let's assume user must pick or we default to all if they just hit enter?
|
|
212
|
+
// Actually, usually enter means default. Let's make All the default if just Enter.
|
|
213
|
+
selectedAdapters = availableAdapters;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let valid = true;
|
|
218
|
+
const chosen: CLIAdapter[] = [];
|
|
219
|
+
|
|
220
|
+
for (const sel of selections) {
|
|
221
|
+
const num = parseInt(sel, 10);
|
|
222
|
+
if (isNaN(num) || num < 1 || num > availableAdapters.length + 1) {
|
|
223
|
+
console.log(chalk.yellow(`Invalid selection: ${sel}`));
|
|
224
|
+
valid = false;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
if (num === availableAdapters.length + 1) {
|
|
228
|
+
chosen.push(...availableAdapters);
|
|
229
|
+
} else {
|
|
230
|
+
chosen.push(availableAdapters[num - 1]);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (valid) {
|
|
235
|
+
selectedAdapters = [...new Set(chosen)];
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Source Directory
|
|
241
|
+
console.log();
|
|
242
|
+
const sourceDirInput = await question('Enter your source directory (e.g., src, lib, .) [default: .]: ');
|
|
243
|
+
const sourceDir = sourceDirInput || '.';
|
|
244
|
+
|
|
245
|
+
// Lint Check
|
|
246
|
+
console.log();
|
|
247
|
+
const addLint = await question('Would you like to add a linting check? [y/N]: ');
|
|
248
|
+
let lintCmd: string | null = null;
|
|
249
|
+
if (addLint.toLowerCase().startsWith('y')) {
|
|
250
|
+
lintCmd = await question('Enter lint command (blank to fill later): ');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Unit Test Check
|
|
254
|
+
console.log();
|
|
255
|
+
const addTest = await question('Would you like to add a unit test check? [y/N]: ');
|
|
256
|
+
let testCmd: string | null = null;
|
|
257
|
+
if (addTest.toLowerCase().startsWith('y')) {
|
|
258
|
+
testCmd = await question('Enter test command (blank to fill later): ');
|
|
135
259
|
}
|
|
260
|
+
|
|
261
|
+
rl.close();
|
|
262
|
+
return {
|
|
263
|
+
sourceDir,
|
|
264
|
+
lintCmd,
|
|
265
|
+
testCmd,
|
|
266
|
+
selectedAdapters
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
} catch (error) {
|
|
270
|
+
rl.close();
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function generateConfigYml(config: InitConfig): string {
|
|
276
|
+
const cliList = config.selectedAdapters.map(a => ` - ${a.name}`).join('\n');
|
|
277
|
+
|
|
278
|
+
let entryPoints = '';
|
|
279
|
+
|
|
280
|
+
// If we have checks, we need a source directory entry point
|
|
281
|
+
if (config.lintCmd !== null || config.testCmd !== null) {
|
|
282
|
+
entryPoints += ` - path: "${config.sourceDir}"
|
|
283
|
+
checks:\n`;
|
|
284
|
+
if (config.lintCmd !== null) entryPoints += ` - lint\n`;
|
|
285
|
+
if (config.testCmd !== null) entryPoints += ` - unit-tests\n`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Always include root entry point for reviews
|
|
289
|
+
entryPoints += ` - path: "."
|
|
290
|
+
reviews:
|
|
291
|
+
- code-quality`;
|
|
292
|
+
|
|
293
|
+
return `base_branch: origin/main
|
|
294
|
+
log_dir: .gauntlet_logs
|
|
295
|
+
|
|
296
|
+
# Run gates in parallel when possible (default: true)
|
|
297
|
+
# allow_parallel: true
|
|
298
|
+
|
|
299
|
+
cli:
|
|
300
|
+
default_preference:
|
|
301
|
+
${cliList}
|
|
302
|
+
# Check CLI usage quota before running (if unavailable, uses next in list)
|
|
303
|
+
# check_usage_limit: false
|
|
304
|
+
|
|
305
|
+
entry_points:
|
|
306
|
+
${entryPoints}
|
|
307
|
+
`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function promptAndInstallCommands(projectRoot: string, canonicalCommandPath: string, availableAdapters: CLIAdapter[]): Promise<void> {
|
|
311
|
+
// Only proceed if we have available adapters
|
|
312
|
+
if (availableAdapters.length === 0) return;
|
|
313
|
+
|
|
314
|
+
const rl = readline.createInterface({
|
|
315
|
+
input: process.stdin,
|
|
316
|
+
output: process.stdout
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const question = (prompt: string): Promise<string> => {
|
|
320
|
+
return new Promise((resolve) => {
|
|
321
|
+
rl.question(prompt, (answer) => {
|
|
322
|
+
resolve(answer?.trim() ?? '');
|
|
323
|
+
});
|
|
324
|
+
});
|
|
136
325
|
};
|
|
137
326
|
|
|
138
327
|
try {
|
|
@@ -150,14 +339,12 @@ async function promptAndInstallCommands(projectRoot: string, canonicalCommandPat
|
|
|
150
339
|
|
|
151
340
|
let installLevel: InstallLevel = 'none';
|
|
152
341
|
let answer = await question('Select option [1-3]: ');
|
|
153
|
-
|
|
154
|
-
// Handle EOF or empty input for non-TTY
|
|
155
|
-
if (!isTTY && answer === '' && lineIndex > inputLines.length) {
|
|
156
|
-
console.log(chalk.dim('\nNo input received, skipping command installation.'));
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
342
|
+
let installLevelAttempts = 0;
|
|
159
343
|
|
|
160
344
|
while (true) {
|
|
345
|
+
installLevelAttempts++;
|
|
346
|
+
if (installLevelAttempts > MAX_PROMPT_ATTEMPTS) throw new Error('Too many invalid attempts');
|
|
347
|
+
|
|
161
348
|
if (answer === '1') {
|
|
162
349
|
installLevel = 'none';
|
|
163
350
|
break;
|
|
@@ -169,47 +356,48 @@ async function promptAndInstallCommands(projectRoot: string, canonicalCommandPat
|
|
|
169
356
|
break;
|
|
170
357
|
} else {
|
|
171
358
|
console.log(chalk.yellow('Please enter 1, 2, or 3'));
|
|
172
|
-
if (!isTTY && lineIndex >= inputLines.length) {
|
|
173
|
-
console.log(chalk.dim('\nNo more input, skipping command installation.'));
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
359
|
answer = await question('Select option [1-3]: ');
|
|
177
360
|
}
|
|
178
361
|
}
|
|
179
362
|
|
|
180
363
|
if (installLevel === 'none') {
|
|
181
364
|
console.log(chalk.dim('\nSkipping command installation.'));
|
|
182
|
-
rl
|
|
365
|
+
rl.close();
|
|
183
366
|
return;
|
|
184
367
|
}
|
|
185
368
|
|
|
186
|
-
//
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
369
|
+
// Filter available adapters based on install level support
|
|
370
|
+
const installableAdapters = installLevel === 'project'
|
|
371
|
+
? availableAdapters.filter(a => a.getProjectCommandDir() !== null)
|
|
372
|
+
: availableAdapters.filter(a => a.getUserCommandDir() !== null);
|
|
373
|
+
|
|
374
|
+
if (installableAdapters.length === 0) {
|
|
375
|
+
console.log(chalk.yellow(`No available agents support ${installLevel}-level commands.`));
|
|
376
|
+
rl.close();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
191
379
|
|
|
192
380
|
console.log();
|
|
193
381
|
console.log('Which CLI agents would you like to install the command for?');
|
|
194
|
-
|
|
382
|
+
installableAdapters.forEach((adapter, i) => {
|
|
195
383
|
console.log(` ${i + 1}) ${adapter.name}`);
|
|
196
384
|
});
|
|
197
|
-
console.log(` ${
|
|
385
|
+
console.log(` ${installableAdapters.length + 1}) All of the above`);
|
|
198
386
|
console.log();
|
|
199
387
|
|
|
200
388
|
let selectedAgents: string[] = [];
|
|
201
|
-
answer = await question(`Select options (comma-separated, e.g., 1,2 or ${
|
|
389
|
+
answer = await question(`Select options (comma-separated, e.g., 1,2 or ${installableAdapters.length + 1} for all): `);
|
|
390
|
+
let agentSelectionAttempts = 0;
|
|
202
391
|
|
|
203
392
|
while (true) {
|
|
393
|
+
agentSelectionAttempts++;
|
|
394
|
+
if (agentSelectionAttempts > MAX_PROMPT_ATTEMPTS) throw new Error('Too many invalid attempts');
|
|
395
|
+
|
|
204
396
|
const selections = answer.split(',').map(s => s.trim()).filter(s => s);
|
|
205
397
|
|
|
206
398
|
if (selections.length === 0) {
|
|
207
|
-
if (!isTTY && lineIndex >= inputLines.length) {
|
|
208
|
-
console.log(chalk.dim('\nNo more input, skipping command installation.'));
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
399
|
console.log(chalk.yellow('Please select at least one option'));
|
|
212
|
-
answer = await question(`Select options (comma-separated, e.g., 1,2 or ${
|
|
400
|
+
answer = await question(`Select options (comma-separated, e.g., 1,2 or ${installableAdapters.length + 1} for all): `);
|
|
213
401
|
continue;
|
|
214
402
|
}
|
|
215
403
|
|
|
@@ -218,16 +406,15 @@ async function promptAndInstallCommands(projectRoot: string, canonicalCommandPat
|
|
|
218
406
|
|
|
219
407
|
for (const sel of selections) {
|
|
220
408
|
const num = parseInt(sel, 10);
|
|
221
|
-
if (isNaN(num) || num < 1 || num >
|
|
409
|
+
if (isNaN(num) || num < 1 || num > installableAdapters.length + 1) {
|
|
222
410
|
console.log(chalk.yellow(`Invalid selection: ${sel}`));
|
|
223
411
|
valid = false;
|
|
224
412
|
break;
|
|
225
413
|
}
|
|
226
|
-
if (num ===
|
|
227
|
-
|
|
228
|
-
agents.push(...availableAdapters.map(a => a.name));
|
|
414
|
+
if (num === installableAdapters.length + 1) {
|
|
415
|
+
agents.push(...installableAdapters.map(a => a.name));
|
|
229
416
|
} else {
|
|
230
|
-
agents.push(
|
|
417
|
+
agents.push(installableAdapters[num - 1].name);
|
|
231
418
|
}
|
|
232
419
|
}
|
|
233
420
|
|
|
@@ -235,21 +422,16 @@ async function promptAndInstallCommands(projectRoot: string, canonicalCommandPat
|
|
|
235
422
|
selectedAgents = [...new Set(agents)]; // Dedupe
|
|
236
423
|
break;
|
|
237
424
|
}
|
|
238
|
-
|
|
239
|
-
if (!isTTY && lineIndex >= inputLines.length) {
|
|
240
|
-
console.log(chalk.dim('\nNo more input, skipping command installation.'));
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
answer = await question(`Select options (comma-separated, e.g., 1,2 or ${availableAdapters.length + 1} for all): `);
|
|
425
|
+
answer = await question(`Select options (comma-separated, e.g., 1,2 or ${installableAdapters.length + 1} for all): `);
|
|
244
426
|
}
|
|
245
427
|
|
|
246
|
-
rl
|
|
428
|
+
rl.close();
|
|
247
429
|
|
|
248
430
|
// Install commands
|
|
249
431
|
await installCommands(installLevel, selectedAgents, projectRoot, canonicalCommandPath);
|
|
250
432
|
|
|
251
433
|
} catch (error: any) {
|
|
252
|
-
rl
|
|
434
|
+
rl.close();
|
|
253
435
|
throw error;
|
|
254
436
|
}
|
|
255
437
|
}
|
|
@@ -286,11 +468,7 @@ async function installCommands(
|
|
|
286
468
|
}
|
|
287
469
|
|
|
288
470
|
if (!commandDir) {
|
|
289
|
-
if
|
|
290
|
-
console.log(chalk.yellow(` ${adapter.name}: No project-level command support, skipping`));
|
|
291
|
-
} else {
|
|
292
|
-
console.log(chalk.yellow(` ${adapter.name}: No user-level command support, skipping`));
|
|
293
|
-
}
|
|
471
|
+
// This shouldn't happen if we filtered correctly, but good safety check
|
|
294
472
|
continue;
|
|
295
473
|
}
|
|
296
474
|
|
package/src/commands/rerun.ts
CHANGED
|
@@ -7,7 +7,8 @@ import { JobGenerator } from '../core/job.js';
|
|
|
7
7
|
import { Runner } from '../core/runner.js';
|
|
8
8
|
import { Logger } from '../output/logger.js';
|
|
9
9
|
import { ConsoleReporter } from '../output/console.js';
|
|
10
|
-
import { findPreviousFailures, GateFailures, PreviousViolation } from '../utils/log-parser.js';
|
|
10
|
+
import { findPreviousFailures, type GateFailures, type PreviousViolation } from '../utils/log-parser.js';
|
|
11
|
+
import { rotateLogs } from './shared.js';
|
|
11
12
|
|
|
12
13
|
export function registerRerunCommand(program: Command): void {
|
|
13
14
|
program
|
|
@@ -51,6 +52,9 @@ export function registerRerunCommand(program: Command): void {
|
|
|
51
52
|
console.log(chalk.dim('No previous failures found. Running as normal...'));
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
// Rotate logs before starting the new run
|
|
56
|
+
await rotateLogs(config.project.log_dir);
|
|
57
|
+
|
|
54
58
|
// Detect changes (default to uncommitted unless --commit is specified)
|
|
55
59
|
// Note: Rerun defaults to uncommitted changes for faster iteration loops,
|
|
56
60
|
// unlike 'run' which defaults to base_branch comparison.
|
package/src/commands/review.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { JobGenerator } from '../core/job.js';
|
|
|
7
7
|
import { Runner } from '../core/runner.js';
|
|
8
8
|
import { Logger } from '../output/logger.js';
|
|
9
9
|
import { ConsoleReporter } from '../output/console.js';
|
|
10
|
+
import { rotateLogs } from './shared.js';
|
|
10
11
|
|
|
11
12
|
export function registerReviewCommand(program: Command): void {
|
|
12
13
|
program
|
|
@@ -18,6 +19,10 @@ export function registerReviewCommand(program: Command): void {
|
|
|
18
19
|
.action(async (options) => {
|
|
19
20
|
try {
|
|
20
21
|
const config = await loadConfig();
|
|
22
|
+
|
|
23
|
+
// Rotate logs before starting
|
|
24
|
+
await rotateLogs(config.project.log_dir);
|
|
25
|
+
|
|
21
26
|
const changeDetector = new ChangeDetector(config.project.base_branch, {
|
|
22
27
|
commit: options.commit,
|
|
23
28
|
uncommitted: options.uncommitted
|
package/src/commands/run.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { JobGenerator } from '../core/job.js';
|
|
|
7
7
|
import { Runner } from '../core/runner.js';
|
|
8
8
|
import { Logger } from '../output/logger.js';
|
|
9
9
|
import { ConsoleReporter } from '../output/console.js';
|
|
10
|
+
import { rotateLogs } from './shared.js';
|
|
10
11
|
|
|
11
12
|
export function registerRunCommand(program: Command): void {
|
|
12
13
|
program
|
|
@@ -18,6 +19,10 @@ export function registerRunCommand(program: Command): void {
|
|
|
18
19
|
.action(async (options) => {
|
|
19
20
|
try {
|
|
20
21
|
const config = await loadConfig();
|
|
22
|
+
|
|
23
|
+
// Rotate logs before starting
|
|
24
|
+
await rotateLogs(config.project.log_dir);
|
|
25
|
+
|
|
21
26
|
const changeDetector = new ChangeDetector(config.project.base_branch, {
|
|
22
27
|
commit: options.commit,
|
|
23
28
|
uncommitted: options.uncommitted
|
package/src/commands/shared.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
|
|
3
4
|
export async function exists(path: string): Promise<boolean> {
|
|
4
5
|
try {
|
|
@@ -8,3 +9,36 @@ export async function exists(path: string): Promise<boolean> {
|
|
|
8
9
|
return false;
|
|
9
10
|
}
|
|
10
11
|
}
|
|
12
|
+
|
|
13
|
+
export async function rotateLogs(logDir: string): Promise<void> {
|
|
14
|
+
const previousDir = path.join(logDir, 'previous');
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
// 1. Ensure logDir exists (if not, nothing to rotate, but we should create it for future use if needed,
|
|
18
|
+
// though usually the logger creates it. If it doesn't exist, we can just return).
|
|
19
|
+
if (!(await exists(logDir))) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 2. Clear .gauntlet_logs/previous if it exists
|
|
24
|
+
if (await exists(previousDir)) {
|
|
25
|
+
const previousFiles = await fs.readdir(previousDir);
|
|
26
|
+
await Promise.all(
|
|
27
|
+
previousFiles.map(file => fs.rm(path.join(previousDir, file), { recursive: true, force: true }))
|
|
28
|
+
);
|
|
29
|
+
} else {
|
|
30
|
+
await fs.mkdir(previousDir, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 3. Move all existing files in .gauntlet_logs/ to .gauntlet_logs/previous
|
|
34
|
+
const files = await fs.readdir(logDir);
|
|
35
|
+
await Promise.all(
|
|
36
|
+
files
|
|
37
|
+
.filter(file => file !== 'previous')
|
|
38
|
+
.map(file => fs.rename(path.join(logDir, file), path.join(previousDir, file)))
|
|
39
|
+
);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
// Log warning but don't crash the run as log rotation failure isn't critical
|
|
42
|
+
console.warn(`Failed to rotate logs in ${logDir}:`, error instanceof Error ? error.message : error);
|
|
43
|
+
}
|
|
44
|
+
}
|