argus-reviewer-e2e 0.4.0 → 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/cli.js CHANGED
@@ -6,7 +6,7 @@ import { basename, extname, isAbsolute, join, relative, resolve } from 'node:pat
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
7
  import { parseArgs } from 'node:util';
8
8
  import { bindSession, renderTestFile, takeTests, td, test as registerTest, TdSession, } from './api.js';
9
- import { DEFAULT_RECORD_STEP_CAP, loadConfig, resolveBlockSeverities, resolveConfig, resolveMaxComments, sanitizeExpectation, unknownProviderSlugs, } from './config.js';
9
+ import { checkRequestTimeoutMs, applyBudgetSetting, DEFAULT_BUDGET_USD, DEFAULT_RECORD_STEP_CAP, loadConfig, parseBudgetSetting, resolveBatchModel, resolveBlockSeverities, resolveConfig, resolveMaxComments, sanitizeExpectation, UNCAPPED_WARNING, unknownProviderSlugs, } from './config.js';
10
10
  import { debug, setLiveDir } from './debug.js';
11
11
  import { defaultExec, detectEnvironment, resolveA0Host } from './detect.js';
12
12
  import { BrowserDriver } from './driver/browser.js';
@@ -23,6 +23,7 @@ import { resolveTrust } from './trust.js';
23
23
  import { linkFindings } from './evidence/link.js';
24
24
  import { DecisionClient } from './vision/decisions.js';
25
25
  import { isReviewProfile, packRubric } from './review/packs.js';
26
+ import { planChunks } from './review/chunks.js';
26
27
  import { partitionByExclude } from './review/scope.js';
27
28
  import { capTestFindings } from './review/testfiles.js';
28
29
  import { auditOf, validateFindings } from './review/validate.js';
@@ -47,12 +48,15 @@ import { OpenRouterClient } from './vision/openrouter.js';
47
48
  import { Ledger } from './vision/ledger.js';
48
49
  import { selectionFromFlags } from './pipeline/contracts.js';
49
50
  import { MENTION_HELP, mayRunMention, parseMention, postIssueComment, } from './mention.js';
51
+ import { affordableBatchPrefix } from './pipeline/budget.js';
50
52
  import { runVerify, writeEvidenceReport } from './pipeline/verify.js';
51
53
  import { APP_LANE_DEFAULT_TIMEOUT_MS, APP_LANE_REPORT, runAppLane, } from './pipeline/app.js';
52
54
  import { CliError, errorJson, renderError, toCliError } from './ui/errors.js';
53
55
  import { colorEnabled, createStyler } from './ui/style.js';
54
56
  import { renderSummary, verifySummary } from './ui/summary.js';
55
57
  import { PROOF_LEVELS, proofMeter, SEVERITY_GLYPH, SEVERITY_LABEL, shortSha } from './report/viewmodel.js';
58
+ import { DEFAULT_BRANCH as DEFAULT_PR_BRANCH, initPr, validateBranch, validateRepo } from './onboarding/pr.js';
59
+ import { renderScaffold, scaffoldChecklist } from './onboarding/scaffold.js';
56
60
  import { INLINE_SENTINEL, inlineDedupKey, normalizeFindingMessage } from './review/inline.js';
57
61
  /** Flags accepted before or after any command; stripped before dispatch. */
58
62
  const GLOBAL_FLAGS = new Set(['--json', '--no-color', '--debug']);
@@ -131,7 +135,7 @@ const HELP_GROUPS = [
131
135
  {
132
136
  title: 'Setup',
133
137
  commands: [
134
- [['init [--force]'], 'Scaffold config, a smoke test and the PR workflow.'],
138
+ [['init [--force | --pr]'], 'Scaffold config, a smoke test and the PR workflow.'],
135
139
  [['--help'], 'Show this help.'],
136
140
  ],
137
141
  },
@@ -182,6 +186,14 @@ Options:
182
186
  --report-dir <dir> Report output dir (default: config reportDir or ./argus-reviewer-report)
183
187
  --fixture <dir> Review a local fixture repo (ref argus-fixture-base vs HEAD)
184
188
  instead of a live PR, with no GitHub API calls. Used by npm run demo.
189
+ --mode <mode> realtime (default) | batch. batch submits the chunks through
190
+ OpenRouter's async Batch API and falls back to realtime on
191
+ failure or timeout. Overrides ARGUS_REVIEW_MODE and review.mode.
192
+ --batch-model <slug> Model for batch mode (a :batch slug; default
193
+ deepseek/deepseek-v4.1-flash:batch). Overrides
194
+ ARGUS_BATCH_MODEL and review.batchModel.
195
+ Env: ARGUS_REQUEST_TIMEOUT_MS sets the per-request timeout (default 120000,
196
+ max 900000; also review.requestTimeoutMs).
185
197
  -h, --help Show this help`;
186
198
  const CACHE_USAGE = `Usage: argus-reviewer cache <list|prune> [options]
187
199
 
@@ -305,29 +317,32 @@ function createClient(deps, config, ctx) {
305
317
  // Lazy: a cache-hit replay makes zero vision calls and needs no key. The
306
318
  // error fires clearly on the first actual model call.
307
319
  let inner;
308
- return {
309
- complete: async (opts) => {
310
- if (inner === undefined) {
311
- const apiKey = ctx.env.OPENROUTER_API_KEY;
312
- if (apiKey === undefined || apiKey === '') {
313
- throw new CliError('OPENROUTER_KEY_MISSING', 'OPENROUTER_API_KEY is not set; model calls bill through this key (BYOK)');
314
- }
315
- const envTrace = parseOpenRouterTrace(ctx.env);
316
- const trace = { ...(envTrace ?? {}), ...(config.openrouter?.trace ?? {}) };
317
- const headers = { ...(config.openrouter?.headers ?? {}) };
318
- const traceOpt = Object.keys(trace).length > 0 ? trace : undefined;
319
- const headersOpt = Object.keys(headers).length > 0 ? headers : undefined;
320
- inner = new OpenRouterClient({
321
- apiKey,
322
- ...(traceOpt ? { trace: traceOpt } : {}),
323
- ...(headersOpt ? { headers: headersOpt } : {}),
324
- onCall: (call) => {
325
- ctx.out(`openrouter ${call.kind} ${call.model} ${call.tokens}tok $${call.costUsd.toFixed(6)}`);
326
- },
327
- });
320
+ const getInner = () => {
321
+ if (inner === undefined) {
322
+ const apiKey = ctx.env.OPENROUTER_API_KEY;
323
+ if (apiKey === undefined || apiKey === '') {
324
+ throw new CliError('OPENROUTER_KEY_MISSING', 'OPENROUTER_API_KEY is not set; model calls bill through this key (BYOK)');
328
325
  }
329
- return inner.complete(opts);
330
- },
326
+ const envTrace = parseOpenRouterTrace(ctx.env);
327
+ const trace = { ...(envTrace ?? {}), ...(config.openrouter?.trace ?? {}) };
328
+ const headers = { ...(config.openrouter?.headers ?? {}) };
329
+ const traceOpt = Object.keys(trace).length > 0 ? trace : undefined;
330
+ const headersOpt = Object.keys(headers).length > 0 ? headers : undefined;
331
+ inner = new OpenRouterClient({
332
+ apiKey,
333
+ timeoutMs: config.review.requestTimeoutMs,
334
+ ...(traceOpt ? { trace: traceOpt } : {}),
335
+ ...(headersOpt ? { headers: headersOpt } : {}),
336
+ onCall: (call) => {
337
+ ctx.out(`openrouter ${call.kind} ${call.model} ${call.tokens}tok $${call.costUsd.toFixed(6)}`);
338
+ },
339
+ });
340
+ }
341
+ return inner;
342
+ };
343
+ return {
344
+ complete: async (opts) => getInner().complete(opts),
345
+ completeBatch: async (opts) => getInner().completeBatch(opts),
331
346
  };
332
347
  }
333
348
  async function launchDriver(config, deps) {
@@ -554,13 +569,15 @@ async function cmdRun(args, ctx, deps) {
554
569
  config.cacheDir = resolve(ctx.cwd, values['cache-dir']);
555
570
  }
556
571
  const envBudget = ctx.env.ARGUS_BUDGET_USD;
557
- if (envBudget !== undefined && envBudget !== '') {
558
- const parsed = Number(envBudget);
559
- if (Number.isFinite(parsed) && parsed > 0)
560
- config.budgetUsd = parsed;
561
- else
562
- ctx.err(`warning: ignoring invalid ARGUS_BUDGET_USD="${envBudget}"`);
563
- }
572
+ const envSetting = parseBudgetSetting(envBudget);
573
+ if (envSetting.kind === 'invalid') {
574
+ ctx.err(`warning: ignoring invalid ARGUS_BUDGET_USD="${envBudget}"`);
575
+ }
576
+ const applied = applyBudgetSetting(envSetting);
577
+ if (applied !== 'keep')
578
+ config.budgetUsd = applied;
579
+ if (config.budgetUsd === undefined)
580
+ ctx.err(UNCAPPED_WARNING);
564
581
  const liveDir = resolve(ctx.cwd, config.cacheDir ?? '.argus-reviewer-cache');
565
582
  // liveLog's mkdir is non-recursive by design — create a custom nested
566
583
  // cache dir (and ancestors) here once so the first live write lands.
@@ -1072,8 +1089,6 @@ const CODE_REVIEW_SCHEMA = {
1072
1089
  required: ['summary', 'verdict', 'findings'],
1073
1090
  },
1074
1091
  };
1075
- const CHUNK_TOKEN_TARGET = 6000;
1076
- const CHUNK_FILE_OVERHEAD = 100;
1077
1092
  const MAX_PR_FILE_PAGES = 10;
1078
1093
  async function fetchPrFiles(repo, pr, token, ctx) {
1079
1094
  const files = [];
@@ -1142,32 +1157,7 @@ export async function loadFixture(dir, exec = defaultExec) {
1142
1157
  return { files: filesFromUnifiedDiff(diff.stdout), meta, diff: diff.stdout };
1143
1158
  }
1144
1159
  export function buildPatchChunks(files, contexts = {}) {
1145
- const section = (c) => {
1146
- const ctxBlock = contexts[c.filename];
1147
- const head = ctxBlock === undefined ? `### ${c.filename}` : `### ${c.filename}\n${ctxBlock}`;
1148
- return `${head}\n\`\`\`diff\n${c.patch}\n\`\`\``;
1149
- };
1150
- const chunks = [];
1151
- let current = [];
1152
- let currentTokens = 0;
1153
- for (const f of files) {
1154
- const fileTokens = Math.ceil((f.patch?.length ?? 0) / 4) +
1155
- Math.ceil((contexts[f.filename]?.length ?? 0) / 4) +
1156
- CHUNK_FILE_OVERHEAD;
1157
- if (current.length > 0 && currentTokens + fileTokens > CHUNK_TOKEN_TARGET) {
1158
- chunks.push(current.map(section).join('\n\n'));
1159
- current = [f];
1160
- currentTokens = fileTokens;
1161
- }
1162
- else {
1163
- current.push(f);
1164
- currentTokens += fileTokens;
1165
- }
1166
- }
1167
- if (current.length > 0) {
1168
- chunks.push(current.map(section).join('\n\n'));
1169
- }
1170
- return chunks;
1160
+ return planChunks(files, contexts).map((c) => c.text);
1171
1161
  }
1172
1162
  export function buildCodeReviewMessages(repo, pr, patchText, chunkIndex = 0, totalChunks = 1, profiles = []) {
1173
1163
  const rubric = packRubric(profiles);
@@ -1560,6 +1550,8 @@ async function cmdCodeReview(args, ctx, deps) {
1560
1550
  help: { type: 'boolean', short: 'h', default: false },
1561
1551
  'report-dir': { type: 'string' },
1562
1552
  fixture: { type: 'string' },
1553
+ mode: { type: 'string' },
1554
+ 'batch-model': { type: 'string' },
1563
1555
  },
1564
1556
  });
1565
1557
  if (values.help) {
@@ -1612,9 +1604,35 @@ async function cmdCodeReview(args, ctx, deps) {
1612
1604
  .map((s) => s.trim())
1613
1605
  .filter(isReviewProfile);
1614
1606
  }
1607
+ // Review mode: --mode beats the operator env, which beats config (same
1608
+ // operator-env pattern as ARGUS_CODE_MODEL — the only lever in the
1609
+ // untrusted lane, where PR-controlled config never executes).
1610
+ const modeRaw = (values.mode ?? ctx.env.ARGUS_REVIEW_MODE ?? '').trim();
1611
+ if (modeRaw === 'realtime' || modeRaw === 'batch')
1612
+ config.review.mode = modeRaw;
1613
+ else if (modeRaw !== '') {
1614
+ if (values.mode !== undefined) {
1615
+ usageError(ctx, 'code-review', `--mode must be realtime or batch, got "${modeRaw}"`, 'argus-reviewer code-review --mode batch');
1616
+ return 2;
1617
+ }
1618
+ ctx.err(`warning: ignoring invalid ARGUS_REVIEW_MODE="${modeRaw}"`);
1619
+ }
1620
+ const batchModelRaw = (values['batch-model'] ?? ctx.env.ARGUS_BATCH_MODEL ?? '').trim();
1621
+ if (batchModelRaw !== '')
1622
+ config.review.batchModel = batchModelRaw;
1623
+ const timeoutRaw = ctx.env.ARGUS_REQUEST_TIMEOUT_MS?.trim();
1624
+ if (timeoutRaw !== undefined && timeoutRaw !== '') {
1625
+ const ms = /^\d+$/.test(timeoutRaw) ? Number(timeoutRaw) : Number.NaN;
1626
+ const bad = checkRequestTimeoutMs(ms);
1627
+ if (bad !== undefined) {
1628
+ usageError(ctx, 'code-review', `ARGUS_REQUEST_TIMEOUT_MS: ${bad.replace('requestTimeoutMs', 'value')}, got "${timeoutRaw}"`, 'ARGUS_REQUEST_TIMEOUT_MS=300000 argus-reviewer code-review');
1629
+ return 2;
1630
+ }
1631
+ config.review.requestTimeoutMs = ms;
1632
+ }
1615
1633
  const model = config.code_model ?? config.model;
1616
1634
  const runNonce = runNonceFrom(ctx.env);
1617
- debug('code-review', `repo=${repo ?? 'none'} pr=${pr ?? 'none'} model=${model} budget=${config.codeReviewBudgetUsd ?? 'unlimited'}`);
1635
+ debug('code-review', `repo=${repo ?? 'none'} pr=${pr ?? 'none'} model=${model} budget=${config.codeReviewBudgetUsd ?? 'UNCAPPED'}`);
1618
1636
  const skip = async (reason) => {
1619
1637
  ctx.out(`code-review: skipping: ${reason}`);
1620
1638
  stage(`skipped — ${reason}`);
@@ -1657,14 +1675,16 @@ async function cmdCodeReview(args, ctx, deps) {
1657
1675
  const prNum = pr;
1658
1676
  const ghToken = token;
1659
1677
  const envBudget = ctx.env.ARGUS_BUDGET_USD;
1660
- if (envBudget !== undefined && envBudget !== '') {
1661
- const parsed = Number(envBudget);
1662
- if (Number.isFinite(parsed) && parsed > 0)
1663
- config.codeReviewBudgetUsd = parsed;
1664
- else
1665
- ctx.err(`warning: ignoring invalid ARGUS_BUDGET_USD="${envBudget}"`);
1678
+ const envSetting = parseBudgetSetting(envBudget);
1679
+ if (envSetting.kind === 'invalid') {
1680
+ ctx.err(`warning: ignoring invalid ARGUS_BUDGET_USD="${envBudget}"`);
1666
1681
  }
1682
+ const appliedBudget = applyBudgetSetting(envSetting);
1683
+ if (appliedBudget !== 'keep')
1684
+ config.codeReviewBudgetUsd = appliedBudget;
1667
1685
  const budget = config.codeReviewBudgetUsd;
1686
+ if (budget === undefined)
1687
+ ctx.err(UNCAPPED_WARNING);
1668
1688
  const [allFiles, index] = await Promise.all([
1669
1689
  fixture !== undefined
1670
1690
  ? Promise.resolve(fixture.files)
@@ -1690,7 +1710,8 @@ async function cmdCodeReview(args, ctx, deps) {
1690
1710
  if (attached > 0) {
1691
1711
  debug('code-review', `contexts=${attached}/${files.length}`);
1692
1712
  }
1693
- const chunks = buildPatchChunks(files, contexts);
1713
+ const plan = planChunks(files, contexts);
1714
+ const chunks = plan.map((c) => c.text);
1694
1715
  debug('code-review', `chunks=${chunks.length} files=${files.length}`);
1695
1716
  try {
1696
1717
  const client = createClient(deps, config, ctx);
@@ -1806,21 +1827,101 @@ async function cmdCodeReview(args, ctx, deps) {
1806
1827
  });
1807
1828
  }
1808
1829
  };
1830
+ // Batch mode: all chunks go out as one async batch up front. A whole-
1831
+ // batch failure (or timeout) leaves `batched` empty so every chunk runs
1832
+ // realtime below; a single errored request falls back for that chunk
1833
+ // only. Synthesis stays realtime — it needs the merged chunk findings.
1834
+ const batched = new Map();
1835
+ let batchRecord;
1836
+ if (config.review.mode === 'batch') {
1837
+ if (client.completeBatch === undefined || chunks.length === 0) {
1838
+ batchRecord = { used: false, chunks: chunks.length, fellBack: 'client has no batch support' };
1839
+ }
1840
+ else {
1841
+ const allRequests = chunks.map((chunk, i) => ({
1842
+ customId: `chunk-${i}`,
1843
+ messages: buildCodeReviewMessages(repoName, prNum, chunk, i, chunks.length, config.review.profiles),
1844
+ schema: CODE_REVIEW_SCHEMA,
1845
+ provider: config.provider,
1846
+ }));
1847
+ // A submitted batch cannot be stopped, so size it against the
1848
+ // remaining budget first (conservative token-based estimate). Chunks
1849
+ // past the prefix run realtime below, where the per-chunk budget
1850
+ // gate applies.
1851
+ const fit = affordableBatchPrefix(allRequests, budget, ledger.visionCostUsd);
1852
+ const requests = allRequests.slice(0, fit);
1853
+ if (fit < allRequests.length) {
1854
+ ctx.err(fit === 0
1855
+ ? `code-review: batch skipped: projected cost of ${allRequests.length} chunk(s) exceeds the $${budget} budget; using realtime with budget checks`
1856
+ : `code-review: batch limited to ${fit} of ${allRequests.length} chunk(s): projected cost exceeds the $${budget} budget; the rest run realtime with budget checks`);
1857
+ }
1858
+ if (fit === 0) {
1859
+ batchRecord = { used: false, chunks: chunks.length, fellBack: 'projected batch cost exceeds budget' };
1860
+ }
1861
+ else
1862
+ try {
1863
+ stage(`submitting ${requests.length} chunk(s) as a batch, poll deadline ${Math.round(config.review.batchTimeoutMs / 1000)}s`);
1864
+ const batchModel = resolveBatchModel(reviewModel, config.review.batchModel);
1865
+ stage(`batch model ${batchModel}`);
1866
+ const items = await client.completeBatch({
1867
+ model: batchModel,
1868
+ requests,
1869
+ kind: 'code',
1870
+ deadlineMs: config.review.batchTimeoutMs,
1871
+ });
1872
+ items.forEach((item, i) => {
1873
+ if (item.result !== undefined)
1874
+ batched.set(i, item.result);
1875
+ });
1876
+ batchRecord = {
1877
+ used: true,
1878
+ chunks: chunks.length,
1879
+ retriedRealtime: chunks.length - batched.size,
1880
+ };
1881
+ if (batched.size < chunks.length) {
1882
+ ctx.err(`code-review: ${chunks.length - batched.size} batch request(s) failed; running those chunks realtime`);
1883
+ }
1884
+ }
1885
+ catch (e) {
1886
+ const reason = e.message;
1887
+ debug('code-review', `batch failed: ${reason}`);
1888
+ ctx.err(`code-review: batch failed (${reason}); falling back to realtime`);
1889
+ batchRecord = { used: false, chunks: chunks.length, fellBack: reason };
1890
+ }
1891
+ }
1892
+ }
1893
+ const reviewedChunks = new Set();
1894
+ let chunkSpend = 0;
1809
1895
  for (let i = 0; i < chunks.length; i++) {
1810
- if (ledger.budgetExceeded)
1811
- break;
1896
+ const fromBatch = batched.get(i);
1897
+ // Batch results are already paid for: always take them. Only
1898
+ // realtime chunks are gated by the budget.
1899
+ if (fromBatch === undefined) {
1900
+ if (ledger.budgetExceeded)
1901
+ continue;
1902
+ // Stop BEFORE a chunk the remaining budget cannot be expected to
1903
+ // cover (projected at the mean cost so far) — checking only after
1904
+ // the spend lets every run overshoot its cap by a whole chunk.
1905
+ if (reviewedChunks.size > 0 && !ledger.canSpend(chunkSpend / reviewedChunks.size)) {
1906
+ ctx.err(`code-review: budget would be exceeded by chunk ${i + 1}; stopping early`);
1907
+ continue;
1908
+ }
1909
+ }
1812
1910
  debug('code-review', `chunk=${i + 1}/${chunks.length}`);
1813
1911
  const chunk = chunks[i];
1814
1912
  if (chunk === undefined)
1815
1913
  continue;
1816
- const response = await client.complete({
1817
- model: reviewModel,
1818
- messages: buildCodeReviewMessages(repoName, prNum, chunk, i, chunks.length, config.review.profiles),
1819
- schema: CODE_REVIEW_SCHEMA,
1820
- kind: 'code',
1821
- provider: config.provider,
1822
- });
1914
+ const response = fromBatch ??
1915
+ (await client.complete({
1916
+ model: reviewModel,
1917
+ messages: buildCodeReviewMessages(repoName, prNum, chunk, i, chunks.length, config.review.profiles),
1918
+ schema: CODE_REVIEW_SCHEMA,
1919
+ kind: 'code',
1920
+ provider: config.provider,
1921
+ }));
1823
1922
  recordSpend(response.cost);
1923
+ chunkSpend += response.cost.costUsd;
1924
+ reviewedChunks.add(i);
1824
1925
  lastModel = response.model;
1825
1926
  const parsed = parseCodeReview(response.content);
1826
1927
  const anchored = filterToDiffLines(parsed.findings, diffRanges, blockSeverities);
@@ -1834,15 +1935,18 @@ async function cmdCodeReview(args, ctx, deps) {
1834
1935
  }
1835
1936
  allFindings.push(...vetted.kept);
1836
1937
  stage(`chunk ${i + 1}/${chunks.length} — ${parsed.findings.length} finding(s)`);
1837
- if (ledger.budgetExceeded) {
1938
+ if (ledger.budgetExceeded && fromBatch === undefined) {
1838
1939
  ctx.err(`code-review: budget exceeded after chunk ${i + 1}; stopping early`);
1839
- break;
1840
1940
  }
1841
1941
  }
1942
+ const chunksReviewed = reviewedChunks.size;
1842
1943
  let modelVerdict;
1843
1944
  let modelSummary;
1844
1945
  let finalFindings = allFindings;
1845
- if (chunks.length > 1 && !ledger.budgetExceeded) {
1946
+ // Synthesis is paid too: project it at the mean chunk cost.
1947
+ const synthAllowed = !ledger.budgetExceeded &&
1948
+ (reviewedChunks.size === 0 || ledger.canSpend(chunkSpend / reviewedChunks.size));
1949
+ if (chunks.length > 1 && synthAllowed) {
1846
1950
  try {
1847
1951
  debug('code-review', 'synthesis');
1848
1952
  stage('synthesizing chunk findings');
@@ -1911,12 +2015,25 @@ async function cmdCodeReview(args, ctx, deps) {
1911
2015
  }
1912
2016
  if (modelVerdict === verdict && modelSummary !== undefined)
1913
2017
  summary = modelSummary;
2018
+ // Files with at least one reviewed chunk. A budget stop leaves the
2019
+ // tail of the plan unreviewed; the scope and summary must say so.
2020
+ const reviewedSet = new Set(plan.flatMap((c, i) => (reviewedChunks.has(i) ? c.files : [])));
2021
+ const unreviewed = files.length - reviewedSet.size;
1914
2022
  const scope = {
1915
2023
  totalFiles: allFiles.length,
1916
- reviewedFiles: files.length,
2024
+ reviewedFiles: reviewedSet.size,
1917
2025
  excludedFiles: excluded.length,
1918
2026
  excludedSample: excluded.slice(0, 5).map((f) => f.filename),
2027
+ chunksTotal: chunks.length,
2028
+ chunksReviewed,
2029
+ unreviewedFiles: unreviewed,
1919
2030
  };
2031
+ if (chunks.length > 1) {
2032
+ const coverage = chunksReviewed === chunks.length
2033
+ ? `Reviewed all ${chunks.length} chunks (${reviewedSet.size} of ${files.length} files).`
2034
+ : `Reviewed ${chunksReviewed} of ${chunks.length} chunks (${reviewedSet.size} of ${files.length} files); ${unreviewed} file(s) were not reviewed.`;
2035
+ summary = `${coverage} ${summary}`;
2036
+ }
1920
2037
  if (excluded.length > 0) {
1921
2038
  summary = `Reviewed ${files.length} of ${allFiles.length} changed files (${excluded.length} excluded by review.exclude). ${summary}`;
1922
2039
  }
@@ -2169,6 +2286,7 @@ async function cmdCodeReview(args, ctx, deps) {
2169
2286
  ...(droppedFindings.length > 0 ? { droppedFindings } : {}),
2170
2287
  ...(modelVerdict !== undefined && modelVerdict !== verdict ? { modelVerdict } : {}),
2171
2288
  scope,
2289
+ ...(batchRecord !== undefined ? { batch: batchRecord } : {}),
2172
2290
  ...(validation !== undefined ? { validation } : {}),
2173
2291
  ...(testFileCapped > 0 ? { testFileCapped } : {}),
2174
2292
  maxComments,
@@ -2275,22 +2393,24 @@ async function cmdVerify(args, ctx, deps) {
2275
2393
  const trace = parseOpenRouterTrace(ctx.env);
2276
2394
  const git = await gitInfo(ctx.cwd);
2277
2395
  const envBudget = envOr(ctx.env.ARGUS_BUDGET_USD);
2278
- let actionBudget;
2279
- if (envBudget !== undefined) {
2280
- const parsed = Number(envBudget);
2281
- if (Number.isFinite(parsed) && parsed > 0)
2282
- actionBudget = parsed;
2283
- else
2284
- ctx.err(`warning: ignoring invalid ARGUS_BUDGET_USD="${envBudget}"`);
2285
- }
2396
+ const envSetting = parseBudgetSetting(envBudget);
2397
+ if (envSetting.kind === 'invalid') {
2398
+ ctx.err(`warning: ignoring invalid ARGUS_BUDGET_USD="${envBudget}"`);
2399
+ }
2400
+ // 'keep' → fall back to config; otherwise the env value (undefined = unlimited)
2401
+ const appliedBudget = applyBudgetSetting(envSetting);
2402
+ const hasEnvBudget = appliedBudget !== 'keep';
2403
+ const envCap = appliedBudget === 'keep' ? undefined : appliedBudget;
2286
2404
  const budgets = {};
2287
- const reviewBudget = actionBudget ?? config.codeReviewBudgetUsd;
2288
- const flowBudget = actionBudget ?? config.budgetUsd;
2405
+ const reviewBudget = hasEnvBudget ? envCap : config.codeReviewBudgetUsd;
2406
+ const flowBudget = hasEnvBudget ? envCap : config.budgetUsd;
2289
2407
  if (reviewBudget !== undefined)
2290
2408
  budgets.review = { limitUsd: reviewBudget };
2291
2409
  if (flowBudget !== undefined)
2292
2410
  budgets.flow = { limitUsd: flowBudget };
2293
- const appBudget = config.app.budgetUsd ?? actionBudget ?? config.budgetUsd;
2411
+ const appBudget = config.app.budgetUsd ?? (hasEnvBudget ? envCap : config.budgetUsd);
2412
+ if (selection.app && appBudget === undefined)
2413
+ ctx.err(UNCAPPED_WARNING);
2294
2414
  budgets.app = {
2295
2415
  ...(appBudget !== undefined ? { limitUsd: appBudget } : {}),
2296
2416
  maxDurationMs: config.app.timeoutMs ?? APP_LANE_DEFAULT_TIMEOUT_MS,
@@ -2730,145 +2850,52 @@ Then reports which optional features your environment already supports
2730
2850
 
2731
2851
  Options:
2732
2852
  --force Overwrite files that already exist
2733
- -h, --help`;
2734
- function initConfig(a0Host) {
2735
- // R19 — a detected Agent Zero host earns a labeled suggestion, never an
2736
- // enabled lane: `verify --a0` is explicit opt-in per run, and completed
2737
- // delegations cap at inconclusive (self-reported evidence).
2738
- const a0Block = a0Host !== undefined
2739
- ? `
2740
- // Optional: Agent Zero detected at ${a0Host}. Nothing below runs unless
2741
- // you ask for it — both stays commented until you opt in deliberately.
2742
- // a0: { url: ${JSON.stringify(a0Host)} }, // enables \`verify --a0\` (self-reported, unmetered)
2743
- // heal: 'a0', // escalates a failed heal to the A0 host
2744
- `
2745
- : '';
2746
- return `import { defineConfig } from 'argus-reviewer-e2e'
2853
+ --pr Open an onboarding pull request instead of writing files here
2854
+ (uses your git and gh; never reads or sends your OpenRouter key)
2855
+ --repo <owner/name> With --pr: confirm the target (must match origin)
2856
+ --branch <name> With --pr: branch to use (default argus/onboarding)
2857
+ -h, --help
2747
2858
 
2748
- export default defineConfig({
2749
- // The app under test. command boots it (omit if it is already running);
2750
- // argus-reviewer polls url until it responds before running tests.
2751
- target: {
2752
- command: 'npm run dev',
2753
- url: 'http://localhost:3000',
2754
- readyTimeoutMs: 30_000,
2755
- },
2756
- // Hard per-run cap on vision-model spend (USD). Steps replayed from the
2757
- // fingerprint cache cost $0 regardless of this cap.
2758
- budgetUsd: 1,
2759
- testsDir: 'tests/argus',
2760
- // Exploratory lane: after the test loop, a bounded agent pass probes the
2761
- // app itself — same-origin navigation, clicks, invalid input — while taps
2762
- // capture console errors, page errors, and failed requests. Findings
2763
- // render as 'observed' — evidence only, never verdict-changing.
2764
- // maxSteps caps acts per run; budgetUsd caps explore model spend (falls
2765
- // back to budgetUsd). Point it at disposable targets only — clicks and
2766
- // form submits have real side effects.
2767
- // explore: { enabled: true, maxSteps: 20, budgetUsd: 0.25 },${a0Block}
2768
- })
2769
- `;
2859
+ --pr refuses to overwrite existing files and, if the branch or an open PR
2860
+ already exists, reports it instead of creating another.`;
2861
+ /** `argus-reviewer init --pr` — open an onboarding PR through local git + gh. */
2862
+ async function cmdInitPr(values, ctx, deps) {
2863
+ const branch = values.branch ?? DEFAULT_PR_BRANCH;
2864
+ const invalid = (values.force ? '--force cannot be combined with --pr (a PR never overwrites files)' : undefined) ??
2865
+ (values.repo !== undefined ? validateRepo(values.repo) : undefined) ??
2866
+ validateBranch(branch);
2867
+ if (invalid !== undefined) {
2868
+ usageError(ctx, 'init', invalid, 'argus-reviewer init --help');
2869
+ return 2;
2870
+ }
2871
+ try {
2872
+ const result = await initPr({
2873
+ cwd: ctx.cwd,
2874
+ exec: deps.exec ?? defaultExec,
2875
+ repo: values.repo,
2876
+ branch,
2877
+ budgetUsd: DEFAULT_BUDGET_USD,
2878
+ });
2879
+ ctx.out(result.kind === 'existing'
2880
+ ? `onboarding PR already open for ${result.repo} (${result.branch}): ${result.url}`
2881
+ : `opened onboarding PR for ${result.repo} (${result.branch}): ${result.url}`);
2882
+ ctx.out('Add OPENROUTER_API_KEY as a repository secret before merging; this command never reads it.');
2883
+ return 0;
2884
+ }
2885
+ catch (e) {
2886
+ reportError(ctx, e, 'init --pr', 'COMMAND_FAILED');
2887
+ return 1;
2888
+ }
2770
2889
  }
2771
- const INIT_TEST = `test('home renders', async (td) => {
2772
- const ok = await td.assert('the page rendered without obvious errors')
2773
- if (!ok) throw new Error('home did not render')
2774
- })
2775
- `;
2776
- const INIT_WORKFLOW = `name: argus-reviewer
2777
-
2778
- on:
2779
- pull_request:
2780
- # 'labeled' lets a maintainer re-trigger with the argus-probe label when
2781
- # sandbox probes are enabled for fork PRs.
2782
- types: [opened, synchronize, reopened, labeled]
2783
-
2784
- jobs:
2785
- argus:
2786
- runs-on: ubuntu-latest
2787
- # 'labeled' fires on EVERY label — only argus-probe is the fork-gate
2788
- # signal worth a full review run.
2789
- if: github.event.action != 'labeled' || github.event.label.name == 'argus-probe'
2790
- permissions:
2791
- contents: read
2792
- issues: write
2793
- pull-requests: write
2794
- checks: write
2795
- statuses: write
2796
- steps:
2797
- # persist-credentials: false keeps the GITHUB_TOKEN out of .git/config —
2798
- # the probe sandbox masks .git regardless, but don't store it at all.
2799
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
2800
- with:
2801
- persist-credentials: false
2802
- ref: \${{ github.event.pull_request.head.sha || github.sha }}
2803
- # Optional verdict-as-review: let Argus submit APPROVE / REQUEST_CHANGES
2804
- # so require_approving_reviews counts it. GITHUB_TOKEN cannot approve, so
2805
- # create + install your own GitHub App (docs/github-app.md), set the
2806
- # ARGUS_APP_ID variable and ARGUS_APP_PRIVATE_KEY secret, then uncomment:
2807
- # - uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2
2808
- # id: argus-app
2809
- # with:
2810
- # app-id: \${{ vars.ARGUS_APP_ID }}
2811
- # private-key: \${{ secrets.ARGUS_APP_PRIVATE_KEY }}
2812
- # and pass approval-token plus its evidence inputs to the action below:
2813
- # approval-token: \${{ steps.argus-app.outputs.token }}
2814
- # approval-evidence: 'npm test' # command the approval stands on
2815
- # approval-check: 'test' # check-run name, green on head SHA
2816
- - uses: duketopceo/Argus/action@1f6bdc322f06e6dab39b3e3765b34676a0058882 # v0.4.0
2817
- with:
2818
- openrouter-api-key: \${{ secrets.OPENROUTER_API_KEY }}
2819
- `;
2820
- const INIT_MENTION_WORKFLOW = `name: argus-mention
2821
-
2822
- # @argus mention commands on PR comments — '@argus review', '@argus
2823
- # record "<flow>"', '@argus persist', '@argus help'. issue_comment is
2824
- # strictly more privileged than pull_request (secrets + write token are
2825
- # present), so the checkout below deliberately resolves the BASE ref —
2826
- # never the PR head. Argus reviews the head diff over the API.
2827
- on:
2828
- issue_comment:
2829
- types: [created]
2830
-
2831
- jobs:
2832
- argus-mention:
2833
- runs-on: ubuntu-latest
2834
- if: github.event.issue.pull_request && startsWith(github.event.comment.body, '@argus')
2835
- permissions:
2836
- # contents: write — '@argus persist' commits reproduced probes to an
2837
- # argus/ branch via the git/refs + contents APIs and opens a PR.
2838
- contents: write
2839
- issues: write
2840
- pull-requests: write
2841
- checks: write
2842
- statuses: write
2843
- steps:
2844
- # No 'ref' — the default checkout resolves the base branch. persist
2845
- # writes via the API, so checkout credentials stay disabled.
2846
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
2847
- with:
2848
- persist-credentials: false
2849
- # Record commands need the app's dependencies to boot its target.
2850
- # Uncomment if you use '@argus record':
2851
- # - run: npm ci
2852
- - uses: duketopceo/Argus/action@1f6bdc322f06e6dab39b3e3765b34676a0058882 # v0.4.0
2853
- with:
2854
- openrouter-api-key: \${{ secrets.OPENROUTER_API_KEY }}
2855
- # '@argus record' uploads the generated test + flow cache as an
2856
- # artifact — committing to a PR branch is intentionally not done.
2857
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
2858
- if: contains(github.event.comment.body, 'record')
2859
- with:
2860
- name: argus-recorded-flow
2861
- path: |
2862
- tests/argus/
2863
- .argus-reviewer-cache/
2864
- if-no-files-found: ignore
2865
- `;
2866
2890
  /** `argus-reviewer init` — scaffold config, a smoke test, and the workflow. */
2867
2891
  async function cmdInit(args, ctx, deps) {
2868
2892
  const { values } = parseArgs({
2869
2893
  args,
2870
2894
  options: {
2871
2895
  force: { type: 'boolean', default: false },
2896
+ pr: { type: 'boolean', default: false },
2897
+ repo: { type: 'string' },
2898
+ branch: { type: 'string' },
2872
2899
  help: { type: 'boolean', short: 'h', default: false },
2873
2900
  },
2874
2901
  });
@@ -2876,6 +2903,12 @@ async function cmdInit(args, ctx, deps) {
2876
2903
  ctx.out(INIT_USAGE);
2877
2904
  return 0;
2878
2905
  }
2906
+ if (values.pr)
2907
+ return cmdInitPr(values, ctx, deps);
2908
+ if (values.repo !== undefined || values.branch !== undefined) {
2909
+ usageError(ctx, 'init', '--repo and --branch only apply with --pr', 'argus-reviewer init --pr');
2910
+ return 2;
2911
+ }
2879
2912
  // Detect first so the generated config can auto-enable what is present
2880
2913
  // (e.g. an Agent Zero instance → heal: 'a0').
2881
2914
  const env = await detectEnvironment(ctx.env, {
@@ -2887,22 +2920,15 @@ async function cmdInit(args, ctx, deps) {
2887
2920
  'vision-e2e.config.ts',
2888
2921
  'vision-e2e.config.json',
2889
2922
  ];
2890
- const files = [
2891
- ['tests/argus/smoke.test.ts', INIT_TEST],
2892
- ['.github/workflows/argus-reviewer.yml', INIT_WORKFLOW],
2893
- ['.github/workflows/argus-mention.yml', INIT_MENTION_WORKFLOW],
2894
- ];
2895
2923
  const hasConfig = configNames.some((n) => existsSync(join(ctx.cwd, n)));
2896
- if (!hasConfig || values.force) {
2897
- files.unshift(['argus-reviewer.config.ts', initConfig(env.a0.host)]);
2898
- }
2924
+ const files = renderScaffold({ a0Host: env.a0.host, includeConfig: !hasConfig || values.force });
2899
2925
  // DESIGN.md 7.8: a three-step checklist (files, environment, next
2900
2926
  // command) around the unchanged "What runs and what it costs" block.
2901
2927
  const { style } = ctx;
2902
2928
  const row = (status, text) => ` ${style.glyph(status)} ${text}`;
2903
2929
  const fixLine = (cmd) => ` ${style.role('accent', cmd)}`;
2904
2930
  ctx.out(style.bold('1. Write the setup files'));
2905
- for (const [rel, content] of files) {
2931
+ for (const { path: rel, content } of files) {
2906
2932
  const path = join(ctx.cwd, rel);
2907
2933
  if (existsSync(path) && !values.force) {
2908
2934
  ctx.out(row('skipped', `exists, skipping: ${rel}`));
@@ -2950,12 +2976,8 @@ async function cmdInit(args, ctx, deps) {
2950
2976
  // R19: name what leaves the machine, the default spend posture, and
2951
2977
  // the stop path before the user runs anything. Kept verbatim (DESIGN 7.8).
2952
2978
  ctx.out('');
2953
- ctx.out('What runs and what it costs:');
2954
- ctx.out(' sent to provider PR diffs, page screenshots/DOM snapshots, and');
2955
- ctx.out(' review prompts — via your OpenRouter key (BYOK)');
2956
- ctx.out(` default budget $${dm.budgetUsd ?? 1}/run cap (budgetUsd); cached replay costs $0`);
2957
- ctx.out(' how to stop Ctrl+C locally; in CI remove the workflow file');
2958
- ctx.out(' or delete the OPENROUTER_API_KEY secret');
2979
+ for (const line of scaffoldChecklist(DEFAULT_BUDGET_USD))
2980
+ ctx.out(line);
2959
2981
  ctx.out('');
2960
2982
  ctx.out(style.bold('3. Run the default lane (code review)'));
2961
2983
  ctx.out(fixLine('argus-reviewer verify'));