council-review 0.1.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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +374 -0
  3. package/dist/bin.d.ts +3 -0
  4. package/dist/bin.d.ts.map +1 -0
  5. package/dist/bin.js +19 -0
  6. package/dist/bin.js.map +1 -0
  7. package/dist/cli.d.ts +14 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +829 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/config.d.ts +61 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +235 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/herdr.d.ts +36 -0
  16. package/dist/herdr.d.ts.map +1 -0
  17. package/dist/herdr.js +196 -0
  18. package/dist/herdr.js.map +1 -0
  19. package/dist/index.d.ts +30 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +30 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/levels.d.ts +9 -0
  24. package/dist/levels.d.ts.map +1 -0
  25. package/dist/levels.js +19 -0
  26. package/dist/levels.js.map +1 -0
  27. package/dist/merge.d.ts +90 -0
  28. package/dist/merge.d.ts.map +1 -0
  29. package/dist/merge.js +441 -0
  30. package/dist/merge.js.map +1 -0
  31. package/dist/node-guard.d.ts +9 -0
  32. package/dist/node-guard.d.ts.map +1 -0
  33. package/dist/node-guard.js +21 -0
  34. package/dist/node-guard.js.map +1 -0
  35. package/dist/panel.d.ts +89 -0
  36. package/dist/panel.d.ts.map +1 -0
  37. package/dist/panel.js +275 -0
  38. package/dist/panel.js.map +1 -0
  39. package/dist/picker.d.ts +24 -0
  40. package/dist/picker.d.ts.map +1 -0
  41. package/dist/picker.js +182 -0
  42. package/dist/picker.js.map +1 -0
  43. package/dist/providers.d.ts +66 -0
  44. package/dist/providers.d.ts.map +1 -0
  45. package/dist/providers.js +544 -0
  46. package/dist/providers.js.map +1 -0
  47. package/dist/report.d.ts +116 -0
  48. package/dist/report.d.ts.map +1 -0
  49. package/dist/report.js +493 -0
  50. package/dist/report.js.map +1 -0
  51. package/dist/resolve.d.ts +58 -0
  52. package/dist/resolve.d.ts.map +1 -0
  53. package/dist/resolve.js +147 -0
  54. package/dist/resolve.js.map +1 -0
  55. package/dist/reviewer-spawn.d.ts +67 -0
  56. package/dist/reviewer-spawn.d.ts.map +1 -0
  57. package/dist/reviewer-spawn.js +94 -0
  58. package/dist/reviewer-spawn.js.map +1 -0
  59. package/dist/reviewer-tools.d.ts +113 -0
  60. package/dist/reviewer-tools.d.ts.map +1 -0
  61. package/dist/reviewer-tools.js +557 -0
  62. package/dist/reviewer-tools.js.map +1 -0
  63. package/dist/runner.d.ts +79 -0
  64. package/dist/runner.d.ts.map +1 -0
  65. package/dist/runner.js +811 -0
  66. package/dist/runner.js.map +1 -0
  67. package/dist/schema.d.ts +63 -0
  68. package/dist/schema.d.ts.map +1 -0
  69. package/dist/schema.js +302 -0
  70. package/dist/schema.js.map +1 -0
  71. package/dist/scope.d.ts +40 -0
  72. package/dist/scope.d.ts.map +1 -0
  73. package/dist/scope.js +372 -0
  74. package/dist/scope.js.map +1 -0
  75. package/dist/snapshot.d.ts +57 -0
  76. package/dist/snapshot.d.ts.map +1 -0
  77. package/dist/snapshot.js +471 -0
  78. package/dist/snapshot.js.map +1 -0
  79. package/dist/status.d.ts +90 -0
  80. package/dist/status.d.ts.map +1 -0
  81. package/dist/status.js +336 -0
  82. package/dist/status.js.map +1 -0
  83. package/dist/thinking.d.ts +53 -0
  84. package/dist/thinking.d.ts.map +1 -0
  85. package/dist/thinking.js +116 -0
  86. package/dist/thinking.js.map +1 -0
  87. package/package.json +81 -0
  88. package/src/bin.ts +20 -0
  89. package/src/cli.ts +996 -0
  90. package/src/config.ts +338 -0
  91. package/src/herdr.ts +231 -0
  92. package/src/index.ts +30 -0
  93. package/src/levels.ts +23 -0
  94. package/src/merge.ts +548 -0
  95. package/src/node-guard.ts +25 -0
  96. package/src/panel.ts +354 -0
  97. package/src/picker.ts +230 -0
  98. package/src/providers.ts +778 -0
  99. package/src/report.ts +594 -0
  100. package/src/resolve.ts +179 -0
  101. package/src/reviewer-spawn.ts +131 -0
  102. package/src/reviewer-tools.ts +715 -0
  103. package/src/runner.ts +1120 -0
  104. package/src/schema.ts +373 -0
  105. package/src/scope.ts +448 -0
  106. package/src/snapshot.ts +536 -0
  107. package/src/status.ts +470 -0
  108. package/src/thinking.ts +148 -0
package/dist/cli.js ADDED
@@ -0,0 +1,829 @@
1
+ /**
2
+ * The `council-review` binary: argument parsing, subcommand dispatch, and exit-code mapping.
3
+ * See `openspec/changes/add-council-review/specs/council-review/cli/spec.md` for the requirements
4
+ * this implements, and `design.md`'s "Exit code 3 outranks exit code 1" for the taxonomy's one
5
+ * ordering rule.
6
+ *
7
+ * This is the only module in the package that calls `process.exit`-equivalents
8
+ * (`process.exitCode`) on purpose and the only one that owns stdio routing for `--json` mode.
9
+ * Every other module throws a typed, `exitCode`-carrying error; this file is where those are
10
+ * mapped to a process exit code.
11
+ */
12
+ import { parseArgs } from 'node:util';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { isThinkingLevel } from './levels.js';
17
+ import { ConfigError, CONFIG_DEFAULTS, configPath, ignorePath, loadConfig, writeConfig, loadIgnore, appendIgnore, } from './config.js';
18
+ import { loadCatalog, readyModels } from './providers.js';
19
+ import { supportedLevels, formatClampNotice } from './thinking.js';
20
+ import { resolveConfiguredPanel, resolveSpecPanel, enforceIndependence, } from './panel.js';
21
+ import { pickPanel } from './picker.js';
22
+ import { findRepoRoot, resolveScope, checkScopeSelectors } from './scope.js';
23
+ import { buildSnapshot, writePatch, sweepOrphans } from './snapshot.js';
24
+ import { runPanel } from './runner.js';
25
+ import { mergeFindings } from './merge.js';
26
+ import { loadPreviousFindings, diffAgainstBaseline } from './resolve.js';
27
+ import { createRunDir, updateLastPointer, reviewsDirPath, listRuns, gcRuns, writeManifest, writeReviewerArtifacts, writeFindings, writeReport, writeHandoff, emitMachineReadableFindings, } from './report.js';
28
+ import { splitPaneAndRun, setPaneTitle, notifyComplete, handoffToAgent } from './herdr.js';
29
+ import { collectStatus, formatStatusText, gitignoreExcludesReviews } from './status.js';
30
+ // -------------------------------------------------------------------------------------------
31
+ // CLI-level errors
32
+ // -------------------------------------------------------------------------------------------
33
+ /** A usage problem this file itself detects (not raised by any other module). Exit code 2. */
34
+ class UsageError extends Error {
35
+ exitCode = 2;
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = 'UsageError';
39
+ Object.setPrototypeOf(this, UsageError.prototype);
40
+ }
41
+ }
42
+ /** Specifically an unrecognised flag/subcommand, or any other `parseArgs` failure — the CLI
43
+ * spec's "Unknown flag or subcommand" scenario requires printing usage on top of the message,
44
+ * which plain `UsageError` does not (a conflicting-selector or missing-config error should not
45
+ * dump the whole flag surface). `subcommand` is which help text to print alongside it. */
46
+ class FlagParseError extends UsageError {
47
+ subcommand;
48
+ constructor(message, subcommand) {
49
+ super(message);
50
+ this.name = 'FlagParseError';
51
+ this.subcommand = subcommand;
52
+ Object.setPrototypeOf(this, FlagParseError.prototype);
53
+ }
54
+ }
55
+ function callParseArgs(subcommand, fn) {
56
+ try {
57
+ return fn();
58
+ }
59
+ catch (err) {
60
+ throw new FlagParseError(err instanceof Error ? err.message : String(err), subcommand);
61
+ }
62
+ }
63
+ function hasExitCode(err) {
64
+ return err instanceof Error && typeof err.exitCode === 'number';
65
+ }
66
+ // -------------------------------------------------------------------------------------------
67
+ // Help text
68
+ // -------------------------------------------------------------------------------------------
69
+ const REVIEW_HELP = `council-review [flags]
70
+
71
+ Runs a review of local git state using the configured (or given) panel. This is what runs when
72
+ no subcommand is given.
73
+
74
+ Subcommands:
75
+ init [--pick] Bootstrap this repository's configuration
76
+ models List discovered, ready models
77
+ show [run-id] Render a stored run's report
78
+ ignore <finding-id> Suppress a finding
79
+ gc [--keep <n>] Prune stored runs
80
+ status [--json] [--verify] Report whether this project is configured
81
+ Run "council-review <subcommand> --help" for a subcommand's own flags.
82
+
83
+ Scope flags:
84
+ --staged Review the staged index only
85
+ --range <A..B> Review a commit range
86
+ --revision <rev> Review a single revision's own change
87
+ --paths <glob>[,<glob>...] Narrow to matching paths (repeatable)
88
+ --base <branch> Override the configured base branch
89
+
90
+ Panel flags:
91
+ --models <spec> One-off panel: comma-separated provider/modelId[:level] entries or globs
92
+ --pick Re-open interactive selection; replaces the saved panel
93
+ --thinking <level> Panel-wide thinking level
94
+ --allow-correlated Waive the vendor-independence guard
95
+
96
+ Run flags:
97
+ --timeout <seconds> Per-reviewer wall-clock timeout
98
+ --max-tokens <n> Per-reviewer output-token ceiling (default: none)
99
+ --since <last|run-id> Diff findings against a previous run
100
+ --fail-on <level|none> Severity threshold for exit code 1
101
+ --no-suppress Ignore .council/ignore.json for this run
102
+ --json Emit merged findings on stdout; everything else goes to stderr
103
+
104
+ Environment flags:
105
+ --pane Run the review in a new herdr pane
106
+ --direction <horizontal|vertical> Direction for --pane (default: horizontal)
107
+ --no-pane Do not delegate to a new pane even if --pane is set
108
+ --handoff <agent> Deliver the handoff prompt to a herdr-managed agent
109
+ --no-notify Suppress the herdr completion notification
110
+
111
+ Exit codes: 0 clean, 1 findings at/above --fail-on, 2 config/usage error, 3 degraded panel
112
+ (outranks 1), 4 vendor-independence guard refusal.
113
+ `;
114
+ const SUBCOMMAND_HELP = {
115
+ init: `council-review init [--pick]
116
+
117
+ Runs panel selection, then writes .council/config.json and .council/ignore.json and ensures
118
+ .gitignore excludes .council/reviews/. Safe to re-run: existing suppressions in ignore.json are
119
+ preserved, and only the panel-related configuration is rewritten. Must be run inside a git
120
+ repository. --pick is accepted for symmetry with the review-time flag of the same name; init
121
+ always re-opens selection.
122
+ `,
123
+ models: `council-review models
124
+
125
+ Lists discovered, ready models: provider, model id, derived vendor, context window, input/output
126
+ cost rates and supported thinking levels. Makes no model call and never prompts, even without a
127
+ terminal attached.
128
+ `,
129
+ show: `council-review show [run-id]
130
+
131
+ Renders a stored run's REPORT.md. Defaults to the most recent run when no run-id is given. Makes
132
+ no model call and incurs no cost.
133
+ `,
134
+ ignore: `council-review ignore <finding-id> [--reason <text>] [--run <run-id>]
135
+
136
+ Resolves <finding-id> against the referenced run's merged findings (default: the most recent
137
+ run), and appends its fingerprint to .council/ignore.json, with an optional reason. Idempotent:
138
+ suppressing an already-suppressed finding leaves exactly one entry. An unknown finding id exits 2
139
+ and leaves the ignore file unchanged.
140
+ `,
141
+ gc: `council-review gc [--keep <n>]
142
+
143
+ Prunes stored runs under .council/reviews/, retaining the <n> most recent (default: the
144
+ configured "retain" value, or 20). Never removes the run the most-recent pointer resolves to.
145
+ Also sweeps snapshot directories orphaned by a run that could not clean up after itself.
146
+ `,
147
+ status: `council-review status [--json] [--verify]
148
+
149
+ Reports whether this project is configured for council-review, plus the resolved panel,
150
+ independence-guard verdict, settings, suppression count, stored runs and gitignore/herdr state.
151
+ Intended as the entry point for a coding agent to check in milliseconds whether it can run a
152
+ review here at all.
153
+
154
+ The default form makes no host call and no model call: it reads only files already on disk (plus
155
+ the single "git rev-parse" used to find the repository root). --verify additionally discovers the
156
+ model catalog and reports each panel entry's actual readiness and effective thinking level.
157
+
158
+ --json emits the full report as JSON on stdout instead of the human-readable form.
159
+
160
+ Exit codes: 0 when configured, 2 when not (including outside a git repository or with an invalid
161
+ config) -- never anything else.
162
+ `,
163
+ };
164
+ function printHelp(subcommand, stream = process.stdout) {
165
+ const text = subcommand !== null && subcommand in SUBCOMMAND_HELP
166
+ ? SUBCOMMAND_HELP[subcommand]
167
+ : REVIEW_HELP;
168
+ stream.write(text);
169
+ }
170
+ // -------------------------------------------------------------------------------------------
171
+ // Small shared helpers
172
+ // -------------------------------------------------------------------------------------------
173
+ function readPackageVersion() {
174
+ try {
175
+ const here = path.dirname(fileURLToPath(import.meta.url));
176
+ const pkgPath = path.join(here, '..', 'package.json');
177
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
178
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
179
+ }
180
+ catch {
181
+ return '0.0.0';
182
+ }
183
+ }
184
+ function resolveExtensionPath() {
185
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), 'reviewer-tools.js');
186
+ }
187
+ /** Writes to stderr in `--json` mode (to keep stdout parseable), stdout otherwise. */
188
+ function out(json, msg) {
189
+ (json ? process.stderr : process.stdout).write(msg);
190
+ }
191
+ function tryLoadConfig(repoRoot) {
192
+ try {
193
+ return loadConfig(repoRoot);
194
+ }
195
+ catch (err) {
196
+ if (err instanceof ConfigError)
197
+ return null;
198
+ throw err;
199
+ }
200
+ }
201
+ function resolveLastRunId(reviewsDir) {
202
+ try {
203
+ return path.basename(fs.realpathSync(path.join(reviewsDir, 'last')));
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ }
209
+ function panelEntriesFrom(reviewers) {
210
+ const panel = reviewers.map((r) => ({ provider: r.provider, model: r.model }));
211
+ const modelThinkingLevels = {};
212
+ for (const r of reviewers) {
213
+ if (r.thinking.requested !== null) {
214
+ modelThinkingLevels[`${r.provider}/${r.model}`] = r.thinking.requested;
215
+ }
216
+ }
217
+ return { panel, modelThinkingLevels };
218
+ }
219
+ /**
220
+ * Persists a panel produced by the picker into `.council/config.json`. Used by `init`, and by a
221
+ * review-time `--pick` (which "replaces the saved panel" per the panel-selection spec). Never
222
+ * used for `--models`, which defines the panel for one run only.
223
+ */
224
+ function persistPanel(repoRoot, reviewers) {
225
+ const { panel, modelThinkingLevels } = panelEntriesFrom(reviewers);
226
+ writeConfig(repoRoot, { version: CONFIG_DEFAULTS.version, panel, modelThinkingLevels });
227
+ }
228
+ function ensureIgnoreFile(repoRoot) {
229
+ const file = ignorePath(repoRoot);
230
+ if (fs.existsSync(file))
231
+ return;
232
+ fs.mkdirSync(path.dirname(file), { recursive: true });
233
+ const empty = { version: 1, entries: [] };
234
+ fs.writeFileSync(file, `${JSON.stringify(empty, null, 2)}\n`, 'utf8');
235
+ }
236
+ const GITIGNORE_ENTRY = '.council/reviews/';
237
+ /** The write half of the gitignore check; `gitignoreExcludesReviews` (status.ts) is the read
238
+ * half -- both match the same two accepted forms, so a project `status` reports as already
239
+ * excluded is never one `init` would append a duplicate entry to, or vice versa. */
240
+ function ensureGitignoreEntry(repoRoot) {
241
+ if (gitignoreExcludesReviews(repoRoot))
242
+ return;
243
+ const file = path.join(repoRoot, '.gitignore');
244
+ let content = '';
245
+ try {
246
+ content = fs.readFileSync(file, 'utf8');
247
+ }
248
+ catch {
249
+ // No .gitignore yet; created fresh below.
250
+ }
251
+ const needsNewline = content.length > 0 && !content.endsWith('\n');
252
+ fs.writeFileSync(file, `${content}${needsNewline ? '\n' : ''}${GITIGNORE_ENTRY}\n`, 'utf8');
253
+ }
254
+ function parsePositiveInt(flag, raw) {
255
+ const n = Number(raw);
256
+ if (!Number.isInteger(n) || n <= 0) {
257
+ throw new UsageError(`${flag} must be a positive integer, got "${raw}"`);
258
+ }
259
+ return n;
260
+ }
261
+ const FAIL_ON_LEVELS = ['critical', 'high', 'medium', 'low', 'none'];
262
+ function validateFailOn(raw) {
263
+ if (!FAIL_ON_LEVELS.includes(raw)) {
264
+ throw new UsageError(`invalid --fail-on value: "${raw}" (expected one of ${FAIL_ON_LEVELS.join(', ')})`);
265
+ }
266
+ return raw;
267
+ }
268
+ const DIRECTIONS = ['horizontal', 'vertical'];
269
+ /**
270
+ * Validates `--direction` the same way every other enum-valued flag is validated (`--thinking`,
271
+ * `--fail-on`): anything other than exactly one of the two accepted literals exits 2 naming what
272
+ * was given, rather than silently coercing an unrecognised value to the default. Before this
273
+ * check existed, the call site did `values.direction === 'vertical' ? 'vertical' : 'horizontal'`,
274
+ * so any typo — or, worse, `--direction down` — was silently accepted as `horizontal`. `down` is
275
+ * a particularly likely mistake: herdr's own `pane split --direction` vocabulary is `right`/`down`
276
+ * (see `splitPaneAndRun` in herdr.ts, which maps `horizontal`->`right` and `vertical`->`down`), so
277
+ * a user who reaches for the herdr word for "stacked" gets, silently, the opposite layout.
278
+ */
279
+ function validateDirection(raw) {
280
+ if (raw === undefined)
281
+ return 'horizontal';
282
+ if (!DIRECTIONS.includes(raw)) {
283
+ throw new UsageError(`invalid --direction value: "${raw}" (expected one of ${DIRECTIONS.join(', ')})`);
284
+ }
285
+ return raw;
286
+ }
287
+ const SEVERITY_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
288
+ function breachesThreshold(findings, failOn) {
289
+ if (failOn === 'none')
290
+ return false;
291
+ const min = SEVERITY_RANK[failOn];
292
+ return findings.some((f) => SEVERITY_RANK[f.severity] >= min);
293
+ }
294
+ /**
295
+ * Redirects `process.stdout.write` to `process.stderr.write` for the duration of `fn`, so that
296
+ * `runner.ts`'s own progress output (which writes directly to `process.stdout` and takes no
297
+ * stream parameter) does not contaminate stdout while `--json` mode is active. Restored in a
298
+ * `finally`, and a no-op (calls `fn` directly) when `active` is false.
299
+ */
300
+ async function withStdoutRedirectedToStderr(active, fn) {
301
+ if (!active)
302
+ return fn();
303
+ const original = process.stdout.write.bind(process.stdout);
304
+ process.stdout.write = ((...args) => process.stderr.write(...args));
305
+ try {
306
+ return await fn();
307
+ }
308
+ finally {
309
+ process.stdout.write = original;
310
+ }
311
+ }
312
+ const TASK_PROMPT = `You are one independent reviewer in a multi-model code review panel. You are
313
+ given a unified diff patch and read-only access to a frozen snapshot of the repository at the
314
+ tree the patch ends at, via your council_read, council_grep, council_list and council_git tools.
315
+
316
+ Review the patch for correctness bugs, security issues, and other defects a careful senior
317
+ engineer would flag before merging: logic errors, unhandled edge cases, resource leaks, race
318
+ conditions, broken error handling, security vulnerabilities, and violations of the codebase's own
319
+ established conventions. Do not comment on style preferences and do not report merely cosmetic
320
+ issues.
321
+
322
+ You do not know which other models, if any, are also reviewing this patch, and you will never see
323
+ their output or the fact that they exist. Form your own independent judgment using only what you
324
+ can read yourself.`;
325
+ // -------------------------------------------------------------------------------------------
326
+ // init
327
+ // -------------------------------------------------------------------------------------------
328
+ async function cmdInit(args, pickerIO) {
329
+ callParseArgs('init', () => parseArgs({
330
+ args: args,
331
+ options: { pick: { type: 'boolean' } },
332
+ allowPositionals: false,
333
+ strict: true,
334
+ }));
335
+ // `--pick` is accepted but does not change behaviour: `init` always re-opens selection, per
336
+ // the "Fresh initialization" and "Re-running the picker later" scenarios both describing the
337
+ // same effect.
338
+ const repoRoot = findRepoRoot(process.cwd());
339
+ const existing = tryLoadConfig(repoRoot);
340
+ process.stderr.write('council-review: discovering models...\n');
341
+ const catalog = await loadCatalog({ vendorOverrides: existing?.vendorOverrides ?? {} });
342
+ const reviewers = await pickPanel(catalog, pickerIO);
343
+ enforceIndependence(reviewers, false);
344
+ persistPanel(repoRoot, reviewers);
345
+ ensureIgnoreFile(repoRoot);
346
+ ensureGitignoreEntry(repoRoot);
347
+ process.stdout.write(`council-review: initialized with ${reviewers.length} reviewer(s):\n`);
348
+ for (const r of reviewers) {
349
+ process.stdout.write(` ${r.provider}/${r.model} (${r.vendor})\n`);
350
+ }
351
+ process.stdout.write(`Wrote ${configPath(repoRoot)}\n`);
352
+ return 0;
353
+ }
354
+ // -------------------------------------------------------------------------------------------
355
+ // models
356
+ // -------------------------------------------------------------------------------------------
357
+ async function cmdModels(args) {
358
+ callParseArgs('models', () => parseArgs({ args: args, options: {}, allowPositionals: false, strict: true }));
359
+ let overrides = {};
360
+ try {
361
+ const repoRoot = findRepoRoot(process.cwd());
362
+ overrides = tryLoadConfig(repoRoot)?.vendorOverrides ?? {};
363
+ }
364
+ catch {
365
+ // `models` is useful outside a repository too; fall back to no overrides.
366
+ }
367
+ const catalog = await loadCatalog({ vendorOverrides: overrides });
368
+ const models = readyModels(catalog);
369
+ if (models.length === 0) {
370
+ process.stdout.write('No ready models found.\n');
371
+ return 0;
372
+ }
373
+ for (const m of models) {
374
+ const levels = supportedLevels(m);
375
+ const context = m.contextWindow === null ? '?' : String(m.contextWindow);
376
+ const inCost = m.inputCostPerMTok === null ? '?' : `$${m.inputCostPerMTok}`;
377
+ const outCost = m.outputCostPerMTok === null ? '?' : `$${m.outputCostPerMTok}`;
378
+ process.stdout.write(`${m.provider}/${m.id}\tvendor=${m.vendor}\tcontext=${context}\tin=${inCost}/Mtok\t` +
379
+ `out=${outCost}/Mtok\tthinking=${levels.length > 0 ? levels.join(',') : 'none'}\n`);
380
+ }
381
+ return 0;
382
+ }
383
+ // -------------------------------------------------------------------------------------------
384
+ // show
385
+ // -------------------------------------------------------------------------------------------
386
+ async function cmdShow(args) {
387
+ const { positionals } = callParseArgs('show', () => parseArgs({ args: args, options: {}, allowPositionals: true, strict: true }));
388
+ if (positionals.length > 1) {
389
+ throw new UsageError(`show: unexpected extra argument "${positionals[1]}"`);
390
+ }
391
+ const repoRoot = findRepoRoot(process.cwd());
392
+ const reviewsDir = reviewsDirPath(repoRoot);
393
+ let runId;
394
+ if (positionals.length === 1) {
395
+ runId = positionals[0];
396
+ if (!listRuns(repoRoot).some((r) => r.id === runId)) {
397
+ throw new UsageError(`show: unknown run "${runId}"`);
398
+ }
399
+ }
400
+ else {
401
+ const lastId = resolveLastRunId(reviewsDir);
402
+ if (lastId === null) {
403
+ throw new UsageError('show: no runs found; run a review first');
404
+ }
405
+ runId = lastId;
406
+ }
407
+ const reportFile = path.join(reviewsDir, runId, 'REPORT.md');
408
+ let content;
409
+ try {
410
+ content = fs.readFileSync(reportFile, 'utf8');
411
+ }
412
+ catch {
413
+ throw new UsageError(`show: could not read the report for run "${runId}"`);
414
+ }
415
+ process.stdout.write(content);
416
+ return 0;
417
+ }
418
+ // -------------------------------------------------------------------------------------------
419
+ // ignore
420
+ // -------------------------------------------------------------------------------------------
421
+ async function cmdIgnore(args) {
422
+ const { values, positionals } = callParseArgs('ignore', () => parseArgs({
423
+ args: args,
424
+ options: { reason: { type: 'string' }, run: { type: 'string' } },
425
+ allowPositionals: true,
426
+ strict: true,
427
+ }));
428
+ if (positionals.length !== 1) {
429
+ throw new UsageError('ignore: expected exactly one finding id');
430
+ }
431
+ const findingId = positionals[0];
432
+ const repoRoot = findRepoRoot(process.cwd());
433
+ const reviewsDir = reviewsDirPath(repoRoot);
434
+ let runId;
435
+ if (values.run !== undefined) {
436
+ runId = values.run;
437
+ if (!listRuns(repoRoot).some((r) => r.id === runId)) {
438
+ throw new UsageError(`ignore: unknown run "${runId}"`);
439
+ }
440
+ }
441
+ else {
442
+ const lastId = resolveLastRunId(reviewsDir);
443
+ if (lastId === null) {
444
+ throw new UsageError('ignore: no runs found; run a review first');
445
+ }
446
+ runId = lastId;
447
+ }
448
+ const findingsFile = path.join(reviewsDir, runId, 'findings.json');
449
+ let findings;
450
+ try {
451
+ findings = JSON.parse(fs.readFileSync(findingsFile, 'utf8'));
452
+ }
453
+ catch {
454
+ throw new UsageError(`ignore: could not read the merged findings for run "${runId}"`);
455
+ }
456
+ const finding = findings.find((f) => f.id === findingId);
457
+ if (!finding) {
458
+ throw new UsageError(`ignore: unknown finding id "${findingId}" in run "${runId}"`);
459
+ }
460
+ appendIgnore(repoRoot, {
461
+ fingerprint: finding.fingerprint,
462
+ ...(values.reason !== undefined ? { reason: values.reason } : {}),
463
+ });
464
+ process.stdout.write(`council-review: suppressed ${findingId} (fingerprint ${finding.fingerprint})\n`);
465
+ return 0;
466
+ }
467
+ // -------------------------------------------------------------------------------------------
468
+ // gc
469
+ // -------------------------------------------------------------------------------------------
470
+ async function cmdGc(args) {
471
+ const { values } = callParseArgs('gc', () => parseArgs({
472
+ args: args,
473
+ options: { keep: { type: 'string' } },
474
+ allowPositionals: false,
475
+ strict: true,
476
+ }));
477
+ const repoRoot = findRepoRoot(process.cwd());
478
+ const cfg = tryLoadConfig(repoRoot);
479
+ let keep = cfg?.retain ?? CONFIG_DEFAULTS.retain;
480
+ if (values.keep !== undefined) {
481
+ const n = Number(values.keep);
482
+ if (!Number.isInteger(n) || n < 0) {
483
+ throw new UsageError(`gc: --keep must be a non-negative integer, got "${values.keep}"`);
484
+ }
485
+ keep = n;
486
+ }
487
+ const { removed } = gcRuns(repoRoot, keep);
488
+ // `gc` never itself holds an active snapshot (it builds none), so it passes no roots of its
489
+ // own to exempt -- protection for a snapshot a *different* process is still using comes from
490
+ // `sweepOrphans` reading each candidate's own liveness marker (its owning process's PID),
491
+ // written by `buildSnapshot` before freezing the tree.
492
+ const sweep = sweepOrphans([]);
493
+ if (removed.length === 0) {
494
+ process.stdout.write('council-review: nothing to prune\n');
495
+ }
496
+ else {
497
+ process.stdout.write(`council-review: removed ${removed.length} run(s): ${removed.join(', ')}\n`);
498
+ }
499
+ if (sweep.removed > 0) {
500
+ process.stdout.write(`council-review: swept ${sweep.removed} orphaned snapshot(s)\n`);
501
+ }
502
+ return 0;
503
+ }
504
+ // -------------------------------------------------------------------------------------------
505
+ // status
506
+ // -------------------------------------------------------------------------------------------
507
+ async function cmdStatus(args) {
508
+ const { values } = callParseArgs('status', () => parseArgs({
509
+ args: args,
510
+ options: { json: { type: 'boolean' }, verify: { type: 'boolean' } },
511
+ allowPositionals: false,
512
+ strict: true,
513
+ }));
514
+ const report = await collectStatus(process.cwd(), { verify: Boolean(values.verify) });
515
+ if (values.json) {
516
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
517
+ }
518
+ else {
519
+ process.stdout.write(formatStatusText(report));
520
+ }
521
+ return report.configured ? 0 : 2;
522
+ }
523
+ // -------------------------------------------------------------------------------------------
524
+ // review (bare invocation)
525
+ // -------------------------------------------------------------------------------------------
526
+ async function cmdReview(args, pickerIO) {
527
+ const { values } = callParseArgs('review', () => parseArgs({
528
+ args: args,
529
+ options: {
530
+ staged: { type: 'boolean' },
531
+ range: { type: 'string' },
532
+ revision: { type: 'string' },
533
+ paths: { type: 'string', multiple: true },
534
+ base: { type: 'string' },
535
+ models: { type: 'string' },
536
+ pick: { type: 'boolean' },
537
+ thinking: { type: 'string' },
538
+ 'allow-correlated': { type: 'boolean' },
539
+ timeout: { type: 'string' },
540
+ 'max-tokens': { type: 'string' },
541
+ since: { type: 'string' },
542
+ 'fail-on': { type: 'string' },
543
+ 'no-suppress': { type: 'boolean' },
544
+ json: { type: 'boolean' },
545
+ pane: { type: 'boolean' },
546
+ 'no-pane': { type: 'boolean' },
547
+ direction: { type: 'string' },
548
+ handoff: { type: 'string' },
549
+ 'no-notify': { type: 'boolean' },
550
+ },
551
+ allowPositionals: false,
552
+ strict: true,
553
+ }));
554
+ if (values.models !== undefined && values.pick) {
555
+ throw new UsageError('conflicting panel flags: --models and --pick may not be combined');
556
+ }
557
+ const selectors = {
558
+ staged: values.staged,
559
+ range: values.range,
560
+ revision: values.revision,
561
+ paths: values.paths?.flatMap((p) => p
562
+ .split(',')
563
+ .map((s) => s.trim())
564
+ .filter((s) => s.length > 0)),
565
+ base: values.base,
566
+ };
567
+ checkScopeSelectors(selectors);
568
+ let thinkingFlag;
569
+ if (values.thinking !== undefined) {
570
+ if (!isThinkingLevel(values.thinking)) {
571
+ throw new UsageError(`invalid --thinking level: "${values.thinking}"`);
572
+ }
573
+ thinkingFlag = values.thinking;
574
+ }
575
+ // Validated eagerly (alongside --thinking above), not deferred to inside the `--pane` branch
576
+ // below: an unrecognised value is a usage error regardless of whether --pane is even present,
577
+ // consistent with every other enum-valued flag never waiting on how it happens to be used.
578
+ const direction = validateDirection(values.direction);
579
+ const allowCorrelated = Boolean(values['allow-correlated']);
580
+ const jsonMode = Boolean(values.json);
581
+ const noSuppress = Boolean(values['no-suppress']);
582
+ const repoRoot = findRepoRoot(process.cwd());
583
+ const havePanelOverride = values.models !== undefined || Boolean(values.pick);
584
+ const configExists = fs.existsSync(configPath(repoRoot));
585
+ let cfg;
586
+ if (!configExists && havePanelOverride) {
587
+ cfg = {
588
+ version: CONFIG_DEFAULTS.version,
589
+ baseBranch: CONFIG_DEFAULTS.baseBranch,
590
+ panel: [],
591
+ includeContextFiles: CONFIG_DEFAULTS.includeContextFiles,
592
+ timeoutSeconds: CONFIG_DEFAULTS.timeoutSeconds,
593
+ maxOutputTokens: CONFIG_DEFAULTS.maxOutputTokens,
594
+ mergeWindow: CONFIG_DEFAULTS.mergeWindow,
595
+ claimSimilarity: CONFIG_DEFAULTS.claimSimilarity,
596
+ failOn: CONFIG_DEFAULTS.failOn,
597
+ retain: CONFIG_DEFAULTS.retain,
598
+ };
599
+ }
600
+ else {
601
+ // Missing config with no override throws here, its message already directing to `init`
602
+ // (config.ts's own wording satisfies the "Review without configuration" scenario verbatim).
603
+ cfg = loadConfig(repoRoot);
604
+ }
605
+ const catalog = await loadCatalog({ vendorOverrides: cfg.vendorOverrides ?? {} });
606
+ let reviewers;
607
+ let persistNewPanel = false;
608
+ if (values.models !== undefined) {
609
+ reviewers = resolveSpecPanel(values.models, catalog, cfg, thinkingFlag);
610
+ }
611
+ else if (values.pick) {
612
+ reviewers = await pickPanel(catalog, pickerIO);
613
+ persistNewPanel = true;
614
+ }
615
+ else {
616
+ if (cfg.panel.length === 0) {
617
+ throw new UsageError('no panel configured; run "council-review init" or pass --models/--pick.');
618
+ }
619
+ reviewers = resolveConfiguredPanel(cfg.panel, catalog, cfg, thinkingFlag);
620
+ }
621
+ // Guard #1: end of selection.
622
+ enforceIndependence(reviewers, allowCorrelated);
623
+ if (persistNewPanel) {
624
+ persistPanel(repoRoot, reviewers);
625
+ }
626
+ setPaneTitle(`council-review: ${reviewers.length} models`);
627
+ const scope = await resolveScope(repoRoot, selectors, { baseBranch: cfg.baseBranch });
628
+ if (scope.empty) {
629
+ out(jsonMode, 'council-review: nothing to review; the resolved scope is empty.\n');
630
+ return 0;
631
+ }
632
+ if (values.pane && !values['no-pane']) {
633
+ const delegateArgv = [process.argv[0], process.argv[1], ...args];
634
+ const { attempted } = splitPaneAndRun(delegateArgv, direction);
635
+ if (attempted) {
636
+ out(jsonMode, 'council-review: review delegated to a new pane.\n');
637
+ return 0;
638
+ }
639
+ // Not attempted (outside herdr, or already running inside a delegated pane): fall through
640
+ // and run the review in this process.
641
+ }
642
+ // Guard #2: immediately before launch, so a hand-edited config panel is checked too.
643
+ enforceIndependence(reviewers, allowCorrelated);
644
+ const run = createRunDir(repoRoot);
645
+ const patchPath = writePatch(run.path, scope);
646
+ const controller = new AbortController();
647
+ const onSignal = () => controller.abort();
648
+ process.on('SIGINT', onSignal);
649
+ process.on('SIGTERM', onSignal);
650
+ let snapshot;
651
+ try {
652
+ // `handleSignals: false`: this process manages SIGINT/SIGTERM itself (above) to report the
653
+ // interruption and apply its own exit code before exiting. snapshot.ts's own signal handling
654
+ // calls `process.exit()` synchronously and would otherwise race that reporting and win,
655
+ // since Node invokes every listener for one signal synchronously and none are awaited.
656
+ // `snapshot.cleanup()` in the `finally` block below remains the sole cleanup path here, and
657
+ // stays idempotent, so it composes safely with `runPanel`'s own call to it on the AbortError
658
+ // path.
659
+ snapshot = await buildSnapshot(repoRoot, scope, {
660
+ include: cfg.snapshot?.include,
661
+ handleSignals: false,
662
+ });
663
+ const timeoutSeconds = values.timeout !== undefined
664
+ ? parsePositiveInt('--timeout', values.timeout)
665
+ : cfg.timeoutSeconds;
666
+ const maxOutputTokens = values['max-tokens'] !== undefined
667
+ ? parsePositiveInt('--max-tokens', values['max-tokens'])
668
+ : cfg.maxOutputTokens;
669
+ const activeSnapshot = snapshot;
670
+ let outcome;
671
+ try {
672
+ outcome = await withStdoutRedirectedToStderr(jsonMode, () => runPanel({
673
+ reviewers,
674
+ snapshot: activeSnapshot,
675
+ repoRoot,
676
+ patchPath,
677
+ prompt: TASK_PROMPT,
678
+ extensionPath: resolveExtensionPath(),
679
+ includeContextFiles: cfg.includeContextFiles,
680
+ timeoutSeconds,
681
+ maxOutputTokens,
682
+ signal: controller.signal,
683
+ }));
684
+ }
685
+ catch (err) {
686
+ if (err instanceof Error && err.name === 'AbortError') {
687
+ out(jsonMode, 'council-review: run interrupted.\n');
688
+ return 130;
689
+ }
690
+ throw err;
691
+ }
692
+ for (const r of outcome.results) {
693
+ const notice = formatClampNotice(`${r.reviewer.provider}/${r.reviewer.model}`, r.reviewer.thinking);
694
+ if (notice)
695
+ out(jsonMode, `council-review: ${notice}\n`);
696
+ }
697
+ const reviewerFindings = outcome.results.map((r) => ({
698
+ reviewerId: `${r.reviewer.provider}/${r.reviewer.model}`,
699
+ findings: r.findings,
700
+ }));
701
+ const ignoreFile = loadIgnore(repoRoot);
702
+ const mergeOutcome = mergeFindings(reviewerFindings, {
703
+ mergeWindow: cfg.mergeWindow,
704
+ claimSimilarity: cfg.claimSimilarity,
705
+ ignore: ignoreFile,
706
+ suppress: !noSuppress,
707
+ });
708
+ let resolution = null;
709
+ if (values.since !== undefined) {
710
+ const baseline = loadPreviousFindings(run.reviewsDir, values.since);
711
+ resolution = diffAgainstBaseline(mergeOutcome.findings, baseline);
712
+ }
713
+ for (const r of outcome.results)
714
+ writeReviewerArtifacts(run, r);
715
+ const findingsPath = writeFindings(run, mergeOutcome.findings);
716
+ const manifestInput = {
717
+ run,
718
+ identity: activeSnapshot.identity,
719
+ scope,
720
+ outcome,
721
+ suppressed: mergeOutcome.suppressed,
722
+ overrides: { allowCorrelated, includeContextFiles: cfg.includeContextFiles, noSuppress },
723
+ hostVersion: null,
724
+ };
725
+ writeManifest(manifestInput);
726
+ const reportPath = writeReport(run, manifestInput, mergeOutcome.findings, resolution);
727
+ const handoffPath = writeHandoff(run, findingsPath);
728
+ updateLastPointer(run);
729
+ const summary = `${mergeOutcome.findings.length} finding(s), ${outcome.reporting}/${outcome.launched} ` +
730
+ `reviewer(s) reported${outcome.degraded ? ' (degraded)' : ''}`;
731
+ notifyComplete(summary, Boolean(values['no-notify']));
732
+ if (values.handoff !== undefined) {
733
+ handoffToAgent(values.handoff, handoffPath);
734
+ }
735
+ if (jsonMode) {
736
+ emitMachineReadableFindings(mergeOutcome.findings);
737
+ }
738
+ else {
739
+ process.stdout.write(`council-review: report written to ${reportPath}\n`);
740
+ }
741
+ const failOn = values['fail-on'] !== undefined ? validateFailOn(values['fail-on']) : cfg.failOn;
742
+ const breached = breachesThreshold(mergeOutcome.findings, failOn);
743
+ // Exit-code taxonomy (cli spec's "Exit-code taxonomy" requirement): a degraded panel outranks
744
+ // a threshold breach, so a CI job never mistakes a partial panel for a clean measurement.
745
+ if (outcome.degraded)
746
+ return 3;
747
+ if (breached)
748
+ return 1;
749
+ return 0;
750
+ }
751
+ finally {
752
+ process.off('SIGINT', onSignal);
753
+ process.off('SIGTERM', onSignal);
754
+ // Idempotent; always called regardless of which path above returned or threw, including the
755
+ // AbortError path (where runPanel has already called it itself).
756
+ snapshot?.cleanup();
757
+ }
758
+ }
759
+ // -------------------------------------------------------------------------------------------
760
+ // Dispatch
761
+ // -------------------------------------------------------------------------------------------
762
+ const KNOWN_SUBCOMMANDS = ['init', 'models', 'show', 'ignore', 'gc', 'status'];
763
+ function isKnownSubcommand(s) {
764
+ return KNOWN_SUBCOMMANDS.includes(s);
765
+ }
766
+ async function dispatch(argv, pickerIO) {
767
+ if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) {
768
+ process.stdout.write(`${readPackageVersion()}\n`);
769
+ return 0;
770
+ }
771
+ if (argv.length === 0 || argv[0].startsWith('-')) {
772
+ return cmdReview(argv, pickerIO);
773
+ }
774
+ const sub = argv[0];
775
+ const rest = argv.slice(1);
776
+ if (!isKnownSubcommand(sub)) {
777
+ process.stderr.write(`council-review: unrecognised subcommand "${sub}"\n\n`);
778
+ printHelp(null, process.stderr);
779
+ return 2;
780
+ }
781
+ switch (sub) {
782
+ case 'init':
783
+ return cmdInit(rest, pickerIO);
784
+ case 'models':
785
+ return cmdModels(rest);
786
+ case 'show':
787
+ return cmdShow(rest);
788
+ case 'ignore':
789
+ return cmdIgnore(rest);
790
+ case 'gc':
791
+ return cmdGc(rest);
792
+ case 'status':
793
+ return cmdStatus(rest);
794
+ }
795
+ }
796
+ function handleTopLevelError(err) {
797
+ if (err instanceof Error && err.name === 'AbortError') {
798
+ process.stderr.write('council-review: interrupted\n');
799
+ return 130;
800
+ }
801
+ if (err instanceof FlagParseError) {
802
+ process.stderr.write(`council-review: ${err.message}\n\n`);
803
+ printHelp(err.subcommand, process.stderr);
804
+ return err.exitCode;
805
+ }
806
+ if (hasExitCode(err)) {
807
+ process.stderr.write(`council-review: ${err.message}\n`);
808
+ return err.exitCode;
809
+ }
810
+ process.stderr.write(`council-review: unexpected error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`);
811
+ return 70;
812
+ }
813
+ /** Exported for `test/unit/cli.test.ts`, which drives the CLI in-process rather than by spawning
814
+ * a subprocess for every scenario. Not part of the library's public surface (not re-exported from
815
+ * `src/index.ts`). */
816
+ export async function runCli(argv, deps = {}) {
817
+ if (argv.includes('--help') || argv.includes('-h')) {
818
+ const subToken = argv.find((a) => !a.startsWith('-'));
819
+ printHelp(subToken && isKnownSubcommand(subToken) ? subToken : null);
820
+ return 0;
821
+ }
822
+ try {
823
+ return await dispatch(argv, deps.pickerIO);
824
+ }
825
+ catch (err) {
826
+ return handleTopLevelError(err);
827
+ }
828
+ }
829
+ //# sourceMappingURL=cli.js.map