@delegance/claude-autopilot 2.3.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 +21 -0
- package/package.json +1 -1
- package/src/cli/ci.ts +2 -0
- package/src/cli/costs.ts +80 -0
- package/src/cli/fix.ts +187 -0
- package/src/cli/index.ts +39 -1
- package/src/cli/pr-review-comments.ts +92 -0
- package/src/cli/run.ts +34 -5
- package/src/core/chunking/index.ts +10 -2
- package/src/core/config/loader.ts +16 -4
- package/src/core/config/schema.ts +18 -1
- package/src/core/config/types.ts +2 -1
- package/src/core/ignore/index.ts +12 -0
- package/src/core/persist/cost-log.ts +30 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
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
|
+
|
|
13
|
+
## [2.4.0] — 2026-04-22
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **`ignore:` config key** — embed suppression rules in `autopilot.config.yaml` via `ignore: ['tests/**', { rule: hardcoded-secrets, path: src/vendor/** }]`; merged with `.autopilot-ignore` file rules at run time
|
|
17
|
+
- **Per-run cost log** — appends `{timestamp, files, inputTokens, outputTokens, costUSD, durationMs}` to `.autopilot-cache/costs.jsonl` after every run; corrupt lines skipped on read; `readCostLog()` exported for tooling
|
|
18
|
+
- **`--inline-comments`** — posts a GitHub PR review with per-line inline comments for every finding that has a `file:line`; re-runs dismiss the previous autopilot review before posting a new one; `autopilot ci` enables this by default (`--no-inline-comments` to opt out)
|
|
19
|
+
- **`reviewStrategy: auto-diff`** — tries diff first, falls back to full-file `auto` when diff is empty (new files, no git history); `--diff` flag still forces pure diff mode
|
|
20
|
+
- `src/cli/pr-review-comments.ts` — `postReviewComments()` using `gh api repos/{nwo}/pulls/{pr}/reviews`
|
|
21
|
+
- `src/core/persist/cost-log.ts` — `appendCostLog()`, `readCostLog()`
|
|
22
|
+
- 9 new tests — **257 total**
|
|
23
|
+
|
|
3
24
|
## [2.3.0] — 2026-04-22
|
|
4
25
|
|
|
5
26
|
### Added
|
package/package.json
CHANGED
package/src/cli/ci.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface CiCommandOptions {
|
|
|
7
7
|
postComments?: boolean;
|
|
8
8
|
sarifOutput?: string;
|
|
9
9
|
diff?: boolean;
|
|
10
|
+
inlineComments?: boolean;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -36,5 +37,6 @@ export async function runCi(options: CiCommandOptions = {}): Promise<number> {
|
|
|
36
37
|
format: 'sarif',
|
|
37
38
|
outputPath: sarifOutput,
|
|
38
39
|
diff: options.diff,
|
|
40
|
+
inlineComments: options.inlineComments ?? true,
|
|
39
41
|
});
|
|
40
42
|
}
|
package/src/cli/costs.ts
ADDED
|
@@ -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'
|
|
@@ -68,10 +69,19 @@ Options (run):
|
|
|
68
69
|
--dry-run Show what would run without executing
|
|
69
70
|
--diff Send git diff hunks instead of full files (~70% fewer tokens)
|
|
70
71
|
--delta Only report findings new since last run (suppress pre-existing)
|
|
72
|
+
--inline-comments Post per-line review comments on the PR diff
|
|
71
73
|
--post-comments Post/update a summary comment on the open PR
|
|
72
74
|
--format <text|sarif> Output format (default: text)
|
|
73
75
|
--output <path> Output file path (required with --format sarif)
|
|
74
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
|
+
|
|
75
85
|
Options (watch):
|
|
76
86
|
--config <path> Path to config file (default: ./autopilot.config.yaml)
|
|
77
87
|
--debounce <ms> Debounce delay in ms (default: 300)
|
|
@@ -124,6 +134,7 @@ switch (subcommand) {
|
|
|
124
134
|
const dryRun = boolFlag('dry-run');
|
|
125
135
|
const diff = boolFlag('diff');
|
|
126
136
|
const delta = boolFlag('delta');
|
|
137
|
+
const inlineComments = boolFlag('inline-comments');
|
|
127
138
|
const postComments = boolFlag('post-comments');
|
|
128
139
|
const formatArg = flag('format');
|
|
129
140
|
const outputPath = flag('output');
|
|
@@ -144,6 +155,7 @@ switch (subcommand) {
|
|
|
144
155
|
dryRun,
|
|
145
156
|
diff,
|
|
146
157
|
delta,
|
|
158
|
+
inlineComments,
|
|
147
159
|
postComments,
|
|
148
160
|
format: formatArg as 'text' | 'sarif' | undefined,
|
|
149
161
|
outputPath,
|
|
@@ -157,12 +169,14 @@ switch (subcommand) {
|
|
|
157
169
|
const config = flag('config');
|
|
158
170
|
const outputPath = flag('output');
|
|
159
171
|
const noPostComments = boolFlag('no-post-comments');
|
|
172
|
+
const noInlineComments = boolFlag('no-inline-comments');
|
|
160
173
|
const diff = boolFlag('diff');
|
|
161
174
|
const code = await runCi({
|
|
162
175
|
configPath: config,
|
|
163
176
|
base,
|
|
164
177
|
sarifOutput: outputPath,
|
|
165
178
|
postComments: noPostComments ? false : undefined,
|
|
179
|
+
inlineComments: noInlineComments ? false : undefined,
|
|
166
180
|
diff,
|
|
167
181
|
});
|
|
168
182
|
process.exit(code);
|
|
@@ -185,6 +199,30 @@ switch (subcommand) {
|
|
|
185
199
|
break;
|
|
186
200
|
}
|
|
187
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
|
+
|
|
188
226
|
case 'setup': {
|
|
189
227
|
const force = args.includes('--force');
|
|
190
228
|
await runSetup({ force });
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { runSafe } from '../core/shell.ts';
|
|
2
|
+
import type { Finding } from '../core/findings/types.ts';
|
|
3
|
+
|
|
4
|
+
const REVIEW_MARKER = '<!-- autopilot-inline -->';
|
|
5
|
+
|
|
6
|
+
function getRepoNwo(cwd: string): string | null {
|
|
7
|
+
const raw = runSafe('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], { cwd });
|
|
8
|
+
return raw ? raw.trim() : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** True when a review with our marker already exists on this PR (avoids duplicates on re-runs). */
|
|
12
|
+
function findExistingReviewId(pr: number, nwo: string, cwd: string): number | null {
|
|
13
|
+
const raw = runSafe('gh', [
|
|
14
|
+
'api', `repos/${nwo}/pulls/${pr}/reviews`,
|
|
15
|
+
'--jq', `[.[] | select(.body | startswith("${REVIEW_MARKER}")) | .id] | first`,
|
|
16
|
+
], { cwd });
|
|
17
|
+
if (!raw) return null;
|
|
18
|
+
const n = parseInt(raw.trim(), 10);
|
|
19
|
+
return isNaN(n) ? null : n;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PostReviewCommentsResult {
|
|
23
|
+
posted: number;
|
|
24
|
+
skipped: number; // findings with no line number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Posts (or re-submits) a PR review with inline comments for each finding
|
|
29
|
+
* that has a file + line number. Findings without line numbers are skipped.
|
|
30
|
+
* Re-runs dismiss the previous autopilot review first to avoid stacking.
|
|
31
|
+
*/
|
|
32
|
+
export async function postReviewComments(
|
|
33
|
+
pr: number,
|
|
34
|
+
findings: Finding[],
|
|
35
|
+
cwd: string,
|
|
36
|
+
): Promise<PostReviewCommentsResult> {
|
|
37
|
+
const nwo = getRepoNwo(cwd);
|
|
38
|
+
if (!nwo) throw new Error('Could not determine repository name — is gh authenticated?');
|
|
39
|
+
|
|
40
|
+
const commentable = findings.filter(
|
|
41
|
+
f => f.line !== undefined && f.file && f.file !== '<unspecified>' && f.file !== '<pipeline>',
|
|
42
|
+
);
|
|
43
|
+
const skipped = findings.length - commentable.length;
|
|
44
|
+
|
|
45
|
+
if (commentable.length === 0) return { posted: 0, skipped };
|
|
46
|
+
|
|
47
|
+
// Dismiss existing review so we don't stack on re-runs
|
|
48
|
+
const existingId = findExistingReviewId(pr, nwo, cwd);
|
|
49
|
+
if (existingId) {
|
|
50
|
+
runSafe('gh', [
|
|
51
|
+
'api', `repos/${nwo}/pulls/${pr}/reviews/${existingId}/dismissals`,
|
|
52
|
+
'--method', 'PUT',
|
|
53
|
+
'--field', 'message=Superseded by updated autopilot review',
|
|
54
|
+
], { cwd });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Build review body
|
|
58
|
+
const body = [
|
|
59
|
+
REVIEW_MARKER,
|
|
60
|
+
`**Autopilot** found ${commentable.length} inline finding${commentable.length !== 1 ? 's' : ''}.`,
|
|
61
|
+
].join('\n');
|
|
62
|
+
|
|
63
|
+
// Build comments array as JSON
|
|
64
|
+
const comments = commentable.map(f => ({
|
|
65
|
+
path: f.file,
|
|
66
|
+
line: f.line,
|
|
67
|
+
side: 'RIGHT',
|
|
68
|
+
body: formatFindingBody(f),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
// gh api doesn't support array fields well via --field, use --input with JSON
|
|
72
|
+
const payload = JSON.stringify({ body, event: 'COMMENT', comments });
|
|
73
|
+
const result = runSafe('gh', [
|
|
74
|
+
'api', `repos/${nwo}/pulls/${pr}/reviews`,
|
|
75
|
+
'--method', 'POST',
|
|
76
|
+
'--input', '-',
|
|
77
|
+
], { cwd, input: payload });
|
|
78
|
+
|
|
79
|
+
if (!result) throw new Error('Failed to post review — gh api returned no output');
|
|
80
|
+
|
|
81
|
+
return { posted: commentable.length, skipped };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatFindingBody(f: Finding): string {
|
|
85
|
+
const sev = f.severity === 'critical' ? '🚨 **CRITICAL**'
|
|
86
|
+
: f.severity === 'warning' ? '⚠️ **Warning**'
|
|
87
|
+
: '💡 **Note**';
|
|
88
|
+
const lines = [`${sev} — ${f.message}`];
|
|
89
|
+
if (f.suggestion) lines.push(`\n> **Suggestion:** ${f.suggestion}`);
|
|
90
|
+
lines.push(`\n*[@delegance/claude-autopilot](https://github.com/axledbetter/claude-autopilot)*`);
|
|
91
|
+
return lines.join('');
|
|
92
|
+
}
|
package/src/cli/run.ts
CHANGED
|
@@ -38,8 +38,10 @@ import { detectProtectedPaths } from '../core/detect/protected-paths.ts';
|
|
|
38
38
|
import { detectGitContext } from '../core/detect/git-context.ts';
|
|
39
39
|
import { detectProject } from './detector.ts';
|
|
40
40
|
import { detectPrNumber, formatComment, postPrComment } from './pr-comment.ts';
|
|
41
|
-
import {
|
|
41
|
+
import { postReviewComments } from './pr-review-comments.ts';
|
|
42
|
+
import { loadIgnoreRules, parseConfigIgnore, applyIgnoreRules } from '../core/ignore/index.ts';
|
|
42
43
|
import { loadCachedFindings, saveCachedFindings, filterNewFindings } from '../core/persist/findings-cache.ts';
|
|
44
|
+
import { appendCostLog } from '../core/persist/cost-log.ts';
|
|
43
45
|
|
|
44
46
|
function readToolVersion(): string {
|
|
45
47
|
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
@@ -66,8 +68,9 @@ export interface RunCommandOptions {
|
|
|
66
68
|
base?: string; // git base ref (default HEAD~1)
|
|
67
69
|
files?: string[]; // explicit file list (skips git detection)
|
|
68
70
|
dryRun?: boolean; // skip review, print what would run
|
|
69
|
-
diff?: boolean;
|
|
70
|
-
delta?: boolean;
|
|
71
|
+
diff?: boolean; // use diff strategy (send git hunks instead of full files)
|
|
72
|
+
delta?: boolean; // only report findings not present in last run's baseline
|
|
73
|
+
inlineComments?: boolean; // post per-line review comments on the PR diff
|
|
71
74
|
format?: 'text' | 'sarif';
|
|
72
75
|
outputPath?: string;
|
|
73
76
|
postComments?: boolean; // post/update summary comment on the open PR
|
|
@@ -190,8 +193,8 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
190
193
|
console.log('');
|
|
191
194
|
const result = await runAutopilot(input);
|
|
192
195
|
|
|
193
|
-
// Apply .autopilot-ignore
|
|
194
|
-
const ignoreRules = loadIgnoreRules(cwd);
|
|
196
|
+
// Apply .autopilot-ignore + config ignore: rules
|
|
197
|
+
const ignoreRules = [...loadIgnoreRules(cwd), ...parseConfigIgnore(config.ignore)];
|
|
195
198
|
if (ignoreRules.length > 0) {
|
|
196
199
|
const before = result.allFindings.length;
|
|
197
200
|
result.allFindings = applyIgnoreRules(result.allFindings, ignoreRules);
|
|
@@ -220,6 +223,17 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
220
223
|
// Always persist the unfiltered findings as the new baseline
|
|
221
224
|
saveCachedFindings(cwd, result.allFindings);
|
|
222
225
|
|
|
226
|
+
// Append to per-run cost log
|
|
227
|
+
const reviewPhase = result.phases.find(p => p.phase === 'review') as { usage?: { input: number; output: number } } | undefined;
|
|
228
|
+
appendCostLog(cwd, {
|
|
229
|
+
timestamp: new Date().toISOString(),
|
|
230
|
+
files: touchedFiles.length,
|
|
231
|
+
inputTokens: reviewPhase?.usage?.input ?? 0,
|
|
232
|
+
outputTokens: reviewPhase?.usage?.output ?? 0,
|
|
233
|
+
costUSD: result.totalCostUSD ?? 0,
|
|
234
|
+
durationMs: result.durationMs,
|
|
235
|
+
});
|
|
236
|
+
|
|
223
237
|
// emitAnnotations is a no-op unless GITHUB_ACTIONS=true
|
|
224
238
|
emitAnnotations(result.allFindings);
|
|
225
239
|
|
|
@@ -231,6 +245,21 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
231
245
|
console.log(fmt('dim', `[run] SARIF written to ${options.outputPath}`));
|
|
232
246
|
}
|
|
233
247
|
|
|
248
|
+
// Post inline PR review comments if requested
|
|
249
|
+
if (options.inlineComments) {
|
|
250
|
+
const pr = detectPrNumber(cwd);
|
|
251
|
+
if (!pr) {
|
|
252
|
+
console.log(fmt('yellow', ' [run] --inline-comments: no open PR found — skipping'));
|
|
253
|
+
} else {
|
|
254
|
+
try {
|
|
255
|
+
const { posted, skipped } = await postReviewComments(pr, result.allFindings, cwd);
|
|
256
|
+
console.log(fmt('dim', ` [run] PR #${pr} inline review: ${posted} comment${posted !== 1 ? 's' : ''} posted${skipped > 0 ? `, ${skipped} skipped (no line number)` : ''}`));
|
|
257
|
+
} catch (err) {
|
|
258
|
+
console.error(fmt('yellow', ` [run] Failed to post inline comments: ${err instanceof Error ? err.message : String(err)}`));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
234
263
|
// Post PR comment if requested
|
|
235
264
|
if (options.postComments) {
|
|
236
265
|
const pr = detectPrNumber(cwd);
|
|
@@ -13,12 +13,12 @@ export interface ReviewChunk {
|
|
|
13
13
|
|
|
14
14
|
export interface BuildChunksInput {
|
|
15
15
|
touchedFiles: string[];
|
|
16
|
-
strategy: 'auto' | 'single-pass' | 'file-level' | 'diff';
|
|
16
|
+
strategy: 'auto' | 'single-pass' | 'file-level' | 'diff' | 'auto-diff';
|
|
17
17
|
chunking?: AutopilotConfig['chunking'];
|
|
18
18
|
engine: ReviewEngine;
|
|
19
19
|
cwd?: string;
|
|
20
20
|
protectedPaths?: string[];
|
|
21
|
-
base?: string; // git base ref — required for 'diff' strategy
|
|
21
|
+
base?: string; // git base ref — required for 'diff'/'auto-diff' strategy
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
const DEFAULT_SMALL_TIER_TOKENS = 8000;
|
|
@@ -33,6 +33,14 @@ export async function buildReviewChunks(input: BuildChunksInput): Promise<Review
|
|
|
33
33
|
return buildDiffChunks(input);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// auto-diff: try diff first; fall back to full-file auto if diff is empty
|
|
37
|
+
// (handles new files, initial commits, or repos with no base ref)
|
|
38
|
+
if (input.strategy === 'auto-diff') {
|
|
39
|
+
const diffChunks = buildDiffChunks(input);
|
|
40
|
+
if (diffChunks.length > 0) return diffChunks;
|
|
41
|
+
// fall through to auto with full files
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
const ranked = rankByRisk(input.touchedFiles, { protectedPaths: input.protectedPaths });
|
|
37
45
|
const fileContents = await readFiles(ranked, input.cwd);
|
|
38
46
|
|
|
@@ -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 =>
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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: {
|
package/src/core/config/types.ts
CHANGED
|
@@ -27,7 +27,8 @@ export interface AutopilotConfig {
|
|
|
27
27
|
maxCodexRetries?: number;
|
|
28
28
|
maxBugbotRounds?: number;
|
|
29
29
|
};
|
|
30
|
-
|
|
30
|
+
ignore?: Array<string | { rule?: string; path: string }>;
|
|
31
|
+
reviewStrategy?: 'auto' | 'single-pass' | 'file-level' | 'diff' | 'auto-diff';
|
|
31
32
|
chunking?: {
|
|
32
33
|
smallTierMaxTokens?: number;
|
|
33
34
|
partialReviewTokens?: number;
|
package/src/core/ignore/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { minimatch } from 'minimatch';
|
|
4
4
|
import type { Finding } from '../findings/types.ts';
|
|
5
|
+
import type { AutopilotConfig } from '../config/types.ts';
|
|
5
6
|
|
|
6
7
|
export interface IgnoreRule {
|
|
7
8
|
ruleId: string | '*'; // finding id prefix or '*' for any
|
|
@@ -36,6 +37,17 @@ function matchesRule(finding: Finding, rule: IgnoreRule): boolean {
|
|
|
36
37
|
return minimatch(finding.file.replace(/\\/g, '/'), rule.pathGlob, { matchBase: true });
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/** Convert `ignore:` entries from autopilot.config.yaml into IgnoreRules. */
|
|
41
|
+
export function parseConfigIgnore(entries: AutopilotConfig['ignore']): IgnoreRule[] {
|
|
42
|
+
if (!entries || entries.length === 0) return [];
|
|
43
|
+
return entries.map(entry => {
|
|
44
|
+
if (typeof entry === 'string') {
|
|
45
|
+
return { ruleId: '*', pathGlob: entry };
|
|
46
|
+
}
|
|
47
|
+
return { ruleId: entry.rule ?? '*', pathGlob: entry.path };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
39
51
|
export function applyIgnoreRules(findings: Finding[], rules: IgnoreRule[]): Finding[] {
|
|
40
52
|
if (rules.length === 0) return findings;
|
|
41
53
|
return findings.filter(f => !rules.some(r => matchesRule(f, r)));
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const CACHE_DIR = '.autopilot-cache';
|
|
5
|
+
const LOG_FILE = 'costs.jsonl';
|
|
6
|
+
|
|
7
|
+
export interface CostLogEntry {
|
|
8
|
+
timestamp: string;
|
|
9
|
+
files: number;
|
|
10
|
+
inputTokens: number;
|
|
11
|
+
outputTokens: number;
|
|
12
|
+
costUSD: number;
|
|
13
|
+
durationMs: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function appendCostLog(cwd: string, entry: CostLogEntry): void {
|
|
17
|
+
const dir = path.join(cwd, CACHE_DIR);
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
fs.appendFileSync(path.join(dir, LOG_FILE), JSON.stringify(entry) + '\n', 'utf8');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function readCostLog(cwd: string): CostLogEntry[] {
|
|
23
|
+
const p = path.join(cwd, CACHE_DIR, LOG_FILE);
|
|
24
|
+
if (!fs.existsSync(p)) return [];
|
|
25
|
+
return fs.readFileSync(p, 'utf8')
|
|
26
|
+
.split('\n')
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.map(line => { try { return JSON.parse(line) as CostLogEntry; } catch { return null; } })
|
|
29
|
+
.filter((e): e is CostLogEntry => e !== null);
|
|
30
|
+
}
|