@pugi/cli 0.1.0-beta.4 → 0.1.0-beta.40
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 -25
- package/bin/run.js +33 -1
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- package/dist/commands/smoke.js +133 -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/artifact-chain/dispatcher.js +148 -0
- package/dist/core/artifact-chain/exporter.js +164 -0
- package/dist/core/artifact-chain/state.js +243 -0
- package/dist/core/artifact-chain/steps.js +169 -0
- package/dist/core/auth/ensure-authenticated.js +129 -0
- package/dist/core/auth/env-provider.js +238 -0
- package/dist/core/auto-update/channels.js +122 -0
- package/dist/core/auto-update/checker.js +241 -0
- package/dist/core/auto-update/state.js +235 -0
- package/dist/core/bare-mode/index.js +107 -0
- package/dist/core/bash-classifier.js +108 -1
- package/dist/core/checkpoint/resumer.js +149 -0
- package/dist/core/checkpoint/rewinder.js +291 -0
- package/dist/core/codegraph/decision-store.js +248 -0
- package/dist/core/codegraph/detect-repo.js +459 -0
- package/dist/core/codegraph/install.js +134 -0
- package/dist/core/codegraph/offer-hook.js +220 -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 +208 -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/bare-mode.js +42 -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/pugi-md.js +89 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/dispatch/cache-cleanup.js +197 -0
- package/dist/core/dispatch/cache-handoff.js +295 -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 +322 -0
- package/dist/core/engine/anvil-client.js +115 -5
- package/dist/core/engine/budgets.js +98 -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 +860 -211
- package/dist/core/engine/prompts.js +88 -2
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +992 -36
- package/dist/core/feedback/queue.js +177 -0
- package/dist/core/feedback/submitter.js +145 -0
- package/dist/core/file-cache.js +113 -1
- package/dist/core/hooks/events.js +44 -0
- package/dist/core/hooks/index.js +15 -0
- package/dist/core/hooks/registry.js +213 -0
- package/dist/core/hooks/runner.js +236 -0
- package/dist/core/hooks/v2/event-emitter.js +115 -0
- package/dist/core/hooks/v2/executor.js +282 -0
- package/dist/core/hooks/v2/index.js +25 -0
- package/dist/core/hooks/v2/lifecycle.js +104 -0
- package/dist/core/hooks/v2/loader.js +216 -0
- package/dist/core/hooks/v2/matcher.js +125 -0
- package/dist/core/hooks/v2/trust.js +143 -0
- package/dist/core/hooks/v2/types.js +86 -0
- package/dist/core/lsp/cache.js +105 -0
- package/dist/core/lsp/client.js +776 -0
- package/dist/core/lsp/language-detect.js +66 -0
- package/dist/core/lsp/post-edit-diagnostics.js +171 -0
- package/dist/core/mcp/client.js +75 -6
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/orchestrator-tools.js +662 -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/memory/dual-write.js +416 -0
- package/dist/core/memory/phase1-kinds.js +20 -0
- package/dist/core/memory-sync/queue.js +158 -0
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/onboarding/marker.js +111 -0
- package/dist/core/onboarding/telemetry-state.js +108 -0
- package/dist/core/output-style/presets.js +176 -0
- package/dist/core/output-style/state.js +185 -0
- package/dist/core/permissions/auto-classifier.js +124 -0
- package/dist/core/permissions/circuit-breaker.js +83 -0
- package/dist/core/permissions/gate.js +278 -0
- package/dist/core/permissions/index.js +20 -0
- package/dist/core/permissions/mode.js +174 -0
- package/dist/core/permissions/state.js +241 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/prd-check/parser.js +215 -0
- package/dist/core/prd-check/reporter.js +127 -0
- package/dist/core/prd-check/session-review.js +557 -0
- package/dist/core/prd-check/verifiers.js +223 -0
- package/dist/core/pugi-md/context-injector.js +76 -0
- package/dist/core/pugi-md/walk-up.js +207 -0
- package/dist/core/release-notes/parser.js +241 -0
- package/dist/core/release-notes/state.js +116 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/session.js +1899 -38
- package/dist/core/repl/slash-commands.js +406 -21
- package/dist/core/repl/store/session-store.js +31 -2
- package/dist/core/repl/workspace-context.js +22 -0
- package/dist/core/repo-map/build.js +125 -0
- package/dist/core/repo-map/cache.js +185 -0
- package/dist/core/repo-map/extractor.js +254 -0
- package/dist/core/repo-map/formatter.js +145 -0
- package/dist/core/repo-map/scanner.js +211 -0
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/session.js +92 -0
- package/dist/core/settings.js +80 -0
- package/dist/core/share/formatter.js +271 -0
- package/dist/core/share/redactor.js +221 -0
- package/dist/core/share/uploader.js +267 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/smoke/headless-driver.js +174 -0
- package/dist/core/smoke/orchestrator.js +194 -0
- package/dist/core/smoke/runner.js +238 -0
- package/dist/core/smoke/scenario-parser.js +316 -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/telemetry/emitter.js +229 -0
- package/dist/core/telemetry/queue.js +251 -0
- package/dist/core/theme/context.js +91 -0
- package/dist/core/theme/presets.js +228 -0
- package/dist/core/theme/state.js +181 -0
- package/dist/core/todos/invariant.js +10 -0
- package/dist/core/todos/state.js +177 -0
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/core/vim/keymap.js +288 -0
- package/dist/core/vim/state.js +92 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +3073 -321
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/chain.js +489 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/compact.js +297 -0
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +242 -11
- package/dist/runtime/commands/dispatch.js +126 -0
- package/dist/runtime/commands/doctor.js +390 -0
- package/dist/runtime/commands/feedback.js +184 -0
- package/dist/runtime/commands/hooks.js +184 -0
- package/dist/runtime/commands/lsp.js +368 -0
- package/dist/runtime/commands/mcp.js +879 -0
- package/dist/runtime/commands/memory.js +508 -0
- package/dist/runtime/commands/model.js +237 -0
- package/dist/runtime/commands/onboarding.js +275 -0
- package/dist/runtime/commands/patch.js +128 -0
- package/dist/runtime/commands/permissions.js +112 -0
- package/dist/runtime/commands/plan.js +143 -0
- package/dist/runtime/commands/prd-check.js +285 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/release-notes.js +229 -0
- package/dist/runtime/commands/repo-map.js +95 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/resume.js +118 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/commands/rewind.js +333 -0
- package/dist/runtime/commands/sessions.js +163 -0
- package/dist/runtime/commands/share.js +316 -0
- package/dist/runtime/commands/status.js +186 -0
- package/dist/runtime/commands/stickers.js +82 -0
- package/dist/runtime/commands/style.js +194 -0
- package/dist/runtime/commands/theme.js +196 -0
- package/dist/runtime/commands/undo.js +32 -0
- package/dist/runtime/commands/update.js +289 -0
- package/dist/runtime/commands/vim.js +140 -0
- package/dist/runtime/commands/worktree.js +177 -0
- package/dist/runtime/headless-repl.js +195 -0
- 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 +229 -0
- package/dist/tools/apply-patch.js +556 -0
- 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/lsp-tools.js +189 -0
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +46 -0
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/todo-write.js +184 -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 +81 -0
- package/dist/tui/conversation-pane.js +82 -8
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/doctor-table.js +46 -0
- package/dist/tui/feedback-prompt.js +156 -0
- package/dist/tui/input-box.js +69 -2
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/onboarding-wizard.js +240 -0
- package/dist/tui/permissions-picker.js +86 -0
- package/dist/tui/render.js +35 -0
- package/dist/tui/repl-render.js +303 -13
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +72 -14
- 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/stickers-art.js +136 -0
- package/dist/tui/style-table.js +28 -0
- package/dist/tui/theme-table.js +29 -0
- package/dist/tui/tool-stream-pane.js +52 -3
- package/dist/tui/update-banner.js +20 -2
- package/dist/tui/vim-input.js +267 -0
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +12 -6
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +11 -0
- package/test/scenarios/identity.scenario.txt +11 -0
- package/test/scenarios/persona-handoff.scenario.txt +11 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
package/dist/runtime/cli.js
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
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
|
-
import { statSync } from 'node:fs';
|
|
4
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
5
6
|
import { dirname, relative, resolve } from 'node:path';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import { AnvilEngineLoopClient } from '../core/engine/anvil-client.js';
|
|
8
|
-
import { NoopEngineAdapter } from '../core/engine/noop.js';
|
|
9
9
|
import { NativePugiEngineAdapter } from '../core/engine/native-pugi.js';
|
|
10
|
-
import {
|
|
10
|
+
import { loadMcpRegistry } from '../core/mcp/registry.js';
|
|
11
|
+
import { loadHookRegistryOrExit } from './load-hooks-or-exit.js';
|
|
12
|
+
import { defaultNonInteractiveMcpPrompt } from '../tools/mcp-tool.js';
|
|
11
13
|
import { openSession, recordCommandCompleted, recordCommandStarted, recordToolCall, recordToolResult, } from '../core/session.js';
|
|
12
14
|
import { loadSettings } from '../core/settings.js';
|
|
13
15
|
import { FileReadCache } from '../core/file-cache.js';
|
|
14
16
|
import { resolveWorkspacePath } from '../core/path-security.js';
|
|
15
17
|
import { globTool, grepTool, readTool } from '../tools/file-tools.js';
|
|
16
|
-
import { toolRegistry, toolSchemaBundleHashInput } from '../tools/registry.js';
|
|
17
18
|
import { webFetchTool } from '../tools/web-fetch.js';
|
|
18
19
|
import { emptyIndex, rebuildIndex, readIndex, upsertArtifact, writeIndex, } from '../core/index-store.js';
|
|
19
20
|
import { signatureForPlanReview } from '../core/repl/ask.js';
|
|
@@ -21,17 +22,55 @@ import { buildRuntimeConfig, fetchPersonaRoster, loadRuntimeConfig, openPugiSess
|
|
|
21
22
|
import { PUGI_TAGLINE } from '@pugi/personas';
|
|
22
23
|
import { resolveRoster, renderRosterTable } from './commands/roster.js';
|
|
23
24
|
import { runDelegateCommand } from './commands/delegate.js';
|
|
25
|
+
import { runDispatchCommand } from './commands/dispatch.js';
|
|
24
26
|
import { clearApiKey, DEFAULT_API_URL, listStoredCredentials, maskApiKey, normalizeApiUrl, purgeAllCredentials, readCredentialsFile, resolveActiveCredential, storeApiKey, switchActiveAccount, } from '../core/credentials.js';
|
|
27
|
+
import { resolveAndValidateEnvLogin, } from '../core/auth/env-provider.js';
|
|
25
28
|
import { runDeployCommand } from '../commands/deploy.js';
|
|
26
29
|
import { runJobsCommand } from '../commands/jobs.js';
|
|
27
30
|
import { runConfigCommand } from './commands/config.js';
|
|
31
|
+
import { runStyleCommand } from './commands/style.js';
|
|
32
|
+
import { runThemeCommand } from './commands/theme.js';
|
|
33
|
+
import { runOnboardingCommand } from './commands/onboarding.js';
|
|
34
|
+
import { runVimCommand } from './commands/vim.js';
|
|
35
|
+
import { isOnboarded } from '../core/onboarding/marker.js';
|
|
36
|
+
import { ensureInitialized as ensureInitializedHelper } from '../core/onboarding/ensure-initialized.js';
|
|
37
|
+
import { ensureAuthenticated as ensureAuthenticatedHelper } from '../core/auth/ensure-authenticated.js';
|
|
28
38
|
import { runPrivacyCommand } from './commands/privacy.js';
|
|
39
|
+
import { runReport } from './commands/report.js';
|
|
40
|
+
import { runDoctorCommand, defaultHome as defaultDoctorHome } from './commands/doctor.js';
|
|
41
|
+
import { parsePrdCheckArgs, runPrdCheckCommand, } from './commands/prd-check.js';
|
|
42
|
+
import { runChainCommand, } from './commands/chain.js';
|
|
43
|
+
import { runStatusCommand, defaultStatusHome, } from './commands/status.js';
|
|
44
|
+
import { runStickersCommand } from './commands/stickers.js';
|
|
45
|
+
import { runRepoMapCommand } from './commands/repo-map.js';
|
|
46
|
+
import { runReleaseNotesCommand, defaultReleaseNotesHome, } from './commands/release-notes.js';
|
|
29
47
|
import { runUndoCommand } from './commands/undo.js';
|
|
48
|
+
import { runCompactCommand } from './commands/compact.js';
|
|
49
|
+
import { runRewindCommand } from './commands/rewind.js';
|
|
50
|
+
import { runSessionsCommand } from './commands/sessions.js';
|
|
51
|
+
// Day 4 ADR-0063: persona-memory operator surface (list / recall / write /
|
|
52
|
+
// forget / sync). The runner is shared by `pugi memory` top-level and the
|
|
53
|
+
// in-REPL `/memory` slash so the two surfaces stay single-sourced.
|
|
54
|
+
import { runMemoryCommand } from './commands/memory.js';
|
|
30
55
|
import { runBudgetCommand } from './commands/budget.js';
|
|
56
|
+
import { BARE_MODE_BANNER, isBareMode, setBareMode, } from '../core/bare-mode/index.js';
|
|
57
|
+
import { runCostCommand } from './commands/cost.js';
|
|
58
|
+
import { runShareCommand } from './commands/share.js';
|
|
31
59
|
import { runSkillsCommand } from './commands/skills.js';
|
|
60
|
+
import { runHooksCommand } from './commands/hooks.js';
|
|
61
|
+
import { installDefaultSkills } from '../core/skills/defaults.js';
|
|
32
62
|
import { runAgentsCommand } from './commands/agents.js';
|
|
63
|
+
import { runLspCommand } from './commands/lsp.js';
|
|
64
|
+
import { runPatchCommand } from './commands/patch.js';
|
|
65
|
+
import { runWorktreeCommand } from './commands/worktree.js';
|
|
33
66
|
import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
|
|
34
67
|
import { runReviewConsensus } from './commands/review-consensus.js';
|
|
68
|
+
import { runMcpCommand } from './commands/mcp.js';
|
|
69
|
+
import { runPermissionsCommand } from './commands/permissions.js';
|
|
70
|
+
import { runPlanCommand } from './commands/plan.js';
|
|
71
|
+
import { parsePermissionMode } from '../core/permissions/index.js';
|
|
72
|
+
import { protectedTargetReason } from '../core/permission.js';
|
|
73
|
+
import { DECOMPOSE_PROMPT_SUFFIX, parseDecompositionFromText, writeDecomposition, } from './plan-decompose.js';
|
|
35
74
|
import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
|
|
36
75
|
import { slugForCwd } from '../core/repl/history.js';
|
|
37
76
|
import { dispatchEdit, } from '../core/edits/index.js';
|
|
@@ -46,19 +85,39 @@ import { dispatchEdit, } from '../core/edits/index.js';
|
|
|
46
85
|
* packages/pugi-sdk/package.json); the publish workflow validates the
|
|
47
86
|
* three are in lockstep.
|
|
48
87
|
*/
|
|
49
|
-
|
|
88
|
+
// PR-CLI-SERVER-VERSION-HANDSHAKE (#225). PUGI_CLI_VERSION lives in
|
|
89
|
+
// `runtime/version.ts` now so the engine transport interceptor can
|
|
90
|
+
// import it without dragging in the cli.ts module graph. Re-exported
|
|
91
|
+
// here under the original name so every existing reader (`pugi version`,
|
|
92
|
+
// `pugi doctor --json`, splash render, telemetry) keeps working with
|
|
93
|
+
// zero churn. Bumping the CLI version is still a single-file edit —
|
|
94
|
+
// just on `runtime/version.ts` instead of here. The β1 sanitizer that
|
|
95
|
+
// guarded against `workspace:*` leaks moved with the constant.
|
|
96
|
+
import { PUGI_CLI_VERSION, sanitizeSemver } from './version.js';
|
|
50
97
|
const handlers = {
|
|
51
98
|
accounts,
|
|
52
99
|
agents: dispatchAgents,
|
|
53
100
|
ask: dispatchAsk,
|
|
54
101
|
build: runEngineTask('build_task'),
|
|
55
102
|
budget: dispatchBudget,
|
|
103
|
+
// Wave 6 (2026-05-27): `pugi chain` walks the deterministic 7-step
|
|
104
|
+
// artifact pipeline (PRD → ADR → mindmap → ER → sequence → tests →
|
|
105
|
+
// code). Subcommands: new / status / next / show / export / list.
|
|
106
|
+
// Same handler powers the in-REPL `/chain` slash via session.ts.
|
|
107
|
+
chain: dispatchChain,
|
|
56
108
|
code: runEngineTask('code'),
|
|
57
109
|
config: dispatchConfig,
|
|
110
|
+
cost: dispatchCost,
|
|
58
111
|
delegate: dispatchDelegate,
|
|
112
|
+
// Leak L10 (2026-05-27): `pugi dispatch list-cache-refs` /
|
|
113
|
+
// `clear-cache-refs` operate on `.pugi/cache-refs/` — the persisted
|
|
114
|
+
// prompt-cache inheritance handles for fork-subagent dispatches. The
|
|
115
|
+
// handler module lives in commands/dispatch.ts so the table stays narrow.
|
|
116
|
+
dispatch: dispatchSubagentCacheRefs,
|
|
59
117
|
deploy: dispatchDeploy,
|
|
60
118
|
doctor,
|
|
61
119
|
explain: runEngineTask('explain'),
|
|
120
|
+
hooks: dispatchHooks,
|
|
62
121
|
fix: runEngineTask('fix'),
|
|
63
122
|
handoff,
|
|
64
123
|
help,
|
|
@@ -67,19 +126,94 @@ const handlers = {
|
|
|
67
126
|
jobs,
|
|
68
127
|
login,
|
|
69
128
|
logout,
|
|
70
|
-
|
|
129
|
+
lsp: dispatchLsp,
|
|
130
|
+
mcp: dispatchMcp,
|
|
131
|
+
// ADR-0063 Day 4: `pugi memory list|recall|write|forget|sync`. Routes
|
|
132
|
+
// to `runMemoryCommand` (admin-api `/api/persona-memory` + offline
|
|
133
|
+
// queue at `~/.pugi/memory-queue.jsonl`).
|
|
134
|
+
memory: dispatchMemory,
|
|
135
|
+
patch: dispatchPatch,
|
|
136
|
+
permissions: dispatchPermissions,
|
|
137
|
+
perms: dispatchPermissions,
|
|
138
|
+
plan: dispatchPlan,
|
|
71
139
|
'plan-review': dispatchPlanReview,
|
|
140
|
+
// Wave 6 (2026-05-27): `pugi prd-check` verifies PRD acceptance
|
|
141
|
+
// criteria against committed code/tests/docs/commands BEFORE an
|
|
142
|
+
// operator (or autonomous agent) claims a feature done. Same
|
|
143
|
+
// handler powers the in-REPL `/prd-check` slash via session.ts.
|
|
144
|
+
'prd-check': dispatchPrdCheck,
|
|
72
145
|
privacy: dispatchPrivacy,
|
|
146
|
+
// L24 (2026-05-27): `pugi release-notes` shows the bundled CHANGELOG
|
|
147
|
+
// diff between the operator's last-seen version + installed version.
|
|
148
|
+
// The slash counterpart `/release-notes` shares this handler via the
|
|
149
|
+
// shared `runReleaseNotesCommand` runner.
|
|
150
|
+
'release-notes': releaseNotes,
|
|
151
|
+
releaseNotes,
|
|
152
|
+
// PAVF-7 (2026-05-27): `pugi report --from-error` captures the
|
|
153
|
+
// most-recent failed session as a redacted bundle so operators can
|
|
154
|
+
// file clean bug reports without manual log-grepping.
|
|
155
|
+
report: dispatchReport,
|
|
73
156
|
review,
|
|
74
157
|
resume,
|
|
75
158
|
roster: dispatchRoster,
|
|
76
159
|
sessions,
|
|
160
|
+
share: dispatchShare,
|
|
77
161
|
skills: dispatchSkills,
|
|
162
|
+
status,
|
|
163
|
+
stickers,
|
|
164
|
+
// Leak L28 (2026-05-27): `pugi repo-map` walks the source tree,
|
|
165
|
+
// extracts top-level function / class / interface / type / enum
|
|
166
|
+
// declarations + JSDoc summaries, caches the result in
|
|
167
|
+
// `.pugi/repo-map.json`, and renders the compact markdown listing.
|
|
168
|
+
// Same builder powers the engine boot-time system-prompt injection
|
|
169
|
+
// — running the CLI command shows the operator EXACTLY what the
|
|
170
|
+
// engine would see.
|
|
171
|
+
'repo-map': dispatchRepoMap,
|
|
172
|
+
// Leak L21 (2026-05-27): in-CLI feedback collector. Shares the
|
|
173
|
+
// same handler as the in-REPL `/feedback` slash; the wrapper just
|
|
174
|
+
// routes TTY vs non-TTY before mounting Ink.
|
|
175
|
+
feedback: dispatchFeedback,
|
|
176
|
+
// BIG TRACK 10 Phase 1 (2026-05-27): `pugi smoke` runs the scenario
|
|
177
|
+
// corpus through `pugi --headless` and reports pass/fail per
|
|
178
|
+
// scenario. Subcommand-only — no slash counterpart per the Phase 1
|
|
179
|
+
// scope ("no new slash commands; harness is CLI subcommand only").
|
|
180
|
+
smoke: dispatchSmoke,
|
|
78
181
|
sync,
|
|
182
|
+
style: dispatchStyle,
|
|
183
|
+
// Leak L30 (2026-05-27): `pugi theme` flips the local TUI color
|
|
184
|
+
// palette (orthogonal to `pugi style` — that one steers engine
|
|
185
|
+
// prose register). 4 presets: default / dark / light / colorblind.
|
|
186
|
+
theme: dispatchTheme,
|
|
187
|
+
// Leak L25 (2026-05-27): `pugi onboarding` walks the new operator
|
|
188
|
+
// through auth / mode / style / MCP / telemetry. Idempotent;
|
|
189
|
+
// `--reset` clears the marker file so the bare-invocation hint
|
|
190
|
+
// re-arms without nuking persisted defaults.
|
|
191
|
+
onboarding: dispatchOnboarding,
|
|
192
|
+
// Leak L26 (2026-05-27): `pugi vim` toggles vim-style modal editing
|
|
193
|
+
// in the REPL input buffer. Bare invocation toggles, `on`/`off`
|
|
194
|
+
// sets explicitly; preference persists in ~/.pugi/config.json.
|
|
195
|
+
vim: dispatchVim,
|
|
79
196
|
undo: dispatchUndo,
|
|
197
|
+
compact: dispatchCompact,
|
|
198
|
+
// Leak L9 (2026-05-27): `pugi rewind [N | --to <id>]` rolls the
|
|
199
|
+
// conversation back to a checkpoint by appending a tombstone marker
|
|
200
|
+
// to the NDJSON event log. The slash counterpart `/rewind` forwards
|
|
201
|
+
// to the same runner via session.ts.
|
|
202
|
+
rewind: dispatchRewind,
|
|
203
|
+
// L19 (2026-05-27): `pugi usage` is an alias of `pugi cost` — same
|
|
204
|
+
// handler, same flags. Operators trained on Claude Code expect either
|
|
205
|
+
// verb to surface the per-model token + USD table.
|
|
206
|
+
usage: dispatchCost,
|
|
207
|
+
// Leak L27 (2026-05-27): `pugi update` — channel-aware npm registry
|
|
208
|
+
// probe + optional npm install shell-out. Same handler powers the
|
|
209
|
+
// in-REPL `/update` slash via the session module. R2 atomic swap
|
|
210
|
+
// deferred to Phase 2 per the sprint plan; npm is the single
|
|
211
|
+
// distribution channel today.
|
|
212
|
+
update: dispatchUpdate,
|
|
80
213
|
version,
|
|
81
214
|
web: dispatchWeb,
|
|
82
215
|
whoami,
|
|
216
|
+
worktree: dispatchWorktree,
|
|
83
217
|
};
|
|
84
218
|
/**
|
|
85
219
|
* α6.3 `pugi ask "<question>"` — surface the office-hours forcing-question
|
|
@@ -250,6 +384,207 @@ async function dispatchPrivacy(args, flags, _session) {
|
|
|
250
384
|
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
251
385
|
});
|
|
252
386
|
}
|
|
387
|
+
/**
|
|
388
|
+
* ADR-0063 Day 4 — `pugi memory <sub>` top-level dispatcher.
|
|
389
|
+
*
|
|
390
|
+
* Forwards to the shared `runMemoryCommand` runner. Exit codes:
|
|
391
|
+
*
|
|
392
|
+
* - 0 — happy paths (listed / recalled / written / forgot / synced /
|
|
393
|
+
* queued_offline / sync_noop / sync_partial)
|
|
394
|
+
* - 1 — unauthenticated / feature_disabled / unknown_sub
|
|
395
|
+
* - 2 — invalid_args
|
|
396
|
+
*
|
|
397
|
+
* `forget_not_found` exits 0 because the operator-visible behaviour
|
|
398
|
+
* (the memory is gone) matches their intent; the JSON envelope still
|
|
399
|
+
* carries the `forget_not_found` status flag for scripted callers.
|
|
400
|
+
*/
|
|
401
|
+
async function dispatchMemory(args, flags, _session) {
|
|
402
|
+
const result = await runMemoryCommand(args, {
|
|
403
|
+
workspaceRoot: process.cwd(),
|
|
404
|
+
json: flags.json,
|
|
405
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
406
|
+
});
|
|
407
|
+
switch (result.status) {
|
|
408
|
+
case 'unauthenticated':
|
|
409
|
+
case 'feature_disabled':
|
|
410
|
+
case 'unknown_sub':
|
|
411
|
+
process.exitCode = 1;
|
|
412
|
+
return;
|
|
413
|
+
case 'invalid_args':
|
|
414
|
+
process.exitCode = 2;
|
|
415
|
+
return;
|
|
416
|
+
default:
|
|
417
|
+
// 'listed' | 'recalled' | 'written' | 'queued_offline' | 'forgot' |
|
|
418
|
+
// 'forget_not_found' | 'synced' | 'sync_partial' | 'sync_noop' — exit 0.
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Leak L18 (2026-05-27) — `pugi style` top-level dispatcher.
|
|
424
|
+
*
|
|
425
|
+
* Forwards to the shared `runStyleCommand` runner. The REPL `/style`
|
|
426
|
+
* slash uses the same runner via a dynamic import inside
|
|
427
|
+
* `core/repl/session.ts` so the two surfaces stay single-sourced.
|
|
428
|
+
*
|
|
429
|
+
* Exit-code policy:
|
|
430
|
+
* - 0 — show / switch / reset / list happy paths
|
|
431
|
+
* - 1 — unknown preset slug
|
|
432
|
+
* - 2 — conflicting flags (`--reset` + positional / `--reset --persist`)
|
|
433
|
+
*
|
|
434
|
+
* The runner returns the code; we attach it to `process.exitCode` so
|
|
435
|
+
* subsequent dispatch wrappers do not clobber it on success.
|
|
436
|
+
*/
|
|
437
|
+
async function dispatchStyle(args, flags, _session) {
|
|
438
|
+
const rc = await runStyleCommand(args, {
|
|
439
|
+
workspaceRoot: process.cwd(),
|
|
440
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
441
|
+
});
|
|
442
|
+
if (rc !== 0)
|
|
443
|
+
process.exitCode = rc;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Leak L30 (2026-05-27) — `pugi theme` top-level dispatcher.
|
|
447
|
+
*
|
|
448
|
+
* Forwards to the shared `runThemeCommand` runner. The REPL `/theme`
|
|
449
|
+
* slash uses the same runner via a dynamic import inside
|
|
450
|
+
* `core/repl/session.ts` so the two surfaces stay single-sourced.
|
|
451
|
+
*
|
|
452
|
+
* Exit-code policy mirrors `dispatchStyle`:
|
|
453
|
+
* - 0 — show / switch / reset / list happy paths
|
|
454
|
+
* - 1 — unknown preset slug
|
|
455
|
+
* - 2 — conflicting flags (`--reset` + positional / `--reset --persist`)
|
|
456
|
+
*
|
|
457
|
+
* The runner returns the code; we attach it to `process.exitCode` so
|
|
458
|
+
* subsequent dispatch wrappers do not clobber it on success.
|
|
459
|
+
*/
|
|
460
|
+
/**
|
|
461
|
+
* Leak L12 (2026-05-27) — `pugi hooks` top-level dispatcher (MVP).
|
|
462
|
+
*
|
|
463
|
+
* Two subcommands:
|
|
464
|
+
* - `pugi hooks list` — show configured hooks per event.
|
|
465
|
+
* - `pugi hooks doctor` — validate `~/.pugi/hooks-mvp.json`.
|
|
466
|
+
*
|
|
467
|
+
* MVP scope: 2 events of 8 (SessionStart + PreToolUse). Remaining 6
|
|
468
|
+
* events (PostToolUse, UserPromptSubmit, Stop, SubagentStop,
|
|
469
|
+
* PreCompact, Notification) deferred to fast-follow PR. The runner
|
|
470
|
+
* pattern established here is reusable for those events without
|
|
471
|
+
* touching this dispatcher.
|
|
472
|
+
*
|
|
473
|
+
* Exit codes:
|
|
474
|
+
* 0 -> happy path.
|
|
475
|
+
* 1 -> config present but invalid (doctor only).
|
|
476
|
+
* 2 -> argument error / unknown subcommand.
|
|
477
|
+
*/
|
|
478
|
+
async function dispatchHooks(args, flags, _session) {
|
|
479
|
+
const rc = await runHooksCommand(args, {
|
|
480
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
481
|
+
});
|
|
482
|
+
if (rc !== 0)
|
|
483
|
+
process.exitCode = rc;
|
|
484
|
+
}
|
|
485
|
+
async function dispatchTheme(args, flags, _session) {
|
|
486
|
+
const rc = await runThemeCommand(args, {
|
|
487
|
+
workspaceRoot: process.cwd(),
|
|
488
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
489
|
+
});
|
|
490
|
+
if (rc !== 0)
|
|
491
|
+
process.exitCode = rc;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* BIG TRACK 10 Phase 1 (2026-05-27) — `pugi smoke` top-level dispatcher.
|
|
495
|
+
*
|
|
496
|
+
* Loads the bundled scenario corpus (`apps/pugi-cli/test/scenarios/`),
|
|
497
|
+
* runs each scenario through `pugi --headless` via the smoke
|
|
498
|
+
* orchestrator, and surfaces the pass/fail summary. `--filter <pat>`
|
|
499
|
+
* subsets the corpus; `--scenarios-dir <path>` swaps in an external
|
|
500
|
+
* dir (handy for project-local scenarios in customer repos).
|
|
501
|
+
*
|
|
502
|
+
* Exit-code policy:
|
|
503
|
+
* 0 — every scenario passed (or filter matched nothing)
|
|
504
|
+
* 1 — at least one scenario failed (assertion, parse error, executor crash)
|
|
505
|
+
* 2 — invalid CLI args (--filter without a value, unknown flag)
|
|
506
|
+
*/
|
|
507
|
+
async function dispatchSmoke(args, flags, _session) {
|
|
508
|
+
const { runSmokeCommand } = await import('../commands/smoke.js');
|
|
509
|
+
const ctx = {
|
|
510
|
+
args,
|
|
511
|
+
json: flags.json,
|
|
512
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
513
|
+
};
|
|
514
|
+
if (flags.smokeFilter !== undefined)
|
|
515
|
+
ctx.filter = flags.smokeFilter;
|
|
516
|
+
const rc = await runSmokeCommand(ctx);
|
|
517
|
+
if (rc !== 0)
|
|
518
|
+
process.exitCode = rc;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Leak L25 (2026-05-27) — `pugi onboarding` top-level dispatcher.
|
|
522
|
+
*
|
|
523
|
+
* Walks the new operator through auth / permission mode / output
|
|
524
|
+
* style / MCP / telemetry consent. The Ink wizard mounts only when
|
|
525
|
+
* stdin is a TTY and `--json` is not set; otherwise we dump the
|
|
526
|
+
* current snapshot + hints in the non-interactive envelope so
|
|
527
|
+
* scripted callers see the same structured payload.
|
|
528
|
+
*
|
|
529
|
+
* Auth status: we resolve credentials once up front and pass the
|
|
530
|
+
* boolean to the runner; the wizard surfaces a `pugi login` hint
|
|
531
|
+
* when auth is missing but DOES NOT block — local defaults are still
|
|
532
|
+
* configurable without an active credential.
|
|
533
|
+
*
|
|
534
|
+
* Exit-code policy:
|
|
535
|
+
* 0 — completed / cancelled / non-interactive / reset
|
|
536
|
+
* 2 — conflicting / unknown flags
|
|
537
|
+
*/
|
|
538
|
+
async function dispatchOnboarding(args, flags, _session) {
|
|
539
|
+
const credential = resolveActiveCredential();
|
|
540
|
+
const rc = await runOnboardingCommand(args, {
|
|
541
|
+
workspaceRoot: process.cwd(),
|
|
542
|
+
env: process.env,
|
|
543
|
+
authPresent: credential !== null,
|
|
544
|
+
interactive: isInteractive(flags) && !flags.json,
|
|
545
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
546
|
+
});
|
|
547
|
+
if (rc !== 0)
|
|
548
|
+
process.exitCode = rc;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Leak L26 (2026-05-27) — `pugi vim` top-level dispatcher.
|
|
552
|
+
*
|
|
553
|
+
* Forwards to the shared `runVimCommand` runner. The REPL `/vim` slash
|
|
554
|
+
* uses the same runner via a dynamic import inside
|
|
555
|
+
* `core/repl/session.ts` so the two surfaces stay single-sourced.
|
|
556
|
+
*
|
|
557
|
+
* Exit-code policy:
|
|
558
|
+
* - 0 — show / enable / disable / toggle happy paths
|
|
559
|
+
* - 2 — unknown subcommand (e.g. `pugi vim chaos`) or too many args
|
|
560
|
+
*/
|
|
561
|
+
async function dispatchVim(args, flags, _session) {
|
|
562
|
+
const rc = await runVimCommand(args, {
|
|
563
|
+
env: process.env,
|
|
564
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
565
|
+
});
|
|
566
|
+
if (rc !== 0)
|
|
567
|
+
process.exitCode = rc;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* PAVF-7 (2026-05-27): `pugi report --from-error` — bundle the most-
|
|
571
|
+
* recent failed session into a redacted local report so operators can
|
|
572
|
+
* file clean bug tickets without manual log-grepping. v1 is local-only
|
|
573
|
+
* (no auto-upload — see commands/report.ts header for the rationale).
|
|
574
|
+
*/
|
|
575
|
+
async function dispatchReport(args, flags, _session) {
|
|
576
|
+
const rc = runReport(args, {
|
|
577
|
+
cwd: process.cwd(),
|
|
578
|
+
json: flags.json,
|
|
579
|
+
emit: (line) => {
|
|
580
|
+
if (!flags.json)
|
|
581
|
+
process.stdout.write(line);
|
|
582
|
+
},
|
|
583
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
584
|
+
});
|
|
585
|
+
if (rc !== 0)
|
|
586
|
+
process.exitCode = rc;
|
|
587
|
+
}
|
|
253
588
|
/**
|
|
254
589
|
* `pugi roster` - α7.5 Phase 1.
|
|
255
590
|
*
|
|
@@ -303,6 +638,42 @@ async function dispatchDelegate(args, flags, _session) {
|
|
|
303
638
|
},
|
|
304
639
|
});
|
|
305
640
|
}
|
|
641
|
+
/**
|
|
642
|
+
* `pugi chain` — Wave 6 artifact chain dispatcher (2026-05-27).
|
|
643
|
+
* Forwards to `runChainCommand` with the live credential + session
|
|
644
|
+
* opener wired so the dispatcher can hit Anvil. The slash counterpart
|
|
645
|
+
* `/chain` shares the same handler via session.ts so the surface
|
|
646
|
+
* stays single-sourced.
|
|
647
|
+
*/
|
|
648
|
+
async function dispatchChain(args, flags, _session) {
|
|
649
|
+
const root = process.cwd();
|
|
650
|
+
// Wave 6 UX: chain reads / writes `.pugi/chains/*` so the auto-init
|
|
651
|
+
// pre-flight matches the engine commands. Auto-login resolves so a
|
|
652
|
+
// first-run `pugi chain new` from a cold cwd surfaces a login prompt
|
|
653
|
+
// instead of a silent unauthenticated error one layer deeper.
|
|
654
|
+
await runAutoInitPreflight(root, flags);
|
|
655
|
+
const auth = await runAutoAuthPreflight(flags);
|
|
656
|
+
const cachedCred = auth.status === 'ready' ? auth.credential : null;
|
|
657
|
+
await runChainCommand(args, {
|
|
658
|
+
cwd: root,
|
|
659
|
+
json: flags.json,
|
|
660
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
661
|
+
resolveConfig: () => {
|
|
662
|
+
// Prefer the pre-flight cached credential to avoid the second
|
|
663
|
+
// disk read (resolveActiveCredential reads ~/.pugi/credentials.json).
|
|
664
|
+
const credential = cachedCred ?? resolveActiveCredential();
|
|
665
|
+
if (!credential)
|
|
666
|
+
return null;
|
|
667
|
+
return buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey });
|
|
668
|
+
},
|
|
669
|
+
openSession: async (config, workspaceCwd) => {
|
|
670
|
+
const result = await openPugiSession(config, { workspaceCwd });
|
|
671
|
+
if (result.status === 'ok')
|
|
672
|
+
return { sessionId: result.response.sessionId };
|
|
673
|
+
return { error: `${result.status}: ${result.message}` };
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
}
|
|
306
677
|
async function dispatchUndo(args, flags, session) {
|
|
307
678
|
await runUndoCommand(args, {
|
|
308
679
|
workspaceRoot: process.cwd(),
|
|
@@ -310,12 +681,261 @@ async function dispatchUndo(args, flags, session) {
|
|
|
310
681
|
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
311
682
|
});
|
|
312
683
|
}
|
|
684
|
+
/**
|
|
685
|
+
* Leak L8 (2026-05-27) — `pugi compact` summarises older REPL turns
|
|
686
|
+
* into a single boundary marker, freeing context for the next `pugi
|
|
687
|
+
* resume <id>`. The slash `/compact` inside a live REPL forwards
|
|
688
|
+
* through the same runner via session.ts so the surface stays single-
|
|
689
|
+
* sourced.
|
|
690
|
+
*/
|
|
691
|
+
async function dispatchCompact(args, flags, _session) {
|
|
692
|
+
// Wave 6 BT 8 (Claude Code parity): parse `--force` / `-f` so the
|
|
693
|
+
// operator can produce a marker against a short session. Auto-trigger
|
|
694
|
+
// paths never pass this flag — only the explicit CLI / slash invocation.
|
|
695
|
+
const force = args.some((t) => t === '--force' || t === '-f');
|
|
696
|
+
const result = await runCompactCommand(args, {
|
|
697
|
+
workspaceRoot: process.cwd(),
|
|
698
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
699
|
+
force,
|
|
700
|
+
});
|
|
701
|
+
if (result.status === 'failed_no_session'
|
|
702
|
+
|| result.status === 'failed_transport'
|
|
703
|
+
|| result.status === 'failed_store') {
|
|
704
|
+
process.exitCode = 1;
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (result.status === 'noop_empty' || result.status === 'noop_recent_marker') {
|
|
708
|
+
process.exitCode = 2;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
313
711
|
async function dispatchBudget(args, flags, _session) {
|
|
314
712
|
await runBudgetCommand(args, {
|
|
315
713
|
workspaceRoot: process.cwd(),
|
|
316
714
|
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
317
715
|
});
|
|
318
716
|
}
|
|
717
|
+
/**
|
|
718
|
+
* Leak L9 (2026-05-27) — `pugi rewind [N | --to <id>]` rolls the
|
|
719
|
+
* conversation back to a checkpoint by appending a tombstone marker to
|
|
720
|
+
* the NDJSON event log. Append-only: events stay durable; `pugi
|
|
721
|
+
* sessions undo-rewind` reverses the operation. The slash `/rewind`
|
|
722
|
+
* forwards through this same runner via session.ts.
|
|
723
|
+
*/
|
|
724
|
+
async function dispatchRewind(args, flags, _session) {
|
|
725
|
+
const result = await runRewindCommand(args, {
|
|
726
|
+
workspaceRoot: process.cwd(),
|
|
727
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
728
|
+
});
|
|
729
|
+
if (result.status === 'failed_no_session'
|
|
730
|
+
|| result.status === 'failed_store') {
|
|
731
|
+
process.exitCode = 1;
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (result.status === 'failed_parse') {
|
|
735
|
+
process.exitCode = 2;
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (result.status === 'noop_zero' || result.status === 'noop_empty') {
|
|
739
|
+
process.exitCode = 2;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Leak L6 — `pugi permissions [mode] [--persist] [--confirm]`.
|
|
744
|
+
*
|
|
745
|
+
* Surface the same intent as the in-REPL `/permissions` slash. Mode
|
|
746
|
+
* arg is positional; `--persist` and `--confirm` are zero-arg flags
|
|
747
|
+
* already consumed by `parseArgs` into `flags.persist` / `flags.confirm`.
|
|
748
|
+
*
|
|
749
|
+
* Examples:
|
|
750
|
+
* pugi permissions -> show current mode + table
|
|
751
|
+
* pugi permissions plan -> flip workspace state to plan
|
|
752
|
+
* pugi permissions allow --persist -> flip + write ~/.pugi/config.json
|
|
753
|
+
* pugi permissions bypass --confirm -> flip to bypass (acknowledge banner)
|
|
754
|
+
*/
|
|
755
|
+
async function dispatchPermissions(args, flags, _session) {
|
|
756
|
+
const head = args[0];
|
|
757
|
+
if (head && parsePermissionMode(head) === null) {
|
|
758
|
+
writeOutput(flags, { error: 'unknown_mode', mode: head }, `Unknown mode '${head}'. Allowed: default, acceptEdits, plan, auto, dontAsk, bypassPermissions (α6 aliases ask/allow/bypass accepted).`);
|
|
759
|
+
process.exitCode = 1;
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const mode = head ? parsePermissionMode(head) : undefined;
|
|
763
|
+
// Wave 6 cleanup (2026-05-27): no positional mode + interactive TTY
|
|
764
|
+
// → mount the Ink picker so the operator can arrow-select. Falls back
|
|
765
|
+
// to the legacy text table on non-TTY / --json / CI so scripted
|
|
766
|
+
// callers (and the deferred follow-up from PR #617) keep working.
|
|
767
|
+
// `bypass` selected from the picker still routes through
|
|
768
|
+
// `runPermissionsCommand` with `confirmBypass: true` — the picker IS
|
|
769
|
+
// the confirm gesture (arrow + Enter is the explicit acknowledge).
|
|
770
|
+
if (!mode && isInteractive(flags) && !flags.json) {
|
|
771
|
+
const { resolveLayeredMode } = await import('./commands/permissions.js');
|
|
772
|
+
const layered = resolveLayeredMode(process.cwd());
|
|
773
|
+
const { renderPermissionsPicker, PermissionsPickerCancelledError } = await import('../tui/render.js');
|
|
774
|
+
try {
|
|
775
|
+
const chosen = await renderPermissionsPicker({
|
|
776
|
+
currentMode: layered.effective,
|
|
777
|
+
sourceLabel: layered.source,
|
|
778
|
+
firstRun: layered.firstRun,
|
|
779
|
+
});
|
|
780
|
+
await runPermissionsCommand({
|
|
781
|
+
mode: chosen,
|
|
782
|
+
persist: Boolean(flags.persist),
|
|
783
|
+
// The picker selection IS the confirm gesture for `bypassPermissions`.
|
|
784
|
+
confirmBypass: chosen === 'bypassPermissions' ? true : Boolean(flags.confirm),
|
|
785
|
+
}, {
|
|
786
|
+
workspaceRoot: process.cwd(),
|
|
787
|
+
writeOutput: (text) => writeOutput(flags, { text }, text),
|
|
788
|
+
});
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
catch (err) {
|
|
792
|
+
if (err instanceof PermissionsPickerCancelledError) {
|
|
793
|
+
writeOutput(flags, { cancelled: true }, 'Permissions picker cancelled. No change.');
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
throw err;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
await runPermissionsCommand({
|
|
800
|
+
...(mode ? { mode } : {}),
|
|
801
|
+
persist: Boolean(flags.persist),
|
|
802
|
+
confirmBypass: Boolean(flags.confirm),
|
|
803
|
+
}, {
|
|
804
|
+
workspaceRoot: process.cwd(),
|
|
805
|
+
writeOutput: (text) => writeOutput(flags, { text }, text),
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* L19 sprint (2026-05-27): `pugi cost` / `pugi usage` top-level surface.
|
|
810
|
+
*
|
|
811
|
+
* Aliased through the handlers table so `pugi usage` reuses the same
|
|
812
|
+
* implementation. The persisted store lives at `<cwd>/.pugi/cost.json`
|
|
813
|
+
* and is shared with the REPL `/cost` / `/usage` slash handlers.
|
|
814
|
+
*/
|
|
815
|
+
async function dispatchCost(args, flags, _session) {
|
|
816
|
+
await runCostCommand(args, {
|
|
817
|
+
workspaceRoot: process.cwd(),
|
|
818
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Leak L20 (2026-05-27): `pugi share` top-level surface. Exports the
|
|
823
|
+
* current session transcript as Markdown to gist (default when `gh` is
|
|
824
|
+
* available) or pugi.io (--pugi). The handler delegates to
|
|
825
|
+
* `runShareCommand` so the slash surface (`/share`) and the shell
|
|
826
|
+
* surface share one code path. JSON output mode is honoured via the
|
|
827
|
+
* shared `writeOutput` wrapper.
|
|
828
|
+
*/
|
|
829
|
+
async function dispatchShare(args, flags, _session) {
|
|
830
|
+
await runShareCommand(args, {
|
|
831
|
+
workspaceRoot: process.cwd(),
|
|
832
|
+
cliVersion: PUGI_CLI_VERSION,
|
|
833
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Leak L7 — `pugi plan [--back | --persist | <prompt...>]`.
|
|
838
|
+
*
|
|
839
|
+
* Quick mode-switch shortcut + optional one-shot engine dispatch. Slash
|
|
840
|
+
* surface `/plan` shares the same `runPlanCommand` helper so the
|
|
841
|
+
* workspace-state writes go through one code path. Argument grammar:
|
|
842
|
+
*
|
|
843
|
+
* pugi plan -> set workspace mode = plan + banner
|
|
844
|
+
* pugi plan --back -> restore the mode that was active
|
|
845
|
+
* before the most recent /plan entry
|
|
846
|
+
* pugi plan --persist -> set + also write ~/.pugi/config.json
|
|
847
|
+
* pugi plan <prompt...> -> set + run `runEngineTask('plan')`
|
|
848
|
+
* with the prompt (existing offline /
|
|
849
|
+
* engine path; the permission gate now
|
|
850
|
+
* sees plan as workspace state)
|
|
851
|
+
* pugi plan <prompt> --auto-back -> ALSO restore previous mode once
|
|
852
|
+
* the engine returns (defaults to
|
|
853
|
+
* leaving the operator in plan
|
|
854
|
+
* mode so they can iterate)
|
|
855
|
+
*
|
|
856
|
+
* The handler intentionally intercepts the mode-switch flags BEFORE
|
|
857
|
+
* delegating to `runEngineTask('plan')` for the prompt path. Without
|
|
858
|
+
* this wrapper, `pugi plan` (no args) would error out of the engine
|
|
859
|
+
* task ("requires a prompt") which is the legacy behaviour; the L7
|
|
860
|
+
* spec wants bare `pugi plan` to be the mode switch.
|
|
861
|
+
*/
|
|
862
|
+
async function dispatchPlan(args, flags, session) {
|
|
863
|
+
// Strip `--back` / `--auto-back` from the positional args — the global
|
|
864
|
+
// parseArgs does not consume them (they are command-local). Anything
|
|
865
|
+
// else stays in `prompt` so the engine sees the operator's text
|
|
866
|
+
// verbatim. The flag parser keeps both `--back` and the spelling
|
|
867
|
+
// variants the operator might type from muscle memory after using
|
|
868
|
+
// `git checkout --` style flows.
|
|
869
|
+
let back = false;
|
|
870
|
+
let autoBack = false;
|
|
871
|
+
const remaining = [];
|
|
872
|
+
for (const arg of args) {
|
|
873
|
+
if (arg === '--back') {
|
|
874
|
+
back = true;
|
|
875
|
+
}
|
|
876
|
+
else if (arg === '--auto-back') {
|
|
877
|
+
autoBack = true;
|
|
878
|
+
}
|
|
879
|
+
else {
|
|
880
|
+
remaining.push(arg);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
const hasPrompt = remaining.length > 0;
|
|
884
|
+
const persist = Boolean(flags.persist);
|
|
885
|
+
// --back and a prompt are mutually exclusive — back is a revert action,
|
|
886
|
+
// not a dispatch one. Refuse the combination with a clear hint instead
|
|
887
|
+
// of silently dropping one or the other.
|
|
888
|
+
if (back && hasPrompt) {
|
|
889
|
+
writeOutput(flags, { ok: false, error: 'pugi plan --back does not accept a prompt; revert first, then dispatch.' }, 'pugi plan --back does not accept a prompt; revert first, then dispatch.');
|
|
890
|
+
process.exitCode = 2;
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
// --back + --auto-back is incoherent (auto-back applies to the
|
|
894
|
+
// dispatch path) — refuse rather than degrade silently.
|
|
895
|
+
if (back && autoBack) {
|
|
896
|
+
writeOutput(flags, { ok: false, error: 'pugi plan --back and --auto-back cannot be combined.' }, 'pugi plan --back and --auto-back cannot be combined.');
|
|
897
|
+
process.exitCode = 2;
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
// When a prompt is going to be dispatched in --json mode, suppress
|
|
901
|
+
// the human-readable banner writes so the engine task remains the
|
|
902
|
+
// single JSON emitter on stdout. The mode write still happens. In
|
|
903
|
+
// human (non --json) mode the banner prints normally so the operator
|
|
904
|
+
// sees the gate-state change before the engine starts thinking.
|
|
905
|
+
const sinkSilent = hasPrompt && flags.json;
|
|
906
|
+
const writeLine = (line) => {
|
|
907
|
+
if (sinkSilent)
|
|
908
|
+
return;
|
|
909
|
+
writeOutput(flags, { text: line }, line);
|
|
910
|
+
};
|
|
911
|
+
const result = await runPlanCommand({ back, persist }, {
|
|
912
|
+
workspaceRoot: process.cwd(),
|
|
913
|
+
writeOutput: writeLine,
|
|
914
|
+
});
|
|
915
|
+
// No prompt → mode-switch only. Done.
|
|
916
|
+
if (!hasPrompt)
|
|
917
|
+
return;
|
|
918
|
+
// Prompt present → fall through to the existing engine task with the
|
|
919
|
+
// remaining args. The workspace mode is now `plan` (or stayed `plan`
|
|
920
|
+
// if already there); the engine sees the same plan-task semantics it
|
|
921
|
+
// always has — read-only schema + executor refusal sentinel — but the
|
|
922
|
+
// permission GATE now also enforces plan independently.
|
|
923
|
+
try {
|
|
924
|
+
await runEngineTask('plan')(remaining, flags, session);
|
|
925
|
+
}
|
|
926
|
+
finally {
|
|
927
|
+
// --auto-back restores the previous mode AFTER the engine returns
|
|
928
|
+
// (success OR failure) so the operator's gate state mirrors a normal
|
|
929
|
+
// `--back` invocation. Without --auto-back the operator stays in
|
|
930
|
+
// plan and can iterate / inspect before acting.
|
|
931
|
+
if (autoBack && (result.verdict === 'entered' || result.verdict === 'persisted')) {
|
|
932
|
+
await runPlanCommand({ back: true, persist: false }, {
|
|
933
|
+
workspaceRoot: process.cwd(),
|
|
934
|
+
writeOutput: writeLine,
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
319
939
|
async function dispatchSkills(args, flags, _session) {
|
|
320
940
|
await runSkillsCommand(args, {
|
|
321
941
|
workspaceRoot: process.cwd(),
|
|
@@ -330,6 +950,19 @@ async function dispatchAgents(args, flags, _session) {
|
|
|
330
950
|
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
331
951
|
});
|
|
332
952
|
}
|
|
953
|
+
/**
|
|
954
|
+
* Leak L10 (2026-05-27): `pugi dispatch <sub>` — operator-facing
|
|
955
|
+
* inspection + GC for fork-subagent prompt-cache inherit refs
|
|
956
|
+
* (.pugi/cache-refs/). Delegates to the standalone runner in
|
|
957
|
+
* commands/dispatch.ts so the cli.ts table stays under control.
|
|
958
|
+
*/
|
|
959
|
+
async function dispatchSubagentCacheRefs(args, flags, _session) {
|
|
960
|
+
await runDispatchCommand(args, {
|
|
961
|
+
workspaceRoot: process.cwd(),
|
|
962
|
+
json: flags.json,
|
|
963
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
964
|
+
});
|
|
965
|
+
}
|
|
333
966
|
/**
|
|
334
967
|
* `pugi web <url>` — Sprint α6.15 Phase 1 quick-win subcommand.
|
|
335
968
|
*
|
|
@@ -371,8 +1004,155 @@ async function dispatchWeb(args, flags, _session) {
|
|
|
371
1004
|
}
|
|
372
1005
|
writeOutput(flags, result, `# ${result.title}\n# ${result.url}\n# fetched ${result.fetched_at}\n\n${result.content_md}`);
|
|
373
1006
|
}
|
|
1007
|
+
/**
|
|
1008
|
+
* α7.7: `pugi lsp <op> <file> [args]` — direct LSP queries. Delegated
|
|
1009
|
+
* to the standalone runner in `./commands/lsp.ts` so the giant cli.ts
|
|
1010
|
+
* dispatch table stays narrow. The runner spawns + tears down the LSP
|
|
1011
|
+
* server per invocation (no daemon yet — that ships in α7.7b).
|
|
1012
|
+
*/
|
|
1013
|
+
async function dispatchLsp(args, flags, _session) {
|
|
1014
|
+
const result = await runLspCommand(args, { cwd: process.cwd(), json: flags.json });
|
|
1015
|
+
console.log(result.text);
|
|
1016
|
+
if (result.exitCode !== 0)
|
|
1017
|
+
process.exitCode = result.exitCode;
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* β4 M6 + M7 + Sl7 (2026-05-26): `pugi mcp <sub>` — MCP execution +
|
|
1021
|
+
* server. `list / trust / deny / install` manage the client-side
|
|
1022
|
+
* registry (the same surface `pugi config mcp ...` exposes); `serve`
|
|
1023
|
+
* boots Pugi-as-MCP-server over stdio (default) or HTTP+SSE; `perms`
|
|
1024
|
+
* inspects + resets the per-(server, tool) permission cache that
|
|
1025
|
+
* gates engine-loop dispatch.
|
|
1026
|
+
*
|
|
1027
|
+
* The serve sub-command never returns under normal conditions — the
|
|
1028
|
+
* stdio path runs until stdin closes (parent agent disconnect) and the
|
|
1029
|
+
* HTTP path runs until SIGINT/SIGTERM. Both honour the optional
|
|
1030
|
+
* AbortSignal we pass through from the REPL slash bridge in β4b.
|
|
1031
|
+
*/
|
|
1032
|
+
async function dispatchMcp(args, flags, _session) {
|
|
1033
|
+
await runMcpCommand(args, {
|
|
1034
|
+
workspaceRoot: process.cwd(),
|
|
1035
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* α7.7: `pugi patch` — apply a unified-diff patch from stdin or a file.
|
|
1040
|
+
* Routes through the same security gate as the Layer A/B/C applicators
|
|
1041
|
+
* (see `src/core/edits/security-gate.ts`). Exit codes mirror the
|
|
1042
|
+
* security taxonomy so CI loops can alert on hostile patches without
|
|
1043
|
+
* confusing them with operator typos.
|
|
1044
|
+
*
|
|
1045
|
+
* R1 fix (2026-05-26, PR #413 r1): pass `flags.dryRun` through so the
|
|
1046
|
+
* top-level parser's consumption of `--dry-run` does not silently
|
|
1047
|
+
* disable dry-run mode on `pugi patch --dry-run < diff.patch`.
|
|
1048
|
+
*/
|
|
1049
|
+
async function dispatchPatch(args, flags, _session) {
|
|
1050
|
+
const result = await runPatchCommand(args, {
|
|
1051
|
+
cwd: process.cwd(),
|
|
1052
|
+
json: flags.json,
|
|
1053
|
+
dryRun: flags.dryRun,
|
|
1054
|
+
});
|
|
1055
|
+
console.log(result.text);
|
|
1056
|
+
if (result.exitCode !== 0)
|
|
1057
|
+
process.exitCode = result.exitCode;
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* α7.7: `pugi worktree <op>` — manual scratch worktree management.
|
|
1061
|
+
* The `pugi build` and `pugi review --consensus` paths use the same
|
|
1062
|
+
* primitives internally (`createWorktree` / `promoteWorktree`); this
|
|
1063
|
+
* surface is the operator escape hatch for debug + experiment flows.
|
|
1064
|
+
*
|
|
1065
|
+
* R1 fix (2026-05-26, PR #413 r1): forward `flags.dryRun` so the
|
|
1066
|
+
* top-level parser's consumption of `--dry-run` does not silently
|
|
1067
|
+
* disable dry-run mode on `pugi worktree promote --dry-run <path>`.
|
|
1068
|
+
*/
|
|
1069
|
+
async function dispatchWorktree(args, flags, _session) {
|
|
1070
|
+
const result = await runWorktreeCommand(args, {
|
|
1071
|
+
cwd: process.cwd(),
|
|
1072
|
+
json: flags.json,
|
|
1073
|
+
dryRun: flags.dryRun,
|
|
1074
|
+
});
|
|
1075
|
+
console.log(result.text);
|
|
1076
|
+
if (result.exitCode !== 0)
|
|
1077
|
+
process.exitCode = result.exitCode;
|
|
1078
|
+
}
|
|
374
1079
|
export async function runCli(argv) {
|
|
375
1080
|
const { command, args, flags, isBareInvocation } = parseArgs(argv);
|
|
1081
|
+
// Leak L22 — print the one-line bare banner once per invocation when
|
|
1082
|
+
// the flag is active and stdout is NOT bound for JSON consumption. The
|
|
1083
|
+
// banner goes to stderr so it never lands in a `--json` envelope or a
|
|
1084
|
+
// pipe-captured stdout stream; operators see it on the terminal,
|
|
1085
|
+
// scripted callers stay clean. Suppressed for `pugi version` / `pugi
|
|
1086
|
+
// help` (short, scripted-friendly surfaces) and when the operator
|
|
1087
|
+
// sets PUGI_BARE without the flag (avoids double-printing across
|
|
1088
|
+
// scripted nested invocations).
|
|
1089
|
+
if (flags.bare &&
|
|
1090
|
+
!flags.json &&
|
|
1091
|
+
command !== 'version' &&
|
|
1092
|
+
command !== 'help' &&
|
|
1093
|
+
argv.includes('--bare')) {
|
|
1094
|
+
process.stderr.write(`${BARE_MODE_BANNER}\n`);
|
|
1095
|
+
}
|
|
1096
|
+
// β-headless dispatch (CEO directive 2026-05-27 "нужно тестирование по
|
|
1097
|
+
// кругу"): when `--print <brief>` is set we route to the headless
|
|
1098
|
+
// runner BEFORE the REPL / splash / command branches. The runner
|
|
1099
|
+
// never mounts Ink, never opens raw stdin, never prints the splash
|
|
1100
|
+
// — only the structured event stream lands on stdout. Same engine
|
|
1101
|
+
// adapter path the REPL uses (no fork), only the output sink
|
|
1102
|
+
// differs.
|
|
1103
|
+
if (typeof flags.print === 'string') {
|
|
1104
|
+
const { runHeadlessPrint } = await import('./headless.js');
|
|
1105
|
+
// Default to NDJSON when stdout is not a TTY OR when --json is set
|
|
1106
|
+
// explicitly. A human running `pugi --print "..."` in their
|
|
1107
|
+
// terminal without flags gets the readable text sink; a pipe gets
|
|
1108
|
+
// the machine-readable stream.
|
|
1109
|
+
const wantJson = flags.json || !process.stdout.isTTY;
|
|
1110
|
+
const headlessFactory = getEngineClientFactory();
|
|
1111
|
+
const exitCode = await runHeadlessPrint({
|
|
1112
|
+
prompt: flags.print,
|
|
1113
|
+
json: wantJson,
|
|
1114
|
+
cwd: flags.cwd ?? process.cwd(),
|
|
1115
|
+
...(flags.workspace ? { workspace: flags.workspace } : {}),
|
|
1116
|
+
...(flags.sessionId ? { sessionIdOverride: flags.sessionId } : {}),
|
|
1117
|
+
...(flags.timeoutSeconds ? { timeoutSeconds: flags.timeoutSeconds } : {}),
|
|
1118
|
+
noTools: flags.noTools,
|
|
1119
|
+
...(flags.maxTurns ? { maxTurns: flags.maxTurns } : {}),
|
|
1120
|
+
...(headlessFactory ? { engineClientFactory: headlessFactory } : {}),
|
|
1121
|
+
...(headlessStdoutWriter ? { stdoutWrite: headlessStdoutWriter } : {}),
|
|
1122
|
+
...(headlessStderrWriter ? { stderrWrite: headlessStderrWriter } : {}),
|
|
1123
|
+
});
|
|
1124
|
+
process.exitCode = exitCode;
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
// BIG TRACK 10 Phase 1 (2026-05-27) — `--headless` flag. When the
|
|
1128
|
+
// operator (or harness) passes `--headless` on a bare/repl
|
|
1129
|
+
// invocation we route into the multi-turn line-by-line headless
|
|
1130
|
+
// loop. Differs from `--print` (one-shot): headless reads stdin
|
|
1131
|
+
// until close. The dispatch lives BEFORE the REPL / splash branches
|
|
1132
|
+
// so the Ink TUI never mounts. Suppressed when `--print` is also
|
|
1133
|
+
// set (the one-shot variant wins — explicit single-turn overrides
|
|
1134
|
+
// the multi-turn loop).
|
|
1135
|
+
if (flags.headless && typeof flags.print !== 'string') {
|
|
1136
|
+
const { runHeadlessRepl } = await import('./headless-repl.js');
|
|
1137
|
+
const exitCode = await runHeadlessRepl({
|
|
1138
|
+
cwd: flags.cwd ?? process.cwd(),
|
|
1139
|
+
});
|
|
1140
|
+
process.exitCode = exitCode;
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
// Leak L25 (2026-05-27): first-run hint. When the operator types a
|
|
1144
|
+
// bare `pugi` on a real TTY AND the onboarding marker is absent, drop
|
|
1145
|
+
// a one-line hint on stderr BEFORE the REPL splash mounts. Stderr so
|
|
1146
|
+
// the line never lands in a `--json` envelope or a scripted stdout
|
|
1147
|
+
// pipe; suppressed when --json is set or the operator already walked
|
|
1148
|
+
// the wizard. The marker check is best-effort — a fs glitch returns
|
|
1149
|
+
// false and we print the hint, which is harmless.
|
|
1150
|
+
if (isBareInvocation
|
|
1151
|
+
&& isInteractive(flags)
|
|
1152
|
+
&& !flags.json
|
|
1153
|
+
&& !isOnboarded(process.env)) {
|
|
1154
|
+
process.stderr.write('Tip: run `pugi onboarding` to configure defaults.\n');
|
|
1155
|
+
}
|
|
376
1156
|
// Bare `pugi` on a TTY enters the REPL-by-default agentic session
|
|
377
1157
|
// (Sprint α5.7, ADR-0056). The REPL is the customer-facing surface
|
|
378
1158
|
// that brings Pugi to parity with Claude Code / Codex CLI. When the
|
|
@@ -447,6 +1227,7 @@ function parseArgs(argv) {
|
|
|
447
1227
|
offline: false,
|
|
448
1228
|
noTty: false,
|
|
449
1229
|
allowFetch: false,
|
|
1230
|
+
allowSearch: false,
|
|
450
1231
|
noUpdateCheck: false,
|
|
451
1232
|
noSplash: process.env.PUGI_SKIP_SPLASH === '1',
|
|
452
1233
|
// Claude triple-review P1 PR #369: default tool-stream pane HIDDEN
|
|
@@ -456,13 +1237,61 @@ function parseArgs(argv) {
|
|
|
456
1237
|
// "Mira: ..." prose, never "Read(path)" prefix) OR fires falsely on
|
|
457
1238
|
// accidental `Verb(noun)` shapes producing stuck `running` rows.
|
|
458
1239
|
// Hide by default; opt-in via `--tool-stream` OR PUGI_TOOL_STREAM=1
|
|
459
|
-
// for development/testing. Will flip
|
|
1240
|
+
// for development/testing. Will flip to default ON when backend
|
|
460
1241
|
// emits real tool events (filed as α6.13.X follow-up).
|
|
461
1242
|
noToolStream: process.env.PUGI_TOOL_STREAM === '1' || process.env.PUGI_HIDE_TOOL_STREAM === '1'
|
|
462
1243
|
? process.env.PUGI_HIDE_TOOL_STREAM === '1'
|
|
463
1244
|
: true,
|
|
1245
|
+
noDefaults: process.env.PUGI_INIT_NO_DEFAULTS === '1',
|
|
1246
|
+
// Wave 6 UX (2026-05-27): auto-init / auto-login opt-outs. Default
|
|
1247
|
+
// OFF (auto-init + auto-login are on by default on an interactive
|
|
1248
|
+
// TTY). PUGI_NO_AUTO_* env vars provide a per-shell escape hatch
|
|
1249
|
+
// without needing к thread the flag through every invocation.
|
|
1250
|
+
noInit: process.env.PUGI_NO_AUTO_INIT === '1',
|
|
1251
|
+
noLogin: process.env.PUGI_NO_AUTO_LOGIN === '1',
|
|
1252
|
+
decompose: false,
|
|
1253
|
+
// β-headless: --no-tools default OFF so existing flag-free invocations
|
|
1254
|
+
// keep tool advertisement. Flipped only by explicit operator opt-in.
|
|
1255
|
+
noTools: false,
|
|
1256
|
+
// Leak L6 — `pugi permissions <mode> --persist/--confirm`. Default
|
|
1257
|
+
// false so existing invocations stay no-op on the new permission
|
|
1258
|
+
// surface.
|
|
1259
|
+
persist: false,
|
|
1260
|
+
confirm: false,
|
|
1261
|
+
// Leak L22 — `--bare` flag (skip project auto-discovery). Default
|
|
1262
|
+
// honors the env var so a wrapper script that exports PUGI_BARE=1
|
|
1263
|
+
// keeps the bit even when the operator forgets the flag, and the
|
|
1264
|
+
// explicit flag overrides on the way through the loop below.
|
|
1265
|
+
bare: isBareMode(),
|
|
1266
|
+
// Leak L33 — `--ascii-only` for `pugi stickers`. Default off so the
|
|
1267
|
+
// interactive surface keeps its boxed renderer; opt-in via flag
|
|
1268
|
+
// for pipe / script use.
|
|
1269
|
+
asciiOnly: false,
|
|
1270
|
+
// Leak L24 — `--reset` for `pugi release-notes`. Default off so a
|
|
1271
|
+
// bare invocation only surfaces new sections. Opt-in to force the
|
|
1272
|
+
// full bundled changelog к re-render (clears the on-disk marker).
|
|
1273
|
+
reset: false,
|
|
1274
|
+
// Leak L28 — `--refresh` for `pugi repo-map`. Default off so a
|
|
1275
|
+
// bare invocation hits the cache when mtime + size match; opt-in
|
|
1276
|
+
// for a cold rebuild from the source tree.
|
|
1277
|
+
refresh: false,
|
|
1278
|
+
// BIG TRACK 10 Phase 1 — `--headless` for multi-turn programmatic
|
|
1279
|
+
// drive. Default off; explicit opt-in only. The CLI ALSO honors
|
|
1280
|
+
// `PUGI_HEADLESS=1` so the smoke harness can pre-set the env when
|
|
1281
|
+
// a wrapper script forgets the flag.
|
|
1282
|
+
headless: process.env.PUGI_HEADLESS === '1',
|
|
464
1283
|
};
|
|
465
1284
|
const args = [];
|
|
1285
|
+
// Leak L22: scan for `--bare` BEFORE the early-return short-circuits
|
|
1286
|
+
// below. Operators may pass `pugi --bare --version` or `pugi --bare
|
|
1287
|
+
// --help` and the short-circuit return must still flip the bare bit
|
|
1288
|
+
// so subprocesses + env-consulting modules see the activated state.
|
|
1289
|
+
// The bit is idempotent — re-applied inside the main loop below for
|
|
1290
|
+
// non-short-circuit paths.
|
|
1291
|
+
if (argv.includes('--bare')) {
|
|
1292
|
+
flags.bare = true;
|
|
1293
|
+
setBareMode();
|
|
1294
|
+
}
|
|
466
1295
|
// Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
|
|
467
1296
|
// (npm uses --version on every published bin, Homebrew formula uses it in
|
|
468
1297
|
// the test block). Normalize them to the `version` command so users can
|
|
@@ -493,7 +1322,7 @@ function parseArgs(argv) {
|
|
|
493
1322
|
else if (arg === '--consensus') {
|
|
494
1323
|
// α6.7: customer-facing 3-model consensus review. Routes through
|
|
495
1324
|
// the SSE-based runtime gate rather than the legacy artifact
|
|
496
|
-
// writer. The triple flag stays unset
|
|
1325
|
+
// writer. The triple flag stays unset so the existing
|
|
497
1326
|
// performRemoteTripleReview path is never accidentally entered.
|
|
498
1327
|
flags.consensus = true;
|
|
499
1328
|
}
|
|
@@ -506,6 +1335,12 @@ function parseArgs(argv) {
|
|
|
506
1335
|
else if (arg === '--allow-fetch') {
|
|
507
1336
|
flags.allowFetch = true;
|
|
508
1337
|
}
|
|
1338
|
+
else if (arg === '--allow-search') {
|
|
1339
|
+
// β1b T4 (2026-05-26): unlock the `web_search` tool for one
|
|
1340
|
+
// invocation, mirroring the `--allow-fetch` gate. Distinct flag
|
|
1341
|
+
// because an operator may want to query without fetching pages.
|
|
1342
|
+
flags.allowSearch = true;
|
|
1343
|
+
}
|
|
509
1344
|
else if (arg === '--no-update-check') {
|
|
510
1345
|
flags.noUpdateCheck = true;
|
|
511
1346
|
}
|
|
@@ -516,10 +1351,51 @@ function parseArgs(argv) {
|
|
|
516
1351
|
flags.noToolStream = true;
|
|
517
1352
|
}
|
|
518
1353
|
else if (arg === '--tool-stream') {
|
|
519
|
-
// Opt-in
|
|
520
|
-
// pane shows
|
|
1354
|
+
// Opt-in for α6.12 dev/testing — backend tool events not live yet,
|
|
1355
|
+
// pane shows synthesized heuristic OR empty placeholder
|
|
521
1356
|
flags.noToolStream = false;
|
|
522
1357
|
}
|
|
1358
|
+
else if (arg === '--no-defaults') {
|
|
1359
|
+
// Init-only flag: skip the bundled default-skills install. Parsed
|
|
1360
|
+
// at the global level for consistency with --no-splash / --no-tool-stream.
|
|
1361
|
+
flags.noDefaults = true;
|
|
1362
|
+
}
|
|
1363
|
+
else if (arg === '--ascii-only') {
|
|
1364
|
+
// Leak L33 — `pugi stickers --ascii-only` skips the Ink boxed
|
|
1365
|
+
// renderer. Parsed globally so the dispatcher can pass the flag
|
|
1366
|
+
// through to runStickersCommand without per-command argv slicing.
|
|
1367
|
+
flags.asciiOnly = true;
|
|
1368
|
+
}
|
|
1369
|
+
else if (arg === '--reset') {
|
|
1370
|
+
// Leak L24 — `pugi release-notes --reset` clears the on-disk
|
|
1371
|
+
// `~/.pugi/.last-seen-version` marker so the full bundled
|
|
1372
|
+
// changelog re-renders. Parsed globally for symmetry with the
|
|
1373
|
+
// rest of the flag grammar; `runReleaseNotesCommand` is the
|
|
1374
|
+
// single consumer today.
|
|
1375
|
+
flags.reset = true;
|
|
1376
|
+
}
|
|
1377
|
+
else if (arg === '--refresh') {
|
|
1378
|
+
// Leak L28 — `pugi repo-map --refresh` busts the cache and
|
|
1379
|
+
// rebuilds the AST-light summary from a cold scan. Parsed
|
|
1380
|
+
// globally for symmetry with the rest of the flag grammar;
|
|
1381
|
+
// `runRepoMapCommand` is the single consumer today.
|
|
1382
|
+
flags.refresh = true;
|
|
1383
|
+
}
|
|
1384
|
+
else if (arg === '--format=json' || arg === '--format' && argv[index + 1] === 'json') {
|
|
1385
|
+
// Leak L28 — `pugi repo-map --format=json` is a per-command
|
|
1386
|
+
// synonym for the global `--json` flag. The L28 spec calls
|
|
1387
|
+
// out the `--format=json` shape explicitly so we accept it
|
|
1388
|
+
// verbatim and route through the existing JSON envelope.
|
|
1389
|
+
flags.json = true;
|
|
1390
|
+
if (arg === '--format')
|
|
1391
|
+
index += 1;
|
|
1392
|
+
}
|
|
1393
|
+
else if (arg === '--decompose') {
|
|
1394
|
+
// α6.8 EXTEND PR1: plan-only flag. Other engine commands ignore
|
|
1395
|
+
// it. Parsed globally for symmetry with the rest of the flag
|
|
1396
|
+
// grammar; `runEngineTask('plan')` is the single consumer.
|
|
1397
|
+
flags.decompose = true;
|
|
1398
|
+
}
|
|
523
1399
|
else if (arg.startsWith('--privacy=')) {
|
|
524
1400
|
flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
|
|
525
1401
|
}
|
|
@@ -530,18 +1406,224 @@ function parseArgs(argv) {
|
|
|
530
1406
|
flags.privacy = parsePrivacyMode(next);
|
|
531
1407
|
index += 1;
|
|
532
1408
|
}
|
|
1409
|
+
else if (arg === '--print') {
|
|
1410
|
+
// β-headless: top-level `--print <brief>` runs a single
|
|
1411
|
+
// non-interactive engine turn. Consumes the next argv token as
|
|
1412
|
+
// the brief — refusing if it looks like another flag so a
|
|
1413
|
+
// dangling `--print --json` does not silently swallow `--json`.
|
|
1414
|
+
const next = argv[index + 1];
|
|
1415
|
+
if (!next || next.startsWith('--')) {
|
|
1416
|
+
throw new Error('--print requires a brief (e.g. --print "create word_counter.py")');
|
|
1417
|
+
}
|
|
1418
|
+
flags.print = next;
|
|
1419
|
+
index += 1;
|
|
1420
|
+
}
|
|
1421
|
+
else if (arg.startsWith('--print=')) {
|
|
1422
|
+
flags.print = arg.slice('--print='.length);
|
|
1423
|
+
}
|
|
1424
|
+
else if (arg === '--cwd') {
|
|
1425
|
+
const next = argv[index + 1];
|
|
1426
|
+
if (!next || next.startsWith('--'))
|
|
1427
|
+
throw new Error('--cwd requires a path');
|
|
1428
|
+
flags.cwd = next;
|
|
1429
|
+
index += 1;
|
|
1430
|
+
}
|
|
1431
|
+
else if (arg.startsWith('--cwd=')) {
|
|
1432
|
+
flags.cwd = arg.slice('--cwd='.length);
|
|
1433
|
+
}
|
|
1434
|
+
else if (arg === '--workspace') {
|
|
1435
|
+
const next = argv[index + 1];
|
|
1436
|
+
if (!next || next.startsWith('--'))
|
|
1437
|
+
throw new Error('--workspace requires a slug');
|
|
1438
|
+
flags.workspace = next;
|
|
1439
|
+
index += 1;
|
|
1440
|
+
}
|
|
1441
|
+
else if (arg.startsWith('--workspace=')) {
|
|
1442
|
+
flags.workspace = arg.slice('--workspace='.length);
|
|
1443
|
+
}
|
|
1444
|
+
else if (arg === '--session') {
|
|
1445
|
+
const next = argv[index + 1];
|
|
1446
|
+
if (!next || next.startsWith('--'))
|
|
1447
|
+
throw new Error('--session requires an id');
|
|
1448
|
+
flags.sessionId = next;
|
|
1449
|
+
index += 1;
|
|
1450
|
+
}
|
|
1451
|
+
else if (arg.startsWith('--session=')) {
|
|
1452
|
+
flags.sessionId = arg.slice('--session='.length);
|
|
1453
|
+
}
|
|
1454
|
+
else if (arg === '--timeout') {
|
|
1455
|
+
const next = argv[index + 1];
|
|
1456
|
+
if (!next || next.startsWith('--'))
|
|
1457
|
+
throw new Error('--timeout requires seconds');
|
|
1458
|
+
const parsed = Number(next);
|
|
1459
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
1460
|
+
throw new Error(`--timeout requires positive seconds, got "${next}"`);
|
|
1461
|
+
}
|
|
1462
|
+
flags.timeoutSeconds = parsed;
|
|
1463
|
+
index += 1;
|
|
1464
|
+
}
|
|
1465
|
+
else if (arg.startsWith('--timeout=')) {
|
|
1466
|
+
const raw = arg.slice('--timeout='.length);
|
|
1467
|
+
const parsed = Number(raw);
|
|
1468
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
1469
|
+
throw new Error(`--timeout requires positive seconds, got "${raw}"`);
|
|
1470
|
+
}
|
|
1471
|
+
flags.timeoutSeconds = parsed;
|
|
1472
|
+
}
|
|
1473
|
+
else if (arg === '--no-tools') {
|
|
1474
|
+
flags.noTools = true;
|
|
1475
|
+
}
|
|
1476
|
+
else if (arg === '--max-turns') {
|
|
1477
|
+
const next = argv[index + 1];
|
|
1478
|
+
if (!next || next.startsWith('--'))
|
|
1479
|
+
throw new Error('--max-turns requires an integer');
|
|
1480
|
+
const parsed = Number(next);
|
|
1481
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
1482
|
+
throw new Error(`--max-turns requires positive integer, got "${next}"`);
|
|
1483
|
+
}
|
|
1484
|
+
flags.maxTurns = parsed;
|
|
1485
|
+
index += 1;
|
|
1486
|
+
}
|
|
1487
|
+
else if (arg.startsWith('--max-turns=')) {
|
|
1488
|
+
const raw = arg.slice('--max-turns='.length);
|
|
1489
|
+
const parsed = Number(raw);
|
|
1490
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
1491
|
+
throw new Error(`--max-turns requires positive integer, got "${raw}"`);
|
|
1492
|
+
}
|
|
1493
|
+
flags.maxTurns = parsed;
|
|
1494
|
+
}
|
|
1495
|
+
else if (arg.startsWith('--commit=')) {
|
|
1496
|
+
// `pugi review --triple --commit <SHA>` activates the multi-
|
|
1497
|
+
// provider routing path against a specific revision.
|
|
1498
|
+
flags.commit = arg.slice('--commit='.length);
|
|
1499
|
+
}
|
|
1500
|
+
else if (arg === '--commit') {
|
|
1501
|
+
const next = argv[index + 1];
|
|
1502
|
+
if (!next)
|
|
1503
|
+
throw new Error('--commit requires a SHA or ref');
|
|
1504
|
+
flags.commit = next;
|
|
1505
|
+
index += 1;
|
|
1506
|
+
}
|
|
1507
|
+
else if (arg.startsWith('--base=')) {
|
|
1508
|
+
flags.base = arg.slice('--base='.length);
|
|
1509
|
+
}
|
|
1510
|
+
else if (arg === '--base') {
|
|
1511
|
+
const next = argv[index + 1];
|
|
1512
|
+
if (!next)
|
|
1513
|
+
throw new Error('--base requires a ref');
|
|
1514
|
+
flags.base = next;
|
|
1515
|
+
index += 1;
|
|
1516
|
+
}
|
|
1517
|
+
else if (arg.startsWith('--mode=')) {
|
|
1518
|
+
// Leak L6: top-level `--mode plan|ask|allow|bypass`. Validation
|
|
1519
|
+
// happens at the consumer side (parsePermissionMode) so the
|
|
1520
|
+
// parser stays string-typed; an invalid value surfaces a clean
|
|
1521
|
+
// error in the dispatcher rather than blowing up here.
|
|
1522
|
+
flags.mode = arg.slice('--mode='.length);
|
|
1523
|
+
}
|
|
1524
|
+
else if (arg === '--mode') {
|
|
1525
|
+
const next = argv[index + 1];
|
|
1526
|
+
if (!next || next.startsWith('--')) {
|
|
1527
|
+
throw new Error('--mode requires default|acceptEdits|plan|auto|dontAsk|bypassPermissions (α6 aliases ask|allow|bypass accepted)');
|
|
1528
|
+
}
|
|
1529
|
+
flags.mode = next;
|
|
1530
|
+
index += 1;
|
|
1531
|
+
}
|
|
1532
|
+
else if (arg === '--persist') {
|
|
1533
|
+
// Leak L6: paired with `pugi permissions <mode>` to also write
|
|
1534
|
+
// the mode to ~/.pugi/config.json::defaultPermissionMode.
|
|
1535
|
+
flags.persist = true;
|
|
1536
|
+
}
|
|
1537
|
+
else if (arg === '--confirm') {
|
|
1538
|
+
// Leak L6: required for `pugi permissions bypass` (bypass
|
|
1539
|
+
// disables policy hooks; the gate refuses the flip without
|
|
1540
|
+
// acknowledgement).
|
|
1541
|
+
flags.confirm = true;
|
|
1542
|
+
}
|
|
1543
|
+
else if (arg === '--bare') {
|
|
1544
|
+
// Leak L22: disable project auto-discovery for this invocation.
|
|
1545
|
+
// Set BOTH the parsed flag and the process env so downstream
|
|
1546
|
+
// modules consulting `isBareMode()` (markdown-traverse callsite,
|
|
1547
|
+
// REPL auto-init gate, doctor probe, subprocess spawns) see a
|
|
1548
|
+
// coherent activated state without re-threading the bit through
|
|
1549
|
+
// every call signature.
|
|
1550
|
+
flags.bare = true;
|
|
1551
|
+
setBareMode();
|
|
1552
|
+
}
|
|
1553
|
+
else if (arg === '--no-init') {
|
|
1554
|
+
// Wave 6 UX (2026-05-27): opt-out for the auto-init pre-flight
|
|
1555
|
+
// wrapper. The flag-driven path mirrors PUGI_NO_AUTO_INIT=1 so a
|
|
1556
|
+
// single invocation can override the env state and vice versa.
|
|
1557
|
+
flags.noInit = true;
|
|
1558
|
+
}
|
|
1559
|
+
else if (arg === '--no-login') {
|
|
1560
|
+
// Wave 6 UX (2026-05-27): opt-out for the auto-login pre-flight
|
|
1561
|
+
// wrapper. The auth resolution still runs (env / file paths) —
|
|
1562
|
+
// only the inline device-flow launch is suppressed.
|
|
1563
|
+
flags.noLogin = true;
|
|
1564
|
+
}
|
|
1565
|
+
else if (arg === '--headless') {
|
|
1566
|
+
// BIG TRACK 10 Phase 1 — line-by-line stdin → engine → JSON
|
|
1567
|
+
// envelopes on stdout. Distinct from `--print` (single-shot).
|
|
1568
|
+
// The dispatcher routes to `runHeadlessRepl` BEFORE the Ink
|
|
1569
|
+
// REPL when this flag is set on a bare/repl invocation.
|
|
1570
|
+
flags.headless = true;
|
|
1571
|
+
}
|
|
1572
|
+
else if (arg === '--filter') {
|
|
1573
|
+
// BIG TRACK 10 Phase 1 — `pugi smoke --filter <pattern>`.
|
|
1574
|
+
// Generic flag name so future commands (e.g. `pugi sessions
|
|
1575
|
+
// --filter`) can reuse it without a second flag wired through
|
|
1576
|
+
// parseArgs.
|
|
1577
|
+
const next = argv[index + 1];
|
|
1578
|
+
if (!next || next.startsWith('--')) {
|
|
1579
|
+
throw new Error('--filter requires a pattern (substring or *-glob)');
|
|
1580
|
+
}
|
|
1581
|
+
flags.smokeFilter = next;
|
|
1582
|
+
index += 1;
|
|
1583
|
+
}
|
|
1584
|
+
else if (arg.startsWith('--filter=')) {
|
|
1585
|
+
flags.smokeFilter = arg.slice('--filter='.length);
|
|
1586
|
+
}
|
|
533
1587
|
else {
|
|
534
1588
|
args.push(arg);
|
|
535
1589
|
}
|
|
536
1590
|
}
|
|
537
1591
|
const isBareInvocation = args.length === 0;
|
|
1592
|
+
const command = args.shift() ?? 'help';
|
|
1593
|
+
// Sprint α6.X CEO dogfood 2026-05-26 (P0 hot-fix): trailing `--help`
|
|
1594
|
+
// / `-h` on ANY sub-command must route to the help printer rather
|
|
1595
|
+
// than dispatching the real engine. Before this guard `pugi build
|
|
1596
|
+
// --help` burned 86k tokens running the actual build loop because
|
|
1597
|
+
// the dispatcher saw `--help` as an opaque arg and forwarded it
|
|
1598
|
+
// through to the engine. Re-routing here means `pugi <cmd> --help`
|
|
1599
|
+
// becomes `pugi help <cmd>` deterministically across the entire
|
|
1600
|
+
// command tree.
|
|
1601
|
+
//
|
|
1602
|
+
// β1 Tt3 carve-out: commands that ship their OWN `--help` block
|
|
1603
|
+
// (login, init, ...) must keep `--help` in their args so the
|
|
1604
|
+
// command-local printer fires. Without this carve-out
|
|
1605
|
+
// `pugi login --help` produces the global help and the per-variant
|
|
1606
|
+
// reference (`--provider device|token|env`) gets lost. The carve-out
|
|
1607
|
+
// list mirrors handlers whose source carries an
|
|
1608
|
+
// `args.includes('--help')` short-circuit.
|
|
1609
|
+
if ((args.includes('--help') || args.includes('-h')) && !COMMAND_LOCAL_HELP.has(command)) {
|
|
1610
|
+
return { command: 'help', args: [command], flags, isBareInvocation: false };
|
|
1611
|
+
}
|
|
538
1612
|
return {
|
|
539
|
-
command
|
|
1613
|
+
command,
|
|
540
1614
|
args,
|
|
541
1615
|
flags,
|
|
542
1616
|
isBareInvocation,
|
|
543
1617
|
};
|
|
544
1618
|
}
|
|
1619
|
+
/**
|
|
1620
|
+
* β1 Tt3: commands that own their `--help` rendering. The bare-help
|
|
1621
|
+
* redirect leaves their `--help` arg in place so the command-local
|
|
1622
|
+
* printer fires instead of the global summary.
|
|
1623
|
+
*/
|
|
1624
|
+
const COMMAND_LOCAL_HELP = new Set([
|
|
1625
|
+
'login',
|
|
1626
|
+
]);
|
|
545
1627
|
async function version(_args, flags, _session) {
|
|
546
1628
|
const payload = {
|
|
547
1629
|
name: 'pugi',
|
|
@@ -549,7 +1631,353 @@ async function version(_args, flags, _session) {
|
|
|
549
1631
|
};
|
|
550
1632
|
writeOutput(flags, payload, `pugi ${payload.version}`);
|
|
551
1633
|
}
|
|
552
|
-
|
|
1634
|
+
/**
|
|
1635
|
+
* Per-command help bodies (task #100). When the operator types
|
|
1636
|
+
* `pugi <cmd> --help` the dispatcher routes here with `args = [cmd]`.
|
|
1637
|
+
* If we have a focused body for that command, print it instead of the
|
|
1638
|
+
* global summary. Falls back to the global summary so unknown / new
|
|
1639
|
+
* commands still get a useful response.
|
|
1640
|
+
*
|
|
1641
|
+
* Source of truth for each entry: the comment block at the top of the
|
|
1642
|
+
* command's implementation module + any flags the command declares.
|
|
1643
|
+
* Keep entries short — operators want the one-liner of intent + the
|
|
1644
|
+
* 2-5 most useful flags, not a tutorial. The global help still has the
|
|
1645
|
+
* full per-section reference; the per-command body is the "tell me
|
|
1646
|
+
* how to use this NOW" surface.
|
|
1647
|
+
*/
|
|
1648
|
+
const COMMAND_HELP_BODIES = {
|
|
1649
|
+
init: [
|
|
1650
|
+
'pugi init — bootstrap a new Pugi workspace in the current directory.',
|
|
1651
|
+
'',
|
|
1652
|
+
'Creates .pugi/{PUGI.md, mcp.json, index.json, artifacts/, sessions/} and',
|
|
1653
|
+
'seeds the 6 default skills. Idempotent — running again only fills gaps.',
|
|
1654
|
+
'',
|
|
1655
|
+
'Flags:',
|
|
1656
|
+
' --no-defaults Skip the bundled default-skills install.',
|
|
1657
|
+
'',
|
|
1658
|
+
'Env:',
|
|
1659
|
+
' PUGI_INIT_NO_DEFAULTS=1 Same as --no-defaults.',
|
|
1660
|
+
],
|
|
1661
|
+
explain: [
|
|
1662
|
+
'pugi explain "<question>" — read-only Q&A about the workspace.',
|
|
1663
|
+
'',
|
|
1664
|
+
'Calls the engine loop in explain mode (budget: 5 calls / 20k tokens).',
|
|
1665
|
+
'No file writes; safe to run against unfamiliar code.',
|
|
1666
|
+
'',
|
|
1667
|
+
'Examples:',
|
|
1668
|
+
' pugi explain "what does this package.json define?"',
|
|
1669
|
+
' pugi explain "trace the auth flow in src/auth/"',
|
|
1670
|
+
],
|
|
1671
|
+
code: [
|
|
1672
|
+
'pugi code "<brief>" — engineering-mode write loop (80k token budget).',
|
|
1673
|
+
'',
|
|
1674
|
+
'Writes files in the current workspace. Use --no-tty in CI / pipes.',
|
|
1675
|
+
],
|
|
1676
|
+
fix: [
|
|
1677
|
+
'pugi fix "<brief>" — minimal-diff bugfix loop (50k token budget).',
|
|
1678
|
+
'',
|
|
1679
|
+
'Same as `pugi code` but the prompt biases toward the smallest patch',
|
|
1680
|
+
'that closes the brief — refuses scope creep / refactor invitations.',
|
|
1681
|
+
],
|
|
1682
|
+
build: [
|
|
1683
|
+
'pugi build "<brief>" — feature-build loop (200k token budget).',
|
|
1684
|
+
'',
|
|
1685
|
+
'Multi-turn engineering with plan-review checkpoints. Pairs with',
|
|
1686
|
+
'pugi plan --decompose <idea> when the brief is bigger than one PR.',
|
|
1687
|
+
],
|
|
1688
|
+
plan: [
|
|
1689
|
+
'pugi plan --decompose <idea> — split an idea into 3-7 components.',
|
|
1690
|
+
'',
|
|
1691
|
+
'Writes .pugi/plan/<session-id>/splits/NN-<name>/spec.md plus',
|
|
1692
|
+
'manifest.md with the dependency DAG. Pass each split to `pugi build`.',
|
|
1693
|
+
],
|
|
1694
|
+
review: [
|
|
1695
|
+
'pugi review — code review surfaces.',
|
|
1696
|
+
'',
|
|
1697
|
+
' --triple 3-model consensus via Anvil paid fleet.',
|
|
1698
|
+
' --triple --commit <SHA> Review a specific commit (vs origin/main).',
|
|
1699
|
+
' --consensus Customer-facing consensus review (codex + claude + deepseek).',
|
|
1700
|
+
' Optional: --commit <sha> | --pr <num> | --branch <name>.',
|
|
1701
|
+
'',
|
|
1702
|
+
'Exit codes: 0 PASS · 1 WARN · 2 BLOCK · 5 auth_missing · 7 rate_limited.',
|
|
1703
|
+
],
|
|
1704
|
+
privacy: [
|
|
1705
|
+
'pugi privacy — privacy-mode operations.',
|
|
1706
|
+
'',
|
|
1707
|
+
' show Display effective mode + source.',
|
|
1708
|
+
' set <mode> Local-only legacy values (local-only|metadata|full).',
|
|
1709
|
+
'',
|
|
1710
|
+
'For tenant-scoped server-side modes (strict|balanced|permissive), use:',
|
|
1711
|
+
' pugi config get privacy',
|
|
1712
|
+
' pugi config set privacy=<mode>',
|
|
1713
|
+
],
|
|
1714
|
+
share: [
|
|
1715
|
+
'pugi share — export the current session transcript (leak L20).',
|
|
1716
|
+
'',
|
|
1717
|
+
'Reads .pugi/events.jsonl, formats it as Markdown, and uploads to',
|
|
1718
|
+
'either a GitHub Gist (`gh`-backed, default when `gh` is available)',
|
|
1719
|
+
'or pugi.io (--pugi). Always prompts before upload unless --yes is',
|
|
1720
|
+
'set. Refuses upload entirely if the transcript carries an active',
|
|
1721
|
+
'`Bearer ` credential — re-run with --redact to scrub it first.',
|
|
1722
|
+
'',
|
|
1723
|
+
'Flags:',
|
|
1724
|
+
' --gist Force gist target; refuses if gh CLI is absent.',
|
|
1725
|
+
' --pugi Force pugi.io target (requires `pugi login`).',
|
|
1726
|
+
' --redact Run PII scrubber before upload.',
|
|
1727
|
+
' --preview Print the transcript to stdout WITHOUT upload.',
|
|
1728
|
+
' --yes, -y Skip the y/n confirmation prompt.',
|
|
1729
|
+
' --json Emit a structured JSON envelope only.',
|
|
1730
|
+
'',
|
|
1731
|
+
'Examples:',
|
|
1732
|
+
' pugi share Auto-pick + confirm.',
|
|
1733
|
+
' pugi share --preview --redact See what would be shared.',
|
|
1734
|
+
' pugi share --gist --redact --yes Scripted secret-gist upload.',
|
|
1735
|
+
],
|
|
1736
|
+
cost: [
|
|
1737
|
+
'pugi cost — token + USD breakdown for the current Pugi session.',
|
|
1738
|
+
'',
|
|
1739
|
+
'Reads .pugi/cost.json (persisted via the in-REPL CostTracker) and',
|
|
1740
|
+
'prints a per-model table plus dollar estimate. Alias: pugi usage.',
|
|
1741
|
+
'',
|
|
1742
|
+
'Flags:',
|
|
1743
|
+
' --all-sessions 30-day rolling aggregate across all sessions.',
|
|
1744
|
+
' --window=<days> Override the aggregate window (max 365).',
|
|
1745
|
+
' --reset --yes Clear the current-session counter. History',
|
|
1746
|
+
' is preserved. Requires --yes to confirm.',
|
|
1747
|
+
' --json Emit a structured JSON envelope only.',
|
|
1748
|
+
'',
|
|
1749
|
+
'Examples:',
|
|
1750
|
+
' pugi cost Current session totals.',
|
|
1751
|
+
' pugi cost --all-sessions Past 30 days aggregated.',
|
|
1752
|
+
' pugi cost --all-sessions --window=7',
|
|
1753
|
+
' pugi cost --reset --yes Wipe the session counter.',
|
|
1754
|
+
' pugi usage Alias for pugi cost.',
|
|
1755
|
+
],
|
|
1756
|
+
config: [
|
|
1757
|
+
'pugi config — read / write CLI + tenant configuration.',
|
|
1758
|
+
'',
|
|
1759
|
+
' get <key> Local config value.',
|
|
1760
|
+
' get privacy Tenant privacy snapshot (admin-api).',
|
|
1761
|
+
' get routing Effective routing table.',
|
|
1762
|
+
' set <key>=<value> Local config write.',
|
|
1763
|
+
' set privacy=<mode> Flip tenant privacy (strict|balanced|permissive).',
|
|
1764
|
+
' set routing.<tag>.<budget>=<model> Override one routing lane.',
|
|
1765
|
+
' unset routing.<tag>.<budget> Revert a routing override.',
|
|
1766
|
+
' mcp trust|deny|list <name> MCP server trust + visibility.',
|
|
1767
|
+
],
|
|
1768
|
+
sync: [
|
|
1769
|
+
'pugi sync — explicit-continuation handoff bundle upload.',
|
|
1770
|
+
'',
|
|
1771
|
+
' --dry-run Print the bundle plan without uploading.',
|
|
1772
|
+
' --privacy <mode> Override per-bundle privacy posture.',
|
|
1773
|
+
],
|
|
1774
|
+
whoami: [
|
|
1775
|
+
'pugi whoami — show the active credential + JWT principal + plan tier.',
|
|
1776
|
+
'',
|
|
1777
|
+
'Reads from ~/.pugi/credentials.json. No network call unless --remote.',
|
|
1778
|
+
],
|
|
1779
|
+
login: [
|
|
1780
|
+
'pugi login — authenticate against an api.pugi.io endpoint.',
|
|
1781
|
+
'',
|
|
1782
|
+
'Interactive picker by default (browser OAuth / PAT / env). Non-interactive:',
|
|
1783
|
+
' --provider device Device-flow OAuth.',
|
|
1784
|
+
' --provider token --token <jwt> Pass a JWT directly.',
|
|
1785
|
+
' --provider env Read PUGI_API_KEY (or --key) + verify via /api/pugi/health.',
|
|
1786
|
+
' --provider env --key <value> --skip-validate Explicit key, no probe (CI bootstrap).',
|
|
1787
|
+
],
|
|
1788
|
+
accounts: [
|
|
1789
|
+
'pugi accounts — manage stored credentials across endpoints.',
|
|
1790
|
+
'',
|
|
1791
|
+
' pugi accounts list Every account + its endpoint + active flag.',
|
|
1792
|
+
' pugi accounts switch <label> Re-point the active account.',
|
|
1793
|
+
' pugi accounts remove <label> Delete a stored credential.',
|
|
1794
|
+
],
|
|
1795
|
+
jobs: [
|
|
1796
|
+
'pugi jobs — list, tail, or kill background dispatch jobs.',
|
|
1797
|
+
'',
|
|
1798
|
+
' list All jobs in the registry.',
|
|
1799
|
+
' tail <id> Stream output from one job.',
|
|
1800
|
+
' kill <id> Cancel a running job.',
|
|
1801
|
+
],
|
|
1802
|
+
delegate: [
|
|
1803
|
+
'pugi delegate <slug> "<brief>" — dispatch a brief to one specialist persona.',
|
|
1804
|
+
'',
|
|
1805
|
+
'Slugs (Tier 1 alpha 7.5): dev qa pm devops researcher analyst designer',
|
|
1806
|
+
'frontend architect. `pugi roster` lists the live set.',
|
|
1807
|
+
],
|
|
1808
|
+
chain: [
|
|
1809
|
+
'pugi chain — Wave 6 artifact chain (PRD → ADR → mindmap → ER → sequence → tests → code).',
|
|
1810
|
+
'',
|
|
1811
|
+
' new "<intent>" Start a new chain from a one-sentence intent.',
|
|
1812
|
+
' status [<chain-id>] Show current cursor + per-step table.',
|
|
1813
|
+
' next [<chain-id>] Approve the last step and dispatch the next.',
|
|
1814
|
+
' show <step> [<chain-id>] Render one artifact (prd/adr/mindmap/er/sequence/tests/code).',
|
|
1815
|
+
' export [<chain-id>] [--json] Bundle every artifact as markdown / JSON.',
|
|
1816
|
+
' list Every chain in this workspace.',
|
|
1817
|
+
],
|
|
1818
|
+
dispatch: [
|
|
1819
|
+
'pugi dispatch <sub> — inspect + GC fork-subagent prompt-cache inherit refs.',
|
|
1820
|
+
'',
|
|
1821
|
+
' list-cache-refs Table of every active ref under .pugi/cache-refs/.',
|
|
1822
|
+
' clear-cache-refs [--older-than 1h] Evict refs older than the window (default 24h).',
|
|
1823
|
+
'',
|
|
1824
|
+
'Leak L10 (2026-05-27): when Mira spawns a child via the `agent` tool,',
|
|
1825
|
+
'a prompt-cache handle is persisted so the child loop can request',
|
|
1826
|
+
'parent-context reuse on the wire. These commands surface + clean up',
|
|
1827
|
+
'the persisted refs.',
|
|
1828
|
+
],
|
|
1829
|
+
roster: [
|
|
1830
|
+
'pugi roster — list the live Tier 1 personas + roles.',
|
|
1831
|
+
],
|
|
1832
|
+
doctor: [
|
|
1833
|
+
'pugi doctor — diagnose CLI + workspace + adapter capabilities.',
|
|
1834
|
+
'',
|
|
1835
|
+
'Prints CLI version, Node version, workspace state (.pugi presence,',
|
|
1836
|
+
'event log, settings), permission mode, and the capability matrix per',
|
|
1837
|
+
'engine adapter. Safe to run anywhere; no network calls.',
|
|
1838
|
+
],
|
|
1839
|
+
'prd-check': [
|
|
1840
|
+
'pugi prd-check <prd-path> | --all | --session — Wave 6 verified-deliverable gate.',
|
|
1841
|
+
'',
|
|
1842
|
+
'DEFAULT MODE — verify acceptance criteria against committed artifacts.',
|
|
1843
|
+
'Reads a markdown PRD, parses the acceptance-criteria section, and',
|
|
1844
|
+
'runs verifiers:',
|
|
1845
|
+
' file:<path> fs.existsSync',
|
|
1846
|
+
' test:<spec> spec file exists + has ≥1 test()/it() block',
|
|
1847
|
+
' doc:<path> doc exists + has > 100 chars',
|
|
1848
|
+
' command:<name> CLI registry contains the command',
|
|
1849
|
+
' route:METHOD /p best-effort grep of controllers',
|
|
1850
|
+
'',
|
|
1851
|
+
' --all Scan docs/prd/**.md instead of one file.',
|
|
1852
|
+
' --json Emit a structured envelope to stdout.',
|
|
1853
|
+
'',
|
|
1854
|
+
'SESSION MODE (Wave 6 final) — review the live session against the PRD.',
|
|
1855
|
+
'Walks up for PRD.md or apps/<app>/PRODUCT.md, reads the last 20 turns',
|
|
1856
|
+
'from .pugi/events.jsonl, and dispatches a cross-review subagent to',
|
|
1857
|
+
'list which requirements are SATISFIED and which remain OUTSTANDING.',
|
|
1858
|
+
'',
|
|
1859
|
+
' --session Run the session-review mode (no <path>, no --all).',
|
|
1860
|
+
'',
|
|
1861
|
+
'Exit codes: 0 healthy · 1 failing · 2 unparsed / arg error.',
|
|
1862
|
+
],
|
|
1863
|
+
status: [
|
|
1864
|
+
'pugi status — concise session snapshot.',
|
|
1865
|
+
'',
|
|
1866
|
+
'Different from `pugi doctor` (environment health). Status answers',
|
|
1867
|
+
'"what is this Pugi session doing right now?" — session id + age,',
|
|
1868
|
+
'cwd, permission mode, CLI version, token usage, active + completed',
|
|
1869
|
+
'dispatches, last command, compact boundary count, auth identity.',
|
|
1870
|
+
'',
|
|
1871
|
+
' --json Emit a structured envelope to stdout.',
|
|
1872
|
+
'',
|
|
1873
|
+
'Live REPL state (tokens, last command) is only available via the',
|
|
1874
|
+
'in-REPL `/status` slash; the shell path degrades those fields к',
|
|
1875
|
+
'"n/a" and exits 0.',
|
|
1876
|
+
],
|
|
1877
|
+
report: [
|
|
1878
|
+
'pugi report — capture a bug report from the most-recent session.',
|
|
1879
|
+
'',
|
|
1880
|
+
' --from-error Bundle the most-recent failed session as a',
|
|
1881
|
+
' redacted local report (default + only mode in v1).',
|
|
1882
|
+
'',
|
|
1883
|
+
'Output: writes .pugi/reports/<timestamp>-<session-id>/{report.json, report.md}.',
|
|
1884
|
+
'Secrets (bearer tokens, JWTs, named env values) are stripped before disk write.',
|
|
1885
|
+
'Auto-upload to api.pugi.io planned for a follow-up; v1 keeps everything local.',
|
|
1886
|
+
],
|
|
1887
|
+
ask: [
|
|
1888
|
+
'pugi ask "<question>" — surface a yes/no question modal locally.',
|
|
1889
|
+
'',
|
|
1890
|
+
'Useful in shell scripts that need a human-confirm before a destructive',
|
|
1891
|
+
'step. Exits 0 on yes, 1 on no, 2 on cancel.',
|
|
1892
|
+
],
|
|
1893
|
+
update: [
|
|
1894
|
+
'pugi update — channel-aware @pugi/cli update check + install.',
|
|
1895
|
+
'',
|
|
1896
|
+
'Polls npm registry dist-tags for a newer @pugi/cli on the configured',
|
|
1897
|
+
'channel (stable / beta / canary). Without flags, prints the install',
|
|
1898
|
+
'command and exits. With --apply, shells out to `npm install -g …`.',
|
|
1899
|
+
'',
|
|
1900
|
+
' --check Non-interactive probe + JSON envelope.',
|
|
1901
|
+
' --channel <name> Switch channel (stable | beta | canary) and probe.',
|
|
1902
|
+
' Persisted to ~/.pugi/config.json::updateChannel.',
|
|
1903
|
+
' --apply Shell out to `npm install -g @pugi/cli@<tag>`',
|
|
1904
|
+
' after a y/n confirmation.',
|
|
1905
|
+
' --yes, -y Skip the confirmation prompt on --apply.',
|
|
1906
|
+
' --json Force JSON envelope (auto-on with --check).',
|
|
1907
|
+
'',
|
|
1908
|
+
'Channel mapping: stable -> npm `latest`, beta -> npm `beta`,',
|
|
1909
|
+
'canary -> npm `next`. Default channel is `beta` (Pugi currently',
|
|
1910
|
+
'ships beta releases only).',
|
|
1911
|
+
'',
|
|
1912
|
+
'Also available as /update from inside the REPL — slash form NEVER',
|
|
1913
|
+
'spawns npm (would corrupt the running binary); it only prints the',
|
|
1914
|
+
'install command for the operator к run after exit.',
|
|
1915
|
+
'',
|
|
1916
|
+
'R2 atomic swap (sprint plan L27) deferred к Phase 2 — npm is the',
|
|
1917
|
+
'only distribution channel today.',
|
|
1918
|
+
],
|
|
1919
|
+
stickers: [
|
|
1920
|
+
'pugi stickers — show a Pugi brand sticker (gimmick).',
|
|
1921
|
+
'',
|
|
1922
|
+
'Picks one of the curated pug-face ASCII variants at random and footers',
|
|
1923
|
+
'it with a rotating brand quote. Brand-personality surface — never a gate.',
|
|
1924
|
+
'',
|
|
1925
|
+
' --json Emit a structured envelope (id · caption · quote).',
|
|
1926
|
+
' --ascii-only Plain stdout (no box, no dim accents) for scripting.',
|
|
1927
|
+
'',
|
|
1928
|
+
'Also available as /stickers from inside the REPL.',
|
|
1929
|
+
],
|
|
1930
|
+
feedback: [
|
|
1931
|
+
'pugi feedback — file a bug / feature / general comment from the CLI.',
|
|
1932
|
+
'',
|
|
1933
|
+
'Interactive five-step wizard:',
|
|
1934
|
+
' 1. category (bug / feature / general / praise)',
|
|
1935
|
+
' 2. rating (1-5 stars)',
|
|
1936
|
+
' 3. comment (multi-line, Ctrl-D submits)',
|
|
1937
|
+
' 4. include redacted last 5 turns? (y/n, default n)',
|
|
1938
|
+
' 5. confirm submit (y/n, default y)',
|
|
1939
|
+
'',
|
|
1940
|
+
'On network failure the envelope is appended to',
|
|
1941
|
+
'.pugi/feedback-queue.jsonl and drained on the next online session.',
|
|
1942
|
+
'',
|
|
1943
|
+
'Also available as /feedback from inside the REPL.',
|
|
1944
|
+
],
|
|
1945
|
+
'release-notes': [
|
|
1946
|
+
'pugi release-notes — show what changed since you last upgraded.',
|
|
1947
|
+
'',
|
|
1948
|
+
'Reads the bundled CHANGELOG.md, slices to sections strictly newer than',
|
|
1949
|
+
'~/.pugi/.last-seen-version, renders Markdown to stdout, then bumps the',
|
|
1950
|
+
'last-seen marker to the installed CLI version. Re-running is a no-op',
|
|
1951
|
+
'until you upgrade again.',
|
|
1952
|
+
'',
|
|
1953
|
+
' --json Emit a structured envelope (sections + meta).',
|
|
1954
|
+
' --reset Clear last-seen marker; re-render every section.',
|
|
1955
|
+
'',
|
|
1956
|
+
'Also available as /release-notes from inside the REPL.',
|
|
1957
|
+
],
|
|
1958
|
+
deploy: [
|
|
1959
|
+
'pugi deploy — trigger a vendor deployment from the bound Git source.',
|
|
1960
|
+
'',
|
|
1961
|
+
' --target vercel <vercelProject> --project <id> Vercel deploy.',
|
|
1962
|
+
' --target render <renderService> --project <id> Render deploy (Sprint 2 stub).',
|
|
1963
|
+
' --status <id> Vendor-agnostic status snapshot.',
|
|
1964
|
+
' --logs <id> [--tail] Build-log tail.',
|
|
1965
|
+
'',
|
|
1966
|
+
'Optional: --target-env production|preview, --ref <ref>, --integration <id>.',
|
|
1967
|
+
],
|
|
1968
|
+
};
|
|
1969
|
+
async function help(args, flags, _session) {
|
|
1970
|
+
// 2026-05-27 task #100: per-command help bodies. When dispatcher
|
|
1971
|
+
// routed `pugi <cmd> --help` here it passes `args = [cmd]`; if we
|
|
1972
|
+
// have a focused body, print that. Falls through to the global
|
|
1973
|
+
// summary on unknown / new commands so the dispatcher's redirect
|
|
1974
|
+
// never produces a worse-than-baseline response.
|
|
1975
|
+
const requested = args[0];
|
|
1976
|
+
if (requested && COMMAND_HELP_BODIES[requested]) {
|
|
1977
|
+
const body = COMMAND_HELP_BODIES[requested];
|
|
1978
|
+
writeOutput(flags, { command: requested, lines: body }, body.join('\n'));
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
553
1981
|
const commands = Object.keys(handlers).sort();
|
|
554
1982
|
writeOutput(flags, { commands }, [
|
|
555
1983
|
'Pugi CLI',
|
|
@@ -569,6 +1997,9 @@ async function help(_args, flags, _session) {
|
|
|
569
1997
|
'',
|
|
570
1998
|
'Review gate:',
|
|
571
1999
|
' pugi review --triple Prepare the Anvil-backed triple-review gate.',
|
|
2000
|
+
' pugi review --triple --commit <SHA>',
|
|
2001
|
+
' 3-model consensus via Anvil (Anthropic · OpenAI · Google).',
|
|
2002
|
+
' Optional: --base <ref> | "<prompt>". Quota: 1 slot per call.',
|
|
572
2003
|
' pugi review --consensus 3-model consensus review (codex · claude · deepseek).',
|
|
573
2004
|
' Optional: --commit <sha> | --pr <num> | --branch <name>.',
|
|
574
2005
|
' Exits 0 PASS · 1 WARN · 2 BLOCK.',
|
|
@@ -587,6 +2018,13 @@ async function help(_args, flags, _session) {
|
|
|
587
2018
|
'Persona dispatch (α7.5):',
|
|
588
2019
|
' pugi roster List the live Tier 1 personas + roles.',
|
|
589
2020
|
' pugi delegate <slug> "<brief>" Dispatch a brief to one specialist.',
|
|
2021
|
+
' pugi dispatch list-cache-refs Inspect fork-subagent prompt-cache inherit refs.',
|
|
2022
|
+
' pugi dispatch clear-cache-refs GC stale cache refs (--older-than 1h).',
|
|
2023
|
+
'',
|
|
2024
|
+
'Plan decomposition (α6.8):',
|
|
2025
|
+
' pugi plan --decompose <idea> Split a high-level idea into 3-7 components.',
|
|
2026
|
+
' Writes .pugi/plan/<session-id>/splits/NN-<name>/spec.md',
|
|
2027
|
+
' plus manifest.md with the dependency DAG.',
|
|
590
2028
|
'',
|
|
591
2029
|
'Deploy:',
|
|
592
2030
|
' pugi deploy --target vercel <vercelProject> --project <id>',
|
|
@@ -610,75 +2048,302 @@ async function help(_args, flags, _session) {
|
|
|
610
2048
|
' PUGI_SKIP_SPLASH=1.',
|
|
611
2049
|
' --no-tool-stream Hide the live tool stream pane (α6.12).',
|
|
612
2050
|
' Pairs with PUGI_HIDE_TOOL_STREAM=1.',
|
|
2051
|
+
' --no-defaults Skip bundled default-skills install on',
|
|
2052
|
+
' `pugi init`. Pairs with PUGI_INIT_NO_DEFAULTS=1.',
|
|
2053
|
+
' --bare Disable project auto-discovery — no PUGI.md /',
|
|
2054
|
+
' AGENTS.md / CLAUDE.md / GEMINI.md walk-up, no',
|
|
2055
|
+
' auto-init of .pugi/, no persona auto-load.',
|
|
2056
|
+
' Pairs with PUGI_BARE=1.',
|
|
613
2057
|
'',
|
|
614
2058
|
PUGI_TAGLINE,
|
|
615
2059
|
'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
|
|
616
2060
|
].join('\n'));
|
|
617
2061
|
}
|
|
2062
|
+
/**
|
|
2063
|
+
* `pugi doctor` — Leak L17 (2026-05-27). Delegates to the diagnostics
|
|
2064
|
+
* probe runner in `runtime/commands/doctor.ts`. The handler stays
|
|
2065
|
+
* thin so the probe surface stays single-sourced between the CLI
|
|
2066
|
+
* shell command, the `pnpm run doctor --json` package script, and
|
|
2067
|
+
* the in-REPL `/doctor` slash command.
|
|
2068
|
+
*
|
|
2069
|
+
* Exit codes are set by `runDoctorCommand` (0 = healthy/warnings,
|
|
2070
|
+
* 2 = at least one error probe). The pre-L17 minimal doctor surface
|
|
2071
|
+
* (adapter capabilities + schema bundle hash) is preserved under
|
|
2072
|
+
* `payload.meta.legacy` so any operator scripts that grep the JSON
|
|
2073
|
+
* keep working through the transition; the field is marked for
|
|
2074
|
+
* removal in a follow-up sprint once the new shape is the
|
|
2075
|
+
* documented contract.
|
|
2076
|
+
*/
|
|
618
2077
|
async function doctor(_args, flags, _session) {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
2078
|
+
await runDoctorCommand({
|
|
2079
|
+
cwd: process.cwd(),
|
|
2080
|
+
home: defaultDoctorHome(),
|
|
2081
|
+
env: process.env,
|
|
2082
|
+
json: flags.json,
|
|
2083
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* `pugi prd-check` — Wave 6 verified-deliverable gate (2026-05-27).
|
|
2088
|
+
*
|
|
2089
|
+
* Reads `docs/prd/<feature>.md` (or any explicit path), parses the
|
|
2090
|
+
* acceptance-criteria section, and runs file / test / doc / command
|
|
2091
|
+
* / route verifiers per criterion. Same handler powers the in-REPL
|
|
2092
|
+
* `/prd-check` slash via session.ts so the verdict is identical
|
|
2093
|
+
* between surfaces.
|
|
2094
|
+
*
|
|
2095
|
+
* The `knownCommands` set is sourced from the same `handlers` map
|
|
2096
|
+
* used by the CLI dispatcher (one source of truth), so a PRD that
|
|
2097
|
+
* mentions `pugi <name>` resolves against the EXACT registry the
|
|
2098
|
+
* shell exposes.
|
|
2099
|
+
*
|
|
2100
|
+
* Exit codes (from reporter.exitCodeFor):
|
|
2101
|
+
* 0 — healthy (every criterion PASS or SKIPPED)
|
|
2102
|
+
* 1 — failing (≥1 FAIL)
|
|
2103
|
+
* 2 — unparsed (PRD has no acceptance section) OR arg error
|
|
2104
|
+
*/
|
|
2105
|
+
async function dispatchPrdCheck(args, flags, _session) {
|
|
2106
|
+
const parsed = parsePrdCheckArgs(args, { jsonDefault: flags.json });
|
|
2107
|
+
if (!parsed.ok) {
|
|
2108
|
+
writeOutput(flags, { ok: false, error: parsed.error }, parsed.error);
|
|
2109
|
+
process.exitCode = 2;
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
await runPrdCheckCommand({
|
|
2113
|
+
cwd: process.cwd(),
|
|
2114
|
+
...(parsed.prdPath !== undefined ? { prdPath: parsed.prdPath } : {}),
|
|
2115
|
+
flags: parsed.flags,
|
|
2116
|
+
knownCommands: knownCommandNames(),
|
|
2117
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
/**
|
|
2121
|
+
* Snapshot the set of registered CLI command names — used by the
|
|
2122
|
+
* prd-check `command:` verifier so PRD mentions of `pugi <name>`
|
|
2123
|
+
* resolve against the exact same registry the shell exposes.
|
|
2124
|
+
*/
|
|
2125
|
+
function knownCommandNames() {
|
|
2126
|
+
return new Set(Object.keys(handlers));
|
|
2127
|
+
}
|
|
2128
|
+
/**
|
|
2129
|
+
* `pugi update` — Leak L27 (2026-05-27). Channel-aware npm registry
|
|
2130
|
+
* probe + optional shell-out to `npm install -g @pugi/cli@<tag>`.
|
|
2131
|
+
*
|
|
2132
|
+
* Argument grammar:
|
|
2133
|
+
* pugi update -> probe + offer install command
|
|
2134
|
+
* pugi update --check -> probe + JSON envelope (scripted)
|
|
2135
|
+
* pugi update --channel <name> -> persist channel + probe
|
|
2136
|
+
* pugi update --apply [--yes] -> probe + shell out to npm
|
|
2137
|
+
* pugi update --json -> JSON envelope (any subcommand)
|
|
2138
|
+
*
|
|
2139
|
+
* The handler delegates to `runUpdateCommand` in
|
|
2140
|
+
* `runtime/commands/update.ts` so the in-REPL `/update` slash + the
|
|
2141
|
+
* top-level shell command share one channel-resolution + persistence
|
|
2142
|
+
* + probe surface. Exit codes:
|
|
2143
|
+
*
|
|
2144
|
+
* 0 — happy path (no update OR update completed OR probe-only)
|
|
2145
|
+
* 1 — install / probe failure with structured error
|
|
2146
|
+
* 2 — argument error (unknown flag, unknown channel)
|
|
2147
|
+
*/
|
|
2148
|
+
async function dispatchUpdate(args, flags, _session) {
|
|
2149
|
+
const { parseUpdateArgs, runUpdateCommand, defaultSpawnInstaller } = await import('./commands/update.js');
|
|
2150
|
+
const parsed = parseUpdateArgs(args, { jsonDefault: flags.json });
|
|
2151
|
+
if ('error' in parsed) {
|
|
2152
|
+
writeOutput(flags, { ok: false, error: parsed.error }, parsed.error);
|
|
2153
|
+
process.exitCode = 2;
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
const envelope = await runUpdateCommand({
|
|
2157
|
+
cwd: process.cwd(),
|
|
2158
|
+
home: homedir(),
|
|
2159
|
+
env: process.env,
|
|
2160
|
+
flags: parsed,
|
|
2161
|
+
promptConfirm: async (question) => {
|
|
2162
|
+
const answer = await readSingleChoice(`${question} `);
|
|
2163
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
631
2164
|
},
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
}
|
|
641
|
-
|
|
2165
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
2166
|
+
spawnInstaller: defaultSpawnInstaller,
|
|
2167
|
+
});
|
|
2168
|
+
if (!envelope.ok) {
|
|
2169
|
+
// `apply_cancelled_by_operator` is a benign decline; we still
|
|
2170
|
+
// surface a non-zero exit so scripted callers can detect that the
|
|
2171
|
+
// operator did not green-light the install.
|
|
2172
|
+
process.exitCode = 1;
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* `pugi status` — Leak L34 (2026-05-27). Concise session-state probe
|
|
2177
|
+
* mirroring Claude Code's `/status`. Distinct from `pugi doctor`
|
|
2178
|
+
* (environment health) — `status` answers "what is THIS Pugi
|
|
2179
|
+
* session doing right now?" with session id + age, cwd, permission
|
|
2180
|
+
* mode, CLI version, token usage, dispatch count, last command,
|
|
2181
|
+
* compact boundaries, and auth identity.
|
|
2182
|
+
*
|
|
2183
|
+
* The top-level shell invocation has no live REPL state — fields
|
|
2184
|
+
* that need a live session (`tokens`, `lastCommand`) degrade к the
|
|
2185
|
+
* `n/a` sentinel. The same handler powers the in-REPL `/status`
|
|
2186
|
+
* slash, which passes live state through `StatusCommandContext`.
|
|
2187
|
+
*
|
|
2188
|
+
* Always exits 0 — the command is informational, never a gate.
|
|
2189
|
+
*/
|
|
2190
|
+
async function status(_args, flags, _session) {
|
|
2191
|
+
await runStatusCommand({
|
|
2192
|
+
cwd: process.cwd(),
|
|
2193
|
+
home: defaultStatusHome(),
|
|
2194
|
+
env: process.env,
|
|
2195
|
+
json: flags.json,
|
|
2196
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* `pugi stickers` — Leak L33 (2026-05-27). Brand-personality gimmick
|
|
2201
|
+
* mirroring Claude Code's `/stickers` easter egg. Picks one curated
|
|
2202
|
+
* pug-face ASCII variant at random + footers it with a rotating quote
|
|
2203
|
+
* from the Pugi brand corpus. Always exits 0 — never a gate.
|
|
2204
|
+
*
|
|
2205
|
+
* The handler stays thin: corpus + picker + pure renderers live in
|
|
2206
|
+
* `tui/stickers-art.tsx`; this wrapper just hands the resolved result
|
|
2207
|
+
* к the shared `writeOutput` helper so `--json` keeps producing a
|
|
2208
|
+
* structured envelope (id + caption + quote + meta) for scripted
|
|
2209
|
+
* callers. The `--ascii-only` flag drops the box decoration in the
|
|
2210
|
+
* non-JSON path so pipes (`pugi stickers --ascii-only | lolcat`) get
|
|
2211
|
+
* clean plain-text frames.
|
|
2212
|
+
*
|
|
2213
|
+
* The same handler powers the in-REPL `/stickers` slash, which routes
|
|
2214
|
+
* the text through the conversation system pane line-buffer.
|
|
2215
|
+
*/
|
|
2216
|
+
async function stickers(_args, flags, _session) {
|
|
2217
|
+
runStickersCommand({
|
|
2218
|
+
json: flags.json,
|
|
2219
|
+
asciiOnly: flags.asciiOnly,
|
|
2220
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
/**
|
|
2224
|
+
* `pugi repo-map` — Leak L28 (2026-05-27). Builds + caches the AST-
|
|
2225
|
+
* light symbol summary of the workspace. The handler is intentionally
|
|
2226
|
+
* thin: argv tail tokens are honoured for `--refresh` symmetry (the
|
|
2227
|
+
* global parser already sets `flags.refresh`, but accepting the flag
|
|
2228
|
+
* positionally lets `pugi repo-map refresh` work too — both forms
|
|
2229
|
+
* land в the same path). Exit code is always 0 (informational).
|
|
2230
|
+
*
|
|
2231
|
+
* The same builder is invoked lazily on engine boot when `--bare` is
|
|
2232
|
+
* not set; running the CLI command shows the operator EXACTLY what
|
|
2233
|
+
* the engine would inject into the system prompt.
|
|
2234
|
+
*/
|
|
2235
|
+
async function dispatchRepoMap(args, flags, _session) {
|
|
2236
|
+
const refresh = flags.refresh || args.includes('--refresh') || args.includes('refresh');
|
|
2237
|
+
await runRepoMapCommand({
|
|
2238
|
+
cwd: process.cwd(),
|
|
2239
|
+
refresh,
|
|
2240
|
+
json: flags.json,
|
|
2241
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
/**
|
|
2245
|
+
* `pugi feedback` — Leak L21 (2026-05-27). In-CLI feedback collector.
|
|
2246
|
+
*
|
|
2247
|
+
* Five-step wizard:
|
|
2248
|
+
* 1. category (bug / feature / general / praise)
|
|
2249
|
+
* 2. rating (1-5)
|
|
2250
|
+
* 3. comment (multi-line, Ctrl-D submits)
|
|
2251
|
+
* 4. include redacted session context? (y/n, default n)
|
|
2252
|
+
* 5. confirm submit (y/n, default y)
|
|
2253
|
+
*
|
|
2254
|
+
* POSTs to `<apiUrl>/api/pugi/feedback`. On transient failure (404,
|
|
2255
|
+
* 5xx, network error) the envelope is appended to
|
|
2256
|
+
* `<cwd>/.pugi/feedback-queue.jsonl`. On next online session the
|
|
2257
|
+
* background flusher drains the queue silently.
|
|
2258
|
+
*
|
|
2259
|
+
* Non-TTY callers (CI, pipes) get a one-line "non-interactive — re-run
|
|
2260
|
+
* in a real terminal" stub. The feedback wizard is intentionally
|
|
2261
|
+
* TTY-only — scripting a star-rating + multi-line comment from a
|
|
2262
|
+
* shell pipe would just produce low-signal noise.
|
|
2263
|
+
*/
|
|
2264
|
+
async function dispatchFeedback(_args, flags, _session) {
|
|
2265
|
+
if (!isInteractive(flags)) {
|
|
2266
|
+
writeOutput(flags, {
|
|
2267
|
+
ok: false,
|
|
2268
|
+
error: 'pugi feedback requires an interactive terminal. Re-run from a real TTY.',
|
|
2269
|
+
}, 'pugi feedback: non-interactive shell — re-run from a real terminal.');
|
|
2270
|
+
process.exitCode = 2;
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
const { renderFeedbackPrompt } = await import('../tui/feedback-prompt.js');
|
|
2274
|
+
const { runFeedbackCommand, renderFeedbackToast } = await import('./commands/feedback.js');
|
|
2275
|
+
const { submitFeedback } = await import('../core/feedback/submitter.js');
|
|
2276
|
+
const verdict = await renderFeedbackPrompt();
|
|
2277
|
+
if (verdict.cancelled || !verdict.draft) {
|
|
2278
|
+
writeOutput(flags, { ok: true, kind: 'cancelled' }, 'Feedback cancelled. Nothing was sent.');
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
// Best-effort credential resolution. Anonymous submission is allowed
|
|
2282
|
+
// (the server may still accept it for ungated `/api/pugi/feedback`
|
|
2283
|
+
// routes); on no-credential we route the POST through an empty
|
|
2284
|
+
// bearer + the operator gets the 4xx → "rejected" toast if the
|
|
2285
|
+
// server requires auth.
|
|
2286
|
+
const credential = resolveActiveCredential(process.env);
|
|
2287
|
+
const apiUrl = credential?.apiUrl ?? (process.env.PUGI_API_URL || 'https://api.pugi.io');
|
|
2288
|
+
const apiKey = credential?.apiKey ?? '';
|
|
2289
|
+
const result = await runFeedbackCommand({
|
|
2290
|
+
cwd: process.cwd(),
|
|
642
2291
|
cliVersion: PUGI_CLI_VERSION,
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
notAutomatic: [...settings.workflow.notAutomatic, ...settings.permissions.notAutomatic],
|
|
651
|
-
protectedFileCheck: decidePermission({ tool: 'doctor', kind: 'edit', target: '.env' }, settings, cwd),
|
|
652
|
-
protectedFileSafety: 'configured-in-m1',
|
|
653
|
-
mcpTrust: 'not-configured',
|
|
654
|
-
releaseGuard: 'scaffolded',
|
|
655
|
-
tools: toolRegistry,
|
|
656
|
-
engineAdapters: capabilities,
|
|
657
|
-
schemaBundleHash: createHash('sha256')
|
|
658
|
-
.update(toolSchemaBundleHashInput())
|
|
659
|
-
.digest('hex'),
|
|
660
|
-
};
|
|
661
|
-
writeOutput(flags, payload, [
|
|
662
|
-
'Pugi doctor',
|
|
663
|
-
`CLI: ${payload.cliVersion}`,
|
|
664
|
-
`Node: ${payload.nodeVersion}`,
|
|
665
|
-
`Workspace: ${payload.workspaceRoot}`,
|
|
666
|
-
`Pugi mode: ${payload.pugiMode ? 'detected' : 'not detected'}`,
|
|
667
|
-
`Pugi dir: ${payload.pugiDir ? 'present' : 'missing'}`,
|
|
668
|
-
`Event log: ${payload.eventLog ? 'present' : 'missing'}`,
|
|
669
|
-
`Permission mode: ${payload.permissionMode}`,
|
|
670
|
-
`Approvals: ${payload.approvals}`,
|
|
671
|
-
`Release guard: ${payload.releaseGuard}`,
|
|
672
|
-
].join('\n'));
|
|
2292
|
+
submit: async (env) => submitFeedback(env, { apiUrl, apiKey }),
|
|
2293
|
+
draft: verdict.draft,
|
|
2294
|
+
// `pugi feedback` from a fresh shell has no live transcript — the
|
|
2295
|
+
// session-context provider is omitted. The REPL slash variant
|
|
2296
|
+
// wires this in via `runFeedbackSlash` (session.ts).
|
|
2297
|
+
});
|
|
2298
|
+
writeOutput(flags, { ok: true, result }, renderFeedbackToast(result));
|
|
673
2299
|
}
|
|
674
|
-
|
|
675
|
-
|
|
2300
|
+
/**
|
|
2301
|
+
* `pugi release-notes` — Leak L24 (2026-05-27). Diff between the
|
|
2302
|
+
* last-seen + installed CLI versions, rendered from the bundled
|
|
2303
|
+
* `apps/pugi-cli/CHANGELOG.md`. Bumps `~/.pugi/.last-seen-version`
|
|
2304
|
+
* to the installed version on every successful render so the next
|
|
2305
|
+
* invocation is a no-op until the operator upgrades again.
|
|
2306
|
+
*
|
|
2307
|
+
* The handler stays thin: parser, slicer, and state I/O all live in
|
|
2308
|
+
* `core/release-notes/`. This wrapper just hands ambient state to
|
|
2309
|
+
* `runReleaseNotesCommand` so `--json` keeps producing the same
|
|
2310
|
+
* envelope from both the top-level shell + the in-REPL `/release-notes`
|
|
2311
|
+
* slash dispatcher.
|
|
2312
|
+
*
|
|
2313
|
+
* Always exits 0 — the command is informational, never a gate. Read
|
|
2314
|
+
* failures, missing CHANGELOG, and write failures all degrade to a
|
|
2315
|
+
* structured envelope with a human-readable footer.
|
|
2316
|
+
*/
|
|
2317
|
+
async function releaseNotes(_args, flags, _session) {
|
|
2318
|
+
runReleaseNotesCommand({
|
|
2319
|
+
home: defaultReleaseNotesHome(),
|
|
2320
|
+
json: flags.json,
|
|
2321
|
+
reset: flags.reset,
|
|
2322
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
/**
|
|
2326
|
+
* Programmatic init scaffolder. Idempotent — every helper call is a
|
|
2327
|
+
* `*_IfMissing` write, so re-running over an existing .pugi/ workspace
|
|
2328
|
+
* adds nothing to `created` and the operator sees the "Already
|
|
2329
|
+
* initialized" copy. Default skills install is best-effort: failure
|
|
2330
|
+
* does not throw, the error is appended to the result via stderr so
|
|
2331
|
+
* the slash dispatcher can surface it in the REPL system pane.
|
|
2332
|
+
*
|
|
2333
|
+
* Callers MUST provide `cwd` explicitly; the function does not read
|
|
2334
|
+
* `process.cwd()` so REPL invocations from an arbitrary workspace
|
|
2335
|
+
* cannot accidentally scaffold the binary's install directory.
|
|
2336
|
+
*/
|
|
2337
|
+
export async function scaffoldPugiWorkspace(input) {
|
|
2338
|
+
const cwd = input.cwd;
|
|
2339
|
+
const log = input.log ?? ((line) => process.stderr.write(line));
|
|
676
2340
|
const pugiDir = resolve(cwd, '.pugi');
|
|
677
2341
|
const created = [];
|
|
678
2342
|
const skipped = [];
|
|
679
2343
|
ensureDir(pugiDir, created, skipped);
|
|
680
2344
|
ensureDir(resolve(pugiDir, 'artifacts'), created, skipped);
|
|
681
2345
|
ensureDir(resolve(pugiDir, 'sessions'), created, skipped);
|
|
2346
|
+
ensureDir(resolve(pugiDir, 'skills'), created, skipped);
|
|
682
2347
|
writeJsonIfMissing(resolve(pugiDir, 'settings.json'), {
|
|
683
2348
|
schema: 1,
|
|
684
2349
|
workflow: {
|
|
@@ -700,6 +2365,9 @@ async function init(_args, flags, _session) {
|
|
|
700
2365
|
mode: 'balanced',
|
|
701
2366
|
telemetry: 'off',
|
|
702
2367
|
},
|
|
2368
|
+
ui: {
|
|
2369
|
+
cyberZoo: 'on',
|
|
2370
|
+
},
|
|
703
2371
|
artifacts: {
|
|
704
2372
|
defaultPath: '.pugi/artifacts',
|
|
705
2373
|
promoteExplicitly: true,
|
|
@@ -707,7 +2375,19 @@ async function init(_args, flags, _session) {
|
|
|
707
2375
|
}, created, skipped);
|
|
708
2376
|
writeJsonIfMissing(resolve(pugiDir, 'mcp.json'), {
|
|
709
2377
|
schema: 1,
|
|
710
|
-
|
|
2378
|
+
// 2026-05-27 dogfood: `servers` MUST be an object keyed by server
|
|
2379
|
+
// name (z.record(mcpServerConfigSchema) in
|
|
2380
|
+
// apps/pugi-cli/src/core/mcp/registry.ts:51). A bare `[]` array
|
|
2381
|
+
// here passed schema validation на pugi init exit но crashed
|
|
2382
|
+
// the next dispatch with
|
|
2383
|
+
// "MCP config at .pugi/mcp.json failed validation:
|
|
2384
|
+
// servers: Expected object, received array"
|
|
2385
|
+
// and the operator's first command after `pugi init` printed an
|
|
2386
|
+
// error banner before the actual reply. Empty object matches the
|
|
2387
|
+
// schema default and keeps the file forwards-compatible with
|
|
2388
|
+
// `pugi mcp install <name> ...` which merges into the same
|
|
2389
|
+
// record shape.
|
|
2390
|
+
servers: {},
|
|
711
2391
|
}, created, skipped);
|
|
712
2392
|
writeJsonIfMissing(resolve(pugiDir, 'index.json'), emptyIndex(), created, skipped);
|
|
713
2393
|
writeTextIfMissing(resolve(pugiDir, 'PUGI.md'), [
|
|
@@ -748,19 +2428,148 @@ async function init(_args, flags, _session) {
|
|
|
748
2428
|
// Ensure `.pugi/` is git-ignored so users do not accidentally commit
|
|
749
2429
|
// local audit logs, artifacts, or triple-review request payloads.
|
|
750
2430
|
ensurePugiGitIgnore(cwd, created, skipped);
|
|
751
|
-
|
|
2431
|
+
// Bundled default skills (brand-voice, endpoint-probe, readme-sync).
|
|
2432
|
+
// Skipped when --no-defaults is passed OR when PUGI_INIT_NO_DEFAULTS=1.
|
|
2433
|
+
// Idempotent: a skill whose target directory already exists is left
|
|
2434
|
+
// alone so re-running `pugi init` after the operator customised one of
|
|
2435
|
+
// the defaults does not clobber their edits.
|
|
2436
|
+
let defaultSkills = [];
|
|
2437
|
+
if (!input.noDefaults) {
|
|
2438
|
+
try {
|
|
2439
|
+
defaultSkills = await installDefaultSkills({
|
|
2440
|
+
workspaceRoot: cwd,
|
|
2441
|
+
log,
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
catch (error) {
|
|
2445
|
+
// Default-skills install is a convenience layer. A failure here
|
|
2446
|
+
// (bad sha256 hashing, permission error on .pugi/skills/) must not
|
|
2447
|
+
// leave `pugi init` in a half-state where settings.json exists but
|
|
2448
|
+
// the operator sees an unexplained crash. Log the error to stderr
|
|
2449
|
+
// and continue — the operator can still install skills manually.
|
|
2450
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2451
|
+
log(`[pugi init] default-skills install failed: ${message}\n`);
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
return {
|
|
752
2455
|
status: 'initialized',
|
|
753
2456
|
root: cwd,
|
|
754
2457
|
created,
|
|
755
2458
|
skipped,
|
|
2459
|
+
defaultSkills,
|
|
2460
|
+
alreadyInitialized: created.length === 0,
|
|
756
2461
|
};
|
|
757
|
-
|
|
2462
|
+
}
|
|
2463
|
+
/**
|
|
2464
|
+
* Standalone `pugi init` CLI entry. Thin wrapper around
|
|
2465
|
+
* `scaffoldPugiWorkspace` that handles flag plumbing + writeOutput
|
|
2466
|
+
* formatting. β1a r1: extracted from the previous inline init so the
|
|
2467
|
+
* REPL's `/init` slash can call `scaffoldPugiWorkspace` directly.
|
|
2468
|
+
*/
|
|
2469
|
+
async function init(_args, flags, _session) {
|
|
2470
|
+
const result = await scaffoldPugiWorkspace({
|
|
2471
|
+
cwd: process.cwd(),
|
|
2472
|
+
noDefaults: flags.noDefaults,
|
|
2473
|
+
});
|
|
2474
|
+
const defaultSkillLines = flags.noDefaults
|
|
2475
|
+
? ['Default skills: skipped (--no-defaults)']
|
|
2476
|
+
: result.defaultSkills.length === 0
|
|
2477
|
+
? ['Default skills: none installed']
|
|
2478
|
+
: [
|
|
2479
|
+
'Default skills:',
|
|
2480
|
+
...result.defaultSkills.map((entry) => ` ${entry.name.padEnd(18)} ${entry.status}`),
|
|
2481
|
+
];
|
|
2482
|
+
// Wave 6 BT 9 Phase 2 (2026-05-27): codegraph context-aware auto-install.
|
|
2483
|
+
// After scaffold, evaluate whether the repo looks big-enough + matches a
|
|
2484
|
+
// supported language. The init flow surfaces the offer copy + the docs
|
|
2485
|
+
// URL; the operator decides via the interactive Yes/no prompt OR (in
|
|
2486
|
+
// --json / --no-tty mode) explicitly via `pugi mcp install codegraph
|
|
2487
|
+
// codegraph serve --mcp` later. We DO NOT auto-install here on the
|
|
2488
|
+
// non-interactive path — silently writing к .pugi/mcp.json without a
|
|
2489
|
+
// visible operator confirmation would violate the trust contract.
|
|
2490
|
+
const codegraphLines = await maybeOfferCodegraphInline(result.root, flags);
|
|
2491
|
+
writeOutput(flags, { ...result, codegraph: codegraphLines.envelope }, [
|
|
758
2492
|
'Pugi initialized',
|
|
759
|
-
`Root: ${
|
|
760
|
-
created.length
|
|
761
|
-
|
|
2493
|
+
`Root: ${result.root}`,
|
|
2494
|
+
result.created.length
|
|
2495
|
+
? `Created:\n${result.created.map((path) => ` ${path}`).join('\n')}`
|
|
2496
|
+
: 'Created: none',
|
|
2497
|
+
result.skipped.length
|
|
2498
|
+
? `Already present:\n${result.skipped.map((path) => ` ${path}`).join('\n')}`
|
|
2499
|
+
: 'Already present: none',
|
|
2500
|
+
...defaultSkillLines,
|
|
2501
|
+
...codegraphLines.text,
|
|
762
2502
|
].join('\n'));
|
|
763
2503
|
}
|
|
2504
|
+
/**
|
|
2505
|
+
* Codegraph offer inline branch for `pugi init` (Wave 6 BT 9 Phase 2).
|
|
2506
|
+
*
|
|
2507
|
+
* Pure information surface — does NOT prompt synchronously. Returns:
|
|
2508
|
+
*
|
|
2509
|
+
* - `text[]` — lines к append к the human-facing init summary
|
|
2510
|
+
* - `envelope` — structured JSON payload included in `--json` output
|
|
2511
|
+
* so a CI harness can branch на the verdict без
|
|
2512
|
+
* re-running detection.
|
|
2513
|
+
*
|
|
2514
|
+
* The interactive Yes/no prompt lives one layer up (the `/init` REPL
|
|
2515
|
+
* slash handles it). The standalone `pugi init` is intentionally non-
|
|
2516
|
+
* interactive — operators wanting a one-liner install can run
|
|
2517
|
+
* `pugi mcp install codegraph codegraph serve --mcp` after seeing the
|
|
2518
|
+
* hint here.
|
|
2519
|
+
*/
|
|
2520
|
+
async function maybeOfferCodegraphInline(workspaceRoot, flags) {
|
|
2521
|
+
try {
|
|
2522
|
+
const { evaluateOffer, emitOfferShown } = await import('../core/codegraph/offer-hook.js');
|
|
2523
|
+
const verdict = evaluateOffer({ workspaceRoot });
|
|
2524
|
+
if (!verdict.shouldPrompt) {
|
|
2525
|
+
return {
|
|
2526
|
+
text: [],
|
|
2527
|
+
envelope: {
|
|
2528
|
+
status: 'skipped',
|
|
2529
|
+
reason: verdict.reason,
|
|
2530
|
+
},
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
2533
|
+
// Surface the telemetry shown-event only for surfaces that
|
|
2534
|
+
// actually rendered к the operator. `--json` consumers still see
|
|
2535
|
+
// the verdict в the envelope so we count those as shown too.
|
|
2536
|
+
emitOfferShown(verdict.detection);
|
|
2537
|
+
const noTty = flags.noTty || flags.json;
|
|
2538
|
+
const lines = [
|
|
2539
|
+
'',
|
|
2540
|
+
'Codegraph context-aware install (Wave 6):',
|
|
2541
|
+
` ${verdict.promptCopy}`,
|
|
2542
|
+
` Docs: ${verdict.docsUrl}`,
|
|
2543
|
+
];
|
|
2544
|
+
if (!noTty) {
|
|
2545
|
+
lines.push(' Accept: `pugi mcp install codegraph codegraph serve --mcp && pugi mcp trust codegraph`', ' Skip: `pugi mcp install codegraph` will not run automatically — your call.');
|
|
2546
|
+
}
|
|
2547
|
+
else {
|
|
2548
|
+
lines.push(' Non-interactive mode — codegraph NOT auto-installed.', ' Run `pugi mcp install codegraph codegraph serve --mcp` to opt in.');
|
|
2549
|
+
}
|
|
2550
|
+
return {
|
|
2551
|
+
text: lines,
|
|
2552
|
+
envelope: {
|
|
2553
|
+
status: 'offered',
|
|
2554
|
+
sizeCategory: verdict.detection.sizeCategory,
|
|
2555
|
+
languages: verdict.detection.languages,
|
|
2556
|
+
primarySymbolCount: verdict.detection.primarySymbolCount,
|
|
2557
|
+
copy: verdict.promptCopy,
|
|
2558
|
+
docsUrl: verdict.docsUrl,
|
|
2559
|
+
},
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
catch (error) {
|
|
2563
|
+
// Defensive — codegraph offer is best-effort, must not fail init.
|
|
2564
|
+
return {
|
|
2565
|
+
text: [],
|
|
2566
|
+
envelope: {
|
|
2567
|
+
status: 'error',
|
|
2568
|
+
reason: error.message,
|
|
2569
|
+
},
|
|
2570
|
+
};
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
764
2573
|
async function idea(args, flags, session) {
|
|
765
2574
|
const prompt = args.join(' ').trim();
|
|
766
2575
|
if (!prompt) {
|
|
@@ -901,6 +2710,7 @@ async function idea(args, flags, session) {
|
|
|
901
2710
|
*/
|
|
902
2711
|
async function offlinePlan(args, flags, session) {
|
|
903
2712
|
const root = process.cwd();
|
|
2713
|
+
await runAutoInitPreflight(root, flags);
|
|
904
2714
|
ensureInitialized(root);
|
|
905
2715
|
const prompt = args.join(' ').trim();
|
|
906
2716
|
const latestIdea = latestArtifactDir(root);
|
|
@@ -975,6 +2785,7 @@ async function offlinePlan(args, flags, session) {
|
|
|
975
2785
|
}
|
|
976
2786
|
async function offlineBuild(args, flags, session) {
|
|
977
2787
|
const root = process.cwd();
|
|
2788
|
+
await runAutoInitPreflight(root, flags);
|
|
978
2789
|
ensureInitialized(root);
|
|
979
2790
|
const prompt = args.join(' ').trim();
|
|
980
2791
|
if (!prompt) {
|
|
@@ -1072,6 +2883,7 @@ async function offlineExplain(args, flags, session) {
|
|
|
1072
2883
|
}
|
|
1073
2884
|
async function review(args, flags, session) {
|
|
1074
2885
|
const root = process.cwd();
|
|
2886
|
+
await runAutoInitPreflight(root, flags);
|
|
1075
2887
|
ensureInitialized(root);
|
|
1076
2888
|
const prompt = args.join(' ').trim();
|
|
1077
2889
|
// α6.7: customer-facing consensus review routes here. Distinct from
|
|
@@ -1079,10 +2891,20 @@ async function review(args, flags, session) {
|
|
|
1079
2891
|
// streaming UX and rubric-driven exit codes don't disturb the existing
|
|
1080
2892
|
// pugi-cli surfaces that depend on the old shape.
|
|
1081
2893
|
if (flags.consensus) {
|
|
2894
|
+
// 2026-05-27 (Codex r0 P1 on PR #489): pass the globally-parsed
|
|
2895
|
+
// --commit / --base flags to consensus so `pugi review --consensus
|
|
2896
|
+
// --commit X` reviews the requested SHA instead of silently falling
|
|
2897
|
+
// back to the working-tree diff. parseConsensusArgs gives the inline
|
|
2898
|
+
// args (`--commit Y` after the command name) precedence; the
|
|
2899
|
+
// fallback only fires when `args` does not carry the token.
|
|
1082
2900
|
const exitCode = await runReviewConsensus(args, {
|
|
1083
2901
|
cwd: root,
|
|
1084
2902
|
config: resolveRuntimeConfig(),
|
|
1085
2903
|
json: flags.json,
|
|
2904
|
+
flagsFallback: {
|
|
2905
|
+
...(flags.commit ? { commit: flags.commit } : {}),
|
|
2906
|
+
...(flags.base ? { base: flags.base } : {}),
|
|
2907
|
+
},
|
|
1086
2908
|
emit: (line) => {
|
|
1087
2909
|
if (!flags.json)
|
|
1088
2910
|
process.stdout.write(line);
|
|
@@ -1094,6 +2916,15 @@ async function review(args, flags, session) {
|
|
|
1094
2916
|
process.exitCode = exitCode;
|
|
1095
2917
|
return;
|
|
1096
2918
|
}
|
|
2919
|
+
if (flags.triple && flags.commit) {
|
|
2920
|
+
// CEO directive 2026-05-27: `pugi review --triple --commit <SHA>`
|
|
2921
|
+
// dispatches to the customer-facing 3-model consensus path through
|
|
2922
|
+
// Anvil's already-paid Anthropic / OpenAI / Google routes. Replaces
|
|
2923
|
+
// the dev-only Codex/Claude/Gemini OAuth CLIs the `/triple-review`
|
|
2924
|
+
// skill uses.
|
|
2925
|
+
await performTripleProviderReview(root, session, flags, prompt);
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
1097
2928
|
if (flags.triple && flags.remote) {
|
|
1098
2929
|
await performRemoteTripleReview(root, session, flags, prompt);
|
|
1099
2930
|
return;
|
|
@@ -1199,6 +3030,7 @@ async function review(args, flags, session) {
|
|
|
1199
3030
|
}
|
|
1200
3031
|
async function sync(_args, flags, session) {
|
|
1201
3032
|
const root = process.cwd();
|
|
3033
|
+
await runAutoInitPreflight(root, flags);
|
|
1202
3034
|
ensureInitialized(root);
|
|
1203
3035
|
const settings = loadSettings(root);
|
|
1204
3036
|
const mode = flags.privacy ?? privacyModeFromSettings(settings.privacy.mode);
|
|
@@ -1432,17 +3264,214 @@ async function performRemoteTripleReview(root, session, flags, prompt) {
|
|
|
1432
3264
|
workspace: {
|
|
1433
3265
|
rootName: root.split('/').at(-1) ?? 'workspace',
|
|
1434
3266
|
gitBranch: safeGit(root, ['branch', '--show-current']).trim() || null,
|
|
1435
|
-
gitHead: safeGit(root, ['rev-parse', '--short', 'HEAD']).trim() || null,
|
|
3267
|
+
gitHead: safeGit(root, ['rev-parse', '--short', 'HEAD']).trim() || null,
|
|
3268
|
+
baseRef,
|
|
3269
|
+
dirty: Boolean(safeGit(root, ['status', '--short']).trim()),
|
|
3270
|
+
},
|
|
3271
|
+
diffPatch: augmentedDiff,
|
|
3272
|
+
diffStats,
|
|
3273
|
+
prompt: prompt || undefined,
|
|
3274
|
+
locale: 'en-US',
|
|
3275
|
+
reviewerPersona: 'oes-dev',
|
|
3276
|
+
});
|
|
3277
|
+
writeFileSync(requestPath, `${JSON.stringify(requestBody, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
3278
|
+
registerArtifact(root, {
|
|
3279
|
+
id: artifactIdFromDir(artifactDir),
|
|
3280
|
+
kind: 'triple-review',
|
|
3281
|
+
path: relative(root, artifactDir),
|
|
3282
|
+
sessionId: session.id,
|
|
3283
|
+
createdAt: new Date().toISOString(),
|
|
3284
|
+
files: ['triple-review-request.json'],
|
|
3285
|
+
});
|
|
3286
|
+
if (!config) {
|
|
3287
|
+
const reason = 'No active Pugi credentials. Run `pugi login --token <PAT>` or set PUGI_API_KEY for CI use.';
|
|
3288
|
+
recordToolResult(session, toolCallId, 'error', reason);
|
|
3289
|
+
writeFileSync(summaryPath, buildTripleReviewMarkdown({
|
|
3290
|
+
prompt,
|
|
3291
|
+
requestPath: relative(root, requestPath),
|
|
3292
|
+
verdict: null,
|
|
3293
|
+
reason,
|
|
3294
|
+
response: null,
|
|
3295
|
+
}), { encoding: 'utf8', mode: 0o600 });
|
|
3296
|
+
writeOutput(flags, {
|
|
3297
|
+
status: 'auth_missing',
|
|
3298
|
+
request: relative(root, requestPath),
|
|
3299
|
+
summary: relative(root, summaryPath),
|
|
3300
|
+
}, [
|
|
3301
|
+
'Pugi triple-review request prepared but not sent — no active credentials.',
|
|
3302
|
+
`Request: ${relative(root, requestPath)}`,
|
|
3303
|
+
`Run \`pugi login --token <PAT>\` (or export PUGI_API_KEY for CI) then retry \`pugi review --triple --remote\`.`,
|
|
3304
|
+
].join('\n'));
|
|
3305
|
+
process.exitCode = 5;
|
|
3306
|
+
return;
|
|
3307
|
+
}
|
|
3308
|
+
const submitResult = await submitTripleReview(config, requestBody);
|
|
3309
|
+
if (submitResult.status === 'ok') {
|
|
3310
|
+
persistTripleReviewResult(resultPath, submitResult.response);
|
|
3311
|
+
writeFileSync(summaryPath, buildTripleReviewMarkdown({
|
|
3312
|
+
prompt,
|
|
3313
|
+
requestPath: relative(root, requestPath),
|
|
3314
|
+
verdict: submitResult.response.verdict,
|
|
3315
|
+
reason: submitResult.response.reason,
|
|
3316
|
+
response: submitResult.response,
|
|
3317
|
+
}), { encoding: 'utf8', mode: 0o600 });
|
|
3318
|
+
recordToolResult(session, toolCallId, submitResult.response.verdict === 'BLOCK' ? 'error' : 'success', `Verdict: ${submitResult.response.verdict} (${submitResult.response.reason})`);
|
|
3319
|
+
writeOutput(flags, {
|
|
3320
|
+
status: 'completed',
|
|
3321
|
+
verdict: submitResult.response.verdict,
|
|
3322
|
+
reason: submitResult.response.reason,
|
|
3323
|
+
counts: submitResult.response.counts,
|
|
3324
|
+
reviewerCount: submitResult.response.reviewerCount,
|
|
3325
|
+
effectiveTier: submitResult.response.effectiveTier,
|
|
3326
|
+
result: relative(root, resultPath),
|
|
3327
|
+
summary: relative(root, summaryPath),
|
|
3328
|
+
}, [
|
|
3329
|
+
`Pugi triple-review ${submitResult.response.verdict}: ${submitResult.response.reason}`,
|
|
3330
|
+
`Reviewers: ${submitResult.response.reviewerCount} (tier ${submitResult.response.effectiveTier})`,
|
|
3331
|
+
`Findings: P0=${submitResult.response.counts.P0} P1=${submitResult.response.counts.P1} P2=${submitResult.response.counts.P2} P3=${submitResult.response.counts.P3}`,
|
|
3332
|
+
`Result: ${relative(root, resultPath)}`,
|
|
3333
|
+
`Summary: ${relative(root, summaryPath)}`,
|
|
3334
|
+
].join('\n'));
|
|
3335
|
+
if (submitResult.response.verdict === 'BLOCK') {
|
|
3336
|
+
process.exitCode = 9;
|
|
3337
|
+
}
|
|
3338
|
+
return;
|
|
3339
|
+
}
|
|
3340
|
+
// Non-OK paths: persist local artifact noting outcome, surface actionable error.
|
|
3341
|
+
const outcome = describeSubmitFailure(submitResult);
|
|
3342
|
+
writeFileSync(summaryPath, buildTripleReviewMarkdown({
|
|
3343
|
+
prompt,
|
|
3344
|
+
requestPath: relative(root, requestPath),
|
|
3345
|
+
verdict: null,
|
|
3346
|
+
reason: outcome.message,
|
|
3347
|
+
response: null,
|
|
3348
|
+
}), { encoding: 'utf8', mode: 0o600 });
|
|
3349
|
+
recordToolResult(session, toolCallId, 'error', outcome.message);
|
|
3350
|
+
writeOutput(flags, {
|
|
3351
|
+
status: submitResult.status,
|
|
3352
|
+
code: submitResult.code,
|
|
3353
|
+
message: outcome.message,
|
|
3354
|
+
request: relative(root, requestPath),
|
|
3355
|
+
summary: relative(root, summaryPath),
|
|
3356
|
+
}, [
|
|
3357
|
+
outcome.headline,
|
|
3358
|
+
`Request: ${relative(root, requestPath)}`,
|
|
3359
|
+
`Summary: ${relative(root, summaryPath)}`,
|
|
3360
|
+
outcome.next ? `Next: ${outcome.next}` : '',
|
|
3361
|
+
]
|
|
3362
|
+
.filter(Boolean)
|
|
3363
|
+
.join('\n'));
|
|
3364
|
+
process.exitCode = outcome.exitCode;
|
|
3365
|
+
}
|
|
3366
|
+
/**
|
|
3367
|
+
* `pugi review --triple --commit <SHA>` — customer-facing 3-model
|
|
3368
|
+
* consensus review via Anvil multi-provider routing.
|
|
3369
|
+
*
|
|
3370
|
+
* Dispatches the same diff to Anthropic / OpenAI / Google models
|
|
3371
|
+
* (routed through Anvil's already-paid fleet, NOT OAuth-bound dev
|
|
3372
|
+
* CLIs) and renders the per-reviewer verdict + cross-model
|
|
3373
|
+
* disagreement summary at the end. Quota: one `reviewPerMonth` slot
|
|
3374
|
+
* per call regardless of provider count — the controller-level
|
|
3375
|
+
* `@QuotaGated('reviewPerMonth')` decorator enforces single-slot
|
|
3376
|
+
* debit (see apps/admin-api/src/pugi/pugi.controller.ts).
|
|
3377
|
+
*
|
|
3378
|
+
* CEO directive 2026-05-27: replaces the dev-only `/triple-review`
|
|
3379
|
+
* skill's Codex/Claude/Gemini OAuth dependency with a customer-
|
|
3380
|
+
* runnable Pugi product surface. Dogfood loop: Pugi reviews Pugi PRs.
|
|
3381
|
+
*/
|
|
3382
|
+
async function performTripleProviderReview(root, session, flags, prompt) {
|
|
3383
|
+
const config = resolveRuntimeConfig();
|
|
3384
|
+
const artifactDir = createArtifactDir(root, prompt || 'triple-providers');
|
|
3385
|
+
const requestPath = resolve(artifactDir, 'triple-review-request.json');
|
|
3386
|
+
const resultPath = resolve(artifactDir, 'triple-review-result.json');
|
|
3387
|
+
const summaryPath = resolve(artifactDir, 'triple-review.md');
|
|
3388
|
+
const toolCallId = recordToolCall(session, 'review:triple-providers', prompt || `review ${flags.commit ?? 'HEAD'} via providers`);
|
|
3389
|
+
// Resolve base ref. CLI flag wins over settings → so an operator
|
|
3390
|
+
// can target a specific integration branch without editing settings.
|
|
3391
|
+
const settings = loadSettings(root);
|
|
3392
|
+
const baseRef = flags.base ?? resolveBaseRef(root, settings) ?? 'origin/main';
|
|
3393
|
+
// Normalise both the commit and the base to short SHAs so the audit
|
|
3394
|
+
// log stores a stable reference even if branches move.
|
|
3395
|
+
const commitRef = flags.commit ?? 'HEAD';
|
|
3396
|
+
// 2026-05-27 (Codex r0 P2 on PR #489): safeGit returns '' on a bad ref
|
|
3397
|
+
// (it swallows the git exit code so callers don't have to wrap every
|
|
3398
|
+
// probe). Without an explicit refusal, a misspelled --commit or --base
|
|
3399
|
+
// produced an EMPTY diff that the gate then PASSED — operators saw a
|
|
3400
|
+
// green review for changes that were never reviewed. Resolve both refs
|
|
3401
|
+
// through `rev-parse --verify` first; an empty result is a hard error.
|
|
3402
|
+
const verifiedCommit = safeGit(root, ['rev-parse', '--verify', commitRef]).trim();
|
|
3403
|
+
if (!verifiedCommit) {
|
|
3404
|
+
throw new Error(`pugi review --triple: cannot resolve --commit '${commitRef}' — ` +
|
|
3405
|
+
`check the SHA or branch name. ` +
|
|
3406
|
+
`Refusing to submit an empty diff for review.`);
|
|
3407
|
+
}
|
|
3408
|
+
const verifiedBase = safeGit(root, ['rev-parse', '--verify', baseRef]).trim();
|
|
3409
|
+
if (!verifiedBase) {
|
|
3410
|
+
throw new Error(`pugi review --triple: cannot resolve --base '${baseRef}' — ` +
|
|
3411
|
+
`check the ref or set base via 'pugi config set review.base=<ref>'. ` +
|
|
3412
|
+
`Refusing to submit an empty diff for review.`);
|
|
3413
|
+
}
|
|
3414
|
+
const resolvedCommit = safeGit(root, ['rev-parse', '--short', commitRef]).trim() || commitRef;
|
|
3415
|
+
// merge-base is intentionally a PROBE: an empty result is a valid
|
|
3416
|
+
// signal (orphan branch, shallow clone, moved tag) that the dispatch
|
|
3417
|
+
// path handles by falling back к range-notation. Use the legacy
|
|
3418
|
+
// `safeGit` (probe semantics) explicitly rather than the strict
|
|
3419
|
+
// variant.
|
|
3420
|
+
const mergeBase = safeGitProbe(root, ['merge-base', baseRef, commitRef]).trim() || '';
|
|
3421
|
+
// 2026-05-27 (Claude review followup #489): when merge-base returns empty
|
|
3422
|
+
// (orphan branch, shallow clone, moved tag), we MUST NOT pass the
|
|
3423
|
+
// `<range> <commitRef>` two-arg form to `git diff` — that combo is
|
|
3424
|
+
// invalid syntax, git exits 129, `safeGit` swallows the error, and the
|
|
3425
|
+
// diff payload ships empty. An empty diff is then classified as
|
|
3426
|
+
// `'code'` server-side, dispatched to reviewers who emit a trivial
|
|
3427
|
+
// `VERDICT: PASS` over zero lines — a SILENT GREEN REVIEW on a commit
|
|
3428
|
+
// nobody actually examined. Branch on `mergeBase` так что:
|
|
3429
|
+
// - mergeBase present → `git diff <mergeBase> <commitRef> --`
|
|
3430
|
+
// (both endpoints explicit, only-uncommitted-against-base ignored
|
|
3431
|
+
// because commitRef is a SHA, not HEAD).
|
|
3432
|
+
// - mergeBase empty → `git diff <baseRef>..<commitRef> --`
|
|
3433
|
+
// (range form encodes both endpoints; do NOT append commitRef
|
|
3434
|
+
// again or git rejects the args).
|
|
3435
|
+
const diffRange = mergeBase || `${baseRef}..${commitRef}`;
|
|
3436
|
+
const diffArgs = mergeBase
|
|
3437
|
+
? ['diff', mergeBase, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES]
|
|
3438
|
+
: ['diff', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
|
|
3439
|
+
const diffStatArgs = mergeBase
|
|
3440
|
+
? ['diff', '--shortstat', mergeBase, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES]
|
|
3441
|
+
: ['diff', '--shortstat', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
|
|
3442
|
+
// Use the strict variant — a non-empty diffPatch is load-bearing for
|
|
3443
|
+
// the review gate. If git fails for ANY reason (bad ref, ENOBUFS, FS
|
|
3444
|
+
// permission), we'd rather surface a hard error than ship a green
|
|
3445
|
+
// review on nothing. The `--shortstat` companion uses the same
|
|
3446
|
+
// helper so the throw is symmetric.
|
|
3447
|
+
const diffPatch = safeGitRequired(root, diffArgs, 'triple-providers diff');
|
|
3448
|
+
const diffStats = parseDiffStats(safeGitRequired(root, diffStatArgs, 'triple-providers diff --shortstat'));
|
|
3449
|
+
if (diffPatch.trim() === '') {
|
|
3450
|
+
throw new Error(`pugi review --triple: empty diff between '${baseRef}' and '${commitRef}'. ` +
|
|
3451
|
+
`Refusing to dispatch a review for zero changes — check the refs ` +
|
|
3452
|
+
`or commit your changes before running.`);
|
|
3453
|
+
}
|
|
3454
|
+
const requestBody = pugiTripleReviewRequestSchema.parse({
|
|
3455
|
+
schema: 1,
|
|
3456
|
+
workspace: {
|
|
3457
|
+
rootName: root.split('/').at(-1) ?? 'workspace',
|
|
3458
|
+
gitBranch: safeGit(root, ['branch', '--show-current']).trim() || null,
|
|
3459
|
+
gitHead: resolvedCommit || null,
|
|
1436
3460
|
baseRef,
|
|
1437
3461
|
dirty: Boolean(safeGit(root, ['status', '--short']).trim()),
|
|
1438
3462
|
},
|
|
1439
|
-
diffPatch
|
|
3463
|
+
diffPatch,
|
|
1440
3464
|
diffStats,
|
|
1441
3465
|
prompt: prompt || undefined,
|
|
1442
3466
|
locale: 'en-US',
|
|
1443
3467
|
reviewerPersona: 'oes-dev',
|
|
3468
|
+
commit: resolvedCommit,
|
|
3469
|
+
modelProviders: ['claude', 'gpt', 'gemini'],
|
|
3470
|
+
});
|
|
3471
|
+
writeFileSync(requestPath, `${JSON.stringify(requestBody, null, 2)}\n`, {
|
|
3472
|
+
encoding: 'utf8',
|
|
3473
|
+
mode: 0o600,
|
|
1444
3474
|
});
|
|
1445
|
-
writeFileSync(requestPath, `${JSON.stringify(requestBody, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
1446
3475
|
registerArtifact(root, {
|
|
1447
3476
|
id: artifactIdFromDir(artifactDir),
|
|
1448
3477
|
kind: 'triple-review',
|
|
@@ -1466,70 +3495,174 @@ async function performRemoteTripleReview(root, session, flags, prompt) {
|
|
|
1466
3495
|
request: relative(root, requestPath),
|
|
1467
3496
|
summary: relative(root, summaryPath),
|
|
1468
3497
|
}, [
|
|
1469
|
-
'Pugi triple-review request prepared but not sent — no active credentials.',
|
|
3498
|
+
'Pugi triple-provider review request prepared but not sent — no active credentials.',
|
|
1470
3499
|
`Request: ${relative(root, requestPath)}`,
|
|
1471
|
-
`Run \`pugi login --token <PAT>\` (or export PUGI_API_KEY for CI) then retry \`pugi review --triple --
|
|
3500
|
+
`Run \`pugi login --token <PAT>\` (or export PUGI_API_KEY for CI) then retry \`pugi review --triple --commit ${resolvedCommit}\`.`,
|
|
1472
3501
|
].join('\n'));
|
|
1473
3502
|
process.exitCode = 5;
|
|
1474
3503
|
return;
|
|
1475
3504
|
}
|
|
1476
3505
|
const submitResult = await submitTripleReview(config, requestBody);
|
|
1477
|
-
if (submitResult.status
|
|
1478
|
-
|
|
3506
|
+
if (submitResult.status !== 'ok') {
|
|
3507
|
+
const outcome = describeSubmitFailure(submitResult);
|
|
1479
3508
|
writeFileSync(summaryPath, buildTripleReviewMarkdown({
|
|
1480
3509
|
prompt,
|
|
1481
3510
|
requestPath: relative(root, requestPath),
|
|
1482
|
-
verdict:
|
|
1483
|
-
reason:
|
|
1484
|
-
response:
|
|
3511
|
+
verdict: null,
|
|
3512
|
+
reason: outcome.message,
|
|
3513
|
+
response: null,
|
|
1485
3514
|
}), { encoding: 'utf8', mode: 0o600 });
|
|
1486
|
-
recordToolResult(session, toolCallId,
|
|
3515
|
+
recordToolResult(session, toolCallId, 'error', outcome.message);
|
|
1487
3516
|
writeOutput(flags, {
|
|
1488
|
-
status:
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
reviewerCount: submitResult.response.reviewerCount,
|
|
1493
|
-
effectiveTier: submitResult.response.effectiveTier,
|
|
1494
|
-
result: relative(root, resultPath),
|
|
3517
|
+
status: submitResult.status,
|
|
3518
|
+
code: submitResult.code,
|
|
3519
|
+
message: outcome.message,
|
|
3520
|
+
request: relative(root, requestPath),
|
|
1495
3521
|
summary: relative(root, summaryPath),
|
|
1496
3522
|
}, [
|
|
1497
|
-
|
|
1498
|
-
`
|
|
1499
|
-
`Findings: P0=${submitResult.response.counts.P0} P1=${submitResult.response.counts.P1} P2=${submitResult.response.counts.P2} P3=${submitResult.response.counts.P3}`,
|
|
1500
|
-
`Result: ${relative(root, resultPath)}`,
|
|
3523
|
+
outcome.headline,
|
|
3524
|
+
`Request: ${relative(root, requestPath)}`,
|
|
1501
3525
|
`Summary: ${relative(root, summaryPath)}`,
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
3526
|
+
outcome.next ? `Next: ${outcome.next}` : '',
|
|
3527
|
+
]
|
|
3528
|
+
.filter(Boolean)
|
|
3529
|
+
.join('\n'));
|
|
3530
|
+
process.exitCode = outcome.exitCode;
|
|
1506
3531
|
return;
|
|
1507
3532
|
}
|
|
1508
|
-
|
|
1509
|
-
|
|
3533
|
+
const response = submitResult.response;
|
|
3534
|
+
persistTripleReviewResult(resultPath, response);
|
|
1510
3535
|
writeFileSync(summaryPath, buildTripleReviewMarkdown({
|
|
1511
3536
|
prompt,
|
|
1512
3537
|
requestPath: relative(root, requestPath),
|
|
1513
|
-
verdict:
|
|
1514
|
-
reason:
|
|
1515
|
-
response
|
|
3538
|
+
verdict: response.verdict,
|
|
3539
|
+
reason: response.reason,
|
|
3540
|
+
response,
|
|
1516
3541
|
}), { encoding: 'utf8', mode: 0o600 });
|
|
1517
|
-
recordToolResult(session, toolCallId, 'error',
|
|
3542
|
+
recordToolResult(session, toolCallId, response.verdict === 'BLOCK' ? 'error' : 'success', `Verdict: ${response.verdict} (${response.reason})`);
|
|
3543
|
+
const verdictReport = renderTripleProviderVerdict({
|
|
3544
|
+
response,
|
|
3545
|
+
commit: resolvedCommit,
|
|
3546
|
+
baseRef,
|
|
3547
|
+
});
|
|
1518
3548
|
writeOutput(flags, {
|
|
1519
|
-
status:
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
3549
|
+
status: 'completed',
|
|
3550
|
+
verdict: response.verdict,
|
|
3551
|
+
reason: response.reason,
|
|
3552
|
+
counts: response.counts,
|
|
3553
|
+
reviewerCount: response.reviewerCount,
|
|
3554
|
+
effectiveTier: response.effectiveTier,
|
|
3555
|
+
commit: resolvedCommit,
|
|
3556
|
+
baseRef,
|
|
3557
|
+
reviewers: response.reviewers.map((r) => ({
|
|
3558
|
+
provider: r.provider ?? null,
|
|
3559
|
+
model: r.model,
|
|
3560
|
+
declaredVerdict: r.declaredVerdict,
|
|
3561
|
+
findings: r.findings,
|
|
3562
|
+
latencyMs: r.latencyMs,
|
|
3563
|
+
tokensUsed: r.tokensUsed,
|
|
3564
|
+
error: r.error,
|
|
3565
|
+
})),
|
|
3566
|
+
result: relative(root, resultPath),
|
|
1523
3567
|
summary: relative(root, summaryPath),
|
|
1524
|
-
},
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
3568
|
+
}, verdictReport);
|
|
3569
|
+
if (response.verdict === 'BLOCK') {
|
|
3570
|
+
process.exitCode = 9;
|
|
3571
|
+
}
|
|
3572
|
+
else if (response.verdict === 'WARN') {
|
|
3573
|
+
process.exitCode = 1;
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3576
|
+
/**
|
|
3577
|
+
* Pretty-printer for the `pugi review --triple --commit <SHA>` verdict.
|
|
3578
|
+
* Mirrors the `/triple-review` skill's verdict block (per-reviewer
|
|
3579
|
+
* counts table → final GATE line → per-reviewer verbatim → cross-
|
|
3580
|
+
* model disagreement summary → tokens/cost note) so the output is
|
|
3581
|
+
* familiar to operators who already use the dev-only skill.
|
|
3582
|
+
*/
|
|
3583
|
+
export function renderTripleProviderVerdict(input) {
|
|
3584
|
+
const { response, commit, baseRef } = input;
|
|
3585
|
+
const divider = '═'.repeat(68);
|
|
3586
|
+
const subDivider = '─'.repeat(68);
|
|
3587
|
+
// Per-reviewer counts table.
|
|
3588
|
+
const reviewerRows = response.reviewers.map((reviewer) => {
|
|
3589
|
+
const c = { P0: 0, P1: 0, P2: 0, P3: 0 };
|
|
3590
|
+
for (const f of reviewer.findings)
|
|
3591
|
+
c[f.severity] += 1;
|
|
3592
|
+
const status = reviewer.error
|
|
3593
|
+
? 'ERROR'
|
|
3594
|
+
: reviewer.declaredVerdict ?? 'UNKNOWN';
|
|
3595
|
+
const label = reviewer.provider
|
|
3596
|
+
? reviewer.provider.toUpperCase().padEnd(8)
|
|
3597
|
+
: reviewer.model.slice(0, 8).padEnd(8);
|
|
3598
|
+
return ` ${label} ${pad(c.P0)} ${pad(c.P1)} ${pad(c.P2)} ${pad(c.P3)} ${status}`;
|
|
3599
|
+
});
|
|
3600
|
+
// Cross-model disagreement: list severities flagged by 1 of N but not
|
|
3601
|
+
// the others. Surfaces the "highest-signal moment" per the skill.
|
|
3602
|
+
const disagreements = [];
|
|
3603
|
+
const allFindings = response.reviewers.flatMap((r) => r.findings.map((f) => ({
|
|
3604
|
+
provider: r.provider ?? r.model,
|
|
3605
|
+
severity: f.severity,
|
|
3606
|
+
line: f.line,
|
|
3607
|
+
issue: f.issue,
|
|
3608
|
+
})));
|
|
3609
|
+
const p1Flaggers = new Set(response.reviewers
|
|
3610
|
+
.filter((r) => r.findings.some((f) => f.severity === 'P1'))
|
|
3611
|
+
.map((r) => r.provider ?? r.model));
|
|
3612
|
+
if (p1Flaggers.size === 1) {
|
|
3613
|
+
const sole = [...p1Flaggers][0];
|
|
3614
|
+
disagreements.push(`Only ${sole} flagged a P1 — examine the disagreement, often the highest-signal moment.`);
|
|
3615
|
+
}
|
|
3616
|
+
const p0Flaggers = new Set(response.reviewers
|
|
3617
|
+
.filter((r) => r.findings.some((f) => f.severity === 'P0'))
|
|
3618
|
+
.map((r) => r.provider ?? r.model));
|
|
3619
|
+
if (p0Flaggers.size > 0 && p0Flaggers.size < response.reviewers.length) {
|
|
3620
|
+
disagreements.push(`P0 flagged by ${[...p0Flaggers].join(', ')} but not ${response.reviewers
|
|
3621
|
+
.filter((r) => !p0Flaggers.has(r.provider ?? r.model))
|
|
3622
|
+
.map((r) => r.provider ?? r.model)
|
|
3623
|
+
.join(', ')} — verify the finding before merging.`);
|
|
3624
|
+
}
|
|
3625
|
+
// Tokens / cost summary. Tokens are best-effort (some providers
|
|
3626
|
+
// return null). Cost is a placeholder pending billing wire-up; we
|
|
3627
|
+
// surface the quota note inline so the operator knows it counts as
|
|
3628
|
+
// one slot, not three.
|
|
3629
|
+
const totalTokens = response.reviewers.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
|
|
3630
|
+
// Verbatim reviewer outputs. Each section gets a header so operators
|
|
3631
|
+
// can scroll quickly and copy any individual reviewer's text into
|
|
3632
|
+
// their own notes / triage doc.
|
|
3633
|
+
const reviewerSections = response.reviewers.map((reviewer) => {
|
|
3634
|
+
const label = reviewer.provider
|
|
3635
|
+
? reviewer.provider.toUpperCase()
|
|
3636
|
+
: reviewer.model;
|
|
3637
|
+
const body = reviewer.error
|
|
3638
|
+
? `(reviewer errored: ${reviewer.error})`
|
|
3639
|
+
: reviewer.rawContent.trim() || '(empty response)';
|
|
3640
|
+
return [subDivider, `${label} SAYS (${reviewer.model}):`, '', body].join('\n');
|
|
3641
|
+
});
|
|
3642
|
+
return [
|
|
3643
|
+
`PUGI TRIPLE-PROVIDER REVIEW — commit ${commit} vs ${baseRef}`,
|
|
3644
|
+
divider,
|
|
3645
|
+
'',
|
|
3646
|
+
` P0 P1 P2 P3 Status`,
|
|
3647
|
+
...reviewerRows,
|
|
3648
|
+
'',
|
|
3649
|
+
`GATE: ${response.verdict}`,
|
|
3650
|
+
`Reason: ${response.reason}`,
|
|
3651
|
+
'',
|
|
3652
|
+
...reviewerSections,
|
|
3653
|
+
'',
|
|
3654
|
+
subDivider,
|
|
3655
|
+
'CROSS-MODEL DISAGREEMENT:',
|
|
3656
|
+
disagreements.length === 0
|
|
3657
|
+
? ' (none — all reviewers agreed within rubric tolerance)'
|
|
3658
|
+
: disagreements.map((d) => ` - ${d}`).join('\n'),
|
|
3659
|
+
'',
|
|
3660
|
+
`Tokens: ~${totalTokens} total across ${response.reviewers.length} reviewers`,
|
|
3661
|
+
'Quota: charged as 1 review slot (multi-provider counts as a single call).',
|
|
3662
|
+
].join('\n');
|
|
3663
|
+
}
|
|
3664
|
+
function pad(n) {
|
|
3665
|
+
return String(n).padStart(2, ' ');
|
|
1533
3666
|
}
|
|
1534
3667
|
function describeSubmitFailure(result) {
|
|
1535
3668
|
switch (result.status) {
|
|
@@ -1650,6 +3783,7 @@ function parseDiffStats(raw) {
|
|
|
1650
3783
|
}
|
|
1651
3784
|
async function handoff(args, flags, session) {
|
|
1652
3785
|
const root = process.cwd();
|
|
3786
|
+
await runAutoInitPreflight(root, flags);
|
|
1653
3787
|
ensureInitialized(root);
|
|
1654
3788
|
const reason = args[0] || 'web_continue';
|
|
1655
3789
|
const prompt = args.slice(1).join(' ').trim() || 'continue local Pugi session';
|
|
@@ -1657,6 +3791,25 @@ async function handoff(args, flags, session) {
|
|
|
1657
3791
|
writeOutput(flags, bundle, ['Pugi handoff bundle created', `Bundle: ${bundle.path}`].join('\n'));
|
|
1658
3792
|
}
|
|
1659
3793
|
async function sessions(args, flags, _session) {
|
|
3794
|
+
// L9 (2026-05-27): `pugi sessions undo-rewind [<session-id>]` rolls
|
|
3795
|
+
// back the latest /rewind by appending an inverse marker. Append-only,
|
|
3796
|
+
// reversible. Falls through to the legacy artifact-based handler when
|
|
3797
|
+
// the sub-command is not recognised.
|
|
3798
|
+
if (args[0] === 'undo-rewind') {
|
|
3799
|
+
const result = await runSessionsCommand(args, {
|
|
3800
|
+
workspaceRoot: process.cwd(),
|
|
3801
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
3802
|
+
});
|
|
3803
|
+
if (result) {
|
|
3804
|
+
if (result.status === 'failed_no_session' || result.status === 'failed_store') {
|
|
3805
|
+
process.exitCode = 1;
|
|
3806
|
+
}
|
|
3807
|
+
else if (result.status === 'noop_no_rewind') {
|
|
3808
|
+
process.exitCode = 2;
|
|
3809
|
+
}
|
|
3810
|
+
return;
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
1660
3813
|
// α6.4: `pugi sessions --local` / `--search "query"` route to the
|
|
1661
3814
|
// local SessionStore. The default surface stays artifact-based for
|
|
1662
3815
|
// backward compat — operators who relied on the index.json view get
|
|
@@ -1666,6 +3819,7 @@ async function sessions(args, flags, _session) {
|
|
|
1666
3819
|
return;
|
|
1667
3820
|
}
|
|
1668
3821
|
const root = process.cwd();
|
|
3822
|
+
await runAutoInitPreflight(root, flags);
|
|
1669
3823
|
ensureInitialized(root);
|
|
1670
3824
|
const rebuild = args.includes('--rebuild');
|
|
1671
3825
|
let index = rebuild ? null : readIndex(root);
|
|
@@ -1850,6 +4004,7 @@ async function resume(args, flags, session) {
|
|
|
1850
4004
|
await resumeLocalSession({ flags, arg0, wantsList });
|
|
1851
4005
|
return;
|
|
1852
4006
|
}
|
|
4007
|
+
await runAutoInitPreflight(root, flags);
|
|
1853
4008
|
ensureInitialized(root);
|
|
1854
4009
|
const target = args[0];
|
|
1855
4010
|
const artifacts = listArtifactSets(root);
|
|
@@ -2057,6 +4212,44 @@ const ENGINE_EXIT_CODES = {
|
|
|
2057
4212
|
function commandLabel(kind) {
|
|
2058
4213
|
return kind === 'build_task' ? 'build' : kind;
|
|
2059
4214
|
}
|
|
4215
|
+
/**
|
|
4216
|
+
* Heuristic: does the user-supplied first arg look like a file or
|
|
4217
|
+
* directory path the operator wants `pugi explain` to inspect? Used to
|
|
4218
|
+
* decide whether to run the pre-engine path-security gate vs treat the
|
|
4219
|
+
* arg as a free-form natural-language prompt.
|
|
4220
|
+
*
|
|
4221
|
+
* Triggers when the arg:
|
|
4222
|
+
* - starts with `.` (`.env`, `./src/foo`, `..`)
|
|
4223
|
+
* - starts with `/` (absolute path)
|
|
4224
|
+
* - contains `/` (`apps/admin-api/src/index.ts`)
|
|
4225
|
+
* - contains no spaces AND exists on disk relative to the workspace
|
|
4226
|
+
*
|
|
4227
|
+
* Misses (treated as free-form prompts):
|
|
4228
|
+
* - "what does this package.json define?" (has spaces)
|
|
4229
|
+
* - "trace the auth flow" (has spaces)
|
|
4230
|
+
*
|
|
4231
|
+
* The pre-engine gate is a defence in depth — the bash classifier and
|
|
4232
|
+
* file-tools `resolveWorkspacePath` already refuse the bad paths inside
|
|
4233
|
+
* the engine, but failing fast at the CLI seam lets the operator see a
|
|
4234
|
+
* crisp permission error with exit code 8 instead of the engine
|
|
4235
|
+
* pretending to "explain" the protected file.
|
|
4236
|
+
*/
|
|
4237
|
+
function looksLikePath(arg) {
|
|
4238
|
+
if (!arg)
|
|
4239
|
+
return false;
|
|
4240
|
+
if (arg.includes(' '))
|
|
4241
|
+
return false;
|
|
4242
|
+
if (arg.startsWith('.') || arg.startsWith('/') || arg.includes('/'))
|
|
4243
|
+
return true;
|
|
4244
|
+
// Last-resort check: bare-token paths that exist on disk
|
|
4245
|
+
// (`README.md`, `package.json`) still benefit from the gate.
|
|
4246
|
+
try {
|
|
4247
|
+
return existsSync(resolve(process.cwd(), arg));
|
|
4248
|
+
}
|
|
4249
|
+
catch {
|
|
4250
|
+
return false;
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
2060
4253
|
/**
|
|
2061
4254
|
* Sprint 2 Track A: wire `pugi code/explain/fix/plan/build` to the real
|
|
2062
4255
|
* `NativePugiEngineAdapter`. Each command:
|
|
@@ -2090,19 +4283,81 @@ let engineClientFactory = null;
|
|
|
2090
4283
|
export function setEngineClientFactory(factory) {
|
|
2091
4284
|
engineClientFactory = factory;
|
|
2092
4285
|
}
|
|
4286
|
+
/**
|
|
4287
|
+
* β-headless test seam: surface the module-scoped engine client factory
|
|
4288
|
+
* to sibling runtime modules (`headless.ts`) so the same fixture
|
|
4289
|
+
* injection that `setEngineClientFactory` provides for the
|
|
4290
|
+
* `runEngineTask` path applies to `pugi --print` runs. Production
|
|
4291
|
+
* callers never read this — the factory is `null` and falls through
|
|
4292
|
+
* to the real `AnvilEngineLoopClient`.
|
|
4293
|
+
*/
|
|
4294
|
+
export function getEngineClientFactory() {
|
|
4295
|
+
return engineClientFactory;
|
|
4296
|
+
}
|
|
4297
|
+
/**
|
|
4298
|
+
* β-headless test seam: optional stdout/stderr writers injected for
|
|
4299
|
+
* `pugi --print` runs. When set, the headless runner forwards every
|
|
4300
|
+
* NDJSON line / human-readable chunk to these closures instead of the
|
|
4301
|
+
* real `process.stdout.write` / `process.stderr.write`. Needed because
|
|
4302
|
+
* `node:test`'s worker pool hijacks `process.stdout` for a binary IPC
|
|
4303
|
+
* channel — a captureStdio override would race the runner's frames
|
|
4304
|
+
* and surface as `Unexpected token '\x0F'` JSON parse failures in spec
|
|
4305
|
+
* assertions. Production never sets these.
|
|
4306
|
+
*/
|
|
4307
|
+
let headlessStdoutWriter = null;
|
|
4308
|
+
let headlessStderrWriter = null;
|
|
4309
|
+
export function setHeadlessWriters(writers) {
|
|
4310
|
+
headlessStdoutWriter = writers.stdout ?? null;
|
|
4311
|
+
headlessStderrWriter = writers.stderr ?? null;
|
|
4312
|
+
}
|
|
2093
4313
|
function runEngineTask(kind) {
|
|
2094
4314
|
return async (args, flags, session) => {
|
|
2095
4315
|
const label = commandLabel(kind);
|
|
2096
4316
|
const root = process.cwd();
|
|
2097
|
-
//
|
|
2098
|
-
//
|
|
2099
|
-
//
|
|
4317
|
+
// Wave 6 UX (2026-05-27): auto-init pre-flight. On an interactive
|
|
4318
|
+
// TTY in a workspace без `.pugi/` we prompt
|
|
4319
|
+
// "Initialize a new Pugi workspace here? (Y/n)" and scaffold
|
|
4320
|
+
// inline on Y. Falls back к the legacy strict-assert (throw `Run
|
|
4321
|
+
// pugi init first`) in CI / `--no-init`, keeping pinned CI
|
|
4322
|
+
// assertions green.
|
|
4323
|
+
await runAutoInitPreflight(root, flags);
|
|
4324
|
+
// Post-condition assertion — narrows for the type checker and
|
|
4325
|
+
// matches the pre-Wave-6 invariant that the engine adapter
|
|
4326
|
+
// expects `.pugi/` к exist before it writes the events mirror.
|
|
2100
4327
|
ensureInitialized(root);
|
|
2101
|
-
|
|
4328
|
+
// Wave 6 UX (2026-05-27): auto-login pre-flight. Read-only
|
|
4329
|
+
// operators (`pugi explain` against a public repo) and `plan`/
|
|
4330
|
+
// `build` still have legitimate offline fallbacks below, so the
|
|
4331
|
+
// helper output is informational here — we capture it for the
|
|
4332
|
+
// engine_unavailable branch below but never bail unconditionally
|
|
4333
|
+
// on `missing`. `code` / `fix` reject offline runs explicitly,
|
|
4334
|
+
// mirroring the pre-existing contract.
|
|
4335
|
+
const auth = await runAutoAuthPreflight(flags);
|
|
4336
|
+
const credential = auth.status === 'ready' ? auth.credential : null;
|
|
2102
4337
|
const envConfig = loadRuntimeConfig();
|
|
2103
4338
|
const config = credential
|
|
2104
4339
|
? buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey })
|
|
2105
4340
|
: envConfig;
|
|
4341
|
+
// α6.8 EXTEND PR1 v2: `--decompose` gating runs BEFORE the offline
|
|
4342
|
+
// fallback. Two reasons:
|
|
4343
|
+
// 1. The flag is plan-only — surfacing the rejection for
|
|
4344
|
+
// `pugi build --decompose` before we drop into `offlineBuild`
|
|
4345
|
+
// means the operator gets a deterministic error instead of a
|
|
4346
|
+
// silent no-op stub.
|
|
4347
|
+
// 2. The decompose post-processor depends on the engine's final
|
|
4348
|
+
// text. The offline plan stub does not invoke the engine, so
|
|
4349
|
+
// `pugi plan --decompose --offline` would silently skip the
|
|
4350
|
+
// decomposition step. Refusing the combination up front is the
|
|
4351
|
+
// cheapest way to keep the contract honest.
|
|
4352
|
+
if (flags.decompose && kind !== 'plan') {
|
|
4353
|
+
throw new Error(`--decompose is only valid for \`pugi plan\` (got \`pugi ${label}\`)`);
|
|
4354
|
+
}
|
|
4355
|
+
if (flags.decompose && flags.offline) {
|
|
4356
|
+
throw new Error('--decompose requires the engine — drop --offline (decomposition needs the model to emit a fenced JSON block)');
|
|
4357
|
+
}
|
|
4358
|
+
if (flags.decompose && !config) {
|
|
4359
|
+
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)');
|
|
4360
|
+
}
|
|
2106
4361
|
// Offline fallback: preserves the local-first invariant. `plan` /
|
|
2107
4362
|
// `build` / `explain` drop back to their pre-Sprint-2 stub
|
|
2108
4363
|
// behaviour so an operator without an API key (or with --offline)
|
|
@@ -2127,6 +4382,73 @@ function runEngineTask(kind) {
|
|
|
2127
4382
|
if (kind === 'explain')
|
|
2128
4383
|
return offlineExplain(args, flags, session);
|
|
2129
4384
|
}
|
|
4385
|
+
// P0 fix 2026-05-28 (Codex audit): pre-engine path validation for
|
|
4386
|
+
// `pugi explain <path>`. Without this gate, when the first arg
|
|
4387
|
+
// resolves to an on-disk path the engine would happily forward it
|
|
4388
|
+
// to the model — which could then `bash cat .env` or `cat ../X` and
|
|
4389
|
+
// sidestep the file-tools `resolveWorkspacePath`/
|
|
4390
|
+
// `permissionGatedResolve` checks. The bash-classifier now refuses
|
|
4391
|
+
// those reads (PROTECTED_BASENAME_PATTERNS + detectParentTraversalRead),
|
|
4392
|
+
// but we ALSO fail fast at the CLI seam so:
|
|
4393
|
+
// - `pugi explain .env` exits non-zero with a permission error
|
|
4394
|
+
// - `pugi explain ..` exits non-zero with a path-escape error
|
|
4395
|
+
// - `pugi explain alias-to-env` (symlink to .env) exits non-zero
|
|
4396
|
+
// because `permissionGatedResolve` re-checks the realpath
|
|
4397
|
+
// matching the offlineExplain behaviour the spec asserts.
|
|
4398
|
+
if (kind === 'explain' && args.length > 0) {
|
|
4399
|
+
const firstArg = args[0];
|
|
4400
|
+
if (firstArg && looksLikePath(firstArg)) {
|
|
4401
|
+
const targetExists = (() => {
|
|
4402
|
+
try {
|
|
4403
|
+
// First reject parent-traversal patterns OUTRIGHT — even a
|
|
4404
|
+
// path that does not currently exist must not address a
|
|
4405
|
+
// location above the workspace.
|
|
4406
|
+
const resolved = resolveWorkspacePath(root, firstArg);
|
|
4407
|
+
// For paths that exist, run the realpath-aware permission
|
|
4408
|
+
// re-check so symlink aliases to protected files refuse
|
|
4409
|
+
// the same way the file-tools gate would.
|
|
4410
|
+
const settings = loadSettings(root);
|
|
4411
|
+
const protectedReason = protectedTargetReason({ tool: 'explain', kind: 'read', target: firstArg }, root);
|
|
4412
|
+
if (protectedReason) {
|
|
4413
|
+
throw new Error(`Permission deny for explain ${firstArg}: ${protectedReason}`);
|
|
4414
|
+
}
|
|
4415
|
+
// Symlink alias re-check: resolve to realpath and re-test
|
|
4416
|
+
// the basename. Mirrors `permissionGatedResolve` in
|
|
4417
|
+
// file-tools.ts so `alias-to-env -> .env` is refused.
|
|
4418
|
+
try {
|
|
4419
|
+
const real = realpathSync.native(resolved);
|
|
4420
|
+
if (real !== resolved) {
|
|
4421
|
+
const realProtected = protectedTargetReason({ tool: 'explain', kind: 'read', target: relative(root, real) }, root);
|
|
4422
|
+
if (realProtected) {
|
|
4423
|
+
throw new Error(`Permission deny for explain ${firstArg} (via symlink): ${realProtected}`);
|
|
4424
|
+
}
|
|
4425
|
+
}
|
|
4426
|
+
}
|
|
4427
|
+
catch (e) {
|
|
4428
|
+
const code = e.code;
|
|
4429
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR')
|
|
4430
|
+
throw e;
|
|
4431
|
+
}
|
|
4432
|
+
// Suppress unused-var warning while keeping settings load
|
|
4433
|
+
// explicit (some lint configs treat the const as dead).
|
|
4434
|
+
void settings;
|
|
4435
|
+
return true;
|
|
4436
|
+
}
|
|
4437
|
+
catch (error) {
|
|
4438
|
+
const message = error.message;
|
|
4439
|
+
writeOutput(flags, {
|
|
4440
|
+
command: label,
|
|
4441
|
+
status: 'blocked',
|
|
4442
|
+
reason: message,
|
|
4443
|
+
}, [`pugi ${label} refused: ${message}`].join('\n'));
|
|
4444
|
+
process.exitCode = ENGINE_EXIT_CODES.blocked;
|
|
4445
|
+
return false;
|
|
4446
|
+
}
|
|
4447
|
+
})();
|
|
4448
|
+
if (!targetExists)
|
|
4449
|
+
return;
|
|
4450
|
+
}
|
|
4451
|
+
}
|
|
2130
4452
|
// Engine path prompt gate. (Offline `explain` accepts a path as
|
|
2131
4453
|
// its first positional arg — that branch returned above before
|
|
2132
4454
|
// we reach this gate.)
|
|
@@ -2155,214 +4477,401 @@ function runEngineTask(kind) {
|
|
|
2155
4477
|
throw new Error(`pugi ${label} requires a prompt`);
|
|
2156
4478
|
}
|
|
2157
4479
|
}
|
|
4480
|
+
// α6.8 EXTEND PR1: when `--decompose` is set, augment the user
|
|
4481
|
+
// prompt with the decomposition-request suffix BEFORE the adapter
|
|
4482
|
+
// run. The system prompt for `plan` already constrains the model
|
|
4483
|
+
// to read-only tools + a plan deliverable; the suffix layers the
|
|
4484
|
+
// JSON-emission contract on top so the post-run parser can lift
|
|
4485
|
+
// the structured payload out of the final answer. The plan-only /
|
|
4486
|
+
// engine-required gates fired before the offline fallback above,
|
|
4487
|
+
// so by here we know we are on the engine path with a plan task.
|
|
4488
|
+
if (flags.decompose && kind === 'plan') {
|
|
4489
|
+
prompt = `${prompt}\n${DECOMPOSE_PROMPT_SUFFIX}`;
|
|
4490
|
+
}
|
|
2158
4491
|
// Narrow `config` for the type checker — the offline branches above
|
|
2159
4492
|
// return whenever `config` is null, so by this point it must be set.
|
|
2160
4493
|
if (!config) {
|
|
2161
4494
|
throw new Error('internal: engine config missing after offline gate');
|
|
2162
4495
|
}
|
|
2163
4496
|
const client = engineClientFactory ? engineClientFactory(config) : new AnvilEngineLoopClient(config);
|
|
2164
|
-
|
|
4497
|
+
// β1b r1 (--allow-fetch / --allow-search wiring, 2026-05-26):
|
|
4498
|
+
// forward operator flags to the adapter so the schema-advertise +
|
|
4499
|
+
// executor-dispatch gates see the OR of (settings.json flag, CLI
|
|
4500
|
+
// flag). PR #425 r1 Backend Architect: the comment at
|
|
4501
|
+
// `tool-bridge.ts:740` documented `--allow-fetch` but the flag was
|
|
4502
|
+
// never wired into the adapter constructor — fix lands here.
|
|
4503
|
+
//
|
|
4504
|
+
// β4 r2 P1 #3 — load the MCP registry pre-run so the engine's
|
|
4505
|
+
// tool-bridge advertises every trusted server's tools under
|
|
4506
|
+
// `mcp__<server>__<tool>`. Before this fix the registry was never
|
|
4507
|
+
// loaded in the CLI engine path: `pugi mcp install` + `pugi mcp
|
|
4508
|
+
// trust` ran successfully but `pugi code/explain/fix/build` still
|
|
4509
|
+
// saw zero `mcp__*` tools in the schema (so the feature was
|
|
4510
|
+
// non-functional at the customer-facing surface). The adapter does
|
|
4511
|
+
// NOT own the registry lifecycle — we tear it down in the `finally`
|
|
4512
|
+
// below regardless of outcome so live MCP child processes are
|
|
4513
|
+
// reaped before the CLI exits.
|
|
4514
|
+
//
|
|
4515
|
+
// Failure mode: a bad `.pugi/mcp.json` (corrupted JSON, schema
|
|
4516
|
+
// violation) bubbles as an exception from `loadMcpRegistry`. We
|
|
4517
|
+
// surface it as a warning on stderr and continue WITHOUT MCP — the
|
|
4518
|
+
// operator's `pugi code "..."` invocation should not fail just
|
|
4519
|
+
// because a stale MCP entry refuses to parse. They get the engine
|
|
4520
|
+
// run without `mcp__*` tools and a clear hint to fix the file.
|
|
4521
|
+
let mcpRegistry;
|
|
4522
|
+
try {
|
|
4523
|
+
mcpRegistry = await loadMcpRegistry(root);
|
|
4524
|
+
}
|
|
4525
|
+
catch (error) {
|
|
4526
|
+
process.stderr.write(`pugi ${label}: MCP registry load failed — ${error.message}. ` +
|
|
4527
|
+
`Continuing without MCP tools. Fix .pugi/mcp.json to enable.\n`);
|
|
4528
|
+
mcpRegistry = undefined;
|
|
4529
|
+
}
|
|
4530
|
+
// P1 fix (deep audit 2026-05-26): load the workspace HookRegistry so
|
|
4531
|
+
// `.pugi/hooks/` lifecycle hooks fire for model-initiated tool calls
|
|
4532
|
+
// from the engine loop, not just for direct CLI tool invocations.
|
|
4533
|
+
// SECURITY: a `PreToolUse onFailure: 'block'` hook that refuses bash
|
|
4534
|
+
// containing `rm` now applies to model dispatch. Before this fix the
|
|
4535
|
+
// hooks were INVISIBLE to the engine adapter — a workspace operator
|
|
4536
|
+
// who set up a block hook for destructive bash would still see the
|
|
4537
|
+
// model freely dispatch those calls.
|
|
4538
|
+
//
|
|
4539
|
+
// r2 fix (triple-review 2026-05-26 P2): the fail-open path is a
|
|
4540
|
+
// security hole. If `.pugi/hooks.json` exists but is malformed
|
|
4541
|
+
// (truncated write, typo, partial edit) and the operator has block
|
|
4542
|
+
// hooks configured, the previous `continue without hooks` silently
|
|
4543
|
+
// disabled the BLOCK rules — a hostile or careless mutation of the
|
|
4544
|
+
// file would turn off all SECURITY-CRITICAL refusals without any
|
|
4545
|
+
// visible signal. We now distinguish three cases:
|
|
4546
|
+
//
|
|
4547
|
+
// (a) Neither user nor project hooks file exists → no hooks. Safe.
|
|
4548
|
+
// (b) File(s) exist and load() succeeds → hooks live. Normal.
|
|
4549
|
+
// (c) File(s) exist and load() fails → REFUSE THE RUN with a
|
|
4550
|
+
// fatal stderr message and `process.exit(1)`. Operator must
|
|
4551
|
+
// fix the file OR set `PUGI_HOOKS_BYPASS=1` to override (the
|
|
4552
|
+
// escape hatch is logged loudly so it cannot be silent).
|
|
4553
|
+
//
|
|
4554
|
+
// The bypass env var exists for the mid-edit recovery case (the
|
|
4555
|
+
// operator is in the middle of fixing the file and needs to run
|
|
4556
|
+
// pugi to see the world state). It is NEVER a default — the
|
|
4557
|
+
// operator types it explicitly.
|
|
4558
|
+
const hookOutcome = await loadHookRegistryOrExit({
|
|
4559
|
+
workspaceRoot: root,
|
|
4560
|
+
session,
|
|
4561
|
+
label,
|
|
4562
|
+
});
|
|
4563
|
+
if (hookOutcome.kind === 'parse-failure-refused') {
|
|
4564
|
+
// The helper already emitted the fatal message on stderr. Exit
|
|
4565
|
+
// directly so dispatchEngineCommand's caller observes a non-zero
|
|
4566
|
+
// exit code without a stack trace.
|
|
4567
|
+
process.exit(1);
|
|
4568
|
+
}
|
|
4569
|
+
const hooks = hookOutcome.hooks;
|
|
4570
|
+
const adapter = new NativePugiEngineAdapter({
|
|
4571
|
+
client,
|
|
4572
|
+
session,
|
|
4573
|
+
allowFetch: flags.allowFetch,
|
|
4574
|
+
allowSearch: flags.allowSearch,
|
|
4575
|
+
...(mcpRegistry ? { mcpRegistry } : {}),
|
|
4576
|
+
...(hooks ? { hooks } : {}),
|
|
4577
|
+
// Non-interactive CLI path: the FSM prompt callback always denies
|
|
4578
|
+
// until the operator explicitly grants permission via
|
|
4579
|
+
// `pugi mcp perms` (out-of-band). A future Ink-backed REPL path
|
|
4580
|
+
// overrides this with a modal prompt; pipes / CI never auto-allow.
|
|
4581
|
+
mcpPrompt: defaultNonInteractiveMcpPrompt,
|
|
4582
|
+
// P1 fix (deep audit 2026-05-26): CLI dispatcher is non-interactive
|
|
4583
|
+
// by default — pipes, CI, and scripted `pugi code "..."` runs do
|
|
4584
|
+
// not have an ink modal to surface ask_user_question into. The
|
|
4585
|
+
// REPL layer (β2b ink modal wiring, future) overrides this with
|
|
4586
|
+
// `interactive: true` + a live askUserBridge.
|
|
4587
|
+
interactive: false,
|
|
4588
|
+
});
|
|
2165
4589
|
const toolCallId = recordToolCall(session, `engine:${adapter.name}`, `${label}: ${prompt}`);
|
|
2166
4590
|
const taskId = `${kind}-${Date.now()}`;
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
4591
|
+
// β4 r2 P1 #3 — try/finally so loaded MCP child processes are
|
|
4592
|
+
// reaped regardless of run outcome (success, blocked, failed,
|
|
4593
|
+
// thrown). The shutdown is best-effort; we never want a stuck
|
|
4594
|
+
// MCP server to mask a successful Pugi run.
|
|
4595
|
+
try {
|
|
4596
|
+
const events = adapter.run({
|
|
4597
|
+
id: taskId,
|
|
4598
|
+
kind,
|
|
4599
|
+
prompt,
|
|
4600
|
+
workspaceRoot: root,
|
|
4601
|
+
allowedPaths: [root],
|
|
4602
|
+
deniedPaths: [],
|
|
4603
|
+
artifacts: [],
|
|
4604
|
+
// plan mode is enforced inside the tool-bridge (read-only schema +
|
|
4605
|
+
// executor refusal sentinel). The permission mode here is the
|
|
4606
|
+
// workspace-level toggle and is unchanged from interactive default.
|
|
4607
|
+
permissionMode: 'auto',
|
|
4608
|
+
}, { sessionId: session.id });
|
|
4609
|
+
const statusEvents = [];
|
|
4610
|
+
let result = null;
|
|
4611
|
+
for await (const event of events) {
|
|
4612
|
+
if (event.type === 'status') {
|
|
4613
|
+
statusEvents.push(event.message);
|
|
4614
|
+
// For `explain` the spec wants status events on stderr so the
|
|
4615
|
+
// final summary on stdout is grep-able. Other commands keep the
|
|
4616
|
+
// events on stdout-via-final-text so the operator sees the
|
|
4617
|
+
// chronological trace.
|
|
4618
|
+
if (kind === 'explain' && !flags.json) {
|
|
4619
|
+
process.stderr.write(`${event.message}\n`);
|
|
4620
|
+
}
|
|
4621
|
+
}
|
|
4622
|
+
else {
|
|
4623
|
+
result = {
|
|
4624
|
+
status: event.result.status,
|
|
4625
|
+
summary: event.result.summary,
|
|
4626
|
+
filesChanged: event.result.filesChanged,
|
|
4627
|
+
eventRefs: event.result.eventRefs,
|
|
4628
|
+
risks: event.result.risks,
|
|
4629
|
+
};
|
|
2191
4630
|
}
|
|
2192
4631
|
}
|
|
2193
|
-
|
|
4632
|
+
if (!result) {
|
|
4633
|
+
// Adapter MUST emit a terminal result event. Treat the empty
|
|
4634
|
+
// outcome as a failure so the CLI surfaces a clear error rather
|
|
4635
|
+
// than exiting 0 with no output.
|
|
2194
4636
|
result = {
|
|
2195
|
-
status:
|
|
2196
|
-
summary:
|
|
2197
|
-
filesChanged:
|
|
2198
|
-
eventRefs:
|
|
2199
|
-
risks: event
|
|
4637
|
+
status: 'failed',
|
|
4638
|
+
summary: 'engine adapter returned no result',
|
|
4639
|
+
filesChanged: [],
|
|
4640
|
+
eventRefs: [],
|
|
4641
|
+
risks: ['adapter terminated without emitting a result event'],
|
|
2200
4642
|
};
|
|
2201
4643
|
}
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
//
|
|
2205
|
-
//
|
|
2206
|
-
//
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
summary: result.summary,
|
|
2240
|
-
eventRefs: result.eventRefs,
|
|
2241
|
-
},
|
|
2242
|
-
dryRun: flags.dryRun,
|
|
2243
|
-
});
|
|
2244
|
-
// Merge dispatcher-touched files into `result.filesChanged` so the
|
|
2245
|
-
// operator-facing summary lists them alongside tool-driven edits.
|
|
2246
|
-
for (const dr of dispatchResults) {
|
|
2247
|
-
if (dr.ok && dr.absPath) {
|
|
2248
|
-
const rel = relative(root, dr.absPath);
|
|
2249
|
-
if (!result.filesChanged.includes(rel))
|
|
2250
|
-
result.filesChanged.push(rel);
|
|
4644
|
+
// α6.6 diff escalation — Layer A/B/C dispatcher.
|
|
4645
|
+
//
|
|
4646
|
+
// Some models emit file edits as inline SEARCH/REPLACE markers in
|
|
4647
|
+
// the final response rather than through tool calls (especially
|
|
4648
|
+
// Gemini and o1 family, which under-use tool schemas in long
|
|
4649
|
+
// reasoning chains). We run the dispatcher against the model's
|
|
4650
|
+
// final text so those markers still land on disk. Tool-call edits
|
|
4651
|
+
// (Layer-A equivalent already handled by `edit`/`write` tools) are
|
|
4652
|
+
// unaffected — the dispatcher only fires on prose blocks that
|
|
4653
|
+
// happen to contain markers.
|
|
4654
|
+
//
|
|
4655
|
+
// Scope: code / fix / build / explain only. `plan` is read-only
|
|
4656
|
+
// (the engine refuses write tools), so even a stray marker in plan
|
|
4657
|
+
// output gets ignored to honour the plan-mode contract.
|
|
4658
|
+
//
|
|
4659
|
+
// Dry-run + read-only short-circuits: when the flags forbid writes
|
|
4660
|
+
// we dispatch with `dryRun: true` so the operator still sees what
|
|
4661
|
+
// WOULD have been written, but nothing touches disk.
|
|
4662
|
+
let dispatchResults = [];
|
|
4663
|
+
if (kind === 'code' || kind === 'fix' || kind === 'build_task') {
|
|
4664
|
+
dispatchResults = await runMarkerDispatch({
|
|
4665
|
+
root,
|
|
4666
|
+
result: {
|
|
4667
|
+
status: result.status,
|
|
4668
|
+
summary: result.summary,
|
|
4669
|
+
eventRefs: result.eventRefs,
|
|
4670
|
+
},
|
|
4671
|
+
dryRun: flags.dryRun,
|
|
4672
|
+
});
|
|
4673
|
+
// Merge dispatcher-touched files into `result.filesChanged` so the
|
|
4674
|
+
// operator-facing summary lists them alongside tool-driven edits.
|
|
4675
|
+
for (const dr of dispatchResults) {
|
|
4676
|
+
if (dr.ok && dr.absPath) {
|
|
4677
|
+
const rel = relative(root, dr.absPath);
|
|
4678
|
+
if (!result.filesChanged.includes(rel))
|
|
4679
|
+
result.filesChanged.push(rel);
|
|
4680
|
+
}
|
|
2251
4681
|
}
|
|
2252
4682
|
}
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
});
|
|
2266
|
-
}
|
|
2267
|
-
// Pull the headline metrics out of `eventRefs` so the summary and
|
|
2268
|
-
// JSON envelope match without re-parsing strings in two places.
|
|
2269
|
-
const metrics = parseEventRefs(result.eventRefs);
|
|
2270
|
-
const finalStatus = result.status === 'failed' ? 'error' : 'success';
|
|
2271
|
-
recordToolResult(session, toolCallId, finalStatus, result.summary);
|
|
2272
|
-
// Exit code policy (spec §1-§5):
|
|
2273
|
-
// code/fix/build → 0 done, 8 failed, 9 blocked
|
|
2274
|
-
// explain → same triple; read-only blocked = budget exhaustion
|
|
2275
|
-
// plan → 0 on done OR plan-mode refusal (refusal is a
|
|
2276
|
-
// SUCCESS for plan: the gate worked); 8 on failed
|
|
2277
|
-
// transport; 9 on budget exhaustion.
|
|
2278
|
-
//
|
|
2279
|
-
// Code Reviewer P2 retro 2026-05-23: previously `plan` masked
|
|
2280
|
-
// `budget_exhausted` as exit 0, so a CI loop with a token budget
|
|
2281
|
-
// hit looked identical to a successful plan. We now distinguish
|
|
2282
|
-
// via the adapter's `outcome=<status>` echo on `eventRefs` so
|
|
2283
|
-
// shell wrappers can branch on the real cause.
|
|
2284
|
-
if (kind === 'plan') {
|
|
2285
|
-
if (result.status === 'failed') {
|
|
2286
|
-
process.exitCode = ENGINE_EXIT_CODES.failed;
|
|
2287
|
-
}
|
|
2288
|
-
else if (result.status === 'blocked' &&
|
|
2289
|
-
metrics.outcome === 'budget_exhausted') {
|
|
2290
|
-
process.exitCode = ENGINE_EXIT_CODES.blocked;
|
|
4683
|
+
// For `plan` we always write a plan.md artifact, regardless of
|
|
4684
|
+
// outcome. A blocked plan (budget exhausted, tool refusal) still
|
|
4685
|
+
// produces a reviewable artifact — the reason is recorded inline.
|
|
4686
|
+
let planArtifact = null;
|
|
4687
|
+
if (kind === 'plan') {
|
|
4688
|
+
planArtifact = writePlanArtifact({
|
|
4689
|
+
root,
|
|
4690
|
+
session,
|
|
4691
|
+
prompt,
|
|
4692
|
+
result,
|
|
4693
|
+
statusEvents,
|
|
4694
|
+
});
|
|
2291
4695
|
}
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
4696
|
+
// α6.8 EXTEND PR1: `--decompose` post-processing. We only attempt
|
|
4697
|
+
// the parse on a `done` plan (a blocked/failed plan is already
|
|
4698
|
+
// captured in plan.md with its reason; no JSON to extract). The
|
|
4699
|
+
// model's final answer arrives via `result.summary` — on success
|
|
4700
|
+
// the adapter prefix is empty so it is the raw final text. We
|
|
4701
|
+
// strip any leading/trailing whitespace then run the parser
|
|
4702
|
+
// against the contents. On parse failure we surface a non-fatal
|
|
4703
|
+
// structured error in the payload — the operator still gets the
|
|
4704
|
+
// plan.md artifact and can re-run.
|
|
4705
|
+
//
|
|
4706
|
+
// TODO(α7.x): `result.summary` is currently a string contract that
|
|
4707
|
+
// doubles as both "human-readable headline" and "raw final model
|
|
4708
|
+
// text". Split into `{ summary, finalText }` on the adapter so the
|
|
4709
|
+
// parser does not have to assume the prefix is empty. Tracked in
|
|
4710
|
+
// PR #423 v2 retro (P2.6, Claude review).
|
|
4711
|
+
let decomposeArtifact = null;
|
|
4712
|
+
let decomposeError = null;
|
|
4713
|
+
if (flags.decompose && kind === 'plan' && result.status === 'done') {
|
|
4714
|
+
const parsed = parseDecompositionFromText(result.summary);
|
|
4715
|
+
if (parsed.ok) {
|
|
4716
|
+
decomposeArtifact = writeDecomposition({
|
|
4717
|
+
root,
|
|
4718
|
+
sessionId: session.id,
|
|
4719
|
+
// Persist the OPERATOR's original prompt, not the prompt+suffix
|
|
4720
|
+
// we sent to the engine. The suffix is plumbing; the manifest
|
|
4721
|
+
// header reads naturally only with the operator text.
|
|
4722
|
+
prompt: args.join(' ').trim() || prompt,
|
|
4723
|
+
decomposition: parsed.decomposition,
|
|
4724
|
+
rationale: parsed.rationale,
|
|
4725
|
+
});
|
|
4726
|
+
}
|
|
4727
|
+
else {
|
|
4728
|
+
decomposeError = { reason: parsed.reason, detail: parsed.detail };
|
|
4729
|
+
}
|
|
2298
4730
|
}
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
reason: dr.reason,
|
|
2324
|
-
detail: dr.detail,
|
|
2325
|
-
})),
|
|
2326
|
-
// The full event stream is useful for cabinet UI replay. We surface
|
|
2327
|
-
// it in JSON mode only — text mode operators want the summary, not
|
|
2328
|
-
// 30 turn-level lines.
|
|
2329
|
-
events: flags.json ? statusEvents : undefined,
|
|
2330
|
-
};
|
|
2331
|
-
const textLines = [];
|
|
2332
|
-
if (kind === 'plan' && planArtifact) {
|
|
2333
|
-
textLines.push(`Pugi plan written to ${planArtifact.relPath}`);
|
|
2334
|
-
}
|
|
2335
|
-
textLines.push(`Pugi ${label}: ${result.status}`);
|
|
2336
|
-
textLines.push(`Summary: ${result.summary}`);
|
|
2337
|
-
if (result.filesChanged.length > 0) {
|
|
2338
|
-
textLines.push(`Files modified (${result.filesChanged.length}):`);
|
|
2339
|
-
for (const file of result.filesChanged)
|
|
2340
|
-
textLines.push(` - ${file}`);
|
|
2341
|
-
}
|
|
2342
|
-
else if (kind !== 'explain' && kind !== 'plan') {
|
|
2343
|
-
textLines.push('Files modified: none');
|
|
2344
|
-
}
|
|
2345
|
-
textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
|
|
2346
|
-
if (dispatchResults.length > 0) {
|
|
2347
|
-
const okCount = dispatchResults.filter((d) => d.ok).length;
|
|
2348
|
-
const failCount = dispatchResults.length - okCount;
|
|
2349
|
-
textLines.push(`Diff dispatch: ${okCount} applied, ${failCount} rejected (${dispatchResults.length} marker block${dispatchResults.length === 1 ? '' : 's'})`);
|
|
2350
|
-
for (const dr of dispatchResults) {
|
|
2351
|
-
if (dr.ok) {
|
|
2352
|
-
textLines.push(` + ${dr.layer} ${dr.file} (${dr.bytesWritten} bytes)`);
|
|
4731
|
+
// Pull the headline metrics out of `eventRefs` so the summary and
|
|
4732
|
+
// JSON envelope match without re-parsing strings in two places.
|
|
4733
|
+
const metrics = parseEventRefs(result.eventRefs);
|
|
4734
|
+
const finalStatus = result.status === 'failed' ? 'error' : 'success';
|
|
4735
|
+
recordToolResult(session, toolCallId, finalStatus, result.summary);
|
|
4736
|
+
// Exit code policy (spec §1-§5):
|
|
4737
|
+
// code/fix/build → 0 done, 8 failed, 9 blocked
|
|
4738
|
+
// explain → same triple; read-only blocked = budget exhaustion
|
|
4739
|
+
// plan → 0 on done OR plan-mode refusal (refusal is a
|
|
4740
|
+
// SUCCESS for plan: the gate worked); 8 on failed
|
|
4741
|
+
// transport; 9 on budget exhaustion.
|
|
4742
|
+
//
|
|
4743
|
+
// Code Reviewer P2 retro 2026-05-23: previously `plan` masked
|
|
4744
|
+
// `budget_exhausted` as exit 0, so a CI loop with a token budget
|
|
4745
|
+
// hit looked identical to a successful plan. We now distinguish
|
|
4746
|
+
// via the adapter's `outcome=<status>` echo on `eventRefs` so
|
|
4747
|
+
// shell wrappers can branch on the real cause.
|
|
4748
|
+
if (kind === 'plan') {
|
|
4749
|
+
if (result.status === 'failed') {
|
|
4750
|
+
process.exitCode = ENGINE_EXIT_CODES.failed;
|
|
4751
|
+
}
|
|
4752
|
+
else if (result.status === 'blocked' &&
|
|
4753
|
+
metrics.outcome === 'budget_exhausted') {
|
|
4754
|
+
process.exitCode = ENGINE_EXIT_CODES.blocked;
|
|
2353
4755
|
}
|
|
2354
4756
|
else {
|
|
2355
|
-
|
|
4757
|
+
// `done`, or `blocked` with outcome=tool_refused (= the plan-mode
|
|
4758
|
+
// gate fired, which is the contract working as designed), or
|
|
4759
|
+
// `blocked` with no outcome echo (legacy adapter — preserve the
|
|
4760
|
+
// pre-retro 0 behaviour to avoid breaking external scripts).
|
|
4761
|
+
process.exitCode = 0;
|
|
4762
|
+
}
|
|
4763
|
+
}
|
|
4764
|
+
else {
|
|
4765
|
+
process.exitCode = ENGINE_EXIT_CODES[result.status];
|
|
4766
|
+
}
|
|
4767
|
+
const payload = {
|
|
4768
|
+
command: label,
|
|
4769
|
+
taskId,
|
|
4770
|
+
status: result.status,
|
|
4771
|
+
summary: result.summary,
|
|
4772
|
+
filesChanged: result.filesChanged,
|
|
4773
|
+
toolCalls: metrics.toolCalls,
|
|
4774
|
+
turns: metrics.turns,
|
|
4775
|
+
tokens: metrics.tokens,
|
|
4776
|
+
sessionId: session.id,
|
|
4777
|
+
sessionEventsMirror: metrics.mirror,
|
|
4778
|
+
risks: result.risks,
|
|
4779
|
+
plan: planArtifact ? { path: planArtifact.relPath } : undefined,
|
|
4780
|
+
// α6.6 — per-edit dispatcher trace. Empty array when no inline
|
|
4781
|
+
// markers were detected in the model's final response.
|
|
4782
|
+
diffEdits: dispatchResults.map((dr) => ({
|
|
4783
|
+
layer: dr.layer,
|
|
4784
|
+
file: dr.file,
|
|
4785
|
+
ok: dr.ok,
|
|
4786
|
+
bytesWritten: dr.bytesWritten,
|
|
4787
|
+
reason: dr.reason,
|
|
4788
|
+
detail: dr.detail,
|
|
4789
|
+
})),
|
|
4790
|
+
// α6.8 EXTEND PR1: decompose artifacts (only present when
|
|
4791
|
+
// `--decompose` was passed AND the model emitted a parseable
|
|
4792
|
+
// JSON block). The `error` shape lands when the model returned
|
|
4793
|
+
// unparseable output; the operator can re-run with a tighter
|
|
4794
|
+
// prompt without losing the plain plan.md artifact.
|
|
4795
|
+
decompose: decomposeArtifact !== null
|
|
4796
|
+
? {
|
|
4797
|
+
manifest: relative(root, decomposeArtifact.manifestPath),
|
|
4798
|
+
planDir: relative(root, decomposeArtifact.planDir),
|
|
4799
|
+
splits: decomposeArtifact.splitPaths,
|
|
4800
|
+
}
|
|
4801
|
+
: decomposeError !== null
|
|
4802
|
+
? { error: decomposeError }
|
|
4803
|
+
: undefined,
|
|
4804
|
+
// The full event stream is useful for cabinet UI replay. We surface
|
|
4805
|
+
// it in JSON mode only — text mode operators want the summary, not
|
|
4806
|
+
// 30 turn-level lines.
|
|
4807
|
+
events: flags.json ? statusEvents : undefined,
|
|
4808
|
+
};
|
|
4809
|
+
const textLines = [];
|
|
4810
|
+
if (kind === 'plan' && planArtifact) {
|
|
4811
|
+
textLines.push(`Pugi plan written to ${planArtifact.relPath}`);
|
|
4812
|
+
}
|
|
4813
|
+
if (decomposeArtifact !== null) {
|
|
4814
|
+
textLines.push(`Decomposition: ${decomposeArtifact.splitPaths.length} component spec${decomposeArtifact.splitPaths.length === 1 ? '' : 's'} under ${relative(root, decomposeArtifact.planDir)}`);
|
|
4815
|
+
textLines.push(`Manifest: ${relative(root, decomposeArtifact.manifestPath)}`);
|
|
4816
|
+
}
|
|
4817
|
+
else if (decomposeError !== null) {
|
|
4818
|
+
textLines.push(`Decomposition: skipped (${decomposeError.reason}) — plan.md still written`);
|
|
4819
|
+
}
|
|
4820
|
+
textLines.push(`Pugi ${label}: ${result.status}`);
|
|
4821
|
+
textLines.push(`Summary: ${result.summary}`);
|
|
4822
|
+
if (result.filesChanged.length > 0) {
|
|
4823
|
+
textLines.push(`Files modified (${result.filesChanged.length}):`);
|
|
4824
|
+
for (const file of result.filesChanged)
|
|
4825
|
+
textLines.push(` - ${file}`);
|
|
4826
|
+
}
|
|
4827
|
+
else if (kind !== 'explain' && kind !== 'plan') {
|
|
4828
|
+
textLines.push('Files modified: none');
|
|
4829
|
+
}
|
|
4830
|
+
textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
|
|
4831
|
+
if (dispatchResults.length > 0) {
|
|
4832
|
+
const okCount = dispatchResults.filter((d) => d.ok).length;
|
|
4833
|
+
const failCount = dispatchResults.length - okCount;
|
|
4834
|
+
textLines.push(`Diff dispatch: ${okCount} applied, ${failCount} rejected (${dispatchResults.length} marker block${dispatchResults.length === 1 ? '' : 's'})`);
|
|
4835
|
+
for (const dr of dispatchResults) {
|
|
4836
|
+
if (dr.ok) {
|
|
4837
|
+
textLines.push(` + ${dr.layer} ${dr.file} (${dr.bytesWritten} bytes)`);
|
|
4838
|
+
}
|
|
4839
|
+
else {
|
|
4840
|
+
textLines.push(` ! ${dr.layer} ${dr.file}: ${dr.reason ?? 'failure'} — ${dr.detail ?? ''}`);
|
|
4841
|
+
}
|
|
2356
4842
|
}
|
|
2357
4843
|
}
|
|
4844
|
+
if (result.risks.length > 0) {
|
|
4845
|
+
textLines.push(`Risks: ${result.risks.join('; ')}`);
|
|
4846
|
+
}
|
|
4847
|
+
textLines.push(`Session: ${session.id}`);
|
|
4848
|
+
if (metrics.mirror)
|
|
4849
|
+
textLines.push(`Events mirror: ${metrics.mirror}`);
|
|
4850
|
+
writeOutput(flags, payload, textLines.join('\n'));
|
|
2358
4851
|
}
|
|
2359
|
-
|
|
2360
|
-
|
|
4852
|
+
finally {
|
|
4853
|
+
// β4 r2 P1 #3 — tear down live MCP child processes BEFORE the
|
|
4854
|
+
// CLI exits. shutdown() is idempotent and swallows per-server
|
|
4855
|
+
// disconnect errors, so it is safe even if no servers connected.
|
|
4856
|
+
if (mcpRegistry) {
|
|
4857
|
+
await mcpRegistry.shutdown().catch((error) => {
|
|
4858
|
+
process.stderr.write(`pugi ${label}: MCP registry shutdown reported error — ${error.message}\n`);
|
|
4859
|
+
});
|
|
4860
|
+
}
|
|
4861
|
+
// Leak L15 (2026-05-27) — tear down any LSP servers warmed up
|
|
4862
|
+
// by the post-edit diagnostics cache. The cache is per-process
|
|
4863
|
+
// and survives across multiple tool calls; without this hook a
|
|
4864
|
+
// `pugi code ...` invocation would leak a tsserver process when
|
|
4865
|
+
// the Node host exits. The dynamic import keeps the cache module
|
|
4866
|
+
// out of the cold path for runs that never touch LSP.
|
|
4867
|
+
try {
|
|
4868
|
+
const { stopAllLspClients } = await import('../core/lsp/cache.js');
|
|
4869
|
+
await stopAllLspClients();
|
|
4870
|
+
}
|
|
4871
|
+
catch (error) {
|
|
4872
|
+
process.stderr.write(`pugi ${label}: LSP cache shutdown reported error — ${error.message}\n`);
|
|
4873
|
+
}
|
|
2361
4874
|
}
|
|
2362
|
-
textLines.push(`Session: ${session.id}`);
|
|
2363
|
-
if (metrics.mirror)
|
|
2364
|
-
textLines.push(`Events mirror: ${metrics.mirror}`);
|
|
2365
|
-
writeOutput(flags, payload, textLines.join('\n'));
|
|
2366
4875
|
};
|
|
2367
4876
|
}
|
|
2368
4877
|
// Exported for the α6.6.1 triple-review remediation spec
|
|
@@ -2594,7 +5103,7 @@ async function login(args, flags, _session) {
|
|
|
2594
5103
|
if (args.includes('--help') || args.includes('-h')) {
|
|
2595
5104
|
writeOutput(flags, {
|
|
2596
5105
|
command: 'login',
|
|
2597
|
-
usage: 'pugi login [--provider device|token|env] [--token <PAT>] [--token-stdin] [--label <name>] [--api-url <url>]',
|
|
5106
|
+
usage: 'pugi login [--provider device|token|env] [--token <PAT>] [--token-stdin] [--key <value>] [--skip-validate] [--label <name>] [--api-url <url>]',
|
|
2598
5107
|
}, [
|
|
2599
5108
|
'Usage: pugi login [options]',
|
|
2600
5109
|
'',
|
|
@@ -2606,19 +5115,27 @@ async function login(args, flags, _session) {
|
|
|
2606
5115
|
'Non-interactive options:',
|
|
2607
5116
|
' --provider device Run the device-flow login (recommended).',
|
|
2608
5117
|
' --provider token Store an API key passed via --token / --token-stdin / PUGI_LOGIN_TOKEN.',
|
|
2609
|
-
' --provider env
|
|
5118
|
+
' --provider env Read PUGI_API_KEY (or --key) and verify it via /api/pugi/health.',
|
|
2610
5119
|
' --token <PAT> Inline API key (visible in `ps`).',
|
|
2611
5120
|
' --token-stdin Read API key from stdin (gh-CLI style).',
|
|
5121
|
+
' --key <value> Explicit key for --provider env; beats PUGI_API_KEY.',
|
|
5122
|
+
' --skip-validate Skip the /api/pugi/health probe for --provider env (CI bootstrap).',
|
|
2612
5123
|
' --label <name> Short label surfaced in `pugi accounts list`.',
|
|
2613
5124
|
' --api-url <url> Override the Anvil endpoint (self-hosted).',
|
|
2614
5125
|
' --no-device-flow Refuse the device flow; fail fast in CI without a token.',
|
|
2615
5126
|
'',
|
|
5127
|
+
'Environment variables:',
|
|
5128
|
+
' PUGI_API_KEY Read by --provider env. Pass --key to override.',
|
|
5129
|
+
' PUGI_LOGIN_TOKEN Read by --provider token in non-interactive shells.',
|
|
5130
|
+
' PUGI_API_URL Override the Anvil endpoint (same as --api-url).',
|
|
5131
|
+
'',
|
|
2616
5132
|
'Examples:',
|
|
2617
5133
|
' pugi login # interactive picker on a TTY',
|
|
2618
5134
|
' pugi login --provider device # explicit browser OAuth',
|
|
2619
5135
|
' pugi login --provider token --token sk-xx # paste in a key',
|
|
2620
5136
|
' echo $TOKEN | pugi login --provider token --token-stdin',
|
|
2621
|
-
' PUGI_API_KEY=
|
|
5137
|
+
' PUGI_API_KEY=pugi_xxx pugi login --provider env',
|
|
5138
|
+
' pugi login --provider env --key pugi_xxx # explicit key beats env',
|
|
2622
5139
|
].join('\n'));
|
|
2623
5140
|
return;
|
|
2624
5141
|
}
|
|
@@ -2641,6 +5158,11 @@ async function login(args, flags, _session) {
|
|
|
2641
5158
|
const apiUrlOverride = extractApiUrlFlag(args);
|
|
2642
5159
|
const labelFlag = extractLabelFlag(args);
|
|
2643
5160
|
const provider = parseProviderFlag(args);
|
|
5161
|
+
// Leak L35 (2026-05-27): `--key` is the explicit-arg path for
|
|
5162
|
+
// `--provider env`; `--skip-validate` bypasses the /api/pugi/health
|
|
5163
|
+
// probe (CI bootstrap before the network is up).
|
|
5164
|
+
const envExplicitKey = extractKeyFlag(args);
|
|
5165
|
+
const envSkipValidate = args.includes('--skip-validate');
|
|
2644
5166
|
const apiUrl = normalizeApiUrl(apiUrlOverride ?? process.env.PUGI_API_URL ?? DEFAULT_API_URL);
|
|
2645
5167
|
// Path 1: explicit --provider trumps everything else.
|
|
2646
5168
|
if (provider) {
|
|
@@ -2651,6 +5173,8 @@ async function login(args, flags, _session) {
|
|
|
2651
5173
|
explicitToken: tokenFromArgs,
|
|
2652
5174
|
tokenStdinFlag,
|
|
2653
5175
|
noDeviceFlow,
|
|
5176
|
+
envExplicitKey,
|
|
5177
|
+
envSkipValidate,
|
|
2654
5178
|
});
|
|
2655
5179
|
return;
|
|
2656
5180
|
}
|
|
@@ -2698,6 +5222,8 @@ async function login(args, flags, _session) {
|
|
|
2698
5222
|
flags,
|
|
2699
5223
|
label: labelFlag,
|
|
2700
5224
|
noDeviceFlow,
|
|
5225
|
+
envExplicitKey,
|
|
5226
|
+
envSkipValidate,
|
|
2701
5227
|
});
|
|
2702
5228
|
return;
|
|
2703
5229
|
}
|
|
@@ -2916,16 +5442,28 @@ async function dispatchLoginProvider(provider, ctx) {
|
|
|
2916
5442
|
return;
|
|
2917
5443
|
}
|
|
2918
5444
|
case 'env': {
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
5445
|
+
// Leak L35 (2026-05-27): resolve the env / --key candidate,
|
|
5446
|
+
// run the local format check, then probe `/api/pugi/health`
|
|
5447
|
+
// BEFORE persisting. A bad token never lands on disk so the
|
|
5448
|
+
// next `pugi <anything>` does not silently 401 against the
|
|
5449
|
+
// cabinet. `--skip-validate` opts out for CI bootstrap.
|
|
5450
|
+
const resolved = await resolveAndValidateEnvLogin({
|
|
5451
|
+
apiUrl: ctx.apiUrl,
|
|
5452
|
+
explicitKey: ctx.envExplicitKey,
|
|
5453
|
+
env: process.env,
|
|
5454
|
+
skipValidate: ctx.envSkipValidate ?? false,
|
|
5455
|
+
});
|
|
5456
|
+
if (resolved.kind !== 'ok') {
|
|
5457
|
+
reportEnvLoginFailure(resolved, ctx.flags);
|
|
5458
|
+
return;
|
|
2922
5459
|
}
|
|
2923
5460
|
storeAndAnnounceToken({
|
|
2924
5461
|
apiUrl: ctx.apiUrl,
|
|
2925
|
-
apiKey:
|
|
5462
|
+
apiKey: resolved.token,
|
|
2926
5463
|
label: ctx.label,
|
|
2927
5464
|
source: 'env',
|
|
2928
5465
|
flags: ctx.flags,
|
|
5466
|
+
validatedLatencyMs: resolved.latencyMs > 0 ? resolved.latencyMs : undefined,
|
|
2929
5467
|
});
|
|
2930
5468
|
return;
|
|
2931
5469
|
}
|
|
@@ -2944,6 +5482,15 @@ function storeAndAnnounceToken(input) {
|
|
|
2944
5482
|
label: input.label,
|
|
2945
5483
|
source: input.source,
|
|
2946
5484
|
});
|
|
5485
|
+
const textLines = [
|
|
5486
|
+
`Pugi logged in for ${record.apiUrl}`,
|
|
5487
|
+
`Method: ${input.source}${record.label ? ` (${record.label})` : ''}`,
|
|
5488
|
+
`Token: ${maskApiKey(record.apiKey)}`,
|
|
5489
|
+
];
|
|
5490
|
+
if (typeof input.validatedLatencyMs === 'number') {
|
|
5491
|
+
textLines.push(`Verified via /api/pugi/health in ${input.validatedLatencyMs}ms`);
|
|
5492
|
+
}
|
|
5493
|
+
textLines.push('Stored at ~/.pugi/credentials.json (mode 0600). Run `pugi whoami` to verify.');
|
|
2947
5494
|
writeOutput(input.flags, {
|
|
2948
5495
|
status: 'logged_in',
|
|
2949
5496
|
apiUrl: record.apiUrl,
|
|
@@ -2951,12 +5498,55 @@ function storeAndAnnounceToken(input) {
|
|
|
2951
5498
|
label: record.label ?? null,
|
|
2952
5499
|
createdAt: record.createdAt,
|
|
2953
5500
|
source: input.source,
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
5501
|
+
...(typeof input.validatedLatencyMs === 'number'
|
|
5502
|
+
? { validatedLatencyMs: input.validatedLatencyMs }
|
|
5503
|
+
: {}),
|
|
5504
|
+
}, textLines.join('\n'));
|
|
5505
|
+
}
|
|
5506
|
+
/**
|
|
5507
|
+
* Render a typed `EnvLoginFailure` from `resolveAndValidateEnvLogin`
|
|
5508
|
+
* onto the surrounding CLI surface. Maps the failure kind to:
|
|
5509
|
+
* - an exit code (1 by default; 2 for invalid format so a CI step
|
|
5510
|
+
* can disambiguate "missing key" vs "key shape wrong" without
|
|
5511
|
+
* parsing stderr; 4 for network / server errors so retry logic
|
|
5512
|
+
* can distinguish transient failures from credential failures)
|
|
5513
|
+
* - a structured JSON payload for `--json` consumers
|
|
5514
|
+
* - a human-readable stderr line for the interactive path
|
|
5515
|
+
*
|
|
5516
|
+
* The token itself is never echoed — only the validator's own message
|
|
5517
|
+
* (which the env-provider module composed without the secret in it).
|
|
5518
|
+
*/
|
|
5519
|
+
function reportEnvLoginFailure(failure, flags) {
|
|
5520
|
+
const exitCode = (() => {
|
|
5521
|
+
switch (failure.kind) {
|
|
5522
|
+
case 'missing':
|
|
5523
|
+
return 1;
|
|
5524
|
+
case 'invalid-format':
|
|
5525
|
+
return 2;
|
|
5526
|
+
case 'unauthorized':
|
|
5527
|
+
return 3;
|
|
5528
|
+
case 'network-error':
|
|
5529
|
+
case 'server-error':
|
|
5530
|
+
return 4;
|
|
5531
|
+
case 'unexpected-status':
|
|
5532
|
+
return 5;
|
|
5533
|
+
default: {
|
|
5534
|
+
const exhaustive = failure;
|
|
5535
|
+
return Number(exhaustive) || 1;
|
|
5536
|
+
}
|
|
5537
|
+
}
|
|
5538
|
+
})();
|
|
5539
|
+
const payload = {
|
|
5540
|
+
status: 'login_failed',
|
|
5541
|
+
kind: failure.kind,
|
|
5542
|
+
message: failure.message,
|
|
5543
|
+
};
|
|
5544
|
+
if ('status' in failure)
|
|
5545
|
+
payload.httpStatus = failure.status;
|
|
5546
|
+
if ('cause' in failure && failure.cause)
|
|
5547
|
+
payload.cause = failure.cause;
|
|
5548
|
+
writeOutput(flags, payload, failure.message);
|
|
5549
|
+
process.exitCode = exitCode;
|
|
2960
5550
|
}
|
|
2961
5551
|
/**
|
|
2962
5552
|
* OAuth 2.0 Device Authorization Grant client (RFC 8628). Renders
|
|
@@ -3712,6 +6302,17 @@ function extractApiUrlFlag(args) {
|
|
|
3712
6302
|
function extractLabelFlag(args) {
|
|
3713
6303
|
return extractNamedFlagValue(args, 'label');
|
|
3714
6304
|
}
|
|
6305
|
+
/**
|
|
6306
|
+
* `pugi login --provider env --key <value>` — explicit key arg that
|
|
6307
|
+
* beats `PUGI_API_KEY` env. Same precedence rule as `gh auth login
|
|
6308
|
+
* --with-token`, `aws configure set`, and `pugi config`: the most
|
|
6309
|
+
* specific operator intent (a typed flag) overrides the ambient
|
|
6310
|
+
* environment so an operator can override a stale `PUGI_API_KEY`
|
|
6311
|
+
* from their shell rc without unsetting it first.
|
|
6312
|
+
*/
|
|
6313
|
+
function extractKeyFlag(args) {
|
|
6314
|
+
return extractNamedFlagValue(args, 'key');
|
|
6315
|
+
}
|
|
3715
6316
|
/**
|
|
3716
6317
|
* `pugi jobs` — surface the persistent JobRegistry on the CLI.
|
|
3717
6318
|
* Sprint α5.9 (ADR-0056 PR-PUGI-CLI-M1-GAP-J). Subcommand parsing
|
|
@@ -3811,11 +6412,105 @@ function ensureDir(path, created, skipped) {
|
|
|
3811
6412
|
mkdirSync(path, { recursive: true });
|
|
3812
6413
|
created.push(path);
|
|
3813
6414
|
}
|
|
6415
|
+
/**
|
|
6416
|
+
* Strict assertion — the workspace MUST already be initialised. Used
|
|
6417
|
+
* AFTER `runAutoInitPreflight` so the surrounding engine command can
|
|
6418
|
+
* narrow on the precondition. Kept synchronous because the async
|
|
6419
|
+
* pre-flight (with the optional prompt + scaffold) is a separate
|
|
6420
|
+
* step at command entry; this is the post-condition assertion.
|
|
6421
|
+
*/
|
|
3814
6422
|
function ensureInitialized(root) {
|
|
3815
6423
|
if (!existsSync(resolve(root, '.pugi'))) {
|
|
3816
6424
|
throw new Error('Run pugi init first');
|
|
3817
6425
|
}
|
|
3818
6426
|
}
|
|
6427
|
+
/**
|
|
6428
|
+
* Wave 6 UX (2026-05-27): async pre-flight wrapper around the
|
|
6429
|
+
* `ensureInitializedHelper` from `core/onboarding/ensure-initialized.ts`.
|
|
6430
|
+
* Called at command entry for every command that touches `.pugi/`.
|
|
6431
|
+
*
|
|
6432
|
+
* - `.pugi/` already exists → no-op (helper short-circuits).
|
|
6433
|
+
* - Interactive TTY + missing → prompt "Initialize? (Y/n)". On Y,
|
|
6434
|
+
* scaffold inline and continue. On n, throw a clean error so the
|
|
6435
|
+
* surrounding command bails without dropping into a half-state.
|
|
6436
|
+
* - Non-interactive + missing → throw (matches the legacy
|
|
6437
|
+
* `ensureInitialized` strict assertion). The caller MUST run
|
|
6438
|
+
* `pugi init` explicitly before piping into Pugi from CI.
|
|
6439
|
+
*
|
|
6440
|
+
* Operator opt-out: `--no-init` (parsed into `flags.noInit`) OR
|
|
6441
|
+
* `PUGI_NO_AUTO_INIT=1` forces the strict assertion даже on TTY so
|
|
6442
|
+
* shells / wrappers that own init orchestration can disable us.
|
|
6443
|
+
*/
|
|
6444
|
+
async function runAutoInitPreflight(root, flags) {
|
|
6445
|
+
const result = await ensureInitializedHelper({
|
|
6446
|
+
cwd: root,
|
|
6447
|
+
interactive: isInteractive(flags),
|
|
6448
|
+
skip: flags.noInit || process.env.PUGI_NO_AUTO_INIT === '1',
|
|
6449
|
+
prompt: async (question) => readSingleChoice(question),
|
|
6450
|
+
scaffold: async (input) => {
|
|
6451
|
+
// Forward to the real scaffolder. The helper does not import
|
|
6452
|
+
// `scaffoldPugiWorkspace` directly to keep its module import-
|
|
6453
|
+
// cycle free; threading it via the callback also lets the
|
|
6454
|
+
// spec swap in a fake.
|
|
6455
|
+
await scaffoldPugiWorkspace({ cwd: input.cwd, noDefaults: flags.noDefaults });
|
|
6456
|
+
},
|
|
6457
|
+
});
|
|
6458
|
+
if (result.status === 'declined') {
|
|
6459
|
+
if (result.reason === 'user_declined') {
|
|
6460
|
+
throw new Error('Initialization declined. Run `pugi init` when ready.');
|
|
6461
|
+
}
|
|
6462
|
+
// non_interactive / disabled → match the legacy strict-assert
|
|
6463
|
+
// message so CI scripts that grep for "Run pugi init first" keep
|
|
6464
|
+
// working. The helper's structured `reason` field is still
|
|
6465
|
+
// available via the spec for finer-grained branching.
|
|
6466
|
+
throw new Error('Run pugi init first');
|
|
6467
|
+
}
|
|
6468
|
+
}
|
|
6469
|
+
/**
|
|
6470
|
+
* Wave 6 UX (2026-05-27): async pre-flight wrapper around the
|
|
6471
|
+
* `ensureAuthenticatedHelper` from `core/auth/ensure-authenticated.ts`.
|
|
6472
|
+
* Called at command entry for every command that authenticates against
|
|
6473
|
+
* Anvil. Returns a structured envelope; the caller decides how к
|
|
6474
|
+
* handle the `missing` path (engine commands fall back к offline OR
|
|
6475
|
+
* raise `engine_unavailable`, write commands raise unauthenticated,
|
|
6476
|
+
* read commands MAY proceed in degraded mode).
|
|
6477
|
+
*
|
|
6478
|
+
* The inline login launches `performDeviceFlowLogin` against the
|
|
6479
|
+
* detected apiUrl. Operator opt-out via `--no-login` flag OR
|
|
6480
|
+
* `PUGI_NO_AUTO_LOGIN=1` matches the auto-init equivalent.
|
|
6481
|
+
*/
|
|
6482
|
+
async function runAutoAuthPreflight(flags) {
|
|
6483
|
+
return ensureAuthenticatedHelper({
|
|
6484
|
+
resolve: () => resolveActiveCredential(),
|
|
6485
|
+
interactive: isInteractive(flags),
|
|
6486
|
+
skip: flags.noLogin || process.env.PUGI_NO_AUTO_LOGIN === '1',
|
|
6487
|
+
// Headless mode (`--headless` / `--print`) cannot block on a
|
|
6488
|
+
// browser-popup login. The helper refuses the inline branch when
|
|
6489
|
+
// this flag is set даже on a TTY.
|
|
6490
|
+
headless: Boolean(flags.headless || flags.print !== undefined),
|
|
6491
|
+
login: async () => {
|
|
6492
|
+
// Best-effort inline device-flow. Returns true on success
|
|
6493
|
+
// (credential persisted), false on cancel. Errors propagate up
|
|
6494
|
+
// and the helper converts them к `login_failed`.
|
|
6495
|
+
const apiUrl = normalizeApiUrl(process.env.PUGI_API_URL ?? DEFAULT_API_URL);
|
|
6496
|
+
const before = resolveActiveCredential();
|
|
6497
|
+
try {
|
|
6498
|
+
await performDeviceFlowLogin(apiUrl, flags, null);
|
|
6499
|
+
}
|
|
6500
|
+
catch {
|
|
6501
|
+
return false;
|
|
6502
|
+
}
|
|
6503
|
+
// The device-flow handler may set process.exitCode on cancel;
|
|
6504
|
+
// we reset it so the surrounding command does not inherit a
|
|
6505
|
+
// 130 from the login surface даже on success. Re-resolution
|
|
6506
|
+
// below is the source of truth.
|
|
6507
|
+
if (process.exitCode === 130)
|
|
6508
|
+
process.exitCode = 0;
|
|
6509
|
+
const after = resolveActiveCredential();
|
|
6510
|
+
return Boolean(after && after.apiKey !== before?.apiKey) || Boolean(after && !before);
|
|
6511
|
+
},
|
|
6512
|
+
});
|
|
6513
|
+
}
|
|
3819
6514
|
function createArtifactDir(root, seed) {
|
|
3820
6515
|
const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${slugify(seed)}`;
|
|
3821
6516
|
const artifactDir = resolve(root, '.pugi', 'artifacts', id);
|
|
@@ -3990,7 +6685,31 @@ function fileBytes(path) {
|
|
|
3990
6685
|
return 0;
|
|
3991
6686
|
}
|
|
3992
6687
|
}
|
|
3993
|
-
|
|
6688
|
+
/**
|
|
6689
|
+
* Git invocation helpers — probe vs required semantics.
|
|
6690
|
+
*
|
|
6691
|
+
* 2026-05-27 (Claude review followup #489): the historical `safeGit`
|
|
6692
|
+
* collapsed BOTH "tell me the branch name if you can" probes AND
|
|
6693
|
+
* "give me the diff or fail" hard requirements into a single helper
|
|
6694
|
+
* that swallowed every error as an empty string. That's the correct
|
|
6695
|
+
* shape for the probe case (branch / status / dirty flag — empty
|
|
6696
|
+
* result is a valid signal) but catastrophically wrong for the diff
|
|
6697
|
+
* case (empty result === false PASS on a commit nobody reviewed).
|
|
6698
|
+
*
|
|
6699
|
+
* The split:
|
|
6700
|
+
* - `safeGitProbe` — best-effort. Returns '' on any error. Use for
|
|
6701
|
+
* branch name lookups, status probes, opt-in dirty detection.
|
|
6702
|
+
* - `safeGitRequired` — throws on non-zero exit / ENOBUFS / bad ref.
|
|
6703
|
+
* Use for diff, merge-base resolution, anything whose empty
|
|
6704
|
+
* output would silently corrupt downstream behaviour.
|
|
6705
|
+
*
|
|
6706
|
+
* Legacy `safeGit` is kept as a deprecated alias of `safeGitProbe`
|
|
6707
|
+
* so existing call-sites (branch detection, status, etc.) keep their
|
|
6708
|
+
* tolerant semantics until they are individually migrated. Diff /
|
|
6709
|
+
* merge-base / rev-parse-verify call-sites are migrated к
|
|
6710
|
+
* `safeGitRequired` in this same patch.
|
|
6711
|
+
*/
|
|
6712
|
+
export function safeGitProbe(root, args) {
|
|
3994
6713
|
try {
|
|
3995
6714
|
return execFileSync('git', args, {
|
|
3996
6715
|
cwd: root,
|
|
@@ -4008,6 +6727,38 @@ function safeGit(root, args) {
|
|
|
4008
6727
|
return '';
|
|
4009
6728
|
}
|
|
4010
6729
|
}
|
|
6730
|
+
/**
|
|
6731
|
+
* Strict variant — throws on non-zero exit, ENOBUFS, or any git-side
|
|
6732
|
+
* failure. The thrown error carries the operation context so the
|
|
6733
|
+
* caller (triple-review dispatch, etc.) can fail loud rather than
|
|
6734
|
+
* ship an empty diff to a remote reviewer.
|
|
6735
|
+
*/
|
|
6736
|
+
export function safeGitRequired(root, args, context) {
|
|
6737
|
+
try {
|
|
6738
|
+
return execFileSync('git', args, {
|
|
6739
|
+
cwd: root,
|
|
6740
|
+
encoding: 'utf8',
|
|
6741
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
6742
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
6743
|
+
});
|
|
6744
|
+
}
|
|
6745
|
+
catch (err) {
|
|
6746
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
6747
|
+
throw new Error(`git ${args.slice(0, 2).join(' ')} failed (${context}): ${cause}. ` +
|
|
6748
|
+
`Refusing to proceed — empty git output here would corrupt downstream behaviour.`);
|
|
6749
|
+
}
|
|
6750
|
+
}
|
|
6751
|
+
/**
|
|
6752
|
+
* Deprecated alias preserved for diff / status / branch probes that
|
|
6753
|
+
* legitimately want a tolerant empty-string-on-error shape. New call
|
|
6754
|
+
* sites should pick `safeGitProbe` or `safeGitRequired` explicitly.
|
|
6755
|
+
*
|
|
6756
|
+
* @deprecated 2026-05-27 — prefer `safeGitProbe` (tolerant) or
|
|
6757
|
+
* `safeGitRequired` (strict, throws).
|
|
6758
|
+
*/
|
|
6759
|
+
function safeGit(root, args) {
|
|
6760
|
+
return safeGitProbe(root, args);
|
|
6761
|
+
}
|
|
4011
6762
|
/**
|
|
4012
6763
|
* Glob patterns excluded from triple-review `diffPatch` before egress.
|
|
4013
6764
|
*
|
|
@@ -4148,5 +6899,6 @@ export function packageRoot() {
|
|
|
4148
6899
|
export const __test__ = {
|
|
4149
6900
|
sleep,
|
|
4150
6901
|
pollDeviceFlowUntilTerminal,
|
|
6902
|
+
sanitizeSemver,
|
|
4151
6903
|
};
|
|
4152
6904
|
//# sourceMappingURL=cli.js.map
|