@delegance/claude-autopilot 1.7.2 → 1.9.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,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.0] — 2026-04-22
4
+
5
+ ### Added
6
+ - **`--post-comments` flag on `run`** — posts a formatted markdown summary to the open PR after the pipeline; edits existing autopilot comment on re-runs instead of creating a new one (tracked via `<!-- autopilot-review -->` marker)
7
+ - **`detectPrNumber()`** — reads `PR_NUMBER`/`GH_PR_NUMBER`/`GITHUB_PR_NUMBER` env vars (CI) or falls back to `gh pr view` (local)
8
+ - **`formatComment()`** — status badge, context line, phase table, critical/warning findings with `file:line`, notes in `<details>`, cost footer
9
+ - 10 new formatter tests — **215 total**
10
+
11
+ ## [1.8.0] — 2026-04-22
12
+
13
+ ### Added
14
+ - **Shared `parseReviewOutput()`** (`src/adapters/review-engine/parse-output.ts`) — extracts `file:line` attribution from review finding bodies; used by all five adapters; eliminates ~100 lines of duplicated parser code
15
+
16
+ ### Fixed
17
+ - `hardcoded-secrets` false positive on route object keys containing `password` (e.g. `forgot_password: '/forgot-password'`)
18
+
19
+ ## [1.7.2] — 2026-04-22
20
+
21
+ ### Fixed
22
+ - `hardcoded-secrets` rule no longer fires on route path values (values starting with `/`)
23
+
24
+ ## [1.7.1] — 2026-04-22
25
+
26
+ ### Added
27
+ - Detection logging: `auto-detected:` line in run output shows stack, protected paths, and test command when inferred; git context (branch + last commit) shown on every run
28
+
3
29
  ## [1.7.0] — 2026-04-22
4
30
 
5
31
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delegance/claude-autopilot",
3
- "version": "1.7.2",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "description": "Claude Code automation pipeline: spec → plan → implement → validate → PR",
6
6
  "keywords": [
@@ -1,8 +1,8 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
- import type { Finding } from '../../core/findings/types.ts';
3
2
  import { AutopilotError } from '../../core/errors.ts';
4
3
  import type { Capabilities } from '../base.ts';
5
4
  import type { ReviewEngine, ReviewInput, ReviewOutput } from './types.ts';
5
+ import { parseReviewOutput } from './parse-output.ts';
6
6
 
7
7
  const DEFAULT_MODEL = 'claude-opus-4-7';
8
8
  const MAX_OUTPUT_TOKENS = 4096;
@@ -90,7 +90,7 @@ export const claudeAdapter: ReviewEngine = {
90
90
  : undefined;
91
91
 
92
92
  return {
93
- findings: parseClaudeOutput(rawOutput),
93
+ findings: parseReviewOutput(rawOutput, 'claude'),
94
94
  rawOutput,
95
95
  usage: response.usage
96
96
  ? { input: response.usage.input_tokens, output: response.usage.output_tokens, costUSD }
@@ -100,28 +100,3 @@ export const claudeAdapter: ReviewEngine = {
100
100
  };
101
101
 
102
102
  export default claudeAdapter;
103
-
104
- function parseClaudeOutput(output: string): Finding[] {
105
- const findings: Finding[] = [];
106
- const regex = /### \[(CRITICAL|WARNING|NOTE)\]\s*(.+?)(?=\n### \[|## Review Summary|$)/gs;
107
- let match: RegExpExecArray | null;
108
- while ((match = regex.exec(output)) !== null) {
109
- const severity = match[1]!.toLowerCase() as Finding['severity'];
110
- const body = match[2]!.trim();
111
- const titleEnd = body.indexOf('\n');
112
- const title = (titleEnd > 0 ? body.slice(0, titleEnd) : body).trim();
113
- const suggestion = body.match(/\*\*Suggestion:\*\*\s*(.+)/s)?.[1]?.trim();
114
- findings.push({
115
- id: `claude-${findings.length}`,
116
- source: 'review-engine',
117
- severity,
118
- category: 'claude-review',
119
- file: '<unspecified>',
120
- message: title,
121
- suggestion,
122
- protectedPath: false,
123
- createdAt: new Date().toISOString(),
124
- });
125
- }
126
- return findings;
127
- }
@@ -1,5 +1,5 @@
1
1
  import OpenAI from 'openai';
2
- import type { Finding } from '../../core/findings/types.ts';
2
+ import { parseReviewOutput } from './parse-output.ts';
3
3
  import { AutopilotError } from '../../core/errors.ts';
4
4
  import type { Capabilities } from '../base.ts';
5
5
  import type { ReviewEngine, ReviewInput, ReviewOutput } from './types.ts';
@@ -74,7 +74,7 @@ export const codexAdapter: ReviewEngine = {
74
74
 
75
75
  const rawOutput = response.output_text ?? '';
76
76
  return {
77
- findings: parseCodexOutput(rawOutput),
77
+ findings: parseReviewOutput(rawOutput, 'codex'),
78
78
  rawOutput,
79
79
  usage: response.usage ? { input: response.usage.input_tokens, output: response.usage.output_tokens } : undefined,
80
80
  };
@@ -82,28 +82,3 @@ export const codexAdapter: ReviewEngine = {
82
82
  };
83
83
 
84
84
  export default codexAdapter;
85
-
86
- function parseCodexOutput(output: string): Finding[] {
87
- const findings: Finding[] = [];
88
- const regex = /### \[(CRITICAL|WARNING|NOTE)\]\s*(.+?)(?=\n### \[|## Review Summary|$)/gs;
89
- let match: RegExpExecArray | null;
90
- while ((match = regex.exec(output)) !== null) {
91
- const severity = match[1]!.toLowerCase() as Finding['severity'];
92
- const body = match[2]!.trim();
93
- const titleEnd = body.indexOf('\n');
94
- const title = (titleEnd > 0 ? body.slice(0, titleEnd) : body).trim();
95
- const suggestion = body.match(/\*\*Suggestion:\*\*\s*(.+)/s)?.[1]?.trim();
96
- findings.push({
97
- id: `codex-${findings.length}`,
98
- source: 'review-engine',
99
- severity,
100
- category: 'codex-review',
101
- file: '<unspecified>',
102
- message: title,
103
- suggestion,
104
- protectedPath: false,
105
- createdAt: new Date().toISOString(),
106
- });
107
- }
108
- return findings;
109
- }
@@ -1,5 +1,5 @@
1
1
  import { GoogleGenerativeAI } from '@google/generative-ai';
2
- import type { Finding } from '../../core/findings/types.ts';
2
+ import { parseReviewOutput } from './parse-output.ts';
3
3
  import { AutopilotError } from '../../core/errors.ts';
4
4
  import type { Capabilities } from '../base.ts';
5
5
  import type { ReviewEngine, ReviewInput, ReviewOutput } from './types.ts';
@@ -95,7 +95,7 @@ export const geminiAdapter: ReviewEngine = {
95
95
  : undefined;
96
96
 
97
97
  return {
98
- findings: parseGeminiOutput(rawOutput),
98
+ findings: parseReviewOutput(rawOutput, 'gemini'),
99
99
  rawOutput,
100
100
  usage: usage
101
101
  ? { input: usage.promptTokenCount, output: usage.candidatesTokenCount, costUSD }
@@ -105,28 +105,3 @@ export const geminiAdapter: ReviewEngine = {
105
105
  };
106
106
 
107
107
  export default geminiAdapter;
108
-
109
- function parseGeminiOutput(output: string): Finding[] {
110
- const findings: Finding[] = [];
111
- const regex = /### \[(CRITICAL|WARNING|NOTE)\]\s*(.+?)(?=\n### \[|## Review Summary|$)/gs;
112
- let match: RegExpExecArray | null;
113
- while ((match = regex.exec(output)) !== null) {
114
- const severity = match[1]!.toLowerCase() as Finding['severity'];
115
- const body = match[2]!.trim();
116
- const titleEnd = body.indexOf('\n');
117
- const title = (titleEnd > 0 ? body.slice(0, titleEnd) : body).trim();
118
- const suggestion = body.match(/\*\*Suggestion:\*\*\s*(.+)/s)?.[1]?.trim();
119
- findings.push({
120
- id: `gemini-${findings.length}`,
121
- source: 'review-engine',
122
- severity,
123
- category: 'gemini-review',
124
- file: '<unspecified>',
125
- message: title,
126
- suggestion,
127
- protectedPath: false,
128
- createdAt: new Date().toISOString(),
129
- });
130
- }
131
- return findings;
132
- }
@@ -1,5 +1,5 @@
1
1
  import OpenAI from 'openai';
2
- import type { Finding } from '../../core/findings/types.ts';
2
+ import { parseReviewOutput } from './parse-output.ts';
3
3
  import { AutopilotError } from '../../core/errors.ts';
4
4
  import type { Capabilities } from '../base.ts';
5
5
  import type { ReviewEngine, ReviewInput, ReviewOutput } from './types.ts';
@@ -90,7 +90,7 @@ export const openaiCompatibleAdapter: ReviewEngine = {
90
90
 
91
91
  const rawOutput = response.choices[0]?.message.content ?? '';
92
92
  return {
93
- findings: parseOutput(rawOutput),
93
+ findings: parseReviewOutput(rawOutput, 'openai-compatible'),
94
94
  rawOutput,
95
95
  usage: response.usage
96
96
  ? { input: response.usage.prompt_tokens, output: response.usage.completion_tokens }
@@ -100,28 +100,3 @@ export const openaiCompatibleAdapter: ReviewEngine = {
100
100
  };
101
101
 
102
102
  export default openaiCompatibleAdapter;
103
-
104
- function parseOutput(output: string): Finding[] {
105
- const findings: Finding[] = [];
106
- const regex = /### \[(CRITICAL|WARNING|NOTE)\]\s*(.+?)(?=\n### \[|## Review Summary|$)/gs;
107
- let match: RegExpExecArray | null;
108
- while ((match = regex.exec(output)) !== null) {
109
- const severity = match[1]!.toLowerCase() as Finding['severity'];
110
- const body = match[2]!.trim();
111
- const titleEnd = body.indexOf('\n');
112
- const title = (titleEnd > 0 ? body.slice(0, titleEnd) : body).trim();
113
- const suggestion = body.match(/\*\*Suggestion:\*\*\s*(.+)/s)?.[1]?.trim();
114
- findings.push({
115
- id: `openai-compatible-${findings.length}`,
116
- source: 'review-engine',
117
- severity,
118
- category: 'openai-compatible-review',
119
- file: '<unspecified>',
120
- message: title,
121
- suggestion,
122
- protectedPath: false,
123
- createdAt: new Date().toISOString(),
124
- });
125
- }
126
- return findings;
127
- }
@@ -0,0 +1,48 @@
1
+ import type { Finding } from '../../core/findings/types.ts';
2
+
3
+ // Matches "path/to/file.ts:42", "`path/to/file.ts`", or bare filenames with common extensions
4
+ const FILE_REF = /(?:`([^`]+\.[a-z]{1,6})`|(\b[\w./\-]+\.[a-z]{1,6})(?::(\d+))?)/;
5
+
6
+ function extractFileRef(text: string): { file: string; line?: number } {
7
+ const m = text.match(FILE_REF);
8
+ if (!m) return { file: '<unspecified>' };
9
+ const raw = (m[1] ?? m[2])!;
10
+ // Skip version strings (v1.2.3) and bare dotfile extensions with no path separator
11
+ if (/^v?\d/.test(raw) || (!raw.includes('/') && raw.startsWith('.') && raw.split('.').length === 2)) {
12
+ return { file: '<unspecified>' };
13
+ }
14
+ const line = m[3] ? parseInt(m[3], 10) : undefined;
15
+ return { file: raw, line };
16
+ }
17
+
18
+ /**
19
+ * Parses the structured [CRITICAL|WARNING|NOTE] markdown format
20
+ * produced by all review engine adapters. Extracts file:line references
21
+ * from the finding body when present.
22
+ */
23
+ export function parseReviewOutput(output: string, idPrefix: string): Finding[] {
24
+ const findings: Finding[] = [];
25
+ const regex = /### \[(CRITICAL|WARNING|NOTE)\]\s*(.+?)(?=\n### \[|## Review Summary|$)/gs;
26
+ let match: RegExpExecArray | null;
27
+ while ((match = regex.exec(output)) !== null) {
28
+ const severity = match[1]!.toLowerCase() as Finding['severity'];
29
+ const body = match[2]!.trim();
30
+ const titleEnd = body.indexOf('\n');
31
+ const title = (titleEnd > 0 ? body.slice(0, titleEnd) : body).trim();
32
+ const suggestion = body.match(/\*\*Suggestion:\*\*\s*(.+)/s)?.[1]?.trim();
33
+ const { file, line } = extractFileRef(body);
34
+ findings.push({
35
+ id: `${idPrefix}-${findings.length}`,
36
+ source: 'review-engine',
37
+ severity,
38
+ category: 'review-engine',
39
+ file,
40
+ line,
41
+ message: title,
42
+ suggestion,
43
+ protectedPath: false,
44
+ createdAt: new Date().toISOString(),
45
+ });
46
+ }
47
+ return findings;
48
+ }
package/src/cli/index.ts CHANGED
@@ -65,6 +65,7 @@ Options (run):
65
65
  --config <path> Path to config file (default: ./autopilot.config.yaml)
66
66
  --files <a,b,c> Explicit comma-separated file list (skips git detection)
67
67
  --dry-run Show what would run without executing
68
+ --post-comments Post/update a summary comment on the open PR
68
69
  --format <text|sarif> Output format (default: text)
69
70
  --output <path> Output file path (required with --format sarif)
70
71
 
@@ -118,6 +119,7 @@ switch (subcommand) {
118
119
  const config = flag('config');
119
120
  const filesArg = flag('files');
120
121
  const dryRun = boolFlag('dry-run');
122
+ const postComments = boolFlag('post-comments');
121
123
  const formatArg = flag('format');
122
124
  const outputPath = flag('output');
123
125
 
@@ -135,6 +137,7 @@ switch (subcommand) {
135
137
  configPath: config,
136
138
  files: filesArg ? filesArg.split(',').map(f => f.trim()) : undefined,
137
139
  dryRun,
140
+ postComments,
138
141
  format: formatArg as 'text' | 'sarif' | undefined,
139
142
  outputPath,
140
143
  });
@@ -0,0 +1,137 @@
1
+ import { runSafe } from '../core/shell.ts';
2
+ import type { RunResult } from '../core/pipeline/run.ts';
3
+ import type { AutopilotConfig } from '../core/config/types.ts';
4
+ import type { GitContext } from '../core/detect/git-context.ts';
5
+ import { readFileSync } from 'node:fs';
6
+ import { join, dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const COMMENT_MARKER = '<!-- autopilot-review -->';
10
+
11
+ function readVersion(): string {
12
+ try {
13
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json');
14
+ return (JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string }).version;
15
+ } catch { return 'unknown'; }
16
+ }
17
+
18
+ /** Detect the current open PR number via gh CLI or CI env vars. */
19
+ export function detectPrNumber(cwd: string): number | null {
20
+ // CI env vars set by GitHub Actions
21
+ const fromEnv = process.env.PR_NUMBER ?? process.env.GH_PR_NUMBER ?? process.env.GITHUB_PR_NUMBER;
22
+ if (fromEnv && /^\d+$/.test(fromEnv)) return parseInt(fromEnv, 10);
23
+
24
+ // gh CLI — works locally and in CI when gh is authenticated
25
+ const raw = runSafe('gh', ['pr', 'view', '--json', 'number', '--jq', '.number'], { cwd });
26
+ if (raw) {
27
+ const n = parseInt(raw.trim(), 10);
28
+ if (!isNaN(n)) return n;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /** Find the ID of a previously-posted autopilot comment, if any. */
34
+ function findExistingCommentId(pr: number, cwd: string): number | null {
35
+ const raw = runSafe('gh', ['api', `repos/{owner}/{repo}/issues/${pr}/comments`,
36
+ '--jq', `[.[] | select(.body | startswith("${COMMENT_MARKER}")) | .id] | first`], { cwd });
37
+ if (!raw) return null;
38
+ const n = parseInt(raw.trim(), 10);
39
+ return isNaN(n) ? null : n;
40
+ }
41
+
42
+ /** Format a RunResult into a markdown PR comment. */
43
+ export function formatComment(
44
+ result: RunResult,
45
+ config: AutopilotConfig,
46
+ gitCtx: GitContext,
47
+ touchedFileCount: number,
48
+ ): string {
49
+ const statusIcon = result.status === 'pass' ? '✅' : result.status === 'warn' ? '⚠️' : '❌';
50
+ const statusLabel = result.status === 'pass' ? 'Passed' : result.status === 'warn' ? 'Passed with warnings' : 'Failed';
51
+
52
+ const lines: string[] = [
53
+ COMMENT_MARKER,
54
+ `## ${statusIcon} Autopilot Review — ${statusLabel}`,
55
+ '',
56
+ ];
57
+
58
+ // Context line
59
+ const ctx: string[] = [];
60
+ if (config.stack) ctx.push(`**Stack:** ${config.stack}`);
61
+ if (gitCtx.branch) ctx.push(`**Branch:** \`${gitCtx.branch}\``);
62
+ if (gitCtx.commitMessage) ctx.push(`**Commit:** ${gitCtx.commitMessage}`);
63
+ ctx.push(`**Files reviewed:** ${touchedFileCount}`);
64
+ lines.push(ctx.join(' · '), '');
65
+
66
+ // Phase table
67
+ lines.push('| Phase | Status | Findings |');
68
+ lines.push('|---|:---:|:---:|');
69
+ for (const phase of result.phases) {
70
+ const icon = phase.status === 'pass' ? '✅' : phase.status === 'skip' ? '—' :
71
+ phase.status === 'warn' ? '⚠️' : '❌';
72
+ lines.push(`| ${phase.phase} | ${icon} | ${phase.findings.length} |`);
73
+ }
74
+ lines.push('');
75
+
76
+ // Findings by severity
77
+ const critical = result.allFindings.filter(f => f.severity === 'critical');
78
+ const warnings = result.allFindings.filter(f => f.severity === 'warning');
79
+ const notes = result.allFindings.filter(f => f.severity === 'note');
80
+
81
+ if (critical.length > 0) {
82
+ lines.push('### 🚨 Critical');
83
+ for (const f of critical) {
84
+ const loc = f.file !== '<unspecified>' ? `\`${f.file}${f.line ? `:${f.line}` : ''}\` — ` : '';
85
+ lines.push(`- ${loc}${f.message}`);
86
+ if (f.suggestion) lines.push(` > ${f.suggestion}`);
87
+ }
88
+ lines.push('');
89
+ }
90
+
91
+ if (warnings.length > 0) {
92
+ lines.push('### ⚠️ Warnings');
93
+ for (const f of warnings) {
94
+ const loc = f.file !== '<unspecified>' ? `\`${f.file}${f.line ? `:${f.line}` : ''}\` — ` : '';
95
+ lines.push(`- ${loc}${f.message}`);
96
+ if (f.suggestion) lines.push(` > ${f.suggestion}`);
97
+ }
98
+ lines.push('');
99
+ }
100
+
101
+ if (notes.length > 0) {
102
+ lines.push('<details><summary>Notes</summary>\n');
103
+ for (const f of notes) {
104
+ const loc = f.file !== '<unspecified>' ? `\`${f.file}${f.line ? `:${f.line}` : ''}\` — ` : '';
105
+ lines.push(`- ${loc}${f.message}`);
106
+ }
107
+ lines.push('\n</details>\n');
108
+ }
109
+
110
+ if (result.totalCostUSD !== undefined) {
111
+ lines.push(`*Cost: $${result.totalCostUSD.toFixed(4)} · ${result.durationMs}ms · [@delegance/claude-autopilot](https://github.com/axledbetter/claude-autopilot) v${readVersion()}*`);
112
+ } else {
113
+ lines.push(`*${result.durationMs}ms · [@delegance/claude-autopilot](https://github.com/axledbetter/claude-autopilot) v${readVersion()}*`);
114
+ }
115
+
116
+ return lines.join('\n');
117
+ }
118
+
119
+ /** Post or update the autopilot comment on the given PR. */
120
+ export async function postPrComment(
121
+ pr: number,
122
+ body: string,
123
+ cwd: string,
124
+ ): Promise<{ action: 'created' | 'updated'; url: string | null }> {
125
+ const existingId = findExistingCommentId(pr, cwd);
126
+
127
+ if (existingId) {
128
+ runSafe('gh', ['api', `repos/{owner}/{repo}/issues/comments/${existingId}`,
129
+ '--method', 'PATCH', '--field', `body=${body}`], { cwd });
130
+ return { action: 'updated', url: null };
131
+ }
132
+
133
+ const raw = runSafe('gh', ['pr', 'comment', String(pr), '--body', body], { cwd });
134
+ // gh outputs the comment URL on success
135
+ const url = raw?.trim() ?? null;
136
+ return { action: 'created', url };
137
+ }
package/src/cli/run.ts CHANGED
@@ -37,6 +37,7 @@ import { detectStack } from '../core/detect/stack.ts';
37
37
  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
+ import { detectPrNumber, formatComment, postPrComment } from './pr-comment.ts';
40
41
 
41
42
  function readToolVersion(): string {
42
43
  const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../package.json');
@@ -60,11 +61,12 @@ function fmt(color: keyof typeof C, text: string): string {
60
61
  export interface RunCommandOptions {
61
62
  cwd?: string;
62
63
  configPath?: string;
63
- base?: string; // git base ref (default HEAD~1)
64
- files?: string[]; // explicit file list (skips git detection)
65
- dryRun?: boolean; // skip review, print what would run
64
+ base?: string; // git base ref (default HEAD~1)
65
+ files?: string[]; // explicit file list (skips git detection)
66
+ dryRun?: boolean; // skip review, print what would run
66
67
  format?: 'text' | 'sarif';
67
68
  outputPath?: string;
69
+ postComments?: boolean; // post/update summary comment on the open PR
68
70
  }
69
71
 
70
72
  /**
@@ -189,6 +191,22 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
189
191
  console.log(fmt('dim', `[run] SARIF written to ${options.outputPath}`));
190
192
  }
191
193
 
194
+ // Post PR comment if requested
195
+ if (options.postComments) {
196
+ const pr = detectPrNumber(cwd);
197
+ if (!pr) {
198
+ console.log(fmt('yellow', ' [run] --post-comments: no open PR found — skipping comment'));
199
+ } else {
200
+ try {
201
+ const body = formatComment(result, config, gitCtx, touchedFiles.length);
202
+ const { action } = await postPrComment(pr, body, cwd);
203
+ console.log(fmt('dim', ` [run] PR #${pr} comment ${action}`));
204
+ } catch (err) {
205
+ console.error(fmt('yellow', ` [run] Failed to post PR comment: ${err instanceof Error ? err.message : String(err)}`));
206
+ }
207
+ }
208
+ }
209
+
192
210
  // Print phase summaries
193
211
  for (const phase of result.phases) {
194
212
  const icon = phase.status === 'pass' ? fmt('green', '✓') :