@delegance/claude-autopilot 2.5.0 → 5.0.0-alpha.2

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 (129) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +169 -106
  3. package/bin/_launcher.js +77 -0
  4. package/bin/claude-autopilot.js +3 -0
  5. package/bin/guardrail.js +3 -0
  6. package/package.json +23 -9
  7. package/presets/generic/guardrail.config.yaml +35 -0
  8. package/presets/generic/stack.md +40 -0
  9. package/presets/nextjs-supabase/{autopilot.config.yaml → guardrail.config.yaml} +7 -0
  10. package/scripts/autoregress.ts +27 -11
  11. package/skills/autopilot/SKILL.md +170 -0
  12. package/skills/claude-autopilot.md +80 -0
  13. package/skills/guardrail.md +39 -0
  14. package/skills/migrate/SKILL.md +83 -0
  15. package/src/adapters/council/claude.ts +41 -0
  16. package/src/adapters/council/openai.ts +40 -0
  17. package/src/adapters/council/types.ts +7 -0
  18. package/src/adapters/loader.ts +7 -7
  19. package/src/adapters/review-engine/auto.ts +2 -2
  20. package/src/adapters/review-engine/claude.ts +9 -11
  21. package/src/adapters/review-engine/codex.ts +9 -11
  22. package/src/adapters/review-engine/gemini.ts +9 -11
  23. package/src/adapters/review-engine/openai-compatible.ts +10 -12
  24. package/src/adapters/review-engine/parse-output.ts +32 -6
  25. package/src/adapters/review-engine/prompt-builder.ts +19 -0
  26. package/src/adapters/review-engine/types.ts +1 -1
  27. package/src/adapters/vcs-host/commit-status.ts +39 -0
  28. package/src/adapters/vcs-host/github.ts +2 -2
  29. package/src/cli/baseline.ts +125 -0
  30. package/src/cli/ci.ts +11 -8
  31. package/src/cli/costs.ts +2 -2
  32. package/src/cli/council.ts +96 -0
  33. package/src/cli/detector.ts +21 -5
  34. package/src/cli/explain.ts +197 -0
  35. package/src/cli/fix.ts +173 -111
  36. package/src/cli/hook.ts +72 -27
  37. package/src/cli/ignore-helper.ts +116 -0
  38. package/src/cli/index.ts +355 -31
  39. package/src/cli/init.ts +12 -12
  40. package/src/cli/lsp.ts +200 -0
  41. package/src/cli/mcp.ts +206 -0
  42. package/src/cli/pr-comment.ts +5 -5
  43. package/src/cli/pr-desc.ts +168 -0
  44. package/src/cli/pr-review-comments.ts +3 -3
  45. package/src/cli/pr.ts +76 -0
  46. package/src/cli/preflight.ts +109 -32
  47. package/src/cli/report.ts +186 -0
  48. package/src/cli/run.ts +140 -36
  49. package/src/cli/scan.ts +233 -0
  50. package/src/cli/setup.ts +121 -15
  51. package/src/cli/test-gen.ts +125 -0
  52. package/src/cli/triage.ts +137 -0
  53. package/src/cli/watch.ts +52 -31
  54. package/src/cli/worker.ts +109 -0
  55. package/src/core/cache/review-cache.ts +2 -2
  56. package/src/core/chunking/index.ts +2 -2
  57. package/src/core/config/loader.ts +10 -10
  58. package/src/core/config/preset-resolver.ts +6 -6
  59. package/src/core/config/schema.ts +103 -2
  60. package/src/core/config/types.ts +57 -2
  61. package/src/core/council/config.ts +71 -0
  62. package/src/core/council/context.ts +17 -0
  63. package/src/core/council/runner.ts +83 -0
  64. package/src/core/council/types.ts +45 -0
  65. package/src/core/detect/llm-key.ts +89 -0
  66. package/src/core/detect/workspaces.ts +103 -0
  67. package/src/core/errors.ts +4 -4
  68. package/src/core/fix/generator.ts +149 -0
  69. package/src/core/ignore/index.ts +4 -4
  70. package/src/core/mcp/concurrency.ts +16 -0
  71. package/src/core/mcp/handlers/fix-finding.ts +126 -0
  72. package/src/core/mcp/handlers/get-capabilities.ts +62 -0
  73. package/src/core/mcp/handlers/get-findings.ts +36 -0
  74. package/src/core/mcp/handlers/review-diff.ts +65 -0
  75. package/src/core/mcp/handlers/scan-files.ts +65 -0
  76. package/src/core/mcp/handlers/validate-fix.ts +41 -0
  77. package/src/core/mcp/run-store.ts +85 -0
  78. package/src/core/mcp/workspace.ts +35 -0
  79. package/src/core/persist/baseline.ts +112 -0
  80. package/src/core/persist/cost-log.ts +1 -1
  81. package/src/core/persist/findings-cache.ts +1 -1
  82. package/src/core/persist/triage.ts +112 -0
  83. package/src/core/phases/static-rules.ts +18 -5
  84. package/src/core/pipeline/review-phase.ts +65 -26
  85. package/src/core/pipeline/run.ts +42 -10
  86. package/src/core/runtime/lock.ts +2 -2
  87. package/src/core/runtime/state.ts +2 -2
  88. package/src/core/schema-alignment/detector.ts +59 -0
  89. package/src/core/schema-alignment/extractor/index.ts +24 -0
  90. package/src/core/schema-alignment/extractor/prisma.ts +21 -0
  91. package/src/core/schema-alignment/extractor/sql.ts +99 -0
  92. package/src/core/schema-alignment/llm-check.ts +91 -0
  93. package/src/core/schema-alignment/scanner.ts +107 -0
  94. package/src/core/schema-alignment/types.ts +43 -0
  95. package/src/core/shell.ts +3 -3
  96. package/src/core/static-rules/registry.ts +17 -8
  97. package/src/core/static-rules/rules/brand-tokens.ts +145 -0
  98. package/src/core/static-rules/rules/hardcoded-secrets.ts +27 -1
  99. package/src/core/static-rules/rules/insecure-redirect.ts +67 -0
  100. package/src/core/static-rules/rules/missing-auth.ts +70 -0
  101. package/src/core/static-rules/rules/schema-alignment.ts +132 -0
  102. package/src/core/static-rules/rules/sql-injection.ts +71 -0
  103. package/src/core/static-rules/rules/ssrf.ts +63 -0
  104. package/src/core/static-rules/tailwind-extractor.ts +38 -0
  105. package/src/core/test-gen/coverage-analyzer.ts +93 -0
  106. package/src/core/test-gen/framework-detector.ts +21 -0
  107. package/src/core/test-gen/test-writer.ts +33 -0
  108. package/src/core/ui/design-context-loader.ts +87 -0
  109. package/src/core/worker/client.ts +46 -0
  110. package/src/core/worker/lockfile.ts +38 -0
  111. package/src/core/worker/server.ts +81 -0
  112. package/src/formatters/junit.ts +52 -0
  113. package/src/formatters/sarif.ts +2 -2
  114. package/src/index.ts +1 -2
  115. package/tests/snapshots/baselines/src-formatters-sarif.json +4 -4
  116. package/tests/snapshots/index.json +3 -3
  117. package/tests/snapshots/src-formatters-sarif.snap.ts +1 -1
  118. package/tests/snapshots/src-snapshots-impact-selector.snap.ts +3 -3
  119. package/tests/snapshots/src-snapshots-import-scanner.snap.ts +3 -3
  120. package/tests/snapshots/src-snapshots-serializer.snap.ts +2 -2
  121. package/bin/autopilot.js +0 -20
  122. package/skills/autopilot.md +0 -157
  123. /package/presets/go/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
  124. /package/presets/python-fastapi/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
  125. /package/presets/rails-postgres/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
  126. /package/presets/t3/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
  127. /package/{src → scripts}/snapshots/impact-selector.ts +0 -0
  128. /package/{src → scripts}/snapshots/import-scanner.ts +0 -0
  129. /package/{src → scripts}/snapshots/serializer.ts +0 -0
package/src/cli/setup.ts CHANGED
@@ -5,9 +5,13 @@ import { fileURLToPath } from 'node:url';
5
5
  import { detectProject } from './detector.ts';
6
6
  import { runHook } from './hook.ts';
7
7
  import { runDoctor } from './preflight.ts';
8
+ import { detectLLMKey, LLM_KEY_NAMES } from '../core/detect/llm-key.ts';
8
9
 
9
10
  const PASS = '\x1b[32m✓\x1b[0m';
10
11
  const WARN = '\x1b[33m!\x1b[0m';
12
+ const DIM = (t: string) => `\x1b[2m${t}\x1b[0m`;
13
+ const BOLD = (t: string) => `\x1b[1m${t}\x1b[0m`;
14
+ const CYAN = (t: string) => `\x1b[36m${t}\x1b[0m`;
11
15
 
12
16
  const PRESET_LABELS: Record<string, string> = {
13
17
  'nextjs-supabase': 'Next.js + Supabase',
@@ -15,19 +19,69 @@ const PRESET_LABELS: Record<string, string> = {
15
19
  'rails-postgres': 'Ruby on Rails + PostgreSQL',
16
20
  'python-fastapi': 'Python FastAPI',
17
21
  'go': 'Go + PostgreSQL',
22
+ 'generic': 'Generic (no stack-specific assumptions)',
23
+ };
24
+
25
+ export type ProfileName = 'security-strict' | 'team' | 'solo';
26
+
27
+ const PROFILES: Record<ProfileName, { label: string; overlay: string }> = {
28
+ 'security-strict': {
29
+ label: 'Security Strict',
30
+ overlay: [
31
+ 'staticRules:',
32
+ ' - hardcoded-secrets',
33
+ ' - npm-audit',
34
+ ' - package-lock-sync',
35
+ ' - sql-injection',
36
+ ' - missing-auth',
37
+ ' - ssrf',
38
+ ' - insecure-redirect',
39
+ 'policy:',
40
+ ' failOn: warning',
41
+ ' newOnly: false',
42
+ ].join('\n'),
43
+ },
44
+ 'team': {
45
+ label: 'Team',
46
+ overlay: [
47
+ 'staticRules:',
48
+ ' - hardcoded-secrets',
49
+ ' - npm-audit',
50
+ ' - package-lock-sync',
51
+ ' - sql-injection',
52
+ ' - missing-auth',
53
+ ' - ssrf',
54
+ ' - insecure-redirect',
55
+ 'policy:',
56
+ ' failOn: critical',
57
+ ' newOnly: false',
58
+ ].join('\n'),
59
+ },
60
+ 'solo': {
61
+ label: 'Solo Dev',
62
+ overlay: [
63
+ 'staticRules:',
64
+ ' - hardcoded-secrets',
65
+ ' - npm-audit',
66
+ 'policy:',
67
+ ' failOn: critical',
68
+ ' newOnly: false',
69
+ ].join('\n'),
70
+ },
18
71
  };
19
72
 
20
73
  export interface SetupOptions {
21
74
  cwd?: string;
22
75
  force?: boolean;
23
76
  skipHook?: boolean;
77
+ profile?: ProfileName;
24
78
  }
25
79
 
26
80
  function presetSearchPaths(name: string, cwd: string): string[] {
27
81
  const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
28
82
  return [
29
- path.join(pkgRoot, 'presets', name, 'autopilot.config.yaml'),
30
- path.join(cwd, 'node_modules', '@delegance', 'claude-autopilot', 'presets', name, 'autopilot.config.yaml'),
83
+ path.join(pkgRoot, 'presets', name, 'guardrail.config.yaml'),
84
+ path.join(cwd, 'node_modules', '@delegance', 'claude-autopilot', 'presets', name, 'guardrail.config.yaml'),
31
85
  ];
32
86
  }
33
87
 
@@ -40,24 +94,35 @@ function findPresetConfig(name: string, cwd: string): string | null {
40
94
 
41
95
  export async function runSetup(options: SetupOptions = {}): Promise<void> {
42
96
  const cwd = options.cwd ?? process.cwd();
43
- const dest = path.join(cwd, 'autopilot.config.yaml');
97
+ const dest = path.join(cwd, 'guardrail.config.yaml');
44
98
 
45
99
  if (fs.existsSync(dest) && !options.force) {
46
- throw new Error('autopilot.config.yaml already exists — use --force to overwrite');
100
+ throw new Error('guardrail.config.yaml already exists — use --force to overwrite');
47
101
  }
48
102
 
49
- console.log('\n[setup] Detecting project type...');
103
+ console.log(`\n${BOLD('[guardrail setup]')} ${DIM(cwd)}\n`);
104
+ console.log(`${BOLD('Detecting project…')}\n`);
50
105
 
51
106
  const detection = detectProject(cwd);
52
107
  const label = PRESET_LABELS[detection.preset] ?? detection.preset;
53
108
 
54
109
  if (detection.confidence === 'high') {
55
- console.log(` ${PASS} ${label} (${detection.evidence})`);
110
+ console.log(` ${PASS} Stack: ${label}`);
111
+ console.log(` ${PASS} Evidence: ${DIM(detection.evidence)}`);
112
+ } else {
113
+ console.log(` ${WARN} Stack: ${label} ${DIM('(low confidence — fallback preset)')}`);
114
+ console.log(` ${DIM(detection.evidence)}`);
115
+ console.log(` ${DIM('Edit guardrail.config.yaml to switch presets if needed')}`);
116
+ }
117
+ console.log(` ${PASS} Test command: ${DIM(detection.testCommand)}`);
118
+
119
+ const { hasKey, preferred } = detectLLMKey();
120
+ if (hasKey) {
121
+ console.log(` ${PASS} LLM API key: detected (${preferred})`);
56
122
  } else {
57
- console.log(` ${WARN} ${label} no strong signals found, defaulted to ${detection.preset}`);
58
- console.log(` \x1b[2mEdit autopilot.config.yaml to switch presets if needed\x1b[0m`);
123
+ console.log(` ${WARN} LLM API key: not found`);
124
+ console.log(` ${DIM(`Set one of: ${LLM_KEY_NAMES.join(', ')}`)}`);
59
125
  }
60
- console.log(` ${PASS} Test command: ${detection.testCommand}`);
61
126
 
62
127
  const presetConfigPath = findPresetConfig(detection.preset, cwd);
63
128
  if (!presetConfigPath) {
@@ -66,20 +131,61 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
66
131
 
67
132
  let presetContent = await fsAsync.readFile(presetConfigPath, 'utf8');
68
133
  presetContent = presetContent.trimEnd() + `\ntestCommand: "${detection.testCommand}"\n`;
134
+
135
+ // Apply profile overlay if specified
136
+ if (options.profile) {
137
+ const profile = PROFILES[options.profile];
138
+ if (profile) {
139
+ console.log(` ${PASS} Profile: ${profile.label}`);
140
+ // Profile overlay replaces staticRules + policy sections from preset
141
+ presetContent = presetContent
142
+ .replace(/^staticRules:.*?(?=^\w|\z)/ms, '')
143
+ .replace(/^policy:.*?(?=^\w|\z)/ms, '');
144
+ presetContent = presetContent.trimEnd() + `\n${profile.overlay}\n`;
145
+ }
146
+ }
147
+
69
148
  await fsAsync.writeFile(dest, presetContent, 'utf8');
70
- console.log(` ${PASS} Created autopilot.config.yaml`);
71
149
 
150
+ console.log(`\n${BOLD('Config written to guardrail.config.yaml:')}\n`);
151
+ for (const line of presetContent.trimEnd().split('\n')) {
152
+ console.log(` ${DIM(line)}`);
153
+ }
154
+
155
+ let hookInstalled = false;
72
156
  if (!options.skipHook) {
73
157
  const hookCode = await runHook('install', { cwd, silent: true });
74
- if (hookCode === 0) {
75
- console.log(` ${PASS} Installed pre-push git hook`);
158
+ hookInstalled = hookCode === 0;
159
+ if (hookInstalled) {
160
+ console.log(`\n ${PASS} Pre-push git hook installed`);
76
161
  } else {
77
- console.log(` ${WARN} Hook install failed (not fatal — run: npx autopilot hook install)`);
162
+ console.log(`\n ${WARN} Hook install failed (run: npx guardrail hook install)`);
78
163
  }
79
164
  }
80
165
 
81
- console.log('\n[setup] Checking prerequisites...');
166
+ console.log('\nChecking prerequisites');
82
167
  await runDoctor();
83
168
 
84
- console.log('\n[setup] Done. Run: npx autopilot run\n');
169
+ console.log(`\n${BOLD('Next steps:')}\n`);
170
+ if (!hasKey) {
171
+ console.log(` 1. ${CYAN('Set an LLM API key')} — guardrail needs one to review code:`);
172
+ console.log(` export ANTHROPIC_API_KEY=sk-ant-... # https://console.anthropic.com/`);
173
+ console.log(` export OPENAI_API_KEY=sk-... # https://platform.openai.com/api-keys`);
174
+ console.log(` export GROQ_API_KEY=gsk_... # https://console.groq.com/keys (free)\n`);
175
+ console.log(` 2. ${CYAN('Review your changes:')}`);
176
+ console.log(` npx guardrail run --base main\n`);
177
+ console.log(` 3. ${CYAN('Scan any path directly:')}`);
178
+ console.log(` npx guardrail scan src/auth/\n`);
179
+ } else {
180
+ console.log(` ${CYAN('Review git-changed files:')}`);
181
+ console.log(` npx guardrail run --base main\n`);
182
+ console.log(` ${CYAN('Scan any path (no git needed):')}`);
183
+ console.log(` npx guardrail scan src/auth/\n`);
184
+ console.log(` ${CYAN('Ask a targeted question:')}`);
185
+ console.log(` npx guardrail scan --ask "is there SQL injection here?" src/db/\n`);
186
+ if (!hookInstalled && !options.skipHook) {
187
+ console.log(` ${CYAN('Install pre-push hook (auto-runs before git push):')}`);
188
+ console.log(` npx guardrail hook install\n`);
189
+ }
190
+ }
85
191
  }
@@ -0,0 +1,125 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { loadConfig } from '../core/config/loader.ts';
5
+ import { loadAdapter } from '../adapters/loader.ts';
6
+ import type { ReviewEngine } from '../adapters/review-engine/types.ts';
7
+ import { findCoverageGaps } from '../core/test-gen/coverage-analyzer.ts';
8
+ import { detectTestFramework } from '../core/test-gen/framework-detector.ts';
9
+ import { writeGeneratedTest, buildGenerationPrompt } from '../core/test-gen/test-writer.ts';
10
+
11
+ const C = { reset: '\x1b[0m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', dim: '\x1b[2m', bold: '\x1b[1m', cyan: '\x1b[36m' };
12
+
13
+ export interface TestGenOptions {
14
+ cwd?: string;
15
+ configPath?: string;
16
+ targets?: string[];
17
+ base?: string;
18
+ dryRun?: boolean;
19
+ verify?: boolean;
20
+ }
21
+
22
+ export async function runTestGen(options: TestGenOptions = {}): Promise<number> {
23
+ const cwd = options.cwd ?? process.cwd();
24
+ const configPath = options.configPath ?? path.join(cwd, 'guardrail.config.yaml');
25
+
26
+ let config = { configVersion: 1 as const, testCommand: null as string | null };
27
+ if (fs.existsSync(configPath)) {
28
+ try {
29
+ const loaded = await loadConfig(configPath);
30
+ if (loaded) config = loaded as typeof config;
31
+ } catch {
32
+ // proceed with defaults if config fails to load
33
+ }
34
+ }
35
+
36
+ // Collect files to analyze
37
+ let files: string[];
38
+ if (options.targets && options.targets.length > 0) {
39
+ files = options.targets.map(t => path.isAbsolute(t) ? t : path.resolve(cwd, t));
40
+ } else {
41
+ // Fall back to git-changed files
42
+ try {
43
+ const base = options.base ?? 'HEAD~1';
44
+ const out = execFileSync('git', ['diff', '--name-only', base, 'HEAD'], { cwd, encoding: 'utf8' });
45
+ files = out.trim().split('\n').filter(Boolean).map(f => path.resolve(cwd, f));
46
+ } catch {
47
+ console.error(`${C.red}[test-gen] No targets specified and git diff failed. Pass a path: guardrail test-gen src/${C.reset}`);
48
+ return 1;
49
+ }
50
+ }
51
+
52
+ console.log(`${C.bold}[test-gen]${C.reset} Analyzing ${files.length} file(s)...`);
53
+ const gaps = findCoverageGaps(files);
54
+
55
+ if (gaps.length === 0) {
56
+ console.log(`${C.green}[test-gen] No coverage gaps found${C.reset}`);
57
+ return 0;
58
+ }
59
+
60
+ for (const gap of gaps) {
61
+ const rel = path.relative(cwd, gap.file);
62
+ const covered = gap.exports.length;
63
+ console.log(` ${C.cyan}${rel}${C.reset} ${covered} uncovered export(s): ${gap.exports.join(', ')}`);
64
+ }
65
+
66
+ if (options.dryRun) {
67
+ console.log(`\n${C.yellow}[test-gen] Dry run — not generating tests${C.reset}`);
68
+ return 0;
69
+ }
70
+
71
+ // Load review engine for generation
72
+ const engineRef = (config as { reviewEngine?: unknown }).reviewEngine ?? 'auto';
73
+ let engine: Awaited<ReturnType<typeof loadAdapter>>;
74
+ try {
75
+ engine = await loadAdapter({ point: 'review-engine', ref: engineRef as string });
76
+ } catch (err) {
77
+ console.error(`${C.red}[test-gen] Could not load review engine: ${err}${C.reset}`);
78
+ return 1;
79
+ }
80
+
81
+ const framework = detectTestFramework(cwd);
82
+ const written: string[] = [];
83
+
84
+ for (const gap of gaps) {
85
+ let sourceContent: string;
86
+ try { sourceContent = fs.readFileSync(gap.file, 'utf8'); } catch { continue; }
87
+
88
+ const prompt = buildGenerationPrompt(gap, sourceContent, framework);
89
+
90
+ process.stdout.write(` Generating ${path.relative(cwd, gap.testFile)}... `);
91
+ try {
92
+ const result = await (engine as unknown as ReviewEngine).review({ content: prompt, kind: 'spec', context: { cwd } });
93
+
94
+ // Extract code block if wrapped in markdown
95
+ let code = result.rawOutput.trim();
96
+ const fenceMatch = code.match(/```(?:typescript|ts|javascript|js)?\n([\s\S]*?)```/);
97
+ if (fenceMatch) code = fenceMatch[1]!.trim();
98
+
99
+ const testPath = writeGeneratedTest(gap, code);
100
+ written.push(testPath);
101
+ console.log(`${C.green}done${C.reset}`);
102
+
103
+ // Verify mode
104
+ if (options.verify && config.testCommand) {
105
+ try {
106
+ const [cmd, ...cmdArgs] = config.testCommand.split(/\s+/);
107
+ execFileSync(cmd!, cmdArgs, { cwd, stdio: 'ignore', timeout: 60_000 });
108
+ } catch {
109
+ fs.unlinkSync(testPath);
110
+ written.pop();
111
+ console.log(` ${C.yellow} ↳ tests failed — reverted${C.reset}`);
112
+ }
113
+ }
114
+ } catch (err) {
115
+ console.log(`${C.red}failed: ${err}${C.reset}`);
116
+ }
117
+ }
118
+
119
+ if (written.length > 0) {
120
+ console.log(`\n${C.green}[test-gen] Generated ${written.length} test file(s):${C.reset}`);
121
+ for (const f of written) console.log(` ${path.relative(cwd, f)}`);
122
+ }
123
+
124
+ return 0;
125
+ }
@@ -0,0 +1,137 @@
1
+ import { loadCachedFindings } from '../core/persist/findings-cache.ts';
2
+ import {
3
+ loadTriage, saveTriage, addTriageEntry, removeTriageEntry, clearExpiredEntries,
4
+ type TriageState,
5
+ } from '../core/persist/triage.ts';
6
+
7
+ const C = {
8
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
9
+ green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m',
10
+ };
11
+ const fmt = (c: keyof typeof C, t: string) => `${C[c]}${t}${C.reset}`;
12
+
13
+ export interface TriageCommandOptions {
14
+ cwd?: string;
15
+ }
16
+
17
+ function parseTriageArgs(rest: string[]): { reason?: string; expiresInDays?: number; positional: string[] } {
18
+ let reason: string | undefined;
19
+ let expiresInDays: number | undefined;
20
+ const positional: string[] = [];
21
+ for (let i = 0; i < rest.length; i++) {
22
+ if (rest[i] === '--reason' && rest[i + 1]) { reason = rest[++i]; }
23
+ else if (rest[i] === '--expires' && rest[i + 1]) { expiresInDays = parseInt(rest[++i]!, 10); }
24
+ else if (!rest[i]!.startsWith('--')) { positional.push(rest[i]!); }
25
+ }
26
+ return { reason, expiresInDays, positional };
27
+ }
28
+
29
+ export async function runTriage(
30
+ subcommand: string | undefined,
31
+ rest: string[],
32
+ options: TriageCommandOptions = {},
33
+ ): Promise<number> {
34
+ const cwd = options.cwd ?? process.cwd();
35
+
36
+ if (subcommand === 'list' || subcommand === 'show') {
37
+ return cmdList(cwd);
38
+ }
39
+
40
+ if (subcommand === 'clear') {
41
+ const { positional } = parseTriageArgs(rest);
42
+ return cmdClear(cwd, positional, rest.includes('--expired'));
43
+ }
44
+
45
+ // Default: triage <finding-id> <state>
46
+ const findingId = subcommand;
47
+ const { reason, expiresInDays, positional } = parseTriageArgs(rest);
48
+ const stateArg = positional[0];
49
+
50
+ if (!findingId || !stateArg) {
51
+ printUsage();
52
+ return 1;
53
+ }
54
+
55
+ if (stateArg !== 'accepted-risk' && stateArg !== 'false-positive') {
56
+ console.error(fmt('red', `[triage] State must be "accepted-risk" or "false-positive", got: "${stateArg}"`));
57
+ return 1;
58
+ }
59
+
60
+ const findings = loadCachedFindings(cwd);
61
+ const finding = findings.find(f => f.id === findingId || f.id.startsWith(findingId));
62
+ if (!finding) {
63
+ console.error(fmt('red', `[triage] Finding not found: "${findingId}"`));
64
+ console.error(fmt('dim', ' Run `guardrail run` or `guardrail scan` first, then `guardrail report` to list IDs'));
65
+ return 1;
66
+ }
67
+
68
+ addTriageEntry(cwd, finding, stateArg as TriageState, { reason, expiresInDays });
69
+
70
+ const expNote = expiresInDays !== undefined ? fmt('dim', ` (expires in ${expiresInDays} days)`) : '';
71
+ console.log(`${fmt('green', '✓')} ${fmt('bold', stateArg)} ${finding.file}${finding.line ? `:${finding.line}` : ''} — ${finding.message}${expNote}`);
72
+ if (reason) console.log(fmt('dim', ` Reason: ${reason}`));
73
+ console.log(fmt('dim', ' Suppressed from future runs. Commit .guardrail-triage.json to share with team.'));
74
+ return 0;
75
+ }
76
+
77
+ function cmdList(cwd: string): number {
78
+ const store = loadTriage(cwd);
79
+ const now = new Date().toISOString();
80
+ const active = store.entries.filter(e => !e.expiresAt || e.expiresAt > now);
81
+ const expired = store.entries.filter(e => e.expiresAt && e.expiresAt <= now);
82
+
83
+ if (store.entries.length === 0) {
84
+ console.log(fmt('dim', '[triage] No triaged findings.'));
85
+ return 0;
86
+ }
87
+
88
+ console.log(`\n${fmt('bold', '[guardrail triage]')} ${active.length} active, ${expired.length} expired\n`);
89
+ for (const e of active) {
90
+ const tag = e.state === 'false-positive'
91
+ ? fmt('dim', 'false-positive ')
92
+ : fmt('yellow', 'accepted-risk ');
93
+ const exp = e.expiresAt ? fmt('dim', ` expires ${e.expiresAt.slice(0, 10)}`) : '';
94
+ console.log(` [${tag}] ${fmt('dim', `${e.file}${e.line ? `:${e.line}` : ''}`)} — ${e.id}${exp}`);
95
+ if (e.reason) console.log(fmt('dim', ` Reason: ${e.reason}`));
96
+ }
97
+ if (expired.length > 0) {
98
+ console.log(fmt('dim', `\n ${expired.length} expired — run \`guardrail triage clear --expired\` to remove`));
99
+ }
100
+ console.log('');
101
+ return 0;
102
+ }
103
+
104
+ function cmdClear(cwd: string, ids: string[], expired: boolean): number {
105
+ if (expired) {
106
+ const removed = clearExpiredEntries(cwd);
107
+ console.log(fmt('dim', `[triage] Cleared ${removed} expired entr${removed === 1 ? 'y' : 'ies'}`));
108
+ return 0;
109
+ }
110
+ if (ids.length === 0) {
111
+ console.error(fmt('red', '[triage] clear requires a finding ID or --expired'));
112
+ return 1;
113
+ }
114
+ const removed = removeTriageEntry(cwd, ids);
115
+ console.log(fmt('dim', `[triage] Cleared ${removed} entr${removed === 1 ? 'y' : 'ies'}`));
116
+ return 0;
117
+ }
118
+
119
+ function printUsage(): void {
120
+ console.error(`
121
+ ${fmt('bold', 'Usage:')}
122
+ guardrail triage <finding-id> accepted-risk|false-positive [options]
123
+ guardrail triage list
124
+ guardrail triage clear <finding-id> [<id>...]
125
+ guardrail triage clear --expired
126
+
127
+ ${fmt('bold', 'Options:')}
128
+ --reason <text> Explain why this finding was triaged
129
+ --expires <days> Auto-expire triage after N days
130
+
131
+ ${fmt('bold', 'States:')}
132
+ accepted-risk Known issue, risk accepted — suppress without fixing
133
+ false-positive Finding is incorrect — suppress permanently (or with expiry)
134
+
135
+ ${fmt('dim', 'Finding IDs come from `guardrail report` or the run output.')}
136
+ `);
137
+ }
package/src/cli/watch.ts CHANGED
@@ -3,9 +3,9 @@ import * as path from 'node:path';
3
3
  import { loadConfig } from '../core/config/loader.ts';
4
4
  import { resolvePreset, mergeConfigs } from '../core/config/preset-resolver.ts';
5
5
  import { loadAdapter } from '../adapters/loader.ts';
6
- import { runAutopilot } from '../core/pipeline/run.ts';
6
+ import { runGuardrail } from '../core/pipeline/run.ts';
7
7
  import type { ReviewEngine } from '../adapters/review-engine/types.ts';
8
- import type { AutopilotConfig } from '../core/config/types.ts';
8
+ import type { GuardrailConfig } from '../core/config/types.ts';
9
9
 
10
10
  const C = {
11
11
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
@@ -17,7 +17,7 @@ const fmt = (c: keyof typeof C, t: string) => `${C[c]}${t}${C.reset}`;
17
17
  export const IGNORED_PATTERNS: readonly RegExp[] = [
18
18
  /(^|[/\\])node_modules([/\\]|$)/,
19
19
  /(^|[/\\])\.git([/\\]|$)/,
20
- /(^|[/\\])\.autopilot-cache([/\\]|$)/,
20
+ /(^|[/\\])\.guardrail-cache([/\\]|$)/,
21
21
  /\.(log|tmp|swp|swo|DS_Store)$/,
22
22
  /~$/,
23
23
  ];
@@ -29,10 +29,12 @@ export function isIgnored(p: string): boolean {
29
29
  /**
30
30
  * Pure debounce accumulator — returned functions are the testable core of watch logic.
31
31
  * schedule(file) → adds file, starts/resets timer; when debounce fires, calls flush(batch).
32
+ * onSchedule (optional) is called on every schedule() with the file and current queue size.
32
33
  */
33
34
  export function makeDebouncer(
34
35
  flushFn: (batch: string[]) => void,
35
36
  debounceMs: number,
37
+ onSchedule?: (file: string, queueSize: number) => void,
36
38
  ): { schedule: (file: string) => void; pending: () => string[] } {
37
39
  const pending = new Set<string>();
38
40
  let timer: ReturnType<typeof setTimeout> | null = null;
@@ -46,6 +48,7 @@ export function makeDebouncer(
46
48
  timer = null;
47
49
  flushFn(batch);
48
50
  }, debounceMs);
51
+ onSchedule?.(file, pending.size);
49
52
  },
50
53
  pending() { return [...pending]; },
51
54
  };
@@ -59,47 +62,55 @@ export interface WatchOptions {
59
62
 
60
63
  export async function runWatch(options: WatchOptions = {}): Promise<void> {
61
64
  const cwd = options.cwd ?? process.cwd();
62
- const configPath = options.configPath ?? path.join(cwd, 'autopilot.config.yaml');
65
+ const configPath = options.configPath ?? path.join(cwd, 'guardrail.config.yaml');
63
66
  const debounceMs = options.debounceMs ?? 300;
64
67
 
68
+ // Zero-config fallback — same as `run`
69
+ let config: GuardrailConfig;
65
70
  if (!fs.existsSync(configPath)) {
66
- console.error(fmt('red', `[watch] autopilot.config.yaml not found run: npx autopilot init`));
67
- process.exit(1);
71
+ config = { configVersion: 1, reviewEngine: { adapter: 'auto' }, testCommand: null };
72
+ } else {
73
+ try {
74
+ const userConfig = await loadConfig(configPath);
75
+ config = userConfig.preset
76
+ ? mergeConfigs((await resolvePreset(userConfig.preset)).config, userConfig)
77
+ : userConfig;
78
+ } catch (err) {
79
+ console.error(fmt('red', `[watch] Config error: ${err instanceof Error ? err.message : String(err)}`));
80
+ process.exit(1);
81
+ }
68
82
  }
69
83
 
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
- }
84
+ const hasAnyKey = !!(process.env.ANTHROPIC_API_KEY || process.env.GEMINI_API_KEY ||
85
+ process.env.GOOGLE_API_KEY || process.env.OPENAI_API_KEY || process.env.GROQ_API_KEY);
80
86
 
81
87
  let reviewEngine: ReviewEngine | undefined;
82
- if (config.reviewEngine) {
88
+ if (config.reviewEngine && hasAnyKey) {
83
89
  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
- }
90
+ try {
91
+ reviewEngine = await loadAdapter<ReviewEngine>({
92
+ point: 'review-engine', ref,
93
+ options: typeof config.reviewEngine === 'string' ? undefined : config.reviewEngine.options,
94
+ });
95
+ } catch { /* skip — static rules still run */ }
92
96
  }
93
97
 
94
- console.log(`\n${fmt('bold', '[autopilot watch]')} ${fmt('dim', cwd)}`);
95
- console.log(fmt('dim', ` debounce: ${debounceMs}ms | Ctrl+C to exit\n`));
98
+ const keyStatus = hasAnyKey
99
+ ? fmt('green', '✓ LLM review enabled')
100
+ : fmt('yellow', '! No API key — static rules only (set ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, or GROQ_API_KEY)');
101
+
102
+ console.log(`\n${fmt('bold', '[guardrail watch]')} ${fmt('dim', cwd)}`);
103
+ console.log(fmt('dim', ` debounce: ${debounceMs}ms | Ctrl+C to exit`));
104
+ console.log(` ${keyStatus}\n`);
105
+ console.log(fmt('dim', ' Watching for changes…\n'));
96
106
 
97
107
  let running = false;
98
108
  const nextPending = new Set<string>();
109
+ let debounceLineShown = false;
99
110
 
100
111
  const runBatch = async (batch: string[]) => {
112
+ debounceLineShown = false;
101
113
  if (running) {
102
- // Queue these files for the next run after the current one completes
103
114
  for (const f of batch) nextPending.add(f);
104
115
  return;
105
116
  }
@@ -111,7 +122,7 @@ export async function runWatch(options: WatchOptions = {}): Promise<void> {
111
122
  console.log(fmt('dim', ` changed: ${rel.slice(0, 4).join(', ')}${rel.length > 4 ? ` +${rel.length - 4} more` : ''}`));
112
123
 
113
124
  try {
114
- const result = await runAutopilot({ touchedFiles: rel, config, reviewEngine, cwd });
125
+ const result = await runGuardrail({ touchedFiles: rel, config, reviewEngine, cwd });
115
126
 
116
127
  for (const phase of result.phases) {
117
128
  const icon = phase.status === 'pass' ? fmt('green', '✓')
@@ -137,7 +148,8 @@ export async function runWatch(options: WatchOptions = {}): Promise<void> {
137
148
  }
138
149
 
139
150
  running = false;
140
- // Flush anything that accumulated while we were running
151
+ console.log(fmt('dim', '\n Watching for changes…'));
152
+
141
153
  if (nextPending.size > 0) {
142
154
  const queued = [...nextPending];
143
155
  nextPending.clear();
@@ -145,7 +157,16 @@ export async function runWatch(options: WatchOptions = {}): Promise<void> {
145
157
  }
146
158
  };
147
159
 
148
- const debouncer = makeDebouncer(batch => { runBatch(batch); }, debounceMs);
160
+ const debouncer = makeDebouncer(
161
+ batch => { runBatch(batch); },
162
+ debounceMs,
163
+ (_file, queueSize) => {
164
+ if (!debounceLineShown && !running) {
165
+ process.stdout.write(fmt('dim', ` ⋯ ${queueSize} file(s) queued — reviewing in ${debounceMs}ms…\r`));
166
+ debounceLineShown = true;
167
+ }
168
+ },
169
+ );
149
170
 
150
171
  const onEvent = (_event: string, filename: string | null) => {
151
172
  if (!filename) return;