@delegance/claude-autopilot 1.0.0-alpha.4
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/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/bin/autopilot.js +15 -0
- package/package.json +41 -0
- package/presets/go/autopilot.config.yaml +20 -0
- package/presets/go/rules/go-sql-injection.ts +65 -0
- package/presets/go/stack.md +20 -0
- package/presets/nextjs-supabase/autopilot.config.yaml +29 -0
- package/presets/nextjs-supabase/rules/supabase-rls-bypass.ts +39 -0
- package/presets/nextjs-supabase/stack.md +20 -0
- package/presets/python-fastapi/autopilot.config.yaml +20 -0
- package/presets/python-fastapi/rules/fastapi-missing-auth.ts +50 -0
- package/presets/python-fastapi/stack.md +20 -0
- package/presets/rails-postgres/autopilot.config.yaml +21 -0
- package/presets/rails-postgres/rules/rails-sql-injection.ts +42 -0
- package/presets/rails-postgres/stack.md +20 -0
- package/presets/t3/autopilot.config.yaml +22 -0
- package/presets/t3/rules/t3-server-only.ts +35 -0
- package/presets/t3/stack.md +20 -0
- package/scripts/test-runner.mjs +16 -0
- package/src/adapters/base.ts +19 -0
- package/src/adapters/loader.ts +101 -0
- package/src/adapters/migration-runner/supabase.ts +56 -0
- package/src/adapters/migration-runner/types.ts +36 -0
- package/src/adapters/review-bot-parser/cursor.ts +13 -0
- package/src/adapters/review-bot-parser/declarative-base.ts +64 -0
- package/src/adapters/review-bot-parser/types.ts +9 -0
- package/src/adapters/review-engine/codex.ts +108 -0
- package/src/adapters/review-engine/types.ts +19 -0
- package/src/adapters/vcs-host/github.ts +77 -0
- package/src/adapters/vcs-host/types.ts +44 -0
- package/src/cli/index.ts +110 -0
- package/src/cli/init.ts +88 -0
- package/src/cli/preflight.ts +154 -0
- package/src/cli/run.ts +152 -0
- package/src/cli/watch.ts +169 -0
- package/src/core/.gitkeep +0 -0
- package/src/core/cache/cached-engine.ts +32 -0
- package/src/core/cache/review-cache.ts +70 -0
- package/src/core/chunking/index.ts +82 -0
- package/src/core/config/loader.ts +41 -0
- package/src/core/config/preset-resolver.ts +46 -0
- package/src/core/config/schema.ts +63 -0
- package/src/core/config/types.ts +42 -0
- package/src/core/errors.ts +37 -0
- package/src/core/findings/dedup.ts +14 -0
- package/src/core/findings/types.ts +39 -0
- package/src/core/git/touched-files.ts +51 -0
- package/src/core/index.ts +1 -0
- package/src/core/logging/ndjson-writer.ts +37 -0
- package/src/core/logging/redaction.ts +19 -0
- package/src/core/phases/static-rules.ts +80 -0
- package/src/core/phases/tests.ts +51 -0
- package/src/core/pipeline/review-phase.ts +87 -0
- package/src/core/pipeline/run.ts +80 -0
- package/src/core/runtime/idempotency.ts +6 -0
- package/src/core/runtime/lock.ts +29 -0
- package/src/core/runtime/state.ts +97 -0
- package/src/core/shell.ts +48 -0
package/src/cli/init.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as fsAsync from 'node:fs/promises';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import * as readline from 'node:readline/promises';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
8
|
+
|
|
9
|
+
const PRESET_DESCRIPTIONS: Record<string, string> = {
|
|
10
|
+
'nextjs-supabase': 'Next.js App Router + Supabase (Postgres + RLS)',
|
|
11
|
+
't3': 'T3 Stack (Next.js + tRPC + Prisma + NextAuth)',
|
|
12
|
+
'rails-postgres': 'Ruby on Rails 7 + PostgreSQL',
|
|
13
|
+
'python-fastapi': 'Python FastAPI + SQLAlchemy + Alembic',
|
|
14
|
+
'go': 'Go + PostgreSQL (pgx/v5)',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const PRESET_NAMES = Object.keys(PRESET_DESCRIPTIONS);
|
|
18
|
+
|
|
19
|
+
export async function runInit(cwd: string = process.cwd()): Promise<void> {
|
|
20
|
+
const dest = path.join(cwd, 'autopilot.config.yaml');
|
|
21
|
+
|
|
22
|
+
if (fs.existsSync(dest)) {
|
|
23
|
+
console.error(`\x1b[33m[init] autopilot.config.yaml already exists — remove it first to re-init\x1b[0m`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log('\n\x1b[1m[autopilot init] Choose a preset:\x1b[0m\n');
|
|
28
|
+
PRESET_NAMES.forEach((name, i) => {
|
|
29
|
+
console.log(` ${i + 1}. ${name.padEnd(22)} ${PRESET_DESCRIPTIONS[name]}`);
|
|
30
|
+
});
|
|
31
|
+
console.log('');
|
|
32
|
+
|
|
33
|
+
const rl = readline.createInterface({ input, output });
|
|
34
|
+
let choice: number;
|
|
35
|
+
try {
|
|
36
|
+
const answer = await rl.question(' Enter number (or preset name): ');
|
|
37
|
+
rl.close();
|
|
38
|
+
const trimmed = answer.trim();
|
|
39
|
+
const byName = PRESET_NAMES.indexOf(trimmed);
|
|
40
|
+
if (byName >= 0) {
|
|
41
|
+
choice = byName;
|
|
42
|
+
} else {
|
|
43
|
+
const n = parseInt(trimmed, 10);
|
|
44
|
+
if (isNaN(n) || n < 1 || n > PRESET_NAMES.length) {
|
|
45
|
+
console.error(`\x1b[31m[init] Invalid selection: "${trimmed}"\x1b[0m`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
choice = n - 1;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
rl.close();
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const presetName = PRESET_NAMES[choice]!;
|
|
56
|
+
const presetConfigPath = findPresetConfig(presetName);
|
|
57
|
+
if (!presetConfigPath) {
|
|
58
|
+
console.error(`\x1b[31m[init] Preset config not found for: ${presetName}\x1b[0m`);
|
|
59
|
+
console.error(` Looked in: ${presetSearchPaths(presetName).join(', ')}`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const presetContent = await fsAsync.readFile(presetConfigPath, 'utf8');
|
|
64
|
+
await fsAsync.writeFile(dest, presetContent, 'utf8');
|
|
65
|
+
|
|
66
|
+
console.log(`\n\x1b[32m✓\x1b[0m Created autopilot.config.yaml from preset \x1b[1m${presetName}\x1b[0m`);
|
|
67
|
+
console.log('\nNext steps:');
|
|
68
|
+
console.log(' 1. Review autopilot.config.yaml and adjust testCommand / protectedPaths');
|
|
69
|
+
console.log(' 2. Set OPENAI_API_KEY in your .env file for Codex review');
|
|
70
|
+
console.log(' 3. Run: npx autopilot run\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function presetSearchPaths(name: string): string[] {
|
|
74
|
+
// fileURLToPath handles encoded chars and Windows drive letters safely
|
|
75
|
+
const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
76
|
+
return [
|
|
77
|
+
path.join(pkgRoot, 'presets', name, 'autopilot.config.yaml'),
|
|
78
|
+
path.join(process.cwd(), 'presets', name, 'autopilot.config.yaml'),
|
|
79
|
+
path.join(process.cwd(), 'node_modules', '@delegance', 'claude-autopilot', 'presets', name, 'autopilot.config.yaml'),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function findPresetConfig(name: string): string | null {
|
|
84
|
+
for (const p of presetSearchPaths(name)) {
|
|
85
|
+
if (fs.existsSync(p)) return p;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { runSafe } from '../core/shell.ts';
|
|
5
|
+
|
|
6
|
+
const PASS = '\x1b[32m✓\x1b[0m';
|
|
7
|
+
const FAIL = '\x1b[31m✗\x1b[0m';
|
|
8
|
+
const WARN = '\x1b[33m!\x1b[0m';
|
|
9
|
+
|
|
10
|
+
const ENV_CANDIDATES = ['.env.local', '.env.dev', '.env.development', '.env'];
|
|
11
|
+
|
|
12
|
+
interface Check {
|
|
13
|
+
name: string;
|
|
14
|
+
result: 'pass' | 'fail' | 'warn';
|
|
15
|
+
message?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function loadEnvFile(filePath: string): Record<string, string> {
|
|
19
|
+
const vars: Record<string, string> = {};
|
|
20
|
+
try {
|
|
21
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
22
|
+
for (const line of content.split('\n')) {
|
|
23
|
+
const trimmed = line.trim();
|
|
24
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
25
|
+
const eq = trimmed.indexOf('=');
|
|
26
|
+
if (eq < 0) continue;
|
|
27
|
+
vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim().replace(/^['"]|['"]$/g, '');
|
|
28
|
+
}
|
|
29
|
+
} catch { /* ignore */ }
|
|
30
|
+
return vars;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const checks: Check[] = [];
|
|
34
|
+
|
|
35
|
+
// 1. Node version
|
|
36
|
+
const nodeVersion = process.version;
|
|
37
|
+
const nodeMajor = parseInt(nodeVersion.slice(1).split('.')[0]!, 10);
|
|
38
|
+
checks.push({
|
|
39
|
+
name: `Node.js ${nodeVersion}`,
|
|
40
|
+
result: nodeMajor >= 22 ? 'pass' : 'fail',
|
|
41
|
+
message: nodeMajor < 22 ? `Node 22+ required — current: ${nodeVersion}. Install via nvm: nvm install 22` : undefined,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// 2. tsx available
|
|
45
|
+
const localTsx = path.join(process.cwd(), 'node_modules', '.bin', 'tsx');
|
|
46
|
+
const tsxVersion = fs.existsSync(localTsx)
|
|
47
|
+
? runSafe(localTsx, ['--version'])
|
|
48
|
+
: runSafe('tsx', ['--version']);
|
|
49
|
+
checks.push({
|
|
50
|
+
name: 'tsx available',
|
|
51
|
+
result: tsxVersion ? 'pass' : 'fail',
|
|
52
|
+
message: !tsxVersion ? 'tsx not found — run: npm install --save-dev tsx' : undefined,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// 3. gh CLI authenticated
|
|
56
|
+
const ghAuth = runSafe('gh', ['auth', 'status']);
|
|
57
|
+
checks.push({
|
|
58
|
+
name: 'gh CLI authenticated',
|
|
59
|
+
result: ghAuth !== null ? 'pass' : 'fail',
|
|
60
|
+
message: ghAuth === null ? 'gh CLI not authenticated — run: gh auth login' : undefined,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// 4. autopilot.config.yaml in cwd
|
|
64
|
+
const configYaml = path.join(process.cwd(), 'autopilot.config.yaml');
|
|
65
|
+
checks.push({
|
|
66
|
+
name: 'autopilot.config.yaml',
|
|
67
|
+
result: fs.existsSync(configYaml) ? 'pass' : 'warn',
|
|
68
|
+
message: !fs.existsSync(configYaml)
|
|
69
|
+
? 'autopilot.config.yaml not found in current directory — copy from a preset: presets/nextjs-supabase/autopilot.config.yaml'
|
|
70
|
+
: undefined,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// 5. Local env file exists
|
|
74
|
+
const envFile = ENV_CANDIDATES.find(f => fs.existsSync(f));
|
|
75
|
+
checks.push({
|
|
76
|
+
name: `Local env file (${envFile ?? 'none found'})`,
|
|
77
|
+
result: envFile ? 'pass' : 'warn',
|
|
78
|
+
message: !envFile
|
|
79
|
+
? `No env file found. Looked for: ${ENV_CANDIDATES.join(', ')}. Create one with your OPENAI_API_KEY.`
|
|
80
|
+
: undefined,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// 6. OPENAI_API_KEY set
|
|
84
|
+
const envVars = envFile ? loadEnvFile(envFile) : {};
|
|
85
|
+
const hasOpenAI = !!process.env.OPENAI_API_KEY || !!envVars['OPENAI_API_KEY'];
|
|
86
|
+
checks.push({
|
|
87
|
+
name: 'OPENAI_API_KEY',
|
|
88
|
+
result: hasOpenAI ? 'pass' : 'warn',
|
|
89
|
+
message: !hasOpenAI
|
|
90
|
+
? `OPENAI_API_KEY not set — Codex review steps will be skipped`
|
|
91
|
+
: undefined,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// 7. claude CLI available
|
|
95
|
+
const claudeVersion = runSafe('claude', ['--version']);
|
|
96
|
+
checks.push({
|
|
97
|
+
name: 'claude CLI',
|
|
98
|
+
result: claudeVersion ? 'pass' : 'fail',
|
|
99
|
+
message: !claudeVersion
|
|
100
|
+
? 'claude CLI not found — required for autofix. Install Claude Code: https://claude.ai/claude-code'
|
|
101
|
+
: undefined,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// 8. git user config
|
|
105
|
+
const gitName = runSafe('git', ['config', 'user.name']);
|
|
106
|
+
const gitEmail = runSafe('git', ['config', 'user.email']);
|
|
107
|
+
const gitConfigOk = !!(gitName?.trim()) && !!(gitEmail?.trim());
|
|
108
|
+
checks.push({
|
|
109
|
+
name: 'git user config',
|
|
110
|
+
result: gitConfigOk ? 'pass' : 'warn',
|
|
111
|
+
message: !gitConfigOk
|
|
112
|
+
? 'git user.name / user.email not set — commits will fail.'
|
|
113
|
+
: undefined,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// 9. superpowers plugin
|
|
117
|
+
const home = process.env.HOME ?? '';
|
|
118
|
+
const superpowersPaths = [
|
|
119
|
+
path.join(home, '.claude', 'plugins', 'cache', 'claude-plugins-official', 'superpowers'),
|
|
120
|
+
path.join(home, '.claude', 'plugins', 'cache', 'superpowers-marketplace', 'superpowers'),
|
|
121
|
+
path.join(home, '.claude', 'plugins', 'superpowers'),
|
|
122
|
+
];
|
|
123
|
+
const superpowersOk = superpowersPaths.some(p => fs.existsSync(p));
|
|
124
|
+
checks.push({
|
|
125
|
+
name: 'superpowers plugin',
|
|
126
|
+
result: superpowersOk ? 'pass' : 'warn',
|
|
127
|
+
message: !superpowersOk
|
|
128
|
+
? 'superpowers plugin not detected — install: /plugin install superpowers@claude-plugins-official'
|
|
129
|
+
: undefined,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Print results
|
|
133
|
+
console.log('\n\x1b[1m[preflight] Autopilot prerequisite check\x1b[0m\n');
|
|
134
|
+
let failures = 0;
|
|
135
|
+
let warnings = 0;
|
|
136
|
+
for (const check of checks) {
|
|
137
|
+
const icon = check.result === 'pass' ? PASS : check.result === 'warn' ? WARN : FAIL;
|
|
138
|
+
console.log(` ${icon} ${check.name}`);
|
|
139
|
+
if (check.message) {
|
|
140
|
+
console.log(` \x1b[2m${check.message}\x1b[0m`);
|
|
141
|
+
}
|
|
142
|
+
if (check.result === 'fail') failures++;
|
|
143
|
+
if (check.result === 'warn') warnings++;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log('');
|
|
147
|
+
if (failures > 0) {
|
|
148
|
+
console.log(`\x1b[31m[preflight] ${failures} check(s) failed — fix before running /autopilot\x1b[0m\n`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
} else if (warnings > 0) {
|
|
151
|
+
console.log(`\x1b[33m[preflight] ${warnings} warning(s) — pipeline will run but some steps may be degraded\x1b[0m\n`);
|
|
152
|
+
} else {
|
|
153
|
+
console.log(`\x1b[32m[preflight] All checks passed\x1b[0m\n`);
|
|
154
|
+
}
|
package/src/cli/run.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import { loadConfig } from '../core/config/loader.ts';
|
|
5
|
+
import { resolvePreset } from '../core/config/preset-resolver.ts';
|
|
6
|
+
import { mergeConfigs } from '../core/config/preset-resolver.ts';
|
|
7
|
+
import { loadAdapter } from '../adapters/loader.ts';
|
|
8
|
+
import { runAutopilot } from '../core/pipeline/run.ts';
|
|
9
|
+
import { resolveGitTouchedFiles } from '../core/git/touched-files.ts';
|
|
10
|
+
import type { RunInput } from '../core/pipeline/run.ts';
|
|
11
|
+
import type { ReviewEngine } from '../adapters/review-engine/types.ts';
|
|
12
|
+
import type { AutopilotConfig } from '../core/config/types.ts';
|
|
13
|
+
|
|
14
|
+
const C = {
|
|
15
|
+
reset: '\x1b[0m',
|
|
16
|
+
bold: '\x1b[1m',
|
|
17
|
+
dim: '\x1b[2m',
|
|
18
|
+
green: '\x1b[32m',
|
|
19
|
+
yellow: '\x1b[33m',
|
|
20
|
+
red: '\x1b[31m',
|
|
21
|
+
cyan: '\x1b[36m',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function fmt(color: keyof typeof C, text: string): string {
|
|
25
|
+
return `${C[color]}${text}${C.reset}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RunCommandOptions {
|
|
29
|
+
cwd?: string;
|
|
30
|
+
configPath?: string;
|
|
31
|
+
base?: string; // git base ref (default HEAD~1)
|
|
32
|
+
files?: string[]; // explicit file list (skips git detection)
|
|
33
|
+
dryRun?: boolean; // skip review, print what would run
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns an exit code (0 = pass/warn, 1 = fail/error).
|
|
38
|
+
* Never calls process.exit directly — caller decides when to exit.
|
|
39
|
+
*/
|
|
40
|
+
export async function runCommand(options: RunCommandOptions = {}): Promise<number> {
|
|
41
|
+
const cwd = options.cwd ?? process.cwd();
|
|
42
|
+
const configPath = options.configPath ?? path.join(cwd, 'autopilot.config.yaml');
|
|
43
|
+
|
|
44
|
+
if (!fs.existsSync(configPath)) {
|
|
45
|
+
console.error(fmt('red', `[run] autopilot.config.yaml not found at ${configPath}`));
|
|
46
|
+
console.error(fmt('dim', ' Run: npx autopilot init'));
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Load + merge config
|
|
51
|
+
let config: AutopilotConfig;
|
|
52
|
+
try {
|
|
53
|
+
const userConfig = await loadConfig(configPath);
|
|
54
|
+
if (userConfig.preset) {
|
|
55
|
+
const preset = await resolvePreset(userConfig.preset);
|
|
56
|
+
config = mergeConfigs(preset.config, userConfig);
|
|
57
|
+
} else {
|
|
58
|
+
config = userConfig;
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error(fmt('red', `[run] Config error: ${err instanceof Error ? err.message : String(err)}`));
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Resolve touched files
|
|
66
|
+
const touchedFiles = options.files ?? resolveGitTouchedFiles({ cwd, base: options.base });
|
|
67
|
+
if (touchedFiles.length === 0) {
|
|
68
|
+
console.log(fmt('yellow', '[run] No changed files detected — nothing to review.'));
|
|
69
|
+
console.log(fmt('dim', ' Pass --base <ref> to compare against a different branch/commit.'));
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
console.log(`\n${fmt('bold', '[autopilot run]')} ${fmt('dim', configPath)}`);
|
|
74
|
+
console.log(`${fmt('dim', ` ${touchedFiles.length} changed file(s):`)} ${touchedFiles.slice(0, 5).join(', ')}${touchedFiles.length > 5 ? ` … +${touchedFiles.length - 5} more` : ''}`);
|
|
75
|
+
|
|
76
|
+
if (options.dryRun) {
|
|
77
|
+
console.log(fmt('yellow', '\n[run] Dry run — skipping pipeline execution.\n'));
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Load review engine (optional — skip if no OPENAI_API_KEY or not configured)
|
|
82
|
+
let reviewEngine: ReviewEngine | undefined;
|
|
83
|
+
if (config.reviewEngine) {
|
|
84
|
+
const ref = typeof config.reviewEngine === 'string' ? config.reviewEngine : config.reviewEngine.adapter;
|
|
85
|
+
const hasKey = !!(process.env.OPENAI_API_KEY);
|
|
86
|
+
if (!hasKey && ref === 'codex') {
|
|
87
|
+
console.log(fmt('yellow', '\n [run] OPENAI_API_KEY not set — Codex review step will be skipped'));
|
|
88
|
+
} else {
|
|
89
|
+
try {
|
|
90
|
+
reviewEngine = await loadAdapter<ReviewEngine>({
|
|
91
|
+
point: 'review-engine',
|
|
92
|
+
ref,
|
|
93
|
+
options: typeof config.reviewEngine === 'string' ? undefined : config.reviewEngine.options,
|
|
94
|
+
});
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(fmt('yellow', ` [run] Could not load review engine (${ref}): ${err instanceof Error ? err.message : String(err)} — skipping`));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Execute pipeline
|
|
102
|
+
const input: RunInput = {
|
|
103
|
+
touchedFiles,
|
|
104
|
+
config,
|
|
105
|
+
reviewEngine,
|
|
106
|
+
cwd,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
console.log('');
|
|
110
|
+
const result = await runAutopilot(input);
|
|
111
|
+
|
|
112
|
+
// Print phase summaries
|
|
113
|
+
for (const phase of result.phases) {
|
|
114
|
+
const icon = phase.status === 'pass' ? fmt('green', '✓') :
|
|
115
|
+
phase.status === 'skip' ? fmt('dim', '–') :
|
|
116
|
+
phase.status === 'warn' ? fmt('yellow', '!') : fmt('red', '✗');
|
|
117
|
+
const phaseLabel = phase.phase.padEnd(14);
|
|
118
|
+
const findingCount = phase.findings.length;
|
|
119
|
+
const extra = findingCount > 0 ? fmt('dim', ` (${findingCount} finding${findingCount !== 1 ? 's' : ''})`) : '';
|
|
120
|
+
const dur = 'durationMs' in phase ? fmt('dim', ` ${phase.durationMs}ms`) : '';
|
|
121
|
+
console.log(` ${icon} ${phaseLabel}${extra}${dur}`);
|
|
122
|
+
|
|
123
|
+
// Print critical/warning findings inline
|
|
124
|
+
for (const f of phase.findings) {
|
|
125
|
+
if (f.severity === 'critical' || f.severity === 'warning') {
|
|
126
|
+
const sev = f.severity === 'critical' ? fmt('red', 'CRITICAL') : fmt('yellow', 'WARNING ');
|
|
127
|
+
console.log(` ${sev} ${f.file}${f.line ? `:${f.line}` : ''} — ${f.message}`);
|
|
128
|
+
if (f.suggestion) console.log(fmt('dim', ` ${f.suggestion}`));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Cost summary
|
|
134
|
+
if (result.totalCostUSD !== undefined) {
|
|
135
|
+
console.log(`\n ${fmt('dim', `cost: $${result.totalCostUSD.toFixed(4)}`)} ${fmt('dim', `${result.durationMs}ms total`)}`);
|
|
136
|
+
} else {
|
|
137
|
+
console.log(`\n ${fmt('dim', `${result.durationMs}ms total`)}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Final verdict
|
|
141
|
+
console.log('');
|
|
142
|
+
if (result.status === 'pass') {
|
|
143
|
+
console.log(fmt('green', '[run] ✓ All phases passed\n'));
|
|
144
|
+
return 0;
|
|
145
|
+
} else if (result.status === 'warn') {
|
|
146
|
+
console.log(fmt('yellow', '[run] ! Passed with warnings\n'));
|
|
147
|
+
return 0;
|
|
148
|
+
} else {
|
|
149
|
+
console.log(fmt('red', '[run] ✗ Pipeline failed — see findings above\n'));
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/cli/watch.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { loadConfig } from '../core/config/loader.ts';
|
|
4
|
+
import { resolvePreset, mergeConfigs } from '../core/config/preset-resolver.ts';
|
|
5
|
+
import { loadAdapter } from '../adapters/loader.ts';
|
|
6
|
+
import { runAutopilot } from '../core/pipeline/run.ts';
|
|
7
|
+
import type { ReviewEngine } from '../adapters/review-engine/types.ts';
|
|
8
|
+
import type { AutopilotConfig } from '../core/config/types.ts';
|
|
9
|
+
|
|
10
|
+
const C = {
|
|
11
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
12
|
+
green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', cyan: '\x1b[36m',
|
|
13
|
+
};
|
|
14
|
+
const fmt = (c: keyof typeof C, t: string) => `${C[c]}${t}${C.reset}`;
|
|
15
|
+
|
|
16
|
+
// Anchored to path segment boundaries — avoids matching "mynode_modules" or similar
|
|
17
|
+
export const IGNORED_PATTERNS: readonly RegExp[] = [
|
|
18
|
+
/(^|[/\\])node_modules([/\\]|$)/,
|
|
19
|
+
/(^|[/\\])\.git([/\\]|$)/,
|
|
20
|
+
/(^|[/\\])\.autopilot-cache([/\\]|$)/,
|
|
21
|
+
/\.(log|tmp|swp|swo|DS_Store)$/,
|
|
22
|
+
/~$/,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function isIgnored(p: string): boolean {
|
|
26
|
+
return IGNORED_PATTERNS.some(r => r.test(p));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pure debounce accumulator — returned functions are the testable core of watch logic.
|
|
31
|
+
* schedule(file) → adds file, starts/resets timer; when debounce fires, calls flush(batch).
|
|
32
|
+
*/
|
|
33
|
+
export function makeDebouncer(
|
|
34
|
+
flushFn: (batch: string[]) => void,
|
|
35
|
+
debounceMs: number,
|
|
36
|
+
): { schedule: (file: string) => void; pending: () => string[] } {
|
|
37
|
+
const pending = new Set<string>();
|
|
38
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
39
|
+
return {
|
|
40
|
+
schedule(file: string) {
|
|
41
|
+
pending.add(file);
|
|
42
|
+
if (timer) clearTimeout(timer);
|
|
43
|
+
timer = setTimeout(() => {
|
|
44
|
+
const batch = [...pending];
|
|
45
|
+
pending.clear();
|
|
46
|
+
timer = null;
|
|
47
|
+
flushFn(batch);
|
|
48
|
+
}, debounceMs);
|
|
49
|
+
},
|
|
50
|
+
pending() { return [...pending]; },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface WatchOptions {
|
|
55
|
+
cwd?: string;
|
|
56
|
+
configPath?: string;
|
|
57
|
+
debounceMs?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function runWatch(options: WatchOptions = {}): Promise<void> {
|
|
61
|
+
const cwd = options.cwd ?? process.cwd();
|
|
62
|
+
const configPath = options.configPath ?? path.join(cwd, 'autopilot.config.yaml');
|
|
63
|
+
const debounceMs = options.debounceMs ?? 300;
|
|
64
|
+
|
|
65
|
+
if (!fs.existsSync(configPath)) {
|
|
66
|
+
console.error(fmt('red', `[watch] autopilot.config.yaml not found — run: npx autopilot init`));
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let config: AutopilotConfig;
|
|
71
|
+
try {
|
|
72
|
+
const userConfig = await loadConfig(configPath);
|
|
73
|
+
config = userConfig.preset
|
|
74
|
+
? mergeConfigs((await resolvePreset(userConfig.preset)).config, userConfig)
|
|
75
|
+
: userConfig;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error(fmt('red', `[watch] Config error: ${err instanceof Error ? err.message : String(err)}`));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let reviewEngine: ReviewEngine | undefined;
|
|
82
|
+
if (config.reviewEngine) {
|
|
83
|
+
const ref = typeof config.reviewEngine === 'string' ? config.reviewEngine : config.reviewEngine.adapter;
|
|
84
|
+
if (process.env.OPENAI_API_KEY) {
|
|
85
|
+
try {
|
|
86
|
+
reviewEngine = await loadAdapter<ReviewEngine>({
|
|
87
|
+
point: 'review-engine', ref,
|
|
88
|
+
options: typeof config.reviewEngine === 'string' ? undefined : config.reviewEngine.options,
|
|
89
|
+
});
|
|
90
|
+
} catch { /* skip */ }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log(`\n${fmt('bold', '[autopilot watch]')} ${fmt('dim', cwd)}`);
|
|
95
|
+
console.log(fmt('dim', ` debounce: ${debounceMs}ms | Ctrl+C to exit\n`));
|
|
96
|
+
|
|
97
|
+
let running = false;
|
|
98
|
+
const nextPending = new Set<string>();
|
|
99
|
+
|
|
100
|
+
const runBatch = async (batch: string[]) => {
|
|
101
|
+
if (running) {
|
|
102
|
+
// Queue these files for the next run after the current one completes
|
|
103
|
+
for (const f of batch) nextPending.add(f);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
running = true;
|
|
107
|
+
|
|
108
|
+
const rel = batch.map(f => path.isAbsolute(f) ? path.relative(cwd, f) : f);
|
|
109
|
+
const ts = new Date().toLocaleTimeString();
|
|
110
|
+
console.log(`\n${fmt('cyan', `─── ${ts} ──────────────────────────────────`)}`);
|
|
111
|
+
console.log(fmt('dim', ` changed: ${rel.slice(0, 4).join(', ')}${rel.length > 4 ? ` +${rel.length - 4} more` : ''}`));
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const result = await runAutopilot({ touchedFiles: rel, config, reviewEngine, cwd });
|
|
115
|
+
|
|
116
|
+
for (const phase of result.phases) {
|
|
117
|
+
const icon = phase.status === 'pass' ? fmt('green', '✓')
|
|
118
|
+
: phase.status === 'skip' ? fmt('dim', '–')
|
|
119
|
+
: phase.status === 'warn' ? fmt('yellow', '!')
|
|
120
|
+
: fmt('red', '✗');
|
|
121
|
+
console.log(` ${icon} ${phase.phase.padEnd(14)}${fmt('dim', ` ${(phase as {durationMs?: number}).durationMs ?? 0}ms`)}`);
|
|
122
|
+
for (const f of phase.findings) {
|
|
123
|
+
if (f.severity === 'critical' || f.severity === 'warning') {
|
|
124
|
+
const sev = f.severity === 'critical' ? fmt('red', 'CRITICAL') : fmt('yellow', 'WARNING ');
|
|
125
|
+
console.log(` ${sev} ${f.file}${f.line ? `:${f.line}` : ''} — ${f.message}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const verdict = result.status === 'pass' ? fmt('green', '✓ pass')
|
|
131
|
+
: result.status === 'warn' ? fmt('yellow', '! warn')
|
|
132
|
+
: fmt('red', '✗ fail');
|
|
133
|
+
const cost = result.totalCostUSD !== undefined ? fmt('dim', ` $${result.totalCostUSD.toFixed(4)}`) : '';
|
|
134
|
+
console.log(`\n ${verdict}${cost} ${fmt('dim', `${result.durationMs}ms`)}`);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error(fmt('red', ` error: ${err instanceof Error ? err.message : String(err)}`));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
running = false;
|
|
140
|
+
// Flush anything that accumulated while we were running
|
|
141
|
+
if (nextPending.size > 0) {
|
|
142
|
+
const queued = [...nextPending];
|
|
143
|
+
nextPending.clear();
|
|
144
|
+
runBatch(queued);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const debouncer = makeDebouncer(batch => { runBatch(batch); }, debounceMs);
|
|
149
|
+
|
|
150
|
+
const onEvent = (_event: string, filename: string | null) => {
|
|
151
|
+
if (!filename) return;
|
|
152
|
+
const full = path.isAbsolute(filename) ? filename : path.join(cwd, filename);
|
|
153
|
+
if (isIgnored(full)) return;
|
|
154
|
+
debouncer.schedule(full);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// fs.watch recursive is supported on macOS/Linux kernel ≥5.1; Windows uses ReadDirectoryChangesW.
|
|
158
|
+
// Alpha limitation: not battle-tested in Docker/container contexts — upgrade to chokidar for beta.
|
|
159
|
+
const watcher = fs.watch(cwd, { recursive: true }, onEvent);
|
|
160
|
+
|
|
161
|
+
process.on('SIGINT', () => {
|
|
162
|
+
console.log(fmt('dim', '\n[watch] exiting'));
|
|
163
|
+
watcher.close();
|
|
164
|
+
process.exit(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Keep the process alive
|
|
168
|
+
await new Promise<void>(() => { /* never resolves — watch loop runs until SIGINT */ });
|
|
169
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ReviewEngine, ReviewInput, ReviewOutput } from '../../adapters/review-engine/types.ts';
|
|
2
|
+
import type { Capabilities } from '../../adapters/base.ts';
|
|
3
|
+
import { ReviewCache, type ReviewCacheOptions } from './review-cache.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wraps any ReviewEngine with file-based response caching.
|
|
7
|
+
* Cache key = SHA-256(adapterName + model + content).
|
|
8
|
+
*/
|
|
9
|
+
export function withCache(engine: ReviewEngine, options: ReviewCacheOptions = {}): ReviewEngine {
|
|
10
|
+
const cache = new ReviewCache(options);
|
|
11
|
+
const model = (engine as { model?: string }).model ?? engine.name;
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
name: engine.name,
|
|
15
|
+
apiVersion: engine.apiVersion,
|
|
16
|
+
getCapabilities(): Capabilities {
|
|
17
|
+
return engine.getCapabilities();
|
|
18
|
+
},
|
|
19
|
+
estimateTokens(content: string): number {
|
|
20
|
+
return engine.estimateTokens(content);
|
|
21
|
+
},
|
|
22
|
+
async review(input: ReviewInput): Promise<ReviewOutput> {
|
|
23
|
+
const keyPayload = `${input.content}\x00${input.kind}\x00${input.context?.stack ?? ''}`;
|
|
24
|
+
const key = ReviewCache.keyFor(engine.name, model, keyPayload);
|
|
25
|
+
const cached = await cache.get(key);
|
|
26
|
+
if (cached) return { ...cached, usage: cached.usage ? { ...cached.usage, costUSD: 0 } : undefined };
|
|
27
|
+
const output = await engine.review(input);
|
|
28
|
+
await cache.set(key, output);
|
|
29
|
+
return output;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import type { ReviewOutput } from '../../adapters/review-engine/types.ts';
|
|
6
|
+
|
|
7
|
+
export interface CacheEntry {
|
|
8
|
+
key: string;
|
|
9
|
+
output: ReviewOutput;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
expiresAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ReviewCacheOptions {
|
|
15
|
+
cacheDir?: string;
|
|
16
|
+
ttlMs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
20
|
+
// Prefer env override, then ~/.autopilot-cache to survive across cwd changes and container restarts
|
|
21
|
+
const DEFAULT_CACHE_DIR = process.env.AUTOPILOT_CACHE_DIR
|
|
22
|
+
? path.join(process.env.AUTOPILOT_CACHE_DIR, 'reviews')
|
|
23
|
+
: path.join(os.homedir(), '.autopilot-cache', 'reviews');
|
|
24
|
+
|
|
25
|
+
export class ReviewCache {
|
|
26
|
+
private readonly cacheDir: string;
|
|
27
|
+
private readonly ttlMs: number;
|
|
28
|
+
|
|
29
|
+
constructor(options: ReviewCacheOptions = {}) {
|
|
30
|
+
this.cacheDir = options.cacheDir ?? DEFAULT_CACHE_DIR;
|
|
31
|
+
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static keyFor(adapterName: string, model: string, content: string): string {
|
|
35
|
+
return createHash('sha256').update(`${adapterName}:${model}:${content}`).digest('hex');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async get(key: string): Promise<ReviewOutput | undefined> {
|
|
39
|
+
const filePath = this.entryPath(key);
|
|
40
|
+
try {
|
|
41
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
42
|
+
const entry: CacheEntry = JSON.parse(raw);
|
|
43
|
+
if (new Date(entry.expiresAt) < new Date()) {
|
|
44
|
+
await fs.unlink(filePath).catch(() => undefined);
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
return entry.output;
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async set(key: string, output: ReviewOutput): Promise<void> {
|
|
54
|
+
await fs.mkdir(this.cacheDir, { recursive: true });
|
|
55
|
+
const entry: CacheEntry = {
|
|
56
|
+
key,
|
|
57
|
+
output,
|
|
58
|
+
createdAt: new Date().toISOString(),
|
|
59
|
+
expiresAt: new Date(Date.now() + this.ttlMs).toISOString(),
|
|
60
|
+
};
|
|
61
|
+
const filePath = this.entryPath(key);
|
|
62
|
+
const tmp = `${filePath}.tmp`;
|
|
63
|
+
await fs.writeFile(tmp, JSON.stringify(entry), 'utf8');
|
|
64
|
+
await fs.rename(tmp, filePath);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private entryPath(key: string): string {
|
|
68
|
+
return path.join(this.cacheDir, `${key}.json`);
|
|
69
|
+
}
|
|
70
|
+
}
|