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.
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +19 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +829 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +61 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +235 -0
- package/dist/config.js.map +1 -0
- package/dist/herdr.d.ts +36 -0
- package/dist/herdr.d.ts.map +1 -0
- package/dist/herdr.js +196 -0
- package/dist/herdr.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/levels.d.ts +9 -0
- package/dist/levels.d.ts.map +1 -0
- package/dist/levels.js +19 -0
- package/dist/levels.js.map +1 -0
- package/dist/merge.d.ts +90 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +441 -0
- package/dist/merge.js.map +1 -0
- package/dist/node-guard.d.ts +9 -0
- package/dist/node-guard.d.ts.map +1 -0
- package/dist/node-guard.js +21 -0
- package/dist/node-guard.js.map +1 -0
- package/dist/panel.d.ts +89 -0
- package/dist/panel.d.ts.map +1 -0
- package/dist/panel.js +275 -0
- package/dist/panel.js.map +1 -0
- package/dist/picker.d.ts +24 -0
- package/dist/picker.d.ts.map +1 -0
- package/dist/picker.js +182 -0
- package/dist/picker.js.map +1 -0
- package/dist/providers.d.ts +66 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +544 -0
- package/dist/providers.js.map +1 -0
- package/dist/report.d.ts +116 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +493 -0
- package/dist/report.js.map +1 -0
- package/dist/resolve.d.ts +58 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +147 -0
- package/dist/resolve.js.map +1 -0
- package/dist/reviewer-spawn.d.ts +67 -0
- package/dist/reviewer-spawn.d.ts.map +1 -0
- package/dist/reviewer-spawn.js +94 -0
- package/dist/reviewer-spawn.js.map +1 -0
- package/dist/reviewer-tools.d.ts +113 -0
- package/dist/reviewer-tools.d.ts.map +1 -0
- package/dist/reviewer-tools.js +557 -0
- package/dist/reviewer-tools.js.map +1 -0
- package/dist/runner.d.ts +79 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +811 -0
- package/dist/runner.js.map +1 -0
- package/dist/schema.d.ts +63 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +302 -0
- package/dist/schema.js.map +1 -0
- package/dist/scope.d.ts +40 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +372 -0
- package/dist/scope.js.map +1 -0
- package/dist/snapshot.d.ts +57 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +471 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/status.d.ts +90 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +336 -0
- package/dist/status.js.map +1 -0
- package/dist/thinking.d.ts +53 -0
- package/dist/thinking.d.ts.map +1 -0
- package/dist/thinking.js +116 -0
- package/dist/thinking.js.map +1 -0
- package/package.json +81 -0
- package/src/bin.ts +20 -0
- package/src/cli.ts +996 -0
- package/src/config.ts +338 -0
- package/src/herdr.ts +231 -0
- package/src/index.ts +30 -0
- package/src/levels.ts +23 -0
- package/src/merge.ts +548 -0
- package/src/node-guard.ts +25 -0
- package/src/panel.ts +354 -0
- package/src/picker.ts +230 -0
- package/src/providers.ts +778 -0
- package/src/report.ts +594 -0
- package/src/resolve.ts +179 -0
- package/src/reviewer-spawn.ts +131 -0
- package/src/reviewer-tools.ts +715 -0
- package/src/runner.ts +1120 -0
- package/src/schema.ts +373 -0
- package/src/scope.ts +448 -0
- package/src/snapshot.ts +536 -0
- package/src/status.ts +470 -0
- 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
|