@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.2

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 (68) hide show
  1. package/README.md +33 -0
  2. package/assets/pugi-mascot.ansi +41 -0
  3. package/dist/commands/deploy.js +439 -0
  4. package/dist/core/agents/loader.js +104 -0
  5. package/dist/core/agents/registry.js +1 -1
  6. package/dist/core/consensus/anvil-fanout.js +276 -0
  7. package/dist/core/consensus/diff-capture.js +382 -0
  8. package/dist/core/consensus/rubric.js +233 -0
  9. package/dist/core/context/index.js +21 -0
  10. package/dist/core/context/pugiignore.js +316 -0
  11. package/dist/core/context/repo-skeleton.js +533 -0
  12. package/dist/core/context/watcher.js +342 -0
  13. package/dist/core/context/working-set.js +165 -0
  14. package/dist/core/edits/dispatch.js +185 -0
  15. package/dist/core/edits/index.js +15 -0
  16. package/dist/core/edits/layer-a-apply.js +217 -0
  17. package/dist/core/edits/layer-b-apply.js +211 -0
  18. package/dist/core/edits/layer-c-apply.js +160 -0
  19. package/dist/core/edits/layer-d-ast.js +29 -0
  20. package/dist/core/edits/marker-parser.js +401 -0
  21. package/dist/core/edits/security-gate.js +223 -0
  22. package/dist/core/edits/worktree.js +229 -0
  23. package/dist/core/engine/native-pugi.js +6 -1
  24. package/dist/core/engine/prompts.js +4 -1
  25. package/dist/core/engine/tool-bridge.js +33 -1
  26. package/dist/core/lsp/client.js +631 -0
  27. package/dist/core/repl/ask.js +512 -0
  28. package/dist/core/repl/cancellation.js +98 -0
  29. package/dist/core/repl/dispatch-fsm.js +220 -0
  30. package/dist/core/repl/privacy-banner.js +71 -0
  31. package/dist/core/repl/session.js +1896 -13
  32. package/dist/core/repl/slash-commands.js +59 -32
  33. package/dist/core/repl/store/index.js +12 -0
  34. package/dist/core/repl/store/jsonl-log.js +321 -0
  35. package/dist/core/repl/store/lockfile.js +155 -0
  36. package/dist/core/repl/store/session-store.js +792 -0
  37. package/dist/core/repl/store/types.js +44 -0
  38. package/dist/core/repl/store/uuid-v7.js +68 -0
  39. package/dist/core/repl/workspace-context.js +72 -1
  40. package/dist/core/skills/loader.js +454 -0
  41. package/dist/core/skills/sources.js +480 -0
  42. package/dist/core/skills/trust.js +172 -0
  43. package/dist/runtime/cli.js +767 -10
  44. package/dist/runtime/commands/agents.js +385 -0
  45. package/dist/runtime/commands/config.js +338 -8
  46. package/dist/runtime/commands/lsp.js +184 -0
  47. package/dist/runtime/commands/patch.js +111 -0
  48. package/dist/runtime/commands/review-consensus.js +399 -0
  49. package/dist/runtime/commands/skills.js +401 -0
  50. package/dist/runtime/commands/worktree.js +133 -0
  51. package/dist/tools/apply-patch.js +314 -0
  52. package/dist/tools/file-tools.js +90 -0
  53. package/dist/tools/lsp-tools.js +189 -0
  54. package/dist/tools/registry.js +18 -0
  55. package/dist/tools/web-fetch.js +1 -1
  56. package/dist/tui/agent-tree-pane.js +9 -0
  57. package/dist/tui/ask-cli.js +52 -0
  58. package/dist/tui/ask-modal.js +211 -0
  59. package/dist/tui/conversation-pane.js +48 -3
  60. package/dist/tui/input-box.js +48 -5
  61. package/dist/tui/markdown-render.js +266 -0
  62. package/dist/tui/repl-render.js +185 -0
  63. package/dist/tui/repl-splash-mascot.js +130 -0
  64. package/dist/tui/repl-splash.js +7 -1
  65. package/dist/tui/repl.js +82 -11
  66. package/dist/tui/status-bar.js +63 -3
  67. package/dist/tui/tool-stream-pane.js +91 -0
  68. package/package.json +11 -5
@@ -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