@pugi/cli 0.1.0-beta.13 → 0.1.0-beta.15

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/bin/run.js CHANGED
@@ -1,2 +1,34 @@
1
1
  #!/usr/bin/env node
2
- import '../dist/index.js';
2
+ // 2026-05-27 — silence the noisy `node:sqlite` ExperimentalWarning.
3
+ // Node 22.x prints a multi-line warning the first time any consumer
4
+ // imports `node:sqlite` (see core/repl/store/session-store.ts). CEO has
5
+ // pinged this surface twice now ("(node:XXXXX) ExperimentalWarning:
6
+ // SQLite is an experimental feature and might change at any time" +
7
+ // trace-warnings hint). Hiding it cleanly requires a tap on
8
+ // process.emitWarning BEFORE any module loads the sqlite binding —
9
+ // hence wiring it here in the binary entry, not deep inside the CLI.
10
+ //
11
+ // We allowlist only the exact sqlite warning + leave every other
12
+ // runtime warning (deprecation, security, custom code paths) intact.
13
+ // Operator can still re-enable the noise by exporting
14
+ // PUGI_SHOW_EXPERIMENTAL_WARNINGS=1 — useful when debugging a node
15
+ // version bump that might surface a new experimental flag we should
16
+ // notice.
17
+ if (!process.env.PUGI_SHOW_EXPERIMENTAL_WARNINGS) {
18
+ const originalEmit = process.emit;
19
+ process.emit = function patchedEmit(name, ...args) {
20
+ if (name === 'warning') {
21
+ const w = args[0];
22
+ const isSqliteExperimental =
23
+ w &&
24
+ typeof w === 'object' &&
25
+ w.name === 'ExperimentalWarning' &&
26
+ typeof w.message === 'string' &&
27
+ /SQLite is an experimental feature/i.test(w.message);
28
+ if (isSqliteExperimental) return false;
29
+ }
30
+ return originalEmit.call(this, name, ...args);
31
+ };
32
+ }
33
+
34
+ import('../dist/index.js');
@@ -196,6 +196,25 @@ export class AnvilEngineLoopClient {
196
196
  // for the broader "misleading error" pattern.)
197
197
  try {
198
198
  const parsed = text ? JSON.parse(text) : null;
199
+ // 2026-05-27 dogfood cycle 2: distinct error code for the
200
+ // infra-side "PII scrubber down" case. Previously the engine
201
+ // server returned `privacy_strict_upstream_blocked` here even
202
+ // when the tenant was on BALANCED (the scrubber crash forced
203
+ // a fail-closed). Operators chased the wrong fix ("switch
204
+ // privacy") for hours. Server now emits
205
+ // `pii_scrubber_unavailable` — surface a distinct remediation
206
+ // that points at the infra side, not the operator's privacy
207
+ // posture.
208
+ if (parsed?.code === 'pii_scrubber_unavailable') {
209
+ return {
210
+ stop: 'error',
211
+ code: 'privacy_blocked',
212
+ message: parsed.message ?? 'PII scrubber unavailable; privacy filter refused dispatch.',
213
+ remediation: 'Infra-side issue (not your tenant privacy mode). Wait for ops to restore ' +
214
+ 'the PiiScrubberService, OR temporarily switch your tenant to permissive via ' +
215
+ '`pugi config set privacy=permissive`.',
216
+ };
217
+ }
199
218
  if (parsed?.code === 'privacy_strict_upstream_blocked' || parsed?.code === 'privacy_blocked') {
200
219
  return {
201
220
  stop: 'error',
@@ -763,6 +763,28 @@ function parseArgs(argv) {
763
763
  }
764
764
  flags.maxTurns = parsed;
765
765
  }
766
+ else if (arg.startsWith('--commit=')) {
767
+ // `pugi review --triple --commit <SHA>` activates the multi-
768
+ // provider routing path against a specific revision.
769
+ flags.commit = arg.slice('--commit='.length);
770
+ }
771
+ else if (arg === '--commit') {
772
+ const next = argv[index + 1];
773
+ if (!next)
774
+ throw new Error('--commit requires a SHA or ref');
775
+ flags.commit = next;
776
+ index += 1;
777
+ }
778
+ else if (arg.startsWith('--base=')) {
779
+ flags.base = arg.slice('--base='.length);
780
+ }
781
+ else if (arg === '--base') {
782
+ const next = argv[index + 1];
783
+ if (!next)
784
+ throw new Error('--base requires a ref');
785
+ flags.base = next;
786
+ index += 1;
787
+ }
766
788
  else {
767
789
  args.push(arg);
768
790
  }
@@ -810,7 +832,176 @@ async function version(_args, flags, _session) {
810
832
  };
811
833
  writeOutput(flags, payload, `pugi ${payload.version}`);
812
834
  }
813
- async function help(_args, flags, _session) {
835
+ /**
836
+ * Per-command help bodies (task #100). When the operator types
837
+ * `pugi <cmd> --help` the dispatcher routes here with `args = [cmd]`.
838
+ * If we have a focused body for that command, print it instead of the
839
+ * global summary. Falls back to the global summary so unknown / new
840
+ * commands still get a useful response.
841
+ *
842
+ * Source of truth for each entry: the comment block at the top of the
843
+ * command's implementation module + any flags the command declares.
844
+ * Keep entries short — operators want the one-liner of intent + the
845
+ * 2-5 most useful flags, not a tutorial. The global help still has the
846
+ * full per-section reference; the per-command body is the "tell me
847
+ * how to use this NOW" surface.
848
+ */
849
+ const COMMAND_HELP_BODIES = {
850
+ init: [
851
+ 'pugi init — bootstrap a new Pugi workspace in the current directory.',
852
+ '',
853
+ 'Creates .pugi/{PUGI.md, mcp.json, index.json, artifacts/, sessions/} and',
854
+ 'seeds the 6 default skills. Idempotent — running again only fills gaps.',
855
+ '',
856
+ 'Flags:',
857
+ ' --no-defaults Skip the bundled default-skills install.',
858
+ '',
859
+ 'Env:',
860
+ ' PUGI_INIT_NO_DEFAULTS=1 Same as --no-defaults.',
861
+ ],
862
+ explain: [
863
+ 'pugi explain "<question>" — read-only Q&A about the workspace.',
864
+ '',
865
+ 'Calls the engine loop in explain mode (budget: 5 calls / 20k tokens).',
866
+ 'No file writes; safe to run against unfamiliar code.',
867
+ '',
868
+ 'Examples:',
869
+ ' pugi explain "what does this package.json define?"',
870
+ ' pugi explain "trace the auth flow in src/auth/"',
871
+ ],
872
+ code: [
873
+ 'pugi code "<brief>" — engineering-mode write loop (30k token budget).',
874
+ '',
875
+ 'Writes files in the current workspace. Use --no-tty in CI / pipes.',
876
+ ],
877
+ fix: [
878
+ 'pugi fix "<brief>" — minimal-diff bugfix loop (30k token budget).',
879
+ '',
880
+ 'Same as `pugi code` but the prompt biases toward the smallest patch',
881
+ 'that closes the brief — refuses scope creep / refactor invitations.',
882
+ ],
883
+ build: [
884
+ 'pugi build "<brief>" — feature-build loop (200k token budget).',
885
+ '',
886
+ 'Multi-turn engineering with plan-review checkpoints. Pairs with',
887
+ 'pugi plan --decompose <idea> when the brief is bigger than one PR.',
888
+ ],
889
+ plan: [
890
+ 'pugi plan --decompose <idea> — split an idea into 3-7 components.',
891
+ '',
892
+ 'Writes .pugi/plan/<session-id>/splits/NN-<name>/spec.md plus',
893
+ 'manifest.md with the dependency DAG. Pass each split to `pugi build`.',
894
+ ],
895
+ review: [
896
+ 'pugi review — code review surfaces.',
897
+ '',
898
+ ' --triple 3-model consensus via Anvil paid fleet.',
899
+ ' --triple --commit <SHA> Review a specific commit (vs origin/main).',
900
+ ' --consensus Customer-facing consensus review (codex + claude + deepseek).',
901
+ ' Optional: --commit <sha> | --pr <num> | --branch <name>.',
902
+ '',
903
+ 'Exit codes: 0 PASS · 1 WARN · 2 BLOCK · 5 auth_missing · 7 rate_limited.',
904
+ ],
905
+ privacy: [
906
+ 'pugi privacy — privacy-mode operations.',
907
+ '',
908
+ ' show Display effective mode + source.',
909
+ ' set <mode> Local-only legacy values (local-only|metadata|full).',
910
+ '',
911
+ 'For tenant-scoped server-side modes (strict|balanced|permissive), use:',
912
+ ' pugi config get privacy',
913
+ ' pugi config set privacy=<mode>',
914
+ ],
915
+ config: [
916
+ 'pugi config — read / write CLI + tenant configuration.',
917
+ '',
918
+ ' get <key> Local config value.',
919
+ ' get privacy Tenant privacy snapshot (admin-api).',
920
+ ' get routing Effective routing table.',
921
+ ' set <key>=<value> Local config write.',
922
+ ' set privacy=<mode> Flip tenant privacy (strict|balanced|permissive).',
923
+ ' set routing.<tag>.<budget>=<model> Override one routing lane.',
924
+ ' unset routing.<tag>.<budget> Revert a routing override.',
925
+ ' mcp trust|deny|list <name> MCP server trust + visibility.',
926
+ ],
927
+ sync: [
928
+ 'pugi sync — explicit-continuation handoff bundle upload.',
929
+ '',
930
+ ' --dry-run Print the bundle plan without uploading.',
931
+ ' --privacy <mode> Override per-bundle privacy posture.',
932
+ ],
933
+ whoami: [
934
+ 'pugi whoami — show the active credential + JWT principal + plan tier.',
935
+ '',
936
+ 'Reads from ~/.pugi/credentials.json. No network call unless --remote.',
937
+ ],
938
+ login: [
939
+ 'pugi login — authenticate against an api.pugi.io endpoint.',
940
+ '',
941
+ 'Interactive picker by default (browser OAuth / PAT / env). Non-interactive:',
942
+ ' --provider device Device-flow OAuth.',
943
+ ' --provider token --token <jwt> Pass a JWT directly.',
944
+ ' --provider env --env PUGI_API_KEY Read from an env var.',
945
+ ],
946
+ accounts: [
947
+ 'pugi accounts — manage stored credentials across endpoints.',
948
+ '',
949
+ ' list Every account + its endpoint + active flag.',
950
+ ' switch <label> Re-point the active account.',
951
+ ' remove <label> Delete a stored credential.',
952
+ ],
953
+ jobs: [
954
+ 'pugi jobs — list, tail, or kill background dispatch jobs.',
955
+ '',
956
+ ' list All jobs in the registry.',
957
+ ' tail <id> Stream output from one job.',
958
+ ' kill <id> Cancel a running job.',
959
+ ],
960
+ delegate: [
961
+ 'pugi delegate <slug> "<brief>" — dispatch a brief to one specialist persona.',
962
+ '',
963
+ 'Slugs (Tier 1 alpha 7.5): dev qa pm devops researcher analyst designer',
964
+ 'frontend architect. `pugi roster` lists the live set.',
965
+ ],
966
+ roster: [
967
+ 'pugi roster — list the live Tier 1 personas + roles.',
968
+ ],
969
+ doctor: [
970
+ 'pugi doctor — diagnose CLI + workspace + adapter capabilities.',
971
+ '',
972
+ 'Prints CLI version, Node version, workspace state (.pugi presence,',
973
+ 'event log, settings), permission mode, and the capability matrix per',
974
+ 'engine adapter. Safe to run anywhere; no network calls.',
975
+ ],
976
+ ask: [
977
+ 'pugi ask "<question>" — surface a yes/no question modal locally.',
978
+ '',
979
+ 'Useful in shell scripts that need a human-confirm before a destructive',
980
+ 'step. Exits 0 on yes, 1 on no, 2 on cancel.',
981
+ ],
982
+ deploy: [
983
+ 'pugi deploy — trigger a vendor deployment from the bound Git source.',
984
+ '',
985
+ ' --target vercel <vercelProject> --project <id> Vercel deploy.',
986
+ ' --target render <renderService> --project <id> Render deploy (Sprint 2 stub).',
987
+ ' --status <id> Vendor-agnostic status snapshot.',
988
+ ' --logs <id> [--tail] Build-log tail.',
989
+ '',
990
+ 'Optional: --target-env production|preview, --ref <ref>, --integration <id>.',
991
+ ],
992
+ };
993
+ async function help(args, flags, _session) {
994
+ // 2026-05-27 task #100: per-command help bodies. When dispatcher
995
+ // routed `pugi <cmd> --help` here it passes `args = [cmd]`; if we
996
+ // have a focused body, print that. Falls through to the global
997
+ // summary on unknown / new commands so the dispatcher's redirect
998
+ // never produces a worse-than-baseline response.
999
+ const requested = args[0];
1000
+ if (requested && COMMAND_HELP_BODIES[requested]) {
1001
+ const body = COMMAND_HELP_BODIES[requested];
1002
+ writeOutput(flags, { command: requested, lines: body }, body.join('\n'));
1003
+ return;
1004
+ }
814
1005
  const commands = Object.keys(handlers).sort();
815
1006
  writeOutput(flags, { commands }, [
816
1007
  'Pugi CLI',
@@ -830,6 +1021,9 @@ async function help(_args, flags, _session) {
830
1021
  '',
831
1022
  'Review gate:',
832
1023
  ' pugi review --triple Prepare the Anvil-backed triple-review gate.',
1024
+ ' pugi review --triple --commit <SHA>',
1025
+ ' 3-model consensus via Anvil (Anthropic · OpenAI · Google).',
1026
+ ' Optional: --base <ref> | "<prompt>". Quota: 1 slot per call.',
833
1027
  ' pugi review --consensus 3-model consensus review (codex · claude · deepseek).',
834
1028
  ' Optional: --commit <sha> | --pr <num> | --branch <name>.',
835
1029
  ' Exits 0 PASS · 1 WARN · 2 BLOCK.',
@@ -992,7 +1186,19 @@ export async function scaffoldPugiWorkspace(input) {
992
1186
  }, created, skipped);
993
1187
  writeJsonIfMissing(resolve(pugiDir, 'mcp.json'), {
994
1188
  schema: 1,
995
- servers: [],
1189
+ // 2026-05-27 dogfood: `servers` MUST be an object keyed by server
1190
+ // name (z.record(mcpServerConfigSchema) in
1191
+ // apps/pugi-cli/src/core/mcp/registry.ts:51). A bare `[]` array
1192
+ // here passed schema validation на pugi init exit но crashed
1193
+ // the next dispatch with
1194
+ // "MCP config at .pugi/mcp.json failed validation:
1195
+ // servers: Expected object, received array"
1196
+ // and the operator's first command after `pugi init` printed an
1197
+ // error banner before the actual reply. Empty object matches the
1198
+ // schema default and keeps the file forwards-compatible with
1199
+ // `pugi mcp install <name> ...` which merges into the same
1200
+ // record shape.
1201
+ servers: {},
996
1202
  }, created, skipped);
997
1203
  writeJsonIfMissing(resolve(pugiDir, 'index.json'), emptyIndex(), created, skipped);
998
1204
  writeTextIfMissing(resolve(pugiDir, 'PUGI.md'), [
@@ -1414,10 +1620,20 @@ async function review(args, flags, session) {
1414
1620
  // streaming UX and rubric-driven exit codes don't disturb the existing
1415
1621
  // pugi-cli surfaces that depend on the old shape.
1416
1622
  if (flags.consensus) {
1623
+ // 2026-05-27 (Codex r0 P1 on PR #489): pass the globally-parsed
1624
+ // --commit / --base flags to consensus so `pugi review --consensus
1625
+ // --commit X` reviews the requested SHA instead of silently falling
1626
+ // back to the working-tree diff. parseConsensusArgs gives the inline
1627
+ // args (`--commit Y` after the command name) precedence; the
1628
+ // fallback only fires when `args` does not carry the token.
1417
1629
  const exitCode = await runReviewConsensus(args, {
1418
1630
  cwd: root,
1419
1631
  config: resolveRuntimeConfig(),
1420
1632
  json: flags.json,
1633
+ flagsFallback: {
1634
+ ...(flags.commit ? { commit: flags.commit } : {}),
1635
+ ...(flags.base ? { base: flags.base } : {}),
1636
+ },
1421
1637
  emit: (line) => {
1422
1638
  if (!flags.json)
1423
1639
  process.stdout.write(line);
@@ -1429,6 +1645,15 @@ async function review(args, flags, session) {
1429
1645
  process.exitCode = exitCode;
1430
1646
  return;
1431
1647
  }
1648
+ if (flags.triple && flags.commit) {
1649
+ // CEO directive 2026-05-27: `pugi review --triple --commit <SHA>`
1650
+ // dispatches to the customer-facing 3-model consensus path through
1651
+ // Anvil's already-paid Anthropic / OpenAI / Google routes. Replaces
1652
+ // the dev-only Codex/Claude/Gemini OAuth CLIs the `/triple-review`
1653
+ // skill uses.
1654
+ await performTripleProviderReview(root, session, flags, prompt);
1655
+ return;
1656
+ }
1432
1657
  if (flags.triple && flags.remote) {
1433
1658
  await performRemoteTripleReview(root, session, flags, prompt);
1434
1659
  return;
@@ -1866,6 +2091,274 @@ async function performRemoteTripleReview(root, session, flags, prompt) {
1866
2091
  .join('\n'));
1867
2092
  process.exitCode = outcome.exitCode;
1868
2093
  }
2094
+ /**
2095
+ * `pugi review --triple --commit <SHA>` — customer-facing 3-model
2096
+ * consensus review via Anvil multi-provider routing.
2097
+ *
2098
+ * Dispatches the same diff to Anthropic / OpenAI / Google models
2099
+ * (routed through Anvil's already-paid fleet, NOT OAuth-bound dev
2100
+ * CLIs) and renders the per-reviewer verdict + cross-model
2101
+ * disagreement summary at the end. Quota: one `reviewPerMonth` slot
2102
+ * per call regardless of provider count — the controller-level
2103
+ * `@QuotaGated('reviewPerMonth')` decorator enforces single-slot
2104
+ * debit (see apps/admin-api/src/pugi/pugi.controller.ts).
2105
+ *
2106
+ * CEO directive 2026-05-27: replaces the dev-only `/triple-review`
2107
+ * skill's Codex/Claude/Gemini OAuth dependency with a customer-
2108
+ * runnable Pugi product surface. Dogfood loop: Pugi reviews Pugi PRs.
2109
+ */
2110
+ async function performTripleProviderReview(root, session, flags, prompt) {
2111
+ const config = resolveRuntimeConfig();
2112
+ const artifactDir = createArtifactDir(root, prompt || 'triple-providers');
2113
+ const requestPath = resolve(artifactDir, 'triple-review-request.json');
2114
+ const resultPath = resolve(artifactDir, 'triple-review-result.json');
2115
+ const summaryPath = resolve(artifactDir, 'triple-review.md');
2116
+ const toolCallId = recordToolCall(session, 'review:triple-providers', prompt || `review ${flags.commit ?? 'HEAD'} via providers`);
2117
+ // Resolve base ref. CLI flag wins over settings → so an operator
2118
+ // can target a specific integration branch without editing settings.
2119
+ const settings = loadSettings(root);
2120
+ const baseRef = flags.base ?? resolveBaseRef(root, settings) ?? 'origin/main';
2121
+ // Normalise both the commit and the base to short SHAs so the audit
2122
+ // log stores a stable reference even if branches move.
2123
+ const commitRef = flags.commit ?? 'HEAD';
2124
+ // 2026-05-27 (Codex r0 P2 on PR #489): safeGit returns '' on a bad ref
2125
+ // (it swallows the git exit code so callers don't have to wrap every
2126
+ // probe). Without an explicit refusal, a misspelled --commit or --base
2127
+ // produced an EMPTY diff that the gate then PASSED — operators saw a
2128
+ // green review for changes that were never reviewed. Resolve both refs
2129
+ // through `rev-parse --verify` first; an empty result is a hard error.
2130
+ const verifiedCommit = safeGit(root, ['rev-parse', '--verify', commitRef]).trim();
2131
+ if (!verifiedCommit) {
2132
+ throw new Error(`pugi review --triple: cannot resolve --commit '${commitRef}' — ` +
2133
+ `check the SHA or branch name. ` +
2134
+ `Refusing to submit an empty diff for review.`);
2135
+ }
2136
+ const verifiedBase = safeGit(root, ['rev-parse', '--verify', baseRef]).trim();
2137
+ if (!verifiedBase) {
2138
+ throw new Error(`pugi review --triple: cannot resolve --base '${baseRef}' — ` +
2139
+ `check the ref or set base via 'pugi config set review.base=<ref>'. ` +
2140
+ `Refusing to submit an empty diff for review.`);
2141
+ }
2142
+ const resolvedCommit = safeGit(root, ['rev-parse', '--short', commitRef]).trim() || commitRef;
2143
+ const mergeBase = safeGit(root, ['merge-base', baseRef, commitRef]).trim() || '';
2144
+ const diffRange = mergeBase || `${baseRef}..${commitRef}`;
2145
+ const diffArgs = ['diff', diffRange, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
2146
+ const diffStatArgs = ['diff', '--shortstat', diffRange, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
2147
+ const diffPatch = safeGit(root, diffArgs);
2148
+ const diffStats = parseDiffStats(safeGit(root, diffStatArgs));
2149
+ const requestBody = pugiTripleReviewRequestSchema.parse({
2150
+ schema: 1,
2151
+ workspace: {
2152
+ rootName: root.split('/').at(-1) ?? 'workspace',
2153
+ gitBranch: safeGit(root, ['branch', '--show-current']).trim() || null,
2154
+ gitHead: resolvedCommit || null,
2155
+ baseRef,
2156
+ dirty: Boolean(safeGit(root, ['status', '--short']).trim()),
2157
+ },
2158
+ diffPatch,
2159
+ diffStats,
2160
+ prompt: prompt || undefined,
2161
+ locale: 'en-US',
2162
+ reviewerPersona: 'oes-dev',
2163
+ commit: resolvedCommit,
2164
+ modelProviders: ['claude', 'gpt', 'gemini'],
2165
+ });
2166
+ writeFileSync(requestPath, `${JSON.stringify(requestBody, null, 2)}\n`, {
2167
+ encoding: 'utf8',
2168
+ mode: 0o600,
2169
+ });
2170
+ registerArtifact(root, {
2171
+ id: artifactIdFromDir(artifactDir),
2172
+ kind: 'triple-review',
2173
+ path: relative(root, artifactDir),
2174
+ sessionId: session.id,
2175
+ createdAt: new Date().toISOString(),
2176
+ files: ['triple-review-request.json'],
2177
+ });
2178
+ if (!config) {
2179
+ const reason = 'No active Pugi credentials. Run `pugi login --token <PAT>` or set PUGI_API_KEY for CI use.';
2180
+ recordToolResult(session, toolCallId, 'error', reason);
2181
+ writeFileSync(summaryPath, buildTripleReviewMarkdown({
2182
+ prompt,
2183
+ requestPath: relative(root, requestPath),
2184
+ verdict: null,
2185
+ reason,
2186
+ response: null,
2187
+ }), { encoding: 'utf8', mode: 0o600 });
2188
+ writeOutput(flags, {
2189
+ status: 'auth_missing',
2190
+ request: relative(root, requestPath),
2191
+ summary: relative(root, summaryPath),
2192
+ }, [
2193
+ 'Pugi triple-provider review request prepared but not sent — no active credentials.',
2194
+ `Request: ${relative(root, requestPath)}`,
2195
+ `Run \`pugi login --token <PAT>\` (or export PUGI_API_KEY for CI) then retry \`pugi review --triple --commit ${resolvedCommit}\`.`,
2196
+ ].join('\n'));
2197
+ process.exitCode = 5;
2198
+ return;
2199
+ }
2200
+ const submitResult = await submitTripleReview(config, requestBody);
2201
+ if (submitResult.status !== 'ok') {
2202
+ const outcome = describeSubmitFailure(submitResult);
2203
+ writeFileSync(summaryPath, buildTripleReviewMarkdown({
2204
+ prompt,
2205
+ requestPath: relative(root, requestPath),
2206
+ verdict: null,
2207
+ reason: outcome.message,
2208
+ response: null,
2209
+ }), { encoding: 'utf8', mode: 0o600 });
2210
+ recordToolResult(session, toolCallId, 'error', outcome.message);
2211
+ writeOutput(flags, {
2212
+ status: submitResult.status,
2213
+ code: submitResult.code,
2214
+ message: outcome.message,
2215
+ request: relative(root, requestPath),
2216
+ summary: relative(root, summaryPath),
2217
+ }, [
2218
+ outcome.headline,
2219
+ `Request: ${relative(root, requestPath)}`,
2220
+ `Summary: ${relative(root, summaryPath)}`,
2221
+ outcome.next ? `Next: ${outcome.next}` : '',
2222
+ ]
2223
+ .filter(Boolean)
2224
+ .join('\n'));
2225
+ process.exitCode = outcome.exitCode;
2226
+ return;
2227
+ }
2228
+ const response = submitResult.response;
2229
+ persistTripleReviewResult(resultPath, response);
2230
+ writeFileSync(summaryPath, buildTripleReviewMarkdown({
2231
+ prompt,
2232
+ requestPath: relative(root, requestPath),
2233
+ verdict: response.verdict,
2234
+ reason: response.reason,
2235
+ response,
2236
+ }), { encoding: 'utf8', mode: 0o600 });
2237
+ recordToolResult(session, toolCallId, response.verdict === 'BLOCK' ? 'error' : 'success', `Verdict: ${response.verdict} (${response.reason})`);
2238
+ const verdictReport = renderTripleProviderVerdict({
2239
+ response,
2240
+ commit: resolvedCommit,
2241
+ baseRef,
2242
+ });
2243
+ writeOutput(flags, {
2244
+ status: 'completed',
2245
+ verdict: response.verdict,
2246
+ reason: response.reason,
2247
+ counts: response.counts,
2248
+ reviewerCount: response.reviewerCount,
2249
+ effectiveTier: response.effectiveTier,
2250
+ commit: resolvedCommit,
2251
+ baseRef,
2252
+ reviewers: response.reviewers.map((r) => ({
2253
+ provider: r.provider ?? null,
2254
+ model: r.model,
2255
+ declaredVerdict: r.declaredVerdict,
2256
+ findings: r.findings,
2257
+ latencyMs: r.latencyMs,
2258
+ tokensUsed: r.tokensUsed,
2259
+ error: r.error,
2260
+ })),
2261
+ result: relative(root, resultPath),
2262
+ summary: relative(root, summaryPath),
2263
+ }, verdictReport);
2264
+ if (response.verdict === 'BLOCK') {
2265
+ process.exitCode = 9;
2266
+ }
2267
+ else if (response.verdict === 'WARN') {
2268
+ process.exitCode = 1;
2269
+ }
2270
+ }
2271
+ /**
2272
+ * Pretty-printer for the `pugi review --triple --commit <SHA>` verdict.
2273
+ * Mirrors the `/triple-review` skill's verdict block (per-reviewer
2274
+ * counts table → final GATE line → per-reviewer verbatim → cross-
2275
+ * model disagreement summary → tokens/cost note) so the output is
2276
+ * familiar to operators who already use the dev-only skill.
2277
+ */
2278
+ export function renderTripleProviderVerdict(input) {
2279
+ const { response, commit, baseRef } = input;
2280
+ const divider = '═'.repeat(68);
2281
+ const subDivider = '─'.repeat(68);
2282
+ // Per-reviewer counts table.
2283
+ const reviewerRows = response.reviewers.map((reviewer) => {
2284
+ const c = { P0: 0, P1: 0, P2: 0, P3: 0 };
2285
+ for (const f of reviewer.findings)
2286
+ c[f.severity] += 1;
2287
+ const status = reviewer.error
2288
+ ? 'ERROR'
2289
+ : reviewer.declaredVerdict ?? 'UNKNOWN';
2290
+ const label = reviewer.provider
2291
+ ? reviewer.provider.toUpperCase().padEnd(8)
2292
+ : reviewer.model.slice(0, 8).padEnd(8);
2293
+ return ` ${label} ${pad(c.P0)} ${pad(c.P1)} ${pad(c.P2)} ${pad(c.P3)} ${status}`;
2294
+ });
2295
+ // Cross-model disagreement: list severities flagged by 1 of N but not
2296
+ // the others. Surfaces the "highest-signal moment" per the skill.
2297
+ const disagreements = [];
2298
+ const allFindings = response.reviewers.flatMap((r) => r.findings.map((f) => ({
2299
+ provider: r.provider ?? r.model,
2300
+ severity: f.severity,
2301
+ line: f.line,
2302
+ issue: f.issue,
2303
+ })));
2304
+ const p1Flaggers = new Set(response.reviewers
2305
+ .filter((r) => r.findings.some((f) => f.severity === 'P1'))
2306
+ .map((r) => r.provider ?? r.model));
2307
+ if (p1Flaggers.size === 1) {
2308
+ const sole = [...p1Flaggers][0];
2309
+ disagreements.push(`Only ${sole} flagged a P1 — examine the disagreement, often the highest-signal moment.`);
2310
+ }
2311
+ const p0Flaggers = new Set(response.reviewers
2312
+ .filter((r) => r.findings.some((f) => f.severity === 'P0'))
2313
+ .map((r) => r.provider ?? r.model));
2314
+ if (p0Flaggers.size > 0 && p0Flaggers.size < response.reviewers.length) {
2315
+ disagreements.push(`P0 flagged by ${[...p0Flaggers].join(', ')} but not ${response.reviewers
2316
+ .filter((r) => !p0Flaggers.has(r.provider ?? r.model))
2317
+ .map((r) => r.provider ?? r.model)
2318
+ .join(', ')} — verify the finding before merging.`);
2319
+ }
2320
+ // Tokens / cost summary. Tokens are best-effort (some providers
2321
+ // return null). Cost is a placeholder pending billing wire-up; we
2322
+ // surface the quota note inline so the operator knows it counts as
2323
+ // one slot, not three.
2324
+ const totalTokens = response.reviewers.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
2325
+ // Verbatim reviewer outputs. Each section gets a header so operators
2326
+ // can scroll quickly and copy any individual reviewer's text into
2327
+ // their own notes / triage doc.
2328
+ const reviewerSections = response.reviewers.map((reviewer) => {
2329
+ const label = reviewer.provider
2330
+ ? reviewer.provider.toUpperCase()
2331
+ : reviewer.model;
2332
+ const body = reviewer.error
2333
+ ? `(reviewer errored: ${reviewer.error})`
2334
+ : reviewer.rawContent.trim() || '(empty response)';
2335
+ return [subDivider, `${label} SAYS (${reviewer.model}):`, '', body].join('\n');
2336
+ });
2337
+ return [
2338
+ `PUGI TRIPLE-PROVIDER REVIEW — commit ${commit} vs ${baseRef}`,
2339
+ divider,
2340
+ '',
2341
+ ` P0 P1 P2 P3 Status`,
2342
+ ...reviewerRows,
2343
+ '',
2344
+ `GATE: ${response.verdict}`,
2345
+ `Reason: ${response.reason}`,
2346
+ '',
2347
+ ...reviewerSections,
2348
+ '',
2349
+ subDivider,
2350
+ 'CROSS-MODEL DISAGREEMENT:',
2351
+ disagreements.length === 0
2352
+ ? ' (none — all reviewers agreed within rubric tolerance)'
2353
+ : disagreements.map((d) => ` - ${d}`).join('\n'),
2354
+ '',
2355
+ `Tokens: ~${totalTokens} total across ${response.reviewers.length} reviewers`,
2356
+ 'Quota: charged as 1 review slot (multi-provider counts as a single call).',
2357
+ ].join('\n');
2358
+ }
2359
+ function pad(n) {
2360
+ return String(n).padStart(2, ' ');
2361
+ }
1869
2362
  function describeSubmitFailure(result) {
1870
2363
  switch (result.status) {
1871
2364
  case 'endpoint_missing':
@@ -40,8 +40,23 @@ import { aggregate, exitCodeFor, reviewerVerdictFromRaw, } from '../../core/cons
40
40
  * `--branch <name>` / `--branch=<name>`
41
41
  * `--base <ref>` / `--base=<ref>` (override default origin/main)
42
42
  */
43
- export function parseConsensusArgs(args) {
43
+ export function parseConsensusArgs(args,
44
+ /**
45
+ * 2026-05-27 (Codex r0 P1 on PR #489): cli.ts now parses --commit /
46
+ * --base in the GLOBAL flag pass for the new triple-provider path.
47
+ * Those tokens are consumed BEFORE this function sees `args`, so
48
+ * `pugi review --consensus --commit X` would silently fall back to
49
+ * the default diff and review the wrong changes. Pass the global
50
+ * flags through here so consensus picks them up when present.
51
+ * Inline `--commit`/`--base` tokens in args still win — explicit
52
+ * caller intent is preserved.
53
+ */
54
+ fallback) {
44
55
  const spec = {};
56
+ if (fallback?.commit)
57
+ spec.commit = fallback.commit;
58
+ if (fallback?.base)
59
+ spec.baseRef = fallback.base;
45
60
  for (let i = 0; i < args.length; i += 1) {
46
61
  const arg = args[i] ?? '';
47
62
  const equalsIdx = arg.indexOf('=');
@@ -119,7 +134,7 @@ export async function runReviewConsensus(args, ctx) {
119
134
  // exit 2 — same as BLOCK because the gate could not even run.
120
135
  let captured;
121
136
  try {
122
- const spec = parseConsensusArgs(args);
137
+ const spec = parseConsensusArgs(args, ctx.flagsFallback);
123
138
  captured = captureDiff({ ...spec, cwd: ctx.cwd });
124
139
  }
125
140
  catch (error) {
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
44
44
  * during import). When bumping the CLI version BOTH literals must be
45
45
  * updated; the release smoke-test (`pack:smoke`) verifies they agree.
46
46
  */
47
- export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.13');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.15');
48
48
  /**
49
49
  * Outbound: the CLI's installed semver. Read at request time by
50
50
  * `version-interceptor.ts` and injected on every `fetch` call.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.13",
3
+ "version": "0.1.0-beta.15",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -48,13 +48,13 @@
48
48
  "ink": "^5.0.1",
49
49
  "linkedom": "^0.18.12",
50
50
  "react": "^18.3.1",
51
- "tar": "^6.2.1",
51
+ "tar": "^7.5.11",
52
52
  "tinyglobby": "^0.2.16",
53
53
  "turndown": "^7.2.4",
54
54
  "undici": "^8.3.0",
55
55
  "zod": "^3.23.0",
56
56
  "@pugi/personas": "0.1.2",
57
- "@pugi/sdk": "0.1.0-beta.13"
57
+ "@pugi/sdk": "0.1.0-beta.15"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^22.0.0",