@delegance/claude-autopilot 2.1.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 CHANGED
@@ -1,5 +1,14 @@
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
+
3
12
  ## [2.1.0] — 2026-04-22
4
13
 
5
14
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delegance/claude-autopilot",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "description": "Claude Code automation pipeline: spec → plan → implement → validate → PR",
6
6
  "keywords": [
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('');
@@ -3,6 +3,7 @@ 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
5
  import { rankByRisk } from './risk-ranker.ts';
6
+ import { getFileDiffs, formatDiffContent } from '../git/diff-hunks.ts';
6
7
 
7
8
  export interface ReviewChunk {
8
9
  content: string;
@@ -12,11 +13,12 @@ export interface ReviewChunk {
12
13
 
13
14
  export interface BuildChunksInput {
14
15
  touchedFiles: string[];
15
- strategy: 'auto' | 'single-pass' | 'file-level';
16
+ strategy: 'auto' | 'single-pass' | 'file-level' | 'diff';
16
17
  chunking?: AutopilotConfig['chunking'];
17
18
  engine: ReviewEngine;
18
19
  cwd?: string;
19
20
  protectedPaths?: string[];
21
+ base?: string; // git base ref — required for 'diff' strategy
20
22
  }
21
23
 
22
24
  const DEFAULT_SMALL_TIER_TOKENS = 8000;
@@ -26,6 +28,11 @@ export async function buildReviewChunks(input: BuildChunksInput): Promise<Review
26
28
  const smallMax = input.chunking?.smallTierMaxTokens ?? DEFAULT_SMALL_TIER_TOKENS;
27
29
  const fileMax = input.chunking?.perFileMaxTokens ?? DEFAULT_FILE_TIER_TOKENS;
28
30
 
31
+ // Diff strategy: send unified diff hunks instead of full file contents
32
+ if (input.strategy === 'diff') {
33
+ return buildDiffChunks(input);
34
+ }
35
+
29
36
  const ranked = rankByRisk(input.touchedFiles, { protectedPaths: input.protectedPaths });
30
37
  const fileContents = await readFiles(ranked, input.cwd);
31
38
 
@@ -51,6 +58,19 @@ export async function buildReviewChunks(input: BuildChunksInput): Promise<Review
51
58
  return chunks;
52
59
  }
53
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
+
54
74
  async function readFiles(touchedFiles: string[], cwd?: string): Promise<Map<string, string>> {
55
75
  const result = new Map<string, string>();
56
76
  for (const f of touchedFiles) {
@@ -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> {
@@ -35,6 +36,7 @@ export async function runReviewPhase(input: ReviewPhaseInput): Promise<ReviewPha
35
36
  engine: input.engine,
36
37
  cwd: input.cwd,
37
38
  protectedPaths: input.config.protectedPaths,
39
+ base: input.base,
38
40
  });
39
41
 
40
42
  const allFindings: Finding[] = [];
@@ -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) {