@pugi/cli 0.1.0-beta.2 → 0.1.0-beta.20
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/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-mascot.ansi +15 -40
- package/bin/run.js +33 -1
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +196 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/consensus/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +111 -18
- package/dist/core/engine/anvil-client.js +115 -5
- package/dist/core/engine/budgets.js +89 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +744 -210
- package/dist/core/engine/prompts.js +61 -6
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +818 -31
- package/dist/core/file-cache.js +113 -1
- package/dist/core/init/scaffold.js +195 -0
- package/dist/core/lsp/client.js +174 -29
- package/dist/core/mcp/client.js +75 -6
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/permissions/gate.js +187 -0
- package/dist/core/permissions/index.js +18 -0
- package/dist/core/permissions/mode.js +102 -0
- package/dist/core/permissions/state.js +160 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/repl/codebase-survey.js +308 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/init-interview.js +457 -0
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/onboarding-state.js +297 -0
- package/dist/core/repl/session.js +719 -29
- package/dist/core/repl/slash-commands.js +133 -9
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/settings.js +71 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +1588 -266
- package/dist/runtime/commands/compact.js +296 -0
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +289 -0
- package/dist/runtime/commands/doctor.js +369 -0
- package/dist/runtime/commands/lsp.js +187 -5
- package/dist/runtime/commands/mcp.js +824 -0
- package/dist/runtime/commands/patch.js +17 -0
- package/dist/runtime/commands/permissions.js +87 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/commands/roster.js +117 -0
- package/dist/runtime/commands/status.js +178 -0
- package/dist/runtime/commands/worktree.js +50 -6
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +206 -0
- package/dist/tools/apply-patch.js +281 -39
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/file-tools.js +85 -14
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +22 -2
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +54 -0
- package/dist/tui/conversation-pane.js +69 -8
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/doctor-table.js +31 -0
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/repl-render.js +276 -37
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +25 -6
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/tool-stream-pane.js +7 -0
- package/dist/tui/update-banner.js +20 -2
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +9 -6
package/dist/runtime/cli.js
CHANGED
|
@@ -1,38 +1,50 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { statSync } from 'node:fs';
|
|
5
5
|
import { dirname, relative, resolve } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { AnvilEngineLoopClient } from '../core/engine/anvil-client.js';
|
|
8
|
-
import { NoopEngineAdapter } from '../core/engine/noop.js';
|
|
9
8
|
import { NativePugiEngineAdapter } from '../core/engine/native-pugi.js';
|
|
10
|
-
import {
|
|
9
|
+
import { loadMcpRegistry } from '../core/mcp/registry.js';
|
|
10
|
+
import { loadHookRegistryOrExit } from './load-hooks-or-exit.js';
|
|
11
|
+
import { defaultNonInteractiveMcpPrompt } from '../tools/mcp-tool.js';
|
|
11
12
|
import { openSession, recordCommandCompleted, recordCommandStarted, recordToolCall, recordToolResult, } from '../core/session.js';
|
|
12
13
|
import { loadSettings } from '../core/settings.js';
|
|
13
14
|
import { FileReadCache } from '../core/file-cache.js';
|
|
14
15
|
import { resolveWorkspacePath } from '../core/path-security.js';
|
|
15
16
|
import { globTool, grepTool, readTool } from '../tools/file-tools.js';
|
|
16
|
-
import { toolRegistry, toolSchemaBundleHashInput } from '../tools/registry.js';
|
|
17
17
|
import { webFetchTool } from '../tools/web-fetch.js';
|
|
18
18
|
import { emptyIndex, rebuildIndex, readIndex, upsertArtifact, writeIndex, } from '../core/index-store.js';
|
|
19
19
|
import { signatureForPlanReview } from '../core/repl/ask.js';
|
|
20
|
-
import { buildRuntimeConfig, loadRuntimeConfig, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitSync, submitTripleReview, } from '@pugi/sdk';
|
|
20
|
+
import { buildRuntimeConfig, fetchPersonaRoster, loadRuntimeConfig, openPugiSession, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitDelegate, submitSync, submitTripleReview, } from '@pugi/sdk';
|
|
21
21
|
import { PUGI_TAGLINE } from '@pugi/personas';
|
|
22
|
+
import { resolveRoster, renderRosterTable } from './commands/roster.js';
|
|
23
|
+
import { runDelegateCommand } from './commands/delegate.js';
|
|
22
24
|
import { clearApiKey, DEFAULT_API_URL, listStoredCredentials, maskApiKey, normalizeApiUrl, purgeAllCredentials, readCredentialsFile, resolveActiveCredential, storeApiKey, switchActiveAccount, } from '../core/credentials.js';
|
|
23
25
|
import { runDeployCommand } from '../commands/deploy.js';
|
|
24
26
|
import { runJobsCommand } from '../commands/jobs.js';
|
|
25
27
|
import { runConfigCommand } from './commands/config.js';
|
|
26
28
|
import { runPrivacyCommand } from './commands/privacy.js';
|
|
29
|
+
import { runReport } from './commands/report.js';
|
|
30
|
+
import { runDoctorCommand, defaultHome as defaultDoctorHome } from './commands/doctor.js';
|
|
31
|
+
import { runStatusCommand, defaultStatusHome, } from './commands/status.js';
|
|
27
32
|
import { runUndoCommand } from './commands/undo.js';
|
|
33
|
+
import { runCompactCommand } from './commands/compact.js';
|
|
28
34
|
import { runBudgetCommand } from './commands/budget.js';
|
|
35
|
+
import { runCostCommand } from './commands/cost.js';
|
|
29
36
|
import { runSkillsCommand } from './commands/skills.js';
|
|
37
|
+
import { installDefaultSkills } from '../core/skills/defaults.js';
|
|
30
38
|
import { runAgentsCommand } from './commands/agents.js';
|
|
31
39
|
import { runLspCommand } from './commands/lsp.js';
|
|
32
40
|
import { runPatchCommand } from './commands/patch.js';
|
|
33
41
|
import { runWorktreeCommand } from './commands/worktree.js';
|
|
34
42
|
import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
|
|
35
43
|
import { runReviewConsensus } from './commands/review-consensus.js';
|
|
44
|
+
import { runMcpCommand } from './commands/mcp.js';
|
|
45
|
+
import { runPermissionsCommand } from './commands/permissions.js';
|
|
46
|
+
import { parsePermissionMode } from '../core/permissions/index.js';
|
|
47
|
+
import { DECOMPOSE_PROMPT_SUFFIX, parseDecompositionFromText, writeDecomposition, } from './plan-decompose.js';
|
|
36
48
|
import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
|
|
37
49
|
import { slugForCwd } from '../core/repl/history.js';
|
|
38
50
|
import { dispatchEdit, } from '../core/edits/index.js';
|
|
@@ -47,7 +59,15 @@ import { dispatchEdit, } from '../core/edits/index.js';
|
|
|
47
59
|
* packages/pugi-sdk/package.json); the publish workflow validates the
|
|
48
60
|
* three are in lockstep.
|
|
49
61
|
*/
|
|
50
|
-
|
|
62
|
+
// PR-CLI-SERVER-VERSION-HANDSHAKE (#225). PUGI_CLI_VERSION lives in
|
|
63
|
+
// `runtime/version.ts` now so the engine transport interceptor can
|
|
64
|
+
// import it without dragging in the cli.ts module graph. Re-exported
|
|
65
|
+
// here under the original name so every existing reader (`pugi version`,
|
|
66
|
+
// `pugi doctor --json`, splash render, telemetry) keeps working with
|
|
67
|
+
// zero churn. Bumping the CLI version is still a single-file edit —
|
|
68
|
+
// just on `runtime/version.ts` instead of here. The β1 sanitizer that
|
|
69
|
+
// guarded against `workspace:*` leaks moved with the constant.
|
|
70
|
+
import { PUGI_CLI_VERSION, sanitizeSemver } from './version.js';
|
|
51
71
|
const handlers = {
|
|
52
72
|
accounts,
|
|
53
73
|
agents: dispatchAgents,
|
|
@@ -56,6 +76,8 @@ const handlers = {
|
|
|
56
76
|
budget: dispatchBudget,
|
|
57
77
|
code: runEngineTask('code'),
|
|
58
78
|
config: dispatchConfig,
|
|
79
|
+
cost: dispatchCost,
|
|
80
|
+
delegate: dispatchDelegate,
|
|
59
81
|
deploy: dispatchDeploy,
|
|
60
82
|
doctor,
|
|
61
83
|
explain: runEngineTask('explain'),
|
|
@@ -68,16 +90,30 @@ const handlers = {
|
|
|
68
90
|
login,
|
|
69
91
|
logout,
|
|
70
92
|
lsp: dispatchLsp,
|
|
93
|
+
mcp: dispatchMcp,
|
|
71
94
|
patch: dispatchPatch,
|
|
95
|
+
permissions: dispatchPermissions,
|
|
96
|
+
perms: dispatchPermissions,
|
|
72
97
|
plan: runEngineTask('plan'),
|
|
73
98
|
'plan-review': dispatchPlanReview,
|
|
74
99
|
privacy: dispatchPrivacy,
|
|
100
|
+
// PAVF-7 (2026-05-27): `pugi report --from-error` captures the
|
|
101
|
+
// most-recent failed session as a redacted bundle so operators can
|
|
102
|
+
// file clean bug reports without manual log-grepping.
|
|
103
|
+
report: dispatchReport,
|
|
75
104
|
review,
|
|
76
105
|
resume,
|
|
106
|
+
roster: dispatchRoster,
|
|
77
107
|
sessions,
|
|
78
108
|
skills: dispatchSkills,
|
|
109
|
+
status,
|
|
79
110
|
sync,
|
|
80
111
|
undo: dispatchUndo,
|
|
112
|
+
compact: dispatchCompact,
|
|
113
|
+
// L19 (2026-05-27): `pugi usage` is an alias of `pugi cost` — same
|
|
114
|
+
// handler, same flags. Operators trained on Claude Code expect either
|
|
115
|
+
// verb to surface the per-model token + USD table.
|
|
116
|
+
usage: dispatchCost,
|
|
81
117
|
version,
|
|
82
118
|
web: dispatchWeb,
|
|
83
119
|
whoami,
|
|
@@ -252,6 +288,78 @@ async function dispatchPrivacy(args, flags, _session) {
|
|
|
252
288
|
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
253
289
|
});
|
|
254
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* PAVF-7 (2026-05-27): `pugi report --from-error` — bundle the most-
|
|
293
|
+
* recent failed session into a redacted local report so operators can
|
|
294
|
+
* file clean bug tickets without manual log-grepping. v1 is local-only
|
|
295
|
+
* (no auto-upload — see commands/report.ts header for the rationale).
|
|
296
|
+
*/
|
|
297
|
+
async function dispatchReport(args, flags, _session) {
|
|
298
|
+
const rc = runReport(args, {
|
|
299
|
+
cwd: process.cwd(),
|
|
300
|
+
json: flags.json,
|
|
301
|
+
emit: (line) => {
|
|
302
|
+
if (!flags.json)
|
|
303
|
+
process.stdout.write(line);
|
|
304
|
+
},
|
|
305
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
306
|
+
});
|
|
307
|
+
if (rc !== 0)
|
|
308
|
+
process.exitCode = rc;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* `pugi roster` - α7.5 Phase 1.
|
|
312
|
+
*
|
|
313
|
+
* List the live Tier 1 personas with display name, role, and routing
|
|
314
|
+
* tag. Walks the remote /api/pugi/sessions/roster endpoint when a
|
|
315
|
+
* credential is available; falls back to the local @pugi/personas
|
|
316
|
+
* roster when offline so the operator can still see who is on the team.
|
|
317
|
+
*/
|
|
318
|
+
async function dispatchRoster(_args, flags, _session) {
|
|
319
|
+
const credential = resolveActiveCredential();
|
|
320
|
+
const config = credential
|
|
321
|
+
? buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey })
|
|
322
|
+
: null;
|
|
323
|
+
const { rows, warning } = await resolveRoster(config);
|
|
324
|
+
const payload = {
|
|
325
|
+
ok: true,
|
|
326
|
+
personas: rows,
|
|
327
|
+
warning,
|
|
328
|
+
};
|
|
329
|
+
const text = (warning ? `# warning: ${warning}\n\n` : '') +
|
|
330
|
+
renderRosterTable(rows);
|
|
331
|
+
writeOutput(flags, payload, text);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* `pugi delegate <slug> "<brief>"` - α7.5 Phase 1.
|
|
335
|
+
*
|
|
336
|
+
* Open a fresh REPL session and POST the brief to one Tier 1 persona,
|
|
337
|
+
* bypassing Mira's coordinator pass. Non-interactive: the CLI prints
|
|
338
|
+
* the dispatch id on success and exits; the operator (or a script) can
|
|
339
|
+
* subscribe to the session stream separately if they want the live
|
|
340
|
+
* lifecycle. Interactive operators use `/delegate` from inside the REPL
|
|
341
|
+
* instead so the dispatch lifecycle surfaces inline.
|
|
342
|
+
*/
|
|
343
|
+
async function dispatchDelegate(args, flags, _session) {
|
|
344
|
+
await runDelegateCommand(args, {
|
|
345
|
+
workspaceCwd: process.cwd(),
|
|
346
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
347
|
+
resolveConfig: () => {
|
|
348
|
+
const credential = resolveActiveCredential();
|
|
349
|
+
if (!credential)
|
|
350
|
+
return null;
|
|
351
|
+
return buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey });
|
|
352
|
+
},
|
|
353
|
+
fetchRoster: fetchPersonaRoster,
|
|
354
|
+
submitDelegate,
|
|
355
|
+
openSession: async (config, workspaceCwd) => {
|
|
356
|
+
const result = await openPugiSession(config, { workspaceCwd });
|
|
357
|
+
if (result.status === 'ok')
|
|
358
|
+
return { sessionId: result.response.sessionId };
|
|
359
|
+
return { error: `${result.status}: ${result.message}` };
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|
|
255
363
|
async function dispatchUndo(args, flags, session) {
|
|
256
364
|
await runUndoCommand(args, {
|
|
257
365
|
workspaceRoot: process.cwd(),
|
|
@@ -259,12 +367,77 @@ async function dispatchUndo(args, flags, session) {
|
|
|
259
367
|
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
260
368
|
});
|
|
261
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Leak L8 (2026-05-27) — `pugi compact` summarises older REPL turns
|
|
372
|
+
* into a single boundary marker, freeing context for the next `pugi
|
|
373
|
+
* resume <id>`. The slash `/compact` inside a live REPL forwards
|
|
374
|
+
* through the same runner via session.ts so the surface stays single-
|
|
375
|
+
* sourced.
|
|
376
|
+
*/
|
|
377
|
+
async function dispatchCompact(args, flags, _session) {
|
|
378
|
+
const result = await runCompactCommand(args, {
|
|
379
|
+
workspaceRoot: process.cwd(),
|
|
380
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
381
|
+
});
|
|
382
|
+
if (result.status === 'failed_no_session'
|
|
383
|
+
|| result.status === 'failed_transport'
|
|
384
|
+
|| result.status === 'failed_store') {
|
|
385
|
+
process.exitCode = 1;
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (result.status === 'noop_empty' || result.status === 'noop_recent_marker') {
|
|
389
|
+
process.exitCode = 2;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
262
392
|
async function dispatchBudget(args, flags, _session) {
|
|
263
393
|
await runBudgetCommand(args, {
|
|
264
394
|
workspaceRoot: process.cwd(),
|
|
265
395
|
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
266
396
|
});
|
|
267
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* Leak L6 — `pugi permissions [mode] [--persist] [--confirm]`.
|
|
400
|
+
*
|
|
401
|
+
* Surface the same intent as the in-REPL `/permissions` slash. Mode
|
|
402
|
+
* arg is positional; `--persist` and `--confirm` are zero-arg flags
|
|
403
|
+
* already consumed by `parseArgs` into `flags.persist` / `flags.confirm`.
|
|
404
|
+
*
|
|
405
|
+
* Examples:
|
|
406
|
+
* pugi permissions -> show current mode + table
|
|
407
|
+
* pugi permissions plan -> flip workspace state to plan
|
|
408
|
+
* pugi permissions allow --persist -> flip + write ~/.pugi/config.json
|
|
409
|
+
* pugi permissions bypass --confirm -> flip to bypass (acknowledge banner)
|
|
410
|
+
*/
|
|
411
|
+
async function dispatchPermissions(args, flags, _session) {
|
|
412
|
+
const head = args[0];
|
|
413
|
+
if (head && parsePermissionMode(head) === null) {
|
|
414
|
+
writeOutput(flags, { error: 'unknown_mode', mode: head }, `Unknown mode '${head}'. Allowed: plan, ask, allow, bypass.`);
|
|
415
|
+
process.exitCode = 1;
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const mode = head ? parsePermissionMode(head) : undefined;
|
|
419
|
+
await runPermissionsCommand({
|
|
420
|
+
...(mode ? { mode } : {}),
|
|
421
|
+
persist: Boolean(flags.persist),
|
|
422
|
+
confirmBypass: Boolean(flags.confirm),
|
|
423
|
+
}, {
|
|
424
|
+
workspaceRoot: process.cwd(),
|
|
425
|
+
writeOutput: (text) => writeOutput(flags, { text }, text),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* L19 sprint (2026-05-27): `pugi cost` / `pugi usage` top-level surface.
|
|
430
|
+
*
|
|
431
|
+
* Aliased through the handlers table so `pugi usage` reuses the same
|
|
432
|
+
* implementation. The persisted store lives at `<cwd>/.pugi/cost.json`
|
|
433
|
+
* and is shared with the REPL `/cost` / `/usage` slash handlers.
|
|
434
|
+
*/
|
|
435
|
+
async function dispatchCost(args, flags, _session) {
|
|
436
|
+
await runCostCommand(args, {
|
|
437
|
+
workspaceRoot: process.cwd(),
|
|
438
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
268
441
|
async function dispatchSkills(args, flags, _session) {
|
|
269
442
|
await runSkillsCommand(args, {
|
|
270
443
|
workspaceRoot: process.cwd(),
|
|
@@ -328,22 +501,46 @@ async function dispatchWeb(args, flags, _session) {
|
|
|
328
501
|
*/
|
|
329
502
|
async function dispatchLsp(args, flags, _session) {
|
|
330
503
|
const result = await runLspCommand(args, { cwd: process.cwd(), json: flags.json });
|
|
331
|
-
|
|
332
|
-
console.log(result.text);
|
|
333
|
-
else
|
|
334
|
-
console.log(result.text);
|
|
504
|
+
console.log(result.text);
|
|
335
505
|
if (result.exitCode !== 0)
|
|
336
506
|
process.exitCode = result.exitCode;
|
|
337
507
|
}
|
|
508
|
+
/**
|
|
509
|
+
* β4 M6 + M7 + Sl7 (2026-05-26): `pugi mcp <sub>` — MCP execution +
|
|
510
|
+
* server. `list / trust / deny / install` manage the client-side
|
|
511
|
+
* registry (the same surface `pugi config mcp ...` exposes); `serve`
|
|
512
|
+
* boots Pugi-as-MCP-server over stdio (default) or HTTP+SSE; `perms`
|
|
513
|
+
* inspects + resets the per-(server, tool) permission cache that
|
|
514
|
+
* gates engine-loop dispatch.
|
|
515
|
+
*
|
|
516
|
+
* The serve sub-command never returns under normal conditions — the
|
|
517
|
+
* stdio path runs until stdin closes (parent agent disconnect) and the
|
|
518
|
+
* HTTP path runs until SIGINT/SIGTERM. Both honour the optional
|
|
519
|
+
* AbortSignal we pass through from the REPL slash bridge in β4b.
|
|
520
|
+
*/
|
|
521
|
+
async function dispatchMcp(args, flags, _session) {
|
|
522
|
+
await runMcpCommand(args, {
|
|
523
|
+
workspaceRoot: process.cwd(),
|
|
524
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
525
|
+
});
|
|
526
|
+
}
|
|
338
527
|
/**
|
|
339
528
|
* α7.7: `pugi patch` — apply a unified-diff patch from stdin or a file.
|
|
340
529
|
* Routes through the same security gate as the Layer A/B/C applicators
|
|
341
530
|
* (see `src/core/edits/security-gate.ts`). Exit codes mirror the
|
|
342
531
|
* security taxonomy so CI loops can alert on hostile patches without
|
|
343
532
|
* confusing them with operator typos.
|
|
533
|
+
*
|
|
534
|
+
* R1 fix (2026-05-26, PR #413 r1): pass `flags.dryRun` through so the
|
|
535
|
+
* top-level parser's consumption of `--dry-run` does not silently
|
|
536
|
+
* disable dry-run mode on `pugi patch --dry-run < diff.patch`.
|
|
344
537
|
*/
|
|
345
538
|
async function dispatchPatch(args, flags, _session) {
|
|
346
|
-
const result = await runPatchCommand(args, {
|
|
539
|
+
const result = await runPatchCommand(args, {
|
|
540
|
+
cwd: process.cwd(),
|
|
541
|
+
json: flags.json,
|
|
542
|
+
dryRun: flags.dryRun,
|
|
543
|
+
});
|
|
347
544
|
console.log(result.text);
|
|
348
545
|
if (result.exitCode !== 0)
|
|
349
546
|
process.exitCode = result.exitCode;
|
|
@@ -353,15 +550,54 @@ async function dispatchPatch(args, flags, _session) {
|
|
|
353
550
|
* The `pugi build` and `pugi review --consensus` paths use the same
|
|
354
551
|
* primitives internally (`createWorktree` / `promoteWorktree`); this
|
|
355
552
|
* surface is the operator escape hatch for debug + experiment flows.
|
|
553
|
+
*
|
|
554
|
+
* R1 fix (2026-05-26, PR #413 r1): forward `flags.dryRun` so the
|
|
555
|
+
* top-level parser's consumption of `--dry-run` does not silently
|
|
556
|
+
* disable dry-run mode on `pugi worktree promote --dry-run <path>`.
|
|
356
557
|
*/
|
|
357
558
|
async function dispatchWorktree(args, flags, _session) {
|
|
358
|
-
const result = await runWorktreeCommand(args, {
|
|
559
|
+
const result = await runWorktreeCommand(args, {
|
|
560
|
+
cwd: process.cwd(),
|
|
561
|
+
json: flags.json,
|
|
562
|
+
dryRun: flags.dryRun,
|
|
563
|
+
});
|
|
359
564
|
console.log(result.text);
|
|
360
565
|
if (result.exitCode !== 0)
|
|
361
566
|
process.exitCode = result.exitCode;
|
|
362
567
|
}
|
|
363
568
|
export async function runCli(argv) {
|
|
364
569
|
const { command, args, flags, isBareInvocation } = parseArgs(argv);
|
|
570
|
+
// β-headless dispatch (CEO directive 2026-05-27 "нужно тестирование по
|
|
571
|
+
// кругу"): when `--print <brief>` is set we route to the headless
|
|
572
|
+
// runner BEFORE the REPL / splash / command branches. The runner
|
|
573
|
+
// never mounts Ink, never opens raw stdin, never prints the splash
|
|
574
|
+
// — only the structured event stream lands on stdout. Same engine
|
|
575
|
+
// adapter path the REPL uses (no fork), only the output sink
|
|
576
|
+
// differs.
|
|
577
|
+
if (typeof flags.print === 'string') {
|
|
578
|
+
const { runHeadlessPrint } = await import('./headless.js');
|
|
579
|
+
// Default to NDJSON when stdout is not a TTY OR when --json is set
|
|
580
|
+
// explicitly. A human running `pugi --print "..."` in their
|
|
581
|
+
// terminal without flags gets the readable text sink; a pipe gets
|
|
582
|
+
// the machine-readable stream.
|
|
583
|
+
const wantJson = flags.json || !process.stdout.isTTY;
|
|
584
|
+
const headlessFactory = getEngineClientFactory();
|
|
585
|
+
const exitCode = await runHeadlessPrint({
|
|
586
|
+
prompt: flags.print,
|
|
587
|
+
json: wantJson,
|
|
588
|
+
cwd: flags.cwd ?? process.cwd(),
|
|
589
|
+
...(flags.workspace ? { workspace: flags.workspace } : {}),
|
|
590
|
+
...(flags.sessionId ? { sessionIdOverride: flags.sessionId } : {}),
|
|
591
|
+
...(flags.timeoutSeconds ? { timeoutSeconds: flags.timeoutSeconds } : {}),
|
|
592
|
+
noTools: flags.noTools,
|
|
593
|
+
...(flags.maxTurns ? { maxTurns: flags.maxTurns } : {}),
|
|
594
|
+
...(headlessFactory ? { engineClientFactory: headlessFactory } : {}),
|
|
595
|
+
...(headlessStdoutWriter ? { stdoutWrite: headlessStdoutWriter } : {}),
|
|
596
|
+
...(headlessStderrWriter ? { stderrWrite: headlessStderrWriter } : {}),
|
|
597
|
+
});
|
|
598
|
+
process.exitCode = exitCode;
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
365
601
|
// Bare `pugi` on a TTY enters the REPL-by-default agentic session
|
|
366
602
|
// (Sprint α5.7, ADR-0056). The REPL is the customer-facing surface
|
|
367
603
|
// that brings Pugi to parity with Claude Code / Codex CLI. When the
|
|
@@ -436,6 +672,7 @@ function parseArgs(argv) {
|
|
|
436
672
|
offline: false,
|
|
437
673
|
noTty: false,
|
|
438
674
|
allowFetch: false,
|
|
675
|
+
allowSearch: false,
|
|
439
676
|
noUpdateCheck: false,
|
|
440
677
|
noSplash: process.env.PUGI_SKIP_SPLASH === '1',
|
|
441
678
|
// Claude triple-review P1 PR #369: default tool-stream pane HIDDEN
|
|
@@ -445,11 +682,21 @@ function parseArgs(argv) {
|
|
|
445
682
|
// "Mira: ..." prose, never "Read(path)" prefix) OR fires falsely on
|
|
446
683
|
// accidental `Verb(noun)` shapes producing stuck `running` rows.
|
|
447
684
|
// Hide by default; opt-in via `--tool-stream` OR PUGI_TOOL_STREAM=1
|
|
448
|
-
// for development/testing. Will flip
|
|
685
|
+
// for development/testing. Will flip to default ON when backend
|
|
449
686
|
// emits real tool events (filed as α6.13.X follow-up).
|
|
450
687
|
noToolStream: process.env.PUGI_TOOL_STREAM === '1' || process.env.PUGI_HIDE_TOOL_STREAM === '1'
|
|
451
688
|
? process.env.PUGI_HIDE_TOOL_STREAM === '1'
|
|
452
689
|
: true,
|
|
690
|
+
noDefaults: process.env.PUGI_INIT_NO_DEFAULTS === '1',
|
|
691
|
+
decompose: false,
|
|
692
|
+
// β-headless: --no-tools default OFF so existing flag-free invocations
|
|
693
|
+
// keep tool advertisement. Flipped only by explicit operator opt-in.
|
|
694
|
+
noTools: false,
|
|
695
|
+
// Leak L6 — `pugi permissions <mode> --persist/--confirm`. Default
|
|
696
|
+
// false so existing invocations stay no-op on the new permission
|
|
697
|
+
// surface.
|
|
698
|
+
persist: false,
|
|
699
|
+
confirm: false,
|
|
453
700
|
};
|
|
454
701
|
const args = [];
|
|
455
702
|
// Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
|
|
@@ -482,7 +729,7 @@ function parseArgs(argv) {
|
|
|
482
729
|
else if (arg === '--consensus') {
|
|
483
730
|
// α6.7: customer-facing 3-model consensus review. Routes through
|
|
484
731
|
// the SSE-based runtime gate rather than the legacy artifact
|
|
485
|
-
// writer. The triple flag stays unset
|
|
732
|
+
// writer. The triple flag stays unset so the existing
|
|
486
733
|
// performRemoteTripleReview path is never accidentally entered.
|
|
487
734
|
flags.consensus = true;
|
|
488
735
|
}
|
|
@@ -495,6 +742,12 @@ function parseArgs(argv) {
|
|
|
495
742
|
else if (arg === '--allow-fetch') {
|
|
496
743
|
flags.allowFetch = true;
|
|
497
744
|
}
|
|
745
|
+
else if (arg === '--allow-search') {
|
|
746
|
+
// β1b T4 (2026-05-26): unlock the `web_search` tool for one
|
|
747
|
+
// invocation, mirroring the `--allow-fetch` gate. Distinct flag
|
|
748
|
+
// because an operator may want to query without fetching pages.
|
|
749
|
+
flags.allowSearch = true;
|
|
750
|
+
}
|
|
498
751
|
else if (arg === '--no-update-check') {
|
|
499
752
|
flags.noUpdateCheck = true;
|
|
500
753
|
}
|
|
@@ -505,10 +758,21 @@ function parseArgs(argv) {
|
|
|
505
758
|
flags.noToolStream = true;
|
|
506
759
|
}
|
|
507
760
|
else if (arg === '--tool-stream') {
|
|
508
|
-
// Opt-in
|
|
509
|
-
// pane shows
|
|
761
|
+
// Opt-in for α6.12 dev/testing — backend tool events not live yet,
|
|
762
|
+
// pane shows synthesized heuristic OR empty placeholder
|
|
510
763
|
flags.noToolStream = false;
|
|
511
764
|
}
|
|
765
|
+
else if (arg === '--no-defaults') {
|
|
766
|
+
// Init-only flag: skip the bundled default-skills install. Parsed
|
|
767
|
+
// at the global level for consistency with --no-splash / --no-tool-stream.
|
|
768
|
+
flags.noDefaults = true;
|
|
769
|
+
}
|
|
770
|
+
else if (arg === '--decompose') {
|
|
771
|
+
// α6.8 EXTEND PR1: plan-only flag. Other engine commands ignore
|
|
772
|
+
// it. Parsed globally for symmetry with the rest of the flag
|
|
773
|
+
// grammar; `runEngineTask('plan')` is the single consumer.
|
|
774
|
+
flags.decompose = true;
|
|
775
|
+
}
|
|
512
776
|
else if (arg.startsWith('--privacy=')) {
|
|
513
777
|
flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
|
|
514
778
|
}
|
|
@@ -519,18 +783,180 @@ function parseArgs(argv) {
|
|
|
519
783
|
flags.privacy = parsePrivacyMode(next);
|
|
520
784
|
index += 1;
|
|
521
785
|
}
|
|
786
|
+
else if (arg === '--print') {
|
|
787
|
+
// β-headless: top-level `--print <brief>` runs a single
|
|
788
|
+
// non-interactive engine turn. Consumes the next argv token as
|
|
789
|
+
// the brief — refusing if it looks like another flag so a
|
|
790
|
+
// dangling `--print --json` does not silently swallow `--json`.
|
|
791
|
+
const next = argv[index + 1];
|
|
792
|
+
if (!next || next.startsWith('--')) {
|
|
793
|
+
throw new Error('--print requires a brief (e.g. --print "create word_counter.py")');
|
|
794
|
+
}
|
|
795
|
+
flags.print = next;
|
|
796
|
+
index += 1;
|
|
797
|
+
}
|
|
798
|
+
else if (arg.startsWith('--print=')) {
|
|
799
|
+
flags.print = arg.slice('--print='.length);
|
|
800
|
+
}
|
|
801
|
+
else if (arg === '--cwd') {
|
|
802
|
+
const next = argv[index + 1];
|
|
803
|
+
if (!next || next.startsWith('--'))
|
|
804
|
+
throw new Error('--cwd requires a path');
|
|
805
|
+
flags.cwd = next;
|
|
806
|
+
index += 1;
|
|
807
|
+
}
|
|
808
|
+
else if (arg.startsWith('--cwd=')) {
|
|
809
|
+
flags.cwd = arg.slice('--cwd='.length);
|
|
810
|
+
}
|
|
811
|
+
else if (arg === '--workspace') {
|
|
812
|
+
const next = argv[index + 1];
|
|
813
|
+
if (!next || next.startsWith('--'))
|
|
814
|
+
throw new Error('--workspace requires a slug');
|
|
815
|
+
flags.workspace = next;
|
|
816
|
+
index += 1;
|
|
817
|
+
}
|
|
818
|
+
else if (arg.startsWith('--workspace=')) {
|
|
819
|
+
flags.workspace = arg.slice('--workspace='.length);
|
|
820
|
+
}
|
|
821
|
+
else if (arg === '--session') {
|
|
822
|
+
const next = argv[index + 1];
|
|
823
|
+
if (!next || next.startsWith('--'))
|
|
824
|
+
throw new Error('--session requires an id');
|
|
825
|
+
flags.sessionId = next;
|
|
826
|
+
index += 1;
|
|
827
|
+
}
|
|
828
|
+
else if (arg.startsWith('--session=')) {
|
|
829
|
+
flags.sessionId = arg.slice('--session='.length);
|
|
830
|
+
}
|
|
831
|
+
else if (arg === '--timeout') {
|
|
832
|
+
const next = argv[index + 1];
|
|
833
|
+
if (!next || next.startsWith('--'))
|
|
834
|
+
throw new Error('--timeout requires seconds');
|
|
835
|
+
const parsed = Number(next);
|
|
836
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
837
|
+
throw new Error(`--timeout requires positive seconds, got "${next}"`);
|
|
838
|
+
}
|
|
839
|
+
flags.timeoutSeconds = parsed;
|
|
840
|
+
index += 1;
|
|
841
|
+
}
|
|
842
|
+
else if (arg.startsWith('--timeout=')) {
|
|
843
|
+
const raw = arg.slice('--timeout='.length);
|
|
844
|
+
const parsed = Number(raw);
|
|
845
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
846
|
+
throw new Error(`--timeout requires positive seconds, got "${raw}"`);
|
|
847
|
+
}
|
|
848
|
+
flags.timeoutSeconds = parsed;
|
|
849
|
+
}
|
|
850
|
+
else if (arg === '--no-tools') {
|
|
851
|
+
flags.noTools = true;
|
|
852
|
+
}
|
|
853
|
+
else if (arg === '--max-turns') {
|
|
854
|
+
const next = argv[index + 1];
|
|
855
|
+
if (!next || next.startsWith('--'))
|
|
856
|
+
throw new Error('--max-turns requires an integer');
|
|
857
|
+
const parsed = Number(next);
|
|
858
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
859
|
+
throw new Error(`--max-turns requires positive integer, got "${next}"`);
|
|
860
|
+
}
|
|
861
|
+
flags.maxTurns = parsed;
|
|
862
|
+
index += 1;
|
|
863
|
+
}
|
|
864
|
+
else if (arg.startsWith('--max-turns=')) {
|
|
865
|
+
const raw = arg.slice('--max-turns='.length);
|
|
866
|
+
const parsed = Number(raw);
|
|
867
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
868
|
+
throw new Error(`--max-turns requires positive integer, got "${raw}"`);
|
|
869
|
+
}
|
|
870
|
+
flags.maxTurns = parsed;
|
|
871
|
+
}
|
|
872
|
+
else if (arg.startsWith('--commit=')) {
|
|
873
|
+
// `pugi review --triple --commit <SHA>` activates the multi-
|
|
874
|
+
// provider routing path against a specific revision.
|
|
875
|
+
flags.commit = arg.slice('--commit='.length);
|
|
876
|
+
}
|
|
877
|
+
else if (arg === '--commit') {
|
|
878
|
+
const next = argv[index + 1];
|
|
879
|
+
if (!next)
|
|
880
|
+
throw new Error('--commit requires a SHA or ref');
|
|
881
|
+
flags.commit = next;
|
|
882
|
+
index += 1;
|
|
883
|
+
}
|
|
884
|
+
else if (arg.startsWith('--base=')) {
|
|
885
|
+
flags.base = arg.slice('--base='.length);
|
|
886
|
+
}
|
|
887
|
+
else if (arg === '--base') {
|
|
888
|
+
const next = argv[index + 1];
|
|
889
|
+
if (!next)
|
|
890
|
+
throw new Error('--base requires a ref');
|
|
891
|
+
flags.base = next;
|
|
892
|
+
index += 1;
|
|
893
|
+
}
|
|
894
|
+
else if (arg.startsWith('--mode=')) {
|
|
895
|
+
// Leak L6: top-level `--mode plan|ask|allow|bypass`. Validation
|
|
896
|
+
// happens at the consumer side (parsePermissionMode) so the
|
|
897
|
+
// parser stays string-typed; an invalid value surfaces a clean
|
|
898
|
+
// error in the dispatcher rather than blowing up here.
|
|
899
|
+
flags.mode = arg.slice('--mode='.length);
|
|
900
|
+
}
|
|
901
|
+
else if (arg === '--mode') {
|
|
902
|
+
const next = argv[index + 1];
|
|
903
|
+
if (!next || next.startsWith('--')) {
|
|
904
|
+
throw new Error('--mode requires plan|ask|allow|bypass');
|
|
905
|
+
}
|
|
906
|
+
flags.mode = next;
|
|
907
|
+
index += 1;
|
|
908
|
+
}
|
|
909
|
+
else if (arg === '--persist') {
|
|
910
|
+
// Leak L6: paired with `pugi permissions <mode>` to also write
|
|
911
|
+
// the mode to ~/.pugi/config.json::defaultPermissionMode.
|
|
912
|
+
flags.persist = true;
|
|
913
|
+
}
|
|
914
|
+
else if (arg === '--confirm') {
|
|
915
|
+
// Leak L6: required for `pugi permissions bypass` (bypass
|
|
916
|
+
// disables policy hooks; the gate refuses the flip without
|
|
917
|
+
// acknowledgement).
|
|
918
|
+
flags.confirm = true;
|
|
919
|
+
}
|
|
522
920
|
else {
|
|
523
921
|
args.push(arg);
|
|
524
922
|
}
|
|
525
923
|
}
|
|
526
924
|
const isBareInvocation = args.length === 0;
|
|
925
|
+
const command = args.shift() ?? 'help';
|
|
926
|
+
// Sprint α6.X CEO dogfood 2026-05-26 (P0 hot-fix): trailing `--help`
|
|
927
|
+
// / `-h` on ANY sub-command must route to the help printer rather
|
|
928
|
+
// than dispatching the real engine. Before this guard `pugi build
|
|
929
|
+
// --help` burned 86k tokens running the actual build loop because
|
|
930
|
+
// the dispatcher saw `--help` as an opaque arg and forwarded it
|
|
931
|
+
// through to the engine. Re-routing here means `pugi <cmd> --help`
|
|
932
|
+
// becomes `pugi help <cmd>` deterministically across the entire
|
|
933
|
+
// command tree.
|
|
934
|
+
//
|
|
935
|
+
// β1 Tt3 carve-out: commands that ship their OWN `--help` block
|
|
936
|
+
// (login, init, ...) must keep `--help` in their args so the
|
|
937
|
+
// command-local printer fires. Without this carve-out
|
|
938
|
+
// `pugi login --help` produces the global help and the per-variant
|
|
939
|
+
// reference (`--provider device|token|env`) gets lost. The carve-out
|
|
940
|
+
// list mirrors handlers whose source carries an
|
|
941
|
+
// `args.includes('--help')` short-circuit.
|
|
942
|
+
if ((args.includes('--help') || args.includes('-h')) && !COMMAND_LOCAL_HELP.has(command)) {
|
|
943
|
+
return { command: 'help', args: [command], flags, isBareInvocation: false };
|
|
944
|
+
}
|
|
527
945
|
return {
|
|
528
|
-
command
|
|
946
|
+
command,
|
|
529
947
|
args,
|
|
530
948
|
flags,
|
|
531
949
|
isBareInvocation,
|
|
532
950
|
};
|
|
533
951
|
}
|
|
952
|
+
/**
|
|
953
|
+
* β1 Tt3: commands that own their `--help` rendering. The bare-help
|
|
954
|
+
* redirect leaves their `--help` arg in place so the command-local
|
|
955
|
+
* printer fires instead of the global summary.
|
|
956
|
+
*/
|
|
957
|
+
const COMMAND_LOCAL_HELP = new Set([
|
|
958
|
+
'login',
|
|
959
|
+
]);
|
|
534
960
|
async function version(_args, flags, _session) {
|
|
535
961
|
const payload = {
|
|
536
962
|
name: 'pugi',
|
|
@@ -538,7 +964,220 @@ async function version(_args, flags, _session) {
|
|
|
538
964
|
};
|
|
539
965
|
writeOutput(flags, payload, `pugi ${payload.version}`);
|
|
540
966
|
}
|
|
541
|
-
|
|
967
|
+
/**
|
|
968
|
+
* Per-command help bodies (task #100). When the operator types
|
|
969
|
+
* `pugi <cmd> --help` the dispatcher routes here with `args = [cmd]`.
|
|
970
|
+
* If we have a focused body for that command, print it instead of the
|
|
971
|
+
* global summary. Falls back to the global summary so unknown / new
|
|
972
|
+
* commands still get a useful response.
|
|
973
|
+
*
|
|
974
|
+
* Source of truth for each entry: the comment block at the top of the
|
|
975
|
+
* command's implementation module + any flags the command declares.
|
|
976
|
+
* Keep entries short — operators want the one-liner of intent + the
|
|
977
|
+
* 2-5 most useful flags, not a tutorial. The global help still has the
|
|
978
|
+
* full per-section reference; the per-command body is the "tell me
|
|
979
|
+
* how to use this NOW" surface.
|
|
980
|
+
*/
|
|
981
|
+
const COMMAND_HELP_BODIES = {
|
|
982
|
+
init: [
|
|
983
|
+
'pugi init — bootstrap a new Pugi workspace in the current directory.',
|
|
984
|
+
'',
|
|
985
|
+
'Creates .pugi/{PUGI.md, mcp.json, index.json, artifacts/, sessions/} and',
|
|
986
|
+
'seeds the 6 default skills. Idempotent — running again only fills gaps.',
|
|
987
|
+
'',
|
|
988
|
+
'Flags:',
|
|
989
|
+
' --no-defaults Skip the bundled default-skills install.',
|
|
990
|
+
'',
|
|
991
|
+
'Env:',
|
|
992
|
+
' PUGI_INIT_NO_DEFAULTS=1 Same as --no-defaults.',
|
|
993
|
+
],
|
|
994
|
+
explain: [
|
|
995
|
+
'pugi explain "<question>" — read-only Q&A about the workspace.',
|
|
996
|
+
'',
|
|
997
|
+
'Calls the engine loop in explain mode (budget: 5 calls / 20k tokens).',
|
|
998
|
+
'No file writes; safe to run against unfamiliar code.',
|
|
999
|
+
'',
|
|
1000
|
+
'Examples:',
|
|
1001
|
+
' pugi explain "what does this package.json define?"',
|
|
1002
|
+
' pugi explain "trace the auth flow in src/auth/"',
|
|
1003
|
+
],
|
|
1004
|
+
code: [
|
|
1005
|
+
'pugi code "<brief>" — engineering-mode write loop (30k token budget).',
|
|
1006
|
+
'',
|
|
1007
|
+
'Writes files in the current workspace. Use --no-tty in CI / pipes.',
|
|
1008
|
+
],
|
|
1009
|
+
fix: [
|
|
1010
|
+
'pugi fix "<brief>" — minimal-diff bugfix loop (30k token budget).',
|
|
1011
|
+
'',
|
|
1012
|
+
'Same as `pugi code` but the prompt biases toward the smallest patch',
|
|
1013
|
+
'that closes the brief — refuses scope creep / refactor invitations.',
|
|
1014
|
+
],
|
|
1015
|
+
build: [
|
|
1016
|
+
'pugi build "<brief>" — feature-build loop (200k token budget).',
|
|
1017
|
+
'',
|
|
1018
|
+
'Multi-turn engineering with plan-review checkpoints. Pairs with',
|
|
1019
|
+
'pugi plan --decompose <idea> when the brief is bigger than one PR.',
|
|
1020
|
+
],
|
|
1021
|
+
plan: [
|
|
1022
|
+
'pugi plan --decompose <idea> — split an idea into 3-7 components.',
|
|
1023
|
+
'',
|
|
1024
|
+
'Writes .pugi/plan/<session-id>/splits/NN-<name>/spec.md plus',
|
|
1025
|
+
'manifest.md with the dependency DAG. Pass each split to `pugi build`.',
|
|
1026
|
+
],
|
|
1027
|
+
review: [
|
|
1028
|
+
'pugi review — code review surfaces.',
|
|
1029
|
+
'',
|
|
1030
|
+
' --triple 3-model consensus via Anvil paid fleet.',
|
|
1031
|
+
' --triple --commit <SHA> Review a specific commit (vs origin/main).',
|
|
1032
|
+
' --consensus Customer-facing consensus review (codex + claude + deepseek).',
|
|
1033
|
+
' Optional: --commit <sha> | --pr <num> | --branch <name>.',
|
|
1034
|
+
'',
|
|
1035
|
+
'Exit codes: 0 PASS · 1 WARN · 2 BLOCK · 5 auth_missing · 7 rate_limited.',
|
|
1036
|
+
],
|
|
1037
|
+
privacy: [
|
|
1038
|
+
'pugi privacy — privacy-mode operations.',
|
|
1039
|
+
'',
|
|
1040
|
+
' show Display effective mode + source.',
|
|
1041
|
+
' set <mode> Local-only legacy values (local-only|metadata|full).',
|
|
1042
|
+
'',
|
|
1043
|
+
'For tenant-scoped server-side modes (strict|balanced|permissive), use:',
|
|
1044
|
+
' pugi config get privacy',
|
|
1045
|
+
' pugi config set privacy=<mode>',
|
|
1046
|
+
],
|
|
1047
|
+
cost: [
|
|
1048
|
+
'pugi cost — token + USD breakdown for the current Pugi session.',
|
|
1049
|
+
'',
|
|
1050
|
+
'Reads .pugi/cost.json (persisted via the in-REPL CostTracker) and',
|
|
1051
|
+
'prints a per-model table plus dollar estimate. Alias: pugi usage.',
|
|
1052
|
+
'',
|
|
1053
|
+
'Flags:',
|
|
1054
|
+
' --all-sessions 30-day rolling aggregate across all sessions.',
|
|
1055
|
+
' --window=<days> Override the aggregate window (max 365).',
|
|
1056
|
+
' --reset --yes Clear the current-session counter. History',
|
|
1057
|
+
' is preserved. Requires --yes to confirm.',
|
|
1058
|
+
' --json Emit a structured JSON envelope only.',
|
|
1059
|
+
'',
|
|
1060
|
+
'Examples:',
|
|
1061
|
+
' pugi cost Current session totals.',
|
|
1062
|
+
' pugi cost --all-sessions Past 30 days aggregated.',
|
|
1063
|
+
' pugi cost --all-sessions --window=7',
|
|
1064
|
+
' pugi cost --reset --yes Wipe the session counter.',
|
|
1065
|
+
' pugi usage Alias for pugi cost.',
|
|
1066
|
+
],
|
|
1067
|
+
config: [
|
|
1068
|
+
'pugi config — read / write CLI + tenant configuration.',
|
|
1069
|
+
'',
|
|
1070
|
+
' get <key> Local config value.',
|
|
1071
|
+
' get privacy Tenant privacy snapshot (admin-api).',
|
|
1072
|
+
' get routing Effective routing table.',
|
|
1073
|
+
' set <key>=<value> Local config write.',
|
|
1074
|
+
' set privacy=<mode> Flip tenant privacy (strict|balanced|permissive).',
|
|
1075
|
+
' set routing.<tag>.<budget>=<model> Override one routing lane.',
|
|
1076
|
+
' unset routing.<tag>.<budget> Revert a routing override.',
|
|
1077
|
+
' mcp trust|deny|list <name> MCP server trust + visibility.',
|
|
1078
|
+
],
|
|
1079
|
+
sync: [
|
|
1080
|
+
'pugi sync — explicit-continuation handoff bundle upload.',
|
|
1081
|
+
'',
|
|
1082
|
+
' --dry-run Print the bundle plan without uploading.',
|
|
1083
|
+
' --privacy <mode> Override per-bundle privacy posture.',
|
|
1084
|
+
],
|
|
1085
|
+
whoami: [
|
|
1086
|
+
'pugi whoami — show the active credential + JWT principal + plan tier.',
|
|
1087
|
+
'',
|
|
1088
|
+
'Reads from ~/.pugi/credentials.json. No network call unless --remote.',
|
|
1089
|
+
],
|
|
1090
|
+
login: [
|
|
1091
|
+
'pugi login — authenticate against an api.pugi.io endpoint.',
|
|
1092
|
+
'',
|
|
1093
|
+
'Interactive picker by default (browser OAuth / PAT / env). Non-interactive:',
|
|
1094
|
+
' --provider device Device-flow OAuth.',
|
|
1095
|
+
' --provider token --token <jwt> Pass a JWT directly.',
|
|
1096
|
+
' --provider env --env PUGI_API_KEY Read from an env var.',
|
|
1097
|
+
],
|
|
1098
|
+
accounts: [
|
|
1099
|
+
'pugi accounts — manage stored credentials across endpoints.',
|
|
1100
|
+
'',
|
|
1101
|
+
' list Every account + its endpoint + active flag.',
|
|
1102
|
+
' switch <label> Re-point the active account.',
|
|
1103
|
+
' remove <label> Delete a stored credential.',
|
|
1104
|
+
],
|
|
1105
|
+
jobs: [
|
|
1106
|
+
'pugi jobs — list, tail, or kill background dispatch jobs.',
|
|
1107
|
+
'',
|
|
1108
|
+
' list All jobs in the registry.',
|
|
1109
|
+
' tail <id> Stream output from one job.',
|
|
1110
|
+
' kill <id> Cancel a running job.',
|
|
1111
|
+
],
|
|
1112
|
+
delegate: [
|
|
1113
|
+
'pugi delegate <slug> "<brief>" — dispatch a brief to one specialist persona.',
|
|
1114
|
+
'',
|
|
1115
|
+
'Slugs (Tier 1 alpha 7.5): dev qa pm devops researcher analyst designer',
|
|
1116
|
+
'frontend architect. `pugi roster` lists the live set.',
|
|
1117
|
+
],
|
|
1118
|
+
roster: [
|
|
1119
|
+
'pugi roster — list the live Tier 1 personas + roles.',
|
|
1120
|
+
],
|
|
1121
|
+
doctor: [
|
|
1122
|
+
'pugi doctor — diagnose CLI + workspace + adapter capabilities.',
|
|
1123
|
+
'',
|
|
1124
|
+
'Prints CLI version, Node version, workspace state (.pugi presence,',
|
|
1125
|
+
'event log, settings), permission mode, and the capability matrix per',
|
|
1126
|
+
'engine adapter. Safe to run anywhere; no network calls.',
|
|
1127
|
+
],
|
|
1128
|
+
status: [
|
|
1129
|
+
'pugi status — concise session snapshot.',
|
|
1130
|
+
'',
|
|
1131
|
+
'Different from `pugi doctor` (environment health). Status answers',
|
|
1132
|
+
'"what is this Pugi session doing right now?" — session id + age,',
|
|
1133
|
+
'cwd, permission mode, CLI version, token usage, active + completed',
|
|
1134
|
+
'dispatches, last command, compact boundary count, auth identity.',
|
|
1135
|
+
'',
|
|
1136
|
+
' --json Emit a structured envelope to stdout.',
|
|
1137
|
+
'',
|
|
1138
|
+
'Live REPL state (tokens, last command) is only available via the',
|
|
1139
|
+
'in-REPL `/status` slash; the shell path degrades those fields к',
|
|
1140
|
+
'"n/a" and exits 0.',
|
|
1141
|
+
],
|
|
1142
|
+
report: [
|
|
1143
|
+
'pugi report — capture a bug report from the most-recent session.',
|
|
1144
|
+
'',
|
|
1145
|
+
' --from-error Bundle the most-recent failed session as a',
|
|
1146
|
+
' redacted local report (default + only mode in v1).',
|
|
1147
|
+
'',
|
|
1148
|
+
'Output: writes .pugi/reports/<timestamp>-<session-id>/{report.json, report.md}.',
|
|
1149
|
+
'Secrets (bearer tokens, JWTs, named env values) are stripped before disk write.',
|
|
1150
|
+
'Auto-upload to api.pugi.io planned for a follow-up; v1 keeps everything local.',
|
|
1151
|
+
],
|
|
1152
|
+
ask: [
|
|
1153
|
+
'pugi ask "<question>" — surface a yes/no question modal locally.',
|
|
1154
|
+
'',
|
|
1155
|
+
'Useful in shell scripts that need a human-confirm before a destructive',
|
|
1156
|
+
'step. Exits 0 on yes, 1 on no, 2 on cancel.',
|
|
1157
|
+
],
|
|
1158
|
+
deploy: [
|
|
1159
|
+
'pugi deploy — trigger a vendor deployment from the bound Git source.',
|
|
1160
|
+
'',
|
|
1161
|
+
' --target vercel <vercelProject> --project <id> Vercel deploy.',
|
|
1162
|
+
' --target render <renderService> --project <id> Render deploy (Sprint 2 stub).',
|
|
1163
|
+
' --status <id> Vendor-agnostic status snapshot.',
|
|
1164
|
+
' --logs <id> [--tail] Build-log tail.',
|
|
1165
|
+
'',
|
|
1166
|
+
'Optional: --target-env production|preview, --ref <ref>, --integration <id>.',
|
|
1167
|
+
],
|
|
1168
|
+
};
|
|
1169
|
+
async function help(args, flags, _session) {
|
|
1170
|
+
// 2026-05-27 task #100: per-command help bodies. When dispatcher
|
|
1171
|
+
// routed `pugi <cmd> --help` here it passes `args = [cmd]`; if we
|
|
1172
|
+
// have a focused body, print that. Falls through to the global
|
|
1173
|
+
// summary on unknown / new commands so the dispatcher's redirect
|
|
1174
|
+
// never produces a worse-than-baseline response.
|
|
1175
|
+
const requested = args[0];
|
|
1176
|
+
if (requested && COMMAND_HELP_BODIES[requested]) {
|
|
1177
|
+
const body = COMMAND_HELP_BODIES[requested];
|
|
1178
|
+
writeOutput(flags, { command: requested, lines: body }, body.join('\n'));
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
542
1181
|
const commands = Object.keys(handlers).sort();
|
|
543
1182
|
writeOutput(flags, { commands }, [
|
|
544
1183
|
'Pugi CLI',
|
|
@@ -558,6 +1197,9 @@ async function help(_args, flags, _session) {
|
|
|
558
1197
|
'',
|
|
559
1198
|
'Review gate:',
|
|
560
1199
|
' pugi review --triple Prepare the Anvil-backed triple-review gate.',
|
|
1200
|
+
' pugi review --triple --commit <SHA>',
|
|
1201
|
+
' 3-model consensus via Anvil (Anthropic · OpenAI · Google).',
|
|
1202
|
+
' Optional: --base <ref> | "<prompt>". Quota: 1 slot per call.',
|
|
561
1203
|
' pugi review --consensus 3-model consensus review (codex · claude · deepseek).',
|
|
562
1204
|
' Optional: --commit <sha> | --pr <num> | --branch <name>.',
|
|
563
1205
|
' Exits 0 PASS · 1 WARN · 2 BLOCK.',
|
|
@@ -573,6 +1215,15 @@ async function help(_args, flags, _session) {
|
|
|
573
1215
|
' pugi ask "<question>" Surface a yes/no question modal locally.',
|
|
574
1216
|
' pugi plan-review <task> Generate + present a plan-review modal.',
|
|
575
1217
|
'',
|
|
1218
|
+
'Persona dispatch (α7.5):',
|
|
1219
|
+
' pugi roster List the live Tier 1 personas + roles.',
|
|
1220
|
+
' pugi delegate <slug> "<brief>" Dispatch a brief to one specialist.',
|
|
1221
|
+
'',
|
|
1222
|
+
'Plan decomposition (α6.8):',
|
|
1223
|
+
' pugi plan --decompose <idea> Split a high-level idea into 3-7 components.',
|
|
1224
|
+
' Writes .pugi/plan/<session-id>/splits/NN-<name>/spec.md',
|
|
1225
|
+
' plus manifest.md with the dependency DAG.',
|
|
1226
|
+
'',
|
|
576
1227
|
'Deploy:',
|
|
577
1228
|
' pugi deploy --target vercel <vercelProject> --project <id>',
|
|
578
1229
|
' Trigger a Vercel deployment from the bound Git source.',
|
|
@@ -595,75 +1246,83 @@ async function help(_args, flags, _session) {
|
|
|
595
1246
|
' PUGI_SKIP_SPLASH=1.',
|
|
596
1247
|
' --no-tool-stream Hide the live tool stream pane (α6.12).',
|
|
597
1248
|
' Pairs with PUGI_HIDE_TOOL_STREAM=1.',
|
|
1249
|
+
' --no-defaults Skip bundled default-skills install on',
|
|
1250
|
+
' `pugi init`. Pairs with PUGI_INIT_NO_DEFAULTS=1.',
|
|
598
1251
|
'',
|
|
599
1252
|
PUGI_TAGLINE,
|
|
600
1253
|
'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
|
|
601
1254
|
].join('\n'));
|
|
602
1255
|
}
|
|
1256
|
+
/**
|
|
1257
|
+
* `pugi doctor` — Leak L17 (2026-05-27). Delegates to the diagnostics
|
|
1258
|
+
* probe runner in `runtime/commands/doctor.ts`. The handler stays
|
|
1259
|
+
* thin so the probe surface stays single-sourced between the CLI
|
|
1260
|
+
* shell command, the `pnpm run doctor --json` package script, and
|
|
1261
|
+
* the in-REPL `/doctor` slash command.
|
|
1262
|
+
*
|
|
1263
|
+
* Exit codes are set by `runDoctorCommand` (0 = healthy/warnings,
|
|
1264
|
+
* 2 = at least one error probe). The pre-L17 minimal doctor surface
|
|
1265
|
+
* (adapter capabilities + schema bundle hash) is preserved under
|
|
1266
|
+
* `payload.meta.legacy` so any operator scripts that grep the JSON
|
|
1267
|
+
* keep working through the transition; the field is marked for
|
|
1268
|
+
* removal in a follow-up sprint once the new shape is the
|
|
1269
|
+
* documented contract.
|
|
1270
|
+
*/
|
|
603
1271
|
async function doctor(_args, flags, _session) {
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
return {
|
|
612
|
-
stop: 'error',
|
|
613
|
-
code: 'failed',
|
|
614
|
-
message: 'doctor: inert client',
|
|
615
|
-
};
|
|
616
|
-
},
|
|
617
|
-
};
|
|
618
|
-
const adapters = [
|
|
619
|
-
new NoopEngineAdapter(),
|
|
620
|
-
new NativePugiEngineAdapter({ client: inertClient }),
|
|
621
|
-
];
|
|
622
|
-
const capabilities = await Promise.all(adapters.map(async (adapter) => ({
|
|
623
|
-
name: adapter.name,
|
|
624
|
-
capabilities: await adapter.capabilities(),
|
|
625
|
-
})));
|
|
626
|
-
const payload = {
|
|
627
|
-
cliVersion: PUGI_CLI_VERSION,
|
|
628
|
-
nodeVersion: process.version,
|
|
629
|
-
workspaceRoot: cwd,
|
|
630
|
-
pugiMode: existsSync(resolve(cwd, 'CLAUDE.md')),
|
|
631
|
-
pugiDir: existsSync(resolve(cwd, '.pugi')),
|
|
632
|
-
eventLog: existsSync(resolve(cwd, '.pugi/events.jsonl')),
|
|
633
|
-
permissionMode: settings.permissions.mode,
|
|
634
|
-
approvals: settings.workflow.approvals,
|
|
635
|
-
notAutomatic: [...settings.workflow.notAutomatic, ...settings.permissions.notAutomatic],
|
|
636
|
-
protectedFileCheck: decidePermission({ tool: 'doctor', kind: 'edit', target: '.env' }, settings, cwd),
|
|
637
|
-
protectedFileSafety: 'configured-in-m1',
|
|
638
|
-
mcpTrust: 'not-configured',
|
|
639
|
-
releaseGuard: 'scaffolded',
|
|
640
|
-
tools: toolRegistry,
|
|
641
|
-
engineAdapters: capabilities,
|
|
642
|
-
schemaBundleHash: createHash('sha256')
|
|
643
|
-
.update(toolSchemaBundleHashInput())
|
|
644
|
-
.digest('hex'),
|
|
645
|
-
};
|
|
646
|
-
writeOutput(flags, payload, [
|
|
647
|
-
'Pugi doctor',
|
|
648
|
-
`CLI: ${payload.cliVersion}`,
|
|
649
|
-
`Node: ${payload.nodeVersion}`,
|
|
650
|
-
`Workspace: ${payload.workspaceRoot}`,
|
|
651
|
-
`Pugi mode: ${payload.pugiMode ? 'detected' : 'not detected'}`,
|
|
652
|
-
`Pugi dir: ${payload.pugiDir ? 'present' : 'missing'}`,
|
|
653
|
-
`Event log: ${payload.eventLog ? 'present' : 'missing'}`,
|
|
654
|
-
`Permission mode: ${payload.permissionMode}`,
|
|
655
|
-
`Approvals: ${payload.approvals}`,
|
|
656
|
-
`Release guard: ${payload.releaseGuard}`,
|
|
657
|
-
].join('\n'));
|
|
1272
|
+
await runDoctorCommand({
|
|
1273
|
+
cwd: process.cwd(),
|
|
1274
|
+
home: defaultDoctorHome(),
|
|
1275
|
+
env: process.env,
|
|
1276
|
+
json: flags.json,
|
|
1277
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
1278
|
+
});
|
|
658
1279
|
}
|
|
659
|
-
|
|
660
|
-
|
|
1280
|
+
/**
|
|
1281
|
+
* `pugi status` — Leak L34 (2026-05-27). Concise session-state probe
|
|
1282
|
+
* mirroring Claude Code's `/status`. Distinct from `pugi doctor`
|
|
1283
|
+
* (environment health) — `status` answers "what is THIS Pugi
|
|
1284
|
+
* session doing right now?" with session id + age, cwd, permission
|
|
1285
|
+
* mode, CLI version, token usage, dispatch count, last command,
|
|
1286
|
+
* compact boundaries, and auth identity.
|
|
1287
|
+
*
|
|
1288
|
+
* The top-level shell invocation has no live REPL state — fields
|
|
1289
|
+
* that need a live session (`tokens`, `lastCommand`) degrade к the
|
|
1290
|
+
* `n/a` sentinel. The same handler powers the in-REPL `/status`
|
|
1291
|
+
* slash, which passes live state through `StatusCommandContext`.
|
|
1292
|
+
*
|
|
1293
|
+
* Always exits 0 — the command is informational, never a gate.
|
|
1294
|
+
*/
|
|
1295
|
+
async function status(_args, flags, _session) {
|
|
1296
|
+
await runStatusCommand({
|
|
1297
|
+
cwd: process.cwd(),
|
|
1298
|
+
home: defaultStatusHome(),
|
|
1299
|
+
env: process.env,
|
|
1300
|
+
json: flags.json,
|
|
1301
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Programmatic init scaffolder. Idempotent — every helper call is a
|
|
1306
|
+
* `*_IfMissing` write, so re-running over an existing .pugi/ workspace
|
|
1307
|
+
* adds nothing to `created` and the operator sees the "Already
|
|
1308
|
+
* initialized" copy. Default skills install is best-effort: failure
|
|
1309
|
+
* does not throw, the error is appended to the result via stderr so
|
|
1310
|
+
* the slash dispatcher can surface it in the REPL system pane.
|
|
1311
|
+
*
|
|
1312
|
+
* Callers MUST provide `cwd` explicitly; the function does not read
|
|
1313
|
+
* `process.cwd()` so REPL invocations from an arbitrary workspace
|
|
1314
|
+
* cannot accidentally scaffold the binary's install directory.
|
|
1315
|
+
*/
|
|
1316
|
+
export async function scaffoldPugiWorkspace(input) {
|
|
1317
|
+
const cwd = input.cwd;
|
|
1318
|
+
const log = input.log ?? ((line) => process.stderr.write(line));
|
|
661
1319
|
const pugiDir = resolve(cwd, '.pugi');
|
|
662
1320
|
const created = [];
|
|
663
1321
|
const skipped = [];
|
|
664
1322
|
ensureDir(pugiDir, created, skipped);
|
|
665
1323
|
ensureDir(resolve(pugiDir, 'artifacts'), created, skipped);
|
|
666
1324
|
ensureDir(resolve(pugiDir, 'sessions'), created, skipped);
|
|
1325
|
+
ensureDir(resolve(pugiDir, 'skills'), created, skipped);
|
|
667
1326
|
writeJsonIfMissing(resolve(pugiDir, 'settings.json'), {
|
|
668
1327
|
schema: 1,
|
|
669
1328
|
workflow: {
|
|
@@ -685,6 +1344,9 @@ async function init(_args, flags, _session) {
|
|
|
685
1344
|
mode: 'balanced',
|
|
686
1345
|
telemetry: 'off',
|
|
687
1346
|
},
|
|
1347
|
+
ui: {
|
|
1348
|
+
cyberZoo: 'on',
|
|
1349
|
+
},
|
|
688
1350
|
artifacts: {
|
|
689
1351
|
defaultPath: '.pugi/artifacts',
|
|
690
1352
|
promoteExplicitly: true,
|
|
@@ -692,7 +1354,19 @@ async function init(_args, flags, _session) {
|
|
|
692
1354
|
}, created, skipped);
|
|
693
1355
|
writeJsonIfMissing(resolve(pugiDir, 'mcp.json'), {
|
|
694
1356
|
schema: 1,
|
|
695
|
-
|
|
1357
|
+
// 2026-05-27 dogfood: `servers` MUST be an object keyed by server
|
|
1358
|
+
// name (z.record(mcpServerConfigSchema) in
|
|
1359
|
+
// apps/pugi-cli/src/core/mcp/registry.ts:51). A bare `[]` array
|
|
1360
|
+
// here passed schema validation на pugi init exit но crashed
|
|
1361
|
+
// the next dispatch with
|
|
1362
|
+
// "MCP config at .pugi/mcp.json failed validation:
|
|
1363
|
+
// servers: Expected object, received array"
|
|
1364
|
+
// and the operator's first command after `pugi init` printed an
|
|
1365
|
+
// error banner before the actual reply. Empty object matches the
|
|
1366
|
+
// schema default and keeps the file forwards-compatible with
|
|
1367
|
+
// `pugi mcp install <name> ...` which merges into the same
|
|
1368
|
+
// record shape.
|
|
1369
|
+
servers: {},
|
|
696
1370
|
}, created, skipped);
|
|
697
1371
|
writeJsonIfMissing(resolve(pugiDir, 'index.json'), emptyIndex(), created, skipped);
|
|
698
1372
|
writeTextIfMissing(resolve(pugiDir, 'PUGI.md'), [
|
|
@@ -733,17 +1407,67 @@ async function init(_args, flags, _session) {
|
|
|
733
1407
|
// Ensure `.pugi/` is git-ignored so users do not accidentally commit
|
|
734
1408
|
// local audit logs, artifacts, or triple-review request payloads.
|
|
735
1409
|
ensurePugiGitIgnore(cwd, created, skipped);
|
|
736
|
-
|
|
1410
|
+
// Bundled default skills (brand-voice, endpoint-probe, readme-sync).
|
|
1411
|
+
// Skipped when --no-defaults is passed OR when PUGI_INIT_NO_DEFAULTS=1.
|
|
1412
|
+
// Idempotent: a skill whose target directory already exists is left
|
|
1413
|
+
// alone so re-running `pugi init` after the operator customised one of
|
|
1414
|
+
// the defaults does not clobber their edits.
|
|
1415
|
+
let defaultSkills = [];
|
|
1416
|
+
if (!input.noDefaults) {
|
|
1417
|
+
try {
|
|
1418
|
+
defaultSkills = await installDefaultSkills({
|
|
1419
|
+
workspaceRoot: cwd,
|
|
1420
|
+
log,
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
catch (error) {
|
|
1424
|
+
// Default-skills install is a convenience layer. A failure here
|
|
1425
|
+
// (bad sha256 hashing, permission error on .pugi/skills/) must not
|
|
1426
|
+
// leave `pugi init` in a half-state where settings.json exists but
|
|
1427
|
+
// the operator sees an unexplained crash. Log the error to stderr
|
|
1428
|
+
// and continue — the operator can still install skills manually.
|
|
1429
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1430
|
+
log(`[pugi init] default-skills install failed: ${message}\n`);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
return {
|
|
737
1434
|
status: 'initialized',
|
|
738
1435
|
root: cwd,
|
|
739
1436
|
created,
|
|
740
1437
|
skipped,
|
|
1438
|
+
defaultSkills,
|
|
1439
|
+
alreadyInitialized: created.length === 0,
|
|
741
1440
|
};
|
|
742
|
-
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Standalone `pugi init` CLI entry. Thin wrapper around
|
|
1444
|
+
* `scaffoldPugiWorkspace` that handles flag plumbing + writeOutput
|
|
1445
|
+
* formatting. β1a r1: extracted from the previous inline init so the
|
|
1446
|
+
* REPL's `/init` slash can call `scaffoldPugiWorkspace` directly.
|
|
1447
|
+
*/
|
|
1448
|
+
async function init(_args, flags, _session) {
|
|
1449
|
+
const result = await scaffoldPugiWorkspace({
|
|
1450
|
+
cwd: process.cwd(),
|
|
1451
|
+
noDefaults: flags.noDefaults,
|
|
1452
|
+
});
|
|
1453
|
+
const defaultSkillLines = flags.noDefaults
|
|
1454
|
+
? ['Default skills: skipped (--no-defaults)']
|
|
1455
|
+
: result.defaultSkills.length === 0
|
|
1456
|
+
? ['Default skills: none installed']
|
|
1457
|
+
: [
|
|
1458
|
+
'Default skills:',
|
|
1459
|
+
...result.defaultSkills.map((entry) => ` ${entry.name.padEnd(18)} ${entry.status}`),
|
|
1460
|
+
];
|
|
1461
|
+
writeOutput(flags, result, [
|
|
743
1462
|
'Pugi initialized',
|
|
744
|
-
`Root: ${
|
|
745
|
-
created.length
|
|
746
|
-
|
|
1463
|
+
`Root: ${result.root}`,
|
|
1464
|
+
result.created.length
|
|
1465
|
+
? `Created:\n${result.created.map((path) => ` ${path}`).join('\n')}`
|
|
1466
|
+
: 'Created: none',
|
|
1467
|
+
result.skipped.length
|
|
1468
|
+
? `Already present:\n${result.skipped.map((path) => ` ${path}`).join('\n')}`
|
|
1469
|
+
: 'Already present: none',
|
|
1470
|
+
...defaultSkillLines,
|
|
747
1471
|
].join('\n'));
|
|
748
1472
|
}
|
|
749
1473
|
async function idea(args, flags, session) {
|
|
@@ -1064,10 +1788,20 @@ async function review(args, flags, session) {
|
|
|
1064
1788
|
// streaming UX and rubric-driven exit codes don't disturb the existing
|
|
1065
1789
|
// pugi-cli surfaces that depend on the old shape.
|
|
1066
1790
|
if (flags.consensus) {
|
|
1791
|
+
// 2026-05-27 (Codex r0 P1 on PR #489): pass the globally-parsed
|
|
1792
|
+
// --commit / --base flags to consensus so `pugi review --consensus
|
|
1793
|
+
// --commit X` reviews the requested SHA instead of silently falling
|
|
1794
|
+
// back to the working-tree diff. parseConsensusArgs gives the inline
|
|
1795
|
+
// args (`--commit Y` after the command name) precedence; the
|
|
1796
|
+
// fallback only fires when `args` does not carry the token.
|
|
1067
1797
|
const exitCode = await runReviewConsensus(args, {
|
|
1068
1798
|
cwd: root,
|
|
1069
1799
|
config: resolveRuntimeConfig(),
|
|
1070
1800
|
json: flags.json,
|
|
1801
|
+
flagsFallback: {
|
|
1802
|
+
...(flags.commit ? { commit: flags.commit } : {}),
|
|
1803
|
+
...(flags.base ? { base: flags.base } : {}),
|
|
1804
|
+
},
|
|
1071
1805
|
emit: (line) => {
|
|
1072
1806
|
if (!flags.json)
|
|
1073
1807
|
process.stdout.write(line);
|
|
@@ -1079,6 +1813,15 @@ async function review(args, flags, session) {
|
|
|
1079
1813
|
process.exitCode = exitCode;
|
|
1080
1814
|
return;
|
|
1081
1815
|
}
|
|
1816
|
+
if (flags.triple && flags.commit) {
|
|
1817
|
+
// CEO directive 2026-05-27: `pugi review --triple --commit <SHA>`
|
|
1818
|
+
// dispatches to the customer-facing 3-model consensus path through
|
|
1819
|
+
// Anvil's already-paid Anthropic / OpenAI / Google routes. Replaces
|
|
1820
|
+
// the dev-only Codex/Claude/Gemini OAuth CLIs the `/triple-review`
|
|
1821
|
+
// skill uses.
|
|
1822
|
+
await performTripleProviderReview(root, session, flags, prompt);
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1082
1825
|
if (flags.triple && flags.remote) {
|
|
1083
1826
|
await performRemoteTripleReview(root, session, flags, prompt);
|
|
1084
1827
|
return;
|
|
@@ -1516,6 +2259,307 @@ async function performRemoteTripleReview(root, session, flags, prompt) {
|
|
|
1516
2259
|
.join('\n'));
|
|
1517
2260
|
process.exitCode = outcome.exitCode;
|
|
1518
2261
|
}
|
|
2262
|
+
/**
|
|
2263
|
+
* `pugi review --triple --commit <SHA>` — customer-facing 3-model
|
|
2264
|
+
* consensus review via Anvil multi-provider routing.
|
|
2265
|
+
*
|
|
2266
|
+
* Dispatches the same diff to Anthropic / OpenAI / Google models
|
|
2267
|
+
* (routed through Anvil's already-paid fleet, NOT OAuth-bound dev
|
|
2268
|
+
* CLIs) and renders the per-reviewer verdict + cross-model
|
|
2269
|
+
* disagreement summary at the end. Quota: one `reviewPerMonth` slot
|
|
2270
|
+
* per call regardless of provider count — the controller-level
|
|
2271
|
+
* `@QuotaGated('reviewPerMonth')` decorator enforces single-slot
|
|
2272
|
+
* debit (see apps/admin-api/src/pugi/pugi.controller.ts).
|
|
2273
|
+
*
|
|
2274
|
+
* CEO directive 2026-05-27: replaces the dev-only `/triple-review`
|
|
2275
|
+
* skill's Codex/Claude/Gemini OAuth dependency with a customer-
|
|
2276
|
+
* runnable Pugi product surface. Dogfood loop: Pugi reviews Pugi PRs.
|
|
2277
|
+
*/
|
|
2278
|
+
async function performTripleProviderReview(root, session, flags, prompt) {
|
|
2279
|
+
const config = resolveRuntimeConfig();
|
|
2280
|
+
const artifactDir = createArtifactDir(root, prompt || 'triple-providers');
|
|
2281
|
+
const requestPath = resolve(artifactDir, 'triple-review-request.json');
|
|
2282
|
+
const resultPath = resolve(artifactDir, 'triple-review-result.json');
|
|
2283
|
+
const summaryPath = resolve(artifactDir, 'triple-review.md');
|
|
2284
|
+
const toolCallId = recordToolCall(session, 'review:triple-providers', prompt || `review ${flags.commit ?? 'HEAD'} via providers`);
|
|
2285
|
+
// Resolve base ref. CLI flag wins over settings → so an operator
|
|
2286
|
+
// can target a specific integration branch without editing settings.
|
|
2287
|
+
const settings = loadSettings(root);
|
|
2288
|
+
const baseRef = flags.base ?? resolveBaseRef(root, settings) ?? 'origin/main';
|
|
2289
|
+
// Normalise both the commit and the base to short SHAs so the audit
|
|
2290
|
+
// log stores a stable reference even if branches move.
|
|
2291
|
+
const commitRef = flags.commit ?? 'HEAD';
|
|
2292
|
+
// 2026-05-27 (Codex r0 P2 on PR #489): safeGit returns '' on a bad ref
|
|
2293
|
+
// (it swallows the git exit code so callers don't have to wrap every
|
|
2294
|
+
// probe). Without an explicit refusal, a misspelled --commit or --base
|
|
2295
|
+
// produced an EMPTY diff that the gate then PASSED — operators saw a
|
|
2296
|
+
// green review for changes that were never reviewed. Resolve both refs
|
|
2297
|
+
// through `rev-parse --verify` first; an empty result is a hard error.
|
|
2298
|
+
const verifiedCommit = safeGit(root, ['rev-parse', '--verify', commitRef]).trim();
|
|
2299
|
+
if (!verifiedCommit) {
|
|
2300
|
+
throw new Error(`pugi review --triple: cannot resolve --commit '${commitRef}' — ` +
|
|
2301
|
+
`check the SHA or branch name. ` +
|
|
2302
|
+
`Refusing to submit an empty diff for review.`);
|
|
2303
|
+
}
|
|
2304
|
+
const verifiedBase = safeGit(root, ['rev-parse', '--verify', baseRef]).trim();
|
|
2305
|
+
if (!verifiedBase) {
|
|
2306
|
+
throw new Error(`pugi review --triple: cannot resolve --base '${baseRef}' — ` +
|
|
2307
|
+
`check the ref or set base via 'pugi config set review.base=<ref>'. ` +
|
|
2308
|
+
`Refusing to submit an empty diff for review.`);
|
|
2309
|
+
}
|
|
2310
|
+
const resolvedCommit = safeGit(root, ['rev-parse', '--short', commitRef]).trim() || commitRef;
|
|
2311
|
+
// merge-base is intentionally a PROBE: an empty result is a valid
|
|
2312
|
+
// signal (orphan branch, shallow clone, moved tag) that the dispatch
|
|
2313
|
+
// path handles by falling back к range-notation. Use the legacy
|
|
2314
|
+
// `safeGit` (probe semantics) explicitly rather than the strict
|
|
2315
|
+
// variant.
|
|
2316
|
+
const mergeBase = safeGitProbe(root, ['merge-base', baseRef, commitRef]).trim() || '';
|
|
2317
|
+
// 2026-05-27 (Claude review followup #489): when merge-base returns empty
|
|
2318
|
+
// (orphan branch, shallow clone, moved tag), we MUST NOT pass the
|
|
2319
|
+
// `<range> <commitRef>` two-arg form to `git diff` — that combo is
|
|
2320
|
+
// invalid syntax, git exits 129, `safeGit` swallows the error, and the
|
|
2321
|
+
// diff payload ships empty. An empty diff is then classified as
|
|
2322
|
+
// `'code'` server-side, dispatched to reviewers who emit a trivial
|
|
2323
|
+
// `VERDICT: PASS` over zero lines — a SILENT GREEN REVIEW on a commit
|
|
2324
|
+
// nobody actually examined. Branch on `mergeBase` так что:
|
|
2325
|
+
// - mergeBase present → `git diff <mergeBase> <commitRef> --`
|
|
2326
|
+
// (both endpoints explicit, only-uncommitted-against-base ignored
|
|
2327
|
+
// because commitRef is a SHA, not HEAD).
|
|
2328
|
+
// - mergeBase empty → `git diff <baseRef>..<commitRef> --`
|
|
2329
|
+
// (range form encodes both endpoints; do NOT append commitRef
|
|
2330
|
+
// again or git rejects the args).
|
|
2331
|
+
const diffRange = mergeBase || `${baseRef}..${commitRef}`;
|
|
2332
|
+
const diffArgs = mergeBase
|
|
2333
|
+
? ['diff', mergeBase, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES]
|
|
2334
|
+
: ['diff', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
|
|
2335
|
+
const diffStatArgs = mergeBase
|
|
2336
|
+
? ['diff', '--shortstat', mergeBase, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES]
|
|
2337
|
+
: ['diff', '--shortstat', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
|
|
2338
|
+
// Use the strict variant — a non-empty diffPatch is load-bearing for
|
|
2339
|
+
// the review gate. If git fails for ANY reason (bad ref, ENOBUFS, FS
|
|
2340
|
+
// permission), we'd rather surface a hard error than ship a green
|
|
2341
|
+
// review on nothing. The `--shortstat` companion uses the same
|
|
2342
|
+
// helper so the throw is symmetric.
|
|
2343
|
+
const diffPatch = safeGitRequired(root, diffArgs, 'triple-providers diff');
|
|
2344
|
+
const diffStats = parseDiffStats(safeGitRequired(root, diffStatArgs, 'triple-providers diff --shortstat'));
|
|
2345
|
+
if (diffPatch.trim() === '') {
|
|
2346
|
+
throw new Error(`pugi review --triple: empty diff between '${baseRef}' and '${commitRef}'. ` +
|
|
2347
|
+
`Refusing to dispatch a review for zero changes — check the refs ` +
|
|
2348
|
+
`or commit your changes before running.`);
|
|
2349
|
+
}
|
|
2350
|
+
const requestBody = pugiTripleReviewRequestSchema.parse({
|
|
2351
|
+
schema: 1,
|
|
2352
|
+
workspace: {
|
|
2353
|
+
rootName: root.split('/').at(-1) ?? 'workspace',
|
|
2354
|
+
gitBranch: safeGit(root, ['branch', '--show-current']).trim() || null,
|
|
2355
|
+
gitHead: resolvedCommit || null,
|
|
2356
|
+
baseRef,
|
|
2357
|
+
dirty: Boolean(safeGit(root, ['status', '--short']).trim()),
|
|
2358
|
+
},
|
|
2359
|
+
diffPatch,
|
|
2360
|
+
diffStats,
|
|
2361
|
+
prompt: prompt || undefined,
|
|
2362
|
+
locale: 'en-US',
|
|
2363
|
+
reviewerPersona: 'oes-dev',
|
|
2364
|
+
commit: resolvedCommit,
|
|
2365
|
+
modelProviders: ['claude', 'gpt', 'gemini'],
|
|
2366
|
+
});
|
|
2367
|
+
writeFileSync(requestPath, `${JSON.stringify(requestBody, null, 2)}\n`, {
|
|
2368
|
+
encoding: 'utf8',
|
|
2369
|
+
mode: 0o600,
|
|
2370
|
+
});
|
|
2371
|
+
registerArtifact(root, {
|
|
2372
|
+
id: artifactIdFromDir(artifactDir),
|
|
2373
|
+
kind: 'triple-review',
|
|
2374
|
+
path: relative(root, artifactDir),
|
|
2375
|
+
sessionId: session.id,
|
|
2376
|
+
createdAt: new Date().toISOString(),
|
|
2377
|
+
files: ['triple-review-request.json'],
|
|
2378
|
+
});
|
|
2379
|
+
if (!config) {
|
|
2380
|
+
const reason = 'No active Pugi credentials. Run `pugi login --token <PAT>` or set PUGI_API_KEY for CI use.';
|
|
2381
|
+
recordToolResult(session, toolCallId, 'error', reason);
|
|
2382
|
+
writeFileSync(summaryPath, buildTripleReviewMarkdown({
|
|
2383
|
+
prompt,
|
|
2384
|
+
requestPath: relative(root, requestPath),
|
|
2385
|
+
verdict: null,
|
|
2386
|
+
reason,
|
|
2387
|
+
response: null,
|
|
2388
|
+
}), { encoding: 'utf8', mode: 0o600 });
|
|
2389
|
+
writeOutput(flags, {
|
|
2390
|
+
status: 'auth_missing',
|
|
2391
|
+
request: relative(root, requestPath),
|
|
2392
|
+
summary: relative(root, summaryPath),
|
|
2393
|
+
}, [
|
|
2394
|
+
'Pugi triple-provider review request prepared but not sent — no active credentials.',
|
|
2395
|
+
`Request: ${relative(root, requestPath)}`,
|
|
2396
|
+
`Run \`pugi login --token <PAT>\` (or export PUGI_API_KEY for CI) then retry \`pugi review --triple --commit ${resolvedCommit}\`.`,
|
|
2397
|
+
].join('\n'));
|
|
2398
|
+
process.exitCode = 5;
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
const submitResult = await submitTripleReview(config, requestBody);
|
|
2402
|
+
if (submitResult.status !== 'ok') {
|
|
2403
|
+
const outcome = describeSubmitFailure(submitResult);
|
|
2404
|
+
writeFileSync(summaryPath, buildTripleReviewMarkdown({
|
|
2405
|
+
prompt,
|
|
2406
|
+
requestPath: relative(root, requestPath),
|
|
2407
|
+
verdict: null,
|
|
2408
|
+
reason: outcome.message,
|
|
2409
|
+
response: null,
|
|
2410
|
+
}), { encoding: 'utf8', mode: 0o600 });
|
|
2411
|
+
recordToolResult(session, toolCallId, 'error', outcome.message);
|
|
2412
|
+
writeOutput(flags, {
|
|
2413
|
+
status: submitResult.status,
|
|
2414
|
+
code: submitResult.code,
|
|
2415
|
+
message: outcome.message,
|
|
2416
|
+
request: relative(root, requestPath),
|
|
2417
|
+
summary: relative(root, summaryPath),
|
|
2418
|
+
}, [
|
|
2419
|
+
outcome.headline,
|
|
2420
|
+
`Request: ${relative(root, requestPath)}`,
|
|
2421
|
+
`Summary: ${relative(root, summaryPath)}`,
|
|
2422
|
+
outcome.next ? `Next: ${outcome.next}` : '',
|
|
2423
|
+
]
|
|
2424
|
+
.filter(Boolean)
|
|
2425
|
+
.join('\n'));
|
|
2426
|
+
process.exitCode = outcome.exitCode;
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
const response = submitResult.response;
|
|
2430
|
+
persistTripleReviewResult(resultPath, response);
|
|
2431
|
+
writeFileSync(summaryPath, buildTripleReviewMarkdown({
|
|
2432
|
+
prompt,
|
|
2433
|
+
requestPath: relative(root, requestPath),
|
|
2434
|
+
verdict: response.verdict,
|
|
2435
|
+
reason: response.reason,
|
|
2436
|
+
response,
|
|
2437
|
+
}), { encoding: 'utf8', mode: 0o600 });
|
|
2438
|
+
recordToolResult(session, toolCallId, response.verdict === 'BLOCK' ? 'error' : 'success', `Verdict: ${response.verdict} (${response.reason})`);
|
|
2439
|
+
const verdictReport = renderTripleProviderVerdict({
|
|
2440
|
+
response,
|
|
2441
|
+
commit: resolvedCommit,
|
|
2442
|
+
baseRef,
|
|
2443
|
+
});
|
|
2444
|
+
writeOutput(flags, {
|
|
2445
|
+
status: 'completed',
|
|
2446
|
+
verdict: response.verdict,
|
|
2447
|
+
reason: response.reason,
|
|
2448
|
+
counts: response.counts,
|
|
2449
|
+
reviewerCount: response.reviewerCount,
|
|
2450
|
+
effectiveTier: response.effectiveTier,
|
|
2451
|
+
commit: resolvedCommit,
|
|
2452
|
+
baseRef,
|
|
2453
|
+
reviewers: response.reviewers.map((r) => ({
|
|
2454
|
+
provider: r.provider ?? null,
|
|
2455
|
+
model: r.model,
|
|
2456
|
+
declaredVerdict: r.declaredVerdict,
|
|
2457
|
+
findings: r.findings,
|
|
2458
|
+
latencyMs: r.latencyMs,
|
|
2459
|
+
tokensUsed: r.tokensUsed,
|
|
2460
|
+
error: r.error,
|
|
2461
|
+
})),
|
|
2462
|
+
result: relative(root, resultPath),
|
|
2463
|
+
summary: relative(root, summaryPath),
|
|
2464
|
+
}, verdictReport);
|
|
2465
|
+
if (response.verdict === 'BLOCK') {
|
|
2466
|
+
process.exitCode = 9;
|
|
2467
|
+
}
|
|
2468
|
+
else if (response.verdict === 'WARN') {
|
|
2469
|
+
process.exitCode = 1;
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
/**
|
|
2473
|
+
* Pretty-printer for the `pugi review --triple --commit <SHA>` verdict.
|
|
2474
|
+
* Mirrors the `/triple-review` skill's verdict block (per-reviewer
|
|
2475
|
+
* counts table → final GATE line → per-reviewer verbatim → cross-
|
|
2476
|
+
* model disagreement summary → tokens/cost note) so the output is
|
|
2477
|
+
* familiar to operators who already use the dev-only skill.
|
|
2478
|
+
*/
|
|
2479
|
+
export function renderTripleProviderVerdict(input) {
|
|
2480
|
+
const { response, commit, baseRef } = input;
|
|
2481
|
+
const divider = '═'.repeat(68);
|
|
2482
|
+
const subDivider = '─'.repeat(68);
|
|
2483
|
+
// Per-reviewer counts table.
|
|
2484
|
+
const reviewerRows = response.reviewers.map((reviewer) => {
|
|
2485
|
+
const c = { P0: 0, P1: 0, P2: 0, P3: 0 };
|
|
2486
|
+
for (const f of reviewer.findings)
|
|
2487
|
+
c[f.severity] += 1;
|
|
2488
|
+
const status = reviewer.error
|
|
2489
|
+
? 'ERROR'
|
|
2490
|
+
: reviewer.declaredVerdict ?? 'UNKNOWN';
|
|
2491
|
+
const label = reviewer.provider
|
|
2492
|
+
? reviewer.provider.toUpperCase().padEnd(8)
|
|
2493
|
+
: reviewer.model.slice(0, 8).padEnd(8);
|
|
2494
|
+
return ` ${label} ${pad(c.P0)} ${pad(c.P1)} ${pad(c.P2)} ${pad(c.P3)} ${status}`;
|
|
2495
|
+
});
|
|
2496
|
+
// Cross-model disagreement: list severities flagged by 1 of N but not
|
|
2497
|
+
// the others. Surfaces the "highest-signal moment" per the skill.
|
|
2498
|
+
const disagreements = [];
|
|
2499
|
+
const allFindings = response.reviewers.flatMap((r) => r.findings.map((f) => ({
|
|
2500
|
+
provider: r.provider ?? r.model,
|
|
2501
|
+
severity: f.severity,
|
|
2502
|
+
line: f.line,
|
|
2503
|
+
issue: f.issue,
|
|
2504
|
+
})));
|
|
2505
|
+
const p1Flaggers = new Set(response.reviewers
|
|
2506
|
+
.filter((r) => r.findings.some((f) => f.severity === 'P1'))
|
|
2507
|
+
.map((r) => r.provider ?? r.model));
|
|
2508
|
+
if (p1Flaggers.size === 1) {
|
|
2509
|
+
const sole = [...p1Flaggers][0];
|
|
2510
|
+
disagreements.push(`Only ${sole} flagged a P1 — examine the disagreement, often the highest-signal moment.`);
|
|
2511
|
+
}
|
|
2512
|
+
const p0Flaggers = new Set(response.reviewers
|
|
2513
|
+
.filter((r) => r.findings.some((f) => f.severity === 'P0'))
|
|
2514
|
+
.map((r) => r.provider ?? r.model));
|
|
2515
|
+
if (p0Flaggers.size > 0 && p0Flaggers.size < response.reviewers.length) {
|
|
2516
|
+
disagreements.push(`P0 flagged by ${[...p0Flaggers].join(', ')} but not ${response.reviewers
|
|
2517
|
+
.filter((r) => !p0Flaggers.has(r.provider ?? r.model))
|
|
2518
|
+
.map((r) => r.provider ?? r.model)
|
|
2519
|
+
.join(', ')} — verify the finding before merging.`);
|
|
2520
|
+
}
|
|
2521
|
+
// Tokens / cost summary. Tokens are best-effort (some providers
|
|
2522
|
+
// return null). Cost is a placeholder pending billing wire-up; we
|
|
2523
|
+
// surface the quota note inline so the operator knows it counts as
|
|
2524
|
+
// one slot, not three.
|
|
2525
|
+
const totalTokens = response.reviewers.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
|
|
2526
|
+
// Verbatim reviewer outputs. Each section gets a header so operators
|
|
2527
|
+
// can scroll quickly and copy any individual reviewer's text into
|
|
2528
|
+
// their own notes / triage doc.
|
|
2529
|
+
const reviewerSections = response.reviewers.map((reviewer) => {
|
|
2530
|
+
const label = reviewer.provider
|
|
2531
|
+
? reviewer.provider.toUpperCase()
|
|
2532
|
+
: reviewer.model;
|
|
2533
|
+
const body = reviewer.error
|
|
2534
|
+
? `(reviewer errored: ${reviewer.error})`
|
|
2535
|
+
: reviewer.rawContent.trim() || '(empty response)';
|
|
2536
|
+
return [subDivider, `${label} SAYS (${reviewer.model}):`, '', body].join('\n');
|
|
2537
|
+
});
|
|
2538
|
+
return [
|
|
2539
|
+
`PUGI TRIPLE-PROVIDER REVIEW — commit ${commit} vs ${baseRef}`,
|
|
2540
|
+
divider,
|
|
2541
|
+
'',
|
|
2542
|
+
` P0 P1 P2 P3 Status`,
|
|
2543
|
+
...reviewerRows,
|
|
2544
|
+
'',
|
|
2545
|
+
`GATE: ${response.verdict}`,
|
|
2546
|
+
`Reason: ${response.reason}`,
|
|
2547
|
+
'',
|
|
2548
|
+
...reviewerSections,
|
|
2549
|
+
'',
|
|
2550
|
+
subDivider,
|
|
2551
|
+
'CROSS-MODEL DISAGREEMENT:',
|
|
2552
|
+
disagreements.length === 0
|
|
2553
|
+
? ' (none — all reviewers agreed within rubric tolerance)'
|
|
2554
|
+
: disagreements.map((d) => ` - ${d}`).join('\n'),
|
|
2555
|
+
'',
|
|
2556
|
+
`Tokens: ~${totalTokens} total across ${response.reviewers.length} reviewers`,
|
|
2557
|
+
'Quota: charged as 1 review slot (multi-provider counts as a single call).',
|
|
2558
|
+
].join('\n');
|
|
2559
|
+
}
|
|
2560
|
+
function pad(n) {
|
|
2561
|
+
return String(n).padStart(2, ' ');
|
|
2562
|
+
}
|
|
1519
2563
|
function describeSubmitFailure(result) {
|
|
1520
2564
|
switch (result.status) {
|
|
1521
2565
|
case 'endpoint_missing':
|
|
@@ -2075,6 +3119,33 @@ let engineClientFactory = null;
|
|
|
2075
3119
|
export function setEngineClientFactory(factory) {
|
|
2076
3120
|
engineClientFactory = factory;
|
|
2077
3121
|
}
|
|
3122
|
+
/**
|
|
3123
|
+
* β-headless test seam: surface the module-scoped engine client factory
|
|
3124
|
+
* to sibling runtime modules (`headless.ts`) so the same fixture
|
|
3125
|
+
* injection that `setEngineClientFactory` provides for the
|
|
3126
|
+
* `runEngineTask` path applies to `pugi --print` runs. Production
|
|
3127
|
+
* callers never read this — the factory is `null` and falls through
|
|
3128
|
+
* to the real `AnvilEngineLoopClient`.
|
|
3129
|
+
*/
|
|
3130
|
+
export function getEngineClientFactory() {
|
|
3131
|
+
return engineClientFactory;
|
|
3132
|
+
}
|
|
3133
|
+
/**
|
|
3134
|
+
* β-headless test seam: optional stdout/stderr writers injected for
|
|
3135
|
+
* `pugi --print` runs. When set, the headless runner forwards every
|
|
3136
|
+
* NDJSON line / human-readable chunk to these closures instead of the
|
|
3137
|
+
* real `process.stdout.write` / `process.stderr.write`. Needed because
|
|
3138
|
+
* `node:test`'s worker pool hijacks `process.stdout` for a binary IPC
|
|
3139
|
+
* channel — a captureStdio override would race the runner's frames
|
|
3140
|
+
* and surface as `Unexpected token '\x0F'` JSON parse failures in spec
|
|
3141
|
+
* assertions. Production never sets these.
|
|
3142
|
+
*/
|
|
3143
|
+
let headlessStdoutWriter = null;
|
|
3144
|
+
let headlessStderrWriter = null;
|
|
3145
|
+
export function setHeadlessWriters(writers) {
|
|
3146
|
+
headlessStdoutWriter = writers.stdout ?? null;
|
|
3147
|
+
headlessStderrWriter = writers.stderr ?? null;
|
|
3148
|
+
}
|
|
2078
3149
|
function runEngineTask(kind) {
|
|
2079
3150
|
return async (args, flags, session) => {
|
|
2080
3151
|
const label = commandLabel(kind);
|
|
@@ -2088,6 +3159,26 @@ function runEngineTask(kind) {
|
|
|
2088
3159
|
const config = credential
|
|
2089
3160
|
? buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey })
|
|
2090
3161
|
: envConfig;
|
|
3162
|
+
// α6.8 EXTEND PR1 v2: `--decompose` gating runs BEFORE the offline
|
|
3163
|
+
// fallback. Two reasons:
|
|
3164
|
+
// 1. The flag is plan-only — surfacing the rejection for
|
|
3165
|
+
// `pugi build --decompose` before we drop into `offlineBuild`
|
|
3166
|
+
// means the operator gets a deterministic error instead of a
|
|
3167
|
+
// silent no-op stub.
|
|
3168
|
+
// 2. The decompose post-processor depends on the engine's final
|
|
3169
|
+
// text. The offline plan stub does not invoke the engine, so
|
|
3170
|
+
// `pugi plan --decompose --offline` would silently skip the
|
|
3171
|
+
// decomposition step. Refusing the combination up front is the
|
|
3172
|
+
// cheapest way to keep the contract honest.
|
|
3173
|
+
if (flags.decompose && kind !== 'plan') {
|
|
3174
|
+
throw new Error(`--decompose is only valid for \`pugi plan\` (got \`pugi ${label}\`)`);
|
|
3175
|
+
}
|
|
3176
|
+
if (flags.decompose && flags.offline) {
|
|
3177
|
+
throw new Error('--decompose requires the engine — drop --offline (decomposition needs the model to emit a fenced JSON block)');
|
|
3178
|
+
}
|
|
3179
|
+
if (flags.decompose && !config) {
|
|
3180
|
+
throw new Error('--decompose requires the engine — run `pugi login` or set PUGI_API_KEY (decomposition needs the model to emit a fenced JSON block)');
|
|
3181
|
+
}
|
|
2091
3182
|
// Offline fallback: preserves the local-first invariant. `plan` /
|
|
2092
3183
|
// `build` / `explain` drop back to their pre-Sprint-2 stub
|
|
2093
3184
|
// behaviour so an operator without an API key (or with --offline)
|
|
@@ -2140,214 +3231,388 @@ function runEngineTask(kind) {
|
|
|
2140
3231
|
throw new Error(`pugi ${label} requires a prompt`);
|
|
2141
3232
|
}
|
|
2142
3233
|
}
|
|
3234
|
+
// α6.8 EXTEND PR1: when `--decompose` is set, augment the user
|
|
3235
|
+
// prompt with the decomposition-request suffix BEFORE the adapter
|
|
3236
|
+
// run. The system prompt for `plan` already constrains the model
|
|
3237
|
+
// to read-only tools + a plan deliverable; the suffix layers the
|
|
3238
|
+
// JSON-emission contract on top so the post-run parser can lift
|
|
3239
|
+
// the structured payload out of the final answer. The plan-only /
|
|
3240
|
+
// engine-required gates fired before the offline fallback above,
|
|
3241
|
+
// so by here we know we are on the engine path with a plan task.
|
|
3242
|
+
if (flags.decompose && kind === 'plan') {
|
|
3243
|
+
prompt = `${prompt}\n${DECOMPOSE_PROMPT_SUFFIX}`;
|
|
3244
|
+
}
|
|
2143
3245
|
// Narrow `config` for the type checker — the offline branches above
|
|
2144
3246
|
// return whenever `config` is null, so by this point it must be set.
|
|
2145
3247
|
if (!config) {
|
|
2146
3248
|
throw new Error('internal: engine config missing after offline gate');
|
|
2147
3249
|
}
|
|
2148
3250
|
const client = engineClientFactory ? engineClientFactory(config) : new AnvilEngineLoopClient(config);
|
|
2149
|
-
|
|
3251
|
+
// β1b r1 (--allow-fetch / --allow-search wiring, 2026-05-26):
|
|
3252
|
+
// forward operator flags to the adapter so the schema-advertise +
|
|
3253
|
+
// executor-dispatch gates see the OR of (settings.json flag, CLI
|
|
3254
|
+
// flag). PR #425 r1 Backend Architect: the comment at
|
|
3255
|
+
// `tool-bridge.ts:740` documented `--allow-fetch` but the flag was
|
|
3256
|
+
// never wired into the adapter constructor — fix lands here.
|
|
3257
|
+
//
|
|
3258
|
+
// β4 r2 P1 #3 — load the MCP registry pre-run so the engine's
|
|
3259
|
+
// tool-bridge advertises every trusted server's tools under
|
|
3260
|
+
// `mcp__<server>__<tool>`. Before this fix the registry was never
|
|
3261
|
+
// loaded in the CLI engine path: `pugi mcp install` + `pugi mcp
|
|
3262
|
+
// trust` ran successfully but `pugi code/explain/fix/build` still
|
|
3263
|
+
// saw zero `mcp__*` tools in the schema (so the feature was
|
|
3264
|
+
// non-functional at the customer-facing surface). The adapter does
|
|
3265
|
+
// NOT own the registry lifecycle — we tear it down in the `finally`
|
|
3266
|
+
// below regardless of outcome so live MCP child processes are
|
|
3267
|
+
// reaped before the CLI exits.
|
|
3268
|
+
//
|
|
3269
|
+
// Failure mode: a bad `.pugi/mcp.json` (corrupted JSON, schema
|
|
3270
|
+
// violation) bubbles as an exception from `loadMcpRegistry`. We
|
|
3271
|
+
// surface it as a warning on stderr and continue WITHOUT MCP — the
|
|
3272
|
+
// operator's `pugi code "..."` invocation should not fail just
|
|
3273
|
+
// because a stale MCP entry refuses to parse. They get the engine
|
|
3274
|
+
// run without `mcp__*` tools and a clear hint to fix the file.
|
|
3275
|
+
let mcpRegistry;
|
|
3276
|
+
try {
|
|
3277
|
+
mcpRegistry = await loadMcpRegistry(root);
|
|
3278
|
+
}
|
|
3279
|
+
catch (error) {
|
|
3280
|
+
process.stderr.write(`pugi ${label}: MCP registry load failed — ${error.message}. ` +
|
|
3281
|
+
`Continuing without MCP tools. Fix .pugi/mcp.json to enable.\n`);
|
|
3282
|
+
mcpRegistry = undefined;
|
|
3283
|
+
}
|
|
3284
|
+
// P1 fix (deep audit 2026-05-26): load the workspace HookRegistry so
|
|
3285
|
+
// `.pugi/hooks/` lifecycle hooks fire for model-initiated tool calls
|
|
3286
|
+
// from the engine loop, not just for direct CLI tool invocations.
|
|
3287
|
+
// SECURITY: a `PreToolUse onFailure: 'block'` hook that refuses bash
|
|
3288
|
+
// containing `rm` now applies to model dispatch. Before this fix the
|
|
3289
|
+
// hooks were INVISIBLE to the engine adapter — a workspace operator
|
|
3290
|
+
// who set up a block hook for destructive bash would still see the
|
|
3291
|
+
// model freely dispatch those calls.
|
|
3292
|
+
//
|
|
3293
|
+
// r2 fix (triple-review 2026-05-26 P2): the fail-open path is a
|
|
3294
|
+
// security hole. If `.pugi/hooks.json` exists but is malformed
|
|
3295
|
+
// (truncated write, typo, partial edit) and the operator has block
|
|
3296
|
+
// hooks configured, the previous `continue without hooks` silently
|
|
3297
|
+
// disabled the BLOCK rules — a hostile or careless mutation of the
|
|
3298
|
+
// file would turn off all SECURITY-CRITICAL refusals without any
|
|
3299
|
+
// visible signal. We now distinguish three cases:
|
|
3300
|
+
//
|
|
3301
|
+
// (a) Neither user nor project hooks file exists → no hooks. Safe.
|
|
3302
|
+
// (b) File(s) exist and load() succeeds → hooks live. Normal.
|
|
3303
|
+
// (c) File(s) exist and load() fails → REFUSE THE RUN with a
|
|
3304
|
+
// fatal stderr message and `process.exit(1)`. Operator must
|
|
3305
|
+
// fix the file OR set `PUGI_HOOKS_BYPASS=1` to override (the
|
|
3306
|
+
// escape hatch is logged loudly so it cannot be silent).
|
|
3307
|
+
//
|
|
3308
|
+
// The bypass env var exists for the mid-edit recovery case (the
|
|
3309
|
+
// operator is in the middle of fixing the file and needs to run
|
|
3310
|
+
// pugi to see the world state). It is NEVER a default — the
|
|
3311
|
+
// operator types it explicitly.
|
|
3312
|
+
const hookOutcome = await loadHookRegistryOrExit({
|
|
3313
|
+
workspaceRoot: root,
|
|
3314
|
+
session,
|
|
3315
|
+
label,
|
|
3316
|
+
});
|
|
3317
|
+
if (hookOutcome.kind === 'parse-failure-refused') {
|
|
3318
|
+
// The helper already emitted the fatal message on stderr. Exit
|
|
3319
|
+
// directly so dispatchEngineCommand's caller observes a non-zero
|
|
3320
|
+
// exit code without a stack trace.
|
|
3321
|
+
process.exit(1);
|
|
3322
|
+
}
|
|
3323
|
+
const hooks = hookOutcome.hooks;
|
|
3324
|
+
const adapter = new NativePugiEngineAdapter({
|
|
3325
|
+
client,
|
|
3326
|
+
session,
|
|
3327
|
+
allowFetch: flags.allowFetch,
|
|
3328
|
+
allowSearch: flags.allowSearch,
|
|
3329
|
+
...(mcpRegistry ? { mcpRegistry } : {}),
|
|
3330
|
+
...(hooks ? { hooks } : {}),
|
|
3331
|
+
// Non-interactive CLI path: the FSM prompt callback always denies
|
|
3332
|
+
// until the operator explicitly grants permission via
|
|
3333
|
+
// `pugi mcp perms` (out-of-band). A future Ink-backed REPL path
|
|
3334
|
+
// overrides this with a modal prompt; pipes / CI never auto-allow.
|
|
3335
|
+
mcpPrompt: defaultNonInteractiveMcpPrompt,
|
|
3336
|
+
// P1 fix (deep audit 2026-05-26): CLI dispatcher is non-interactive
|
|
3337
|
+
// by default — pipes, CI, and scripted `pugi code "..."` runs do
|
|
3338
|
+
// not have an ink modal to surface ask_user_question into. The
|
|
3339
|
+
// REPL layer (β2b ink modal wiring, future) overrides this with
|
|
3340
|
+
// `interactive: true` + a live askUserBridge.
|
|
3341
|
+
interactive: false,
|
|
3342
|
+
});
|
|
2150
3343
|
const toolCallId = recordToolCall(session, `engine:${adapter.name}`, `${label}: ${prompt}`);
|
|
2151
3344
|
const taskId = `${kind}-${Date.now()}`;
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
3345
|
+
// β4 r2 P1 #3 — try/finally so loaded MCP child processes are
|
|
3346
|
+
// reaped regardless of run outcome (success, blocked, failed,
|
|
3347
|
+
// thrown). The shutdown is best-effort; we never want a stuck
|
|
3348
|
+
// MCP server to mask a successful Pugi run.
|
|
3349
|
+
try {
|
|
3350
|
+
const events = adapter.run({
|
|
3351
|
+
id: taskId,
|
|
3352
|
+
kind,
|
|
3353
|
+
prompt,
|
|
3354
|
+
workspaceRoot: root,
|
|
3355
|
+
allowedPaths: [root],
|
|
3356
|
+
deniedPaths: [],
|
|
3357
|
+
artifacts: [],
|
|
3358
|
+
// plan mode is enforced inside the tool-bridge (read-only schema +
|
|
3359
|
+
// executor refusal sentinel). The permission mode here is the
|
|
3360
|
+
// workspace-level toggle and is unchanged from interactive default.
|
|
3361
|
+
permissionMode: 'auto',
|
|
3362
|
+
}, { sessionId: session.id });
|
|
3363
|
+
const statusEvents = [];
|
|
3364
|
+
let result = null;
|
|
3365
|
+
for await (const event of events) {
|
|
3366
|
+
if (event.type === 'status') {
|
|
3367
|
+
statusEvents.push(event.message);
|
|
3368
|
+
// For `explain` the spec wants status events on stderr so the
|
|
3369
|
+
// final summary on stdout is grep-able. Other commands keep the
|
|
3370
|
+
// events on stdout-via-final-text so the operator sees the
|
|
3371
|
+
// chronological trace.
|
|
3372
|
+
if (kind === 'explain' && !flags.json) {
|
|
3373
|
+
process.stderr.write(`${event.message}\n`);
|
|
3374
|
+
}
|
|
3375
|
+
}
|
|
3376
|
+
else {
|
|
3377
|
+
result = {
|
|
3378
|
+
status: event.result.status,
|
|
3379
|
+
summary: event.result.summary,
|
|
3380
|
+
filesChanged: event.result.filesChanged,
|
|
3381
|
+
eventRefs: event.result.eventRefs,
|
|
3382
|
+
risks: event.result.risks,
|
|
3383
|
+
};
|
|
2176
3384
|
}
|
|
2177
3385
|
}
|
|
2178
|
-
|
|
3386
|
+
if (!result) {
|
|
3387
|
+
// Adapter MUST emit a terminal result event. Treat the empty
|
|
3388
|
+
// outcome as a failure so the CLI surfaces a clear error rather
|
|
3389
|
+
// than exiting 0 with no output.
|
|
2179
3390
|
result = {
|
|
2180
|
-
status:
|
|
2181
|
-
summary:
|
|
2182
|
-
filesChanged:
|
|
2183
|
-
eventRefs:
|
|
2184
|
-
risks: event
|
|
3391
|
+
status: 'failed',
|
|
3392
|
+
summary: 'engine adapter returned no result',
|
|
3393
|
+
filesChanged: [],
|
|
3394
|
+
eventRefs: [],
|
|
3395
|
+
risks: ['adapter terminated without emitting a result event'],
|
|
2185
3396
|
};
|
|
2186
3397
|
}
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
//
|
|
2190
|
-
//
|
|
2191
|
-
//
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
summary: result.summary,
|
|
2225
|
-
eventRefs: result.eventRefs,
|
|
2226
|
-
},
|
|
2227
|
-
dryRun: flags.dryRun,
|
|
2228
|
-
});
|
|
2229
|
-
// Merge dispatcher-touched files into `result.filesChanged` so the
|
|
2230
|
-
// operator-facing summary lists them alongside tool-driven edits.
|
|
2231
|
-
for (const dr of dispatchResults) {
|
|
2232
|
-
if (dr.ok && dr.absPath) {
|
|
2233
|
-
const rel = relative(root, dr.absPath);
|
|
2234
|
-
if (!result.filesChanged.includes(rel))
|
|
2235
|
-
result.filesChanged.push(rel);
|
|
3398
|
+
// α6.6 diff escalation — Layer A/B/C dispatcher.
|
|
3399
|
+
//
|
|
3400
|
+
// Some models emit file edits as inline SEARCH/REPLACE markers in
|
|
3401
|
+
// the final response rather than through tool calls (especially
|
|
3402
|
+
// Gemini and o1 family, which under-use tool schemas in long
|
|
3403
|
+
// reasoning chains). We run the dispatcher against the model's
|
|
3404
|
+
// final text so those markers still land on disk. Tool-call edits
|
|
3405
|
+
// (Layer-A equivalent already handled by `edit`/`write` tools) are
|
|
3406
|
+
// unaffected — the dispatcher only fires on prose blocks that
|
|
3407
|
+
// happen to contain markers.
|
|
3408
|
+
//
|
|
3409
|
+
// Scope: code / fix / build / explain only. `plan` is read-only
|
|
3410
|
+
// (the engine refuses write tools), so even a stray marker in plan
|
|
3411
|
+
// output gets ignored to honour the plan-mode contract.
|
|
3412
|
+
//
|
|
3413
|
+
// Dry-run + read-only short-circuits: when the flags forbid writes
|
|
3414
|
+
// we dispatch with `dryRun: true` so the operator still sees what
|
|
3415
|
+
// WOULD have been written, but nothing touches disk.
|
|
3416
|
+
let dispatchResults = [];
|
|
3417
|
+
if (kind === 'code' || kind === 'fix' || kind === 'build_task') {
|
|
3418
|
+
dispatchResults = await runMarkerDispatch({
|
|
3419
|
+
root,
|
|
3420
|
+
result: {
|
|
3421
|
+
status: result.status,
|
|
3422
|
+
summary: result.summary,
|
|
3423
|
+
eventRefs: result.eventRefs,
|
|
3424
|
+
},
|
|
3425
|
+
dryRun: flags.dryRun,
|
|
3426
|
+
});
|
|
3427
|
+
// Merge dispatcher-touched files into `result.filesChanged` so the
|
|
3428
|
+
// operator-facing summary lists them alongside tool-driven edits.
|
|
3429
|
+
for (const dr of dispatchResults) {
|
|
3430
|
+
if (dr.ok && dr.absPath) {
|
|
3431
|
+
const rel = relative(root, dr.absPath);
|
|
3432
|
+
if (!result.filesChanged.includes(rel))
|
|
3433
|
+
result.filesChanged.push(rel);
|
|
3434
|
+
}
|
|
2236
3435
|
}
|
|
2237
3436
|
}
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
});
|
|
2251
|
-
}
|
|
2252
|
-
// Pull the headline metrics out of `eventRefs` so the summary and
|
|
2253
|
-
// JSON envelope match without re-parsing strings in two places.
|
|
2254
|
-
const metrics = parseEventRefs(result.eventRefs);
|
|
2255
|
-
const finalStatus = result.status === 'failed' ? 'error' : 'success';
|
|
2256
|
-
recordToolResult(session, toolCallId, finalStatus, result.summary);
|
|
2257
|
-
// Exit code policy (spec §1-§5):
|
|
2258
|
-
// code/fix/build → 0 done, 8 failed, 9 blocked
|
|
2259
|
-
// explain → same triple; read-only blocked = budget exhaustion
|
|
2260
|
-
// plan → 0 on done OR plan-mode refusal (refusal is a
|
|
2261
|
-
// SUCCESS for plan: the gate worked); 8 on failed
|
|
2262
|
-
// transport; 9 on budget exhaustion.
|
|
2263
|
-
//
|
|
2264
|
-
// Code Reviewer P2 retro 2026-05-23: previously `plan` masked
|
|
2265
|
-
// `budget_exhausted` as exit 0, so a CI loop with a token budget
|
|
2266
|
-
// hit looked identical to a successful plan. We now distinguish
|
|
2267
|
-
// via the adapter's `outcome=<status>` echo on `eventRefs` so
|
|
2268
|
-
// shell wrappers can branch on the real cause.
|
|
2269
|
-
if (kind === 'plan') {
|
|
2270
|
-
if (result.status === 'failed') {
|
|
2271
|
-
process.exitCode = ENGINE_EXIT_CODES.failed;
|
|
2272
|
-
}
|
|
2273
|
-
else if (result.status === 'blocked' &&
|
|
2274
|
-
metrics.outcome === 'budget_exhausted') {
|
|
2275
|
-
process.exitCode = ENGINE_EXIT_CODES.blocked;
|
|
3437
|
+
// For `plan` we always write a plan.md artifact, regardless of
|
|
3438
|
+
// outcome. A blocked plan (budget exhausted, tool refusal) still
|
|
3439
|
+
// produces a reviewable artifact — the reason is recorded inline.
|
|
3440
|
+
let planArtifact = null;
|
|
3441
|
+
if (kind === 'plan') {
|
|
3442
|
+
planArtifact = writePlanArtifact({
|
|
3443
|
+
root,
|
|
3444
|
+
session,
|
|
3445
|
+
prompt,
|
|
3446
|
+
result,
|
|
3447
|
+
statusEvents,
|
|
3448
|
+
});
|
|
2276
3449
|
}
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
3450
|
+
// α6.8 EXTEND PR1: `--decompose` post-processing. We only attempt
|
|
3451
|
+
// the parse on a `done` plan (a blocked/failed plan is already
|
|
3452
|
+
// captured in plan.md with its reason; no JSON to extract). The
|
|
3453
|
+
// model's final answer arrives via `result.summary` — on success
|
|
3454
|
+
// the adapter prefix is empty so it is the raw final text. We
|
|
3455
|
+
// strip any leading/trailing whitespace then run the parser
|
|
3456
|
+
// against the contents. On parse failure we surface a non-fatal
|
|
3457
|
+
// structured error in the payload — the operator still gets the
|
|
3458
|
+
// plan.md artifact and can re-run.
|
|
3459
|
+
//
|
|
3460
|
+
// TODO(α7.x): `result.summary` is currently a string contract that
|
|
3461
|
+
// doubles as both "human-readable headline" and "raw final model
|
|
3462
|
+
// text". Split into `{ summary, finalText }` on the adapter so the
|
|
3463
|
+
// parser does not have to assume the prefix is empty. Tracked in
|
|
3464
|
+
// PR #423 v2 retro (P2.6, Claude review).
|
|
3465
|
+
let decomposeArtifact = null;
|
|
3466
|
+
let decomposeError = null;
|
|
3467
|
+
if (flags.decompose && kind === 'plan' && result.status === 'done') {
|
|
3468
|
+
const parsed = parseDecompositionFromText(result.summary);
|
|
3469
|
+
if (parsed.ok) {
|
|
3470
|
+
decomposeArtifact = writeDecomposition({
|
|
3471
|
+
root,
|
|
3472
|
+
sessionId: session.id,
|
|
3473
|
+
// Persist the OPERATOR's original prompt, not the prompt+suffix
|
|
3474
|
+
// we sent to the engine. The suffix is plumbing; the manifest
|
|
3475
|
+
// header reads naturally only with the operator text.
|
|
3476
|
+
prompt: args.join(' ').trim() || prompt,
|
|
3477
|
+
decomposition: parsed.decomposition,
|
|
3478
|
+
rationale: parsed.rationale,
|
|
3479
|
+
});
|
|
3480
|
+
}
|
|
3481
|
+
else {
|
|
3482
|
+
decomposeError = { reason: parsed.reason, detail: parsed.detail };
|
|
3483
|
+
}
|
|
2283
3484
|
}
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
reason: dr.reason,
|
|
2309
|
-
detail: dr.detail,
|
|
2310
|
-
})),
|
|
2311
|
-
// The full event stream is useful for cabinet UI replay. We surface
|
|
2312
|
-
// it in JSON mode only — text mode operators want the summary, not
|
|
2313
|
-
// 30 turn-level lines.
|
|
2314
|
-
events: flags.json ? statusEvents : undefined,
|
|
2315
|
-
};
|
|
2316
|
-
const textLines = [];
|
|
2317
|
-
if (kind === 'plan' && planArtifact) {
|
|
2318
|
-
textLines.push(`Pugi plan written to ${planArtifact.relPath}`);
|
|
2319
|
-
}
|
|
2320
|
-
textLines.push(`Pugi ${label}: ${result.status}`);
|
|
2321
|
-
textLines.push(`Summary: ${result.summary}`);
|
|
2322
|
-
if (result.filesChanged.length > 0) {
|
|
2323
|
-
textLines.push(`Files modified (${result.filesChanged.length}):`);
|
|
2324
|
-
for (const file of result.filesChanged)
|
|
2325
|
-
textLines.push(` - ${file}`);
|
|
2326
|
-
}
|
|
2327
|
-
else if (kind !== 'explain' && kind !== 'plan') {
|
|
2328
|
-
textLines.push('Files modified: none');
|
|
2329
|
-
}
|
|
2330
|
-
textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
|
|
2331
|
-
if (dispatchResults.length > 0) {
|
|
2332
|
-
const okCount = dispatchResults.filter((d) => d.ok).length;
|
|
2333
|
-
const failCount = dispatchResults.length - okCount;
|
|
2334
|
-
textLines.push(`Diff dispatch: ${okCount} applied, ${failCount} rejected (${dispatchResults.length} marker block${dispatchResults.length === 1 ? '' : 's'})`);
|
|
2335
|
-
for (const dr of dispatchResults) {
|
|
2336
|
-
if (dr.ok) {
|
|
2337
|
-
textLines.push(` + ${dr.layer} ${dr.file} (${dr.bytesWritten} bytes)`);
|
|
3485
|
+
// Pull the headline metrics out of `eventRefs` so the summary and
|
|
3486
|
+
// JSON envelope match without re-parsing strings in two places.
|
|
3487
|
+
const metrics = parseEventRefs(result.eventRefs);
|
|
3488
|
+
const finalStatus = result.status === 'failed' ? 'error' : 'success';
|
|
3489
|
+
recordToolResult(session, toolCallId, finalStatus, result.summary);
|
|
3490
|
+
// Exit code policy (spec §1-§5):
|
|
3491
|
+
// code/fix/build → 0 done, 8 failed, 9 blocked
|
|
3492
|
+
// explain → same triple; read-only blocked = budget exhaustion
|
|
3493
|
+
// plan → 0 on done OR plan-mode refusal (refusal is a
|
|
3494
|
+
// SUCCESS for plan: the gate worked); 8 on failed
|
|
3495
|
+
// transport; 9 on budget exhaustion.
|
|
3496
|
+
//
|
|
3497
|
+
// Code Reviewer P2 retro 2026-05-23: previously `plan` masked
|
|
3498
|
+
// `budget_exhausted` as exit 0, so a CI loop with a token budget
|
|
3499
|
+
// hit looked identical to a successful plan. We now distinguish
|
|
3500
|
+
// via the adapter's `outcome=<status>` echo on `eventRefs` so
|
|
3501
|
+
// shell wrappers can branch on the real cause.
|
|
3502
|
+
if (kind === 'plan') {
|
|
3503
|
+
if (result.status === 'failed') {
|
|
3504
|
+
process.exitCode = ENGINE_EXIT_CODES.failed;
|
|
3505
|
+
}
|
|
3506
|
+
else if (result.status === 'blocked' &&
|
|
3507
|
+
metrics.outcome === 'budget_exhausted') {
|
|
3508
|
+
process.exitCode = ENGINE_EXIT_CODES.blocked;
|
|
2338
3509
|
}
|
|
2339
3510
|
else {
|
|
2340
|
-
|
|
3511
|
+
// `done`, or `blocked` with outcome=tool_refused (= the plan-mode
|
|
3512
|
+
// gate fired, which is the contract working as designed), or
|
|
3513
|
+
// `blocked` with no outcome echo (legacy adapter — preserve the
|
|
3514
|
+
// pre-retro 0 behaviour to avoid breaking external scripts).
|
|
3515
|
+
process.exitCode = 0;
|
|
3516
|
+
}
|
|
3517
|
+
}
|
|
3518
|
+
else {
|
|
3519
|
+
process.exitCode = ENGINE_EXIT_CODES[result.status];
|
|
3520
|
+
}
|
|
3521
|
+
const payload = {
|
|
3522
|
+
command: label,
|
|
3523
|
+
taskId,
|
|
3524
|
+
status: result.status,
|
|
3525
|
+
summary: result.summary,
|
|
3526
|
+
filesChanged: result.filesChanged,
|
|
3527
|
+
toolCalls: metrics.toolCalls,
|
|
3528
|
+
turns: metrics.turns,
|
|
3529
|
+
tokens: metrics.tokens,
|
|
3530
|
+
sessionId: session.id,
|
|
3531
|
+
sessionEventsMirror: metrics.mirror,
|
|
3532
|
+
risks: result.risks,
|
|
3533
|
+
plan: planArtifact ? { path: planArtifact.relPath } : undefined,
|
|
3534
|
+
// α6.6 — per-edit dispatcher trace. Empty array when no inline
|
|
3535
|
+
// markers were detected in the model's final response.
|
|
3536
|
+
diffEdits: dispatchResults.map((dr) => ({
|
|
3537
|
+
layer: dr.layer,
|
|
3538
|
+
file: dr.file,
|
|
3539
|
+
ok: dr.ok,
|
|
3540
|
+
bytesWritten: dr.bytesWritten,
|
|
3541
|
+
reason: dr.reason,
|
|
3542
|
+
detail: dr.detail,
|
|
3543
|
+
})),
|
|
3544
|
+
// α6.8 EXTEND PR1: decompose artifacts (only present when
|
|
3545
|
+
// `--decompose` was passed AND the model emitted a parseable
|
|
3546
|
+
// JSON block). The `error` shape lands when the model returned
|
|
3547
|
+
// unparseable output; the operator can re-run with a tighter
|
|
3548
|
+
// prompt without losing the plain plan.md artifact.
|
|
3549
|
+
decompose: decomposeArtifact !== null
|
|
3550
|
+
? {
|
|
3551
|
+
manifest: relative(root, decomposeArtifact.manifestPath),
|
|
3552
|
+
planDir: relative(root, decomposeArtifact.planDir),
|
|
3553
|
+
splits: decomposeArtifact.splitPaths,
|
|
3554
|
+
}
|
|
3555
|
+
: decomposeError !== null
|
|
3556
|
+
? { error: decomposeError }
|
|
3557
|
+
: undefined,
|
|
3558
|
+
// The full event stream is useful for cabinet UI replay. We surface
|
|
3559
|
+
// it in JSON mode only — text mode operators want the summary, not
|
|
3560
|
+
// 30 turn-level lines.
|
|
3561
|
+
events: flags.json ? statusEvents : undefined,
|
|
3562
|
+
};
|
|
3563
|
+
const textLines = [];
|
|
3564
|
+
if (kind === 'plan' && planArtifact) {
|
|
3565
|
+
textLines.push(`Pugi plan written to ${planArtifact.relPath}`);
|
|
3566
|
+
}
|
|
3567
|
+
if (decomposeArtifact !== null) {
|
|
3568
|
+
textLines.push(`Decomposition: ${decomposeArtifact.splitPaths.length} component spec${decomposeArtifact.splitPaths.length === 1 ? '' : 's'} under ${relative(root, decomposeArtifact.planDir)}`);
|
|
3569
|
+
textLines.push(`Manifest: ${relative(root, decomposeArtifact.manifestPath)}`);
|
|
3570
|
+
}
|
|
3571
|
+
else if (decomposeError !== null) {
|
|
3572
|
+
textLines.push(`Decomposition: skipped (${decomposeError.reason}) — plan.md still written`);
|
|
3573
|
+
}
|
|
3574
|
+
textLines.push(`Pugi ${label}: ${result.status}`);
|
|
3575
|
+
textLines.push(`Summary: ${result.summary}`);
|
|
3576
|
+
if (result.filesChanged.length > 0) {
|
|
3577
|
+
textLines.push(`Files modified (${result.filesChanged.length}):`);
|
|
3578
|
+
for (const file of result.filesChanged)
|
|
3579
|
+
textLines.push(` - ${file}`);
|
|
3580
|
+
}
|
|
3581
|
+
else if (kind !== 'explain' && kind !== 'plan') {
|
|
3582
|
+
textLines.push('Files modified: none');
|
|
3583
|
+
}
|
|
3584
|
+
textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
|
|
3585
|
+
if (dispatchResults.length > 0) {
|
|
3586
|
+
const okCount = dispatchResults.filter((d) => d.ok).length;
|
|
3587
|
+
const failCount = dispatchResults.length - okCount;
|
|
3588
|
+
textLines.push(`Diff dispatch: ${okCount} applied, ${failCount} rejected (${dispatchResults.length} marker block${dispatchResults.length === 1 ? '' : 's'})`);
|
|
3589
|
+
for (const dr of dispatchResults) {
|
|
3590
|
+
if (dr.ok) {
|
|
3591
|
+
textLines.push(` + ${dr.layer} ${dr.file} (${dr.bytesWritten} bytes)`);
|
|
3592
|
+
}
|
|
3593
|
+
else {
|
|
3594
|
+
textLines.push(` ! ${dr.layer} ${dr.file}: ${dr.reason ?? 'failure'} — ${dr.detail ?? ''}`);
|
|
3595
|
+
}
|
|
2341
3596
|
}
|
|
2342
3597
|
}
|
|
3598
|
+
if (result.risks.length > 0) {
|
|
3599
|
+
textLines.push(`Risks: ${result.risks.join('; ')}`);
|
|
3600
|
+
}
|
|
3601
|
+
textLines.push(`Session: ${session.id}`);
|
|
3602
|
+
if (metrics.mirror)
|
|
3603
|
+
textLines.push(`Events mirror: ${metrics.mirror}`);
|
|
3604
|
+
writeOutput(flags, payload, textLines.join('\n'));
|
|
2343
3605
|
}
|
|
2344
|
-
|
|
2345
|
-
|
|
3606
|
+
finally {
|
|
3607
|
+
// β4 r2 P1 #3 — tear down live MCP child processes BEFORE the
|
|
3608
|
+
// CLI exits. shutdown() is idempotent and swallows per-server
|
|
3609
|
+
// disconnect errors, so it is safe even if no servers connected.
|
|
3610
|
+
if (mcpRegistry) {
|
|
3611
|
+
await mcpRegistry.shutdown().catch((error) => {
|
|
3612
|
+
process.stderr.write(`pugi ${label}: MCP registry shutdown reported error — ${error.message}\n`);
|
|
3613
|
+
});
|
|
3614
|
+
}
|
|
2346
3615
|
}
|
|
2347
|
-
textLines.push(`Session: ${session.id}`);
|
|
2348
|
-
if (metrics.mirror)
|
|
2349
|
-
textLines.push(`Events mirror: ${metrics.mirror}`);
|
|
2350
|
-
writeOutput(flags, payload, textLines.join('\n'));
|
|
2351
3616
|
};
|
|
2352
3617
|
}
|
|
2353
3618
|
// Exported for the α6.6.1 triple-review remediation spec
|
|
@@ -3975,7 +5240,31 @@ function fileBytes(path) {
|
|
|
3975
5240
|
return 0;
|
|
3976
5241
|
}
|
|
3977
5242
|
}
|
|
3978
|
-
|
|
5243
|
+
/**
|
|
5244
|
+
* Git invocation helpers — probe vs required semantics.
|
|
5245
|
+
*
|
|
5246
|
+
* 2026-05-27 (Claude review followup #489): the historical `safeGit`
|
|
5247
|
+
* collapsed BOTH "tell me the branch name if you can" probes AND
|
|
5248
|
+
* "give me the diff or fail" hard requirements into a single helper
|
|
5249
|
+
* that swallowed every error as an empty string. That's the correct
|
|
5250
|
+
* shape for the probe case (branch / status / dirty flag — empty
|
|
5251
|
+
* result is a valid signal) but catastrophically wrong for the diff
|
|
5252
|
+
* case (empty result === false PASS on a commit nobody reviewed).
|
|
5253
|
+
*
|
|
5254
|
+
* The split:
|
|
5255
|
+
* - `safeGitProbe` — best-effort. Returns '' on any error. Use for
|
|
5256
|
+
* branch name lookups, status probes, opt-in dirty detection.
|
|
5257
|
+
* - `safeGitRequired` — throws on non-zero exit / ENOBUFS / bad ref.
|
|
5258
|
+
* Use for diff, merge-base resolution, anything whose empty
|
|
5259
|
+
* output would silently corrupt downstream behaviour.
|
|
5260
|
+
*
|
|
5261
|
+
* Legacy `safeGit` is kept as a deprecated alias of `safeGitProbe`
|
|
5262
|
+
* so existing call-sites (branch detection, status, etc.) keep their
|
|
5263
|
+
* tolerant semantics until they are individually migrated. Diff /
|
|
5264
|
+
* merge-base / rev-parse-verify call-sites are migrated к
|
|
5265
|
+
* `safeGitRequired` in this same patch.
|
|
5266
|
+
*/
|
|
5267
|
+
export function safeGitProbe(root, args) {
|
|
3979
5268
|
try {
|
|
3980
5269
|
return execFileSync('git', args, {
|
|
3981
5270
|
cwd: root,
|
|
@@ -3993,6 +5282,38 @@ function safeGit(root, args) {
|
|
|
3993
5282
|
return '';
|
|
3994
5283
|
}
|
|
3995
5284
|
}
|
|
5285
|
+
/**
|
|
5286
|
+
* Strict variant — throws on non-zero exit, ENOBUFS, or any git-side
|
|
5287
|
+
* failure. The thrown error carries the operation context so the
|
|
5288
|
+
* caller (triple-review dispatch, etc.) can fail loud rather than
|
|
5289
|
+
* ship an empty diff to a remote reviewer.
|
|
5290
|
+
*/
|
|
5291
|
+
export function safeGitRequired(root, args, context) {
|
|
5292
|
+
try {
|
|
5293
|
+
return execFileSync('git', args, {
|
|
5294
|
+
cwd: root,
|
|
5295
|
+
encoding: 'utf8',
|
|
5296
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
5297
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
5298
|
+
});
|
|
5299
|
+
}
|
|
5300
|
+
catch (err) {
|
|
5301
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
5302
|
+
throw new Error(`git ${args.slice(0, 2).join(' ')} failed (${context}): ${cause}. ` +
|
|
5303
|
+
`Refusing to proceed — empty git output here would corrupt downstream behaviour.`);
|
|
5304
|
+
}
|
|
5305
|
+
}
|
|
5306
|
+
/**
|
|
5307
|
+
* Deprecated alias preserved for diff / status / branch probes that
|
|
5308
|
+
* legitimately want a tolerant empty-string-on-error shape. New call
|
|
5309
|
+
* sites should pick `safeGitProbe` or `safeGitRequired` explicitly.
|
|
5310
|
+
*
|
|
5311
|
+
* @deprecated 2026-05-27 — prefer `safeGitProbe` (tolerant) or
|
|
5312
|
+
* `safeGitRequired` (strict, throws).
|
|
5313
|
+
*/
|
|
5314
|
+
function safeGit(root, args) {
|
|
5315
|
+
return safeGitProbe(root, args);
|
|
5316
|
+
}
|
|
3996
5317
|
/**
|
|
3997
5318
|
* Glob patterns excluded from triple-review `diffPatch` before egress.
|
|
3998
5319
|
*
|
|
@@ -4133,5 +5454,6 @@ export function packageRoot() {
|
|
|
4133
5454
|
export const __test__ = {
|
|
4134
5455
|
sleep,
|
|
4135
5456
|
pollDeviceFlowUntilTerminal,
|
|
5457
|
+
sanitizeSemver,
|
|
4136
5458
|
};
|
|
4137
5459
|
//# sourceMappingURL=cli.js.map
|