@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +58 -0
  4. package/bin/autopilot.js +15 -0
  5. package/package.json +41 -0
  6. package/presets/go/autopilot.config.yaml +20 -0
  7. package/presets/go/rules/go-sql-injection.ts +65 -0
  8. package/presets/go/stack.md +20 -0
  9. package/presets/nextjs-supabase/autopilot.config.yaml +29 -0
  10. package/presets/nextjs-supabase/rules/supabase-rls-bypass.ts +39 -0
  11. package/presets/nextjs-supabase/stack.md +20 -0
  12. package/presets/python-fastapi/autopilot.config.yaml +20 -0
  13. package/presets/python-fastapi/rules/fastapi-missing-auth.ts +50 -0
  14. package/presets/python-fastapi/stack.md +20 -0
  15. package/presets/rails-postgres/autopilot.config.yaml +21 -0
  16. package/presets/rails-postgres/rules/rails-sql-injection.ts +42 -0
  17. package/presets/rails-postgres/stack.md +20 -0
  18. package/presets/t3/autopilot.config.yaml +22 -0
  19. package/presets/t3/rules/t3-server-only.ts +35 -0
  20. package/presets/t3/stack.md +20 -0
  21. package/scripts/test-runner.mjs +16 -0
  22. package/src/adapters/base.ts +19 -0
  23. package/src/adapters/loader.ts +101 -0
  24. package/src/adapters/migration-runner/supabase.ts +56 -0
  25. package/src/adapters/migration-runner/types.ts +36 -0
  26. package/src/adapters/review-bot-parser/cursor.ts +13 -0
  27. package/src/adapters/review-bot-parser/declarative-base.ts +64 -0
  28. package/src/adapters/review-bot-parser/types.ts +9 -0
  29. package/src/adapters/review-engine/codex.ts +108 -0
  30. package/src/adapters/review-engine/types.ts +19 -0
  31. package/src/adapters/vcs-host/github.ts +77 -0
  32. package/src/adapters/vcs-host/types.ts +44 -0
  33. package/src/cli/index.ts +110 -0
  34. package/src/cli/init.ts +88 -0
  35. package/src/cli/preflight.ts +154 -0
  36. package/src/cli/run.ts +152 -0
  37. package/src/cli/watch.ts +169 -0
  38. package/src/core/.gitkeep +0 -0
  39. package/src/core/cache/cached-engine.ts +32 -0
  40. package/src/core/cache/review-cache.ts +70 -0
  41. package/src/core/chunking/index.ts +82 -0
  42. package/src/core/config/loader.ts +41 -0
  43. package/src/core/config/preset-resolver.ts +46 -0
  44. package/src/core/config/schema.ts +63 -0
  45. package/src/core/config/types.ts +42 -0
  46. package/src/core/errors.ts +37 -0
  47. package/src/core/findings/dedup.ts +14 -0
  48. package/src/core/findings/types.ts +39 -0
  49. package/src/core/git/touched-files.ts +51 -0
  50. package/src/core/index.ts +1 -0
  51. package/src/core/logging/ndjson-writer.ts +37 -0
  52. package/src/core/logging/redaction.ts +19 -0
  53. package/src/core/phases/static-rules.ts +80 -0
  54. package/src/core/phases/tests.ts +51 -0
  55. package/src/core/pipeline/review-phase.ts +87 -0
  56. package/src/core/pipeline/run.ts +80 -0
  57. package/src/core/runtime/idempotency.ts +6 -0
  58. package/src/core/runtime/lock.ts +29 -0
  59. package/src/core/runtime/state.ts +97 -0
  60. package/src/core/shell.ts +48 -0
@@ -0,0 +1,19 @@
1
+ // src/adapters/base.ts
2
+
3
+ export interface AdapterBase {
4
+ name: string;
5
+ apiVersion: string;
6
+ getCapabilities(): Capabilities;
7
+ }
8
+
9
+ export interface Capabilities {
10
+ [feature: string]: boolean | number | string;
11
+ }
12
+
13
+ export const CORE_ADAPTER_API_VERSION_MAJOR = 1;
14
+
15
+ export function checkApiVersionCompatibility(adapterApiVersion: string): boolean {
16
+ const parts = adapterApiVersion.split('.');
17
+ const major = parseInt(parts[0] ?? '0', 10);
18
+ return major === CORE_ADAPTER_API_VERSION_MAJOR;
19
+ }
@@ -0,0 +1,101 @@
1
+ import * as path from 'node:path';
2
+ import { AutopilotError } from '../core/errors.ts';
3
+ import { checkApiVersionCompatibility, type AdapterBase } from './base.ts';
4
+
5
+ export type IntegrationPoint = 'review-engine' | 'vcs-host' | 'migration-runner' | 'review-bot-parser';
6
+
7
+ export interface LoadAdapterOptions {
8
+ point: IntegrationPoint;
9
+ ref: string;
10
+ options?: Record<string, unknown>;
11
+ /** Allow loading adapters from arbitrary local paths. Off by default for security. */
12
+ unsafeAllowLocalAdapters?: boolean;
13
+ }
14
+
15
+ const BUILTIN_PATHS: Record<IntegrationPoint, Record<string, string>> = {
16
+ 'review-engine': { codex: './review-engine/codex.ts' },
17
+ 'vcs-host': { github: './vcs-host/github.ts' },
18
+ 'migration-runner': { supabase: './migration-runner/supabase.ts' },
19
+ 'review-bot-parser': { cursor: './review-bot-parser/cursor.ts' },
20
+ };
21
+
22
+ const REQUIRED_BY_POINT: Record<IntegrationPoint, string[]> = {
23
+ 'review-engine': ['review', 'estimateTokens'],
24
+ 'vcs-host': ['getPrDiff', 'getPrMetadata', 'postComment', 'getReviewComments', 'replyToComment', 'createPr', 'push'],
25
+ 'migration-runner': ['discover', 'dryRun', 'apply', 'ledger', 'alreadyApplied'],
26
+ 'review-bot-parser': ['detect', 'fetchFindings', 'detectDismissal'],
27
+ };
28
+
29
+ function isPathRef(ref: string): boolean {
30
+ return ref.startsWith('./') || ref.startsWith('/') || ref.startsWith('../') || ref.endsWith('.ts') || ref.endsWith('.js');
31
+ }
32
+
33
+ export async function loadAdapter<T extends AdapterBase>(options: LoadAdapterOptions): Promise<T> {
34
+ const { point, ref } = options;
35
+ let modulePath: string;
36
+
37
+ if (isPathRef(ref)) {
38
+ if (!options.unsafeAllowLocalAdapters) {
39
+ throw new AutopilotError(
40
+ `Path-based adapter refs require unsafeAllowLocalAdapters:true — set this only for trusted local adapters`,
41
+ { code: 'invalid_config', details: { point, ref } }
42
+ );
43
+ }
44
+ modulePath = path.resolve(ref);
45
+ } else {
46
+ const builtin = BUILTIN_PATHS[point]?.[ref];
47
+ if (!builtin) {
48
+ throw new AutopilotError(`Unknown built-in ${point} adapter: "${ref}"`, {
49
+ code: 'invalid_config',
50
+ details: { point, ref, available: Object.keys(BUILTIN_PATHS[point] ?? {}) },
51
+ });
52
+ }
53
+ modulePath = new URL(builtin, import.meta.url).pathname;
54
+ }
55
+
56
+ let mod: { default?: T } | T;
57
+ try {
58
+ mod = (await import(modulePath)) as { default?: T } | T;
59
+ } catch (err) {
60
+ throw new AutopilotError(`Failed to import adapter from ${modulePath}`, {
61
+ code: 'invalid_config',
62
+ details: { point, ref, modulePath, cause: err instanceof Error ? err.message : String(err) },
63
+ });
64
+ }
65
+
66
+ const adapter = ('default' in mod ? mod.default : mod) as T;
67
+ if (!adapter || typeof adapter !== 'object') {
68
+ throw new AutopilotError(`Adapter module did not export a valid adapter object`, {
69
+ code: 'invalid_config',
70
+ details: { point, ref, modulePath },
71
+ });
72
+ }
73
+
74
+ validateShape(adapter, point, modulePath);
75
+
76
+ if (!checkApiVersionCompatibility(adapter.apiVersion)) {
77
+ throw new AutopilotError(`Adapter apiVersion ${adapter.apiVersion} incompatible with core`, {
78
+ code: 'invalid_config',
79
+ details: { point, ref, adapterApiVersion: adapter.apiVersion },
80
+ });
81
+ }
82
+
83
+ return adapter;
84
+ }
85
+
86
+ function validateShape(adapter: AdapterBase, point: IntegrationPoint, modulePath: string): void {
87
+ const missing: string[] = [];
88
+ const required = ['getCapabilities', ...REQUIRED_BY_POINT[point]];
89
+ for (const method of required) {
90
+ if (typeof (adapter as unknown as Record<string, unknown>)[method] !== 'function') missing.push(method);
91
+ }
92
+ if (typeof adapter.name !== 'string' || typeof adapter.apiVersion !== 'string') {
93
+ missing.push('name/apiVersion');
94
+ }
95
+ if (missing.length > 0) {
96
+ throw new AutopilotError(
97
+ `Adapter at ${modulePath} missing required methods: ${missing.join(', ')}`,
98
+ { code: 'invalid_config', details: { point, modulePath, missing } }
99
+ );
100
+ }
101
+ }
@@ -0,0 +1,56 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { runSafe } from '../../core/shell.ts';
4
+ import type { Capabilities } from '../base.ts';
5
+ import type { MigrationRunner, Migration, MigrationEnv, DryRunResult, ApplyResult, LedgerEntry } from './types.ts';
6
+
7
+ export const supabaseAdapter: MigrationRunner = {
8
+ name: 'supabase',
9
+ apiVersion: '1.0.0',
10
+
11
+ getCapabilities(): Capabilities {
12
+ return { structuredOutput: false, streaming: false, maxContextTokens: 0, inlineComments: false };
13
+ },
14
+
15
+ discover(touchedFiles: string[]): Migration[] {
16
+ const sqlFiles = touchedFiles.filter(f => f.match(/data\/deltas\/[^/]+\.sql$/));
17
+ return sqlFiles.map(p => ({
18
+ name: path.basename(p, '.sql'),
19
+ path: p,
20
+ }));
21
+ },
22
+
23
+ async dryRun(migration: Migration): Promise<DryRunResult> {
24
+ try {
25
+ const content = migration.content ?? fs.readFileSync(migration.path, 'utf8');
26
+ if (!content.trim()) return { ok: false, errors: ['Migration file is empty'] };
27
+ return { ok: true };
28
+ } catch (err) {
29
+ return { ok: false, errors: [err instanceof Error ? err.message : String(err)] };
30
+ }
31
+ },
32
+
33
+ async apply(migration: Migration, env: MigrationEnv): Promise<ApplyResult> {
34
+ const start = Date.now();
35
+ const envFlag = env === 'prod' ? '--prod' : env === 'qa' ? '--qa' : '';
36
+ const args = ['tsx', 'scripts/supabase/migrate.ts', migration.path];
37
+ if (envFlag) args.push(envFlag);
38
+ const result = runSafe('npx', args);
39
+ if (result === null) {
40
+ return { ok: false, errors: [`Migration apply failed for ${migration.name} on ${env}`] };
41
+ }
42
+ return { ok: true, durationMs: Date.now() - start };
43
+ },
44
+
45
+ async ledger(_env: MigrationEnv): Promise<LedgerEntry[]> {
46
+ // alpha.1: full ledger query lands in alpha.2
47
+ return [];
48
+ },
49
+
50
+ async alreadyApplied(migration: Migration, _env: MigrationEnv): Promise<boolean> {
51
+ const result = runSafe('npx', ['tsx', 'scripts/supabase/migrate.ts', migration.path, '--inspect']);
52
+ return result !== null && result.includes('already applied');
53
+ },
54
+ };
55
+
56
+ export default supabaseAdapter;
@@ -0,0 +1,36 @@
1
+ import type { AdapterBase } from '../base.ts';
2
+
3
+ export type MigrationEnv = 'dev' | 'qa' | 'prod';
4
+
5
+ export interface Migration {
6
+ name: string;
7
+ path: string;
8
+ content?: string;
9
+ }
10
+
11
+ export interface DryRunResult {
12
+ ok: boolean;
13
+ errors?: string[];
14
+ warnings?: string[];
15
+ }
16
+
17
+ export interface ApplyResult {
18
+ ok: boolean;
19
+ appliedSha?: string;
20
+ durationMs?: number;
21
+ errors?: string[];
22
+ }
23
+
24
+ export interface LedgerEntry {
25
+ name: string;
26
+ appliedAt: string;
27
+ sha?: string;
28
+ }
29
+
30
+ export interface MigrationRunner extends AdapterBase {
31
+ discover(touchedFiles: string[]): Migration[];
32
+ dryRun(migration: Migration): Promise<DryRunResult>;
33
+ apply(migration: Migration, env: MigrationEnv): Promise<ApplyResult>;
34
+ ledger(env: MigrationEnv): Promise<LedgerEntry[]>;
35
+ alreadyApplied(migration: Migration, env: MigrationEnv): Promise<boolean>;
36
+ }
@@ -0,0 +1,13 @@
1
+ import { makeDeclarativeParser } from './declarative-base.ts';
2
+
3
+ export const cursorAdapter = makeDeclarativeParser({
4
+ name: 'cursor',
5
+ author: 'cursor[bot]',
6
+ severityMap: {
7
+ critical: /\bhigh\b|\bcritical\b/i,
8
+ warning: /\bmedium\b|\bwarning\b/i,
9
+ },
10
+ dismissalKeywords: ['false positive', 'not an issue', 'intentional', 'wontfix'],
11
+ });
12
+
13
+ export default cursorAdapter;
@@ -0,0 +1,64 @@
1
+ import type { Finding } from '../../core/findings/types.ts';
2
+ import type { GenericComment, VcsHost } from '../vcs-host/types.ts';
3
+ import type { Capabilities } from '../base.ts';
4
+ import type { ReviewBotParser } from './types.ts';
5
+
6
+ export interface DeclarativeParserConfig {
7
+ name: string;
8
+ author: string | RegExp;
9
+ severityMap: { critical?: RegExp; warning?: RegExp; note?: RegExp };
10
+ dismissalKeywords: string[];
11
+ }
12
+
13
+ export function makeDeclarativeParser(config: DeclarativeParserConfig): ReviewBotParser {
14
+ const authorTest = typeof config.author === 'string'
15
+ ? (a: string) => a === config.author
16
+ : (a: string) => (config.author as RegExp).test(a);
17
+
18
+ return {
19
+ name: config.name,
20
+ apiVersion: '1.0.0',
21
+
22
+ getCapabilities(): Capabilities {
23
+ return { structuredOutput: false, streaming: false, maxContextTokens: 0, inlineComments: true };
24
+ },
25
+
26
+ detect(comment: GenericComment): boolean {
27
+ return authorTest(comment.author);
28
+ },
29
+
30
+ async fetchFindings(vcs: VcsHost, pr: number | string): Promise<Finding[]> {
31
+ const comments = await vcs.getReviewComments(pr);
32
+ const botComments = comments.filter(c => authorTest(c.author));
33
+ return botComments.map((c, idx) => {
34
+ const body = c.body ?? '';
35
+ const severity = matchSeverity(body, config.severityMap);
36
+ return {
37
+ id: `${config.name}-${idx}-${c.id}`,
38
+ source: `review-bot:${config.name}` as const,
39
+ severity,
40
+ category: `${config.name}-finding`,
41
+ file: c.path ?? '<unspecified>',
42
+ line: c.line,
43
+ message: body.split('\n')[0]?.trim() ?? body,
44
+ protectedPath: false,
45
+ createdAt: new Date().toISOString(),
46
+ };
47
+ });
48
+ },
49
+
50
+ detectDismissal(reply: string): boolean {
51
+ const lower = reply.toLowerCase();
52
+ return config.dismissalKeywords.some(kw => lower.includes(kw));
53
+ },
54
+ };
55
+ }
56
+
57
+ function matchSeverity(
58
+ body: string,
59
+ map: DeclarativeParserConfig['severityMap']
60
+ ): Finding['severity'] {
61
+ if (map.critical && map.critical.test(body)) return 'critical';
62
+ if (map.warning && map.warning.test(body)) return 'warning';
63
+ return 'note';
64
+ }
@@ -0,0 +1,9 @@
1
+ import type { AdapterBase } from '../base.ts';
2
+ import type { Finding } from '../../core/findings/types.ts';
3
+ import type { GenericComment, VcsHost } from '../vcs-host/types.ts';
4
+
5
+ export interface ReviewBotParser extends AdapterBase {
6
+ detect(comment: GenericComment): boolean;
7
+ fetchFindings(vcs: VcsHost, pr: number | string): Promise<Finding[]>;
8
+ detectDismissal(reply: string): boolean;
9
+ }
@@ -0,0 +1,108 @@
1
+ import OpenAI from 'openai';
2
+ import type { Finding } from '../../core/findings/types.ts';
3
+ import { AutopilotError } from '../../core/errors.ts';
4
+ import type { Capabilities } from '../base.ts';
5
+ import type { ReviewEngine, ReviewInput, ReviewOutput } from './types.ts';
6
+
7
+ const DEFAULT_MODEL = process.env.CODEX_MODEL ?? 'gpt-5.3-codex';
8
+ const MAX_OUTPUT_TOKENS = 4096;
9
+
10
+ const SYSTEM_PROMPT_TEMPLATE = `You are a senior software architect providing feedback on designs, proposals, and ideas.
11
+
12
+ The codebase context:
13
+ {STACK}
14
+
15
+ Provide structured feedback in exactly this format:
16
+
17
+ ## Review Summary
18
+ One paragraph overall assessment.
19
+
20
+ ## Findings
21
+
22
+ For each finding, use this format:
23
+ ### [CRITICAL|WARNING|NOTE] <short title>
24
+ <explanation>
25
+ **Suggestion:** <actionable fix>
26
+
27
+ Rules:
28
+ - CRITICAL: Blocks implementation
29
+ - WARNING: Should address before implementing
30
+ - NOTE: Improvement suggestion
31
+ - Maximum 10 findings, ranked by severity
32
+ - Be specific and constructive`;
33
+
34
+ export const codexAdapter: ReviewEngine = {
35
+ name: 'codex',
36
+ apiVersion: '1.0.0',
37
+
38
+ getCapabilities(): Capabilities {
39
+ return { structuredOutput: false, streaming: false, maxContextTokens: 128000, inlineComments: false };
40
+ },
41
+
42
+ estimateTokens(content: string): number {
43
+ return Math.ceil(content.length / 4);
44
+ },
45
+
46
+ async review(input: ReviewInput): Promise<ReviewOutput> {
47
+ const apiKey = process.env.OPENAI_API_KEY;
48
+ if (!apiKey) {
49
+ throw new AutopilotError('OPENAI_API_KEY not set', { code: 'auth', provider: 'codex' });
50
+ }
51
+ const stack = input.context?.stack ?? 'A web application — stack details unspecified.';
52
+ const systemPrompt = SYSTEM_PROMPT_TEMPLATE.replace('{STACK}', stack);
53
+
54
+ const client = new OpenAI({ apiKey });
55
+ let response;
56
+ try {
57
+ response = await client.responses.create({
58
+ model: DEFAULT_MODEL,
59
+ instructions: systemPrompt,
60
+ input: `Please review the following:\n\n---\n\n${input.content}`,
61
+ max_output_tokens: MAX_OUTPUT_TOKENS,
62
+ });
63
+ } catch (err) {
64
+ const message = err instanceof Error ? err.message : String(err);
65
+ const isRateLimit = /rate.limit|429/i.test(message);
66
+ const isAuth = /unauthorized|401|invalid.api.key/i.test(message);
67
+ throw new AutopilotError(`Codex review call failed: ${message}`, {
68
+ code: isAuth ? 'auth' : isRateLimit ? 'rate_limit' : 'transient_network',
69
+ provider: 'codex',
70
+ retryable: isRateLimit,
71
+ });
72
+ }
73
+
74
+ const rawOutput = response.output_text ?? '';
75
+ return {
76
+ findings: parseCodexOutput(rawOutput),
77
+ rawOutput,
78
+ usage: response.usage ? { input: response.usage.input_tokens, output: response.usage.output_tokens } : undefined,
79
+ };
80
+ },
81
+ };
82
+
83
+ export default codexAdapter;
84
+
85
+ function parseCodexOutput(output: string): Finding[] {
86
+ const findings: Finding[] = [];
87
+ const regex = /### \[(CRITICAL|WARNING|NOTE)\]\s*(.+?)(?=\n### \[|## Review Summary|$)/gs;
88
+ let match: RegExpExecArray | null;
89
+ while ((match = regex.exec(output)) !== null) {
90
+ const severity = match[1]!.toLowerCase() as Finding['severity'];
91
+ const body = match[2]!.trim();
92
+ const titleEnd = body.indexOf('\n');
93
+ const title = (titleEnd > 0 ? body.slice(0, titleEnd) : body).trim();
94
+ const suggestion = body.match(/\*\*Suggestion:\*\*\s*(.+)/s)?.[1]?.trim();
95
+ findings.push({
96
+ id: `codex-${findings.length}`,
97
+ source: 'review-engine',
98
+ severity,
99
+ category: 'codex-review',
100
+ file: '<unspecified>',
101
+ message: title,
102
+ suggestion,
103
+ protectedPath: false,
104
+ createdAt: new Date().toISOString(),
105
+ });
106
+ }
107
+ return findings;
108
+ }
@@ -0,0 +1,19 @@
1
+ import type { AdapterBase } from '../base.ts';
2
+ import type { Finding } from '../../core/findings/types.ts';
3
+
4
+ export interface ReviewInput {
5
+ content: string;
6
+ kind: 'spec' | 'pr-diff' | 'file-batch';
7
+ context?: { spec?: string; plan?: string; stack?: string };
8
+ }
9
+
10
+ export interface ReviewOutput {
11
+ findings: Finding[];
12
+ rawOutput: string;
13
+ usage?: { input: number; output: number; costUSD?: number };
14
+ }
15
+
16
+ export interface ReviewEngine extends AdapterBase {
17
+ review(input: ReviewInput): Promise<ReviewOutput>;
18
+ estimateTokens(content: string): number;
19
+ }
@@ -0,0 +1,77 @@
1
+ import { runSafe, runThrowing } from '../../core/shell.ts';
2
+ import { AutopilotError } from '../../core/errors.ts';
3
+ import type { Capabilities } from '../base.ts';
4
+ import type { VcsHost, GenericComment, PrMetadata, CreatePrOptions, CreatePrResult } from './types.ts';
5
+
6
+ export const githubAdapter: VcsHost = {
7
+ name: 'github',
8
+ apiVersion: '1.0.0',
9
+
10
+ getCapabilities(): Capabilities {
11
+ return { structuredOutput: true, streaming: false, maxContextTokens: 0, inlineComments: true };
12
+ },
13
+
14
+ async getPrDiff(pr: number | string): Promise<string> {
15
+ const result = runSafe('gh', ['pr', 'diff', String(pr)]);
16
+ if (result === null) throw new AutopilotError(`Failed to get diff for PR ${pr}`, { code: 'transient_network' });
17
+ return result;
18
+ },
19
+
20
+ async getPrMetadata(pr: number | string): Promise<PrMetadata> {
21
+ const raw = runThrowing('gh', ['pr', 'view', String(pr), '--json', 'title,body,files,headRefOid,baseRefName,headRefName'], { errorCode: 'transient_network' });
22
+ const data = JSON.parse(raw) as { title: string; body: string; files: { path: string }[]; headRefOid: string; baseRefName: string; headRefName: string };
23
+ return {
24
+ title: data.title,
25
+ body: data.body ?? '',
26
+ files: (data.files ?? []).map((f: { path: string }) => f.path),
27
+ headSha: data.headRefOid,
28
+ baseRef: data.baseRefName,
29
+ headRef: data.headRefName,
30
+ };
31
+ },
32
+
33
+ async postComment(pr: number | string, body: string): Promise<void> {
34
+ runThrowing('gh', ['pr', 'comment', String(pr), '--body', body], { errorCode: 'transient_network' });
35
+ },
36
+
37
+ async getReviewComments(pr: number | string): Promise<GenericComment[]> {
38
+ const raw = runSafe('gh', ['api', `repos/{owner}/{repo}/pulls/${pr}/comments`, '--paginate']);
39
+ if (!raw) return [];
40
+ try {
41
+ const parsed = JSON.parse(raw) as Array<{ id: number; user: { login: string }; body: string; path: string; line?: number; html_url: string }>;
42
+ return parsed.map(c => ({ id: c.id, author: c.user.login, body: c.body, path: c.path, line: c.line, url: c.html_url }));
43
+ } catch { return []; }
44
+ },
45
+
46
+ async replyToComment(pr: number | string, commentId: string | number, body: string): Promise<void> {
47
+ runThrowing('gh', ['api', `repos/{owner}/{repo}/pulls/${pr}/comments/${commentId}/replies`, '--method', 'POST', '--field', `body=${body}`], { errorCode: 'transient_network' });
48
+ },
49
+
50
+ async createPr(opts: CreatePrOptions): Promise<CreatePrResult> {
51
+ const existing = runSafe('gh', ['pr', 'list', '--head', opts.head, '--json', 'number,url', '--limit', '1']);
52
+ if (existing) {
53
+ try {
54
+ const parsed = JSON.parse(existing) as Array<{ number: number; url: string }>;
55
+ if (parsed.length > 0 && parsed[0]) {
56
+ return { number: parsed[0].number, url: parsed[0].url, alreadyExisted: true };
57
+ }
58
+ } catch { /* fall through to create */ }
59
+ }
60
+
61
+ const args = ['pr', 'create', '--title', opts.title, '--body', opts.body, '--base', opts.base, '--head', opts.head];
62
+ if (opts.draft) args.push('--draft');
63
+ const raw = runThrowing('gh', args, { errorCode: 'transient_network' });
64
+ const url = raw.trim();
65
+ const match = url.match(/\/pull\/(\d+)$/);
66
+ const number = match ? parseInt(match[1]!, 10) : 0;
67
+ return { number, url, alreadyExisted: false };
68
+ },
69
+
70
+ async push(branch: string, opts?: { setUpstream?: boolean }): Promise<void> {
71
+ const args = ['push', 'origin', branch];
72
+ if (opts?.setUpstream) args.splice(1, 0, '-u');
73
+ runThrowing('git', args, { errorCode: 'transient_network' });
74
+ },
75
+ };
76
+
77
+ export default githubAdapter;
@@ -0,0 +1,44 @@
1
+ import type { AdapterBase } from '../base.ts';
2
+
3
+ export interface GenericComment {
4
+ id: string | number;
5
+ author: string;
6
+ body: string;
7
+ path?: string;
8
+ line?: number;
9
+ url?: string;
10
+ }
11
+
12
+ export interface PrMetadata {
13
+ title: string;
14
+ body: string;
15
+ files: string[];
16
+ headSha: string;
17
+ baseRef: string;
18
+ headRef: string;
19
+ }
20
+
21
+ export interface CreatePrOptions {
22
+ title: string;
23
+ body: string;
24
+ base: string;
25
+ head: string;
26
+ draft?: boolean;
27
+ idempotencyKey?: string;
28
+ }
29
+
30
+ export interface CreatePrResult {
31
+ number: number;
32
+ url: string;
33
+ alreadyExisted: boolean;
34
+ }
35
+
36
+ export interface VcsHost extends AdapterBase {
37
+ getPrDiff(pr: number | string): Promise<string>;
38
+ getPrMetadata(pr: number | string): Promise<PrMetadata>;
39
+ postComment(pr: number | string, body: string, idempotencyKey?: string): Promise<void>;
40
+ getReviewComments(pr: number | string): Promise<GenericComment[]>;
41
+ replyToComment(pr: number | string, commentId: string | number, body: string, idempotencyKey?: string): Promise<void>;
42
+ createPr(opts: CreatePrOptions): Promise<CreatePrResult>;
43
+ push(branch: string, opts?: { setUpstream?: boolean }): Promise<void>;
44
+ }
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * autopilot CLI — entry point
4
+ *
5
+ * Usage:
6
+ * autopilot init scaffold autopilot.config.yaml from a preset
7
+ * autopilot run run the pipeline on git-changed files
8
+ * autopilot run --base main diff against a specific branch
9
+ * autopilot run --dry-run show what would run, no execution
10
+ * autopilot watch re-run pipeline on every file save (debounced)
11
+ * autopilot preflight check prerequisites
12
+ */
13
+ import { runInit } from './init.ts';
14
+ import { runCommand } from './run.ts';
15
+ import { runWatch } from './watch.ts';
16
+
17
+ const args = process.argv.slice(2);
18
+
19
+ const SUBCOMMANDS = ['init', 'run', 'preflight', 'help', '--help', '-h'] as const;
20
+ const VALUE_FLAGS = ['base', 'config', 'files'];
21
+
22
+ // Detect first non-flag arg as subcommand, default to 'run'
23
+ const subcommand = (args[0] && !args[0].startsWith('--')) ? args[0] : 'run';
24
+
25
+ /** Returns value for --name <value>. Exits if value is missing (next token is another flag or absent). */
26
+ function flag(name: string): string | undefined {
27
+ const idx = args.indexOf(`--${name}`);
28
+ if (idx < 0) return undefined;
29
+ const val = args[idx + 1];
30
+ if (val === undefined || val.startsWith('--')) {
31
+ console.error(`\x1b[31m[autopilot] --${name} requires a value\x1b[0m`);
32
+ process.exit(1);
33
+ }
34
+ return val;
35
+ }
36
+
37
+ function boolFlag(name: string): boolean {
38
+ return args.includes(`--${name}`);
39
+ }
40
+
41
+ function printUsage(): void {
42
+ console.log(`
43
+ Usage: autopilot <command> [options]
44
+
45
+ Commands:
46
+ run Run the pipeline on git-changed files (default)
47
+ watch Watch for file changes and re-run pipeline on each save
48
+ init Scaffold autopilot.config.yaml from a preset
49
+ preflight Check prerequisites
50
+
51
+ Options (run):
52
+ --base <ref> Git base ref for diff (default: HEAD~1)
53
+ --config <path> Path to config file (default: ./autopilot.config.yaml)
54
+ --files <a,b,c> Explicit comma-separated file list (skips git detection)
55
+ --dry-run Show what would run without executing
56
+
57
+ Options (watch):
58
+ --config <path> Path to config file (default: ./autopilot.config.yaml)
59
+ --debounce <ms> Debounce delay in ms (default: 300)
60
+ `);
61
+ }
62
+
63
+ switch (subcommand) {
64
+ case 'init':
65
+ await runInit(process.cwd());
66
+ break;
67
+
68
+ case 'preflight':
69
+ await import('./preflight.ts');
70
+ break;
71
+
72
+ case 'help':
73
+ case '--help':
74
+ case '-h':
75
+ printUsage();
76
+ break;
77
+
78
+ case 'watch': {
79
+ const config = flag('config');
80
+ const debounceArg = flag('debounce');
81
+ const debounceMs = debounceArg ? parseInt(debounceArg, 10) : undefined;
82
+ if (debounceArg && (isNaN(debounceMs!) || debounceMs! < 0)) {
83
+ console.error(`\x1b[31m[autopilot] --debounce must be a non-negative integer\x1b[0m`);
84
+ process.exit(1);
85
+ }
86
+ await runWatch({ configPath: config, debounceMs });
87
+ break;
88
+ }
89
+
90
+ case 'run': {
91
+ const base = flag('base');
92
+ const config = flag('config');
93
+ const filesArg = flag('files');
94
+ const dryRun = boolFlag('dry-run');
95
+
96
+ const code = await runCommand({
97
+ base,
98
+ configPath: config,
99
+ files: filesArg ? filesArg.split(',').map(f => f.trim()) : undefined,
100
+ dryRun,
101
+ });
102
+ process.exit(code);
103
+ break;
104
+ }
105
+
106
+ default:
107
+ console.error(`\x1b[31m[autopilot] Unknown subcommand: "${subcommand}"\x1b[0m`);
108
+ printUsage();
109
+ process.exit(1);
110
+ }