@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/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,26 +17,92 @@ 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;
37
23
  }
38
24
 
25
+ /**
26
+ * Checks that the superpowers plugin skills required by the pipeline are resolvable
27
+ * from the usual Claude Code plugin paths. Returns skill names that weren't found.
28
+ */
29
+ const REQUIRED_SUPERPOWERS_SKILLS = [
30
+ 'writing-plans',
31
+ 'using-git-worktrees',
32
+ 'subagent-driven-development',
33
+ ] as const;
34
+
35
+ function skillRoots(): string[] {
36
+ const home = process.env.HOME || process.env.USERPROFILE || '';
37
+ const cwd = process.cwd();
38
+ const roots: string[] = [];
39
+ // Project-local plugin install
40
+ roots.push(path.join(cwd, '.claude', 'plugins'));
41
+ // User-global plugin install
42
+ if (home) roots.push(path.join(home, '.claude', 'plugins'));
43
+ return roots.filter(p => fs.existsSync(p));
44
+ }
45
+
46
+ export function findMissingSuperpowersSkills(): string[] {
47
+ // Traverse each root once, collect all discovered skill names, then diff against
48
+ // the required set. Previous implementation did N × roots separate recursive walks.
49
+ const discovered = new Set<string>();
50
+ const MAX_DIRS_PER_ROOT = 2000; // safety cap to prevent pathological plugin trees
51
+
52
+ for (const root of skillRoots()) {
53
+ collectSkills(root, discovered, { visited: { n: 0 }, max: MAX_DIRS_PER_ROOT });
54
+ }
55
+
56
+ return REQUIRED_SUPERPOWERS_SKILLS.filter(s => !discovered.has(s));
57
+ }
58
+
59
+ // Walks up to 8 levels deep, capped at `max` directories total. When it finds a
60
+ // `skills/` directory, records every `<skill-name>/SKILL.md` and `<skill-name>.md`
61
+ // entry directly into the Set. Never revisits by name (Claude Code plugin caches
62
+ // can contain many parallel copies — we only care whether a skill exists *somewhere*).
63
+ function collectSkills(
64
+ dir: string,
65
+ out: Set<string>,
66
+ ctx: { visited: { n: number }; max: number },
67
+ depth = 0,
68
+ ): void {
69
+ if (depth > 8) return;
70
+ if (ctx.visited.n >= ctx.max) return;
71
+ ctx.visited.n++;
72
+
73
+ let entries: fs.Dirent[];
74
+ try {
75
+ entries = fs.readdirSync(dir, { withFileTypes: true });
76
+ } catch {
77
+ return;
78
+ }
79
+
80
+ // If this dir has a skills/ child, record every skill inside it
81
+ for (const entry of entries) {
82
+ if (entry.isDirectory() && entry.name === 'skills') {
83
+ const skillsDir = path.join(dir, 'skills');
84
+ try {
85
+ for (const skill of fs.readdirSync(skillsDir, { withFileTypes: true })) {
86
+ if (skill.isDirectory() && fs.existsSync(path.join(skillsDir, skill.name, 'SKILL.md'))) {
87
+ out.add(skill.name);
88
+ } else if (skill.isFile() && skill.name.endsWith('.md') && skill.name !== 'README.md') {
89
+ out.add(skill.name.slice(0, -3)); // strip .md
90
+ }
91
+ }
92
+ } catch { /* ignore */ }
93
+ }
94
+ }
95
+
96
+ // Recurse into non-skills dirs (bounded depth + visit cap prevent pathological scans)
97
+ for (const entry of entries) {
98
+ if (!entry.isDirectory()) continue;
99
+ if (entry.name.startsWith('.')) continue;
100
+ if (entry.name === 'node_modules') continue;
101
+ if (entry.name === 'skills') continue; // already handled above
102
+ collectSkills(path.join(dir, entry.name), out, ctx, depth + 1);
103
+ }
104
+ }
105
+
39
106
  export async function runDoctor(): Promise<DoctorResult> {
40
107
  const checks: Check[] = [];
41
108
 
@@ -56,7 +123,7 @@ export async function runDoctor(): Promise<DoctorResult> {
56
123
  checks.push({
57
124
  name: 'tsx available',
58
125
  result: tsxVersion ? 'pass' : 'fail',
59
- message: !tsxVersion ? 'tsx not found — run: npm install @delegance/claude-autopilot (includes tsx)' : undefined,
126
+ message: !tsxVersion ? 'tsx not found — run: npm install @delegance/guardrail (includes tsx)' : undefined,
60
127
  });
61
128
 
62
129
  // 3. gh CLI authenticated
@@ -67,13 +134,13 @@ export async function runDoctor(): Promise<DoctorResult> {
67
134
  message: ghAuth === null ? 'gh CLI not authenticated — run: gh auth login' : undefined,
68
135
  });
69
136
 
70
- // 4. autopilot.config.yaml in cwd
71
- const configYaml = path.join(process.cwd(), 'autopilot.config.yaml');
137
+ // 4. guardrail.config.yaml in cwd
138
+ const configYaml = path.join(process.cwd(), 'guardrail.config.yaml');
72
139
  checks.push({
73
- name: 'autopilot.config.yaml',
140
+ name: 'guardrail.config.yaml',
74
141
  result: fs.existsSync(configYaml) ? 'pass' : 'warn',
75
142
  message: !fs.existsSync(configYaml)
76
- ? 'autopilot.config.yaml not found in current directory — copy from a preset: presets/nextjs-supabase/autopilot.config.yaml'
143
+ ? 'guardrail.config.yaml not found in current directory — copy from a preset: presets/nextjs-supabase/guardrail.config.yaml'
77
144
  : undefined,
78
145
  });
79
146
 
@@ -83,21 +150,18 @@ export async function runDoctor(): Promise<DoctorResult> {
83
150
  name: `Local env file (${envFile ?? 'none found'})`,
84
151
  result: envFile ? 'pass' : 'warn',
85
152
  message: !envFile
86
- ? `No env file found. Looked for: ${ENV_CANDIDATES.join(', ')}. Create one with your OPENAI_API_KEY.`
153
+ ? `No env file found. Looked for: ${ENV_CANDIDATES.join(', ')}. Create one with one of: ${LLM_KEY_NAMES.join(', ')}.`
87
154
  : undefined,
88
155
  });
89
156
 
90
- // 6. LLM API key (ANTHROPIC_API_KEY preferred, OPENAI_API_KEY as fallback)
157
+ // 6. LLM API key shared detection with setup/scan/run (all 5 providers)
91
158
  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';
159
+ const { hasKey, preferred } = detectLLMKey({ extraEnv: envVars });
96
160
  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`
161
+ name: `LLM API key (${preferred ?? 'none'})`,
162
+ result: hasKey ? 'pass' : 'warn',
163
+ message: !hasKey
164
+ ? `No LLM API key found — set one of: ${LLM_KEY_NAMES.join(', ')} to enable review`
101
165
  : undefined,
102
166
  });
103
167
 
@@ -123,9 +187,22 @@ export async function runDoctor(): Promise<DoctorResult> {
123
187
  : undefined,
124
188
  });
125
189
 
190
+ // 9. Superpowers plugin — required for pipeline phases, optional for review-only use
191
+ const missingSkills = findMissingSuperpowersSkills();
192
+ const allSkillsFound = missingSkills.length === 0;
193
+ checks.push({
194
+ name: `Superpowers plugin${allSkillsFound ? '' : ` (missing: ${missingSkills.join(', ')})`}`,
195
+ // Treat as warn, not fail — users who only run `claude-autopilot run` (review phase)
196
+ // don't need superpowers. Pipeline invocations (`autopilot` skill) will hard-fail at
197
+ // their own entry point.
198
+ result: allSkillsFound ? 'pass' : 'warn',
199
+ message: !allSkillsFound
200
+ ? 'Install: `claude plugin install superpowers` (required for pipeline phases — brainstorm/plan/implement)'
201
+ : undefined,
202
+ });
126
203
 
127
204
  // Print results
128
- console.log('\n\x1b[1m[doctor] Autopilot prerequisite check\x1b[0m\n');
205
+ console.log('\n\x1b[1m[doctor] Guardrail prerequisite check\x1b[0m\n');
129
206
  let blockers = 0;
130
207
  let warnings = 0;
131
208
  for (const check of checks) {
@@ -140,7 +217,7 @@ export async function runDoctor(): Promise<DoctorResult> {
140
217
 
141
218
  console.log('');
142
219
  if (blockers > 0) {
143
- console.log(`\x1b[31m[doctor] ${blockers} blocker(s) — fix before running npx autopilot run\x1b[0m\n`);
220
+ console.log(`\x1b[31m[doctor] ${blockers} blocker(s) — fix before running npx guardrail run\x1b[0m\n`);
144
221
  } else if (warnings > 0) {
145
222
  console.log(`\x1b[33m[doctor] ${warnings} warning(s) — pipeline will run but some steps may be skipped\x1b[0m\n`);
146
223
  } 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
+ }