@delegance/claude-autopilot 1.0.0-alpha.4
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 +45 -0
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/bin/autopilot.js +15 -0
- package/package.json +41 -0
- package/presets/go/autopilot.config.yaml +20 -0
- package/presets/go/rules/go-sql-injection.ts +65 -0
- package/presets/go/stack.md +20 -0
- package/presets/nextjs-supabase/autopilot.config.yaml +29 -0
- package/presets/nextjs-supabase/rules/supabase-rls-bypass.ts +39 -0
- package/presets/nextjs-supabase/stack.md +20 -0
- package/presets/python-fastapi/autopilot.config.yaml +20 -0
- package/presets/python-fastapi/rules/fastapi-missing-auth.ts +50 -0
- package/presets/python-fastapi/stack.md +20 -0
- package/presets/rails-postgres/autopilot.config.yaml +21 -0
- package/presets/rails-postgres/rules/rails-sql-injection.ts +42 -0
- package/presets/rails-postgres/stack.md +20 -0
- package/presets/t3/autopilot.config.yaml +22 -0
- package/presets/t3/rules/t3-server-only.ts +35 -0
- package/presets/t3/stack.md +20 -0
- package/scripts/test-runner.mjs +16 -0
- package/src/adapters/base.ts +19 -0
- package/src/adapters/loader.ts +101 -0
- package/src/adapters/migration-runner/supabase.ts +56 -0
- package/src/adapters/migration-runner/types.ts +36 -0
- package/src/adapters/review-bot-parser/cursor.ts +13 -0
- package/src/adapters/review-bot-parser/declarative-base.ts +64 -0
- package/src/adapters/review-bot-parser/types.ts +9 -0
- package/src/adapters/review-engine/codex.ts +108 -0
- package/src/adapters/review-engine/types.ts +19 -0
- package/src/adapters/vcs-host/github.ts +77 -0
- package/src/adapters/vcs-host/types.ts +44 -0
- package/src/cli/index.ts +110 -0
- package/src/cli/init.ts +88 -0
- package/src/cli/preflight.ts +154 -0
- package/src/cli/run.ts +152 -0
- package/src/cli/watch.ts +169 -0
- package/src/core/.gitkeep +0 -0
- package/src/core/cache/cached-engine.ts +32 -0
- package/src/core/cache/review-cache.ts +70 -0
- package/src/core/chunking/index.ts +82 -0
- package/src/core/config/loader.ts +41 -0
- package/src/core/config/preset-resolver.ts +46 -0
- package/src/core/config/schema.ts +63 -0
- package/src/core/config/types.ts +42 -0
- package/src/core/errors.ts +37 -0
- package/src/core/findings/dedup.ts +14 -0
- package/src/core/findings/types.ts +39 -0
- package/src/core/git/touched-files.ts +51 -0
- package/src/core/index.ts +1 -0
- package/src/core/logging/ndjson-writer.ts +37 -0
- package/src/core/logging/redaction.ts +19 -0
- package/src/core/phases/static-rules.ts +80 -0
- package/src/core/phases/tests.ts +51 -0
- package/src/core/pipeline/review-phase.ts +87 -0
- package/src/core/pipeline/run.ts +80 -0
- package/src/core/runtime/idempotency.ts +6 -0
- package/src/core/runtime/lock.ts +29 -0
- package/src/core/runtime/state.ts +97 -0
- package/src/core/shell.ts +48 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ReviewEngine } from '../../adapters/review-engine/types.ts';
|
|
2
|
+
import type { Finding } from '../findings/types.ts';
|
|
3
|
+
import type { AutopilotConfig } from '../config/types.ts';
|
|
4
|
+
import { buildReviewChunks } from '../chunking/index.ts';
|
|
5
|
+
|
|
6
|
+
export interface ReviewPhaseResult {
|
|
7
|
+
phase: 'review';
|
|
8
|
+
status: 'pass' | 'warn' | 'fail' | 'skip';
|
|
9
|
+
findings: Finding[];
|
|
10
|
+
costUSD?: number;
|
|
11
|
+
usage?: { input: number; output: number };
|
|
12
|
+
durationMs: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ReviewPhaseInput {
|
|
16
|
+
touchedFiles: string[];
|
|
17
|
+
engine: ReviewEngine;
|
|
18
|
+
config: AutopilotConfig;
|
|
19
|
+
cwd?: string;
|
|
20
|
+
budgetRemainingUSD?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runReviewPhase(input: ReviewPhaseInput): Promise<ReviewPhaseResult> {
|
|
24
|
+
const start = Date.now();
|
|
25
|
+
|
|
26
|
+
if (input.touchedFiles.length === 0) {
|
|
27
|
+
return { phase: 'review', status: 'skip', findings: [], durationMs: Date.now() - start };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const chunks = await buildReviewChunks({
|
|
31
|
+
touchedFiles: input.touchedFiles,
|
|
32
|
+
strategy: input.config.reviewStrategy ?? 'auto',
|
|
33
|
+
chunking: input.config.chunking,
|
|
34
|
+
engine: input.engine,
|
|
35
|
+
cwd: input.cwd,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const allFindings: Finding[] = [];
|
|
39
|
+
let totalInputTokens = 0;
|
|
40
|
+
let totalOutputTokens = 0;
|
|
41
|
+
let totalCostUSD = 0;
|
|
42
|
+
let budgetExceeded = false;
|
|
43
|
+
|
|
44
|
+
for (const chunk of chunks) {
|
|
45
|
+
if (input.budgetRemainingUSD !== undefined && totalCostUSD >= input.budgetRemainingUSD) {
|
|
46
|
+
budgetExceeded = true;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
const output = await input.engine.review({
|
|
50
|
+
content: chunk.content,
|
|
51
|
+
kind: chunk.kind,
|
|
52
|
+
context: { stack: input.config.stack },
|
|
53
|
+
});
|
|
54
|
+
allFindings.push(...output.findings);
|
|
55
|
+
if (output.usage) {
|
|
56
|
+
totalInputTokens += output.usage.input;
|
|
57
|
+
totalOutputTokens += output.usage.output;
|
|
58
|
+
if (output.usage.costUSD !== undefined) totalCostUSD += output.usage.costUSD;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (budgetExceeded) {
|
|
63
|
+
allFindings.push({
|
|
64
|
+
id: 'budget-exceeded',
|
|
65
|
+
source: 'pipeline',
|
|
66
|
+
severity: 'warning',
|
|
67
|
+
category: 'budget',
|
|
68
|
+
file: '<pipeline>',
|
|
69
|
+
message: `Review budget of $${input.budgetRemainingUSD} USD exceeded — remaining chunks skipped`,
|
|
70
|
+
protectedPath: false,
|
|
71
|
+
createdAt: new Date().toISOString(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const hasCritical = allFindings.some(f => f.severity === 'critical');
|
|
76
|
+
const hasWarning = allFindings.some(f => f.severity === 'warning');
|
|
77
|
+
const status = hasCritical ? 'fail' : hasWarning ? 'warn' : 'pass';
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
phase: 'review',
|
|
81
|
+
status,
|
|
82
|
+
findings: allFindings,
|
|
83
|
+
costUSD: totalCostUSD > 0 ? totalCostUSD : undefined,
|
|
84
|
+
usage: totalInputTokens > 0 ? { input: totalInputTokens, output: totalOutputTokens } : undefined,
|
|
85
|
+
durationMs: Date.now() - start,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { AutopilotConfig } from '../config/types.ts';
|
|
2
|
+
import type { StaticRule } from '../phases/static-rules.ts';
|
|
3
|
+
import type { ReviewEngine } from '../../adapters/review-engine/types.ts';
|
|
4
|
+
import type { Finding } from '../findings/types.ts';
|
|
5
|
+
import type { StaticRulesPhaseResult } from '../phases/static-rules.ts';
|
|
6
|
+
import type { TestsPhaseResult } from '../phases/tests.ts';
|
|
7
|
+
import type { ReviewPhaseResult } from './review-phase.ts';
|
|
8
|
+
import { runStaticRulesPhase } from '../phases/static-rules.ts';
|
|
9
|
+
import { runTestsPhase } from '../phases/tests.ts';
|
|
10
|
+
import { runReviewPhase } from './review-phase.ts';
|
|
11
|
+
|
|
12
|
+
export type PhaseResult = StaticRulesPhaseResult | TestsPhaseResult | ReviewPhaseResult;
|
|
13
|
+
|
|
14
|
+
export interface RunInput {
|
|
15
|
+
touchedFiles: string[];
|
|
16
|
+
config: AutopilotConfig;
|
|
17
|
+
reviewEngine?: ReviewEngine;
|
|
18
|
+
staticRules?: StaticRule[];
|
|
19
|
+
cwd?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RunResult {
|
|
23
|
+
status: 'pass' | 'warn' | 'fail';
|
|
24
|
+
phases: PhaseResult[];
|
|
25
|
+
allFindings: Finding[];
|
|
26
|
+
totalCostUSD?: number;
|
|
27
|
+
durationMs: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runAutopilot(input: RunInput): Promise<RunResult> {
|
|
31
|
+
const start = Date.now();
|
|
32
|
+
const phases: PhaseResult[] = [];
|
|
33
|
+
let totalCostUSD: number | undefined;
|
|
34
|
+
|
|
35
|
+
// Static-rules phase — fail fast on critical
|
|
36
|
+
if (input.staticRules && input.staticRules.length > 0) {
|
|
37
|
+
const result = await runStaticRulesPhase({
|
|
38
|
+
touchedFiles: input.touchedFiles,
|
|
39
|
+
rules: input.staticRules,
|
|
40
|
+
});
|
|
41
|
+
phases.push(result);
|
|
42
|
+
if (result.status === 'fail') return finalize(phases, start, totalCostUSD);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Tests phase — fail fast on test failure
|
|
46
|
+
const testsResult = await runTestsPhase({
|
|
47
|
+
touchedFiles: input.touchedFiles,
|
|
48
|
+
testCommand: input.config.testCommand,
|
|
49
|
+
cwd: input.cwd,
|
|
50
|
+
});
|
|
51
|
+
phases.push(testsResult);
|
|
52
|
+
if (testsResult.status === 'fail') return finalize(phases, start, totalCostUSD);
|
|
53
|
+
|
|
54
|
+
// Review phase (optional — only when engine is provided)
|
|
55
|
+
if (input.reviewEngine) {
|
|
56
|
+
const budgetUSD = (input.config.cost as { budgetUSD?: number } | undefined)?.budgetUSD;
|
|
57
|
+
const reviewResult = await runReviewPhase({
|
|
58
|
+
touchedFiles: input.touchedFiles,
|
|
59
|
+
engine: input.reviewEngine,
|
|
60
|
+
config: input.config,
|
|
61
|
+
cwd: input.cwd,
|
|
62
|
+
budgetRemainingUSD: budgetUSD,
|
|
63
|
+
});
|
|
64
|
+
phases.push(reviewResult);
|
|
65
|
+
if (reviewResult.costUSD !== undefined) {
|
|
66
|
+
totalCostUSD = (totalCostUSD ?? 0) + reviewResult.costUSD;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return finalize(phases, start, totalCostUSD);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function finalize(phases: PhaseResult[], start: number, totalCostUSD: number | undefined): RunResult {
|
|
74
|
+
const allFindings: Finding[] = phases.flatMap(p => p.findings);
|
|
75
|
+
// Trust each phase's own status — it accounts for autofixes and dedup
|
|
76
|
+
const anyFail = phases.some(p => p.status === 'fail');
|
|
77
|
+
const anyWarn = phases.some(p => p.status === 'warn');
|
|
78
|
+
const status: RunResult['status'] = anyFail ? 'fail' : anyWarn ? 'warn' : 'pass';
|
|
79
|
+
return { status, phases, allFindings, totalCostUSD, durationMs: Date.now() - start };
|
|
80
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export function idempotencyKey(runId: string, step: string, inputs: Record<string, unknown>): string {
|
|
4
|
+
const serialized = JSON.stringify({ runId, step, inputs });
|
|
5
|
+
return createHash('sha256').update(serialized).digest('hex').slice(0, 16);
|
|
6
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { AutopilotError } from '../errors.ts';
|
|
4
|
+
|
|
5
|
+
export interface LockHandle {
|
|
6
|
+
release(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function acquireLock(runId: string, lockDir = '.claude'): LockHandle {
|
|
10
|
+
fs.mkdirSync(lockDir, { recursive: true });
|
|
11
|
+
const lockPath = path.join(lockDir, '.lock');
|
|
12
|
+
try {
|
|
13
|
+
fs.writeFileSync(
|
|
14
|
+
lockPath,
|
|
15
|
+
JSON.stringify({ runId, pid: process.pid, acquiredAt: new Date().toISOString() }),
|
|
16
|
+
{ flag: 'wx' }
|
|
17
|
+
);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
throw new AutopilotError('Another autopilot run holds the lock', {
|
|
20
|
+
code: 'concurrency_lock',
|
|
21
|
+
details: { lockPath, cause: err instanceof Error ? err.message : String(err) },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
release: async () => {
|
|
26
|
+
try { await fs.promises.unlink(lockPath); } catch { /* best effort */ }
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { AutopilotError } from '../errors.ts';
|
|
4
|
+
|
|
5
|
+
export type PipelineStep =
|
|
6
|
+
| 'plan' | 'worktree' | 'implement' | 'migrate' | 'validate'
|
|
7
|
+
| 'push' | 'create-pr' | 'review' | 'bugbot';
|
|
8
|
+
|
|
9
|
+
export type StepStatus = 'pending' | 'in-progress' | 'completed' | 'failed' | 'skipped';
|
|
10
|
+
export type RunStatus = 'in-progress' | 'completed' | 'failed' | 'superseded';
|
|
11
|
+
|
|
12
|
+
export interface StepState {
|
|
13
|
+
status: StepStatus;
|
|
14
|
+
idempotencyKey?: string;
|
|
15
|
+
artifact?: string;
|
|
16
|
+
errorCode?: string;
|
|
17
|
+
attempts?: number;
|
|
18
|
+
lastCommitSha?: string;
|
|
19
|
+
appliedMigrations?: string[];
|
|
20
|
+
prNumber?: number;
|
|
21
|
+
alreadyExisted?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RunState {
|
|
25
|
+
runId: string;
|
|
26
|
+
topic: string;
|
|
27
|
+
startedAt: string;
|
|
28
|
+
lastUpdatedAt: string;
|
|
29
|
+
status: RunStatus;
|
|
30
|
+
currentStep: PipelineStep | null;
|
|
31
|
+
steps: Record<PipelineStep, StepState>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const ALL_STEPS: readonly PipelineStep[] = Object.freeze([
|
|
35
|
+
'plan', 'worktree', 'implement', 'migrate', 'validate', 'push', 'create-pr', 'review', 'bugbot',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
function stateFile(runId: string, runsDir: string): string {
|
|
39
|
+
return path.join(runsDir, runId, 'state.json');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function writeAtomic(file: string, content: string): Promise<void> {
|
|
43
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
44
|
+
await fs.writeFile(tmp, content, 'utf8');
|
|
45
|
+
await fs.rename(tmp, file);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CreateRunStateOptions {
|
|
49
|
+
runId: string;
|
|
50
|
+
topic: string;
|
|
51
|
+
runsDir?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function createRunState(options: CreateRunStateOptions): Promise<RunState> {
|
|
55
|
+
const runsDir = options.runsDir ?? path.join('.claude', 'runs');
|
|
56
|
+
await fs.mkdir(path.join(runsDir, options.runId), { recursive: true });
|
|
57
|
+
const now = new Date().toISOString();
|
|
58
|
+
const stepsInit = {} as Record<PipelineStep, StepState>;
|
|
59
|
+
for (const step of ALL_STEPS) stepsInit[step] = { status: 'pending' };
|
|
60
|
+
const state: RunState = {
|
|
61
|
+
runId: options.runId, topic: options.topic,
|
|
62
|
+
startedAt: now, lastUpdatedAt: now,
|
|
63
|
+
status: 'in-progress', currentStep: null, steps: stepsInit,
|
|
64
|
+
};
|
|
65
|
+
await writeAtomic(stateFile(options.runId, runsDir), JSON.stringify(state, null, 2));
|
|
66
|
+
return state;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function loadRunState(runId: string, runsDir?: string): Promise<RunState> {
|
|
70
|
+
const dir = runsDir ?? path.join('.claude', 'runs');
|
|
71
|
+
const file = stateFile(runId, dir);
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(await fs.readFile(file, 'utf8')) as RunState;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
throw new AutopilotError(`Run state not found: ${runId}`, {
|
|
76
|
+
code: 'user_input',
|
|
77
|
+
details: { runId, file, cause: err instanceof Error ? err.message : String(err) },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface UpdateStepOptions {
|
|
83
|
+
runId: string;
|
|
84
|
+
runsDir?: string;
|
|
85
|
+
step: PipelineStep;
|
|
86
|
+
update: Partial<StepState>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function updateStepStatus(options: UpdateStepOptions): Promise<RunState> {
|
|
90
|
+
const runsDir = options.runsDir ?? path.join('.claude', 'runs');
|
|
91
|
+
const state = await loadRunState(options.runId, runsDir);
|
|
92
|
+
state.steps[options.step] = { ...state.steps[options.step], ...options.update };
|
|
93
|
+
state.lastUpdatedAt = new Date().toISOString();
|
|
94
|
+
if (options.update.status === 'in-progress') state.currentStep = options.step;
|
|
95
|
+
await writeAtomic(stateFile(options.runId, runsDir), JSON.stringify(state, null, 2));
|
|
96
|
+
return state;
|
|
97
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// src/core/shell.ts
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { AutopilotError, type ErrorCode } from './errors.ts';
|
|
5
|
+
|
|
6
|
+
export interface RunOptions {
|
|
7
|
+
timeout?: number;
|
|
8
|
+
input?: string;
|
|
9
|
+
cwd?: string;
|
|
10
|
+
env?: NodeJS.ProcessEnv;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Run a command; return stdout on success, null on any failure. Never throws. */
|
|
14
|
+
export function runSafe(cmd: string, args: string[], options: RunOptions = {}): string | null {
|
|
15
|
+
try {
|
|
16
|
+
const result = execFileSync(cmd, args, {
|
|
17
|
+
encoding: 'utf8',
|
|
18
|
+
stdio: options.input ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
|
|
19
|
+
timeout: options.timeout ?? 60000,
|
|
20
|
+
input: options.input,
|
|
21
|
+
cwd: options.cwd,
|
|
22
|
+
env: options.env,
|
|
23
|
+
});
|
|
24
|
+
return result.toString();
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Run a command; throw AutopilotError on failure. */
|
|
31
|
+
export function runThrowing(cmd: string, args: string[], options: RunOptions & { errorCode?: ErrorCode; provider?: string } = {}): string {
|
|
32
|
+
try {
|
|
33
|
+
return execFileSync(cmd, args, {
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
stdio: options.input ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
|
|
36
|
+
timeout: options.timeout ?? 60000,
|
|
37
|
+
input: options.input,
|
|
38
|
+
cwd: options.cwd,
|
|
39
|
+
env: options.env,
|
|
40
|
+
}).toString();
|
|
41
|
+
} catch (err) {
|
|
42
|
+
throw new AutopilotError(`Command failed: ${cmd} ${args.join(' ')}`, {
|
|
43
|
+
code: options.errorCode ?? 'transient_network',
|
|
44
|
+
provider: options.provider,
|
|
45
|
+
details: { cmd, args, cause: err instanceof Error ? err.message : String(err) },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|