@delegance/claude-autopilot 2.3.0 → 2.4.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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.0] — 2026-04-22
4
+
5
+ ### Added
6
+ - **`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
7
+ - **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
8
+ - **`--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)
9
+ - **`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
10
+ - `src/cli/pr-review-comments.ts` — `postReviewComments()` using `gh api repos/{nwo}/pulls/{pr}/reviews`
11
+ - `src/core/persist/cost-log.ts` — `appendCostLog()`, `readCostLog()`
12
+ - 9 new tests — **257 total**
13
+
3
14
  ## [2.3.0] — 2026-04-22
4
15
 
5
16
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delegance/claude-autopilot",
3
- "version": "2.3.0",
3
+ "version": "2.4.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
@@ -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/index.ts CHANGED
@@ -68,6 +68,7 @@ Options (run):
68
68
  --dry-run Show what would run without executing
69
69
  --diff Send git diff hunks instead of full files (~70% fewer tokens)
70
70
  --delta Only report findings new since last run (suppress pre-existing)
71
+ --inline-comments Post per-line review comments on the PR diff
71
72
  --post-comments Post/update a summary comment on the open PR
72
73
  --format <text|sarif> Output format (default: text)
73
74
  --output <path> Output file path (required with --format sarif)
@@ -124,6 +125,7 @@ switch (subcommand) {
124
125
  const dryRun = boolFlag('dry-run');
125
126
  const diff = boolFlag('diff');
126
127
  const delta = boolFlag('delta');
128
+ const inlineComments = boolFlag('inline-comments');
127
129
  const postComments = boolFlag('post-comments');
128
130
  const formatArg = flag('format');
129
131
  const outputPath = flag('output');
@@ -144,6 +146,7 @@ switch (subcommand) {
144
146
  dryRun,
145
147
  diff,
146
148
  delta,
149
+ inlineComments,
147
150
  postComments,
148
151
  format: formatArg as 'text' | 'sarif' | undefined,
149
152
  outputPath,
@@ -157,12 +160,14 @@ switch (subcommand) {
157
160
  const config = flag('config');
158
161
  const outputPath = flag('output');
159
162
  const noPostComments = boolFlag('no-post-comments');
163
+ const noInlineComments = boolFlag('no-inline-comments');
160
164
  const diff = boolFlag('diff');
161
165
  const code = await runCi({
162
166
  configPath: config,
163
167
  base,
164
168
  sarifOutput: outputPath,
165
169
  postComments: noPostComments ? false : undefined,
170
+ inlineComments: noInlineComments ? false : undefined,
166
171
  diff,
167
172
  });
168
173
  process.exit(code);
@@ -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 { loadIgnoreRules, applyIgnoreRules } from '../core/ignore/index.ts';
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; // use diff strategy (send git hunks instead of full files)
70
- delta?: boolean; // only report findings not present in last run's baseline
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 suppression rules
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
 
@@ -27,7 +27,8 @@ export interface AutopilotConfig {
27
27
  maxCodexRetries?: number;
28
28
  maxBugbotRounds?: number;
29
29
  };
30
- reviewStrategy?: 'auto' | 'single-pass' | 'file-level' | 'diff';
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;
@@ -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
+ }