@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
|
-
|
|
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',
|
package/dist/runtime/cli.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/runtime/version.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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": "^
|
|
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.
|
|
57
|
+
"@pugi/sdk": "0.1.0-beta.15"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^22.0.0",
|