@delegance/claude-autopilot 1.6.0 → 1.7.1

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.0] — 2026-04-22
4
+
5
+ ### Added
6
+ - **Stack auto-detection** (`src/core/detect/stack.ts`) — infers human-readable stack string from `package.json`, `go.mod`, `Cargo.toml`, `requirements.txt`, `Gemfile`; detects framework, ORM, auth, UI library, language; injected into review prompt automatically when `stack:` is absent from config
7
+ - **Protected-paths auto-detection** (`src/core/detect/protected-paths.ts`) — scans for migration dirs (`data/deltas/`, `migrations/`, `db/migrate/`, `prisma/migrations/`, `alembic/versions/`, `flyway/`), schema files (`schema.prisma`, `schema.sql`, `db/schema.rb`), infra dirs (`terraform/`, `k8s/`, `helm/`, `.github/workflows/`); populates `protectedPaths` when not set in config
8
+ - **Test-command runtime fallback** — re-runs project detector at `run` time when `testCommand` is absent from config; `null` still disables the test phase explicitly
9
+ - **Git context enrichment** (`src/core/detect/git-context.ts`) — injects branch name and last commit message into the review prompt as `Change context: branch: feat/x | last commit: add user auth` so the LLM understands intent
10
+ - `ReviewInput.context.gitSummary` — new context field; all five adapters (claude, gemini, codex, openai-compatible, auto) inject it when present
11
+ - 18 new tests (9 stack + 9 protected-paths) — **199 total**
12
+
3
13
  ## [1.6.0] — 2026-04-22
4
14
 
5
15
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delegance/claude-autopilot",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "type": "module",
5
5
  "description": "Claude Code automation pipeline: spec → plan → implement → validate → PR",
6
6
  "keywords": [
@@ -14,7 +14,7 @@ const COST_PER_M_OUTPUT = 75.0;
14
14
  const SYSTEM_PROMPT_TEMPLATE = `You are a senior software architect reviewing code changes for quality, security, and correctness.
15
15
 
16
16
  The codebase context:
17
- {STACK}
17
+ {STACK}{GIT_CONTEXT}
18
18
 
19
19
  Provide structured feedback in exactly this format:
20
20
 
@@ -56,7 +56,8 @@ export const claudeAdapter: ReviewEngine = {
56
56
 
57
57
  const model = (input.context as Record<string, unknown> | undefined)?.['model'] as string | undefined ?? DEFAULT_MODEL;
58
58
  const stack = input.context?.stack ?? 'A web application — stack details unspecified.';
59
- const systemPrompt = SYSTEM_PROMPT_TEMPLATE.replace('{STACK}', stack);
59
+ const gitCtx = input.context?.gitSummary ? `\n\nChange context: ${input.context.gitSummary}` : '';
60
+ const systemPrompt = SYSTEM_PROMPT_TEMPLATE.replace('{STACK}', stack).replace('{GIT_CONTEXT}', gitCtx);
60
61
 
61
62
  const client = new Anthropic({ apiKey });
62
63
  let response: Anthropic.Message;
@@ -10,7 +10,7 @@ const MAX_OUTPUT_TOKENS = 4096;
10
10
  const SYSTEM_PROMPT_TEMPLATE = `You are a senior software architect providing feedback on designs, proposals, and ideas.
11
11
 
12
12
  The codebase context:
13
- {STACK}
13
+ {STACK}{GIT_CONTEXT}
14
14
 
15
15
  Provide structured feedback in exactly this format:
16
16
 
@@ -49,7 +49,8 @@ export const codexAdapter: ReviewEngine = {
49
49
  throw new AutopilotError('OPENAI_API_KEY not set', { code: 'auth', provider: 'codex' });
50
50
  }
51
51
  const stack = input.context?.stack ?? 'A web application — stack details unspecified.';
52
- const systemPrompt = SYSTEM_PROMPT_TEMPLATE.replace('{STACK}', stack);
52
+ const gitCtx = input.context?.gitSummary ? `\n\nChange context: ${input.context.gitSummary}` : '';
53
+ const systemPrompt = SYSTEM_PROMPT_TEMPLATE.replace('{STACK}', stack).replace('{GIT_CONTEXT}', gitCtx);
53
54
 
54
55
  const client = new OpenAI({ apiKey });
55
56
  let response;
@@ -14,7 +14,7 @@ const COST_PER_M_OUTPUT = 10.0;
14
14
  const PROMPT_TEMPLATE = `You are a senior software architect reviewing code changes for quality, security, and correctness.
15
15
 
16
16
  The codebase context:
17
- {STACK}
17
+ {STACK}{GIT_CONTEXT}
18
18
 
19
19
  Please review the following:
20
20
 
@@ -64,7 +64,8 @@ export const geminiAdapter: ReviewEngine = {
64
64
 
65
65
  const model = (input.context as Record<string, unknown> | undefined)?.['model'] as string | undefined ?? DEFAULT_MODEL;
66
66
  const stack = input.context?.stack ?? 'A web application — stack details unspecified.';
67
- const prompt = PROMPT_TEMPLATE.replace('{STACK}', stack).replace('{CONTENT}', input.content);
67
+ const gitCtx = input.context?.gitSummary ? `\n\nChange context: ${input.context.gitSummary}` : '';
68
+ const prompt = PROMPT_TEMPLATE.replace('{STACK}', stack).replace('{GIT_CONTEXT}', gitCtx).replace('{CONTENT}', input.content);
68
69
 
69
70
  const genAI = new GoogleGenerativeAI(apiKey);
70
71
  const genModel = genAI.getGenerativeModel({
@@ -9,7 +9,7 @@ const MAX_OUTPUT_TOKENS = 4096;
9
9
  const SYSTEM_PROMPT_TEMPLATE = `You are a senior software architect reviewing code changes for quality, security, and correctness.
10
10
 
11
11
  The codebase context:
12
- {STACK}
12
+ {STACK}{GIT_CONTEXT}
13
13
 
14
14
  Provide structured feedback in exactly this format:
15
15
 
@@ -63,7 +63,8 @@ export const openaiCompatibleAdapter: ReviewEngine = {
63
63
  }
64
64
 
65
65
  const stack = input.context?.stack ?? 'A web application — stack details unspecified.';
66
- const systemPrompt = SYSTEM_PROMPT_TEMPLATE.replace('{STACK}', stack);
66
+ const gitCtx = input.context?.gitSummary ? `\n\nChange context: ${input.context.gitSummary}` : '';
67
+ const systemPrompt = SYSTEM_PROMPT_TEMPLATE.replace('{STACK}', stack).replace('{GIT_CONTEXT}', gitCtx);
67
68
  const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) });
68
69
 
69
70
  let response: OpenAI.Chat.ChatCompletion;
@@ -4,7 +4,7 @@ import type { Finding } from '../../core/findings/types.ts';
4
4
  export interface ReviewInput {
5
5
  content: string;
6
6
  kind: 'spec' | 'pr-diff' | 'file-batch';
7
- context?: { spec?: string; plan?: string; stack?: string; cwd?: string };
7
+ context?: { spec?: string; plan?: string; stack?: string; cwd?: string; gitSummary?: string };
8
8
  }
9
9
 
10
10
  export interface ReviewOutput {
package/src/cli/run.ts CHANGED
@@ -33,6 +33,10 @@ import type { AutopilotConfig } from '../core/config/types.ts';
33
33
  import { fileURLToPath } from 'node:url';
34
34
  import { toSarif } from '../formatters/sarif.ts';
35
35
  import { emitAnnotations } from '../formatters/github-annotations.ts';
36
+ import { detectStack } from '../core/detect/stack.ts';
37
+ import { detectProtectedPaths } from '../core/detect/protected-paths.ts';
38
+ import { detectGitContext } from '../core/detect/git-context.ts';
39
+ import { detectProject } from './detector.ts';
36
40
 
37
41
  function readToolVersion(): string {
38
42
  const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../package.json');
@@ -92,6 +96,27 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
92
96
  return 1;
93
97
  }
94
98
 
99
+ // Fill in missing config fields from auto-detection (track what was auto-detected for logging)
100
+ const autoDetected: string[] = [];
101
+
102
+ if (!config.stack) {
103
+ const detected = detectStack(cwd);
104
+ if (detected) { config = { ...config, stack: detected }; autoDetected.push(`stack: ${detected}`); }
105
+ }
106
+ if (!config.protectedPaths || config.protectedPaths.length === 0) {
107
+ const detected = detectProtectedPaths(cwd);
108
+ if (detected.length > 0) {
109
+ config = { ...config, protectedPaths: detected };
110
+ autoDetected.push(`protected: ${detected.slice(0, 3).join(', ')}${detected.length > 3 ? ` +${detected.length - 3} more` : ''}`);
111
+ }
112
+ }
113
+ if (config.testCommand === undefined) {
114
+ const detected = detectProject(cwd).testCommand;
115
+ config = { ...config, testCommand: detected };
116
+ autoDetected.push(`test: ${detected}`);
117
+ }
118
+ const gitCtx = detectGitContext(cwd);
119
+
95
120
  // Resolve touched files
96
121
  const touchedFiles = options.files ?? resolveGitTouchedFiles({ cwd, base: options.base });
97
122
  if (touchedFiles.length === 0) {
@@ -102,6 +127,12 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
102
127
 
103
128
  console.log(`\n${fmt('bold', '[autopilot run]')} ${fmt('dim', configPath)}`);
104
129
  console.log(`${fmt('dim', ` ${touchedFiles.length} changed file(s):`)} ${touchedFiles.slice(0, 5).join(', ')}${touchedFiles.length > 5 ? ` … +${touchedFiles.length - 5} more` : ''}`);
130
+ if (gitCtx.summary) {
131
+ console.log(fmt('dim', ` ${gitCtx.summary}`));
132
+ }
133
+ if (autoDetected.length > 0) {
134
+ console.log(fmt('dim', ` auto-detected: ${autoDetected.join(' | ')}`));
135
+ }
105
136
 
106
137
  if (options.dryRun) {
107
138
  console.log(fmt('yellow', '\n[run] Dry run — skipping pipeline execution.\n'));
@@ -141,6 +172,7 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
141
172
  reviewEngine,
142
173
  staticRules,
143
174
  cwd,
175
+ gitSummary: gitCtx.summary ?? undefined,
144
176
  };
145
177
 
146
178
  console.log('');
@@ -0,0 +1,27 @@
1
+ import { runSafe } from '../shell.ts';
2
+
3
+ export interface GitContext {
4
+ branch: string | null;
5
+ commitMessage: string | null;
6
+ /** Short summary suitable for injecting into a review prompt */
7
+ summary: string | null;
8
+ }
9
+
10
+ /**
11
+ * Reads branch name and last commit message from git. Returns nulls gracefully
12
+ * if git is unavailable or the repo has no commits.
13
+ */
14
+ export function detectGitContext(cwd: string): GitContext {
15
+ const branch = runSafe('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'])?.trim() ?? null;
16
+ const commitMessage = runSafe('git', ['-C', cwd, 'log', '-1', '--format=%s'])?.trim() ?? null;
17
+
18
+ let summary: string | null = null;
19
+ if (branch || commitMessage) {
20
+ const parts: string[] = [];
21
+ if (branch && branch !== 'HEAD') parts.push(`branch: ${branch}`);
22
+ if (commitMessage) parts.push(`last commit: ${commitMessage}`);
23
+ summary = parts.join(' | ');
24
+ }
25
+
26
+ return { branch, commitMessage, summary };
27
+ }
@@ -0,0 +1,63 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+
4
+ interface MigrationSignal {
5
+ glob: string;
6
+ check: (cwd: string) => boolean;
7
+ }
8
+
9
+ const MIGRATION_SIGNALS: MigrationSignal[] = [
10
+ { glob: 'data/deltas/**', check: c => fs.existsSync(path.join(c, 'data', 'deltas')) },
11
+ { glob: 'migrations/**', check: c => fs.existsSync(path.join(c, 'migrations')) },
12
+ { glob: 'db/migrate/**', check: c => fs.existsSync(path.join(c, 'db', 'migrate')) },
13
+ { glob: 'database/migrations/**', check: c => fs.existsSync(path.join(c, 'database', 'migrations')) },
14
+ { glob: 'prisma/migrations/**', check: c => fs.existsSync(path.join(c, 'prisma', 'migrations')) },
15
+ { glob: 'alembic/versions/**', check: c => fs.existsSync(path.join(c, 'alembic', 'versions')) },
16
+ { glob: 'flyway/**', check: c => fs.existsSync(path.join(c, 'flyway')) },
17
+ // *.sql is handled below via readdirSync
18
+ ];
19
+
20
+ const SCHEMA_FILES = [
21
+ 'prisma/schema.prisma',
22
+ 'schema.prisma',
23
+ 'schema.sql',
24
+ 'db/schema.rb',
25
+ 'config/schema.xml',
26
+ ];
27
+
28
+ const INFRA_SIGNALS: Array<{ glob: string; check: (cwd: string) => boolean }> = [
29
+ { glob: 'terraform/**', check: c => fs.existsSync(path.join(c, 'terraform')) },
30
+ { glob: 'infra/**', check: c => fs.existsSync(path.join(c, 'infra')) },
31
+ { glob: '.github/workflows/**', check: c => fs.existsSync(path.join(c, '.github', 'workflows')) },
32
+ { glob: 'k8s/**', check: c => fs.existsSync(path.join(c, 'k8s')) },
33
+ { glob: 'helm/**', check: c => fs.existsSync(path.join(c, 'helm')) },
34
+ ];
35
+
36
+ /**
37
+ * Scans the project for migration directories, schema files, and infra configs
38
+ * and returns glob patterns suitable for `protectedPaths`.
39
+ */
40
+ export function detectProtectedPaths(cwd: string): string[] {
41
+ const found = new Set<string>();
42
+
43
+ for (const sig of MIGRATION_SIGNALS) {
44
+ if (sig.check(cwd)) found.add(sig.glob);
45
+ }
46
+
47
+ // Root-level .sql files
48
+ try {
49
+ if (fs.readdirSync(cwd).some(f => f.endsWith('.sql'))) found.add('*.sql');
50
+ } catch { /* ignore */ }
51
+
52
+ for (const rel of SCHEMA_FILES) {
53
+ if (fs.existsSync(path.join(cwd, rel))) {
54
+ found.add(rel.includes('/') ? rel.split('/')[0] + '/**' : rel);
55
+ }
56
+ }
57
+
58
+ for (const sig of INFRA_SIGNALS) {
59
+ if (sig.check(cwd)) found.add(sig.glob);
60
+ }
61
+
62
+ return Array.from(found).sort();
63
+ }
@@ -0,0 +1,153 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+
4
+ function readJson(p: string): Record<string, unknown> | null {
5
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
6
+ }
7
+
8
+ function fileContains(p: string, needle: string): boolean {
9
+ try { return fs.readFileSync(p, 'utf8').includes(needle); } catch { return false; }
10
+ }
11
+
12
+ function readFile(p: string): string {
13
+ try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
14
+ }
15
+
16
+ function version(deps: Record<string, string>, name: string): string | null {
17
+ const v = deps[name];
18
+ if (!v) return null;
19
+ return v.replace(/^[\^~>=<\s]+/, '').split('.')[0] ?? null;
20
+ }
21
+
22
+ /**
23
+ * Infers a human-readable stack description from project files.
24
+ * Returns null if nothing definitive is found (caller should omit from prompt).
25
+ */
26
+ export function detectStack(cwd: string): string | null {
27
+ // Go
28
+ const goMod = path.join(cwd, 'go.mod');
29
+ if (fs.existsSync(goMod)) {
30
+ const content = readFile(goMod);
31
+ const parts = ['Go'];
32
+ if (content.includes('gin-gonic/gin')) parts.push('Gin');
33
+ else if (content.includes('labstack/echo')) parts.push('Echo');
34
+ else if (content.includes('gofiber/fiber')) parts.push('Fiber');
35
+ else if (content.includes('go-chi/chi')) parts.push('Chi');
36
+ if (content.includes('database/sql') || content.includes('sqlx') || content.includes('pgx')) parts.push('PostgreSQL');
37
+ if (content.includes('gorm.io')) parts.push('GORM');
38
+ if (content.includes('redis')) parts.push('Redis');
39
+ return parts.join(' + ');
40
+ }
41
+
42
+ // Rust
43
+ const cargoToml = path.join(cwd, 'Cargo.toml');
44
+ if (fs.existsSync(cargoToml)) {
45
+ const content = readFile(cargoToml);
46
+ const parts = ['Rust'];
47
+ if (content.includes('actix-web')) parts.push('Actix-Web');
48
+ else if (content.includes('axum')) parts.push('Axum');
49
+ else if (content.includes('warp')) parts.push('Warp');
50
+ if (content.includes('sqlx') || content.includes('diesel')) parts.push('PostgreSQL');
51
+ if (content.includes('serde')) parts.push('Serde');
52
+ if (content.includes('tokio')) parts.push('Tokio async');
53
+ return parts.join(' + ');
54
+ }
55
+
56
+ // Ruby / Rails
57
+ const gemfile = path.join(cwd, 'Gemfile');
58
+ if (fs.existsSync(gemfile)) {
59
+ const content = readFile(gemfile);
60
+ const parts: string[] = [];
61
+ if (content.includes("'rails'") || content.includes('"rails"')) parts.push('Ruby on Rails');
62
+ else if (content.includes("'sinatra'") || content.includes('"sinatra"')) parts.push('Sinatra');
63
+ else parts.push('Ruby');
64
+ if (content.includes('pg') || content.includes('postgresql')) parts.push('PostgreSQL');
65
+ else if (content.includes('mysql')) parts.push('MySQL');
66
+ else if (content.includes('sqlite')) parts.push('SQLite');
67
+ if (content.includes('rspec')) parts.push('RSpec');
68
+ if (content.includes('sidekiq')) parts.push('Sidekiq');
69
+ return parts.join(' + ');
70
+ }
71
+
72
+ // Python
73
+ const reqTxt = path.join(cwd, 'requirements.txt');
74
+ const pyproject = path.join(cwd, 'pyproject.toml');
75
+ const hasFastapi = fileContains(reqTxt, 'fastapi') || fileContains(pyproject, 'fastapi');
76
+ const hasDjango = fileContains(reqTxt, 'django') || fileContains(pyproject, 'django');
77
+ const hasFlask = fileContains(reqTxt, 'flask') || fileContains(pyproject, 'flask');
78
+ if (hasFastapi || hasDjango || hasFlask || fs.existsSync(reqTxt) || fs.existsSync(pyproject)) {
79
+ const parts: string[] = [];
80
+ if (hasFastapi) parts.push('FastAPI');
81
+ else if (hasDjango) parts.push('Django');
82
+ else if (hasFlask) parts.push('Flask');
83
+ else parts.push('Python');
84
+ const combined = readFile(reqTxt) + readFile(pyproject);
85
+ if (combined.includes('sqlalchemy') || combined.includes('SQLAlchemy')) parts.push('SQLAlchemy');
86
+ if (combined.includes('postgresql') || combined.includes('psycopg')) parts.push('PostgreSQL');
87
+ if (combined.includes('pydantic')) parts.push('Pydantic');
88
+ if (combined.includes('celery')) parts.push('Celery');
89
+ return parts.join(' + ');
90
+ }
91
+
92
+ // Node / JS / TS
93
+ const pkgPath = path.join(cwd, 'package.json');
94
+ if (!fs.existsSync(pkgPath)) return null;
95
+ const pkg = readJson(pkgPath);
96
+ if (!pkg) return null;
97
+
98
+ const deps: Record<string, string> = {
99
+ ...(pkg['dependencies'] as Record<string, string> ?? {}),
100
+ ...(pkg['devDependencies'] as Record<string, string> ?? {}),
101
+ };
102
+
103
+ const parts: string[] = [];
104
+ const isTs = 'typescript' in deps || fs.existsSync(path.join(cwd, 'tsconfig.json'));
105
+
106
+ // Framework
107
+ if ('next' in deps) {
108
+ const v = version(deps, 'next');
109
+ parts.push(v ? `Next.js ${v}` : 'Next.js');
110
+ } else if ('nuxt' in deps || 'nuxt3' in deps) {
111
+ parts.push('Nuxt');
112
+ } else if ('remix' in deps || '@remix-run/react' in deps) {
113
+ parts.push('Remix');
114
+ } else if ('astro' in deps) {
115
+ parts.push('Astro');
116
+ } else if ('express' in deps) {
117
+ parts.push('Express');
118
+ } else if ('fastify' in deps) {
119
+ parts.push('Fastify');
120
+ } else if ('hono' in deps) {
121
+ parts.push('Hono');
122
+ } else if ('react' in deps) {
123
+ parts.push('React');
124
+ } else if ('vue' in deps) {
125
+ parts.push('Vue');
126
+ } else if ('svelte' in deps || '@sveltejs/kit' in deps) {
127
+ parts.push('SvelteKit');
128
+ }
129
+
130
+ // Database / ORM
131
+ if ('@supabase/supabase-js' in deps) parts.push('Supabase');
132
+ if ('prisma' in deps || '@prisma/client' in deps) parts.push('Prisma');
133
+ if ('drizzle-orm' in deps) parts.push('Drizzle');
134
+ if ('typeorm' in deps) parts.push('TypeORM');
135
+ if ('mongoose' in deps) parts.push('MongoDB');
136
+
137
+ // Meta-frameworks / routers
138
+ if ('@trpc/server' in deps) parts.push('tRPC');
139
+ if ('graphql' in deps && ('apollo-server' in deps || '@apollo/server' in deps)) parts.push('GraphQL/Apollo');
140
+
141
+ // Auth
142
+ if ('next-auth' in deps || '@auth/core' in deps) parts.push('NextAuth');
143
+ if ('clerk' in deps || '@clerk/nextjs' in deps) parts.push('Clerk');
144
+
145
+ // UI
146
+ if ('tailwindcss' in deps) parts.push('Tailwind CSS');
147
+
148
+ // Language suffix
149
+ if (isTs) parts.push('TypeScript');
150
+
151
+ if (parts.length === 0) return null;
152
+ return parts.join(' + ');
153
+ }
@@ -17,6 +17,7 @@ export interface ReviewPhaseInput {
17
17
  engine: ReviewEngine;
18
18
  config: AutopilotConfig;
19
19
  cwd?: string;
20
+ gitSummary?: string;
20
21
  budgetRemainingUSD?: number;
21
22
  }
22
23
 
@@ -49,7 +50,7 @@ export async function runReviewPhase(input: ReviewPhaseInput): Promise<ReviewPha
49
50
  const output = await input.engine.review({
50
51
  content: chunk.content,
51
52
  kind: chunk.kind,
52
- context: { stack: input.config.stack, cwd: input.cwd },
53
+ context: { stack: input.config.stack, cwd: input.cwd, gitSummary: input.gitSummary },
53
54
  });
54
55
  allFindings.push(...output.findings);
55
56
  if (output.usage) {
@@ -17,6 +17,7 @@ export interface RunInput {
17
17
  reviewEngine?: ReviewEngine;
18
18
  staticRules?: StaticRule[];
19
19
  cwd?: string;
20
+ gitSummary?: string;
20
21
  }
21
22
 
22
23
  export interface RunResult {
@@ -59,6 +60,7 @@ export async function runAutopilot(input: RunInput): Promise<RunResult> {
59
60
  engine: input.reviewEngine,
60
61
  config: input.config,
61
62
  cwd: input.cwd,
63
+ gitSummary: input.gitSummary,
62
64
  budgetRemainingUSD: budgetUSD,
63
65
  });
64
66
  phases.push(reviewResult);