@delegance/claude-autopilot 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.5.0] — 2026-04-22
4
+
5
+ ### Added
6
+ - **Config schema validation** — `ignore:` and `reviewStrategy: diff|auto-diff` now accepted; unknown keys reported as `unexpected key "<name>"`; enum errors list allowed values; error message includes up to 5 violations with field paths
7
+ - **`autopilot fix`** — reads `.autopilot-cache/findings.json`, asks the configured LLM to rewrite the ±20 lines around each finding, applies patches in place; `--severity critical|warning|all` (default: critical); `--dry-run` previews without writing; exits 1 if any fix fails
8
+ - **`autopilot costs`** — prints all-time run count + spend, 7-day summary, and a last-10-runs table (date, files, tokens in/out, cost, duration)
9
+ - `src/cli/fix.ts` — `runFix()`; sends numbered context window to LLM with fix instructions; strips markdown fences from response; handles `CANNOT_FIX` sentinel gracefully
10
+ - `src/cli/costs.ts` — `runCosts()` reading `.autopilot-cache/costs.jsonl`
11
+ - 9 new tests — **266 total**
12
+
3
13
  ## [2.4.0] — 2026-04-22
4
14
 
5
15
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delegance/claude-autopilot",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "description": "Claude Code automation pipeline: spec → plan → implement → validate → PR",
6
6
  "keywords": [
@@ -0,0 +1,80 @@
1
+ import { readCostLog } from '../core/persist/cost-log.ts';
2
+ import type { CostLogEntry } from '../core/persist/cost-log.ts';
3
+
4
+ const C = {
5
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
6
+ green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m',
7
+ };
8
+ const fmt = (c: keyof typeof C, t: string) => `${C[c]}${t}${C.reset}`;
9
+
10
+ function formatDate(iso: string): string {
11
+ try {
12
+ const d = new Date(iso);
13
+ return `${d.toLocaleDateString()} ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
14
+ } catch { return iso; }
15
+ }
16
+
17
+ function fmtUSD(n: number): string {
18
+ return n === 0 ? fmt('dim', '$0.0000') : `$${n.toFixed(4)}`;
19
+ }
20
+
21
+ function fmtTokens(n: number): string {
22
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
23
+ }
24
+
25
+ export async function runCosts(cwd = process.cwd()): Promise<number> {
26
+ const log = readCostLog(cwd);
27
+
28
+ if (log.length === 0) {
29
+ console.log(fmt('yellow', '[costs] No run history found — run `autopilot run` first.'));
30
+ return 0;
31
+ }
32
+
33
+ // 7-day window
34
+ const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
35
+ const recent = log.filter(e => new Date(e.timestamp).getTime() >= sevenDaysAgo);
36
+ const last10 = log.slice(-10).reverse();
37
+
38
+ const totalCost = log.reduce((s, e) => s + e.costUSD, 0);
39
+ const totalInput = log.reduce((s, e) => s + e.inputTokens, 0);
40
+ const totalOutput = log.reduce((s, e) => s + e.outputTokens, 0);
41
+ const recentCost = recent.reduce((s, e) => s + e.costUSD, 0);
42
+
43
+ console.log(`\n${fmt('bold', '[autopilot costs]')}\n`);
44
+
45
+ // Summary row
46
+ console.log(fmt('bold', 'Summary'));
47
+ console.log(` All-time runs: ${log.length}`);
48
+ console.log(` All-time cost: ${fmtUSD(totalCost)} (${fmtTokens(totalInput)} in / ${fmtTokens(totalOutput)} out)`);
49
+ console.log(` Last 7 days: ${fmtUSD(recentCost)} (${recent.length} run${recent.length !== 1 ? 's' : ''})`);
50
+ console.log('');
51
+
52
+ // Last 10 runs table
53
+ console.log(fmt('bold', `Recent runs (last ${last10.length})`));
54
+ const COL = { date: 22, files: 7, input: 8, output: 8, cost: 10, dur: 8 };
55
+ const header = [
56
+ 'Date'.padEnd(COL.date),
57
+ 'Files'.padStart(COL.files),
58
+ 'In tok'.padStart(COL.input),
59
+ 'Out tok'.padStart(COL.output),
60
+ 'Cost'.padStart(COL.cost),
61
+ 'Time'.padStart(COL.dur),
62
+ ].join(' ');
63
+ console.log(fmt('dim', ' ' + header));
64
+ console.log(fmt('dim', ' ' + '─'.repeat(header.length)));
65
+
66
+ for (const e of last10) {
67
+ const row = [
68
+ formatDate(e.timestamp).padEnd(COL.date),
69
+ String(e.files).padStart(COL.files),
70
+ fmtTokens(e.inputTokens).padStart(COL.input),
71
+ fmtTokens(e.outputTokens).padStart(COL.output),
72
+ fmtUSD(e.costUSD).padStart(COL.cost + 9), // +9 for ANSI codes in dim
73
+ `${e.durationMs}ms`.padStart(COL.dur),
74
+ ].join(' ');
75
+ console.log(' ' + row);
76
+ }
77
+
78
+ console.log('');
79
+ return 0;
80
+ }
package/src/cli/fix.ts ADDED
@@ -0,0 +1,187 @@
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 { 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 type { Finding } from '../core/findings/types.ts';
8
+
9
+ const C = {
10
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
11
+ green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', cyan: '\x1b[36m',
12
+ };
13
+ const fmt = (c: keyof typeof C, t: string) => `${C[c]}${t}${C.reset}`;
14
+
15
+ const CONTEXT_LINES = 20; // lines of file context to send on each side of the finding
16
+
17
+ export interface FixCommandOptions {
18
+ cwd?: string;
19
+ configPath?: string;
20
+ severity?: 'critical' | 'warning' | 'all'; // which findings to fix (default: critical)
21
+ dryRun?: boolean;
22
+ }
23
+
24
+ interface FixResult {
25
+ file: string;
26
+ line: number;
27
+ findingMessage: string;
28
+ status: 'fixed' | 'skipped' | 'failed';
29
+ reason?: string;
30
+ }
31
+
32
+ export async function runFix(options: FixCommandOptions = {}): Promise<number> {
33
+ const cwd = options.cwd ?? process.cwd();
34
+ const configPath = options.configPath ?? path.join(cwd, 'autopilot.config.yaml');
35
+ const severityFilter = options.severity ?? 'critical';
36
+
37
+ if (!fs.existsSync(configPath)) {
38
+ console.error(fmt('red', `[fix] autopilot.config.yaml not found at ${configPath}`));
39
+ return 1;
40
+ }
41
+
42
+ const findings = loadCachedFindings(cwd);
43
+ if (findings.length === 0) {
44
+ console.log(fmt('yellow', '[fix] No cached findings — run `autopilot run` first.'));
45
+ return 0;
46
+ }
47
+
48
+ const fixable = findings.filter(f => {
49
+ if (!f.line || !f.file || f.file === '<unspecified>' || f.file === '<pipeline>') return false;
50
+ if (severityFilter === 'all') return true;
51
+ if (severityFilter === 'critical') return f.severity === 'critical';
52
+ return f.severity === 'critical' || f.severity === 'warning';
53
+ });
54
+
55
+ if (fixable.length === 0) {
56
+ console.log(fmt('yellow', `[fix] No fixable findings (severity=${severityFilter}, need file+line).`));
57
+ return 0;
58
+ }
59
+
60
+ console.log(`\n${fmt('bold', '[autopilot fix]')} ${fixable.length} finding${fixable.length !== 1 ? 's' : ''} to attempt\n`);
61
+
62
+ // Load review engine
63
+ let engine: ReviewEngine;
64
+ try {
65
+ const config = await loadConfig(configPath);
66
+ const ref = typeof config.reviewEngine === 'string' ? config.reviewEngine
67
+ : (config.reviewEngine?.adapter ?? 'auto');
68
+ engine = await loadAdapter<ReviewEngine>({
69
+ point: 'review-engine',
70
+ ref,
71
+ options: typeof config.reviewEngine === 'object' ? config.reviewEngine.options : undefined,
72
+ });
73
+ } catch (err) {
74
+ console.error(fmt('red', `[fix] Could not load review engine: ${err instanceof Error ? err.message : String(err)}`));
75
+ return 1;
76
+ }
77
+
78
+ const results: FixResult[] = [];
79
+
80
+ for (const finding of fixable) {
81
+ const result = await attemptFix(finding, engine, cwd, options.dryRun ?? false);
82
+ results.push(result);
83
+ const icon = result.status === 'fixed' ? fmt('green', '✓')
84
+ : result.status === 'skipped' ? fmt('dim', '–')
85
+ : fmt('red', '✗');
86
+ const loc = `${result.file}:${result.line}`;
87
+ console.log(` ${icon} ${loc.padEnd(40)} ${result.findingMessage.slice(0, 60)}`);
88
+ if (result.reason) console.log(fmt('dim', ` ${result.reason}`));
89
+ }
90
+
91
+ const fixed = results.filter(r => r.status === 'fixed').length;
92
+ const failed = results.filter(r => r.status === 'failed').length;
93
+ console.log('');
94
+ if (options.dryRun) {
95
+ console.log(fmt('yellow', `[fix] Dry run — no files modified. ${fixable.length} finding${fixable.length !== 1 ? 's' : ''} would be attempted.\n`));
96
+ } else {
97
+ console.log(fmt('green', `[fix] ${fixed} fixed`) + fmt('dim', `, ${failed} failed, ${results.length - fixed - failed} skipped\n`));
98
+ }
99
+ return failed > 0 ? 1 : 0;
100
+ }
101
+
102
+ async function attemptFix(
103
+ finding: Finding,
104
+ engine: ReviewEngine,
105
+ cwd: string,
106
+ dryRun: boolean,
107
+ ): Promise<FixResult> {
108
+ const base: FixResult = { file: finding.file, line: finding.line!, findingMessage: finding.message, status: 'skipped' };
109
+
110
+ const absPath = path.resolve(cwd, finding.file);
111
+ let fileContent: string;
112
+ try {
113
+ fileContent = fs.readFileSync(absPath, 'utf8');
114
+ } catch {
115
+ return { ...base, status: 'skipped', reason: 'file not readable' };
116
+ }
117
+
118
+ const lines = fileContent.split('\n');
119
+ const lineIdx = finding.line! - 1;
120
+ if (lineIdx < 0 || lineIdx >= lines.length) {
121
+ return { ...base, status: 'skipped', reason: 'line out of range' };
122
+ }
123
+
124
+ const startIdx = Math.max(0, lineIdx - CONTEXT_LINES);
125
+ const endIdx = Math.min(lines.length - 1, lineIdx + CONTEXT_LINES);
126
+ const contextLines = lines.slice(startIdx, endIdx + 1);
127
+ const startLine = startIdx + 1;
128
+
129
+ const numbered = contextLines.map((l, i) => {
130
+ const n = startLine + i;
131
+ const marker = n === finding.line ? '>>>' : ' ';
132
+ return `${marker} ${String(n).padStart(4)}: ${l}`;
133
+ }).join('\n');
134
+
135
+ const prompt = [
136
+ `File: ${finding.file}`,
137
+ `Finding (line ${finding.line}): [${finding.severity.toUpperCase()}] ${finding.message}`,
138
+ finding.suggestion ? `Suggestion: ${finding.suggestion}` : '',
139
+ '',
140
+ 'Here are the relevant lines (>>> marks the finding):',
141
+ '```',
142
+ numbered,
143
+ '```',
144
+ '',
145
+ `Rewrite ONLY lines ${startLine}–${endIdx + 1} to fix this finding.`,
146
+ 'Rules:',
147
+ '- Output ONLY the replacement lines, no explanation, no markdown fences',
148
+ '- Preserve indentation and line count as much as possible',
149
+ '- Make the minimal change needed to fix the finding',
150
+ '- If the fix cannot be done safely in this context, output exactly: CANNOT_FIX',
151
+ ].filter(Boolean).join('\n');
152
+
153
+ let rawOutput: string;
154
+ try {
155
+ const output = await engine.review({ content: prompt, kind: 'file-batch' });
156
+ rawOutput = output.rawOutput.trim();
157
+ } catch (err) {
158
+ return { ...base, status: 'failed', reason: `LLM error: ${err instanceof Error ? err.message : String(err)}` };
159
+ }
160
+
161
+ if (rawOutput === 'CANNOT_FIX' || rawOutput.includes('CANNOT_FIX')) {
162
+ return { ...base, status: 'skipped', reason: 'LLM: cannot fix safely' };
163
+ }
164
+
165
+ // Strip markdown fences if the model added them despite instructions
166
+ const cleaned = rawOutput.replace(/^```[a-z]*\n?/m, '').replace(/\n?```$/m, '').trimEnd();
167
+ const replacementLines = cleaned.split('\n');
168
+
169
+ if (dryRun) {
170
+ return { ...base, status: 'fixed', reason: `(dry run) would replace lines ${startLine}–${endIdx + 1}` };
171
+ }
172
+
173
+ // Splice replacement into file
174
+ const newLines = [
175
+ ...lines.slice(0, startIdx),
176
+ ...replacementLines,
177
+ ...lines.slice(endIdx + 1),
178
+ ];
179
+
180
+ try {
181
+ fs.writeFileSync(absPath, newLines.join('\n'), 'utf8');
182
+ } catch (err) {
183
+ return { ...base, status: 'failed', reason: `write error: ${err instanceof Error ? err.message : String(err)}` };
184
+ }
185
+
186
+ return { ...base, status: 'fixed' };
187
+ }
package/src/cli/index.ts CHANGED
@@ -15,6 +15,7 @@ import { runWatch } from './watch.ts';
15
15
  import { runSetup } from './setup.ts';
16
16
  import { runDoctor } from './preflight.ts';
17
17
  import { runCi } from './ci.ts';
18
+ import { runFix } from './fix.ts';
18
19
 
19
20
  const args = process.argv.slice(2);
20
21
 
@@ -28,7 +29,7 @@ if (args[0] === '--version' || args[0] === '-v') {
28
29
  process.exit(0);
29
30
  }
30
31
 
31
- const SUBCOMMANDS = ['init', 'run', 'ci', 'watch', 'hook', 'autoregress', 'doctor', 'preflight', 'setup', 'help', '--help', '-h'] as const;
32
+ const SUBCOMMANDS = ['init', 'run', 'ci', 'fix', 'costs', 'watch', 'hook', 'autoregress', 'doctor', 'preflight', 'setup', 'help', '--help', '-h'] as const;
32
33
  const VALUE_FLAGS = ['base', 'config', 'files', 'format', 'output', 'debounce'];
33
34
 
34
35
  // Detect first non-flag arg as subcommand, default to 'run'
@@ -73,6 +74,14 @@ Options (run):
73
74
  --format <text|sarif> Output format (default: text)
74
75
  --output <path> Output file path (required with --format sarif)
75
76
 
77
+ fix Auto-fix cached findings using the configured LLM
78
+ costs Show per-run cost summary from .autopilot-cache/costs.jsonl
79
+
80
+ Options (fix):
81
+ --severity <critical|warning|all> Which findings to fix (default: critical)
82
+ --dry-run Preview fixes without writing files
83
+ --config <path> Path to config file
84
+
76
85
  Options (watch):
77
86
  --config <path> Path to config file (default: ./autopilot.config.yaml)
78
87
  --debounce <ms> Debounce delay in ms (default: 300)
@@ -190,6 +199,30 @@ switch (subcommand) {
190
199
  break;
191
200
  }
192
201
 
202
+ case 'fix': {
203
+ const config = flag('config');
204
+ const severityArg = flag('severity');
205
+ if (severityArg && !['critical', 'warning', 'all'].includes(severityArg)) {
206
+ console.error(`\x1b[31m[autopilot] --severity must be "critical", "warning", or "all"\x1b[0m`);
207
+ process.exit(1);
208
+ }
209
+ const dryRun = boolFlag('dry-run');
210
+ const code = await runFix({
211
+ configPath: config,
212
+ severity: severityArg as 'critical' | 'warning' | 'all' | undefined,
213
+ dryRun,
214
+ });
215
+ process.exit(code);
216
+ break;
217
+ }
218
+
219
+ case 'costs': {
220
+ const { runCosts } = await import('./costs.ts');
221
+ const code = await runCosts();
222
+ process.exit(code);
223
+ break;
224
+ }
225
+
193
226
  case 'setup': {
194
227
  const force = args.includes('--force');
195
228
  await runSetup({ force });
@@ -30,11 +30,23 @@ export async function loadConfig(path: string): Promise<AutopilotConfig> {
30
30
  }
31
31
 
32
32
  if (!validate(parsed)) {
33
- const errors = (validate.errors ?? []).map(e => `${e.instancePath || '<root>'}: ${e.message}`);
34
- throw new AutopilotError('Config schema validation failed', {
35
- code: 'invalid_config',
36
- details: { path, errors },
33
+ const errors = (validate.errors ?? []).map(e => {
34
+ const loc = e.instancePath ? e.instancePath.replace(/^\//, '').replace(/\//g, '.') : '<root>';
35
+ // enum errors: list allowed values
36
+ if (e.keyword === 'enum' && Array.isArray(e.params?.allowedValues)) {
37
+ return `${loc}: must be one of ${(e.params.allowedValues as unknown[]).map(v => JSON.stringify(v)).join(', ')}`;
38
+ }
39
+ // additionalProperties: name the unexpected key
40
+ if (e.keyword === 'additionalProperties' && e.params?.additionalProperty) {
41
+ return `${loc}: unexpected key "${e.params.additionalProperty as string}"`;
42
+ }
43
+ return `${loc}: ${e.message ?? 'invalid'}`;
37
44
  });
45
+ const summary = errors.slice(0, 5).join('\n ');
46
+ throw new AutopilotError(
47
+ `autopilot.config.yaml is invalid:\n ${summary}${errors.length > 5 ? `\n …and ${errors.length - 5} more` : ''}`,
48
+ { code: 'invalid_config', details: { path, errors } },
49
+ );
38
50
  }
39
51
 
40
52
  return parsed as AutopilotConfig;
@@ -35,7 +35,24 @@ export const AUTOPILOT_CONFIG_SCHEMA = {
35
35
  },
36
36
  additionalProperties: false,
37
37
  },
38
- reviewStrategy: { enum: ['auto', 'single-pass', 'file-level'] },
38
+ ignore: {
39
+ type: 'array',
40
+ items: {
41
+ oneOf: [
42
+ { type: 'string' },
43
+ {
44
+ type: 'object',
45
+ required: ['path'],
46
+ properties: {
47
+ rule: { type: 'string' },
48
+ path: { type: 'string' },
49
+ },
50
+ additionalProperties: false,
51
+ },
52
+ ],
53
+ },
54
+ },
55
+ reviewStrategy: { enum: ['auto', 'single-pass', 'file-level', 'diff', 'auto-diff'] },
39
56
  chunking: {
40
57
  type: 'object',
41
58
  properties: {