argus-reviewer-e2e 0.4.1 → 0.4.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.
package/dist/config.d.ts CHANGED
@@ -114,11 +114,17 @@ export interface Config {
114
114
  */
115
115
  decisionModel: string | undefined;
116
116
  /**
117
- * Hard budget for the `argus-reviewer code-review` lane. When set, the
118
- * review stops early if the cumulative OpenRouter cost exceeds this cap.
117
+ * Hard budget for the `argus-reviewer code-review` lane. Unset follows
118
+ * `budgetUsd` (default $1/run); `0` runs the review lane uncapped. After
119
+ * resolveConfig `undefined` means explicitly unlimited.
119
120
  */
120
121
  codeReviewBudgetUsd: number | undefined;
121
122
  provider: ProviderRules;
123
+ /**
124
+ * Per-run spend cap in USD. Default `DEFAULT_BUDGET_USD` ($1). `0` is the
125
+ * explicit unlimited switch (every command logs a warning). After
126
+ * resolveConfig `undefined` means explicitly unlimited — never "unset".
127
+ */
122
128
  budgetUsd: number | undefined;
123
129
  target: Target | undefined;
124
130
  cacheDir: string | undefined;
@@ -271,8 +277,39 @@ export interface Config {
271
277
  * paths); `[]` excludes nothing.
272
278
  */
273
279
  exclude: string[];
280
+ /**
281
+ * `realtime` (default) calls the chat API per chunk. `batch` submits all
282
+ * chunks through OpenRouter's async Batch API (cheaper, slower: minutes),
283
+ * falling back to realtime on failure or timeout.
284
+ */
285
+ mode: 'realtime' | 'batch';
286
+ /**
287
+ * Poll deadline for a batch, ms. Must sit inside the CI job timeout
288
+ * (the shipped workflow's is 15 minutes) with room left for a realtime
289
+ * fallback. Default 480000.
290
+ */
291
+ batchTimeoutMs: number;
292
+ /**
293
+ * Model for `mode: 'batch'`, a `:batch` slug (the base slug is what is
294
+ * sent). Separate from `code_model` because not every realtime model has
295
+ * a batch endpoint. Unset: `<review model>:batch` when that slug is known
296
+ * to exist, else DEFAULT_BATCH_MODEL.
297
+ */
298
+ batchModel: string | undefined;
299
+ /**
300
+ * Per-request timeout for realtime review calls, ms (1..900000,
301
+ * default 120000). Reasoning models need more than the default.
302
+ */
303
+ requestTimeoutMs: number;
274
304
  };
275
305
  }
306
+ export declare const DEFAULT_REQUEST_TIMEOUT_MS = 120000;
307
+ export declare const MAX_REQUEST_TIMEOUT_MS = 900000;
308
+ export declare const DEFAULT_BATCH_MODEL = "deepseek/deepseek-v4.1-flash:batch";
309
+ /** Batch slug for a review: explicit `batchModel`, else `<model>:batch` if known to exist, else the default. */
310
+ export declare function resolveBatchModel(reviewModel: string, batchModel: string | undefined): string;
311
+ /** Validates a per-request timeout; returns an error message or undefined when valid. */
312
+ export declare function checkRequestTimeoutMs(v: unknown): string | undefined;
276
313
  export type ConfigInput = Partial<Omit<Config, 'provider' | 'sandbox' | 'review' | 'explore' | 'app'>> & {
277
314
  provider?: Partial<ProviderRules>;
278
315
  sandbox?: Partial<Sandbox>;
@@ -281,6 +318,27 @@ export type ConfigInput = Partial<Omit<Config, 'provider' | 'sandbox' | 'review'
281
318
  app?: Partial<AppLane>;
282
319
  };
283
320
  export declare const DEFAULT_RECORD_STEP_CAP = 40;
321
+ /** Built-in per-run spend cap (USD) when nothing else is configured. */
322
+ export declare const DEFAULT_BUDGET_USD = 1;
323
+ /** Logged by every paid command when the cap was explicitly disabled. */
324
+ export declare const UNCAPPED_WARNING = "warning: spend cap disabled (budgetUsd/ARGUS_BUDGET_USD = 0): this run is UNCAPPED; model spend is unbounded";
325
+ export type BudgetSetting = {
326
+ kind: 'unset';
327
+ } | {
328
+ kind: 'invalid';
329
+ } | {
330
+ kind: 'unlimited';
331
+ } | {
332
+ kind: 'cap';
333
+ usd: number;
334
+ };
335
+ /** Parse ARGUS_BUDGET_USD / the `budget-usd` action input: `0` = unlimited. */
336
+ export declare function parseBudgetSetting(raw: string | undefined): BudgetSetting;
337
+ /**
338
+ * Apply an env/action budget setting. Returns the new cap (`undefined` =
339
+ * unlimited) or `'keep'` when the setting is unset/invalid.
340
+ */
341
+ export declare function applyBudgetSetting(s: BudgetSetting): number | undefined | 'keep';
284
342
  export declare const DEFAULT_EXPLORE: Explore;
285
343
  export declare const DEFAULT_APP: AppLane;
286
344
  export declare const DEFAULT_SANDBOX: Sandbox;
package/dist/config.js CHANGED
@@ -2,7 +2,58 @@ import { pathToFileURL } from 'node:url';
2
2
  import { DEFAULT_REVIEW_EXCLUDE } from './review/scope.js';
3
3
  import { isReviewProfile } from './review/packs.js';
4
4
  import { JEV_DEFAULT_MODEL } from './vision/decisions.js';
5
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
6
+ export const MAX_REQUEST_TIMEOUT_MS = 900_000;
7
+ export const DEFAULT_BATCH_MODEL = 'deepseek/deepseek-v4.1-flash:batch';
8
+ /**
9
+ * Base slugs measured to have an OpenRouter `:batch` endpoint (reviewer
10
+ * bake-off, 2026-10-03). The realtime default deepseek-v4-flash has none.
11
+ */
12
+ const KNOWN_BATCH_BASES = new Set([
13
+ 'google/gemini-2.5-flash-lite',
14
+ 'deepseek/deepseek-v4.1-flash',
15
+ 'z-ai/glm-5.3',
16
+ 'z-ai/glm-5.3-flash',
17
+ 'openai/gpt-oss-120b',
18
+ ]);
19
+ /** Batch slug for a review: explicit `batchModel`, else `<model>:batch` if known to exist, else the default. */
20
+ export function resolveBatchModel(reviewModel, batchModel) {
21
+ if (batchModel !== undefined && batchModel !== '')
22
+ return batchModel;
23
+ const base = reviewModel.replace(/:batch$/, '');
24
+ return KNOWN_BATCH_BASES.has(base) ? `${base}:batch` : DEFAULT_BATCH_MODEL;
25
+ }
26
+ /** Validates a per-request timeout; returns an error message or undefined when valid. */
27
+ export function checkRequestTimeoutMs(v) {
28
+ return typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= MAX_REQUEST_TIMEOUT_MS
29
+ ? undefined
30
+ : `requestTimeoutMs must be an integer from 1 to ${MAX_REQUEST_TIMEOUT_MS} (ms), got ${String(v)}`;
31
+ }
5
32
  export const DEFAULT_RECORD_STEP_CAP = 40;
33
+ /** Built-in per-run spend cap (USD) when nothing else is configured. */
34
+ export const DEFAULT_BUDGET_USD = 1;
35
+ /** Logged by every paid command when the cap was explicitly disabled. */
36
+ export const UNCAPPED_WARNING = 'warning: spend cap disabled (budgetUsd/ARGUS_BUDGET_USD = 0): this run is UNCAPPED; model spend is unbounded';
37
+ /** Parse ARGUS_BUDGET_USD / the `budget-usd` action input: `0` = unlimited. */
38
+ export function parseBudgetSetting(raw) {
39
+ if (raw === undefined || raw.trim() === '')
40
+ return { kind: 'unset' };
41
+ const n = Number(raw);
42
+ if (!Number.isFinite(n) || n < 0)
43
+ return { kind: 'invalid' };
44
+ return n === 0 ? { kind: 'unlimited' } : { kind: 'cap', usd: n };
45
+ }
46
+ /**
47
+ * Apply an env/action budget setting. Returns the new cap (`undefined` =
48
+ * unlimited) or `'keep'` when the setting is unset/invalid.
49
+ */
50
+ export function applyBudgetSetting(s) {
51
+ if (s.kind === 'cap')
52
+ return s.usd;
53
+ if (s.kind === 'unlimited')
54
+ return undefined;
55
+ return 'keep';
56
+ }
6
57
  export const DEFAULT_EXPLORE = {
7
58
  enabled: false,
8
59
  maxSteps: 20,
@@ -29,13 +80,13 @@ const defaults = {
29
80
  model: 'google/gemini-2.5-flash-lite',
30
81
  escalation_model: 'moonshotai/kimi-k2.5',
31
82
  grounding_model: undefined,
32
- code_model: 'deepseek/deepseek-v4.1-flash',
83
+ code_model: 'deepseek/deepseek-v4-flash',
33
84
  decisionModel: JEV_DEFAULT_MODEL,
34
85
  codeReviewBudgetUsd: undefined,
35
86
  provider: {
36
87
  ignore: ['siliconflow', 'novitaai', 'atlascloud', 'streamlake', 'chutes'],
37
88
  },
38
- budgetUsd: undefined,
89
+ budgetUsd: DEFAULT_BUDGET_USD,
39
90
  target: undefined,
40
91
  cacheDir: undefined,
41
92
  testsDir: undefined,
@@ -67,6 +118,10 @@ const defaults = {
67
118
  requestChanges: true,
68
119
  profiles: [],
69
120
  exclude: [...DEFAULT_REVIEW_EXCLUDE],
121
+ mode: 'realtime',
122
+ batchTimeoutMs: 480_000,
123
+ batchModel: undefined,
124
+ requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
70
125
  },
71
126
  };
72
127
  export function defineConfig(input) {
@@ -207,7 +262,35 @@ export function resolveConfig(input = {}) {
207
262
  rawReview.exclude.every((g) => typeof g === 'string' && g !== '')
208
263
  ? [...rawReview.exclude]
209
264
  : [...DEFAULT_REVIEW_EXCLUDE];
265
+ review.mode = review.mode === 'batch' ? 'batch' : 'realtime';
266
+ review.batchTimeoutMs = posInt(review.batchTimeoutMs, defaults.review.batchTimeoutMs);
267
+ if (typeof review.batchModel !== 'string' || review.batchModel.trim() === '') {
268
+ review.batchModel = undefined;
269
+ }
270
+ if (rawReview.requestTimeoutMs !== undefined) {
271
+ const bad = checkRequestTimeoutMs(rawReview.requestTimeoutMs);
272
+ if (bad !== undefined)
273
+ throw new Error(`review.${bad}`);
274
+ }
210
275
  const resolved = { ...defaults, ...input, provider, sandbox, explore, app, review };
276
+ // 0 = explicit unlimited; anything not a finite non-negative number
277
+ // (mis-typed, negative, null) degrades to the default cap, never to unlimited.
278
+ const rawBudget = input.budgetUsd;
279
+ resolved.budgetUsd =
280
+ rawBudget === undefined
281
+ ? DEFAULT_BUDGET_USD
282
+ : typeof rawBudget === 'number' && Number.isFinite(rawBudget) && rawBudget >= 0
283
+ ? rawBudget === 0
284
+ ? undefined
285
+ : rawBudget
286
+ : DEFAULT_BUDGET_USD;
287
+ const rawReviewBudget = input.codeReviewBudgetUsd;
288
+ resolved.codeReviewBudgetUsd =
289
+ typeof rawReviewBudget === 'number' && Number.isFinite(rawReviewBudget) && rawReviewBudget > 0
290
+ ? rawReviewBudget
291
+ : rawReviewBudget === 0
292
+ ? undefined
293
+ : resolved.budgetUsd;
211
294
  resolved.recordStepCap = posInt(resolved.recordStepCap, DEFAULT_RECORD_STEP_CAP);
212
295
  // Retention is a non-negative integer (0 = keep none) — a mis-typed or
213
296
  // negative bound degrades to unset, never to "keep everything".
package/dist/detect.js CHANGED
@@ -160,7 +160,7 @@ async function a0EnvFileHost(home) {
160
160
  * the default local port (http://localhost:5080).
161
161
  */
162
162
  export async function resolveA0Host(env, opts = {}) {
163
- const home = opts.home ?? homedir();
163
+ const home = opts.home ?? (env.HOME || homedir());
164
164
  const probe = opts.probe ?? defaultProbe;
165
165
  const fromEnv = env.AGENT_ZERO_HOST;
166
166
  if (fromEnv !== undefined && fromEnv !== '')
@@ -192,7 +192,7 @@ async function playwrightBrowsers(home) {
192
192
  }
193
193
  export async function detectEnvironment(env, opts = {}) {
194
194
  const exec = opts.exec ?? defaultExec;
195
- const home = opts.home ?? homedir();
195
+ const home = opts.home ?? (env.HOME || homedir());
196
196
  const [a0Version, gh, browsers, a0Host] = await Promise.all([
197
197
  // Even the presence probe gets the allowlisted env — an `a0` binary is
198
198
  // third-party code and never sees provider/git secrets.
@@ -3,7 +3,7 @@ import { Actions } from './actions.js';
3
3
  import { Config, ProviderRules } from '../config.js';
4
4
  import { CallCost, CallKind } from '../vision/cost.js';
5
5
  import { Ledger } from '../vision/ledger.js';
6
- import { JsonSchema, Message } from '../vision/openrouter.js';
6
+ import { BatchItemResult, BatchRequest, JsonSchema, Message } from '../vision/openrouter.js';
7
7
  import { FingerprintRecord, Point } from '../cache/fingerprint.js';
8
8
  import { CachedAssert, FlowCache } from '../cache/store.js';
9
9
  import { ErrorRecord } from '../journal/schema.js';
@@ -23,6 +23,13 @@ export interface VisionClient {
23
23
  cost: CallCost;
24
24
  model: string;
25
25
  }>;
26
+ /** Optional async Batch API path (code review `review.mode: batch`). */
27
+ completeBatch?(opts: {
28
+ model: string;
29
+ requests: BatchRequest[];
30
+ kind?: CallKind;
31
+ deadlineMs: number;
32
+ }): Promise<BatchItemResult[]>;
26
33
  }
27
34
  export interface UiDriverApi {
28
35
  click(x: number, y: number): Promise<Observation>;
@@ -0,0 +1,12 @@
1
+ export declare const DEFAULT_BRANCH = "argus/onboarding";
2
+ /** A validation message, or undefined when the repo slug is safe to use in argv and URLs. */
3
+ export declare function validateRepo(repo: string): string | undefined;
4
+ export declare function validateBranch(branch: string): string | undefined;
5
+ export interface PrBodyInput {
6
+ repo: string;
7
+ /** Default per-run budget in USD, from the resolved config defaults. */
8
+ budgetUsd: number;
9
+ /** Files this PR adds. */
10
+ paths: string[];
11
+ }
12
+ export declare function renderPrBody(input: PrBodyInput): string;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Pure, runtime-neutral pieces of the onboarding PR: name validation and the
3
+ * PR body. No Node imports, so the CLI (`init --pr`) and the App Worker
4
+ * (bundled for Cloudflare) render the same PR and cannot drift.
5
+ */
6
+ import { scaffoldChecklist } from './scaffold.js';
7
+ export const DEFAULT_BRANCH = 'argus/onboarding';
8
+ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
9
+ const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
10
+ /** A validation message, or undefined when the repo slug is safe to use in argv and URLs. */
11
+ export function validateRepo(repo) {
12
+ if (!REPO_RE.test(repo) || repo.split('/').some((p) => p === '.' || p === '..')) {
13
+ return `--repo must look like owner/name (letters, digits, . _ -), got ${JSON.stringify(repo)}`;
14
+ }
15
+ return undefined;
16
+ }
17
+ export function validateBranch(branch) {
18
+ if (!BRANCH_RE.test(branch) ||
19
+ branch.includes('..') ||
20
+ branch.endsWith('/') ||
21
+ branch.endsWith('.lock')) {
22
+ return `--branch is not a safe branch name: ${JSON.stringify(branch)}`;
23
+ }
24
+ return undefined;
25
+ }
26
+ export function renderPrBody(input) {
27
+ const { repo, budgetUsd, paths } = input;
28
+ return `## Add Argus reviewer
29
+
30
+ This PR adds a review-only [Argus](https://github.com/duketopceo/Argus) setup. Nothing runs until it is merged.
31
+
32
+ ### Files added
33
+
34
+ ${paths.map((p) => `- \`${p}\``).join('\n')}
35
+
36
+ ### Before merging: add the secret
37
+
38
+ - [ ] Add \`OPENROUTER_API_KEY\` as a repository secret: https://github.com/${repo}/settings/secrets/actions/new?name=OPENROUTER_API_KEY
39
+ - Or from your own terminal: \`gh secret set OPENROUTER_API_KEY --repo ${repo}\`
40
+ - [ ] Review the workflow files above. They use \`pull_request\` (never \`pull_request_target\`), \`persist-credentials: false\`, a pinned action SHA and least-privilege \`permissions\`.
41
+
42
+ Argus never sees your key. It is your OpenRouter key (BYOK) and it stays in your repository secrets. The command that opened this PR did not read it.
43
+
44
+ ### What runs and what it costs
45
+
46
+ \`\`\`
47
+ ${scaffoldChecklist(budgetUsd).join('\n')}
48
+ \`\`\`
49
+
50
+ ### How to stop or uninstall
51
+
52
+ - Remove \`.github/workflows/argus-reviewer.yml\` and \`.github/workflows/argus-mention.yml\`, or
53
+ - delete the \`OPENROUTER_API_KEY\` repository secret (reviews stop, nothing else changes).
54
+ - Optionally delete \`argus-reviewer.config.ts\` and \`tests/argus/\`.
55
+ `;
56
+ }
@@ -0,0 +1,23 @@
1
+ import type { ExecFn } from '../detect.js';
2
+ export { DEFAULT_BRANCH, renderPrBody, validateBranch, validateRepo, type PrBodyInput } from './pr-content.js';
3
+ /** owner/name from a GitHub remote URL (ssh, https, with or without .git). */
4
+ export declare function parseGithubRemote(url: string): string | undefined;
5
+ export interface InitPrOptions {
6
+ cwd: string;
7
+ exec: ExecFn;
8
+ repo: string | undefined;
9
+ branch: string;
10
+ budgetUsd: number;
11
+ }
12
+ export type InitPrResult = {
13
+ kind: 'created';
14
+ url: string;
15
+ repo: string;
16
+ branch: string;
17
+ } | {
18
+ kind: 'existing';
19
+ url: string;
20
+ repo: string;
21
+ branch: string;
22
+ };
23
+ export declare function initPr(opts: InitPrOptions): Promise<InitPrResult>;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * `argus-reviewer init --pr`: open an onboarding pull request instead of
3
+ * writing into the working tree.
4
+ *
5
+ * Shells out to the local `git` and `gh` through the injected ExecFn, so the
6
+ * user's own credentials do the pushing and nothing about the repo reaches
7
+ * Argus. The scaffold is committed in a throwaway git worktree, never in the
8
+ * user's checkout. This module never reads, prints or transmits
9
+ * OPENROUTER_API_KEY: the PR only names the secret and links to the page
10
+ * where the user adds it themselves.
11
+ */
12
+ import { existsSync } from 'node:fs';
13
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
14
+ import { tmpdir } from 'node:os';
15
+ import { dirname, join } from 'node:path';
16
+ import { CliError } from '../ui/errors.js';
17
+ import { renderPrBody, validateBranch } from './pr-content.js';
18
+ import { renderScaffold } from './scaffold.js';
19
+ export { DEFAULT_BRANCH, renderPrBody, validateBranch, validateRepo } from './pr-content.js';
20
+ const CONFIG_NAMES = [
21
+ 'argus-reviewer.config.ts',
22
+ 'argus-reviewer.config.json',
23
+ 'vision-e2e.config.ts',
24
+ 'vision-e2e.config.json',
25
+ ];
26
+ const SHORT_MS = 30_000;
27
+ const NET_MS = 120_000;
28
+ /** owner/name from a GitHub remote URL (ssh, https, with or without .git). */
29
+ export function parseGithubRemote(url) {
30
+ const m = url.trim().match(/github\.com[:/]([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/);
31
+ return m?.[1];
32
+ }
33
+ function fail(message, fix) {
34
+ return new CliError('COMMAND_FAILED', message, fix !== undefined ? { fix } : {});
35
+ }
36
+ export async function initPr(opts) {
37
+ const { cwd, exec, branch } = opts;
38
+ const git = (args, ms = SHORT_MS) => exec('git', ['-C', cwd, ...args], ms);
39
+ const gh = (args, ms = SHORT_MS) => exec('gh', args, ms);
40
+ // 1. Which repo. The checkout's origin is the push target, so --repo may
41
+ // only confirm it, never redirect it.
42
+ const originRes = await git(['remote', 'get-url', 'origin']);
43
+ const originRepo = originRes.code === 0 ? parseGithubRemote(originRes.stdout) : undefined;
44
+ if (originRepo === undefined) {
45
+ throw fail('this checkout has no GitHub remote named origin to push the onboarding branch to', 'git remote add origin https://github.com/<owner>/<name>.git');
46
+ }
47
+ if (opts.repo !== undefined && opts.repo.toLowerCase() !== originRepo.toLowerCase()) {
48
+ throw fail(`--repo ${opts.repo} does not match this checkout's origin (${originRepo})`, `run it from a checkout of ${opts.repo}, or drop --repo`);
49
+ }
50
+ const repo = originRepo;
51
+ // 2. An open onboarding PR already? Report it and stop.
52
+ const listed = await gh([
53
+ 'pr',
54
+ 'list',
55
+ '--repo',
56
+ repo,
57
+ '--head',
58
+ branch,
59
+ '--state',
60
+ 'open',
61
+ '--json',
62
+ 'url',
63
+ ]);
64
+ if (listed.code !== 0) {
65
+ throw fail(`could not query pull requests with gh: ${firstLine(listed.stderr) || 'gh not available'}`, 'install the GitHub CLI and run: gh auth login');
66
+ }
67
+ const existingUrl = firstUrl(listed.stdout);
68
+ if (existingUrl !== undefined)
69
+ return { kind: 'existing', url: existingUrl, repo, branch };
70
+ // 3. Never overwrite. An existing config is not a conflict: it is kept
71
+ // and left out of the PR, as plain `init` does.
72
+ const hasConfig = CONFIG_NAMES.some((n) => existsSync(join(cwd, n)));
73
+ const files = renderScaffold({ a0Host: undefined, includeConfig: !hasConfig });
74
+ const conflicts = files.filter((f) => existsSync(join(cwd, f.path))).map((f) => f.path);
75
+ if (conflicts.length > 0) {
76
+ throw fail(`refusing to overwrite existing files:\n${conflicts.map((c) => ` ${c}`).join('\n')}`, 'move or delete them, or use `argus-reviewer init` to keep your versions');
77
+ }
78
+ // 4. Base branch.
79
+ const base = await defaultBranch(git, gh, repo);
80
+ // 5. Branch: reuse whatever already exists, otherwise create and push it.
81
+ const remoteHas = (await git(['ls-remote', '--exit-code', '--heads', 'origin', branch], NET_MS)).code === 0;
82
+ const localHas = (await git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])).code === 0;
83
+ if (!remoteHas && !localHas) {
84
+ await commitInWorktree({ ...opts, files, base, git });
85
+ }
86
+ else if (!remoteHas) {
87
+ const pushed = await git(['push', '-u', 'origin', branch], NET_MS);
88
+ if (pushed.code !== 0)
89
+ throw fail(`git push failed: ${firstLine(pushed.stderr)}`);
90
+ }
91
+ // 6. Open the PR.
92
+ const bodyDir = await mkdtemp(join(tmpdir(), 'argus-initpr-body-'));
93
+ try {
94
+ const bodyFile = join(bodyDir, 'body.md');
95
+ await writeFile(bodyFile, renderPrBody({ repo, budgetUsd: opts.budgetUsd, paths: files.map((f) => f.path) }), 'utf8');
96
+ const created = await gh([
97
+ 'pr',
98
+ 'create',
99
+ '--repo',
100
+ repo,
101
+ '--head',
102
+ branch,
103
+ '--base',
104
+ base,
105
+ '--title',
106
+ 'Add Argus reviewer (review-only, bring your own key)',
107
+ '--body-file',
108
+ bodyFile,
109
+ ], NET_MS);
110
+ const url = firstUrl(created.stdout, true);
111
+ if (created.code !== 0 || url === undefined) {
112
+ throw fail(`gh pr create failed: ${firstLine(created.stderr) || firstLine(created.stdout)}`);
113
+ }
114
+ return { kind: 'created', url, repo, branch };
115
+ }
116
+ finally {
117
+ await rm(bodyDir, { recursive: true, force: true });
118
+ }
119
+ }
120
+ async function defaultBranch(git, gh, repo) {
121
+ const sym = await git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
122
+ const fromRef = sym.code === 0 ? sym.stdout.trim().replace(/^origin\//, '') : '';
123
+ if (fromRef !== '' && !validateBranch(fromRef))
124
+ return fromRef;
125
+ const view = await gh([
126
+ 'repo',
127
+ 'view',
128
+ '--repo',
129
+ repo,
130
+ '--json',
131
+ 'defaultBranchRef',
132
+ '--jq',
133
+ '.defaultBranchRef.name',
134
+ ]);
135
+ const name = view.stdout.trim();
136
+ if (view.code === 0 && name !== '' && !validateBranch(name))
137
+ return name;
138
+ throw fail(`could not determine the default branch of ${repo}`, 'git remote set-head origin --auto');
139
+ }
140
+ async function commitInWorktree(o) {
141
+ const { git, files, base, branch } = o;
142
+ const dir = await mkdtemp(join(tmpdir(), 'argus-initpr-wt-'));
143
+ const wt = (args, ms = SHORT_MS) => o.exec('git', ['-C', dir, ...args], ms);
144
+ try {
145
+ const added = await git(['worktree', 'add', '--no-track', '-b', branch, dir, `origin/${base}`]);
146
+ if (added.code !== 0) {
147
+ throw fail(`could not create the onboarding branch from origin/${base}: ${firstLine(added.stderr)}`, 'git fetch origin');
148
+ }
149
+ for (const f of files) {
150
+ const abs = join(dir, f.path);
151
+ await mkdir(dirname(abs), { recursive: true });
152
+ await writeFile(abs, f.content, 'utf8');
153
+ }
154
+ const staged = await wt(['add', '--', ...files.map((f) => f.path)]);
155
+ if (staged.code !== 0)
156
+ throw fail(`git add failed: ${firstLine(staged.stderr)}`);
157
+ const committed = await wt([
158
+ 'commit',
159
+ '-m',
160
+ 'chore: add Argus reviewer (review-only, BYOK)',
161
+ '-m',
162
+ 'Generated by `argus-reviewer init --pr`.',
163
+ ]);
164
+ if (committed.code !== 0) {
165
+ throw fail(`git commit failed: ${firstLine(committed.stderr) || firstLine(committed.stdout)}`);
166
+ }
167
+ const pushed = await wt(['push', '-u', 'origin', branch], NET_MS);
168
+ if (pushed.code !== 0)
169
+ throw fail(`git push failed: ${firstLine(pushed.stderr)}`);
170
+ }
171
+ finally {
172
+ await git(['worktree', 'remove', '--force', dir]);
173
+ await rm(dir, { recursive: true, force: true });
174
+ }
175
+ }
176
+ function firstLine(s) {
177
+ return s.trim().split('\n')[0] ?? '';
178
+ }
179
+ /** First https URL in gh output: a JSON `[{"url":...}]` list, or (last=true) a bare line. */
180
+ function firstUrl(stdout, last = false) {
181
+ if (last) {
182
+ const line = stdout.trim().split('\n').pop() ?? '';
183
+ return /^https:\/\/github\.com\/\S+$/.test(line) ? line : undefined;
184
+ }
185
+ try {
186
+ const parsed = JSON.parse(stdout);
187
+ if (Array.isArray(parsed)) {
188
+ const u = parsed[0]?.url;
189
+ return typeof u === 'string' && u.startsWith('https://') ? u : undefined;
190
+ }
191
+ }
192
+ catch {
193
+ // fall through
194
+ }
195
+ return undefined;
196
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pure scaffold generator shared by `argus-reviewer init` and `init --pr`
3
+ * (and, later, the App's onboarding Worker, so the surfaces cannot drift).
4
+ * No I/O, no config execution, no environment reads. Workflow templates keep
5
+ * `persist-credentials: false`, a pinned action SHA, least-privilege
6
+ * `permissions`, and never `pull_request_target`.
7
+ */
8
+ /**
9
+ * The one place the user-facing action pin lives. Bump it here (and the golden
10
+ * fixtures) on release; see RELEASING.md. v0.4.0 and v0.4.1 ship an action.yml
11
+ * GitHub cannot parse, so never pin to them.
12
+ */
13
+ export declare const ACTION_PIN_SHA = "dbf4b7f669507402d527cb0e350285ca56129448";
14
+ export declare const ACTION_PIN_TAG = "v0.4.2";
15
+ export interface ScaffoldFile {
16
+ path: string;
17
+ content: string;
18
+ }
19
+ export interface ScaffoldOptions {
20
+ /** Detected Agent Zero host: earns a commented suggestion in the config, never an enabled lane. */
21
+ a0Host: string | undefined;
22
+ /** False when a config file already exists (init without --force). */
23
+ includeConfig: boolean;
24
+ }
25
+ export declare function initConfig(a0Host: string | undefined): string;
26
+ export declare const INIT_TEST = "test('home renders', async (td) => {\n const ok = await td.assert('the page rendered without obvious errors')\n if (!ok) throw new Error('home did not render')\n})\n";
27
+ export declare const INIT_WORKFLOW: string;
28
+ export declare const INIT_MENTION_WORKFLOW: string;
29
+ export declare const CONFIG_PATH = "argus-reviewer.config.ts";
30
+ /** The files `init` writes, in write order. */
31
+ export declare function renderScaffold(opts: ScaffoldOptions): ScaffoldFile[];
32
+ /**
33
+ * "What runs and what it costs": what is sent to the provider, the default
34
+ * budget, and the stop path. Kept verbatim (DESIGN 7.8). `budgetUsd` comes
35
+ * from the caller's resolved defaults so it cannot go stale here.
36
+ */
37
+ export declare function scaffoldChecklist(budgetUsd: number): string[];