@verevoir/review 0.2.0 → 0.3.1

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 (57) 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 +11 -0
  5. package/dist/cli/footer.js +68 -0
  6. package/dist/cli/review.d.ts +45 -0
  7. package/dist/cli/review.js +518 -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 +46 -0
  15. package/dist/dedup.js +169 -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 +78 -0
  22. package/dist/practice-lenses.js +230 -0
  23. package/dist/prompt.d.ts +26 -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 +64 -7
  29. package/dist/reviewer-anthropic.js +7 -1
  30. package/dist/run-review.d.ts +24 -0
  31. package/dist/run-review.js +44 -2
  32. package/dist/source-access.d.ts +8 -0
  33. package/dist/source-access.js +1 -0
  34. package/dist/subject-context-render.d.ts +8 -0
  35. package/dist/subject-context-render.js +29 -0
  36. package/dist/tier.d.ts +35 -0
  37. package/dist/tier.js +172 -0
  38. package/dist/verdict.d.ts +3 -0
  39. package/dist/warm-first.d.ts +2 -0
  40. package/dist/warm-first.js +21 -0
  41. package/lenses.default.json +30 -0
  42. package/package.json +5 -1
  43. package/src/cli/bin.ts +61 -0
  44. package/src/cli/footer.ts +88 -0
  45. package/src/cli/review.ts +656 -0
  46. package/src/context-files.ts +194 -0
  47. package/src/dedup.ts +207 -0
  48. package/src/parseReply.ts +58 -7
  49. package/src/practice-lenses.ts +286 -0
  50. package/src/prompt.ts +47 -0
  51. package/src/references.ts +381 -0
  52. package/src/report.ts +81 -7
  53. package/src/reviewer-anthropic.ts +7 -1
  54. package/src/run-review.ts +70 -7
  55. package/src/tier.ts +207 -0
  56. package/src/verdict.ts +3 -0
  57. package/src/warm-first.ts +27 -0
package/README.md CHANGED
@@ -19,8 +19,8 @@ own header, and the seed commit message, for exactly what moved and why.
19
19
 
20
20
  **Known gaps in this PR**, named rather than silently absorbed:
21
21
 
22
- - No CLI bin yet (no consumer needs it until the preflight/eval-review/CI
23
- Action cutover PRs).
22
+ - The CLI (`aigency-review`, below) is a port of governance's; see "Command
23
+ line" for what was deliberately not ported.
24
24
 
25
25
  All nine of v5's review test suites are ported (see "Tests" below, not
26
26
  two), and the ported-file doc debt (stale README/ownership references) is
@@ -298,6 +298,136 @@ aigency-runtime-v2, from round 2 of an implement↔review loop onward) is
298
298
  responsible for assembling the array from its own prior verdict's
299
299
  blocking findings before calling `runPanel`/`runReviewNode` again.
300
300
 
301
+ ## Command line
302
+
303
+ `aigency-review` runs the panel over a diff from any repository, including a
304
+ public one that cannot call governance's private reusable workflow:
305
+
306
+ ```sh
307
+ npx -y @verevoir/review aigency-review --base origin/main --title "My change"
308
+ git diff origin/main...HEAD | npx -y @verevoir/review aigency-review
309
+ ```
310
+
311
+ **Flags**
312
+
313
+ - `--base <ref>` -- review `git diff --merge-base <ref> HEAD` (run via
314
+ `execFile`, no shell). Needs full history. Without it, the diff is read from
315
+ stdin.
316
+ - `--title <text>` -- shown at the top of the report (default `this change`).
317
+ - `--lenses <path>` -- a lens set JSON file. Else `AIGENCY_REVIEW_LENSES`, else
318
+ the bundled default.
319
+ - `--description-file <path>` -- the PR description, shown under `## The change`.
320
+ Unreadable or blank is `could_not_run`. Without it the prompt says, in one
321
+ line, that no description was supplied and not to judge it.
322
+ - `--checks-file <path>` -- CI check results, rendered verbatim in the shared prompt under
323
+ `## Checks on this change` (after `## The change`, before `## The diff`). Unreadable or
324
+ blank is `could_not_run`. Without it the prompt says, in one line, that no check results
325
+ were supplied and not to assume the tests pass.
326
+ - References in added lines (automatic with a context root) -- ADR citations (`ADR 009`,
327
+ `ADR-009`, `ADR 9`) and repo-relative file paths in the diff's ADDED lines are resolved
328
+ deterministically (no model call) and rendered under `## References in added lines`, after
329
+ `## Changed files in full`. An ADR is looked up in directories named `adrs` or `adr` (depth
330
+ 4, skipping `node_modules`, `.git`, `dist`) and shown with its path and first heading, or
331
+ "not found in this repository"; a path is reported as existing or not. At most 40 are
332
+ resolved. `..`, absolute paths and root-escaping symlinks are refused.
333
+ - With `--practices`, every lens also gets a tier anchor (tag `[blocking]` only when quoting
334
+ the signal failed) and is asked for a `REASON:` line; the report shows every lens as
335
+ `#### <lens>: <verdict>: <reason>`, approving lenses included.
336
+ - With `--practices`, the tier is enforced in code. After each lens's reply is parsed, every
337
+ `[blocking]` finding must contain a verbatim quote of one of THAT lens's practices'
338
+ `_Reject:_` / `_Expect:_` lines: after lower-casing and removing markdown emphasis, quote
339
+ marks and extra whitespace on both sides, a contiguous run of at least 24 characters of the
340
+ signal line (or all of a shorter line). Otherwise it becomes `[smell]` with
341
+ " (downgraded: no signal quoted)", and a lens left with no blocking finding counts as
342
+ approved. Bundled lenses are unchanged.
343
+ - De-duplication: after the panel, ONE extra call through the same reviewer asks which findings
344
+ (ids `lens:index`) describe the same defect. The reply must put every id in exactly one
345
+ group; otherwise (or if the call fails) the un-merged report is kept with a line saying
346
+ de-duplication was skipped and why. Each group is shown once, at its highest tier, with its
347
+ first member's text and `raised by: <lens> (<practices>), ...`. Per-lens verdict lines are
348
+ unchanged, and the call's usage appears in the footer as `dedup`. `--no-dedup` skips it. It is
349
+ also skipped when fewer than two findings were raised.
350
+ - With `--checks-file`, the checks section ends with: "If a check fails, report it once, then
351
+ review the rest of the change on its own merits, as if that failure were fixed."
352
+ - `--warm-first` / `--no-warm-first` -- run the first lens alone, then fan out
353
+ the rest, so they read the prompt cache the first wrote instead of every lens
354
+ writing the same prefix. On by default with 2 or more lenses; costs roughly one
355
+ extra lens-latency of wall time. A first lens that fails does not stop the rest.
356
+ - `--context-root <dir>` -- a tree at the change's head. Each file the diff
357
+ touches (paths from the `+++` headers) that still exists there is shown in
358
+ full, post-change, if it is 16 KB or smaller, in a shared
359
+ `## Changed files in full` section after `## The diff`; skipped files are listed with the reason
360
+ (too large, deleted, not present, binary). Paths with `..` or absolute paths
361
+ are refused, and so is any file whose resolved real path (symlinks followed)
362
+ leaves the root. With `--base` it defaults to the current directory (run from
363
+ the repo root); with stdin and no `--context-root` the prompt says in one line
364
+ that there is no file context.
365
+ - `-h`, `--help`.
366
+
367
+ **Environment**
368
+
369
+ - `ANTHROPIC_API_KEY` -- billed route. Else `ANTHROPIC_API_KEY_FILE`, else
370
+ `$XDG_CONFIG_HOME/verevoir/anthropic.key` (or `~/.config/verevoir/anthropic.key`).
371
+ With no key anywhere the logged-in `claude` CLI is used. A key file that is
372
+ unreadable or blank, or a named `ANTHROPIC_API_KEY_FILE` that does not exist,
373
+ blocks the run rather than silently falling back.
374
+ - `AIGENCY_REVIEW_LENSES` -- lens set path (see above).
375
+ - `AIGENCY_REVIEW_MODEL` -- default `claude-opus-5-5`.
376
+ - `AIGENCY_REVIEW_MAX_TOKENS` -- positive integer, default `65536`.
377
+ - `GITHUB_STEP_SUMMARY` -- when set, the report is appended to that file.
378
+
379
+ **Report footer.** The report ends with what judged it: the model (the id the
380
+ transport reported, else the requested one, said as such), the reviewer route by
381
+ name only (`env`, `key-file` or `claude-cli` -- never a key or a path), and one
382
+ per-lens line with input/output tokens (and cache tokens when non-zero) and wall
383
+ time. The same per-lens lines go to stderr. A route that reports no usage says
384
+ `usage not reported`.
385
+
386
+ **Output.** The markdown report goes to stdout; `RESULT: approved|out_of_scope|rejected|could_not_run`
387
+ goes to stderr.
388
+
389
+ **Exit codes** (from `exitCodeFor`, as in governance): `approved` and
390
+ `out_of_scope` exit `0`; `rejected` and `could_not_run` exit `1`. Anything
391
+ that stops a verdict (bad config, no diff, empty diff, unreadable lens set, a
392
+ bad flag) is `could_not_run`: red, never a pass.
393
+
394
+ **Bundled lenses.** `lenses.default.json` is a copy of
395
+ `verevoir/aigency-governance` `review/lenses.json` at commit `de238aa`. It is a
396
+ snapshot, refreshed deliberately.
397
+
398
+ **Not ported from governance's CLI**: fetching the lens set over HTTP
399
+ (`AIGENCY_REVIEW_LENSES_TOKEN`/`GITHUB_TOKEN`; paths only here), the
400
+ cited-file context built from the checkout (`citations`/`readers`), and
401
+ governance's `practices` fetch. The title is `--title`, not `argv[2]`.
402
+
403
+ **Derived lenses (trial, BL-424).** `--practices <dir>` reads the `*.md`
404
+ files directly under `<dir>` (a guardrails checkout's `corpus/practices`),
405
+ groups them by their front-matter `lens:` value, and makes one lens per value
406
+ (id and title are the value; focus is a fixed adversarial sentence plus the
407
+ group's `**Sensor**` / `**Look for**` text). Practices with no `lens:` are
408
+ warned about on stderr, reviewed by nothing, and counted in the report. A
409
+ tagged practice with no sensor text is `could_not_run`. It cannot be combined
410
+ with `--lenses`. `--print-lenses` (needs `--practices`) prints each lens's id,
411
+ practice ids and focus length as JSON and exits 0 with no diff and no model
412
+ call. `<dir>/README.md` must exist: its "Signals: reject / accept / expect" section and
413
+ the "keyed to different tiers" provenance paragraph are sent to every lens as the
414
+ bar (missing or without those headings is `could_not_run`). Each practice's sensor
415
+ is headed `### <id>: <title>`. `--print-prompt <lens-id>` prints the exact prompt
416
+ that lens would be sent for the diff and exits 0 with no model call.
417
+ Front-matter is parsed by a minimal reader (only the `lens:` line, as in
418
+ v2); no YAML library.
419
+
420
+ **GitHub Actions**
421
+
422
+ ```yaml
423
+ - uses: actions/checkout@v4
424
+ with:
425
+ fetch-depth: 0
426
+ - run: npx -y @verevoir/review@<next> aigency-review --base origin/${{ github.base_ref }} --title "${{ github.event.pull_request.title }}"
427
+ env:
428
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
429
+ ```
430
+
301
431
  ## Merge policy
302
432
 
303
433
  Merges on green, same as this organisation's other rebuild repos. "Green"
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ /// <reference types="node" />
3
+ // The process boundary for `aigency-review`: construct the real adapters and
4
+ // hand them to `main` (review.ts). No decision lives here.
5
+ import { execFile } from 'node:child_process';
6
+ import { appendFile, readFile, readdir, realpath, stat } from 'node:fs/promises';
7
+ import process from 'node:process';
8
+ import { promisify } from 'node:util';
9
+ import { main } from './review.js';
10
+ const execFileAsync = promisify(execFile);
11
+ async function readAll(stream) {
12
+ const chunks = [];
13
+ stream.setEncoding('utf8');
14
+ for await (const chunk of stream)
15
+ chunks.push(chunk);
16
+ return chunks.join('');
17
+ }
18
+ main(process.argv.slice(2), process.env, {
19
+ readStdin: () => readAll(process.stdin),
20
+ stdout: (text) => void process.stdout.write(text),
21
+ stderr: (text) => void process.stderr.write(text),
22
+ readFile: (path) => readFile(path, 'utf8'),
23
+ appendFile: (path, text) => appendFile(path, text, 'utf8'),
24
+ readDir: async (path) => (await readdir(path, { withFileTypes: true })).filter((e) => e.isFile()).map((e) => e.name),
25
+ listDir: async (path) => (await readdir(path, { withFileTypes: true })).map((e) => ({
26
+ name: e.name,
27
+ isDir: e.isDirectory(),
28
+ })),
29
+ realpath: (path) => realpath(path),
30
+ statFile: async (path) => {
31
+ const s = await stat(path);
32
+ return { size: s.size, isFile: s.isFile() };
33
+ },
34
+ }, {
35
+ // execFile, no shell: a ref is an argument, never parsed as a command.
36
+ git: async (args) => (await execFileAsync('git', [...args], { maxBuffer: 512 * 1024 * 1024 })).stdout,
37
+ // dist/cli/bin.js and src/cli/bin.ts both sit two levels under the package root.
38
+ defaultLenses: () => readFile(new URL('../../lenses.default.json', import.meta.url), 'utf8'),
39
+ }).then((code) => {
40
+ process.exitCode = code;
41
+ }, (cause) => {
42
+ const message = cause instanceof Error ? cause.message : String(cause);
43
+ process.stderr.write('RESULT: could_not_run\n');
44
+ process.stderr.write(`Unexpected failure: ${message}\n`);
45
+ process.exitCode = 1;
46
+ });
@@ -0,0 +1,11 @@
1
+ import type { RawCall } from '../raw-call.js';
2
+ export type RouteName = 'env' | 'key-file' | 'claude-cli';
3
+ /** The model(s) the transport reported, else the requested one, said as such. */
4
+ export declare function modelText(requested: string, calls: readonly RawCall[]): string;
5
+ /** The one line saying how many [blocking] findings tier enforcement downgraded; none for 0. */
6
+ export declare function tierLine(downgraded: number): string | undefined;
7
+ /** The one line saying how many smells tier enforcement promoted to [blocking]; none for 0. */
8
+ export declare function promotedLine(promoted: number): string | undefined;
9
+ export declare function renderFooter(requested: string, route: RouteName, calls: readonly RawCall[], downgraded?: number, promoted?: number): string;
10
+ /** One stderr line per lens. */
11
+ export declare function usageLines(calls: readonly RawCall[]): string[];
@@ -0,0 +1,68 @@
1
+ // What judged a review: the model, the route (by NAME only -- never a key or a
2
+ // path), and per-lens token usage and wall time, from runReviewNode's `onCall`
3
+ // records. Pure formatting.
4
+ function seconds(ms) {
5
+ return `${(ms / 1000).toFixed(1)}s`;
6
+ }
7
+ function byLens(calls) {
8
+ return calls.slice().sort((a, b) => (a.lens < b.lens ? -1 : a.lens > b.lens ? 1 : 0));
9
+ }
10
+ function tokens(call) {
11
+ const u = call.usage;
12
+ if (u === undefined)
13
+ return 'usage not reported';
14
+ const cache = u.cacheReadInputTokens > 0 || u.cacheCreationInputTokens > 0
15
+ ? ` (cache read ${String(u.cacheReadInputTokens)}, cache write ` +
16
+ `${String(u.cacheCreationInputTokens)})`
17
+ : '';
18
+ return `${String(u.inputTokens)} input / ${String(u.outputTokens)} output tokens${cache}`;
19
+ }
20
+ /** The model(s) the transport reported, else the requested one, said as such. */
21
+ export function modelText(requested, calls) {
22
+ const resolved = [
23
+ ...new Set(calls.map((c) => c.resolved).filter((m) => m !== null)),
24
+ ].sort();
25
+ if (resolved.length === 0) {
26
+ return `model \`${requested}\` (as requested; the transport reported none)`;
27
+ }
28
+ const names = resolved.map((m) => `\`${m}\``).join(', ');
29
+ return resolved.length === 1 && resolved[0] === requested
30
+ ? `model ${names}`
31
+ : `model ${names} (requested \`${requested}\`)`;
32
+ }
33
+ /** The one line saying how many [blocking] findings tier enforcement downgraded; none for 0. */
34
+ export function tierLine(downgraded) {
35
+ return downgraded > 0
36
+ ? `Tier: ${String(downgraded)} blocking finding(s) downgraded: no Reject/Expect signal quoted`
37
+ : undefined;
38
+ }
39
+ /** The one line saying how many smells tier enforcement promoted to [blocking]; none for 0. */
40
+ export function promotedLine(promoted) {
41
+ return promoted > 0
42
+ ? `Tier: ${String(promoted)} finding(s) promoted: quotes a Reject signal`
43
+ : undefined;
44
+ }
45
+ export function renderFooter(requested, route, calls, downgraded = 0, promoted = 0) {
46
+ const lines = ['---', '', `Judged with ${modelText(requested, calls)}, route: ${route}.`];
47
+ const tier = tierLine(downgraded);
48
+ if (tier !== undefined)
49
+ lines.push('', tier);
50
+ const promotion = promotedLine(promoted);
51
+ if (promotion !== undefined)
52
+ lines.push('', promotion);
53
+ if (calls.length > 0) {
54
+ lines.push('', 'Per-lens usage and wall time:', '');
55
+ for (const call of byLens(calls)) {
56
+ lines.push(call.failure === undefined
57
+ ? `- \`${call.lens}\`: ${tokens(call)}, ${seconds(call.durationMs)}`
58
+ : `- \`${call.lens}\`: failed after ${seconds(call.durationMs)}`);
59
+ }
60
+ }
61
+ return lines.join('\n');
62
+ }
63
+ /** One stderr line per lens. */
64
+ export function usageLines(calls) {
65
+ return byLens(calls).map((call) => call.failure === undefined
66
+ ? `lens ${call.lens}: ${tokens(call)}, ${seconds(call.durationMs)}\n`
67
+ : `lens ${call.lens}: failed after ${seconds(call.durationMs)}\n`);
68
+ }
@@ -0,0 +1,45 @@
1
+ import type { Reviewer } from '../panel.js';
2
+ import type { Outcome } from '../verdict.js';
3
+ import type { DirEntry } from '../references.js';
4
+ /** Governance's pinned default, sent to both routes. */
5
+ export declare const DEFAULT_MODEL = "claude-opus-5-5";
6
+ /** Governance's default output-token budget (billed route only). */
7
+ export declare const DEFAULT_MAX_TOKENS = 65536;
8
+ /** What the prompt's "## The change" shows when no --description-file was given. */
9
+ export declare const NO_DESCRIPTION_NOTE = "No PR description was supplied; do not judge the description.";
10
+ /** What the prompt's "## Checks on this change" shows when no --checks-file was given. */
11
+ export declare const NO_CHECKS_NOTE = "No check results were supplied; do not assume the tests pass.";
12
+ /** Ends the checks section when --checks-file is given, so a red check is reported once. */
13
+ export declare const RED_CI_NOTE: string;
14
+ export type ResultWord = Outcome;
15
+ export interface ReviewIo {
16
+ readonly readStdin: () => Promise<string>;
17
+ readonly stdout: (text: string) => void;
18
+ readonly stderr: (text: string) => void;
19
+ /** Read a UTF-8 file. Must reject with an error carrying a `code` like fs does. */
20
+ readonly readFile: (path: string) => Promise<string>;
21
+ readonly appendFile: (path: string, text: string) => Promise<void>;
22
+ /** File names directly under a directory (no recursion, files only). */
23
+ readonly readDir: (path: string) => Promise<string[]>;
24
+ /** Resolve symlinks (fs.realpath). Rejects with an fs-style `code` if absent. */
25
+ readonly realpath: (path: string) => Promise<string>;
26
+ readonly statFile: (path: string) => Promise<{
27
+ readonly size: number;
28
+ readonly isFile: boolean;
29
+ }>;
30
+ /** List a directory's entries (for the ADR search). Optional: absent, ADRs are not searched. */
31
+ readonly listDir?: (path: string) => Promise<DirEntry[]>;
32
+ }
33
+ export interface ReviewCliDeps {
34
+ /** Run git with these args (no shell) and return stdout. Rejects on failure. */
35
+ readonly git: (args: readonly string[]) => Promise<string>;
36
+ /** Overrides the reviewer; omitted, the route is selected from the account. */
37
+ readonly reviewer?: Reviewer;
38
+ /** The bundled default lens set's JSON text. */
39
+ readonly defaultLenses: () => Promise<string>;
40
+ }
41
+ /**
42
+ * Run the review. Returns the process exit code; never throws (anything
43
+ * unexpected is reported as could_not_run).
44
+ */
45
+ export declare function main(argv: readonly string[], env: Record<string, string | undefined>, io: ReviewIo, deps: ReviewCliDeps): Promise<number>;