@pugi/cli 0.1.0-alpha.10 → 0.1.0-alpha.16

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.
@@ -4,7 +4,9 @@ import { dirname, resolve } from 'node:path';
4
4
  import { z } from 'zod';
5
5
  import { loadMcpRegistry } from '../../core/mcp/registry.js';
6
6
  import { listMcpTrust, setMcpTrust, } from '../../core/mcp/trust.js';
7
+ import { request } from 'undici';
7
8
  import { trustWorkspace } from '../../core/trust.js';
9
+ import { resolveActiveCredential } from '../../core/credentials.js';
8
10
  /**
9
11
  * `pugi config` — operator-level configuration surface.
10
12
  *
@@ -53,24 +55,45 @@ export async function runConfigCommand(args, ctx) {
53
55
  'pugi config mcp trust <name>',
54
56
  'pugi config mcp deny <name>',
55
57
  'pugi config mcp list',
58
+ 'pugi config get routing',
59
+ 'pugi config set routing.<tag>.<budget>=<model>',
60
+ 'pugi config unset routing.<tag>.<budget>',
56
61
  ],
57
62
  }, [
58
63
  'Usage:',
59
- ' pugi config get <key> Read a config value.',
60
- ' pugi config set <key> <value> Write a config value.',
61
- ' pugi config list Show all config values.',
62
- ' pugi config trust . Trust the current workspace for hooks + MCP.',
63
- ' pugi config mcp trust <name> Mark an MCP server as trusted.',
64
- ' pugi config mcp deny <name> Block an MCP server.',
65
- ' pugi config mcp list Show declared MCP servers + trust state.',
64
+ ' pugi config get <key> Read a config value.',
65
+ ' pugi config set <key> <value> Write a config value.',
66
+ ' pugi config list Show all config values.',
67
+ ' pugi config trust . Trust the current workspace for hooks + MCP.',
68
+ ' pugi config mcp trust <name> Mark an MCP server as trusted.',
69
+ ' pugi config mcp deny <name> Block an MCP server.',
70
+ ' pugi config mcp list Show declared MCP servers + trust state.',
71
+ ' pugi config get routing Show effective routing table (defaults + tenant overrides).',
72
+ ' pugi config set routing.<tag>.<budget>=<model> Override the model for one (tag, budget) lane.',
73
+ ' pugi config unset routing.<tag>.<budget> Remove a routing override (revert to default).',
66
74
  ].join('\n'));
67
75
  return;
68
76
  }
69
77
  switch (sub) {
70
78
  case 'get':
79
+ // Special form: `pugi config get routing` hits the admin-api surface,
80
+ // not the local config file. Anything else is a local-config read.
81
+ if (args[1] === 'routing') {
82
+ return runRoutingGet(ctx);
83
+ }
71
84
  return runConfigGet(args.slice(1), ctx);
72
85
  case 'set':
86
+ // Special form: `pugi config set routing.<tag>.<budget>=<model>` hits
87
+ // the admin-api routing override surface.
88
+ if (args[1] && args[1].startsWith('routing.')) {
89
+ return runRoutingSet(args.slice(1), ctx);
90
+ }
73
91
  return runConfigSet(args.slice(1), ctx);
92
+ case 'unset':
93
+ if (args[1] && args[1].startsWith('routing.')) {
94
+ return runRoutingUnset(args.slice(1), ctx);
95
+ }
96
+ throw new Error(`Unknown sub-command "pugi config unset ${args[1] ?? ''}". Only routing.<tag>.<budget> is supported today.`);
74
97
  case 'list':
75
98
  return runConfigList(ctx);
76
99
  case 'trust':
@@ -78,7 +101,7 @@ export async function runConfigCommand(args, ctx) {
78
101
  case 'mcp':
79
102
  return runConfigMcp(args.slice(1), ctx);
80
103
  default:
81
- throw new Error(`Unknown sub-command "pugi config ${sub}". Expected get, set, list, trust, or mcp.`);
104
+ throw new Error(`Unknown sub-command "pugi config ${sub}". Expected get, set, unset, list, trust, or mcp.`);
82
105
  }
83
106
  }
84
107
  function configPath() {
@@ -228,4 +251,175 @@ async function runConfigMcpFlip(args, ctx, state) {
228
251
  ? `MCP server "${name}" is now trusted.`
229
252
  : `MCP server "${name}" is now denied.`);
230
253
  }
254
+ /* ------------------------------------------------------------------ */
255
+ /* α6.10 multi-model routing — config.routing.* subcommands */
256
+ /* ------------------------------------------------------------------ */
257
+ /**
258
+ * Closed sets — match
259
+ * `apps/admin-api/src/mira/routing/dispatch-tag.ts` verbatim. Pinning
260
+ * them in the CLI lets us reject typos client-side before round-tripping
261
+ * to the admin-api (better UX, smaller blast radius for a wrong typo on
262
+ * a flaky network).
263
+ */
264
+ const ROUTING_TAGS = [
265
+ 'classify',
266
+ 'reason',
267
+ 'codegen',
268
+ 'summarize',
269
+ 'vision',
270
+ 'embed',
271
+ ];
272
+ const ROUTING_BUDGETS = ['min', 'std', 'max'];
273
+ function isRoutingTag(value) {
274
+ return ROUTING_TAGS.includes(value);
275
+ }
276
+ function isRoutingBudget(value) {
277
+ return ROUTING_BUDGETS.includes(value);
278
+ }
279
+ /**
280
+ * Resolve the admin-api host + bearer token from the CLI credential
281
+ * store. Throws a structured "anonymous" error when the operator has
282
+ * not logged in — same shape as `pugi whoami` so the harness exit
283
+ * codes stay aligned.
284
+ */
285
+ function resolveAdminApi() {
286
+ const credential = resolveActiveCredential();
287
+ if (!credential) {
288
+ throw new Error('pugi config routing requires authentication. Run `pugi login` first.');
289
+ }
290
+ return { apiUrl: credential.apiUrl, apiKey: credential.apiKey };
291
+ }
292
+ /**
293
+ * `pugi config get routing` — fetch the static default table + the
294
+ * tenant's override table, merge, and render as `routing.<tag>.<budget> = <model>`.
295
+ * Shows which lanes are overridden vs default.
296
+ */
297
+ async function runRoutingGet(ctx) {
298
+ const { apiUrl, apiKey } = resolveAdminApi();
299
+ const [defaults, overrides] = await Promise.all([
300
+ fetchJson(`${apiUrl}/api/admin/model-routing/defaults`, apiKey),
301
+ fetchJson(`${apiUrl}/api/admin/model-routing/overrides`, apiKey),
302
+ ]);
303
+ const overrideMap = new Map();
304
+ for (const row of overrides.overrides) {
305
+ overrideMap.set(`${row.tag}.${row.budgetHint}`, row.model);
306
+ }
307
+ const cells = defaults.defaults.map((cell) => {
308
+ const overridden = overrideMap.get(`${cell.tag}.${cell.budgetHint}`);
309
+ return {
310
+ tag: cell.tag,
311
+ budgetHint: cell.budgetHint,
312
+ model: overridden ?? cell.model,
313
+ source: overridden ? 'override' : 'default',
314
+ };
315
+ });
316
+ const text = [
317
+ 'Routing table (effective = override | default):',
318
+ ...cells.map((cell) => ` routing.${cell.tag.padEnd(10)}.${cell.budgetHint.padEnd(3)} = ${cell.model.padEnd(28)} (${cell.source})`),
319
+ ].join('\n');
320
+ ctx.writeOutput({
321
+ command: 'config.routing.get',
322
+ apiUrl,
323
+ cells,
324
+ }, text);
325
+ }
326
+ /**
327
+ * `pugi config set routing.<tag>.<budget>=<model>` — PUT to the admin-api
328
+ * override surface. Validates tag + budget client-side before round-tripping
329
+ * so a typo fails fast.
330
+ */
331
+ async function runRoutingSet(args, ctx) {
332
+ // The original arg is `routing.<tag>.<budget>=<model>` — args[0] holds
333
+ // everything up to the first whitespace, but the value may have been
334
+ // split. Re-join and re-split on `=` to be robust against a
335
+ // model slug containing `/` or `-`.
336
+ const raw = args.join(' ').trim();
337
+ const eqIdx = raw.indexOf('=');
338
+ if (eqIdx === -1) {
339
+ throw new Error('pugi config set routing.<tag>.<budget>=<model> requires an =<model> suffix.');
340
+ }
341
+ const lhs = raw.slice(0, eqIdx).trim();
342
+ const value = raw.slice(eqIdx + 1).trim();
343
+ const lhsParts = lhs.split('.');
344
+ if (lhsParts.length !== 3 || lhsParts[0] !== 'routing') {
345
+ throw new Error(`Expected routing.<tag>.<budget>, got "${lhs}".`);
346
+ }
347
+ const tag = lhsParts[1] ?? '';
348
+ const budget = lhsParts[2] ?? '';
349
+ if (!isRoutingTag(tag)) {
350
+ throw new Error(`Unknown routing tag "${tag}". Allowed: ${ROUTING_TAGS.join(', ')}.`);
351
+ }
352
+ if (!isRoutingBudget(budget)) {
353
+ throw new Error(`Unknown routing budget "${budget}". Allowed: ${ROUTING_BUDGETS.join(', ')}.`);
354
+ }
355
+ if (value.length === 0) {
356
+ throw new Error('Model slug must be non-empty.');
357
+ }
358
+ const { apiUrl, apiKey } = resolveAdminApi();
359
+ await fetchJson(`${apiUrl}/api/admin/model-routing/overrides/${tag}/${budget}`, apiKey, { method: 'PUT', body: { model: value } });
360
+ ctx.writeOutput({
361
+ command: 'config.routing.set',
362
+ tag,
363
+ budget,
364
+ model: value,
365
+ }, `routing.${tag}.${budget} = ${value}`);
366
+ }
367
+ /**
368
+ * `pugi config unset routing.<tag>.<budget>` — DELETE the override and
369
+ * revert the lane to its static default. Idempotent.
370
+ */
371
+ async function runRoutingUnset(args, ctx) {
372
+ const lhs = (args[0] ?? '').trim();
373
+ const lhsParts = lhs.split('.');
374
+ if (lhsParts.length !== 3 || lhsParts[0] !== 'routing') {
375
+ throw new Error(`Expected routing.<tag>.<budget>, got "${lhs}".`);
376
+ }
377
+ const tag = lhsParts[1] ?? '';
378
+ const budget = lhsParts[2] ?? '';
379
+ if (!isRoutingTag(tag)) {
380
+ throw new Error(`Unknown routing tag "${tag}". Allowed: ${ROUTING_TAGS.join(', ')}.`);
381
+ }
382
+ if (!isRoutingBudget(budget)) {
383
+ throw new Error(`Unknown routing budget "${budget}". Allowed: ${ROUTING_BUDGETS.join(', ')}.`);
384
+ }
385
+ const { apiUrl, apiKey } = resolveAdminApi();
386
+ const result = await fetchJson(`${apiUrl}/api/admin/model-routing/overrides/${tag}/${budget}`, apiKey, { method: 'DELETE' });
387
+ ctx.writeOutput({
388
+ command: 'config.routing.unset',
389
+ tag,
390
+ budget,
391
+ removed: result.removed,
392
+ }, result.removed
393
+ ? `routing.${tag}.${budget} reverted to default.`
394
+ : `routing.${tag}.${budget} had no override (nothing to remove).`);
395
+ }
396
+ /**
397
+ * Thin authenticated fetch helper. Adds the bearer token + accepts JSON +
398
+ * surfaces structured errors. Uses undici `request` (not native `fetch`)
399
+ * because the test suite intercepts via `MockAgent` + `setGlobalDispatcher`
400
+ * — undici's `request` honours the global dispatcher reliably across the
401
+ * pinned undici version. Kept local (not shared with `pugi whoami`) so
402
+ * the routing surface is self-contained — extracting a common helper is
403
+ * α6.10b cleanup once we see two callers.
404
+ */
405
+ async function fetchJson(url, apiKey, options = {}) {
406
+ const method = options.method ?? 'GET';
407
+ const headers = {
408
+ authorization: `Bearer ${apiKey}`,
409
+ accept: 'application/json',
410
+ };
411
+ let body;
412
+ if (options.body !== undefined) {
413
+ body = JSON.stringify(options.body);
414
+ headers['content-type'] = 'application/json';
415
+ }
416
+ const res = await request(url, { method, headers, body });
417
+ if (res.statusCode < 200 || res.statusCode >= 300) {
418
+ const detail = await res.body.text().catch(() => '');
419
+ throw new Error(`pugi config routing: HTTP ${res.statusCode} on ${method} ${url}${detail ? ` -- ${detail.slice(0, 200)}` : ''}`);
420
+ }
421
+ // undici's `request` already returns `body` as a stream wrapped with
422
+ // helpers — `.json()` parses + closes for us.
423
+ return (await res.body.json());
424
+ }
231
425
  //# sourceMappingURL=config.js.map
@@ -0,0 +1,399 @@
1
+ /**
2
+ * `pugi review --consensus` — customer-facing triple-review (α6.7).
3
+ *
4
+ * The differentiator: Claude Code ships single-Claude review, Codex CLI
5
+ * ships single-GPT review, Gemini CLI ships single-Gemini review. Pugi
6
+ * ships a 3-model consensus gate as a first-class command so customers
7
+ * get the same production-readiness signal we use internally - without the
8
+ * three CLI installs, three OAuth flows, and three subscriptions.
9
+ *
10
+ * Flow:
11
+ *
12
+ * 1. Resolve diff source from flags (`--commit` / `--pr` / `--branch`
13
+ * OR default to merge-base vs `origin/main`).
14
+ * 2. POST diff to Anvil's `POST /api/pugi/review-consensus`. Anvil
15
+ * fans out to 3 reviewer routes server-side and streams an SSE.
16
+ * 3. Render per-reviewer state inline as the SSE stream emits events.
17
+ * 4. After the stream closes, recompute the rubric locally (never
18
+ * trust the server's verdict - see anvil-fanout.ts) and print:
19
+ * - per-reviewer summary
20
+ * - rubric verdict + reasoning
21
+ * - recommended next action
22
+ * 5. Exit with 0 PASS / 1 WARN / 2 BLOCK.
23
+ *
24
+ * Backend status: at α6.7 ship the admin-api endpoint is not yet
25
+ * deployed. The handler degrades gracefully — on `endpoint_missing`
26
+ * the CLI prints an actionable "backend not deployed yet" notice and
27
+ * exits 0 (the gate didn't run, but failing CI would be wrong because
28
+ * the operator did nothing wrong). The α6.7.1 sprint lands the server.
29
+ */
30
+ import { captureDiff } from '../../core/consensus/diff-capture.js';
31
+ import { dispatchConsensus, } from '../../core/consensus/anvil-fanout.js';
32
+ import { aggregate, exitCodeFor, reviewerVerdictFromRaw, } from '../../core/consensus/rubric.js';
33
+ /**
34
+ * Parse the command-line tail for the consensus selector + base ref. The
35
+ * arg list excludes the dispatcher's leading `review` keyword.
36
+ *
37
+ * Accepted forms:
38
+ * `--commit <sha>` / `--commit=<sha>`
39
+ * `--pr <number>` / `--pr=<number>`
40
+ * `--branch <name>` / `--branch=<name>`
41
+ * `--base <ref>` / `--base=<ref>` (override default origin/main)
42
+ */
43
+ export function parseConsensusArgs(args) {
44
+ const spec = {};
45
+ for (let i = 0; i < args.length; i += 1) {
46
+ const arg = args[i] ?? '';
47
+ const equalsIdx = arg.indexOf('=');
48
+ const key = equalsIdx === -1 ? arg : arg.slice(0, equalsIdx);
49
+ const inline = equalsIdx === -1 ? null : arg.slice(equalsIdx + 1);
50
+ const value = inline ?? args[i + 1] ?? '';
51
+ const consumed = inline !== null ? 0 : 1;
52
+ if (key === '--commit') {
53
+ if (!value)
54
+ throw new Error('--commit requires a SHA');
55
+ spec.commit = value;
56
+ i += consumed;
57
+ }
58
+ else if (key === '--pr') {
59
+ if (!value)
60
+ throw new Error('--pr requires a number');
61
+ const parsed = Number.parseInt(value, 10);
62
+ if (!Number.isFinite(parsed) || parsed <= 0) {
63
+ throw new Error(`--pr expects a positive integer, got "${value}"`);
64
+ }
65
+ spec.pr = parsed;
66
+ i += consumed;
67
+ }
68
+ else if (key === '--branch') {
69
+ if (!value)
70
+ throw new Error('--branch requires a name');
71
+ spec.branch = value;
72
+ i += consumed;
73
+ }
74
+ else if (key === '--base') {
75
+ if (!value)
76
+ throw new Error('--base requires a ref');
77
+ spec.baseRef = value;
78
+ i += consumed;
79
+ }
80
+ // Unknown args are dropped — `--consensus` itself, `--remote`, `--json`
81
+ // and other passthrough flags are interpreted by the cli.ts parser
82
+ // before this function ever sees them.
83
+ }
84
+ return spec;
85
+ }
86
+ /**
87
+ * Run the consensus review. Returns the intended process exit code so
88
+ * the caller owns the global `process.exitCode` write. This avoids the
89
+ * REPL leak where a slash-invocation would otherwise inherit a stale
90
+ * exit code from a previous consensus run.
91
+ *
92
+ * Exit code contract (matches `handleFanoutFailure` + `exitCodeFor`):
93
+ *
94
+ * 0 = endpoint_missing (graceful degrade, consensus disabled on tier)
95
+ * 0 = PASS (rubric clean) OR empty diff (nothing to review)
96
+ * 1 = WARN (rubric: one reviewer P1, informational)
97
+ * 2 = BLOCK (rubric: P0 or consensus P1) / failed / capture_failed
98
+ * 5 = auth_missing (no credentials) / unauthenticated (token rejected)
99
+ * 7 = rate_limited (quota exhausted, retry after backoff)
100
+ *
101
+ * Aligned with the legacy `describeSubmitFailure` in cli.ts so shell
102
+ * scripts can branch on identical codes across both review surfaces.
103
+ */
104
+ export async function runReviewConsensus(args, ctx) {
105
+ if (!ctx.config) {
106
+ const text = [
107
+ 'pugi review --consensus needs Pugi credentials.',
108
+ 'Run `pugi login --token <PAT>` or export PUGI_API_KEY for CI.',
109
+ ].join('\n');
110
+ ctx.writeOutput({
111
+ command: 'review-consensus',
112
+ status: 'auth_missing',
113
+ message: text,
114
+ }, text);
115
+ return 5;
116
+ }
117
+ // Capture the diff. Failures here are operator-correctable (bad ref,
118
+ // gh not installed for --pr, etc) so we surface a clean error and
119
+ // exit 2 — same as BLOCK because the gate could not even run.
120
+ let captured;
121
+ try {
122
+ const spec = parseConsensusArgs(args);
123
+ captured = captureDiff({ ...spec, cwd: ctx.cwd });
124
+ }
125
+ catch (error) {
126
+ const message = error instanceof Error ? error.message : String(error);
127
+ const text = `Failed to capture diff: ${message}`;
128
+ ctx.writeOutput({ command: 'review-consensus', status: 'capture_failed', message }, text);
129
+ return 2;
130
+ }
131
+ if (captured.diff.trim().length === 0) {
132
+ const text = [
133
+ `No diff captured for ${captured.context.ref}.`,
134
+ 'The consensus gate has nothing to review, nothing to do.',
135
+ ].join('\n');
136
+ ctx.writeOutput({
137
+ command: 'review-consensus',
138
+ status: 'completed',
139
+ verdict: 'PASS',
140
+ reasoning: 'Empty diff: trivial PASS.',
141
+ reviewers: [],
142
+ ref: captured.context.ref,
143
+ stats: captured.context.stats,
144
+ message: text,
145
+ }, text);
146
+ return 0;
147
+ }
148
+ // Banner — operator sees this immediately so a slow Anvil call does
149
+ // not look like the CLI hanging.
150
+ ctx.emit(`Capturing diff (${captured.context.ref})… ${captured.context.stats.filesChanged} files, ` +
151
+ `+${captured.context.stats.insertions} -${captured.context.stats.deletions}\n`);
152
+ ctx.emit('Dispatching to 3 reviewers: codex · claude · deepseek\n\n');
153
+ const reviewerEvents = [];
154
+ const sink = (event) => {
155
+ if (event.type === 'consensus') {
156
+ // Server-side verdict is informational — we recompute below. We
157
+ // still surface it on the stream so the operator sees activity.
158
+ return;
159
+ }
160
+ reviewerEvents.push(event);
161
+ ctx.emit(formatReviewerEventLine(event));
162
+ };
163
+ const dispatch = ctx.dispatch ?? dispatchConsensus;
164
+ const fanoutResult = await dispatch(ctx.config, {
165
+ diff: captured.diff,
166
+ context: {
167
+ branch: captured.context.branch,
168
+ commit: captured.context.commit,
169
+ title: captured.context.title,
170
+ },
171
+ }, sink);
172
+ if (fanoutResult.status !== 'ok') {
173
+ return handleFanoutFailure(fanoutResult, ctx);
174
+ }
175
+ // Collapse the SSE event stream into one `ReviewerVerdict` per
176
+ // reviewer. The final `verdict` event for a reviewer wins; earlier
177
+ // `started` events are scaffolding for the live UI only.
178
+ const verdicts = collapseVerdicts(reviewerEvents);
179
+ const result = aggregate(verdicts);
180
+ ctx.emit('\n────────────────────────────────────────\n');
181
+ for (const verdict of verdicts) {
182
+ ctx.emit(formatReviewerSummaryLine(verdict));
183
+ }
184
+ ctx.emit('\n────────────────────────────────────────\n');
185
+ ctx.emit(`Rubric: ${result.verdict}\n`);
186
+ ctx.emit(` ${result.reasoning}\n`);
187
+ ctx.emit('\n');
188
+ ctx.emit(`Recommended action: ${recommendedAction(result)}\n`);
189
+ ctx.writeOutput({
190
+ command: 'review-consensus',
191
+ status: 'completed',
192
+ verdict: result.verdict,
193
+ reasoning: result.reasoning,
194
+ reviewers: verdicts.map((v) => ({
195
+ reviewer: v.reviewer,
196
+ topSeverity: v.topSeverity,
197
+ findingCount: v.findings.length,
198
+ errored: v.errored,
199
+ })),
200
+ ref: captured.context.ref,
201
+ stats: captured.context.stats,
202
+ }, [
203
+ `Pugi consensus ${result.verdict}`,
204
+ result.reasoning,
205
+ `Reviewers: ${verdicts.map((v) => `${v.reviewer}=${v.topSeverity ?? 'CLEAN'}`).join(' · ')}`,
206
+ ].join('\n'));
207
+ return exitCodeFor(result.verdict);
208
+ }
209
+ /**
210
+ * Translate a fanout failure variant to the matching exit code + output
211
+ * envelope. Returns the exit code so the caller owns `process.exitCode`
212
+ * (avoiding the REPL-inherited-exit-code leak).
213
+ */
214
+ function handleFanoutFailure(result, ctx) {
215
+ if (result.status === 'endpoint_missing') {
216
+ const message = [
217
+ 'Backend not deployed yet: the consensus endpoint lands in alpha 6.7.1.',
218
+ 'No exit-1/2 gate: this is a CLI-side surface waiting for the server.',
219
+ 'Run `pugi review --triple --remote` for the legacy artifact-based flow.',
220
+ ].join('\n');
221
+ ctx.emit('\n');
222
+ ctx.emit(`${message}\n`);
223
+ ctx.writeOutput({ command: 'review-consensus', status: 'endpoint_missing', message }, message);
224
+ // Graceful: operator did nothing wrong, server pending. Exit 0 so
225
+ // CI does not redden on the deploy-lag window.
226
+ return 0;
227
+ }
228
+ if (result.status === 'unauthenticated') {
229
+ const message = `${result.message}. Run \`pugi login --token <PAT>\` and retry.`;
230
+ ctx.emit('\n');
231
+ ctx.emit(`${message}\n`);
232
+ ctx.writeOutput({ command: 'review-consensus', status: 'unauthenticated', message }, message);
233
+ return 5;
234
+ }
235
+ if (result.status === 'rate_limited') {
236
+ const seconds = Math.round(result.retryAfterMs / 1000);
237
+ const message = `Rate limit: retry after ${seconds}s.`;
238
+ ctx.emit('\n');
239
+ ctx.emit(`${message}\n`);
240
+ ctx.writeOutput({ command: 'review-consensus', status: 'rate_limited', message }, message);
241
+ // Exit code contract (kept in sync with `runReviewConsensus`):
242
+ // 0 = endpoint_missing (graceful degrade, consensus disabled on tier)
243
+ // 0 = PASS / empty diff
244
+ // 1 = WARN (informational, single asymmetric P1)
245
+ // 2 = BLOCK / failed / capture_failed (real findings or unrecoverable)
246
+ // 5 = auth_missing / unauthenticated (token rejected by Anvil)
247
+ // 7 = rate_limited (quota exhausted, retry with backoff)
248
+ //
249
+ // Aligned with the legacy `describeSubmitFailure` in cli.ts so a
250
+ // shell script branching on exit code behaves identically across
251
+ // the legacy triple-review and consensus surfaces.
252
+ return 7;
253
+ }
254
+ const message = result.message;
255
+ ctx.emit('\n');
256
+ ctx.emit(`Consensus call failed: ${message}\n`);
257
+ ctx.writeOutput({ command: 'review-consensus', status: 'failed', message }, `Consensus call failed: ${message}`);
258
+ return 2;
259
+ }
260
+ /**
261
+ * Map per-reviewer SSE events to the rubric input shape. One reviewer
262
+ * may emit `started` then `verdict`; the `verdict` event carries the
263
+ * raw text we feed parseFindings.
264
+ *
265
+ * Precedence (verdict wins over error):
266
+ *
267
+ * started -> verdict => verdict (rubric processes findings)
268
+ * started -> error => error (errored=true, no signal)
269
+ * started -> verdict -> error => verdict (terminal verdict wins; the
270
+ * trailing error is a stale retry/transport artifact and must NOT
271
+ * silently downgrade a real P0 BLOCK to "all errored")
272
+ * started -> error -> verdict => verdict (verdict still wins)
273
+ * started (no terminal) => errored placeholder so the reviewer
274
+ * appears in the output instead of being
275
+ * silently dropped
276
+ *
277
+ * The verdict-wins-over-error rule is the fix for a real BLOCK
278
+ * downgrade: Anvil's SSE emitter can send a verdict frame followed by
279
+ * an error frame when a retry layer fires after the terminal verdict
280
+ * already shipped. Without precedence the error would clobber the
281
+ * real verdict and produce a false "errored=true" -> no findings ->
282
+ * possible PASS instead of BLOCK.
283
+ */
284
+ function collapseVerdicts(events) {
285
+ const byReviewer = new Map();
286
+ for (const event of events) {
287
+ const prior = byReviewer.get(event.reviewer);
288
+ if (event.type === 'verdict') {
289
+ // Verdict is always the terminal outcome - overwrite anything we
290
+ // had before (started placeholder, or a stale error frame).
291
+ byReviewer.set(event.reviewer, event);
292
+ }
293
+ else if (event.type === 'error') {
294
+ // Error only wins if no verdict has arrived yet for this reviewer.
295
+ // Once we hold a verdict, a trailing error is transport noise and
296
+ // must not downgrade the verdict to "errored".
297
+ if (!prior || prior.type !== 'verdict') {
298
+ byReviewer.set(event.reviewer, event);
299
+ }
300
+ }
301
+ else if (!prior) {
302
+ // started: hold as placeholder so the reviewer appears in the
303
+ // output even if the stream cuts off before a terminal frame.
304
+ byReviewer.set(event.reviewer, event);
305
+ }
306
+ }
307
+ const out = [];
308
+ for (const [reviewer, event] of byReviewer) {
309
+ if (event.type === 'verdict' && typeof event.rawContent === 'string') {
310
+ out.push(reviewerVerdictFromRaw(reviewer, event.rawContent, false));
311
+ }
312
+ else if (event.type === 'error' || event.error) {
313
+ out.push(reviewerVerdictFromRaw(reviewer, '', true));
314
+ }
315
+ else {
316
+ // Stream ended mid-flight for this reviewer - treat as errored
317
+ // so the rubric's "all errored -> BLOCK" branch fires instead of
318
+ // a misleading PASS.
319
+ out.push(reviewerVerdictFromRaw(reviewer, '', true));
320
+ }
321
+ }
322
+ // Deterministic order: codex, claude, deepseek first, then anyone else
323
+ // alphabetical. Matches the UX preview in the spec and stabilizes JSON
324
+ // output for snapshot diffs.
325
+ const priority = { codex: 0, claude: 1, deepseek: 2 };
326
+ out.sort((a, b) => {
327
+ const pa = priority[a.reviewer] ?? 99;
328
+ const pb = priority[b.reviewer] ?? 99;
329
+ if (pa !== pb)
330
+ return pa - pb;
331
+ return a.reviewer.localeCompare(b.reviewer);
332
+ });
333
+ return out;
334
+ }
335
+ function formatReviewerEventLine(event) {
336
+ const name = event.reviewer.padEnd(9);
337
+ if (event.type === 'started') {
338
+ return ` ${name} reviewing…\n`;
339
+ }
340
+ if (event.type === 'error') {
341
+ const why = event.error ?? 'unknown';
342
+ const ms = event.latencyMs ? ` ${event.latencyMs}ms` : '';
343
+ return ` ${name} ERROR: ${why}${ms}\n`;
344
+ }
345
+ const severity = event.severity ?? 'CLEAN';
346
+ const ms = event.latencyMs ? ` ${event.latencyMs}ms` : '';
347
+ return ` ${name} ${severity}${ms}\n`;
348
+ }
349
+ function formatReviewerSummaryLine(verdict) {
350
+ const name = verdict.reviewer.padEnd(9);
351
+ if (verdict.errored) {
352
+ return ` ${name} ERROR (no signal)\n`;
353
+ }
354
+ if (verdict.findings.length === 0) {
355
+ return ` ${name} CLEAN\n`;
356
+ }
357
+ // Group counts: shows operator the severity breakdown in one line.
358
+ const counts = countSeverities(verdict);
359
+ const summary = formatCounts(counts);
360
+ const top = verdict.findings.slice(0, 3);
361
+ const tail = verdict.findings.length > 3 ? `\n … ${verdict.findings.length - 3} more` : '';
362
+ const findings = top.map((f) => `\n - [${f.severity}] ${f.summary}`).join('');
363
+ return ` ${name} ${summary}${findings}${tail}\n`;
364
+ }
365
+ function countSeverities(verdict) {
366
+ const counts = { P0: 0, P1: 0, P2: 0, P3: 0 };
367
+ for (const f of verdict.findings)
368
+ counts[f.severity] += 1;
369
+ return counts;
370
+ }
371
+ function formatCounts(counts) {
372
+ const parts = [];
373
+ if (counts.P0 > 0)
374
+ parts.push(`${counts.P0}× P0`);
375
+ if (counts.P1 > 0)
376
+ parts.push(`${counts.P1}× P1`);
377
+ if (counts.P2 > 0)
378
+ parts.push(`${counts.P2}× P2`);
379
+ if (counts.P3 > 0)
380
+ parts.push(`${counts.P3}× P3`);
381
+ if (parts.length === 0)
382
+ return 'CLEAN';
383
+ return `[${parts.join(', ')}]`;
384
+ }
385
+ /**
386
+ * Recommended action surfaced as the last line of the human-readable
387
+ * UX. Maps to the rubric verdict + finding shape so the operator does
388
+ * not need to interpret `[P1]` themselves.
389
+ */
390
+ function recommendedAction(result) {
391
+ if (result.verdict === 'PASS') {
392
+ return 'Ship it: no blocking findings.';
393
+ }
394
+ if (result.verdict === 'WARN') {
395
+ return 'Examine the lone P1, decide accept-as-FP or fix, then re-run.';
396
+ }
397
+ return 'Fix the blocking findings, then re-run `pugi review --consensus`.';
398
+ }
399
+ //# sourceMappingURL=review-consensus.js.map
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { AgentTree } from './agent-tree.js';
4
+ export function AgentTreePane(props) {
5
+ const onWatch = props.agents.filter((a) => a.status === 'queued' || a.status === 'thinking').length;
6
+ const total = props.agents.length;
7
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, dimColor: true, children: '─ agents ' }), _jsx(Text, { dimColor: true, children: `(${total} total, ${onWatch} on watch)` })] }), _jsx(AgentTree, { agents: props.agents, nowEpochMs: props.nowEpochMs })] }));
8
+ }
9
+ //# sourceMappingURL=agent-tree-pane.js.map