@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +58 -0
  4. package/bin/autopilot.js +15 -0
  5. package/package.json +41 -0
  6. package/presets/go/autopilot.config.yaml +20 -0
  7. package/presets/go/rules/go-sql-injection.ts +65 -0
  8. package/presets/go/stack.md +20 -0
  9. package/presets/nextjs-supabase/autopilot.config.yaml +29 -0
  10. package/presets/nextjs-supabase/rules/supabase-rls-bypass.ts +39 -0
  11. package/presets/nextjs-supabase/stack.md +20 -0
  12. package/presets/python-fastapi/autopilot.config.yaml +20 -0
  13. package/presets/python-fastapi/rules/fastapi-missing-auth.ts +50 -0
  14. package/presets/python-fastapi/stack.md +20 -0
  15. package/presets/rails-postgres/autopilot.config.yaml +21 -0
  16. package/presets/rails-postgres/rules/rails-sql-injection.ts +42 -0
  17. package/presets/rails-postgres/stack.md +20 -0
  18. package/presets/t3/autopilot.config.yaml +22 -0
  19. package/presets/t3/rules/t3-server-only.ts +35 -0
  20. package/presets/t3/stack.md +20 -0
  21. package/scripts/test-runner.mjs +16 -0
  22. package/src/adapters/base.ts +19 -0
  23. package/src/adapters/loader.ts +101 -0
  24. package/src/adapters/migration-runner/supabase.ts +56 -0
  25. package/src/adapters/migration-runner/types.ts +36 -0
  26. package/src/adapters/review-bot-parser/cursor.ts +13 -0
  27. package/src/adapters/review-bot-parser/declarative-base.ts +64 -0
  28. package/src/adapters/review-bot-parser/types.ts +9 -0
  29. package/src/adapters/review-engine/codex.ts +108 -0
  30. package/src/adapters/review-engine/types.ts +19 -0
  31. package/src/adapters/vcs-host/github.ts +77 -0
  32. package/src/adapters/vcs-host/types.ts +44 -0
  33. package/src/cli/index.ts +110 -0
  34. package/src/cli/init.ts +88 -0
  35. package/src/cli/preflight.ts +154 -0
  36. package/src/cli/run.ts +152 -0
  37. package/src/cli/watch.ts +169 -0
  38. package/src/core/.gitkeep +0 -0
  39. package/src/core/cache/cached-engine.ts +32 -0
  40. package/src/core/cache/review-cache.ts +70 -0
  41. package/src/core/chunking/index.ts +82 -0
  42. package/src/core/config/loader.ts +41 -0
  43. package/src/core/config/preset-resolver.ts +46 -0
  44. package/src/core/config/schema.ts +63 -0
  45. package/src/core/config/types.ts +42 -0
  46. package/src/core/errors.ts +37 -0
  47. package/src/core/findings/dedup.ts +14 -0
  48. package/src/core/findings/types.ts +39 -0
  49. package/src/core/git/touched-files.ts +51 -0
  50. package/src/core/index.ts +1 -0
  51. package/src/core/logging/ndjson-writer.ts +37 -0
  52. package/src/core/logging/redaction.ts +19 -0
  53. package/src/core/phases/static-rules.ts +80 -0
  54. package/src/core/phases/tests.ts +51 -0
  55. package/src/core/pipeline/review-phase.ts +87 -0
  56. package/src/core/pipeline/run.ts +80 -0
  57. package/src/core/runtime/idempotency.ts +6 -0
  58. package/src/core/runtime/lock.ts +29 -0
  59. package/src/core/runtime/state.ts +97 -0
  60. package/src/core/shell.ts +48 -0
@@ -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
+ }
@@ -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
+ }