@atmin.ai/review 0.1.0-alpha.2

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 (48) hide show
  1. package/DEPENDENCIES.md +15 -0
  2. package/LICENSE +55 -0
  3. package/NOTICE +6 -0
  4. package/README.md +144 -0
  5. package/dist/assessment.d.ts +26 -0
  6. package/dist/assessment.js +88 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +100 -0
  9. package/dist/contracts.d.ts +625 -0
  10. package/dist/contracts.js +86 -0
  11. package/dist/cost-report.d.ts +40 -0
  12. package/dist/cost-report.js +42 -0
  13. package/dist/github/api.d.ts +66 -0
  14. package/dist/github/api.js +190 -0
  15. package/dist/github/checks.d.ts +11 -0
  16. package/dist/github/checks.js +42 -0
  17. package/dist/github/cli.d.ts +2 -0
  18. package/dist/github/cli.js +94 -0
  19. package/dist/github/config.d.ts +16 -0
  20. package/dist/github/config.js +41 -0
  21. package/dist/github/runner.d.ts +7 -0
  22. package/dist/github/runner.js +67 -0
  23. package/dist/github/store.d.ts +40 -0
  24. package/dist/github/store.js +134 -0
  25. package/dist/github/task.d.ts +1 -0
  26. package/dist/github/task.js +29 -0
  27. package/dist/github/webhook.d.ts +6 -0
  28. package/dist/github/webhook.js +119 -0
  29. package/dist/github/worker.d.ts +19 -0
  30. package/dist/github/worker.js +209 -0
  31. package/dist/investigation.d.ts +198 -0
  32. package/dist/investigation.js +276 -0
  33. package/dist/openai-model.d.ts +2 -0
  34. package/dist/openai-model.js +41 -0
  35. package/dist/openrouter-model.d.ts +2 -0
  36. package/dist/openrouter-model.js +90 -0
  37. package/dist/provider-error.d.ts +10 -0
  38. package/dist/provider-error.js +21 -0
  39. package/dist/render.d.ts +4 -0
  40. package/dist/render.js +56 -0
  41. package/dist/run.d.ts +6 -0
  42. package/dist/run.js +39 -0
  43. package/dist/snapshot.d.ts +46 -0
  44. package/dist/snapshot.js +246 -0
  45. package/package.json +47 -0
  46. package/profiles/baseline-deepseek.json +10 -0
  47. package/profiles/smoke-openai.json +10 -0
  48. package/profiles/smoke-openrouter-free.json +10 -0
@@ -0,0 +1,40 @@
1
+ export declare function costReport(directory: string): {
2
+ repository: string;
3
+ pr: number;
4
+ headSha: string;
5
+ baseSha: string;
6
+ model: "gpt-5.4-2026-03-05" | "cohere/north-mini-code:free" | "deepseek/deepseek-v3.2";
7
+ rateCard: {
8
+ inputPerMillionUsd: number;
9
+ cachedInputPerMillionUsd: number;
10
+ outputPerMillionUsd: number;
11
+ checkedAt: string;
12
+ source: string;
13
+ providerRoute?: string;
14
+ };
15
+ profile: import("./investigation.js").Profile;
16
+ engineVersion: "r02-10";
17
+ promptHash: string;
18
+ toolHash: string;
19
+ status: "not-started" | "partial" | "completed";
20
+ stopReason: string | null;
21
+ finished: boolean;
22
+ changedFiles: number;
23
+ addedLines: number;
24
+ deletedLines: number;
25
+ binaryFiles: number;
26
+ inspectedFiles: number;
27
+ sourceReads: number;
28
+ modelCalls: number;
29
+ toolCalls: number;
30
+ actualInputTokens: number;
31
+ actualOutputTokens: number;
32
+ knownCostUsd: number;
33
+ unsettledCalls: number;
34
+ unsettledReservedUsd: number;
35
+ totalCostUsd: number | null;
36
+ elapsedMs: number | null;
37
+ findings: number;
38
+ complexity: string;
39
+ limits: string;
40
+ };
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { git, loadReview } from './snapshot.js';
4
+ import { parseProfile } from './investigation.js';
5
+ // Read-only export: include failed/partial runs and unsettled reservations so
6
+ // cheap failures cannot masquerade as low-cost completed reviews.
7
+ export function costReport(directory) {
8
+ const { packet, result } = loadReview(directory);
9
+ const receipt = JSON.parse(readFileSync(join(directory, 'receipt.json'), 'utf8'));
10
+ parseProfile(receipt.profile);
11
+ const number = (n) => { if (!Number.isFinite(n) || n < 0)
12
+ throw new Error('Invalid receipt quantity'); return n; };
13
+ const sum = (field) => receipt.calls.reduce((total, call) => total + number(call[field] ?? 0), 0);
14
+ const unknown = receipt.calls.filter(c => c.meteredUsd === null);
15
+ const stat = git(join(directory, 'source.git'), ['diff', '--no-renames', '--no-ext-diff', '--no-textconv', '--numstat', '-z', packet.mergeBaseSha, packet.headSha, '--']).toString('utf8');
16
+ let addedLines = 0, deletedLines = 0, binaryFiles = 0;
17
+ for (const entry of stat.split('\0').filter(Boolean)) {
18
+ const [added, deleted] = entry.split('\t');
19
+ if (added === '-' || deleted === '-')
20
+ binaryFiles++;
21
+ else {
22
+ addedLines += number(Number(added));
23
+ deletedLines += number(Number(deleted));
24
+ }
25
+ }
26
+ const source = result.evidence.filter(e => e.provenance === 'controller-captured');
27
+ return { repository: packet.repository, pr: packet.pr, headSha: packet.headSha, baseSha: packet.baseSha,
28
+ model: receipt.profile.model, rateCard: receipt.rateCard, profile: receipt.profile,
29
+ engineVersion: receipt.engineVersion, promptHash: receipt.promptHash, toolHash: receipt.toolHash,
30
+ status: result.status, stopReason: receipt.stopReason, finished: receipt.stopReason === 'finished' && result.status === 'completed',
31
+ changedFiles: packet.changedFiles.length, addedLines, deletedLines, binaryFiles,
32
+ inspectedFiles: new Set(source.flatMap(e => e.anchors.map(a => a.path))).size,
33
+ sourceReads: source.length, modelCalls: receipt.calls.length, toolCalls: receipt.toolCalls,
34
+ // Pending input quantities are estimates, not billed input usage.
35
+ actualInputTokens: receipt.calls.filter(c => c.outputTokens !== null).reduce((n, c) => n + number(c.inputTokens), 0),
36
+ actualOutputTokens: sum('outputTokens'), knownCostUsd: sum('meteredUsd'),
37
+ unsettledCalls: unknown.length, unsettledReservedUsd: unknown.reduce((n, c) => n + number(c.reservedUsd), 0),
38
+ totalCostUsd: unknown.length ? null : sum('meteredUsd'),
39
+ elapsedMs: receipt.finishedAt ? number(Date.parse(receipt.finishedAt) - Date.parse(receipt.startedAt)) : null,
40
+ findings: result.findings.length, complexity: 'unlabelled',
41
+ limits: 'Source-only inference cost. Code size and inspection breadth are proxies, not measured complexity. Compute, funding fees and human grading are excluded.' };
42
+ }
@@ -0,0 +1,66 @@
1
+ import type { ValidationCheck } from '../assessment.js';
2
+ import type { PilotConfig } from './config.js';
3
+ import type { PullState } from '../snapshot.js';
4
+ export interface LivePull extends PullState {
5
+ draft: boolean;
6
+ }
7
+ export interface Comment {
8
+ id: number;
9
+ body: string;
10
+ user: {
11
+ login: string;
12
+ type: string;
13
+ };
14
+ }
15
+ export interface CheckOutput {
16
+ status: 'in_progress' | 'completed';
17
+ conclusion?: 'success' | 'failure' | 'cancelled';
18
+ output: {
19
+ title: string;
20
+ summary: string;
21
+ };
22
+ details_url?: string;
23
+ }
24
+ export interface GitHub {
25
+ pull(pr: number): Promise<LivePull>;
26
+ canReview(login: string): Promise<boolean>;
27
+ readToken(): Promise<string>;
28
+ validation(head: string, names: string[]): Promise<ValidationCheck[]>;
29
+ summary(pr: number, marker: string): Promise<Comment | null>;
30
+ create(pr: number, body: string): Promise<number>;
31
+ update(id: number, body: string): Promise<void>;
32
+ findCheck(head: string, externalId: string): Promise<number | null>;
33
+ createCheck(head: string, externalId: string, output: CheckOutput): Promise<number>;
34
+ updateCheck(id: number, output: CheckOutput): Promise<void>;
35
+ }
36
+ export declare class GitHubError extends Error {
37
+ readonly status: number;
38
+ constructor(status: number);
39
+ }
40
+ export declare function appJwt(appId: string, key: string, now?: number): string;
41
+ export declare class AppGitHub implements GitHub {
42
+ private config;
43
+ private appId;
44
+ private key;
45
+ private transport;
46
+ private tokens;
47
+ private login;
48
+ constructor(config: PilotConfig, appId: string, key: string, transport?: typeof fetch);
49
+ private request;
50
+ private token;
51
+ readToken(): Promise<string>;
52
+ verifyInstallation(): Promise<{
53
+ repository: string;
54
+ bot: string;
55
+ }>;
56
+ pull(pr: number): Promise<LivePull>;
57
+ canReview(login: string): Promise<boolean>;
58
+ private botLogin;
59
+ summary(pr: number, marker: string): Promise<Comment | null>;
60
+ create(pr: number, body: string): Promise<number>;
61
+ update(id: number, body: string): Promise<void>;
62
+ validation(head: string, names: string[]): Promise<ValidationCheck[]>;
63
+ findCheck(head: string, externalId: string): Promise<number | null>;
64
+ createCheck(head: string, externalId: string, output: CheckOutput): Promise<number>;
65
+ updateCheck(id: number, output: CheckOutput): Promise<void>;
66
+ }
@@ -0,0 +1,190 @@
1
+ import { sign } from 'node:crypto';
2
+ export class GitHubError extends Error {
3
+ status;
4
+ constructor(status) {
5
+ super(`GitHub request failed (${status})`);
6
+ this.status = status;
7
+ }
8
+ }
9
+ export function appJwt(appId, key, now = Date.now()) {
10
+ const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
11
+ const payload = `${encode({ alg: 'RS256', typ: 'JWT' })}.${encode({ iat: Math.floor(now / 1000) - 60, exp: Math.floor(now / 1000) + 540, iss: appId })}`;
12
+ return `${payload}.${sign('RSA-SHA256', Buffer.from(payload), key).toString('base64url')}`;
13
+ }
14
+ export class AppGitHub {
15
+ config;
16
+ appId;
17
+ key;
18
+ transport;
19
+ tokens = new Map();
20
+ login;
21
+ constructor(config, appId, key, transport = fetch) {
22
+ this.config = config;
23
+ this.appId = appId;
24
+ this.key = key;
25
+ this.transport = transport;
26
+ }
27
+ async request(path, token, method = 'GET', body) {
28
+ const response = await this.transport(`https://api.github.com${path}`, {
29
+ method, redirect: 'error', signal: AbortSignal.timeout(8000),
30
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2026-03-10', 'User-Agent': 'atmin-review-pilot', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
31
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
32
+ }).catch(() => { throw new GitHubError(0); });
33
+ if (!response.ok)
34
+ throw new GitHubError(response.status);
35
+ // API response bodies/errors never enter logs, especially access-token responses.
36
+ try {
37
+ return await response.json();
38
+ }
39
+ catch {
40
+ throw new GitHubError(0);
41
+ }
42
+ }
43
+ async token(write) {
44
+ const scope = write === 'checks' ? 'checks' : write ? 'write' : 'read';
45
+ const cached = this.tokens.get(scope);
46
+ if (cached && cached.expires > Date.now() + 120_000)
47
+ return cached.token;
48
+ const result = await this.request(`/app/installations/${this.config.installationId}/access_tokens`, appJwt(this.appId, this.key), 'POST', {
49
+ repository_ids: [this.config.repositoryId],
50
+ permissions: write === 'checks' ? { checks: 'write' } : write ? { pull_requests: 'write' } : { contents: 'read', pull_requests: 'read' },
51
+ });
52
+ if (typeof result.token !== 'string' || !result.token || !Number.isFinite(Date.parse(result.expires_at)))
53
+ throw new Error('Invalid installation token response');
54
+ this.tokens.set(scope, { token: result.token, expires: Date.parse(result.expires_at) });
55
+ return result.token;
56
+ }
57
+ readToken() { return this.token(false); }
58
+ async verifyInstallation() {
59
+ const data = await this.request(`/repos/${this.config.repository}`, await this.token(false));
60
+ if (data.id !== this.config.repositoryId || data.full_name !== this.config.repository)
61
+ throw new Error('Installation repository identity mismatch');
62
+ // Minting the narrower publisher token checks granted permission without posting anything.
63
+ await this.token(true);
64
+ await this.token('checks');
65
+ return { repository: data.full_name, bot: await this.botLogin() };
66
+ }
67
+ async pull(pr) {
68
+ const token = await this.token(false);
69
+ const data = await this.request(`/repos/${this.config.repository}/pulls/${pr}`, token);
70
+ if (data.base?.repo?.id !== this.config.repositoryId || data.base?.repo?.full_name !== this.config.repository || data.number !== pr)
71
+ throw new Error('PR repository identity mismatch');
72
+ const ref = await this.request(`/repos/${this.config.repository}/git/ref/heads/${encodeURIComponent(data.base.ref)}`, token);
73
+ if (![data.head?.sha, ref.object?.sha].every(sha => typeof sha === 'string' && /^[a-f0-9]{40}$/.test(sha)) || typeof data.base.ref !== 'string' || !['open', 'closed'].includes(data.state) || typeof data.draft !== 'boolean')
74
+ throw new Error('Invalid live PR state');
75
+ return { repository: this.config.repository, pr, headSha: data.head.sha, baseSha: ref.object.sha, baseRef: data.base.ref, state: data.state, draft: data.draft };
76
+ }
77
+ async canReview(login) {
78
+ if (!/^[a-z\d](?:[a-z\d-]{0,38})$/i.test(login))
79
+ return false;
80
+ try {
81
+ const data = await this.request(`/repos/${this.config.repository}/collaborators/${encodeURIComponent(login)}/permission`, await this.token(false));
82
+ return ['write', 'maintain', 'admin'].includes(data.permission);
83
+ }
84
+ catch (error) {
85
+ if (error instanceof GitHubError && error.status === 404)
86
+ return false;
87
+ throw error;
88
+ }
89
+ }
90
+ async botLogin() {
91
+ if (!this.login) {
92
+ const app = await this.request('/app', appJwt(this.appId, this.key));
93
+ if (typeof app.slug !== 'string' || !/^[a-z\d-]+$/.test(app.slug))
94
+ throw new Error('Invalid App identity');
95
+ this.login = `${app.slug}[bot]`;
96
+ }
97
+ return this.login;
98
+ }
99
+ async summary(pr, marker) {
100
+ const login = await this.botLogin();
101
+ const token = await this.token(false);
102
+ let found = null;
103
+ for (let page = 1; page <= 20; page++) {
104
+ const comments = await this.request(`/repos/${this.config.repository}/issues/${pr}/comments?per_page=100&page=${page}`, token);
105
+ if (!Array.isArray(comments))
106
+ throw new Error('Invalid comment listing');
107
+ for (const comment of comments) {
108
+ if (comment.user?.login === login && comment.user?.type === 'Bot' && typeof comment.body === 'string' && comment.body.startsWith(`${marker}\n`)) {
109
+ if (found || !Number.isSafeInteger(comment.id))
110
+ throw new Error('Ambiguous bot summary; operator reconciliation required');
111
+ found = comment;
112
+ }
113
+ }
114
+ if (comments.length < 100)
115
+ return found;
116
+ }
117
+ throw new Error('Comment scan limit reached; no new comment created');
118
+ }
119
+ async create(pr, body) {
120
+ const result = await this.request(`/repos/${this.config.repository}/issues/${pr}/comments`, await this.token(true), 'POST', { body });
121
+ if (!Number.isSafeInteger(result.id))
122
+ throw new Error('Uncertain comment creation');
123
+ return result.id;
124
+ }
125
+ async update(id, body) {
126
+ await this.request(`/repos/${this.config.repository}/issues/comments/${id}`, await this.token(true), 'PATCH', { body });
127
+ }
128
+ async validation(head, names) {
129
+ if (!/^[a-f0-9]{40}$/.test(head))
130
+ throw new Error('Invalid CI commit');
131
+ const checks = [];
132
+ for (const trusted of this.config.trustedChecks ?? []) {
133
+ if (!names.includes(trusted.name))
134
+ continue;
135
+ const missing = { name: trusted.name, status: 'not-run', reason: 'No unique completed check from the trusted GitHub App on this exact head.' };
136
+ // The review App cannot attest its own required validation.
137
+ if (String(trusted.appId) === this.appId) {
138
+ checks.push(missing);
139
+ continue;
140
+ }
141
+ try {
142
+ const query = new URLSearchParams({ check_name: trusted.name, app_id: String(trusted.appId), filter: 'latest', per_page: '100' });
143
+ const data = await this.request(`/repos/${this.config.repository}/commits/${head}/check-runs?${query}`, await this.token('checks'));
144
+ if (!Array.isArray(data.check_runs) || data.total_count !== data.check_runs.length || data.check_runs.length !== 1) {
145
+ checks.push(missing);
146
+ continue;
147
+ }
148
+ const run = data.check_runs[0];
149
+ if (run.name !== trusted.name || run.app?.id !== trusted.appId || run.head_sha !== head
150
+ || !Number.isSafeInteger(run.id) || run.id < 1 || run.status !== 'completed') {
151
+ checks.push(missing);
152
+ continue;
153
+ }
154
+ const status = run.conclusion === 'success' ? 'pass'
155
+ : ['failure', 'timed_out', 'action_required', 'startup_failure'].includes(run.conclusion) ? 'fail' : 'not-run';
156
+ checks.push({ name: trusted.name, status, reason: `GitHub App ${trusted.appId}, check ${run.id}: ${status === 'pass' ? 'passed' : status === 'fail' ? 'failed' : 'did not establish a pass'}.`,
157
+ url: `https://github.com/${this.config.repository}/runs/${run.id}` });
158
+ }
159
+ catch {
160
+ checks.push({ ...missing, reason: 'GitHub CI evidence could not be retrieved. Validation remains unverified.' });
161
+ }
162
+ }
163
+ return checks;
164
+ }
165
+ async findCheck(head, externalId) {
166
+ const token = await this.token('checks');
167
+ for (let page = 1; page <= 20; page++) {
168
+ const data = await this.request(`/repos/${this.config.repository}/commits/${head}/check-runs?check_name=atmin%20review&filter=all&per_page=100&page=${page}`, token);
169
+ if (!Array.isArray(data.check_runs))
170
+ throw new Error('Invalid check listing');
171
+ const found = data.check_runs.filter((r) => String(r.app?.id) === this.appId && r.external_id === externalId && r.head_sha === head);
172
+ if (found.length > 1)
173
+ throw new Error('Ambiguous check creation');
174
+ if (found.length === 1 && Number.isSafeInteger(found[0].id))
175
+ return found[0].id;
176
+ if (data.check_runs.length < 100)
177
+ return null;
178
+ }
179
+ throw new Error('Check scan limit reached');
180
+ }
181
+ async createCheck(head, externalId, output) {
182
+ const data = await this.request(`/repos/${this.config.repository}/check-runs`, await this.token('checks'), 'POST', { name: 'atmin review', head_sha: head, external_id: externalId, ...output });
183
+ if (!Number.isSafeInteger(data.id))
184
+ throw new Error('Uncertain check creation');
185
+ return data.id;
186
+ }
187
+ async updateCheck(id, output) {
188
+ await this.request(`/repos/${this.config.repository}/check-runs/${id}`, await this.token('checks'), 'PATCH', output);
189
+ }
190
+ }
@@ -0,0 +1,11 @@
1
+ import type { Assessment } from '../assessment.js';
2
+ import type { GitHub, CheckOutput } from './api.js';
3
+ import type { Job, Store } from './store.js';
4
+ export declare function assessmentCheck(assessment: Assessment): CheckOutput;
5
+ export declare class Checks {
6
+ private store;
7
+ private github;
8
+ constructor(store: Store, github: GitHub);
9
+ publish(job: Job, head: string, output: CheckOutput): Promise<void>;
10
+ stop(job: Job, conclusion: 'failure' | 'cancelled', title: string): Promise<void>;
11
+ }
@@ -0,0 +1,42 @@
1
+ export function assessmentCheck(assessment) {
2
+ const success = assessment.freshness.status === 'current' && assessment.scope === 'complete'
3
+ && ['passed', 'not-applicable'].includes(assessment.validation) && assessment.findingsVerdict !== 'Changes needed';
4
+ return { status: 'completed', conclusion: success ? 'success' : 'failure', output: {
5
+ title: assessment.outcome, summary: `Scope: ${assessment.scope}. Required validation: ${assessment.validation}.\n\n${assessment.reasons.join('\n')}\n\nP0–P2 block this check; P3 and optional P4 do not. This is a review result, not merge approval.`,
6
+ } };
7
+ }
8
+ // A creation attempt is durable before POST. On a lost response, reconcile by
9
+ // App identity + immutable head + job ID; never blindly create another check.
10
+ export class Checks {
11
+ store;
12
+ github;
13
+ constructor(store, github) {
14
+ this.store = store;
15
+ this.github = github;
16
+ store.db.exec('CREATE TABLE IF NOT EXISTS review_checks (job TEXT PRIMARY KEY, head TEXT NOT NULL, id INTEGER)');
17
+ }
18
+ async publish(job, head, output) {
19
+ let row = this.store.db.prepare('SELECT head,id FROM review_checks WHERE job=?').get(job.id);
20
+ if (row && row.head !== head)
21
+ throw new Error('Check snapshot mismatch');
22
+ let id = row?.id;
23
+ if (id == null) {
24
+ id = await this.github.findCheck(head, job.id);
25
+ if (id == null) {
26
+ if (row)
27
+ throw new Error('Check creation uncertain; reconcile explicitly');
28
+ this.store.db.prepare('INSERT INTO review_checks(job,head) VALUES(?,?)').run(job.id, head);
29
+ id = await this.github.createCheck(head, job.id, output);
30
+ this.store.db.prepare('UPDATE review_checks SET id=? WHERE job=?').run(id, job.id);
31
+ return;
32
+ }
33
+ this.store.db.prepare('INSERT INTO review_checks(job,head,id) VALUES(?,?,?) ON CONFLICT(job) DO UPDATE SET id=excluded.id').run(job.id, head, id);
34
+ }
35
+ await this.github.updateCheck(id, output);
36
+ }
37
+ async stop(job, conclusion, title) {
38
+ const row = this.store.db.prepare('SELECT head FROM review_checks WHERE job=?').get(job.id);
39
+ if (row)
40
+ await this.publish(job, String(row.head), { status: 'completed', conclusion, output: { title, summary: 'No current completed review is claimed. See the PR summary and operator status.' } });
41
+ }
42
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { readConfig, requiredEnv } from './config.js';
6
+ import { Store } from './store.js';
7
+ import { AppGitHub } from './api.js';
8
+ import { webhook } from './webhook.js';
9
+ import { Worker } from './worker.js';
10
+ import { engineRunner } from './runner.js';
11
+ import { readProfile } from '../run.js';
12
+ async function main() {
13
+ const [operation, input, prInput] = process.argv.slice(2);
14
+ if (!operation || operation === '--help') {
15
+ process.stdout.write(`atmin review GitHub pilot\n\n atmin-review-github serve|check <config.json>\n atmin-review-github status|enable|pause <config.json>\n atmin-review-github enqueue|reconcile <config.json> <pr-number>\n\nStarts paused. Operator controls are local only. Requires Node 24+, git and gh.\n`);
16
+ return;
17
+ }
18
+ if (!input || !['serve', 'check', 'status', 'enable', 'pause', 'enqueue', 'reconcile'].includes(operation))
19
+ throw new Error('Invalid pilot command; use --help');
20
+ const config = readConfig(resolve(input));
21
+ process.umask(0o077);
22
+ if (operation === 'check') {
23
+ requiredEnv(readProfile(config.profile).provider === 'openrouter' ? 'OPENROUTER_API_KEY' : 'OPENAI_API_KEY');
24
+ if (Buffer.byteLength(requiredEnv('GITHUB_WEBHOOK_SECRET')) < 32)
25
+ throw new Error('Use a webhook secret of at least 32 bytes');
26
+ const github = new AppGitHub(config, requiredEnv('GITHUB_APP_ID'), readFileSync(requiredEnv('GITHUB_APP_PRIVATE_KEY_PATH'), 'utf8'));
27
+ process.stdout.write(`${JSON.stringify(await github.verifyInstallation())}\n`);
28
+ return;
29
+ }
30
+ const store = new Store(config.stateDirectory);
31
+ if (operation !== 'serve') {
32
+ try {
33
+ if (operation === 'enable')
34
+ store.enable(true);
35
+ if (operation === 'pause')
36
+ store.enable(false);
37
+ if (operation === 'enqueue' || operation === 'reconcile') {
38
+ const pr = Number(prInput);
39
+ if (!Number.isSafeInteger(pr) || pr < 1)
40
+ throw new Error('Expected a positive PR number');
41
+ if (!store.enabled())
42
+ throw new Error('Pilot is paused; enable it first');
43
+ if (operation === 'enqueue' && !store.enqueue(`operator-${randomUUID()}`, pr))
44
+ throw new Error('Review was not queued');
45
+ if (operation === 'reconcile' && !store.retryPublication(pr))
46
+ throw new Error('No current saved report to reconcile');
47
+ }
48
+ process.stdout.write(`${JSON.stringify(store.status(), null, 2)}\n`);
49
+ }
50
+ finally {
51
+ store.close();
52
+ }
53
+ return;
54
+ }
55
+ const secret = requiredEnv('GITHUB_WEBHOOK_SECRET');
56
+ if (Buffer.byteLength(secret) < 32)
57
+ throw new Error('Use a webhook secret of at least 32 bytes');
58
+ const github = new AppGitHub(config, requiredEnv('GITHUB_APP_ID'), readFileSync(requiredEnv('GITHUB_APP_PRIVATE_KEY_PATH'), 'utf8'));
59
+ const owner = randomUUID();
60
+ if (!store.acquire(owner))
61
+ throw new Error('A pilot service already owns this state directory');
62
+ const server = webhook(config, secret, store, github);
63
+ const worker = new Worker(config, store, github, engineRunner(config, github), owner);
64
+ let stopping = false;
65
+ const stop = () => { stopping = true; worker.stop(); server.close(); };
66
+ process.once('SIGINT', stop);
67
+ process.once('SIGTERM', stop);
68
+ const heartbeat = setInterval(() => { if (!store.renew(owner))
69
+ stop(); }, 5000);
70
+ try {
71
+ await new Promise((done, reject) => { server.once('error', reject); server.listen(config.port, config.host, done); });
72
+ process.stdout.write(`${JSON.stringify({ service: 'atmin review', repository: config.repository, enabled: store.enabled(), host: config.host, port: config.port })}\n`);
73
+ while (!stopping) {
74
+ const worked = await worker.tick();
75
+ if (!worked && !stopping)
76
+ await new Promise(done => setTimeout(done, 500));
77
+ }
78
+ }
79
+ finally {
80
+ clearInterval(heartbeat);
81
+ stop();
82
+ process.off('SIGINT', stop);
83
+ process.off('SIGTERM', stop);
84
+ store.release(owner);
85
+ store.close();
86
+ }
87
+ }
88
+ try {
89
+ await main();
90
+ }
91
+ catch {
92
+ process.stderr.write('atmin review pilot could not complete the command. Check local configuration, credentials, port and service status.\n');
93
+ process.exitCode = 1;
94
+ }
@@ -0,0 +1,16 @@
1
+ export interface PilotConfig {
2
+ repository: string;
3
+ repositoryId: number;
4
+ installationId: number;
5
+ profile: string;
6
+ stateDirectory: string;
7
+ host: string;
8
+ port: number;
9
+ maxReviewsPerDay: number;
10
+ trustedChecks?: {
11
+ name: string;
12
+ appId: number;
13
+ }[];
14
+ }
15
+ export declare function readConfig(path: string): PilotConfig;
16
+ export declare function requiredEnv(name: string): string;
@@ -0,0 +1,41 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { readProfile } from '../run.js';
4
+ export function readConfig(path) {
5
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
6
+ const keys = ['repository', 'repositoryId', 'installationId', 'profile', 'stateDirectory', 'host', 'port', 'maxReviewsPerDay', 'trustedChecks'];
7
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || Object.keys(raw).some(k => !keys.includes(k)))
8
+ throw new Error('Invalid pilot configuration');
9
+ if (typeof raw.repository !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(raw.repository))
10
+ throw new Error('Invalid pilot repository');
11
+ for (const key of ['repositoryId', 'installationId', 'port', 'maxReviewsPerDay']) {
12
+ if (!Number.isSafeInteger(raw[key]) || raw[key] < 1)
13
+ throw new Error(`Set a positive ${key}`);
14
+ }
15
+ if (raw.port > 65535 || raw.maxReviewsPerDay > 100)
16
+ throw new Error('Pilot port or daily review limit out of range');
17
+ if (!['127.0.0.1', '0.0.0.0', '::1'].includes(raw.host))
18
+ throw new Error('Unsupported listen address');
19
+ for (const key of ['profile', 'stateDirectory'])
20
+ if (typeof raw[key] !== 'string' || !raw[key])
21
+ throw new Error(`Set ${key}`);
22
+ if (raw.trustedChecks !== undefined) {
23
+ if (!Array.isArray(raw.trustedChecks) || raw.trustedChecks.length > 20
24
+ || raw.trustedChecks.some((c) => !c || typeof c !== 'object' || Array.isArray(c)
25
+ || Object.keys(c).some(k => !['name', 'appId'].includes(k))
26
+ || typeof c.name !== 'string' || !c.name.trim() || c.name.length > 200 || c.name === 'atmin review'
27
+ || !Number.isSafeInteger(c.appId) || c.appId < 1)
28
+ || new Set(raw.trustedChecks.map((c) => c.name)).size !== raw.trustedChecks.length) {
29
+ throw new Error('Trusted checks require unique names and positive GitHub App IDs');
30
+ }
31
+ }
32
+ const config = { ...raw, profile: resolve(dirname(path), raw.profile), stateDirectory: resolve(dirname(path), raw.stateDirectory) };
33
+ readProfile(config.profile);
34
+ return config;
35
+ }
36
+ export function requiredEnv(name) {
37
+ const value = process.env[name];
38
+ if (!value)
39
+ throw new Error(`Missing ${name}`);
40
+ return value;
41
+ }
@@ -0,0 +1,7 @@
1
+ import type { PilotConfig } from './config.js';
2
+ import type { GitHub } from './api.js';
3
+ import type { Job } from './store.js';
4
+ export type Runner = (job: Job, signal: AbortSignal) => Promise<string>;
5
+ export declare function childEnvironment(home: string, credentials: Record<string, string>): NodeJS.ProcessEnv;
6
+ export declare function child(args: string[], env: NodeJS.ProcessEnv, signal: AbortSignal, deadlineMs: number): Promise<void>;
7
+ export declare function engineRunner(config: PilotConfig, github: GitHub): Runner;
@@ -0,0 +1,67 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { readProfile } from '../run.js';
6
+ export function childEnvironment(home, credentials) {
7
+ return { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: home, TMPDIR: home, GH_CONFIG_DIR: join(home, 'gh'), LANG: 'C.UTF-8', ...credentials };
8
+ }
9
+ export function child(args, env, signal, deadlineMs) {
10
+ return new Promise((resolve, reject) => {
11
+ if (signal.aborted) {
12
+ reject(new Error('cancelled'));
13
+ return;
14
+ }
15
+ const proc = spawn(process.execPath, [fileURLToPath(new URL('./task.js', import.meta.url)), ...args], {
16
+ env, cwd: env.HOME, stdio: 'ignore', detached: process.platform !== 'win32',
17
+ });
18
+ let killTimer;
19
+ let stopped = false;
20
+ const kill = (kind) => {
21
+ try {
22
+ if (process.platform !== 'win32' && proc.pid)
23
+ process.kill(-proc.pid, kind);
24
+ else
25
+ proc.kill(kind);
26
+ }
27
+ catch { /* Already exited. */ }
28
+ };
29
+ const cancel = () => {
30
+ if (stopped)
31
+ return;
32
+ stopped = true;
33
+ kill('SIGTERM');
34
+ killTimer = setTimeout(() => kill('SIGKILL'), 2000);
35
+ };
36
+ const deadline = setTimeout(cancel, deadlineMs);
37
+ signal.addEventListener('abort', cancel, { once: true });
38
+ const cleanup = () => { clearTimeout(deadline); clearTimeout(killTimer); signal.removeEventListener('abort', cancel); };
39
+ proc.once('error', () => { cleanup(); reject(new Error('Review child failed to start')); });
40
+ proc.once('exit', code => { cleanup(); if (code === 0 && !stopped)
41
+ resolve();
42
+ else
43
+ reject(new Error('Review child interrupted or failed')); });
44
+ });
45
+ }
46
+ export function engineRunner(config, github) {
47
+ const runs = join(config.stateDirectory, 'runs');
48
+ mkdirSync(runs, { recursive: true, mode: 0o700 });
49
+ return async (job, signal) => {
50
+ const directory = join(runs, job.id);
51
+ const home = mkdtempSync(join(config.stateDirectory, 'job-home-'));
52
+ try {
53
+ const token = await github.readToken();
54
+ await child(['capture', `https://github.com/${config.repository}/pull/${job.pr}`, directory], childEnvironment(home, { GH_TOKEN: token }), signal, 130_000);
55
+ const profile = readProfile(config.profile);
56
+ const keyName = profile.provider === 'openrouter' ? 'OPENROUTER_API_KEY' : 'OPENAI_API_KEY';
57
+ const key = process.env[keyName];
58
+ if (!key)
59
+ throw new Error('Configured model credential unavailable');
60
+ await child(['investigate', directory, config.profile], childEnvironment(home, { [keyName]: key }), signal, profile.deadlineMs + 30_000);
61
+ return directory;
62
+ }
63
+ finally {
64
+ rmSync(home, { recursive: true, force: true });
65
+ }
66
+ };
67
+ }