@delegance/claude-autopilot 2.0.0 → 2.2.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 +16 -0
- package/package.json +1 -1
- package/src/cli/ci.ts +2 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/run.ts +7 -0
- package/src/core/chunking/index.ts +25 -2
- package/src/core/chunking/risk-ranker.ts +56 -0
- package/src/core/config/types.ts +1 -1
- package/src/core/git/diff-hunks.ts +86 -0
- package/src/core/pipeline/review-phase.ts +3 -0
- package/src/core/pipeline/run.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.2.0] — 2026-04-22
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **`reviewStrategy: diff`** — new chunking strategy that sends `git diff` unified hunks instead of full file contents; typically ~70% fewer tokens and more focused findings (LLM sees exactly what changed)
|
|
7
|
+
- **`--diff` flag** on `run` and `ci` subcommands — shorthand to activate diff strategy without editing config
|
|
8
|
+
- **`src/core/git/diff-hunks.ts`** — `getFileDiffs()`, `parseUnifiedDiff()`, `formatDiffContent()`; per-file diff sections in fenced code blocks; files that exceed `maxChars` are omitted with a count notice
|
|
9
|
+
- `BuildChunksInput.base` / `ReviewPhaseInput.base` / `RunInput.base` — threads git base ref through pipeline to diff engine
|
|
10
|
+
- 9 new tests for `parseUnifiedDiff` and `formatDiffContent` — **233 total**
|
|
11
|
+
|
|
12
|
+
## [2.1.0] — 2026-04-22
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **Risk-weighted file ordering** (`src/core/chunking/risk-ranker.ts`) — ranks files before sending to LLM: protected paths (score 100) → auth/security (80) → payment/billing (70) → core logic (50) → config files (40) → everything else (30) → tests (10) → docs (5); ensures most sensitive code appears at the start of the LLM's context window
|
|
16
|
+
- `BuildChunksInput.protectedPaths` — passed from config through review-phase to ranker so glob patterns from `protectedPaths:` config key are respected
|
|
17
|
+
- 9 new tests for `rankByRisk` — **224 total**
|
|
18
|
+
|
|
3
19
|
## [2.0.0] — 2026-04-22
|
|
4
20
|
|
|
5
21
|
### Added
|
package/package.json
CHANGED
package/src/cli/ci.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface CiCommandOptions {
|
|
|
6
6
|
base?: string;
|
|
7
7
|
postComments?: boolean;
|
|
8
8
|
sarifOutput?: string;
|
|
9
|
+
diff?: boolean;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -34,5 +35,6 @@ export async function runCi(options: CiCommandOptions = {}): Promise<number> {
|
|
|
34
35
|
postComments: options.postComments ?? true,
|
|
35
36
|
format: 'sarif',
|
|
36
37
|
outputPath: sarifOutput,
|
|
38
|
+
diff: options.diff,
|
|
37
39
|
});
|
|
38
40
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -66,6 +66,7 @@ Options (run):
|
|
|
66
66
|
--config <path> Path to config file (default: ./autopilot.config.yaml)
|
|
67
67
|
--files <a,b,c> Explicit comma-separated file list (skips git detection)
|
|
68
68
|
--dry-run Show what would run without executing
|
|
69
|
+
--diff Send git diff hunks instead of full files (~70% fewer tokens)
|
|
69
70
|
--post-comments Post/update a summary comment on the open PR
|
|
70
71
|
--format <text|sarif> Output format (default: text)
|
|
71
72
|
--output <path> Output file path (required with --format sarif)
|
|
@@ -120,6 +121,7 @@ switch (subcommand) {
|
|
|
120
121
|
const config = flag('config');
|
|
121
122
|
const filesArg = flag('files');
|
|
122
123
|
const dryRun = boolFlag('dry-run');
|
|
124
|
+
const diff = boolFlag('diff');
|
|
123
125
|
const postComments = boolFlag('post-comments');
|
|
124
126
|
const formatArg = flag('format');
|
|
125
127
|
const outputPath = flag('output');
|
|
@@ -138,6 +140,7 @@ switch (subcommand) {
|
|
|
138
140
|
configPath: config,
|
|
139
141
|
files: filesArg ? filesArg.split(',').map(f => f.trim()) : undefined,
|
|
140
142
|
dryRun,
|
|
143
|
+
diff,
|
|
141
144
|
postComments,
|
|
142
145
|
format: formatArg as 'text' | 'sarif' | undefined,
|
|
143
146
|
outputPath,
|
|
@@ -151,11 +154,13 @@ switch (subcommand) {
|
|
|
151
154
|
const config = flag('config');
|
|
152
155
|
const outputPath = flag('output');
|
|
153
156
|
const noPostComments = boolFlag('no-post-comments');
|
|
157
|
+
const diff = boolFlag('diff');
|
|
154
158
|
const code = await runCi({
|
|
155
159
|
configPath: config,
|
|
156
160
|
base,
|
|
157
161
|
sarifOutput: outputPath,
|
|
158
162
|
postComments: noPostComments ? false : undefined,
|
|
163
|
+
diff,
|
|
159
164
|
});
|
|
160
165
|
process.exit(code);
|
|
161
166
|
break;
|
package/src/cli/run.ts
CHANGED
|
@@ -64,6 +64,7 @@ export interface RunCommandOptions {
|
|
|
64
64
|
base?: string; // git base ref (default HEAD~1)
|
|
65
65
|
files?: string[]; // explicit file list (skips git detection)
|
|
66
66
|
dryRun?: boolean; // skip review, print what would run
|
|
67
|
+
diff?: boolean; // use diff strategy (send git hunks instead of full files)
|
|
67
68
|
format?: 'text' | 'sarif';
|
|
68
69
|
outputPath?: string;
|
|
69
70
|
postComments?: boolean; // post/update summary comment on the open PR
|
|
@@ -167,6 +168,11 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
167
168
|
? await loadRulesFromConfig(config.staticRules)
|
|
168
169
|
: [];
|
|
169
170
|
|
|
171
|
+
// Apply --diff flag: override reviewStrategy to 'diff'
|
|
172
|
+
if (options.diff && config.reviewStrategy !== 'diff') {
|
|
173
|
+
config = { ...config, reviewStrategy: 'diff' };
|
|
174
|
+
}
|
|
175
|
+
|
|
170
176
|
// Execute pipeline
|
|
171
177
|
const input: RunInput = {
|
|
172
178
|
touchedFiles,
|
|
@@ -175,6 +181,7 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
175
181
|
staticRules,
|
|
176
182
|
cwd,
|
|
177
183
|
gitSummary: gitCtx.summary ?? undefined,
|
|
184
|
+
base: options.base,
|
|
178
185
|
};
|
|
179
186
|
|
|
180
187
|
console.log('');
|
|
@@ -2,6 +2,8 @@ import * as fs from 'node:fs/promises';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import type { ReviewEngine, ReviewInput } from '../../adapters/review-engine/types.ts';
|
|
4
4
|
import type { AutopilotConfig } from '../config/types.ts';
|
|
5
|
+
import { rankByRisk } from './risk-ranker.ts';
|
|
6
|
+
import { getFileDiffs, formatDiffContent } from '../git/diff-hunks.ts';
|
|
5
7
|
|
|
6
8
|
export interface ReviewChunk {
|
|
7
9
|
content: string;
|
|
@@ -11,10 +13,12 @@ export interface ReviewChunk {
|
|
|
11
13
|
|
|
12
14
|
export interface BuildChunksInput {
|
|
13
15
|
touchedFiles: string[];
|
|
14
|
-
strategy: 'auto' | 'single-pass' | 'file-level';
|
|
16
|
+
strategy: 'auto' | 'single-pass' | 'file-level' | 'diff';
|
|
15
17
|
chunking?: AutopilotConfig['chunking'];
|
|
16
18
|
engine: ReviewEngine;
|
|
17
19
|
cwd?: string;
|
|
20
|
+
protectedPaths?: string[];
|
|
21
|
+
base?: string; // git base ref — required for 'diff' strategy
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
const DEFAULT_SMALL_TIER_TOKENS = 8000;
|
|
@@ -24,7 +28,13 @@ export async function buildReviewChunks(input: BuildChunksInput): Promise<Review
|
|
|
24
28
|
const smallMax = input.chunking?.smallTierMaxTokens ?? DEFAULT_SMALL_TIER_TOKENS;
|
|
25
29
|
const fileMax = input.chunking?.perFileMaxTokens ?? DEFAULT_FILE_TIER_TOKENS;
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
// Diff strategy: send unified diff hunks instead of full file contents
|
|
32
|
+
if (input.strategy === 'diff') {
|
|
33
|
+
return buildDiffChunks(input);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ranked = rankByRisk(input.touchedFiles, { protectedPaths: input.protectedPaths });
|
|
37
|
+
const fileContents = await readFiles(ranked, input.cwd);
|
|
28
38
|
|
|
29
39
|
if (input.strategy === 'single-pass') {
|
|
30
40
|
const combined = formatBatch(fileContents);
|
|
@@ -48,6 +58,19 @@ export async function buildReviewChunks(input: BuildChunksInput): Promise<Review
|
|
|
48
58
|
return chunks;
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
function buildDiffChunks(input: BuildChunksInput): ReviewChunk[] {
|
|
62
|
+
const cwd = input.cwd ?? process.cwd();
|
|
63
|
+
const base = input.base ?? 'HEAD~1';
|
|
64
|
+
const ranked = rankByRisk(input.touchedFiles, { protectedPaths: input.protectedPaths });
|
|
65
|
+
const diffs = getFileDiffs(cwd, base, ranked);
|
|
66
|
+
|
|
67
|
+
if (diffs.length === 0) return [];
|
|
68
|
+
|
|
69
|
+
// Single chunk — diff content is already compact; truncation handled in formatDiffContent
|
|
70
|
+
const content = formatDiffContent(diffs);
|
|
71
|
+
return [{ content, kind: 'file-batch', files: diffs.map(d => d.file) }];
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
async function readFiles(touchedFiles: string[], cwd?: string): Promise<Map<string, string>> {
|
|
52
75
|
const result = new Map<string, string>();
|
|
53
76
|
for (const f of touchedFiles) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { minimatch } from 'minimatch';
|
|
2
|
+
|
|
3
|
+
interface RankOptions {
|
|
4
|
+
protectedPaths?: string[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const AUTH_PATTERNS = [
|
|
8
|
+
/auth/i, /login/i, /logout/i, /session/i, /token/i, /jwt/i, /oauth/i,
|
|
9
|
+
/password/i, /credential/i, /secret/i, /permission/i, /role/i, /acl/i,
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const PAYMENT_PATTERNS = [
|
|
13
|
+
/payment/i, /billing/i, /stripe/i, /checkout/i, /invoice/i, /charge/i,
|
|
14
|
+
/subscription/i, /wallet/i, /transaction/i, /refund/i,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const CORE_PATTERNS = [
|
|
18
|
+
/\/services\//i, /\/core\//i, /\/api\//i, /\/routes?\//i,
|
|
19
|
+
/\/controllers?\//i, /\/models?\//i, /\/middleware\//i, /\/handlers?\//i,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const TEST_EXT = /\.(test|spec)\.[a-z]+$/i;
|
|
23
|
+
const DOC_EXT = /\.(md|txt|rst|adoc)$/i;
|
|
24
|
+
const CONFIG_EXT = /\.(ya?ml|json|toml|ini|env)$/i;
|
|
25
|
+
const CONFIG_NAMES = /(config|settings|env|constants)\./i;
|
|
26
|
+
|
|
27
|
+
function scoreFile(file: string, protectedPaths: string[]): number {
|
|
28
|
+
const norm = file.replace(/\\/g, '/');
|
|
29
|
+
|
|
30
|
+
// Protected paths are highest risk
|
|
31
|
+
for (const pattern of protectedPaths) {
|
|
32
|
+
if (minimatch(norm, pattern, { matchBase: false }) ||
|
|
33
|
+
minimatch(norm, pattern, { matchBase: true })) {
|
|
34
|
+
return 100;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (TEST_EXT.test(norm)) return 10;
|
|
39
|
+
if (DOC_EXT.test(norm)) return 5;
|
|
40
|
+
|
|
41
|
+
if (AUTH_PATTERNS.some(p => p.test(norm))) return 80;
|
|
42
|
+
if (PAYMENT_PATTERNS.some(p => p.test(norm))) return 70;
|
|
43
|
+
if (CORE_PATTERNS.some(p => p.test(norm))) return 50;
|
|
44
|
+
if (CONFIG_EXT.test(norm) || CONFIG_NAMES.test(norm)) return 40;
|
|
45
|
+
|
|
46
|
+
return 30;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns files sorted highest-risk first so LLM sees the most sensitive code
|
|
51
|
+
* at the start of its context window.
|
|
52
|
+
*/
|
|
53
|
+
export function rankByRisk(files: string[], options: RankOptions = {}): string[] {
|
|
54
|
+
const protectedPaths = options.protectedPaths ?? [];
|
|
55
|
+
return [...files].sort((a, b) => scoreFile(b, protectedPaths) - scoreFile(a, protectedPaths));
|
|
56
|
+
}
|
package/src/core/config/types.ts
CHANGED
|
@@ -27,7 +27,7 @@ export interface AutopilotConfig {
|
|
|
27
27
|
maxCodexRetries?: number;
|
|
28
28
|
maxBugbotRounds?: number;
|
|
29
29
|
};
|
|
30
|
-
reviewStrategy?: 'auto' | 'single-pass' | 'file-level';
|
|
30
|
+
reviewStrategy?: 'auto' | 'single-pass' | 'file-level' | 'diff';
|
|
31
31
|
chunking?: {
|
|
32
32
|
smallTierMaxTokens?: number;
|
|
33
33
|
partialReviewTokens?: number;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { runSafe } from '../shell.ts';
|
|
2
|
+
|
|
3
|
+
export interface FileDiff {
|
|
4
|
+
file: string;
|
|
5
|
+
hunks: string; // unified diff content for this file (header + hunks)
|
|
6
|
+
additions: number;
|
|
7
|
+
deletions: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Returns per-file unified diffs for the given files between base and HEAD.
|
|
12
|
+
* Falls back to working-tree diff (unstaged) when base diff is empty for a file.
|
|
13
|
+
*/
|
|
14
|
+
export function getFileDiffs(cwd: string, base: string, files: string[]): FileDiff[] {
|
|
15
|
+
if (files.length === 0) return [];
|
|
16
|
+
|
|
17
|
+
// Get full diff in one shot — more efficient than per-file calls
|
|
18
|
+
const raw = runSafe('git', ['diff', base, 'HEAD', '--unified=3', '--', ...files], { cwd })
|
|
19
|
+
?? runSafe('git', ['diff', 'HEAD', '--unified=3', '--', ...files], { cwd })
|
|
20
|
+
?? '';
|
|
21
|
+
|
|
22
|
+
return parseUnifiedDiff(raw, files);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parses unified diff output into per-file FileDiff entries.
|
|
27
|
+
* Only returns files that actually have diff content.
|
|
28
|
+
*/
|
|
29
|
+
export function parseUnifiedDiff(raw: string, requestedFiles: string[]): FileDiff[] {
|
|
30
|
+
if (!raw.trim()) return [];
|
|
31
|
+
|
|
32
|
+
const results: FileDiff[] = [];
|
|
33
|
+
const sections = raw.split(/^(?=diff --git )/m).filter(Boolean);
|
|
34
|
+
|
|
35
|
+
const requested = new Set(requestedFiles.map(f => f.replace(/\\/g, '/')));
|
|
36
|
+
|
|
37
|
+
for (const section of sections) {
|
|
38
|
+
// Extract b/ filename from diff header: diff --git a/src/foo.ts b/src/foo.ts
|
|
39
|
+
const headerMatch = section.match(/^diff --git a\/.+ b\/(.+)$/m);
|
|
40
|
+
if (!headerMatch) continue;
|
|
41
|
+
const file = headerMatch[1]!.trim();
|
|
42
|
+
if (!requested.has(file)) continue;
|
|
43
|
+
|
|
44
|
+
// Strip the git binary/index header lines, keep hunk content
|
|
45
|
+
const hunkStart = section.indexOf('@@');
|
|
46
|
+
const hunks = hunkStart >= 0 ? section.slice(hunkStart) : '';
|
|
47
|
+
if (!hunks.trim()) continue;
|
|
48
|
+
|
|
49
|
+
let additions = 0;
|
|
50
|
+
let deletions = 0;
|
|
51
|
+
for (const line of hunks.split('\n')) {
|
|
52
|
+
if (line.startsWith('+') && !line.startsWith('+++')) additions++;
|
|
53
|
+
if (line.startsWith('-') && !line.startsWith('---')) deletions++;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
results.push({ file, hunks: hunks.trimEnd(), additions, deletions });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Formats FileDiff entries into a review-ready string.
|
|
64
|
+
* Total size is bounded by maxChars (default 120K chars ≈ 30K tokens).
|
|
65
|
+
*/
|
|
66
|
+
export function formatDiffContent(diffs: FileDiff[], maxChars = 120_000): string {
|
|
67
|
+
const parts: string[] = [];
|
|
68
|
+
let total = 0;
|
|
69
|
+
let skipped = 0;
|
|
70
|
+
|
|
71
|
+
for (const d of diffs) {
|
|
72
|
+
const section = `## ${d.file} (+${d.additions}/-${d.deletions})\n\`\`\`diff\n${d.hunks}\n\`\`\``;
|
|
73
|
+
if (total + section.length > maxChars) {
|
|
74
|
+
skipped++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
parts.push(section);
|
|
78
|
+
total += section.length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (skipped > 0) {
|
|
82
|
+
parts.push(`[${skipped} file${skipped !== 1 ? 's' : ''} omitted — diff exceeded size limit]`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return parts.join('\n\n');
|
|
86
|
+
}
|
|
@@ -19,6 +19,7 @@ export interface ReviewPhaseInput {
|
|
|
19
19
|
cwd?: string;
|
|
20
20
|
gitSummary?: string;
|
|
21
21
|
budgetRemainingUSD?: number;
|
|
22
|
+
base?: string;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export async function runReviewPhase(input: ReviewPhaseInput): Promise<ReviewPhaseResult> {
|
|
@@ -34,6 +35,8 @@ export async function runReviewPhase(input: ReviewPhaseInput): Promise<ReviewPha
|
|
|
34
35
|
chunking: input.config.chunking,
|
|
35
36
|
engine: input.engine,
|
|
36
37
|
cwd: input.cwd,
|
|
38
|
+
protectedPaths: input.config.protectedPaths,
|
|
39
|
+
base: input.base,
|
|
37
40
|
});
|
|
38
41
|
|
|
39
42
|
const allFindings: Finding[] = [];
|
package/src/core/pipeline/run.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface RunInput {
|
|
|
18
18
|
staticRules?: StaticRule[];
|
|
19
19
|
cwd?: string;
|
|
20
20
|
gitSummary?: string;
|
|
21
|
+
base?: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export interface RunResult {
|
|
@@ -62,6 +63,7 @@ export async function runAutopilot(input: RunInput): Promise<RunResult> {
|
|
|
62
63
|
cwd: input.cwd,
|
|
63
64
|
gitSummary: input.gitSummary,
|
|
64
65
|
budgetRemainingUSD: budgetUSD,
|
|
66
|
+
base: input.base,
|
|
65
67
|
});
|
|
66
68
|
phases.push(reviewResult);
|
|
67
69
|
if (reviewResult.costUSD !== undefined) {
|