@delegance/claude-autopilot 2.4.0 → 5.0.0-alpha.1

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 +50 -0
  2. package/README.md +164 -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 +15 -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 +80 -0
  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 +249 -0
  36. package/src/cli/hook.ts +72 -27
  37. package/src/cli/ignore-helper.ts +116 -0
  38. package/src/cli/index.ts +302 -28
  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 +15 -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 +24 -12
  58. package/src/core/config/preset-resolver.ts +6 -6
  59. package/src/core/config/schema.ts +121 -3
  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/pr.ts ADDED
@@ -0,0 +1,76 @@
1
+ import * as path from 'node:path';
2
+ import * as fs from 'node:fs';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { runCommand } from './run.ts';
5
+
6
+ const C = {
7
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
8
+ green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m',
9
+ };
10
+ const fmt = (c: keyof typeof C, t: string) => `${C[c]}${t}${C.reset}`;
11
+
12
+ export interface PrCommandOptions {
13
+ cwd?: string;
14
+ configPath?: string;
15
+ prNumber?: string;
16
+ noPostComments?: boolean;
17
+ noInlineComments?: boolean;
18
+ }
19
+
20
+ function ghJson<T>(args: string[], cwd: string): T | null {
21
+ const r = spawnSync('gh', args, { cwd, encoding: 'utf8' });
22
+ if (r.status !== 0) return null;
23
+ try { return JSON.parse(r.stdout) as T; } catch { return null; }
24
+ }
25
+
26
+ function gitFetch(remote: string, ref: string, cwd: string): boolean {
27
+ const r = spawnSync('git', ['fetch', remote, ref], { cwd, encoding: 'utf8', stdio: 'pipe' });
28
+ return r.status === 0;
29
+ }
30
+
31
+ export async function runPr(options: PrCommandOptions = {}): Promise<number> {
32
+ const cwd = options.cwd ?? process.cwd();
33
+ const configPath = options.configPath ?? path.join(cwd, 'guardrail.config.yaml');
34
+
35
+ if (!fs.existsSync(configPath)) {
36
+ console.error(fmt('red', `[pr] guardrail.config.yaml not found at ${configPath}`));
37
+ return 1;
38
+ }
39
+
40
+ // Resolve PR number
41
+ let prNumber = options.prNumber;
42
+ if (!prNumber) {
43
+ const detected = ghJson<{ number: number }>(['pr', 'view', '--json', 'number'], cwd);
44
+ if (!detected) {
45
+ console.error(fmt('red', '[pr] No PR number given and no open PR found for current branch.'));
46
+ console.error(fmt('dim', ' Usage: guardrail pr <number>'));
47
+ return 1;
48
+ }
49
+ prNumber = String(detected.number);
50
+ }
51
+
52
+ // Look up PR metadata
53
+ interface PrMeta { number: number; baseRefName: string; headRefName: string; title: string }
54
+ const pr = ghJson<PrMeta>(['pr', 'view', prNumber, '--json', 'number,baseRefName,headRefName,title'], cwd);
55
+ if (!pr) {
56
+ console.error(fmt('red', `[pr] Could not fetch PR #${prNumber} — is gh authenticated?`));
57
+ return 1;
58
+ }
59
+
60
+ console.log(`\n${fmt('bold', `[guardrail pr]`)} #${pr.number} ${fmt('dim', pr.title)}`);
61
+ console.log(fmt('dim', ` base: ${pr.baseRefName} head: ${pr.headRefName}`));
62
+
63
+ // Fetch base ref so diff works locally
64
+ const fetched = gitFetch('origin', pr.baseRefName, cwd);
65
+ if (!fetched) {
66
+ console.log(fmt('yellow', ` [pr] Warning: could not fetch origin/${pr.baseRefName} — diff may be stale`));
67
+ }
68
+
69
+ return runCommand({
70
+ cwd,
71
+ configPath,
72
+ base: `origin/${pr.baseRefName}`,
73
+ postComments: !options.noPostComments,
74
+ inlineComments: !options.noInlineComments,
75
+ });
76
+ }
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { runSafe } from '../core/shell.ts';
6
+ import { detectLLMKey, loadEnvFile, LLM_KEY_NAMES } from '../core/detect/llm-key.ts';
6
7
 
7
8
  const PASS = '\x1b[32m✓\x1b[0m';
8
9
  const FAIL = '\x1b[31m✗\x1b[0m';
@@ -16,21 +17,6 @@ interface Check {
16
17
  message?: string;
17
18
  }
18
19
 
19
- function loadEnvFile(filePath: string): Record<string, string> {
20
- const vars: Record<string, string> = {};
21
- try {
22
- const content = fs.readFileSync(filePath, 'utf-8');
23
- for (const line of content.split('\n')) {
24
- const trimmed = line.trim();
25
- if (!trimmed || trimmed.startsWith('#')) continue;
26
- const eq = trimmed.indexOf('=');
27
- if (eq < 0) continue;
28
- vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim().replace(/^['"]|['"]$/g, '');
29
- }
30
- } catch { /* ignore */ }
31
- return vars;
32
- }
33
-
34
20
  export interface DoctorResult {
35
21
  blockers: number;
36
22
  warnings: number;
@@ -56,7 +42,7 @@ export async function runDoctor(): Promise<DoctorResult> {
56
42
  checks.push({
57
43
  name: 'tsx available',
58
44
  result: tsxVersion ? 'pass' : 'fail',
59
- message: !tsxVersion ? 'tsx not found — run: npm install @delegance/claude-autopilot (includes tsx)' : undefined,
45
+ message: !tsxVersion ? 'tsx not found — run: npm install @delegance/guardrail (includes tsx)' : undefined,
60
46
  });
61
47
 
62
48
  // 3. gh CLI authenticated
@@ -67,13 +53,13 @@ export async function runDoctor(): Promise<DoctorResult> {
67
53
  message: ghAuth === null ? 'gh CLI not authenticated — run: gh auth login' : undefined,
68
54
  });
69
55
 
70
- // 4. autopilot.config.yaml in cwd
71
- const configYaml = path.join(process.cwd(), 'autopilot.config.yaml');
56
+ // 4. guardrail.config.yaml in cwd
57
+ const configYaml = path.join(process.cwd(), 'guardrail.config.yaml');
72
58
  checks.push({
73
- name: 'autopilot.config.yaml',
59
+ name: 'guardrail.config.yaml',
74
60
  result: fs.existsSync(configYaml) ? 'pass' : 'warn',
75
61
  message: !fs.existsSync(configYaml)
76
- ? 'autopilot.config.yaml not found in current directory — copy from a preset: presets/nextjs-supabase/autopilot.config.yaml'
62
+ ? 'guardrail.config.yaml not found in current directory — copy from a preset: presets/nextjs-supabase/guardrail.config.yaml'
77
63
  : undefined,
78
64
  });
79
65
 
@@ -83,21 +69,18 @@ export async function runDoctor(): Promise<DoctorResult> {
83
69
  name: `Local env file (${envFile ?? 'none found'})`,
84
70
  result: envFile ? 'pass' : 'warn',
85
71
  message: !envFile
86
- ? `No env file found. Looked for: ${ENV_CANDIDATES.join(', ')}. Create one with your OPENAI_API_KEY.`
72
+ ? `No env file found. Looked for: ${ENV_CANDIDATES.join(', ')}. Create one with one of: ${LLM_KEY_NAMES.join(', ')}.`
87
73
  : undefined,
88
74
  });
89
75
 
90
- // 6. LLM API key (ANTHROPIC_API_KEY preferred, OPENAI_API_KEY as fallback)
76
+ // 6. LLM API key shared detection with setup/scan/run (all 5 providers)
91
77
  const envVars = envFile ? loadEnvFile(envFile) : {};
92
- const hasAnthropic = !!process.env.ANTHROPIC_API_KEY || !!envVars['ANTHROPIC_API_KEY'];
93
- const hasOpenAI = !!process.env.OPENAI_API_KEY || !!envVars['OPENAI_API_KEY'];
94
- const hasLLMKey = hasAnthropic || hasOpenAI;
95
- const llmKeyName = hasAnthropic ? 'ANTHROPIC_API_KEY' : hasOpenAI ? 'OPENAI_API_KEY' : 'none';
78
+ const { hasKey, preferred } = detectLLMKey({ extraEnv: envVars });
96
79
  checks.push({
97
- name: `LLM API key (${llmKeyName})`,
98
- result: hasLLMKey ? 'pass' : 'warn',
99
- message: !hasLLMKey
100
- ? `No LLM API key found — set ANTHROPIC_API_KEY (recommended) or OPENAI_API_KEY to enable review`
80
+ name: `LLM API key (${preferred ?? 'none'})`,
81
+ result: hasKey ? 'pass' : 'warn',
82
+ message: !hasKey
83
+ ? `No LLM API key found — set one of: ${LLM_KEY_NAMES.join(', ')} to enable review`
101
84
  : undefined,
102
85
  });
103
86
 
@@ -125,7 +108,7 @@ export async function runDoctor(): Promise<DoctorResult> {
125
108
 
126
109
 
127
110
  // Print results
128
- console.log('\n\x1b[1m[doctor] Autopilot prerequisite check\x1b[0m\n');
111
+ console.log('\n\x1b[1m[doctor] Guardrail prerequisite check\x1b[0m\n');
129
112
  let blockers = 0;
130
113
  let warnings = 0;
131
114
  for (const check of checks) {
@@ -140,7 +123,7 @@ export async function runDoctor(): Promise<DoctorResult> {
140
123
 
141
124
  console.log('');
142
125
  if (blockers > 0) {
143
- console.log(`\x1b[31m[doctor] ${blockers} blocker(s) — fix before running npx autopilot run\x1b[0m\n`);
126
+ console.log(`\x1b[31m[doctor] ${blockers} blocker(s) — fix before running npx guardrail run\x1b[0m\n`);
144
127
  } else if (warnings > 0) {
145
128
  console.log(`\x1b[33m[doctor] ${warnings} warning(s) — pipeline will run but some steps may be skipped\x1b[0m\n`);
146
129
  } else {
@@ -0,0 +1,186 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { loadCachedFindings } from '../core/persist/findings-cache.ts';
4
+ import { readCostLog } from '../core/persist/cost-log.ts';
5
+ import type { Finding } from '../core/findings/types.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 ReportCommandOptions {
14
+ cwd?: string;
15
+ output?: string; // file path to write markdown (default: stdout)
16
+ trend?: boolean; // include trend analysis from cost log run history
17
+ }
18
+
19
+ function severityOrder(s: Finding['severity']): number {
20
+ return s === 'critical' ? 0 : s === 'warning' ? 1 : 2;
21
+ }
22
+
23
+ function buildTrendSection(cwd: string): string {
24
+ const log = readCostLog(cwd);
25
+ if (log.length === 0) return '';
26
+
27
+ const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
28
+ const recent = log.filter(e => new Date(e.timestamp).getTime() >= sevenDaysAgo);
29
+ const totalCost = log.reduce((s, e) => s + e.costUSD, 0);
30
+ const avgFiles = log.reduce((s, e) => s + e.files, 0) / log.length;
31
+
32
+ const lines: string[] = [
33
+ '## 📈 Trend',
34
+ '',
35
+ `| Metric | Value |`,
36
+ `|--------|-------|`,
37
+ `| Runs (7d) | ${recent.length} |`,
38
+ `| Runs (all-time) | ${log.length} |`,
39
+ `| All-time cost | $${totalCost.toFixed(4)} |`,
40
+ `| Avg files/run | ${avgFiles.toFixed(1)} |`,
41
+ '',
42
+ ];
43
+
44
+ if (recent.length > 0) {
45
+ lines.push('### Recent runs', '');
46
+ lines.push('| Date | Files | Cost |');
47
+ lines.push('|------|-------|------|');
48
+ for (const e of recent.slice(-7).reverse()) {
49
+ const d = new Date(e.timestamp).toLocaleDateString();
50
+ lines.push(`| ${d} | ${e.files} | $${e.costUSD.toFixed(4)} |`);
51
+ }
52
+ lines.push('');
53
+ }
54
+
55
+ return lines.join('\n');
56
+ }
57
+
58
+ function buildFileBreakdown(findings: Finding[]): string {
59
+ const withFiles = findings.filter(f => f.file && f.file !== '<unspecified>' && f.file !== '<pipeline>');
60
+ if (withFiles.length === 0) return '';
61
+
62
+ const counts = new Map<string, { critical: number; warning: number; note: number; total: number }>();
63
+ for (const f of withFiles) {
64
+ const entry = counts.get(f.file) ?? { critical: 0, warning: 0, note: 0, total: 0 };
65
+ entry[f.severity]++;
66
+ entry.total++;
67
+ counts.set(f.file, entry);
68
+ }
69
+
70
+ const sorted = [...counts.entries()].sort((a, b) => b[1].total - a[1].total).slice(0, 10);
71
+ if (sorted.length < 2) return ''; // single file — not worth a table
72
+
73
+ const lines = [
74
+ '## 📁 By File',
75
+ '',
76
+ '| File | Critical | Warning | Note | Total |',
77
+ '|------|----------|---------|------|-------|',
78
+ ];
79
+ for (const [file, c] of sorted) {
80
+ lines.push(`| \`${file}\` | ${c.critical || '–'} | ${c.warning || '–'} | ${c.note || '–'} | **${c.total}** |`);
81
+ }
82
+ if (counts.size > 10) lines.push(`| *(${counts.size - 10} more files)* | | | | |`);
83
+ lines.push('');
84
+ return lines.join('\n');
85
+ }
86
+
87
+ function buildSourceBreakdown(findings: Finding[]): string {
88
+ const counts = new Map<string, number>();
89
+ for (const f of findings) {
90
+ counts.set(f.source, (counts.get(f.source) ?? 0) + 1);
91
+ }
92
+ if (counts.size < 2) return '';
93
+
94
+ const lines = ['## 🔬 By Source', '', '| Source | Findings |', '|--------|----------|'];
95
+ for (const [source, n] of [...counts.entries()].sort((a, b) => b[1] - a[1])) {
96
+ lines.push(`| ${source} | ${n} |`);
97
+ }
98
+ lines.push('');
99
+ return lines.join('\n');
100
+ }
101
+
102
+ function buildMarkdown(findings: Finding[], cwd: string, trend: boolean): string {
103
+ const critical = findings.filter(f => f.severity === 'critical');
104
+ const warnings = findings.filter(f => f.severity === 'warning');
105
+ const notes = findings.filter(f => f.severity === 'note');
106
+ const fixable = critical.length + warnings.length;
107
+
108
+ const lines: string[] = [
109
+ '# Guardrail Report',
110
+ '',
111
+ `> Generated ${new Date().toISOString()}`,
112
+ '',
113
+ '## Summary',
114
+ '',
115
+ `| Severity | Count |`,
116
+ `|----------|-------|`,
117
+ `| 🚨 Critical | ${critical.length} |`,
118
+ `| ⚠️ Warning | ${warnings.length} |`,
119
+ `| ℹ️ Note | ${notes.length} |`,
120
+ `| **Total** | **${findings.length}** |`,
121
+ '',
122
+ ];
123
+
124
+ if (fixable > 0) {
125
+ lines.push(`> **${fixable} finding${fixable !== 1 ? 's' : ''} can be auto-fixed** — run \`guardrail fix\` to attempt repairs.`, '');
126
+ }
127
+
128
+ if (trend) {
129
+ const trendSection = buildTrendSection(cwd);
130
+ if (trendSection) lines.push(trendSection);
131
+ }
132
+
133
+ const fileBreakdown = buildFileBreakdown(findings);
134
+ if (fileBreakdown) lines.push(fileBreakdown);
135
+
136
+ const sourceBreakdown = buildSourceBreakdown(findings);
137
+ if (sourceBreakdown) lines.push(sourceBreakdown);
138
+
139
+ function renderGroup(label: string, icon: string, group: Finding[]) {
140
+ if (group.length === 0) return;
141
+ lines.push(`## ${icon} ${label}`, '');
142
+ for (const f of group) {
143
+ const loc = f.file && f.file !== '<unspecified>' && f.file !== '<pipeline>'
144
+ ? `\`${f.file}${f.line ? `:${f.line}` : ''}\``
145
+ : null;
146
+ lines.push(`### ${loc ? `${loc} — ` : ''}${f.message}`);
147
+ if (f.suggestion) lines.push('', `> **Suggestion:** ${f.suggestion}`);
148
+ lines.push('', `*Source: ${f.source} · Rule: ${f.id}*`, '');
149
+ }
150
+ }
151
+
152
+ renderGroup('Critical', '🚨', critical);
153
+ renderGroup('Warnings', '⚠️', warnings);
154
+ renderGroup('Notes', 'ℹ️', notes);
155
+
156
+ if (findings.length === 0) {
157
+ lines.push('## ✅ No findings', '', 'No cached findings — run `guardrail run` or `guardrail scan` first.', '');
158
+ }
159
+
160
+ return lines.join('\n');
161
+ }
162
+
163
+ export async function runReport(options: ReportCommandOptions = {}): Promise<number> {
164
+ const cwd = options.cwd ?? process.cwd();
165
+ const findings = loadCachedFindings(cwd);
166
+
167
+ if (findings.length === 0) {
168
+ console.log(fmt('yellow', '[report] No cached findings — run `guardrail run` or `guardrail scan` first.'));
169
+ return 0;
170
+ }
171
+
172
+ const sorted = [...findings].sort((a, b) => severityOrder(a.severity) - severityOrder(b.severity));
173
+ const md = buildMarkdown(sorted, cwd, options.trend ?? false);
174
+
175
+ if (options.output) {
176
+ fs.writeFileSync(options.output, md, 'utf8');
177
+ const critical = findings.filter(f => f.severity === 'critical').length;
178
+ const warnings = findings.filter(f => f.severity === 'warning').length;
179
+ console.log(fmt('bold', `[report] Written to ${options.output}`));
180
+ console.log(` ${critical > 0 ? fmt('red', `${critical} critical`) : fmt('green', '0 critical')} ${warnings > 0 ? fmt('yellow', `${warnings} warning${warnings !== 1 ? 's' : ''}`) : fmt('dim', '0 warnings')}`);
181
+ } else {
182
+ process.stdout.write(md + '\n');
183
+ }
184
+
185
+ return findings.some(f => f.severity === 'critical') ? 1 : 0;
186
+ }
package/src/cli/run.ts CHANGED
@@ -25,23 +25,37 @@ import { loadRulesFromConfig } from '../core/static-rules/registry.ts';
25
25
  import { resolvePreset } from '../core/config/preset-resolver.ts';
26
26
  import { mergeConfigs } from '../core/config/preset-resolver.ts';
27
27
  import { loadAdapter } from '../adapters/loader.ts';
28
- import { runAutopilot } from '../core/pipeline/run.ts';
28
+ import { runGuardrail } from '../core/pipeline/run.ts';
29
29
  import { resolveGitTouchedFiles } from '../core/git/touched-files.ts';
30
30
  import type { RunInput } from '../core/pipeline/run.ts';
31
31
  import type { ReviewEngine } from '../adapters/review-engine/types.ts';
32
- import type { AutopilotConfig } from '../core/config/types.ts';
32
+ import type { GuardrailConfig } from '../core/config/types.ts';
33
33
  import { fileURLToPath } from 'node:url';
34
34
  import { toSarif } from '../formatters/sarif.ts';
35
+ import { toJUnit } from '../formatters/junit.ts';
36
+ import { loadTriage, filterTriaged } from '../core/persist/triage.ts';
35
37
  import { emitAnnotations } from '../formatters/github-annotations.ts';
36
38
  import { detectStack } from '../core/detect/stack.ts';
37
39
  import { detectProtectedPaths } from '../core/detect/protected-paths.ts';
38
40
  import { detectGitContext } from '../core/detect/git-context.ts';
41
+ import { detectWorkspaces, mapFilesToWorkspaces } from '../core/detect/workspaces.ts';
39
42
  import { detectProject } from './detector.ts';
40
43
  import { detectPrNumber, formatComment, postPrComment } from './pr-comment.ts';
41
44
  import { postReviewComments } from './pr-review-comments.ts';
42
45
  import { loadIgnoreRules, parseConfigIgnore, applyIgnoreRules } from '../core/ignore/index.ts';
46
+ import { detectLLMKey, LLM_KEY_HINTS } from '../core/detect/llm-key.ts';
43
47
  import { loadCachedFindings, saveCachedFindings, filterNewFindings } from '../core/persist/findings-cache.ts';
48
+ import { loadBaseline, filterBaselined } from '../core/persist/baseline.ts';
44
49
  import { appendCostLog } from '../core/persist/cost-log.ts';
50
+ import { postCommitStatus, resolveCommitSha } from '../adapters/vcs-host/commit-status.ts';
51
+
52
+ function computeExitCode(findings: { severity: string }[], failOn: string): number {
53
+ if (failOn === 'none') return 0;
54
+ const fail = failOn === 'warning' ? ['critical', 'warning']
55
+ : failOn === 'note' ? ['critical', 'warning', 'note']
56
+ : ['critical'];
57
+ return findings.some(f => fail.includes(f.severity)) ? 1 : 0;
58
+ }
45
59
 
46
60
  function readToolVersion(): string {
47
61
  const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../package.json');
@@ -70,10 +84,13 @@ export interface RunCommandOptions {
70
84
  dryRun?: boolean; // skip review, print what would run
71
85
  diff?: boolean; // use diff strategy (send git hunks instead of full files)
72
86
  delta?: boolean; // only report findings not present in last run's baseline
87
+ newOnly?: boolean; // only report findings not in committed .guardrail-baseline.json
88
+ failOn?: 'critical' | 'warning' | 'note' | 'none'; // severity threshold for exit 1
73
89
  inlineComments?: boolean; // post per-line review comments on the PR diff
74
- format?: 'text' | 'sarif';
90
+ format?: 'text' | 'sarif' | 'junit';
75
91
  outputPath?: string;
76
92
  postComments?: boolean; // post/update summary comment on the open PR
93
+ skipReview?: boolean; // skip tests and review phases (static rules only)
77
94
  }
78
95
 
79
96
  /**
@@ -82,27 +99,26 @@ export interface RunCommandOptions {
82
99
  */
83
100
  export async function runCommand(options: RunCommandOptions = {}): Promise<number> {
84
101
  const cwd = options.cwd ?? process.cwd();
85
- const configPath = options.configPath ?? path.join(cwd, 'autopilot.config.yaml');
102
+ const configPath = options.configPath ?? path.join(cwd, 'guardrail.config.yaml');
86
103
 
104
+ // Load + merge config (graceful zero-config fallback)
105
+ let config: GuardrailConfig;
87
106
  if (!fs.existsSync(configPath)) {
88
- console.error(fmt('red', `[run] autopilot.config.yaml not found at ${configPath}`));
89
- console.error(fmt('dim', ' Run: npx autopilot init'));
90
- return 1;
91
- }
92
-
93
- // Load + merge config
94
- let config: AutopilotConfig;
95
- try {
96
- const userConfig = await loadConfig(configPath);
97
- if (userConfig.preset) {
98
- const preset = await resolvePreset(userConfig.preset);
99
- config = mergeConfigs(preset.config, userConfig);
100
- } else {
101
- config = userConfig;
107
+ console.log(fmt('dim', `[run] No guardrail.config.yaml found using defaults. Run \`npx guardrail setup\` to configure.`));
108
+ config = { configVersion: 1, reviewEngine: { adapter: 'auto' }, testCommand: null };
109
+ } else {
110
+ try {
111
+ const userConfig = await loadConfig(configPath);
112
+ if (userConfig.preset) {
113
+ const preset = await resolvePreset(userConfig.preset);
114
+ config = mergeConfigs(preset.config, userConfig);
115
+ } else {
116
+ config = userConfig;
117
+ }
118
+ } catch (err) {
119
+ console.error(fmt('red', `[run] Config error: ${err instanceof Error ? err.message : String(err)}`));
120
+ return 1;
102
121
  }
103
- } catch (err) {
104
- console.error(fmt('red', `[run] Config error: ${err instanceof Error ? err.message : String(err)}`));
105
- return 1;
106
122
  }
107
123
 
108
124
  // Fill in missing config fields from auto-detection (track what was auto-detected for logging)
@@ -124,6 +140,13 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
124
140
  config = { ...config, testCommand: detected };
125
141
  autoDetected.push(`test: ${detected}`);
126
142
  }
143
+
144
+ // Monorepo workspace detection
145
+ const workspaces = detectWorkspaces(cwd);
146
+ if (workspaces && workspaces.length > 0) {
147
+ autoDetected.push(`workspaces: ${workspaces.length} (${workspaces.map(w => w.name).slice(0, 3).join(', ')}${workspaces.length > 3 ? ` +${workspaces.length - 3} more` : ''})`);
148
+ }
149
+
127
150
  const gitCtx = detectGitContext(cwd);
128
151
 
129
152
  // Resolve touched files
@@ -134,7 +157,7 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
134
157
  return 0;
135
158
  }
136
159
 
137
- console.log(`\n${fmt('bold', '[autopilot run]')} ${fmt('dim', configPath)}`);
160
+ console.log(`\n${fmt('bold', '[guardrail run]')} ${fmt('dim', configPath)}`);
138
161
  console.log(`${fmt('dim', ` ${touchedFiles.length} changed file(s):`)} ${touchedFiles.slice(0, 5).join(', ')}${touchedFiles.length > 5 ? ` … +${touchedFiles.length - 5} more` : ''}`);
139
162
  if (gitCtx.summary) {
140
163
  console.log(fmt('dim', ` ${gitCtx.summary}`));
@@ -152,10 +175,13 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
152
175
  let reviewEngine: ReviewEngine | undefined;
153
176
  if (config.reviewEngine) {
154
177
  const ref = typeof config.reviewEngine === 'string' ? config.reviewEngine : config.reviewEngine.adapter;
155
- const hasAnyKey = !!(process.env.ANTHROPIC_API_KEY || process.env.GEMINI_API_KEY ||
156
- process.env.GOOGLE_API_KEY || process.env.OPENAI_API_KEY || process.env.GROQ_API_KEY);
157
- if (!hasAnyKey && ['auto', 'claude', 'gemini', 'codex', 'openai-compatible'].includes(ref)) {
158
- console.log(fmt('yellow', '\n [run] No LLM API key found — set ANTHROPIC_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, or GROQ_API_KEY to enable review'));
178
+ if (!detectLLMKey().hasKey && ['auto', 'claude', 'gemini', 'codex', 'openai-compatible'].includes(ref)) {
179
+ console.log(fmt('yellow', '\n [run] No LLM API key — set one of:'));
180
+ for (const { name, url, note } of LLM_KEY_HINTS) {
181
+ const suffix = note ? ` (${note})` : '';
182
+ console.log(fmt('dim', ` ${name.padEnd(18)} ${url}${suffix}`));
183
+ }
184
+ console.log('');
159
185
  } else {
160
186
  try {
161
187
  reviewEngine = await loadAdapter<ReviewEngine>({
@@ -179,6 +205,18 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
179
205
  config = { ...config, reviewStrategy: 'diff' };
180
206
  }
181
207
 
208
+ // Pre-run cost estimate
209
+ if (config.cost?.estimateBeforeRun) {
210
+ const totalChars = touchedFiles.reduce((sum, f) => {
211
+ try { return sum + fs.statSync(f).size; } catch { return sum; }
212
+ }, 0);
213
+ const estTokens = Math.round(totalChars / 4);
214
+ const estCost = estTokens / 1_000_000 * 3.0; // rough: $3/M tokens (Sonnet)
215
+ const cap = config.cost?.maxPerRun;
216
+ console.log(fmt('dim', ` [run] estimated: ~${estTokens.toLocaleString()} tokens, ~$${estCost.toFixed(4)}`
217
+ + (cap ? ` (cap: $${cap})` : '')));
218
+ }
219
+
182
220
  // Execute pipeline
183
221
  const input: RunInput = {
184
222
  touchedFiles,
@@ -188,12 +226,19 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
188
226
  cwd,
189
227
  gitSummary: gitCtx.summary ?? undefined,
190
228
  base: options.base,
229
+ skipReview: options.skipReview,
191
230
  };
192
231
 
232
+ // Post pending commit status (best-effort — never fatal)
233
+ const commitSha = resolveCommitSha(cwd);
234
+ if (commitSha) {
235
+ postCommitStatus({ sha: commitSha, state: 'pending', description: `Reviewing ${touchedFiles.length} file(s)…`, cwd });
236
+ }
237
+
193
238
  console.log('');
194
- const result = await runAutopilot(input);
239
+ const result = await runGuardrail(input);
195
240
 
196
- // Apply .autopilot-ignore + config ignore: rules
241
+ // Apply .guardrail-ignore + config ignore: rules
197
242
  const ignoreRules = [...loadIgnoreRules(cwd), ...parseConfigIgnore(config.ignore)];
198
243
  if (ignoreRules.length > 0) {
199
244
  const before = result.allFindings.length;
@@ -203,11 +248,11 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
203
248
  }
204
249
  const suppressed = before - result.allFindings.length;
205
250
  if (suppressed > 0) {
206
- console.log(fmt('dim', ` [run] ${suppressed} finding${suppressed !== 1 ? 's' : ''} suppressed by .autopilot-ignore`));
251
+ console.log(fmt('dim', ` [run] ${suppressed} finding${suppressed !== 1 ? 's' : ''} suppressed by .guardrail-ignore`));
207
252
  }
208
253
  }
209
254
 
210
- // Delta mode: filter to only new findings vs last run's baseline, then persist
255
+ // Delta mode: filter to only new findings vs last run's cache, then persist
211
256
  if (options.delta) {
212
257
  const cached = loadCachedFindings(cwd);
213
258
  const before = result.allFindings.length;
@@ -220,7 +265,41 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
220
265
  console.log(fmt('dim', ` [run] ${existing} pre-existing finding${existing !== 1 ? 's' : ''} hidden (--delta mode)`));
221
266
  }
222
267
  }
223
- // Always persist the unfiltered findings as the new baseline
268
+
269
+ // --new-only / policy.newOnly: filter against committed .guardrail-baseline.json
270
+ const policy = config.policy ?? {};
271
+ const newOnly = options.newOnly ?? policy.newOnly ?? false;
272
+ if (newOnly) {
273
+ const baseline = loadBaseline(cwd, policy.baselinePath);
274
+ if (baseline) {
275
+ const { newFindings, baselinedCount } = filterBaselined(result.allFindings, baseline);
276
+ result.allFindings = newFindings;
277
+ for (const phase of result.phases) {
278
+ phase.findings = filterBaselined(phase.findings, baseline).newFindings;
279
+ }
280
+ if (baselinedCount > 0) {
281
+ console.log(fmt('dim', ` [run] ${baselinedCount} baselined finding${baselinedCount !== 1 ? 's' : ''} suppressed (--new-only)`));
282
+ }
283
+ } else {
284
+ console.log(fmt('yellow', ' [run] --new-only: no .guardrail-baseline.json found — showing all findings'));
285
+ console.log(fmt('dim', ' Run `guardrail baseline create` after first scan to pin the baseline'));
286
+ }
287
+ }
288
+
289
+ // Triage filter: suppress accepted-risk / false-positive findings
290
+ const triageStore = loadTriage(cwd);
291
+ if (triageStore.entries.length > 0) {
292
+ const { active, triageCount } = filterTriaged(result.allFindings, triageStore);
293
+ if (triageCount > 0) {
294
+ result.allFindings = active;
295
+ for (const phase of result.phases) {
296
+ phase.findings = filterTriaged(phase.findings, triageStore).active;
297
+ }
298
+ console.log(fmt('dim', ` [run] ${triageCount} triaged finding${triageCount !== 1 ? 's' : ''} suppressed (accepted-risk / false-positive)`));
299
+ }
300
+ }
301
+
302
+ // Always persist the unfiltered findings as the run cache
224
303
  saveCachedFindings(cwd, result.allFindings);
225
304
 
226
305
  // Append to per-run cost log
@@ -245,6 +324,14 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
245
324
  console.log(fmt('dim', `[run] SARIF written to ${options.outputPath}`));
246
325
  }
247
326
 
327
+ // Write JUnit XML output if requested
328
+ if (options.format === 'junit' && options.outputPath) {
329
+ const junit = toJUnit(result);
330
+ fs.mkdirSync(path.dirname(path.resolve(options.outputPath)), { recursive: true });
331
+ fs.writeFileSync(options.outputPath, junit, 'utf8');
332
+ console.log(fmt('dim', `[run] JUnit XML written to ${options.outputPath}`));
333
+ }
334
+
248
335
  // Post inline PR review comments if requested
249
336
  if (options.inlineComments) {
250
337
  const pr = detectPrNumber(cwd);
@@ -304,16 +391,33 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
304
391
  console.log(`\n ${fmt('dim', `${result.durationMs}ms total`)}`);
305
392
  }
306
393
 
307
- // Final verdict
394
+ // Post final commit status
395
+ if (commitSha) {
396
+ const critical = result.allFindings.filter(f => f.severity === 'critical').length;
397
+ const warnings = result.allFindings.filter(f => f.severity === 'warning').length;
398
+ const state = result.status === 'fail' ? 'failure' : 'success';
399
+ const desc = result.status === 'pass'
400
+ ? 'All checks passed'
401
+ : result.status === 'warn'
402
+ ? `Passed with ${warnings} warning${warnings !== 1 ? 's' : ''}`
403
+ : `${critical} critical finding${critical !== 1 ? 's' : ''}`;
404
+ postCommitStatus({ sha: commitSha, state, description: desc, cwd });
405
+ }
406
+
407
+ // Final verdict — apply policy.failOn threshold
408
+ const failOn = options.failOn ?? policy.failOn ?? 'critical';
409
+ const exitCode = computeExitCode(result.allFindings, failOn);
410
+
308
411
  console.log('');
309
- if (result.status === 'pass') {
412
+ if (exitCode === 0 && result.status !== 'pass') {
413
+ const reason = failOn === 'none' ? ' (policy: fail-on=none)' : ` (policy: fail-on=${failOn})`;
414
+ console.log(fmt('yellow', `[run] ! Passed with findings${reason}\n`));
415
+ } else if (result.status === 'pass') {
310
416
  console.log(fmt('green', '[run] ✓ All phases passed\n'));
311
- return 0;
312
417
  } else if (result.status === 'warn') {
313
418
  console.log(fmt('yellow', '[run] ! Passed with warnings\n'));
314
- return 0;
315
419
  } else {
316
420
  console.log(fmt('red', '[run] ✗ Pipeline failed — see findings above\n'));
317
- return 1;
318
421
  }
422
+ return exitCode;
319
423
  }