@verevoir/review 0.2.0 → 0.3.0

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 (53) hide show
  1. package/README.md +132 -2
  2. package/dist/cli/bin.d.ts +2 -0
  3. package/dist/cli/bin.js +46 -0
  4. package/dist/cli/footer.d.ts +9 -0
  5. package/dist/cli/footer.js +59 -0
  6. package/dist/cli/review.d.ts +45 -0
  7. package/dist/cli/review.js +507 -0
  8. package/dist/context-assembler.d.ts +9 -0
  9. package/dist/context-assembler.js +118 -0
  10. package/dist/context-files.d.ts +33 -0
  11. package/dist/context-files.js +174 -0
  12. package/dist/context-types.d.ts +28 -0
  13. package/dist/context-types.js +5 -0
  14. package/dist/dedup.d.ts +40 -0
  15. package/dist/dedup.js +151 -0
  16. package/dist/diff-hunks.d.ts +17 -0
  17. package/dist/diff-hunks.js +73 -0
  18. package/dist/fs-source-access.d.ts +7 -0
  19. package/dist/fs-source-access.js +39 -0
  20. package/dist/parseReply.js +58 -7
  21. package/dist/practice-lenses.d.ts +69 -0
  22. package/dist/practice-lenses.js +218 -0
  23. package/dist/prompt.d.ts +23 -0
  24. package/dist/prompt.js +18 -0
  25. package/dist/references.d.ts +44 -0
  26. package/dist/references.js +312 -0
  27. package/dist/report.d.ts +17 -1
  28. package/dist/report.js +56 -3
  29. package/dist/run-review.d.ts +16 -0
  30. package/dist/run-review.js +37 -2
  31. package/dist/source-access.d.ts +8 -0
  32. package/dist/source-access.js +1 -0
  33. package/dist/subject-context-render.d.ts +8 -0
  34. package/dist/subject-context-render.js +29 -0
  35. package/dist/tier.d.ts +20 -0
  36. package/dist/tier.js +88 -0
  37. package/dist/warm-first.d.ts +2 -0
  38. package/dist/warm-first.js +21 -0
  39. package/lenses.default.json +30 -0
  40. package/package.json +5 -1
  41. package/src/cli/bin.ts +61 -0
  42. package/src/cli/footer.ts +78 -0
  43. package/src/cli/review.ts +647 -0
  44. package/src/context-files.ts +194 -0
  45. package/src/dedup.ts +187 -0
  46. package/src/parseReply.ts +58 -7
  47. package/src/practice-lenses.ts +265 -0
  48. package/src/prompt.ts +44 -0
  49. package/src/references.ts +381 -0
  50. package/src/report.ts +71 -3
  51. package/src/run-review.ts +55 -7
  52. package/src/tier.ts +103 -0
  53. package/src/warm-first.ts +27 -0
@@ -0,0 +1,647 @@
1
+ /// <reference types="node" />
2
+ // The `aigency-review` command line: the v5 antagonistic panel, runnable from
3
+ // any repository (including a public one) with `npx`.
4
+ //
5
+ // PORTED FROM verevoir/aigency-governance (src/cli/review.ts, src/cli/run.ts
6
+ // and src/review/settings.ts), so a repository that cannot call governance's
7
+ // private reusable workflow gets the same behaviour: settings are resolved
8
+ // first, then the diff, then the lens set, and only then does anything reach
9
+ // a model. Every failure before a verdict is `could_not_run` and red -- never
10
+ // a pass. See the README's "Command line" section for what was deliberately
11
+ // not ported.
12
+ //
13
+ // `main` takes everything that touches the world as an argument (`io`,
14
+ // `deps`), so tests drive it with fakes and no process, disk, git or network.
15
+ // The bin wrapper (bin.ts) is the only place real ones are constructed.
16
+ //
17
+ // BUNDLED DEFAULT LENSES: `lenses.default.json` at the package root is a copy
18
+ // of aigency-governance's review/lenses.json at commit de238aa. It is a
19
+ // snapshot, not a live read: refresh it deliberately when governance's set
20
+ // changes.
21
+
22
+ import { runReviewNode } from '../run-review.js';
23
+ import { parseLensSet } from '../lensSet.js';
24
+ import type { LensSet } from '../lensSet.js';
25
+ import type { Reviewer } from '../panel.js';
26
+ import type { ReviewAccount } from '../review-account.js';
27
+ import type { Outcome } from '../verdict.js';
28
+ import { buildPrompt, renderPrompt } from '../prompt.js';
29
+ import { deriveLensSet, extractBar } from '../practice-lenses.js';
30
+ import { buildFileContext, NO_FILE_CONTEXT_NOTE } from '../context-files.js';
31
+ import { buildReferences } from '../references.js';
32
+ import type { DirEntry } from '../references.js';
33
+ import type { RawCall } from '../raw-call.js';
34
+ import { renderFooter, tierLine, usageLines } from './footer.js';
35
+ import type { RouteName } from './footer.js';
36
+ import type { DerivedLenses } from '../practice-lenses.js';
37
+
38
+ /** Governance's pinned default, sent to both routes. */
39
+ export const DEFAULT_MODEL = 'claude-opus-5-5';
40
+ /** Governance's default output-token budget (billed route only). */
41
+ export const DEFAULT_MAX_TOKENS = 65536;
42
+
43
+ /** What the prompt's "## The change" shows when no --description-file was given. */
44
+ export const NO_DESCRIPTION_NOTE = 'No PR description was supplied; do not judge the description.';
45
+
46
+ /** What the prompt's "## Checks on this change" shows when no --checks-file was given. */
47
+ export const NO_CHECKS_NOTE = 'No check results were supplied; do not assume the tests pass.';
48
+
49
+ /** Ends the checks section when --checks-file is given, so a red check is reported once. */
50
+ export const RED_CI_NOTE =
51
+ 'If a check fails, report it once, then review the rest of the change on its own merits, ' +
52
+ 'as if that failure were fixed.';
53
+
54
+ export type ResultWord = Outcome;
55
+
56
+ export interface ReviewIo {
57
+ readonly readStdin: () => Promise<string>;
58
+ readonly stdout: (text: string) => void;
59
+ readonly stderr: (text: string) => void;
60
+ /** Read a UTF-8 file. Must reject with an error carrying a `code` like fs does. */
61
+ readonly readFile: (path: string) => Promise<string>;
62
+ readonly appendFile: (path: string, text: string) => Promise<void>;
63
+ /** File names directly under a directory (no recursion, files only). */
64
+ readonly readDir: (path: string) => Promise<string[]>;
65
+ /** Resolve symlinks (fs.realpath). Rejects with an fs-style `code` if absent. */
66
+ readonly realpath: (path: string) => Promise<string>;
67
+ readonly statFile: (path: string) => Promise<{ readonly size: number; readonly isFile: boolean }>;
68
+ /** List a directory's entries (for the ADR search). Optional: absent, ADRs are not searched. */
69
+ readonly listDir?: (path: string) => Promise<DirEntry[]>;
70
+ }
71
+
72
+ export interface ReviewCliDeps {
73
+ /** Run git with these args (no shell) and return stdout. Rejects on failure. */
74
+ readonly git: (args: readonly string[]) => Promise<string>;
75
+ /** Overrides the reviewer; omitted, the route is selected from the account. */
76
+ readonly reviewer?: Reviewer;
77
+ /** The bundled default lens set's JSON text. */
78
+ readonly defaultLenses: () => Promise<string>;
79
+ }
80
+
81
+ interface Args {
82
+ readonly base?: string;
83
+ readonly title: string;
84
+ readonly lenses?: string;
85
+ readonly practices?: string;
86
+ readonly contextRoot?: string;
87
+ readonly descriptionFile?: string;
88
+ readonly checksFile?: string;
89
+ readonly warmFirst: boolean;
90
+ readonly dedup: boolean;
91
+ readonly printLenses: boolean;
92
+ readonly printPrompt?: string;
93
+ readonly help: boolean;
94
+ }
95
+
96
+ const USAGE = [
97
+ 'Usage: aigency-review [--base <ref>] [--title <text>] [--lenses <path>]',
98
+ '',
99
+ ' --base <ref> review `git diff --merge-base <ref> HEAD` (needs full history:',
100
+ ' checkout with fetch-depth: 0). Without it the diff is read from stdin.',
101
+ ' --title <text> the title shown at the top of the report (default: "this change")',
102
+ ' --lenses <path> a lens set JSON file (else AIGENCY_REVIEW_LENSES, else the bundled set)',
103
+ ' --practices <dir> derive one lens per `lens:` front-matter value from the practice',
104
+ ' markdown files in <dir> (cannot be combined with --lenses)',
105
+ ' --print-lenses with --practices: print the derived lenses as JSON and exit 0,',
106
+ ' with no reviewer call and no diff',
107
+ ' --print-prompt <lens-id> print the exact prompt that lens would be sent for the diff',
108
+ ' (stdin or --base) and exit 0, with no reviewer call',
109
+ " --context-root <dir> a tree at the change's head: each file the diff touches is shown",
110
+ ' in full (16 KB or smaller). Defaults to the current directory with --base;',
111
+ ' with stdin and no --context-root there is no file context',
112
+ ' --description-file <path> the PR description, shown under "## The change"; without it the',
113
+ ' prompt says no description was supplied',
114
+ ' --checks-file <path> CI check results, shown verbatim under "## Checks on this change";',
115
+ ' without it the prompt says no check results were supplied',
116
+ ' --warm-first / --no-warm-first run the first lens alone so the rest read the prompt cache',
117
+ ' it wrote (default: on with 2 or more lenses; slower, cheaper)',
118
+ ' --no-dedup skip the extra model call that merges findings describing the same',
119
+ ' defect across lenses (default: on when 2 or more findings are raised)',
120
+ ' -h, --help print this and exit 0',
121
+ '',
122
+ 'Environment: ANTHROPIC_API_KEY, ANTHROPIC_API_KEY_FILE, AIGENCY_REVIEW_LENSES,',
123
+ 'AIGENCY_REVIEW_MODEL, AIGENCY_REVIEW_MAX_TOKENS, GITHUB_STEP_SUMMARY.',
124
+ ].join('\n');
125
+
126
+ function parseArgs(argv: readonly string[]): Args | { readonly error: string } {
127
+ let base: string | undefined;
128
+ let title = 'this change';
129
+ let lenses: string | undefined;
130
+ let help = false;
131
+ let practices: string | undefined;
132
+ let printLenses = false;
133
+ let printPrompt: string | undefined;
134
+ let contextRoot: string | undefined;
135
+ let descriptionFile: string | undefined;
136
+ let checksFile: string | undefined;
137
+ let warmFirst = true;
138
+ let dedup = true;
139
+ for (let i = 0; i < argv.length; i++) {
140
+ const raw = argv[i] as string;
141
+ if (raw === '-h' || raw === '--help') {
142
+ help = true;
143
+ continue;
144
+ }
145
+ if (raw === '--print-lenses') {
146
+ printLenses = true;
147
+ continue;
148
+ }
149
+ if (raw === '--warm-first' || raw === '--no-warm-first') {
150
+ warmFirst = raw === '--warm-first';
151
+ continue;
152
+ }
153
+ if (raw === '--no-dedup') {
154
+ dedup = false;
155
+ continue;
156
+ }
157
+ const eq = raw.startsWith('--') ? raw.indexOf('=') : -1;
158
+ const name = eq === -1 ? raw : raw.slice(0, eq);
159
+ if (
160
+ name !== '--base' &&
161
+ name !== '--title' &&
162
+ name !== '--lenses' &&
163
+ name !== '--practices' &&
164
+ name !== '--print-prompt' &&
165
+ name !== '--context-root' &&
166
+ name !== '--description-file' &&
167
+ name !== '--checks-file'
168
+ ) {
169
+ return { error: `Unknown argument ${JSON.stringify(raw)}.` };
170
+ }
171
+ let value: string | undefined;
172
+ if (eq !== -1) {
173
+ value = raw.slice(eq + 1);
174
+ } else {
175
+ value = argv[i + 1];
176
+ i++;
177
+ }
178
+ if (value === undefined || value === '') {
179
+ return { error: `${name} needs a value.` };
180
+ }
181
+ if (name === '--base') base = value;
182
+ else if (name === '--title') title = value;
183
+ else if (name === '--practices') practices = value;
184
+ else if (name === '--print-prompt') printPrompt = value;
185
+ else if (name === '--context-root') contextRoot = value;
186
+ else if (name === '--description-file') descriptionFile = value;
187
+ else if (name === '--checks-file') checksFile = value;
188
+ else lenses = value;
189
+ }
190
+ return {
191
+ ...(base === undefined ? {} : { base }),
192
+ title,
193
+ ...(lenses === undefined ? {} : { lenses }),
194
+ ...(practices === undefined ? {} : { practices }),
195
+ ...(contextRoot === undefined ? {} : { contextRoot }),
196
+ ...(descriptionFile === undefined ? {} : { descriptionFile }),
197
+ ...(checksFile === undefined ? {} : { checksFile }),
198
+ warmFirst,
199
+ dedup,
200
+ printLenses,
201
+ ...(printPrompt === undefined ? {} : { printPrompt }),
202
+ help,
203
+ };
204
+ }
205
+
206
+ function present(value: string | undefined): string | undefined {
207
+ return value === undefined || value.trim() === '' ? undefined : value;
208
+ }
209
+
210
+ function defaultKeyFilePath(env: Record<string, string | undefined>): string | undefined {
211
+ const xdg = present(env['XDG_CONFIG_HOME']);
212
+ if (xdg !== undefined) return `${xdg}/verevoir/anthropic.key`;
213
+ const home = present(env['HOME']);
214
+ return home === undefined ? undefined : `${home}/.config/verevoir/anthropic.key`;
215
+ }
216
+
217
+ type KeyRead =
218
+ | { readonly kind: 'text'; readonly text: string }
219
+ | { readonly kind: 'absent' }
220
+ | { readonly kind: 'unreadable'; readonly reason: string };
221
+
222
+ async function readKeyFile(io: ReviewIo, path: string): Promise<KeyRead> {
223
+ try {
224
+ return { kind: 'text', text: await io.readFile(path) };
225
+ } catch (cause) {
226
+ const code = (cause as { code?: unknown }).code;
227
+ if (code === 'ENOENT' || code === 'ENOTDIR') return { kind: 'absent' };
228
+ // Names the failure, never any bytes: a key must not reach a log.
229
+ const message = cause instanceof Error ? cause.message : String(cause);
230
+ const label = typeof code === 'string' ? code : 'error';
231
+ return { kind: 'unreadable', reason: `${label}: ${message}` };
232
+ }
233
+ }
234
+
235
+ type AccountResolution =
236
+ | { readonly kind: 'ok'; readonly account: ReviewAccount; readonly route: RouteName }
237
+ | { readonly kind: 'problem'; readonly problem: string };
238
+
239
+ /**
240
+ * The key, in governance's order: ANTHROPIC_API_KEY (non-blank), then
241
+ * ANTHROPIC_API_KEY_FILE, then the conventional default key file; nothing
242
+ * configured selects the claude-cli route. A key file someone configured that
243
+ * is unreadable or blank -- or a NAMED file that is missing -- blocks rather
244
+ * than silently falling back to the keyless route.
245
+ */
246
+ async function resolveAccount(
247
+ env: Record<string, string | undefined>,
248
+ io: ReviewIo,
249
+ ): Promise<AccountResolution> {
250
+ const fromEnv = present(env['ANTHROPIC_API_KEY']);
251
+ if (fromEnv !== undefined) {
252
+ return { kind: 'ok', account: { kind: 'key', apiKey: fromEnv }, route: 'env' };
253
+ }
254
+
255
+ const named = present(env['ANTHROPIC_API_KEY_FILE']);
256
+ const path = named ?? defaultKeyFilePath(env);
257
+ if (path === undefined) {
258
+ return { kind: 'ok', account: { kind: 'subscription' }, route: 'claude-cli' };
259
+ }
260
+
261
+ const read = await readKeyFile(io, path);
262
+ if (read.kind === 'absent') {
263
+ if (named !== undefined) {
264
+ return {
265
+ kind: 'problem',
266
+ problem:
267
+ `ANTHROPIC_API_KEY_FILE names ${path}, but no file exists there. Check the path, ` +
268
+ 'or unset ANTHROPIC_API_KEY_FILE to use the claude-cli route.',
269
+ };
270
+ }
271
+ return { kind: 'ok', account: { kind: 'subscription' }, route: 'claude-cli' };
272
+ }
273
+ if (read.kind === 'unreadable') {
274
+ return {
275
+ kind: 'problem',
276
+ problem: `the API key file at ${path} could not be read: ${read.reason}`,
277
+ };
278
+ }
279
+ const key = read.text.trim();
280
+ if (key === '') {
281
+ return {
282
+ kind: 'problem',
283
+ problem: `the API key file at ${path} exists but is blank; fill it in or remove it.`,
284
+ };
285
+ }
286
+ return { kind: 'ok', account: { kind: 'key', apiKey: key }, route: 'key-file' };
287
+ }
288
+
289
+ function couldNotRun(title: string, reason: string, source?: string): string {
290
+ return [
291
+ '## Review: Could not run',
292
+ '',
293
+ `**${title}**`,
294
+ '',
295
+ reason,
296
+ '',
297
+ 'Nothing was reviewed. This is a red result and not a passing one.',
298
+ ...(source === undefined ? [] : ['', '---', '', `Lens set: \`${source}\`.`]),
299
+ ].join('\n');
300
+ }
301
+
302
+ /**
303
+ * Run the review. Returns the process exit code; never throws (anything
304
+ * unexpected is reported as could_not_run).
305
+ */
306
+ export async function main(
307
+ argv: readonly string[],
308
+ env: Record<string, string | undefined>,
309
+ io: ReviewIo,
310
+ deps: ReviewCliDeps,
311
+ ): Promise<number> {
312
+ const parsed = parseArgs(argv);
313
+ if ('error' in parsed) {
314
+ const reason = `${parsed.error}\n\n${USAGE}`;
315
+ return finish(io, env, 'could_not_run', couldNotRun('this change', reason));
316
+ }
317
+ if (parsed.help) {
318
+ io.stdout(`${USAGE}\n`);
319
+ return 0;
320
+ }
321
+ const title = parsed.title;
322
+ const fail = (reason: string, source?: string): Promise<number> =>
323
+ finish(io, env, 'could_not_run', couldNotRun(title, reason, source));
324
+
325
+ try {
326
+ // 0. Derived lenses (--practices), settled before settings or any diff.
327
+ if (parsed.practices !== undefined && parsed.lenses !== undefined) {
328
+ return await fail(
329
+ '--practices and --lenses cannot be used together; choose one lens source.',
330
+ );
331
+ }
332
+ if (parsed.printLenses && parsed.practices === undefined) {
333
+ return await fail('--print-lenses needs --practices <dir>.');
334
+ }
335
+ let derived: DerivedLenses | undefined;
336
+ let bar: string | undefined;
337
+ if (parsed.practices !== undefined) {
338
+ const dir = parsed.practices;
339
+ // The grading rules (README), read first so its failure has its own reason.
340
+ let readme: string;
341
+ try {
342
+ readme = await io.readFile(`${dir}/README.md`);
343
+ } catch (cause) {
344
+ return await fail(
345
+ `The practices README at ${dir}/README.md could not be read, so the review has no ` +
346
+ 'grading rules (reject / accept / expect): ' +
347
+ `${cause instanceof Error ? cause.message : String(cause)}`,
348
+ );
349
+ }
350
+ try {
351
+ const names = (await io.readDir(dir)).filter(
352
+ (n) => n.endsWith('.md') && n.toLowerCase() !== 'readme.md',
353
+ );
354
+ const files = await Promise.all(
355
+ names.map(async (name) => ({ name, text: await io.readFile(`${dir}/${name}`) })),
356
+ );
357
+ derived = deriveLensSet(files);
358
+ } catch (cause) {
359
+ return await fail(
360
+ `The practices under ${dir} could not be read: ` +
361
+ `${cause instanceof Error ? cause.message : String(cause)}`,
362
+ );
363
+ }
364
+ if (derived.untagged.length > 0) {
365
+ io.stderr(
366
+ `warning: ${String(derived.untagged.length)} practice(s) with no "lens:" tag are ` +
367
+ `not reviewed: ${derived.untagged.join(', ')}\n`,
368
+ );
369
+ }
370
+ for (const [lensId, own] of Object.entries(derived.signals)) {
371
+ if (own.length === 0) {
372
+ io.stderr(
373
+ `warning: lens ${lensId} has no _Reject:_ / _Expect:_ signal lines, so every ` +
374
+ 'blocking finding it raises will be downgraded to a smell.\n',
375
+ );
376
+ }
377
+ }
378
+ if (derived.groups.length === 0) {
379
+ return await fail(
380
+ `No practice under ${dir} carries a "lens:" front-matter value, so no lens could be ` +
381
+ 'derived. Refusing to review against nothing.',
382
+ );
383
+ }
384
+ const extracted = extractBar(readme);
385
+ if (!extracted.ok) {
386
+ return await fail(
387
+ `The practices README at ${dir}/README.md cannot supply the grading rules: ` +
388
+ `${extracted.reason}.`,
389
+ );
390
+ }
391
+ bar = extracted.text;
392
+ if (parsed.printLenses) {
393
+ const out = {
394
+ barLength: bar.length,
395
+ lenses: derived.lensSet.lenses.map((lens) => ({
396
+ id: lens.id,
397
+ practices: derived?.groups.find((g) => g.lensId === lens.id)?.practiceIds ?? [],
398
+ focusLength: lens.focus.length,
399
+ })),
400
+ untagged: derived.untagged,
401
+ missingSensor: derived.missingSensor,
402
+ };
403
+ io.stdout(`${JSON.stringify(out, null, 2)}\n`);
404
+ return 0;
405
+ }
406
+ if (derived.missingSensor.length > 0) {
407
+ return await fail(
408
+ `Tagged practice(s) have no Sensor / Look for text, so a lens would check them ` +
409
+ `against nothing: ${derived.missingSensor.join(', ')}.`,
410
+ );
411
+ }
412
+ }
413
+
414
+ // 1. Settings, before anything costs money.
415
+ const problems: string[] = [];
416
+ // --print-prompt makes no model call, so it needs no key.
417
+ const resolved: AccountResolution = parsed.printPrompt
418
+ ? { kind: 'ok', account: { kind: 'subscription' }, route: 'claude-cli' }
419
+ : await resolveAccount(env, io);
420
+ if (resolved.kind === 'problem') problems.push(resolved.problem);
421
+ const model = present(env['AIGENCY_REVIEW_MODEL']) ?? DEFAULT_MODEL;
422
+ let maxTokens = DEFAULT_MAX_TOKENS;
423
+ const rawMax = present(env['AIGENCY_REVIEW_MAX_TOKENS']);
424
+ if (rawMax !== undefined) {
425
+ const n = Number(rawMax);
426
+ if (!Number.isSafeInteger(n) || n <= 0) {
427
+ problems.push(
428
+ `AIGENCY_REVIEW_MAX_TOKENS must be a positive whole number, got ${JSON.stringify(rawMax)}`,
429
+ );
430
+ } else {
431
+ maxTokens = n;
432
+ }
433
+ }
434
+ if (resolved.kind === 'problem' || problems.length > 0) {
435
+ return await fail(
436
+ `The configuration is present and wrong:\n\n${problems.map((p) => `- ${p}`).join('\n')}`,
437
+ );
438
+ }
439
+ const account = resolved.account;
440
+ const route = resolved.route;
441
+
442
+ // 2. The diff.
443
+ let diff: string;
444
+ if (parsed.base !== undefined) {
445
+ try {
446
+ diff = await deps.git(['diff', '--merge-base', parsed.base, 'HEAD']);
447
+ } catch (cause) {
448
+ return await fail(
449
+ `The diff could not be obtained from \`git diff --merge-base ${parsed.base} HEAD\`: ` +
450
+ `${cause instanceof Error ? cause.message : String(cause)}\n\n` +
451
+ 'Check the ref exists and that the checkout has full history (fetch-depth: 0).',
452
+ );
453
+ }
454
+ } else {
455
+ diff = await io.readStdin();
456
+ }
457
+ if (diff.trim() === '') {
458
+ return await fail(
459
+ 'The diff was empty, so there was nothing to review.\n\n' +
460
+ 'This is almost always a wiring problem rather than an empty change: a diff computed ' +
461
+ 'against the wrong base, or a shallow clone with no merge base. Check how the diff was ' +
462
+ 'produced before concluding the change is empty.',
463
+ );
464
+ }
465
+
466
+ // 2a. The PR description: --description-file, else a one-line note that
467
+ // none was supplied (so the lenses do not judge an empty description).
468
+ let description = NO_DESCRIPTION_NOTE;
469
+ if (parsed.descriptionFile !== undefined) {
470
+ let text: string;
471
+ try {
472
+ text = await io.readFile(parsed.descriptionFile);
473
+ } catch (cause) {
474
+ return await fail(
475
+ `The description file ${parsed.descriptionFile} could not be read: ` +
476
+ `${cause instanceof Error ? cause.message : String(cause)}`,
477
+ );
478
+ }
479
+ if (text.trim() === '') {
480
+ return await fail(
481
+ `The description file ${parsed.descriptionFile} is blank. Omit --description-file ` +
482
+ 'if there is no description.',
483
+ );
484
+ }
485
+ description = text.trim();
486
+ }
487
+
488
+ // 2a'. CI check results: --checks-file, else a one-line note that none were supplied.
489
+ let checks = NO_CHECKS_NOTE;
490
+ if (parsed.checksFile !== undefined) {
491
+ let text: string;
492
+ try {
493
+ text = await io.readFile(parsed.checksFile);
494
+ } catch (cause) {
495
+ return await fail(
496
+ `The checks file ${parsed.checksFile} could not be read: ` +
497
+ `${cause instanceof Error ? cause.message : String(cause)}`,
498
+ );
499
+ }
500
+ if (text.trim() === '') {
501
+ return await fail(
502
+ `The checks file ${parsed.checksFile} is blank. Omit --checks-file if there are no ` +
503
+ 'check results.',
504
+ );
505
+ }
506
+ checks = `${text.trim()}\n\n${RED_CI_NOTE}`;
507
+ }
508
+
509
+ // 2b. Whole-file context: --context-root, else the current directory in
510
+ // --base mode, else none (said in one line, not an error).
511
+ const contextRoot = parsed.contextRoot ?? (parsed.base !== undefined ? '.' : undefined);
512
+ let fileContext = NO_FILE_CONTEXT_NOTE;
513
+ let references: string | undefined;
514
+ if (contextRoot !== undefined) {
515
+ try {
516
+ const fs = {
517
+ realpath: io.realpath,
518
+ statFile: io.statFile,
519
+ readFile: io.readFile,
520
+ };
521
+ fileContext = await buildFileContext(diff, contextRoot, fs);
522
+ references = await buildReferences(diff, contextRoot, {
523
+ ...fs,
524
+ ...(io.listDir === undefined ? {} : { listDir: io.listDir }),
525
+ });
526
+ } catch (cause) {
527
+ return await fail(
528
+ `The file context could not be built: ${
529
+ cause instanceof Error ? cause.message : String(cause)
530
+ }`,
531
+ );
532
+ }
533
+ }
534
+ const subject = {
535
+ title,
536
+ description,
537
+ diff,
538
+ fileContext,
539
+ checks,
540
+ ...(references === undefined ? {} : { references }),
541
+ };
542
+
543
+ // 3. The lens set: --lenses, else AIGENCY_REVIEW_LENSES, else bundled.
544
+ const lensPath = parsed.lenses ?? present(env['AIGENCY_REVIEW_LENSES']);
545
+ const source =
546
+ derived !== undefined
547
+ ? `practices in ${parsed.practices ?? ''} (derived: one lens per lens: value)`
548
+ : (lensPath ?? 'bundled default lenses (aigency-governance review/lenses.json @ de238aa)');
549
+ let lensSet: LensSet;
550
+ try {
551
+ if (derived !== undefined) {
552
+ lensSet = derived.lensSet;
553
+ } else {
554
+ const text =
555
+ lensPath === undefined ? await deps.defaultLenses() : await io.readFile(lensPath);
556
+ lensSet = parseLensSet(text);
557
+ }
558
+ } catch (cause) {
559
+ return await fail(
560
+ `The lens set could not be loaded: ${cause instanceof Error ? cause.message : String(cause)}`,
561
+ source,
562
+ );
563
+ }
564
+
565
+ // 3b. --print-prompt: the exact prompt one lens would be sent; no reviewer call.
566
+ if (parsed.printPrompt !== undefined) {
567
+ const lens = lensSet.lenses.find((l) => l.id === parsed.printPrompt);
568
+ if (lens === undefined) {
569
+ return await fail(
570
+ `--print-prompt names lens ${JSON.stringify(parsed.printPrompt)}, which is not in the ` +
571
+ `lens set (${lensSet.lenses.map((l) => l.id).join(', ')}).`,
572
+ source,
573
+ );
574
+ }
575
+ io.stdout(`${renderPrompt(buildPrompt(lens, subject, bar))}\n`);
576
+ return 0;
577
+ }
578
+
579
+ // 4. The panel.
580
+ const calls: RawCall[] = [];
581
+ const result = await runReviewNode(
582
+ {
583
+ subject,
584
+ ...(bar === undefined ? {} : { practices: bar }),
585
+ lensSet,
586
+ lensSetSource: source,
587
+ account,
588
+ model,
589
+ maxTokens,
590
+ ...(deps.reviewer === undefined ? {} : { reviewer: deps.reviewer }),
591
+ warmFirst: parsed.warmFirst,
592
+ dedup: parsed.dedup,
593
+ ...(derived === undefined
594
+ ? {}
595
+ : { tierSignals: derived.signals, practiceNames: derived.practiceNames }),
596
+ },
597
+ (call) => void calls.push(call),
598
+ );
599
+ for (const line of usageLines(calls)) io.stderr(line);
600
+ const tier = tierLine(result.downgraded);
601
+ if (tier !== undefined) io.stderr(`${tier}\n`);
602
+ const footer = `\n\n${renderFooter(model, route, calls, result.downgraded)}`;
603
+ const note =
604
+ derived !== undefined && derived.untagged.length > 0
605
+ ? `\n\n> ${String(derived.untagged.length)} practice(s) had no "lens:" tag and were ` +
606
+ `not reviewed: ${derived.untagged.join(', ')}`
607
+ : '';
608
+ return await finish(
609
+ io,
610
+ env,
611
+ result.verdict.outcome,
612
+ result.report + note + footer,
613
+ result.exitCode,
614
+ );
615
+ } catch (cause) {
616
+ // Defect at the boundary, not a verdict about the change.
617
+ return fail(
618
+ `Unexpected failure: ${cause instanceof Error ? cause.message : String(cause)}. ` +
619
+ 'This is a defect in the runner, not a verdict about the change.',
620
+ );
621
+ }
622
+ }
623
+
624
+ async function finish(
625
+ io: ReviewIo,
626
+ env: Record<string, string | undefined>,
627
+ outcome: ResultWord,
628
+ report: string,
629
+ exitCode: number = 1,
630
+ ): Promise<number> {
631
+ io.stdout(`${report}\n`);
632
+ const summary = present(env['GITHUB_STEP_SUMMARY']);
633
+ if (summary !== undefined) {
634
+ try {
635
+ await io.appendFile(summary, `${report}\n`);
636
+ } catch (cause) {
637
+ // The verdict stands; a summary that could not be written is said, not hidden.
638
+ io.stderr(
639
+ `warning: could not append to GITHUB_STEP_SUMMARY: ${
640
+ cause instanceof Error ? cause.message : String(cause)
641
+ }\n`,
642
+ );
643
+ }
644
+ }
645
+ io.stderr(`RESULT: ${outcome}\n`);
646
+ return exitCode;
647
+ }