@pugi/cli 0.1.0-beta.3 → 0.1.0-beta.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -40
  3. package/bin/run.js +33 -1
  4. package/dist/commands/jobs-watch.js +201 -0
  5. package/dist/commands/jobs.js +15 -0
  6. package/dist/core/agent-progress/cleanup.js +134 -0
  7. package/dist/core/agent-progress/schema.js +144 -0
  8. package/dist/core/agent-progress/writer.js +101 -0
  9. package/dist/core/artifact-chain/dispatcher.js +148 -0
  10. package/dist/core/artifact-chain/exporter.js +164 -0
  11. package/dist/core/artifact-chain/state.js +243 -0
  12. package/dist/core/artifact-chain/steps.js +169 -0
  13. package/dist/core/auth/env-provider.js +238 -0
  14. package/dist/core/auto-update/channels.js +122 -0
  15. package/dist/core/auto-update/checker.js +241 -0
  16. package/dist/core/auto-update/state.js +235 -0
  17. package/dist/core/bare-mode/index.js +107 -0
  18. package/dist/core/checkpoint/resumer.js +149 -0
  19. package/dist/core/checkpoint/rewinder.js +291 -0
  20. package/dist/core/compact/auto-trigger.js +96 -0
  21. package/dist/core/compact/buffer-rewriter.js +115 -0
  22. package/dist/core/compact/summarizer.js +208 -0
  23. package/dist/core/compact/token-counter.js +108 -0
  24. package/dist/core/consensus/diff-capture.js +73 -0
  25. package/dist/core/context/index.js +7 -0
  26. package/dist/core/context/markdown-traverse.js +255 -0
  27. package/dist/core/cost/rate-card.js +129 -0
  28. package/dist/core/cost/tracker.js +221 -0
  29. package/dist/core/denial-tracking/index.js +8 -0
  30. package/dist/core/denial-tracking/state.js +264 -0
  31. package/dist/core/diagnostics/probe-runner.js +93 -0
  32. package/dist/core/diagnostics/probes/api.js +46 -0
  33. package/dist/core/diagnostics/probes/auth.js +86 -0
  34. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  35. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  36. package/dist/core/diagnostics/probes/config.js +72 -0
  37. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  38. package/dist/core/diagnostics/probes/disk.js +81 -0
  39. package/dist/core/diagnostics/probes/git.js +65 -0
  40. package/dist/core/diagnostics/probes/mcp.js +75 -0
  41. package/dist/core/diagnostics/probes/node.js +59 -0
  42. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  43. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  44. package/dist/core/diagnostics/probes/session.js +74 -0
  45. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  46. package/dist/core/diagnostics/probes/workspace.js +63 -0
  47. package/dist/core/diagnostics/types.js +70 -0
  48. package/dist/core/dispatch/cache-cleanup.js +197 -0
  49. package/dist/core/dispatch/cache-handoff.js +295 -0
  50. package/dist/core/edits/dispatch.js +218 -2
  51. package/dist/core/edits/journal.js +199 -0
  52. package/dist/core/edits/layer-d-ast.js +557 -14
  53. package/dist/core/edits/verify-hook.js +273 -0
  54. package/dist/core/edits/worktree.js +111 -18
  55. package/dist/core/engine/anvil-client.js +115 -5
  56. package/dist/core/engine/budgets.js +89 -0
  57. package/dist/core/engine/context-prefix.js +155 -0
  58. package/dist/core/engine/intent.js +260 -0
  59. package/dist/core/engine/native-pugi.js +852 -210
  60. package/dist/core/engine/prompts.js +89 -6
  61. package/dist/core/engine/strip-internal-fields.js +124 -0
  62. package/dist/core/engine/tool-bridge.js +972 -33
  63. package/dist/core/feedback/queue.js +177 -0
  64. package/dist/core/feedback/submitter.js +145 -0
  65. package/dist/core/file-cache.js +113 -1
  66. package/dist/core/hooks/events.js +44 -0
  67. package/dist/core/hooks/index.js +15 -0
  68. package/dist/core/hooks/registry.js +213 -0
  69. package/dist/core/hooks/runner.js +236 -0
  70. package/dist/core/init/scaffold.js +195 -0
  71. package/dist/core/lsp/cache.js +105 -0
  72. package/dist/core/lsp/client.js +174 -29
  73. package/dist/core/lsp/language-detect.js +66 -0
  74. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  75. package/dist/core/mcp/client.js +75 -6
  76. package/dist/core/mcp/http-server.js +553 -0
  77. package/dist/core/mcp/permission.js +190 -0
  78. package/dist/core/mcp/registry.js +24 -2
  79. package/dist/core/mcp/server-tools.js +219 -0
  80. package/dist/core/mcp/server.js +397 -0
  81. package/dist/core/memory/dual-write.js +416 -0
  82. package/dist/core/memory/dual-write.spec.js +297 -0
  83. package/dist/core/memory/phase1-kinds.js +20 -0
  84. package/dist/core/memory-sync/queue.js +158 -0
  85. package/dist/core/memory-sync/queue.spec.js +105 -0
  86. package/dist/core/onboarding/marker.js +111 -0
  87. package/dist/core/onboarding/telemetry-state.js +108 -0
  88. package/dist/core/output-style/presets.js +176 -0
  89. package/dist/core/output-style/state.js +185 -0
  90. package/dist/core/permissions/gate.js +187 -0
  91. package/dist/core/permissions/index.js +18 -0
  92. package/dist/core/permissions/mode.js +102 -0
  93. package/dist/core/permissions/state.js +215 -0
  94. package/dist/core/permissions/tool-class.js +93 -0
  95. package/dist/core/prd-check/parser.js +215 -0
  96. package/dist/core/prd-check/reporter.js +127 -0
  97. package/dist/core/prd-check/session-review.js +557 -0
  98. package/dist/core/prd-check/verifiers.js +223 -0
  99. package/dist/core/pugi-md/context-injector.js +76 -0
  100. package/dist/core/pugi-md/walk-up.js +207 -0
  101. package/dist/core/release-notes/parser.js +241 -0
  102. package/dist/core/release-notes/state.js +116 -0
  103. package/dist/core/repl/codebase-survey.js +308 -0
  104. package/dist/core/repl/history.js +11 -1
  105. package/dist/core/repl/init-interview.js +457 -0
  106. package/dist/core/repl/model-pricing.js +135 -0
  107. package/dist/core/repl/onboarding-state.js +297 -0
  108. package/dist/core/repl/session.js +1529 -30
  109. package/dist/core/repl/slash-commands.js +361 -13
  110. package/dist/core/repl/store/session-store.js +31 -2
  111. package/dist/core/repl/workspace-context.js +22 -0
  112. package/dist/core/repo-map/build.js +125 -0
  113. package/dist/core/repo-map/cache.js +185 -0
  114. package/dist/core/repo-map/extractor.js +254 -0
  115. package/dist/core/repo-map/formatter.js +145 -0
  116. package/dist/core/repo-map/scanner.js +211 -0
  117. package/dist/core/retry-budget/budget.js +284 -0
  118. package/dist/core/retry-budget/index.js +5 -0
  119. package/dist/core/session.js +44 -0
  120. package/dist/core/settings.js +80 -0
  121. package/dist/core/share/formatter.js +271 -0
  122. package/dist/core/share/redactor.js +221 -0
  123. package/dist/core/share/uploader.js +267 -0
  124. package/dist/core/skills/defaults.js +457 -0
  125. package/dist/core/subagents/dispatcher-real.js +600 -0
  126. package/dist/core/subagents/dispatcher.js +113 -24
  127. package/dist/core/subagents/index.js +18 -5
  128. package/dist/core/subagents/isolation-matrix.js +213 -0
  129. package/dist/core/subagents/spawn.js +19 -4
  130. package/dist/core/telemetry/emitter.js +229 -0
  131. package/dist/core/telemetry/queue.js +251 -0
  132. package/dist/core/theme/context.js +91 -0
  133. package/dist/core/theme/presets.js +228 -0
  134. package/dist/core/theme/state.js +181 -0
  135. package/dist/core/todos/invariant.js +10 -0
  136. package/dist/core/todos/state.js +177 -0
  137. package/dist/core/transport/version-interceptor.js +166 -0
  138. package/dist/core/vim/keymap.js +288 -0
  139. package/dist/core/vim/state.js +92 -0
  140. package/dist/index.js +28 -0
  141. package/dist/runtime/bootstrap.js +190 -0
  142. package/dist/runtime/cli.js +2603 -278
  143. package/dist/runtime/commands/chain.js +489 -0
  144. package/dist/runtime/commands/compact.js +297 -0
  145. package/dist/runtime/commands/cost.js +199 -0
  146. package/dist/runtime/commands/delegate.js +312 -0
  147. package/dist/runtime/commands/dispatch.js +126 -0
  148. package/dist/runtime/commands/doctor.js +390 -0
  149. package/dist/runtime/commands/feedback.js +184 -0
  150. package/dist/runtime/commands/hooks.js +184 -0
  151. package/dist/runtime/commands/lsp.js +212 -28
  152. package/dist/runtime/commands/mcp.js +824 -0
  153. package/dist/runtime/commands/memory.js +508 -0
  154. package/dist/runtime/commands/memory.spec.js +174 -0
  155. package/dist/runtime/commands/model.js +237 -0
  156. package/dist/runtime/commands/onboarding.js +275 -0
  157. package/dist/runtime/commands/patch.js +17 -0
  158. package/dist/runtime/commands/permissions.js +87 -0
  159. package/dist/runtime/commands/plan.js +143 -0
  160. package/dist/runtime/commands/prd-check.js +285 -0
  161. package/dist/runtime/commands/release-notes.js +229 -0
  162. package/dist/runtime/commands/repo-map.js +95 -0
  163. package/dist/runtime/commands/report.js +299 -0
  164. package/dist/runtime/commands/resume.js +118 -0
  165. package/dist/runtime/commands/review-consensus.js +17 -2
  166. package/dist/runtime/commands/rewind.js +333 -0
  167. package/dist/runtime/commands/roster.js +117 -0
  168. package/dist/runtime/commands/sessions.js +163 -0
  169. package/dist/runtime/commands/share.js +316 -0
  170. package/dist/runtime/commands/status.js +178 -0
  171. package/dist/runtime/commands/stickers.js +82 -0
  172. package/dist/runtime/commands/style.js +194 -0
  173. package/dist/runtime/commands/theme.js +196 -0
  174. package/dist/runtime/commands/update.js +289 -0
  175. package/dist/runtime/commands/vim.js +140 -0
  176. package/dist/runtime/commands/worktree.js +50 -6
  177. package/dist/runtime/headless.js +543 -0
  178. package/dist/runtime/load-hooks-or-exit.js +71 -0
  179. package/dist/runtime/plan-decompose.js +531 -0
  180. package/dist/runtime/version.js +65 -0
  181. package/dist/tools/agent-tool.js +229 -0
  182. package/dist/tools/apply-patch.js +281 -39
  183. package/dist/tools/ask-user-question.js +213 -0
  184. package/dist/tools/ask-user.js +115 -0
  185. package/dist/tools/file-tools.js +85 -14
  186. package/dist/tools/mcp-tool.js +260 -0
  187. package/dist/tools/multi-edit.js +361 -0
  188. package/dist/tools/registry.js +30 -2
  189. package/dist/tools/skill-tool.js +96 -0
  190. package/dist/tools/tasks.js +208 -0
  191. package/dist/tools/todo-write.js +184 -0
  192. package/dist/tools/web-fetch.js +147 -2
  193. package/dist/tools/web-search.js +458 -0
  194. package/dist/tui/agent-progress-card.js +111 -0
  195. package/dist/tui/agent-tree.js +10 -0
  196. package/dist/tui/ask-modal.js +2 -2
  197. package/dist/tui/ask-user-question-prompt.js +192 -0
  198. package/dist/tui/compact-banner.js +81 -0
  199. package/dist/tui/conversation-pane.js +82 -8
  200. package/dist/tui/cost-table.js +111 -0
  201. package/dist/tui/doctor-table.js +46 -0
  202. package/dist/tui/feedback-prompt.js +156 -0
  203. package/dist/tui/input-box.js +46 -2
  204. package/dist/tui/markdown-render.js +4 -4
  205. package/dist/tui/onboarding-wizard.js +240 -0
  206. package/dist/tui/repl-render.js +293 -35
  207. package/dist/tui/repl-splash.js +2 -2
  208. package/dist/tui/repl.js +45 -13
  209. package/dist/tui/splash.js +1 -1
  210. package/dist/tui/status-bar.js +94 -16
  211. package/dist/tui/status-table.js +7 -0
  212. package/dist/tui/stickers-art.js +136 -0
  213. package/dist/tui/style-table.js +28 -0
  214. package/dist/tui/theme-table.js +29 -0
  215. package/dist/tui/tool-stream-pane.js +7 -0
  216. package/dist/tui/update-banner.js +20 -2
  217. package/dist/tui/vim-input.js +267 -0
  218. package/docs/examples/codegraph.mcp.json +10 -0
  219. package/package.json +9 -6
@@ -1,38 +1,73 @@
1
- import { createHash, randomUUID } from 'node:crypto';
1
+ import { randomUUID } from 'node:crypto';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { statSync } from 'node:fs';
5
+ 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 { decidePermission } from '../core/permission.js';
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';
20
- import { buildRuntimeConfig, loadRuntimeConfig, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitSync, submitTripleReview, } from '@pugi/sdk';
21
+ import { buildRuntimeConfig, fetchPersonaRoster, loadRuntimeConfig, openPugiSession, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitDelegate, submitSync, submitTripleReview, } from '@pugi/sdk';
21
22
  import { PUGI_TAGLINE } from '@pugi/personas';
23
+ import { resolveRoster, renderRosterTable } from './commands/roster.js';
24
+ import { runDelegateCommand } from './commands/delegate.js';
25
+ import { runDispatchCommand } from './commands/dispatch.js';
22
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';
23
28
  import { runDeployCommand } from '../commands/deploy.js';
24
29
  import { runJobsCommand } from '../commands/jobs.js';
25
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';
26
36
  import { runPrivacyCommand } from './commands/privacy.js';
37
+ import { runReport } from './commands/report.js';
38
+ import { runDoctorCommand, defaultHome as defaultDoctorHome } from './commands/doctor.js';
39
+ import { parsePrdCheckArgs, runPrdCheckCommand, } from './commands/prd-check.js';
40
+ import { runChainCommand, } from './commands/chain.js';
41
+ import { runStatusCommand, defaultStatusHome, } from './commands/status.js';
42
+ import { runStickersCommand } from './commands/stickers.js';
43
+ import { runRepoMapCommand } from './commands/repo-map.js';
44
+ import { runReleaseNotesCommand, defaultReleaseNotesHome, } from './commands/release-notes.js';
27
45
  import { runUndoCommand } from './commands/undo.js';
46
+ import { runCompactCommand } from './commands/compact.js';
47
+ import { runRewindCommand } from './commands/rewind.js';
48
+ import { runSessionsCommand } from './commands/sessions.js';
49
+ // Day 4 ADR-0063: persona-memory operator surface (list / recall / write /
50
+ // forget / sync). The runner is shared by `pugi memory` top-level and the
51
+ // in-REPL `/memory` slash so the two surfaces stay single-sourced.
52
+ import { runMemoryCommand } from './commands/memory.js';
28
53
  import { runBudgetCommand } from './commands/budget.js';
54
+ import { BARE_MODE_BANNER, isBareMode, setBareMode, } from '../core/bare-mode/index.js';
55
+ import { runCostCommand } from './commands/cost.js';
56
+ import { runShareCommand } from './commands/share.js';
29
57
  import { runSkillsCommand } from './commands/skills.js';
58
+ import { runHooksCommand } from './commands/hooks.js';
59
+ import { installDefaultSkills } from '../core/skills/defaults.js';
30
60
  import { runAgentsCommand } from './commands/agents.js';
31
61
  import { runLspCommand } from './commands/lsp.js';
32
62
  import { runPatchCommand } from './commands/patch.js';
33
63
  import { runWorktreeCommand } from './commands/worktree.js';
34
64
  import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
35
65
  import { runReviewConsensus } from './commands/review-consensus.js';
66
+ import { runMcpCommand } from './commands/mcp.js';
67
+ import { runPermissionsCommand } from './commands/permissions.js';
68
+ import { runPlanCommand } from './commands/plan.js';
69
+ import { parsePermissionMode } from '../core/permissions/index.js';
70
+ import { DECOMPOSE_PROMPT_SUFFIX, parseDecompositionFromText, writeDecomposition, } from './plan-decompose.js';
36
71
  import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
37
72
  import { slugForCwd } from '../core/repl/history.js';
38
73
  import { dispatchEdit, } from '../core/edits/index.js';
@@ -47,18 +82,39 @@ import { dispatchEdit, } from '../core/edits/index.js';
47
82
  * packages/pugi-sdk/package.json); the publish workflow validates the
48
83
  * three are in lockstep.
49
84
  */
50
- const PUGI_CLI_VERSION = "0.1.0-beta.3";
85
+ // PR-CLI-SERVER-VERSION-HANDSHAKE (#225). PUGI_CLI_VERSION lives in
86
+ // `runtime/version.ts` now so the engine transport interceptor can
87
+ // import it without dragging in the cli.ts module graph. Re-exported
88
+ // here under the original name so every existing reader (`pugi version`,
89
+ // `pugi doctor --json`, splash render, telemetry) keeps working with
90
+ // zero churn. Bumping the CLI version is still a single-file edit —
91
+ // just on `runtime/version.ts` instead of here. The β1 sanitizer that
92
+ // guarded against `workspace:*` leaks moved with the constant.
93
+ import { PUGI_CLI_VERSION, sanitizeSemver } from './version.js';
51
94
  const handlers = {
52
95
  accounts,
53
96
  agents: dispatchAgents,
54
97
  ask: dispatchAsk,
55
98
  build: runEngineTask('build_task'),
56
99
  budget: dispatchBudget,
100
+ // Wave 6 (2026-05-27): `pugi chain` walks the deterministic 7-step
101
+ // artifact pipeline (PRD → ADR → mindmap → ER → sequence → tests →
102
+ // code). Subcommands: new / status / next / show / export / list.
103
+ // Same handler powers the in-REPL `/chain` slash via session.ts.
104
+ chain: dispatchChain,
57
105
  code: runEngineTask('code'),
58
106
  config: dispatchConfig,
107
+ cost: dispatchCost,
108
+ delegate: dispatchDelegate,
109
+ // Leak L10 (2026-05-27): `pugi dispatch list-cache-refs` /
110
+ // `clear-cache-refs` operate on `.pugi/cache-refs/` — the persisted
111
+ // prompt-cache inheritance handles for fork-subagent dispatches. The
112
+ // handler module lives in commands/dispatch.ts so the table stays narrow.
113
+ dispatch: dispatchSubagentCacheRefs,
59
114
  deploy: dispatchDeploy,
60
115
  doctor,
61
116
  explain: runEngineTask('explain'),
117
+ hooks: dispatchHooks,
62
118
  fix: runEngineTask('fix'),
63
119
  handoff,
64
120
  help,
@@ -68,16 +124,84 @@ const handlers = {
68
124
  login,
69
125
  logout,
70
126
  lsp: dispatchLsp,
127
+ mcp: dispatchMcp,
128
+ // ADR-0063 Day 4: `pugi memory list|recall|write|forget|sync`. Routes
129
+ // to `runMemoryCommand` (admin-api `/api/persona-memory` + offline
130
+ // queue at `~/.pugi/memory-queue.jsonl`).
131
+ memory: dispatchMemory,
71
132
  patch: dispatchPatch,
72
- plan: runEngineTask('plan'),
133
+ permissions: dispatchPermissions,
134
+ perms: dispatchPermissions,
135
+ plan: dispatchPlan,
73
136
  'plan-review': dispatchPlanReview,
137
+ // Wave 6 (2026-05-27): `pugi prd-check` verifies PRD acceptance
138
+ // criteria against committed code/tests/docs/commands BEFORE an
139
+ // operator (or autonomous agent) claims a feature done. Same
140
+ // handler powers the in-REPL `/prd-check` slash via session.ts.
141
+ 'prd-check': dispatchPrdCheck,
74
142
  privacy: dispatchPrivacy,
143
+ // L24 (2026-05-27): `pugi release-notes` shows the bundled CHANGELOG
144
+ // diff between the operator's last-seen version + installed version.
145
+ // The slash counterpart `/release-notes` shares this handler via the
146
+ // shared `runReleaseNotesCommand` runner.
147
+ 'release-notes': releaseNotes,
148
+ releaseNotes,
149
+ // PAVF-7 (2026-05-27): `pugi report --from-error` captures the
150
+ // most-recent failed session as a redacted bundle so operators can
151
+ // file clean bug reports without manual log-grepping.
152
+ report: dispatchReport,
75
153
  review,
76
154
  resume,
155
+ roster: dispatchRoster,
77
156
  sessions,
157
+ share: dispatchShare,
78
158
  skills: dispatchSkills,
159
+ status,
160
+ stickers,
161
+ // Leak L28 (2026-05-27): `pugi repo-map` walks the source tree,
162
+ // extracts top-level function / class / interface / type / enum
163
+ // declarations + JSDoc summaries, caches the result in
164
+ // `.pugi/repo-map.json`, and renders the compact markdown listing.
165
+ // Same builder powers the engine boot-time system-prompt injection
166
+ // — running the CLI command shows the operator EXACTLY what the
167
+ // engine would see.
168
+ 'repo-map': dispatchRepoMap,
169
+ // Leak L21 (2026-05-27): in-CLI feedback collector. Shares the
170
+ // same handler as the in-REPL `/feedback` slash; the wrapper just
171
+ // routes TTY vs non-TTY before mounting Ink.
172
+ feedback: dispatchFeedback,
79
173
  sync,
174
+ style: dispatchStyle,
175
+ // Leak L30 (2026-05-27): `pugi theme` flips the local TUI color
176
+ // palette (orthogonal to `pugi style` — that one steers engine
177
+ // prose register). 4 presets: default / dark / light / colorblind.
178
+ theme: dispatchTheme,
179
+ // Leak L25 (2026-05-27): `pugi onboarding` walks the new operator
180
+ // through auth / mode / style / MCP / telemetry. Idempotent;
181
+ // `--reset` clears the marker file so the bare-invocation hint
182
+ // re-arms without nuking persisted defaults.
183
+ onboarding: dispatchOnboarding,
184
+ // Leak L26 (2026-05-27): `pugi vim` toggles vim-style modal editing
185
+ // in the REPL input buffer. Bare invocation toggles, `on`/`off`
186
+ // sets explicitly; preference persists in ~/.pugi/config.json.
187
+ vim: dispatchVim,
80
188
  undo: dispatchUndo,
189
+ compact: dispatchCompact,
190
+ // Leak L9 (2026-05-27): `pugi rewind [N | --to <id>]` rolls the
191
+ // conversation back to a checkpoint by appending a tombstone marker
192
+ // to the NDJSON event log. The slash counterpart `/rewind` forwards
193
+ // to the same runner via session.ts.
194
+ rewind: dispatchRewind,
195
+ // L19 (2026-05-27): `pugi usage` is an alias of `pugi cost` — same
196
+ // handler, same flags. Operators trained on Claude Code expect either
197
+ // verb to surface the per-model token + USD table.
198
+ usage: dispatchCost,
199
+ // Leak L27 (2026-05-27): `pugi update` — channel-aware npm registry
200
+ // probe + optional npm install shell-out. Same handler powers the
201
+ // in-REPL `/update` slash via the session module. R2 atomic swap
202
+ // deferred to Phase 2 per the sprint plan; npm is the single
203
+ // distribution channel today.
204
+ update: dispatchUpdate,
81
205
  version,
82
206
  web: dispatchWeb,
83
207
  whoami,
@@ -252,6 +376,259 @@ async function dispatchPrivacy(args, flags, _session) {
252
376
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
253
377
  });
254
378
  }
379
+ /**
380
+ * ADR-0063 Day 4 — `pugi memory <sub>` top-level dispatcher.
381
+ *
382
+ * Forwards to the shared `runMemoryCommand` runner. Exit codes:
383
+ *
384
+ * - 0 — happy paths (listed / recalled / written / forgot / synced /
385
+ * queued_offline / sync_noop / sync_partial)
386
+ * - 1 — unauthenticated / feature_disabled / unknown_sub
387
+ * - 2 — invalid_args
388
+ *
389
+ * `forget_not_found` exits 0 because the operator-visible behaviour
390
+ * (the memory is gone) matches their intent; the JSON envelope still
391
+ * carries the `forget_not_found` status flag for scripted callers.
392
+ */
393
+ async function dispatchMemory(args, flags, _session) {
394
+ const result = await runMemoryCommand(args, {
395
+ workspaceRoot: process.cwd(),
396
+ json: flags.json,
397
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
398
+ });
399
+ switch (result.status) {
400
+ case 'unauthenticated':
401
+ case 'feature_disabled':
402
+ case 'unknown_sub':
403
+ process.exitCode = 1;
404
+ return;
405
+ case 'invalid_args':
406
+ process.exitCode = 2;
407
+ return;
408
+ default:
409
+ // 'listed' | 'recalled' | 'written' | 'queued_offline' | 'forgot' |
410
+ // 'forget_not_found' | 'synced' | 'sync_partial' | 'sync_noop' — exit 0.
411
+ return;
412
+ }
413
+ }
414
+ /**
415
+ * Leak L18 (2026-05-27) — `pugi style` top-level dispatcher.
416
+ *
417
+ * Forwards to the shared `runStyleCommand` runner. The REPL `/style`
418
+ * slash uses the same runner via a dynamic import inside
419
+ * `core/repl/session.ts` so the two surfaces stay single-sourced.
420
+ *
421
+ * Exit-code policy:
422
+ * - 0 — show / switch / reset / list happy paths
423
+ * - 1 — unknown preset slug
424
+ * - 2 — conflicting flags (`--reset` + positional / `--reset --persist`)
425
+ *
426
+ * The runner returns the code; we attach it to `process.exitCode` so
427
+ * subsequent dispatch wrappers do not clobber it on success.
428
+ */
429
+ async function dispatchStyle(args, flags, _session) {
430
+ const rc = await runStyleCommand(args, {
431
+ workspaceRoot: process.cwd(),
432
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
433
+ });
434
+ if (rc !== 0)
435
+ process.exitCode = rc;
436
+ }
437
+ /**
438
+ * Leak L30 (2026-05-27) — `pugi theme` top-level dispatcher.
439
+ *
440
+ * Forwards to the shared `runThemeCommand` runner. The REPL `/theme`
441
+ * slash uses the same runner via a dynamic import inside
442
+ * `core/repl/session.ts` so the two surfaces stay single-sourced.
443
+ *
444
+ * Exit-code policy mirrors `dispatchStyle`:
445
+ * - 0 — show / switch / reset / list happy paths
446
+ * - 1 — unknown preset slug
447
+ * - 2 — conflicting flags (`--reset` + positional / `--reset --persist`)
448
+ *
449
+ * The runner returns the code; we attach it to `process.exitCode` so
450
+ * subsequent dispatch wrappers do not clobber it on success.
451
+ */
452
+ /**
453
+ * Leak L12 (2026-05-27) — `pugi hooks` top-level dispatcher (MVP).
454
+ *
455
+ * Two subcommands:
456
+ * - `pugi hooks list` — show configured hooks per event.
457
+ * - `pugi hooks doctor` — validate `~/.pugi/hooks-mvp.json`.
458
+ *
459
+ * MVP scope: 2 events of 8 (SessionStart + PreToolUse). Remaining 6
460
+ * events (PostToolUse, UserPromptSubmit, Stop, SubagentStop,
461
+ * PreCompact, Notification) deferred to fast-follow PR. The runner
462
+ * pattern established here is reusable for those events without
463
+ * touching this dispatcher.
464
+ *
465
+ * Exit codes:
466
+ * 0 -> happy path.
467
+ * 1 -> config present but invalid (doctor only).
468
+ * 2 -> argument error / unknown subcommand.
469
+ */
470
+ async function dispatchHooks(args, flags, _session) {
471
+ const rc = await runHooksCommand(args, {
472
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
473
+ });
474
+ if (rc !== 0)
475
+ process.exitCode = rc;
476
+ }
477
+ async function dispatchTheme(args, flags, _session) {
478
+ const rc = await runThemeCommand(args, {
479
+ workspaceRoot: process.cwd(),
480
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
481
+ });
482
+ if (rc !== 0)
483
+ process.exitCode = rc;
484
+ }
485
+ /**
486
+ * Leak L25 (2026-05-27) — `pugi onboarding` top-level dispatcher.
487
+ *
488
+ * Walks the new operator through auth / permission mode / output
489
+ * style / MCP / telemetry consent. The Ink wizard mounts only when
490
+ * stdin is a TTY and `--json` is not set; otherwise we dump the
491
+ * current snapshot + hints in the non-interactive envelope so
492
+ * scripted callers see the same structured payload.
493
+ *
494
+ * Auth status: we resolve credentials once up front and pass the
495
+ * boolean to the runner; the wizard surfaces a `pugi login` hint
496
+ * when auth is missing but DOES NOT block — local defaults are still
497
+ * configurable without an active credential.
498
+ *
499
+ * Exit-code policy:
500
+ * 0 — completed / cancelled / non-interactive / reset
501
+ * 2 — conflicting / unknown flags
502
+ */
503
+ async function dispatchOnboarding(args, flags, _session) {
504
+ const credential = resolveActiveCredential();
505
+ const rc = await runOnboardingCommand(args, {
506
+ workspaceRoot: process.cwd(),
507
+ env: process.env,
508
+ authPresent: credential !== null,
509
+ interactive: isInteractive(flags) && !flags.json,
510
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
511
+ });
512
+ if (rc !== 0)
513
+ process.exitCode = rc;
514
+ }
515
+ /**
516
+ * Leak L26 (2026-05-27) — `pugi vim` top-level dispatcher.
517
+ *
518
+ * Forwards to the shared `runVimCommand` runner. The REPL `/vim` slash
519
+ * uses the same runner via a dynamic import inside
520
+ * `core/repl/session.ts` so the two surfaces stay single-sourced.
521
+ *
522
+ * Exit-code policy:
523
+ * - 0 — show / enable / disable / toggle happy paths
524
+ * - 2 — unknown subcommand (e.g. `pugi vim chaos`) or too many args
525
+ */
526
+ async function dispatchVim(args, flags, _session) {
527
+ const rc = await runVimCommand(args, {
528
+ env: process.env,
529
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
530
+ });
531
+ if (rc !== 0)
532
+ process.exitCode = rc;
533
+ }
534
+ /**
535
+ * PAVF-7 (2026-05-27): `pugi report --from-error` — bundle the most-
536
+ * recent failed session into a redacted local report so operators can
537
+ * file clean bug tickets without manual log-grepping. v1 is local-only
538
+ * (no auto-upload — see commands/report.ts header for the rationale).
539
+ */
540
+ async function dispatchReport(args, flags, _session) {
541
+ const rc = runReport(args, {
542
+ cwd: process.cwd(),
543
+ json: flags.json,
544
+ emit: (line) => {
545
+ if (!flags.json)
546
+ process.stdout.write(line);
547
+ },
548
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
549
+ });
550
+ if (rc !== 0)
551
+ process.exitCode = rc;
552
+ }
553
+ /**
554
+ * `pugi roster` - α7.5 Phase 1.
555
+ *
556
+ * List the live Tier 1 personas with display name, role, and routing
557
+ * tag. Walks the remote /api/pugi/sessions/roster endpoint when a
558
+ * credential is available; falls back to the local @pugi/personas
559
+ * roster when offline so the operator can still see who is on the team.
560
+ */
561
+ async function dispatchRoster(_args, flags, _session) {
562
+ const credential = resolveActiveCredential();
563
+ const config = credential
564
+ ? buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey })
565
+ : null;
566
+ const { rows, warning } = await resolveRoster(config);
567
+ const payload = {
568
+ ok: true,
569
+ personas: rows,
570
+ warning,
571
+ };
572
+ const text = (warning ? `# warning: ${warning}\n\n` : '') +
573
+ renderRosterTable(rows);
574
+ writeOutput(flags, payload, text);
575
+ }
576
+ /**
577
+ * `pugi delegate <slug> "<brief>"` - α7.5 Phase 1.
578
+ *
579
+ * Open a fresh REPL session and POST the brief to one Tier 1 persona,
580
+ * bypassing Mira's coordinator pass. Non-interactive: the CLI prints
581
+ * the dispatch id on success and exits; the operator (or a script) can
582
+ * subscribe to the session stream separately if they want the live
583
+ * lifecycle. Interactive operators use `/delegate` from inside the REPL
584
+ * instead so the dispatch lifecycle surfaces inline.
585
+ */
586
+ async function dispatchDelegate(args, flags, _session) {
587
+ await runDelegateCommand(args, {
588
+ workspaceCwd: process.cwd(),
589
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
590
+ resolveConfig: () => {
591
+ const credential = resolveActiveCredential();
592
+ if (!credential)
593
+ return null;
594
+ return buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey });
595
+ },
596
+ fetchRoster: fetchPersonaRoster,
597
+ submitDelegate,
598
+ openSession: async (config, workspaceCwd) => {
599
+ const result = await openPugiSession(config, { workspaceCwd });
600
+ if (result.status === 'ok')
601
+ return { sessionId: result.response.sessionId };
602
+ return { error: `${result.status}: ${result.message}` };
603
+ },
604
+ });
605
+ }
606
+ /**
607
+ * `pugi chain` — Wave 6 artifact chain dispatcher (2026-05-27).
608
+ * Forwards to `runChainCommand` with the live credential + session
609
+ * opener wired so the dispatcher can hit Anvil. The slash counterpart
610
+ * `/chain` shares the same handler via session.ts so the surface
611
+ * stays single-sourced.
612
+ */
613
+ async function dispatchChain(args, flags, _session) {
614
+ await runChainCommand(args, {
615
+ cwd: process.cwd(),
616
+ json: flags.json,
617
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
618
+ resolveConfig: () => {
619
+ const credential = resolveActiveCredential();
620
+ if (!credential)
621
+ return null;
622
+ return buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey });
623
+ },
624
+ openSession: async (config, workspaceCwd) => {
625
+ const result = await openPugiSession(config, { workspaceCwd });
626
+ if (result.status === 'ok')
627
+ return { sessionId: result.response.sessionId };
628
+ return { error: `${result.status}: ${result.message}` };
629
+ },
630
+ });
631
+ }
255
632
  async function dispatchUndo(args, flags, session) {
256
633
  await runUndoCommand(args, {
257
634
  workspaceRoot: process.cwd(),
@@ -259,12 +636,225 @@ async function dispatchUndo(args, flags, session) {
259
636
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
260
637
  });
261
638
  }
639
+ /**
640
+ * Leak L8 (2026-05-27) — `pugi compact` summarises older REPL turns
641
+ * into a single boundary marker, freeing context for the next `pugi
642
+ * resume <id>`. The slash `/compact` inside a live REPL forwards
643
+ * through the same runner via session.ts so the surface stays single-
644
+ * sourced.
645
+ */
646
+ async function dispatchCompact(args, flags, _session) {
647
+ // Wave 6 BT 8 (Claude Code parity): parse `--force` / `-f` so the
648
+ // operator can produce a marker against a short session. Auto-trigger
649
+ // paths never pass this flag — only the explicit CLI / slash invocation.
650
+ const force = args.some((t) => t === '--force' || t === '-f');
651
+ const result = await runCompactCommand(args, {
652
+ workspaceRoot: process.cwd(),
653
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
654
+ force,
655
+ });
656
+ if (result.status === 'failed_no_session'
657
+ || result.status === 'failed_transport'
658
+ || result.status === 'failed_store') {
659
+ process.exitCode = 1;
660
+ return;
661
+ }
662
+ if (result.status === 'noop_empty' || result.status === 'noop_recent_marker') {
663
+ process.exitCode = 2;
664
+ }
665
+ }
262
666
  async function dispatchBudget(args, flags, _session) {
263
667
  await runBudgetCommand(args, {
264
668
  workspaceRoot: process.cwd(),
265
669
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
266
670
  });
267
671
  }
672
+ /**
673
+ * Leak L9 (2026-05-27) — `pugi rewind [N | --to <id>]` rolls the
674
+ * conversation back to a checkpoint by appending a tombstone marker to
675
+ * the NDJSON event log. Append-only: events stay durable; `pugi
676
+ * sessions undo-rewind` reverses the operation. The slash `/rewind`
677
+ * forwards through this same runner via session.ts.
678
+ */
679
+ async function dispatchRewind(args, flags, _session) {
680
+ const result = await runRewindCommand(args, {
681
+ workspaceRoot: process.cwd(),
682
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
683
+ });
684
+ if (result.status === 'failed_no_session'
685
+ || result.status === 'failed_store') {
686
+ process.exitCode = 1;
687
+ return;
688
+ }
689
+ if (result.status === 'failed_parse') {
690
+ process.exitCode = 2;
691
+ return;
692
+ }
693
+ if (result.status === 'noop_zero' || result.status === 'noop_empty') {
694
+ process.exitCode = 2;
695
+ }
696
+ }
697
+ /**
698
+ * Leak L6 — `pugi permissions [mode] [--persist] [--confirm]`.
699
+ *
700
+ * Surface the same intent as the in-REPL `/permissions` slash. Mode
701
+ * arg is positional; `--persist` and `--confirm` are zero-arg flags
702
+ * already consumed by `parseArgs` into `flags.persist` / `flags.confirm`.
703
+ *
704
+ * Examples:
705
+ * pugi permissions -> show current mode + table
706
+ * pugi permissions plan -> flip workspace state to plan
707
+ * pugi permissions allow --persist -> flip + write ~/.pugi/config.json
708
+ * pugi permissions bypass --confirm -> flip to bypass (acknowledge banner)
709
+ */
710
+ async function dispatchPermissions(args, flags, _session) {
711
+ const head = args[0];
712
+ if (head && parsePermissionMode(head) === null) {
713
+ writeOutput(flags, { error: 'unknown_mode', mode: head }, `Unknown mode '${head}'. Allowed: plan, ask, allow, bypass.`);
714
+ process.exitCode = 1;
715
+ return;
716
+ }
717
+ const mode = head ? parsePermissionMode(head) : undefined;
718
+ await runPermissionsCommand({
719
+ ...(mode ? { mode } : {}),
720
+ persist: Boolean(flags.persist),
721
+ confirmBypass: Boolean(flags.confirm),
722
+ }, {
723
+ workspaceRoot: process.cwd(),
724
+ writeOutput: (text) => writeOutput(flags, { text }, text),
725
+ });
726
+ }
727
+ /**
728
+ * L19 sprint (2026-05-27): `pugi cost` / `pugi usage` top-level surface.
729
+ *
730
+ * Aliased through the handlers table so `pugi usage` reuses the same
731
+ * implementation. The persisted store lives at `<cwd>/.pugi/cost.json`
732
+ * and is shared with the REPL `/cost` / `/usage` slash handlers.
733
+ */
734
+ async function dispatchCost(args, flags, _session) {
735
+ await runCostCommand(args, {
736
+ workspaceRoot: process.cwd(),
737
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
738
+ });
739
+ }
740
+ /**
741
+ * Leak L20 (2026-05-27): `pugi share` top-level surface. Exports the
742
+ * current session transcript as Markdown to gist (default when `gh` is
743
+ * available) or pugi.io (--pugi). The handler delegates to
744
+ * `runShareCommand` so the slash surface (`/share`) and the shell
745
+ * surface share one code path. JSON output mode is honoured via the
746
+ * shared `writeOutput` wrapper.
747
+ */
748
+ async function dispatchShare(args, flags, _session) {
749
+ await runShareCommand(args, {
750
+ workspaceRoot: process.cwd(),
751
+ cliVersion: PUGI_CLI_VERSION,
752
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
753
+ });
754
+ }
755
+ /**
756
+ * Leak L7 — `pugi plan [--back | --persist | <prompt...>]`.
757
+ *
758
+ * Quick mode-switch shortcut + optional one-shot engine dispatch. Slash
759
+ * surface `/plan` shares the same `runPlanCommand` helper so the
760
+ * workspace-state writes go through one code path. Argument grammar:
761
+ *
762
+ * pugi plan -> set workspace mode = plan + banner
763
+ * pugi plan --back -> restore the mode that was active
764
+ * before the most recent /plan entry
765
+ * pugi plan --persist -> set + also write ~/.pugi/config.json
766
+ * pugi plan <prompt...> -> set + run `runEngineTask('plan')`
767
+ * with the prompt (existing offline /
768
+ * engine path; the permission gate now
769
+ * sees plan as workspace state)
770
+ * pugi plan <prompt> --auto-back -> ALSO restore previous mode once
771
+ * the engine returns (defaults to
772
+ * leaving the operator in plan
773
+ * mode so they can iterate)
774
+ *
775
+ * The handler intentionally intercepts the mode-switch flags BEFORE
776
+ * delegating to `runEngineTask('plan')` for the prompt path. Without
777
+ * this wrapper, `pugi plan` (no args) would error out of the engine
778
+ * task ("requires a prompt") which is the legacy behaviour; the L7
779
+ * spec wants bare `pugi plan` to be the mode switch.
780
+ */
781
+ async function dispatchPlan(args, flags, session) {
782
+ // Strip `--back` / `--auto-back` from the positional args — the global
783
+ // parseArgs does not consume them (they are command-local). Anything
784
+ // else stays in `prompt` so the engine sees the operator's text
785
+ // verbatim. The flag parser keeps both `--back` and the spelling
786
+ // variants the operator might type from muscle memory after using
787
+ // `git checkout --` style flows.
788
+ let back = false;
789
+ let autoBack = false;
790
+ const remaining = [];
791
+ for (const arg of args) {
792
+ if (arg === '--back') {
793
+ back = true;
794
+ }
795
+ else if (arg === '--auto-back') {
796
+ autoBack = true;
797
+ }
798
+ else {
799
+ remaining.push(arg);
800
+ }
801
+ }
802
+ const hasPrompt = remaining.length > 0;
803
+ const persist = Boolean(flags.persist);
804
+ // --back and a prompt are mutually exclusive — back is a revert action,
805
+ // not a dispatch one. Refuse the combination with a clear hint instead
806
+ // of silently dropping one or the other.
807
+ if (back && hasPrompt) {
808
+ 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.');
809
+ process.exitCode = 2;
810
+ return;
811
+ }
812
+ // --back + --auto-back is incoherent (auto-back applies to the
813
+ // dispatch path) — refuse rather than degrade silently.
814
+ if (back && autoBack) {
815
+ writeOutput(flags, { ok: false, error: 'pugi plan --back and --auto-back cannot be combined.' }, 'pugi plan --back and --auto-back cannot be combined.');
816
+ process.exitCode = 2;
817
+ return;
818
+ }
819
+ // When a prompt is going to be dispatched in --json mode, suppress
820
+ // the human-readable banner writes so the engine task remains the
821
+ // single JSON emitter on stdout. The mode write still happens. In
822
+ // human (non --json) mode the banner prints normally so the operator
823
+ // sees the gate-state change before the engine starts thinking.
824
+ const sinkSilent = hasPrompt && flags.json;
825
+ const writeLine = (line) => {
826
+ if (sinkSilent)
827
+ return;
828
+ writeOutput(flags, { text: line }, line);
829
+ };
830
+ const result = await runPlanCommand({ back, persist }, {
831
+ workspaceRoot: process.cwd(),
832
+ writeOutput: writeLine,
833
+ });
834
+ // No prompt → mode-switch only. Done.
835
+ if (!hasPrompt)
836
+ return;
837
+ // Prompt present → fall through to the existing engine task with the
838
+ // remaining args. The workspace mode is now `plan` (or stayed `plan`
839
+ // if already there); the engine sees the same plan-task semantics it
840
+ // always has — read-only schema + executor refusal sentinel — but the
841
+ // permission GATE now also enforces plan independently.
842
+ try {
843
+ await runEngineTask('plan')(remaining, flags, session);
844
+ }
845
+ finally {
846
+ // --auto-back restores the previous mode AFTER the engine returns
847
+ // (success OR failure) so the operator's gate state mirrors a normal
848
+ // `--back` invocation. Without --auto-back the operator stays in
849
+ // plan and can iterate / inspect before acting.
850
+ if (autoBack && (result.verdict === 'entered' || result.verdict === 'persisted')) {
851
+ await runPlanCommand({ back: true, persist: false }, {
852
+ workspaceRoot: process.cwd(),
853
+ writeOutput: writeLine,
854
+ });
855
+ }
856
+ }
857
+ }
268
858
  async function dispatchSkills(args, flags, _session) {
269
859
  await runSkillsCommand(args, {
270
860
  workspaceRoot: process.cwd(),
@@ -279,6 +869,19 @@ async function dispatchAgents(args, flags, _session) {
279
869
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
280
870
  });
281
871
  }
872
+ /**
873
+ * Leak L10 (2026-05-27): `pugi dispatch <sub>` — operator-facing
874
+ * inspection + GC for fork-subagent prompt-cache inherit refs
875
+ * (.pugi/cache-refs/). Delegates to the standalone runner in
876
+ * commands/dispatch.ts so the cli.ts table stays under control.
877
+ */
878
+ async function dispatchSubagentCacheRefs(args, flags, _session) {
879
+ await runDispatchCommand(args, {
880
+ workspaceRoot: process.cwd(),
881
+ json: flags.json,
882
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
883
+ });
884
+ }
282
885
  /**
283
886
  * `pugi web <url>` — Sprint α6.15 Phase 1 quick-win subcommand.
284
887
  *
@@ -328,22 +931,46 @@ async function dispatchWeb(args, flags, _session) {
328
931
  */
329
932
  async function dispatchLsp(args, flags, _session) {
330
933
  const result = await runLspCommand(args, { cwd: process.cwd(), json: flags.json });
331
- if (flags.json)
332
- console.log(result.text);
333
- else
334
- console.log(result.text);
934
+ console.log(result.text);
335
935
  if (result.exitCode !== 0)
336
936
  process.exitCode = result.exitCode;
337
937
  }
938
+ /**
939
+ * β4 M6 + M7 + Sl7 (2026-05-26): `pugi mcp <sub>` — MCP execution +
940
+ * server. `list / trust / deny / install` manage the client-side
941
+ * registry (the same surface `pugi config mcp ...` exposes); `serve`
942
+ * boots Pugi-as-MCP-server over stdio (default) or HTTP+SSE; `perms`
943
+ * inspects + resets the per-(server, tool) permission cache that
944
+ * gates engine-loop dispatch.
945
+ *
946
+ * The serve sub-command never returns under normal conditions — the
947
+ * stdio path runs until stdin closes (parent agent disconnect) and the
948
+ * HTTP path runs until SIGINT/SIGTERM. Both honour the optional
949
+ * AbortSignal we pass through from the REPL slash bridge in β4b.
950
+ */
951
+ async function dispatchMcp(args, flags, _session) {
952
+ await runMcpCommand(args, {
953
+ workspaceRoot: process.cwd(),
954
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
955
+ });
956
+ }
338
957
  /**
339
958
  * α7.7: `pugi patch` — apply a unified-diff patch from stdin or a file.
340
959
  * Routes through the same security gate as the Layer A/B/C applicators
341
960
  * (see `src/core/edits/security-gate.ts`). Exit codes mirror the
342
961
  * security taxonomy so CI loops can alert on hostile patches without
343
962
  * confusing them with operator typos.
963
+ *
964
+ * R1 fix (2026-05-26, PR #413 r1): pass `flags.dryRun` through so the
965
+ * top-level parser's consumption of `--dry-run` does not silently
966
+ * disable dry-run mode on `pugi patch --dry-run < diff.patch`.
344
967
  */
345
968
  async function dispatchPatch(args, flags, _session) {
346
- const result = await runPatchCommand(args, { cwd: process.cwd(), json: flags.json });
969
+ const result = await runPatchCommand(args, {
970
+ cwd: process.cwd(),
971
+ json: flags.json,
972
+ dryRun: flags.dryRun,
973
+ });
347
974
  console.log(result.text);
348
975
  if (result.exitCode !== 0)
349
976
  process.exitCode = result.exitCode;
@@ -353,15 +980,82 @@ async function dispatchPatch(args, flags, _session) {
353
980
  * The `pugi build` and `pugi review --consensus` paths use the same
354
981
  * primitives internally (`createWorktree` / `promoteWorktree`); this
355
982
  * surface is the operator escape hatch for debug + experiment flows.
983
+ *
984
+ * R1 fix (2026-05-26, PR #413 r1): forward `flags.dryRun` so the
985
+ * top-level parser's consumption of `--dry-run` does not silently
986
+ * disable dry-run mode on `pugi worktree promote --dry-run <path>`.
356
987
  */
357
988
  async function dispatchWorktree(args, flags, _session) {
358
- const result = await runWorktreeCommand(args, { cwd: process.cwd(), json: flags.json });
989
+ const result = await runWorktreeCommand(args, {
990
+ cwd: process.cwd(),
991
+ json: flags.json,
992
+ dryRun: flags.dryRun,
993
+ });
359
994
  console.log(result.text);
360
995
  if (result.exitCode !== 0)
361
996
  process.exitCode = result.exitCode;
362
997
  }
363
998
  export async function runCli(argv) {
364
999
  const { command, args, flags, isBareInvocation } = parseArgs(argv);
1000
+ // Leak L22 — print the one-line bare banner once per invocation when
1001
+ // the flag is active and stdout is NOT bound for JSON consumption. The
1002
+ // banner goes to stderr so it never lands in a `--json` envelope or a
1003
+ // pipe-captured stdout stream; operators see it on the terminal,
1004
+ // scripted callers stay clean. Suppressed for `pugi version` / `pugi
1005
+ // help` (short, scripted-friendly surfaces) and when the operator
1006
+ // sets PUGI_BARE without the flag (avoids double-printing across
1007
+ // scripted nested invocations).
1008
+ if (flags.bare &&
1009
+ !flags.json &&
1010
+ command !== 'version' &&
1011
+ command !== 'help' &&
1012
+ argv.includes('--bare')) {
1013
+ process.stderr.write(`${BARE_MODE_BANNER}\n`);
1014
+ }
1015
+ // β-headless dispatch (CEO directive 2026-05-27 "нужно тестирование по
1016
+ // кругу"): when `--print <brief>` is set we route to the headless
1017
+ // runner BEFORE the REPL / splash / command branches. The runner
1018
+ // never mounts Ink, never opens raw stdin, never prints the splash
1019
+ // — only the structured event stream lands on stdout. Same engine
1020
+ // adapter path the REPL uses (no fork), only the output sink
1021
+ // differs.
1022
+ if (typeof flags.print === 'string') {
1023
+ const { runHeadlessPrint } = await import('./headless.js');
1024
+ // Default to NDJSON when stdout is not a TTY OR when --json is set
1025
+ // explicitly. A human running `pugi --print "..."` in their
1026
+ // terminal without flags gets the readable text sink; a pipe gets
1027
+ // the machine-readable stream.
1028
+ const wantJson = flags.json || !process.stdout.isTTY;
1029
+ const headlessFactory = getEngineClientFactory();
1030
+ const exitCode = await runHeadlessPrint({
1031
+ prompt: flags.print,
1032
+ json: wantJson,
1033
+ cwd: flags.cwd ?? process.cwd(),
1034
+ ...(flags.workspace ? { workspace: flags.workspace } : {}),
1035
+ ...(flags.sessionId ? { sessionIdOverride: flags.sessionId } : {}),
1036
+ ...(flags.timeoutSeconds ? { timeoutSeconds: flags.timeoutSeconds } : {}),
1037
+ noTools: flags.noTools,
1038
+ ...(flags.maxTurns ? { maxTurns: flags.maxTurns } : {}),
1039
+ ...(headlessFactory ? { engineClientFactory: headlessFactory } : {}),
1040
+ ...(headlessStdoutWriter ? { stdoutWrite: headlessStdoutWriter } : {}),
1041
+ ...(headlessStderrWriter ? { stderrWrite: headlessStderrWriter } : {}),
1042
+ });
1043
+ process.exitCode = exitCode;
1044
+ return;
1045
+ }
1046
+ // Leak L25 (2026-05-27): first-run hint. When the operator types a
1047
+ // bare `pugi` on a real TTY AND the onboarding marker is absent, drop
1048
+ // a one-line hint on stderr BEFORE the REPL splash mounts. Stderr so
1049
+ // the line never lands in a `--json` envelope or a scripted stdout
1050
+ // pipe; suppressed when --json is set or the operator already walked
1051
+ // the wizard. The marker check is best-effort — a fs glitch returns
1052
+ // false and we print the hint, which is harmless.
1053
+ if (isBareInvocation
1054
+ && isInteractive(flags)
1055
+ && !flags.json
1056
+ && !isOnboarded(process.env)) {
1057
+ process.stderr.write('Tip: run `pugi onboarding` to configure defaults.\n');
1058
+ }
365
1059
  // Bare `pugi` on a TTY enters the REPL-by-default agentic session
366
1060
  // (Sprint α5.7, ADR-0056). The REPL is the customer-facing surface
367
1061
  // that brings Pugi to parity with Claude Code / Codex CLI. When the
@@ -436,6 +1130,7 @@ function parseArgs(argv) {
436
1130
  offline: false,
437
1131
  noTty: false,
438
1132
  allowFetch: false,
1133
+ allowSearch: false,
439
1134
  noUpdateCheck: false,
440
1135
  noSplash: process.env.PUGI_SKIP_SPLASH === '1',
441
1136
  // Claude triple-review P1 PR #369: default tool-stream pane HIDDEN
@@ -445,13 +1140,50 @@ function parseArgs(argv) {
445
1140
  // "Mira: ..." prose, never "Read(path)" prefix) OR fires falsely on
446
1141
  // accidental `Verb(noun)` shapes producing stuck `running` rows.
447
1142
  // Hide by default; opt-in via `--tool-stream` OR PUGI_TOOL_STREAM=1
448
- // for development/testing. Will flip к default ON when backend
1143
+ // for development/testing. Will flip to default ON when backend
449
1144
  // emits real tool events (filed as α6.13.X follow-up).
450
1145
  noToolStream: process.env.PUGI_TOOL_STREAM === '1' || process.env.PUGI_HIDE_TOOL_STREAM === '1'
451
1146
  ? process.env.PUGI_HIDE_TOOL_STREAM === '1'
452
1147
  : true,
1148
+ noDefaults: process.env.PUGI_INIT_NO_DEFAULTS === '1',
1149
+ decompose: false,
1150
+ // β-headless: --no-tools default OFF so existing flag-free invocations
1151
+ // keep tool advertisement. Flipped only by explicit operator opt-in.
1152
+ noTools: false,
1153
+ // Leak L6 — `pugi permissions <mode> --persist/--confirm`. Default
1154
+ // false so existing invocations stay no-op on the new permission
1155
+ // surface.
1156
+ persist: false,
1157
+ confirm: false,
1158
+ // Leak L22 — `--bare` flag (skip project auto-discovery). Default
1159
+ // honors the env var so a wrapper script that exports PUGI_BARE=1
1160
+ // keeps the bit even when the operator forgets the flag, and the
1161
+ // explicit flag overrides on the way through the loop below.
1162
+ bare: isBareMode(),
1163
+ // Leak L33 — `--ascii-only` for `pugi stickers`. Default off so the
1164
+ // interactive surface keeps its boxed renderer; opt-in via flag
1165
+ // for pipe / script use.
1166
+ asciiOnly: false,
1167
+ // Leak L24 — `--reset` for `pugi release-notes`. Default off so a
1168
+ // bare invocation only surfaces new sections. Opt-in to force the
1169
+ // full bundled changelog к re-render (clears the on-disk marker).
1170
+ reset: false,
1171
+ // Leak L28 — `--refresh` for `pugi repo-map`. Default off so a
1172
+ // bare invocation hits the cache when mtime + size match; opt-in
1173
+ // for a cold rebuild from the source tree.
1174
+ refresh: false,
453
1175
  };
454
1176
  const args = [];
1177
+ // Leak L22: scan for `--bare` BEFORE the early-return short-circuits
1178
+ // below. Operators may pass `pugi --bare --version` or `pugi --bare
1179
+ // --help` and the short-circuit return must still flip the bare bit
1180
+ // so subprocesses + env-consulting modules see the activated state.
1181
+ // The bit is idempotent — re-applied inside the main loop below for
1182
+ // non-short-circuit paths.
1183
+ if (argv.includes('--bare')) {
1184
+ flags.bare = true;
1185
+ setBareMode();
1186
+ }
455
1187
  // Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
456
1188
  // (npm uses --version on every published bin, Homebrew formula uses it in
457
1189
  // the test block). Normalize them to the `version` command so users can
@@ -482,7 +1214,7 @@ function parseArgs(argv) {
482
1214
  else if (arg === '--consensus') {
483
1215
  // α6.7: customer-facing 3-model consensus review. Routes through
484
1216
  // the SSE-based runtime gate rather than the legacy artifact
485
- // writer. The triple flag stays unset так the existing
1217
+ // writer. The triple flag stays unset so the existing
486
1218
  // performRemoteTripleReview path is never accidentally entered.
487
1219
  flags.consensus = true;
488
1220
  }
@@ -495,6 +1227,12 @@ function parseArgs(argv) {
495
1227
  else if (arg === '--allow-fetch') {
496
1228
  flags.allowFetch = true;
497
1229
  }
1230
+ else if (arg === '--allow-search') {
1231
+ // β1b T4 (2026-05-26): unlock the `web_search` tool for one
1232
+ // invocation, mirroring the `--allow-fetch` gate. Distinct flag
1233
+ // because an operator may want to query without fetching pages.
1234
+ flags.allowSearch = true;
1235
+ }
498
1236
  else if (arg === '--no-update-check') {
499
1237
  flags.noUpdateCheck = true;
500
1238
  }
@@ -505,10 +1243,51 @@ function parseArgs(argv) {
505
1243
  flags.noToolStream = true;
506
1244
  }
507
1245
  else if (arg === '--tool-stream') {
508
- // Opt-in для α6.12 dev/testing — backend tool events not live yet,
509
- // pane shows синтесайз heuristic OR empty placeholder
1246
+ // Opt-in for α6.12 dev/testing — backend tool events not live yet,
1247
+ // pane shows synthesized heuristic OR empty placeholder
510
1248
  flags.noToolStream = false;
511
1249
  }
1250
+ else if (arg === '--no-defaults') {
1251
+ // Init-only flag: skip the bundled default-skills install. Parsed
1252
+ // at the global level for consistency with --no-splash / --no-tool-stream.
1253
+ flags.noDefaults = true;
1254
+ }
1255
+ else if (arg === '--ascii-only') {
1256
+ // Leak L33 — `pugi stickers --ascii-only` skips the Ink boxed
1257
+ // renderer. Parsed globally so the dispatcher can pass the flag
1258
+ // through to runStickersCommand without per-command argv slicing.
1259
+ flags.asciiOnly = true;
1260
+ }
1261
+ else if (arg === '--reset') {
1262
+ // Leak L24 — `pugi release-notes --reset` clears the on-disk
1263
+ // `~/.pugi/.last-seen-version` marker so the full bundled
1264
+ // changelog re-renders. Parsed globally for symmetry with the
1265
+ // rest of the flag grammar; `runReleaseNotesCommand` is the
1266
+ // single consumer today.
1267
+ flags.reset = true;
1268
+ }
1269
+ else if (arg === '--refresh') {
1270
+ // Leak L28 — `pugi repo-map --refresh` busts the cache and
1271
+ // rebuilds the AST-light summary from a cold scan. Parsed
1272
+ // globally for symmetry with the rest of the flag grammar;
1273
+ // `runRepoMapCommand` is the single consumer today.
1274
+ flags.refresh = true;
1275
+ }
1276
+ else if (arg === '--format=json' || arg === '--format' && argv[index + 1] === 'json') {
1277
+ // Leak L28 — `pugi repo-map --format=json` is a per-command
1278
+ // synonym for the global `--json` flag. The L28 spec calls
1279
+ // out the `--format=json` shape explicitly so we accept it
1280
+ // verbatim and route through the existing JSON envelope.
1281
+ flags.json = true;
1282
+ if (arg === '--format')
1283
+ index += 1;
1284
+ }
1285
+ else if (arg === '--decompose') {
1286
+ // α6.8 EXTEND PR1: plan-only flag. Other engine commands ignore
1287
+ // it. Parsed globally for symmetry with the rest of the flag
1288
+ // grammar; `runEngineTask('plan')` is the single consumer.
1289
+ flags.decompose = true;
1290
+ }
512
1291
  else if (arg.startsWith('--privacy=')) {
513
1292
  flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
514
1293
  }
@@ -519,18 +1298,190 @@ function parseArgs(argv) {
519
1298
  flags.privacy = parsePrivacyMode(next);
520
1299
  index += 1;
521
1300
  }
1301
+ else if (arg === '--print') {
1302
+ // β-headless: top-level `--print <brief>` runs a single
1303
+ // non-interactive engine turn. Consumes the next argv token as
1304
+ // the brief — refusing if it looks like another flag so a
1305
+ // dangling `--print --json` does not silently swallow `--json`.
1306
+ const next = argv[index + 1];
1307
+ if (!next || next.startsWith('--')) {
1308
+ throw new Error('--print requires a brief (e.g. --print "create word_counter.py")');
1309
+ }
1310
+ flags.print = next;
1311
+ index += 1;
1312
+ }
1313
+ else if (arg.startsWith('--print=')) {
1314
+ flags.print = arg.slice('--print='.length);
1315
+ }
1316
+ else if (arg === '--cwd') {
1317
+ const next = argv[index + 1];
1318
+ if (!next || next.startsWith('--'))
1319
+ throw new Error('--cwd requires a path');
1320
+ flags.cwd = next;
1321
+ index += 1;
1322
+ }
1323
+ else if (arg.startsWith('--cwd=')) {
1324
+ flags.cwd = arg.slice('--cwd='.length);
1325
+ }
1326
+ else if (arg === '--workspace') {
1327
+ const next = argv[index + 1];
1328
+ if (!next || next.startsWith('--'))
1329
+ throw new Error('--workspace requires a slug');
1330
+ flags.workspace = next;
1331
+ index += 1;
1332
+ }
1333
+ else if (arg.startsWith('--workspace=')) {
1334
+ flags.workspace = arg.slice('--workspace='.length);
1335
+ }
1336
+ else if (arg === '--session') {
1337
+ const next = argv[index + 1];
1338
+ if (!next || next.startsWith('--'))
1339
+ throw new Error('--session requires an id');
1340
+ flags.sessionId = next;
1341
+ index += 1;
1342
+ }
1343
+ else if (arg.startsWith('--session=')) {
1344
+ flags.sessionId = arg.slice('--session='.length);
1345
+ }
1346
+ else if (arg === '--timeout') {
1347
+ const next = argv[index + 1];
1348
+ if (!next || next.startsWith('--'))
1349
+ throw new Error('--timeout requires seconds');
1350
+ const parsed = Number(next);
1351
+ if (!Number.isFinite(parsed) || parsed <= 0) {
1352
+ throw new Error(`--timeout requires positive seconds, got "${next}"`);
1353
+ }
1354
+ flags.timeoutSeconds = parsed;
1355
+ index += 1;
1356
+ }
1357
+ else if (arg.startsWith('--timeout=')) {
1358
+ const raw = arg.slice('--timeout='.length);
1359
+ const parsed = Number(raw);
1360
+ if (!Number.isFinite(parsed) || parsed <= 0) {
1361
+ throw new Error(`--timeout requires positive seconds, got "${raw}"`);
1362
+ }
1363
+ flags.timeoutSeconds = parsed;
1364
+ }
1365
+ else if (arg === '--no-tools') {
1366
+ flags.noTools = true;
1367
+ }
1368
+ else if (arg === '--max-turns') {
1369
+ const next = argv[index + 1];
1370
+ if (!next || next.startsWith('--'))
1371
+ throw new Error('--max-turns requires an integer');
1372
+ const parsed = Number(next);
1373
+ if (!Number.isInteger(parsed) || parsed <= 0) {
1374
+ throw new Error(`--max-turns requires positive integer, got "${next}"`);
1375
+ }
1376
+ flags.maxTurns = parsed;
1377
+ index += 1;
1378
+ }
1379
+ else if (arg.startsWith('--max-turns=')) {
1380
+ const raw = arg.slice('--max-turns='.length);
1381
+ const parsed = Number(raw);
1382
+ if (!Number.isInteger(parsed) || parsed <= 0) {
1383
+ throw new Error(`--max-turns requires positive integer, got "${raw}"`);
1384
+ }
1385
+ flags.maxTurns = parsed;
1386
+ }
1387
+ else if (arg.startsWith('--commit=')) {
1388
+ // `pugi review --triple --commit <SHA>` activates the multi-
1389
+ // provider routing path against a specific revision.
1390
+ flags.commit = arg.slice('--commit='.length);
1391
+ }
1392
+ else if (arg === '--commit') {
1393
+ const next = argv[index + 1];
1394
+ if (!next)
1395
+ throw new Error('--commit requires a SHA or ref');
1396
+ flags.commit = next;
1397
+ index += 1;
1398
+ }
1399
+ else if (arg.startsWith('--base=')) {
1400
+ flags.base = arg.slice('--base='.length);
1401
+ }
1402
+ else if (arg === '--base') {
1403
+ const next = argv[index + 1];
1404
+ if (!next)
1405
+ throw new Error('--base requires a ref');
1406
+ flags.base = next;
1407
+ index += 1;
1408
+ }
1409
+ else if (arg.startsWith('--mode=')) {
1410
+ // Leak L6: top-level `--mode plan|ask|allow|bypass`. Validation
1411
+ // happens at the consumer side (parsePermissionMode) so the
1412
+ // parser stays string-typed; an invalid value surfaces a clean
1413
+ // error in the dispatcher rather than blowing up here.
1414
+ flags.mode = arg.slice('--mode='.length);
1415
+ }
1416
+ else if (arg === '--mode') {
1417
+ const next = argv[index + 1];
1418
+ if (!next || next.startsWith('--')) {
1419
+ throw new Error('--mode requires plan|ask|allow|bypass');
1420
+ }
1421
+ flags.mode = next;
1422
+ index += 1;
1423
+ }
1424
+ else if (arg === '--persist') {
1425
+ // Leak L6: paired with `pugi permissions <mode>` to also write
1426
+ // the mode to ~/.pugi/config.json::defaultPermissionMode.
1427
+ flags.persist = true;
1428
+ }
1429
+ else if (arg === '--confirm') {
1430
+ // Leak L6: required for `pugi permissions bypass` (bypass
1431
+ // disables policy hooks; the gate refuses the flip without
1432
+ // acknowledgement).
1433
+ flags.confirm = true;
1434
+ }
1435
+ else if (arg === '--bare') {
1436
+ // Leak L22: disable project auto-discovery for this invocation.
1437
+ // Set BOTH the parsed flag and the process env so downstream
1438
+ // modules consulting `isBareMode()` (markdown-traverse callsite,
1439
+ // REPL auto-init gate, doctor probe, subprocess spawns) see a
1440
+ // coherent activated state without re-threading the bit through
1441
+ // every call signature.
1442
+ flags.bare = true;
1443
+ setBareMode();
1444
+ }
522
1445
  else {
523
1446
  args.push(arg);
524
1447
  }
525
1448
  }
526
1449
  const isBareInvocation = args.length === 0;
1450
+ const command = args.shift() ?? 'help';
1451
+ // Sprint α6.X CEO dogfood 2026-05-26 (P0 hot-fix): trailing `--help`
1452
+ // / `-h` on ANY sub-command must route to the help printer rather
1453
+ // than dispatching the real engine. Before this guard `pugi build
1454
+ // --help` burned 86k tokens running the actual build loop because
1455
+ // the dispatcher saw `--help` as an opaque arg and forwarded it
1456
+ // through to the engine. Re-routing here means `pugi <cmd> --help`
1457
+ // becomes `pugi help <cmd>` deterministically across the entire
1458
+ // command tree.
1459
+ //
1460
+ // β1 Tt3 carve-out: commands that ship their OWN `--help` block
1461
+ // (login, init, ...) must keep `--help` in their args so the
1462
+ // command-local printer fires. Without this carve-out
1463
+ // `pugi login --help` produces the global help and the per-variant
1464
+ // reference (`--provider device|token|env`) gets lost. The carve-out
1465
+ // list mirrors handlers whose source carries an
1466
+ // `args.includes('--help')` short-circuit.
1467
+ if ((args.includes('--help') || args.includes('-h')) && !COMMAND_LOCAL_HELP.has(command)) {
1468
+ return { command: 'help', args: [command], flags, isBareInvocation: false };
1469
+ }
527
1470
  return {
528
- command: args.shift() ?? 'help',
1471
+ command,
529
1472
  args,
530
1473
  flags,
531
1474
  isBareInvocation,
532
1475
  };
533
1476
  }
1477
+ /**
1478
+ * β1 Tt3: commands that own their `--help` rendering. The bare-help
1479
+ * redirect leaves their `--help` arg in place so the command-local
1480
+ * printer fires instead of the global summary.
1481
+ */
1482
+ const COMMAND_LOCAL_HELP = new Set([
1483
+ 'login',
1484
+ ]);
534
1485
  async function version(_args, flags, _session) {
535
1486
  const payload = {
536
1487
  name: 'pugi',
@@ -538,7 +1489,353 @@ async function version(_args, flags, _session) {
538
1489
  };
539
1490
  writeOutput(flags, payload, `pugi ${payload.version}`);
540
1491
  }
541
- async function help(_args, flags, _session) {
1492
+ /**
1493
+ * Per-command help bodies (task #100). When the operator types
1494
+ * `pugi <cmd> --help` the dispatcher routes here with `args = [cmd]`.
1495
+ * If we have a focused body for that command, print it instead of the
1496
+ * global summary. Falls back to the global summary so unknown / new
1497
+ * commands still get a useful response.
1498
+ *
1499
+ * Source of truth for each entry: the comment block at the top of the
1500
+ * command's implementation module + any flags the command declares.
1501
+ * Keep entries short — operators want the one-liner of intent + the
1502
+ * 2-5 most useful flags, not a tutorial. The global help still has the
1503
+ * full per-section reference; the per-command body is the "tell me
1504
+ * how to use this NOW" surface.
1505
+ */
1506
+ const COMMAND_HELP_BODIES = {
1507
+ init: [
1508
+ 'pugi init — bootstrap a new Pugi workspace in the current directory.',
1509
+ '',
1510
+ 'Creates .pugi/{PUGI.md, mcp.json, index.json, artifacts/, sessions/} and',
1511
+ 'seeds the 6 default skills. Idempotent — running again only fills gaps.',
1512
+ '',
1513
+ 'Flags:',
1514
+ ' --no-defaults Skip the bundled default-skills install.',
1515
+ '',
1516
+ 'Env:',
1517
+ ' PUGI_INIT_NO_DEFAULTS=1 Same as --no-defaults.',
1518
+ ],
1519
+ explain: [
1520
+ 'pugi explain "<question>" — read-only Q&A about the workspace.',
1521
+ '',
1522
+ 'Calls the engine loop in explain mode (budget: 5 calls / 20k tokens).',
1523
+ 'No file writes; safe to run against unfamiliar code.',
1524
+ '',
1525
+ 'Examples:',
1526
+ ' pugi explain "what does this package.json define?"',
1527
+ ' pugi explain "trace the auth flow in src/auth/"',
1528
+ ],
1529
+ code: [
1530
+ 'pugi code "<brief>" — engineering-mode write loop (30k token budget).',
1531
+ '',
1532
+ 'Writes files in the current workspace. Use --no-tty in CI / pipes.',
1533
+ ],
1534
+ fix: [
1535
+ 'pugi fix "<brief>" — minimal-diff bugfix loop (30k token budget).',
1536
+ '',
1537
+ 'Same as `pugi code` but the prompt biases toward the smallest patch',
1538
+ 'that closes the brief — refuses scope creep / refactor invitations.',
1539
+ ],
1540
+ build: [
1541
+ 'pugi build "<brief>" — feature-build loop (200k token budget).',
1542
+ '',
1543
+ 'Multi-turn engineering with plan-review checkpoints. Pairs with',
1544
+ 'pugi plan --decompose <idea> when the brief is bigger than one PR.',
1545
+ ],
1546
+ plan: [
1547
+ 'pugi plan --decompose <idea> — split an idea into 3-7 components.',
1548
+ '',
1549
+ 'Writes .pugi/plan/<session-id>/splits/NN-<name>/spec.md plus',
1550
+ 'manifest.md with the dependency DAG. Pass each split to `pugi build`.',
1551
+ ],
1552
+ review: [
1553
+ 'pugi review — code review surfaces.',
1554
+ '',
1555
+ ' --triple 3-model consensus via Anvil paid fleet.',
1556
+ ' --triple --commit <SHA> Review a specific commit (vs origin/main).',
1557
+ ' --consensus Customer-facing consensus review (codex + claude + deepseek).',
1558
+ ' Optional: --commit <sha> | --pr <num> | --branch <name>.',
1559
+ '',
1560
+ 'Exit codes: 0 PASS · 1 WARN · 2 BLOCK · 5 auth_missing · 7 rate_limited.',
1561
+ ],
1562
+ privacy: [
1563
+ 'pugi privacy — privacy-mode operations.',
1564
+ '',
1565
+ ' show Display effective mode + source.',
1566
+ ' set <mode> Local-only legacy values (local-only|metadata|full).',
1567
+ '',
1568
+ 'For tenant-scoped server-side modes (strict|balanced|permissive), use:',
1569
+ ' pugi config get privacy',
1570
+ ' pugi config set privacy=<mode>',
1571
+ ],
1572
+ share: [
1573
+ 'pugi share — export the current session transcript (leak L20).',
1574
+ '',
1575
+ 'Reads .pugi/events.jsonl, formats it as Markdown, and uploads to',
1576
+ 'either a GitHub Gist (`gh`-backed, default when `gh` is available)',
1577
+ 'or pugi.io (--pugi). Always prompts before upload unless --yes is',
1578
+ 'set. Refuses upload entirely if the transcript carries an active',
1579
+ '`Bearer ` credential — re-run with --redact to scrub it first.',
1580
+ '',
1581
+ 'Flags:',
1582
+ ' --gist Force gist target; refuses if gh CLI is absent.',
1583
+ ' --pugi Force pugi.io target (requires `pugi login`).',
1584
+ ' --redact Run PII scrubber before upload.',
1585
+ ' --preview Print the transcript to stdout WITHOUT upload.',
1586
+ ' --yes, -y Skip the y/n confirmation prompt.',
1587
+ ' --json Emit a structured JSON envelope only.',
1588
+ '',
1589
+ 'Examples:',
1590
+ ' pugi share Auto-pick + confirm.',
1591
+ ' pugi share --preview --redact See what would be shared.',
1592
+ ' pugi share --gist --redact --yes Scripted secret-gist upload.',
1593
+ ],
1594
+ cost: [
1595
+ 'pugi cost — token + USD breakdown for the current Pugi session.',
1596
+ '',
1597
+ 'Reads .pugi/cost.json (persisted via the in-REPL CostTracker) and',
1598
+ 'prints a per-model table plus dollar estimate. Alias: pugi usage.',
1599
+ '',
1600
+ 'Flags:',
1601
+ ' --all-sessions 30-day rolling aggregate across all sessions.',
1602
+ ' --window=<days> Override the aggregate window (max 365).',
1603
+ ' --reset --yes Clear the current-session counter. History',
1604
+ ' is preserved. Requires --yes to confirm.',
1605
+ ' --json Emit a structured JSON envelope only.',
1606
+ '',
1607
+ 'Examples:',
1608
+ ' pugi cost Current session totals.',
1609
+ ' pugi cost --all-sessions Past 30 days aggregated.',
1610
+ ' pugi cost --all-sessions --window=7',
1611
+ ' pugi cost --reset --yes Wipe the session counter.',
1612
+ ' pugi usage Alias for pugi cost.',
1613
+ ],
1614
+ config: [
1615
+ 'pugi config — read / write CLI + tenant configuration.',
1616
+ '',
1617
+ ' get <key> Local config value.',
1618
+ ' get privacy Tenant privacy snapshot (admin-api).',
1619
+ ' get routing Effective routing table.',
1620
+ ' set <key>=<value> Local config write.',
1621
+ ' set privacy=<mode> Flip tenant privacy (strict|balanced|permissive).',
1622
+ ' set routing.<tag>.<budget>=<model> Override one routing lane.',
1623
+ ' unset routing.<tag>.<budget> Revert a routing override.',
1624
+ ' mcp trust|deny|list <name> MCP server trust + visibility.',
1625
+ ],
1626
+ sync: [
1627
+ 'pugi sync — explicit-continuation handoff bundle upload.',
1628
+ '',
1629
+ ' --dry-run Print the bundle plan without uploading.',
1630
+ ' --privacy <mode> Override per-bundle privacy posture.',
1631
+ ],
1632
+ whoami: [
1633
+ 'pugi whoami — show the active credential + JWT principal + plan tier.',
1634
+ '',
1635
+ 'Reads from ~/.pugi/credentials.json. No network call unless --remote.',
1636
+ ],
1637
+ login: [
1638
+ 'pugi login — authenticate against an api.pugi.io endpoint.',
1639
+ '',
1640
+ 'Interactive picker by default (browser OAuth / PAT / env). Non-interactive:',
1641
+ ' --provider device Device-flow OAuth.',
1642
+ ' --provider token --token <jwt> Pass a JWT directly.',
1643
+ ' --provider env Read PUGI_API_KEY (or --key) + verify via /api/pugi/health.',
1644
+ ' --provider env --key <value> --skip-validate Explicit key, no probe (CI bootstrap).',
1645
+ ],
1646
+ accounts: [
1647
+ 'pugi accounts — manage stored credentials across endpoints.',
1648
+ '',
1649
+ ' list Every account + its endpoint + active flag.',
1650
+ ' switch <label> Re-point the active account.',
1651
+ ' remove <label> Delete a stored credential.',
1652
+ ],
1653
+ jobs: [
1654
+ 'pugi jobs — list, tail, or kill background dispatch jobs.',
1655
+ '',
1656
+ ' list All jobs in the registry.',
1657
+ ' tail <id> Stream output from one job.',
1658
+ ' kill <id> Cancel a running job.',
1659
+ ],
1660
+ delegate: [
1661
+ 'pugi delegate <slug> "<brief>" — dispatch a brief to one specialist persona.',
1662
+ '',
1663
+ 'Slugs (Tier 1 alpha 7.5): dev qa pm devops researcher analyst designer',
1664
+ 'frontend architect. `pugi roster` lists the live set.',
1665
+ ],
1666
+ chain: [
1667
+ 'pugi chain — Wave 6 artifact chain (PRD → ADR → mindmap → ER → sequence → tests → code).',
1668
+ '',
1669
+ ' new "<intent>" Start a new chain from a one-sentence intent.',
1670
+ ' status [<chain-id>] Show current cursor + per-step table.',
1671
+ ' next [<chain-id>] Approve the last step and dispatch the next.',
1672
+ ' show <step> [<chain-id>] Render one artifact (prd/adr/mindmap/er/sequence/tests/code).',
1673
+ ' export [<chain-id>] [--json] Bundle every artifact as markdown / JSON.',
1674
+ ' list Every chain in this workspace.',
1675
+ ],
1676
+ dispatch: [
1677
+ 'pugi dispatch <sub> — inspect + GC fork-subagent prompt-cache inherit refs.',
1678
+ '',
1679
+ ' list-cache-refs Table of every active ref under .pugi/cache-refs/.',
1680
+ ' clear-cache-refs [--older-than 1h] Evict refs older than the window (default 24h).',
1681
+ '',
1682
+ 'Leak L10 (2026-05-27): when Mira spawns a child via the `agent` tool,',
1683
+ 'a prompt-cache handle is persisted so the child loop can request',
1684
+ 'parent-context reuse on the wire. These commands surface + clean up',
1685
+ 'the persisted refs.',
1686
+ ],
1687
+ roster: [
1688
+ 'pugi roster — list the live Tier 1 personas + roles.',
1689
+ ],
1690
+ doctor: [
1691
+ 'pugi doctor — diagnose CLI + workspace + adapter capabilities.',
1692
+ '',
1693
+ 'Prints CLI version, Node version, workspace state (.pugi presence,',
1694
+ 'event log, settings), permission mode, and the capability matrix per',
1695
+ 'engine adapter. Safe to run anywhere; no network calls.',
1696
+ ],
1697
+ 'prd-check': [
1698
+ 'pugi prd-check <prd-path> | --all | --session — Wave 6 verified-deliverable gate.',
1699
+ '',
1700
+ 'DEFAULT MODE — verify acceptance criteria against committed artifacts.',
1701
+ 'Reads a markdown PRD, parses the acceptance-criteria section, and',
1702
+ 'runs verifiers:',
1703
+ ' file:<path> fs.existsSync',
1704
+ ' test:<spec> spec file exists + has ≥1 test()/it() block',
1705
+ ' doc:<path> doc exists + has > 100 chars',
1706
+ ' command:<name> CLI registry contains the command',
1707
+ ' route:METHOD /p best-effort grep of controllers',
1708
+ '',
1709
+ ' --all Scan docs/prd/**.md instead of one file.',
1710
+ ' --json Emit a structured envelope to stdout.',
1711
+ '',
1712
+ 'SESSION MODE (Wave 6 final) — review the live session against the PRD.',
1713
+ 'Walks up for PRD.md or apps/<app>/PRODUCT.md, reads the last 20 turns',
1714
+ 'from .pugi/events.jsonl, and dispatches a cross-review subagent to',
1715
+ 'list which requirements are SATISFIED and which remain OUTSTANDING.',
1716
+ '',
1717
+ ' --session Run the session-review mode (no <path>, no --all).',
1718
+ '',
1719
+ 'Exit codes: 0 healthy · 1 failing · 2 unparsed / arg error.',
1720
+ ],
1721
+ status: [
1722
+ 'pugi status — concise session snapshot.',
1723
+ '',
1724
+ 'Different from `pugi doctor` (environment health). Status answers',
1725
+ '"what is this Pugi session doing right now?" — session id + age,',
1726
+ 'cwd, permission mode, CLI version, token usage, active + completed',
1727
+ 'dispatches, last command, compact boundary count, auth identity.',
1728
+ '',
1729
+ ' --json Emit a structured envelope to stdout.',
1730
+ '',
1731
+ 'Live REPL state (tokens, last command) is only available via the',
1732
+ 'in-REPL `/status` slash; the shell path degrades those fields к',
1733
+ '"n/a" and exits 0.',
1734
+ ],
1735
+ report: [
1736
+ 'pugi report — capture a bug report from the most-recent session.',
1737
+ '',
1738
+ ' --from-error Bundle the most-recent failed session as a',
1739
+ ' redacted local report (default + only mode in v1).',
1740
+ '',
1741
+ 'Output: writes .pugi/reports/<timestamp>-<session-id>/{report.json, report.md}.',
1742
+ 'Secrets (bearer tokens, JWTs, named env values) are stripped before disk write.',
1743
+ 'Auto-upload to api.pugi.io planned for a follow-up; v1 keeps everything local.',
1744
+ ],
1745
+ ask: [
1746
+ 'pugi ask "<question>" — surface a yes/no question modal locally.',
1747
+ '',
1748
+ 'Useful in shell scripts that need a human-confirm before a destructive',
1749
+ 'step. Exits 0 on yes, 1 on no, 2 on cancel.',
1750
+ ],
1751
+ update: [
1752
+ 'pugi update — channel-aware @pugi/cli update check + install.',
1753
+ '',
1754
+ 'Polls npm registry dist-tags for a newer @pugi/cli on the configured',
1755
+ 'channel (stable / beta / canary). Without flags, prints the install',
1756
+ 'command and exits. With --apply, shells out to `npm install -g …`.',
1757
+ '',
1758
+ ' --check Non-interactive probe + JSON envelope.',
1759
+ ' --channel <name> Switch channel (stable | beta | canary) and probe.',
1760
+ ' Persisted to ~/.pugi/config.json::updateChannel.',
1761
+ ' --apply Shell out to `npm install -g @pugi/cli@<tag>`',
1762
+ ' after a y/n confirmation.',
1763
+ ' --yes, -y Skip the confirmation prompt on --apply.',
1764
+ ' --json Force JSON envelope (auto-on with --check).',
1765
+ '',
1766
+ 'Channel mapping: stable -> npm `latest`, beta -> npm `beta`,',
1767
+ 'canary -> npm `next`. Default channel is `beta` (Pugi currently',
1768
+ 'ships beta releases only).',
1769
+ '',
1770
+ 'Also available as /update from inside the REPL — slash form NEVER',
1771
+ 'spawns npm (would corrupt the running binary); it only prints the',
1772
+ 'install command for the operator к run after exit.',
1773
+ '',
1774
+ 'R2 atomic swap (sprint plan L27) deferred к Phase 2 — npm is the',
1775
+ 'only distribution channel today.',
1776
+ ],
1777
+ stickers: [
1778
+ 'pugi stickers — show a Pugi brand sticker (gimmick).',
1779
+ '',
1780
+ 'Picks one of the curated pug-face ASCII variants at random and footers',
1781
+ 'it with a rotating brand quote. Brand-personality surface — never a gate.',
1782
+ '',
1783
+ ' --json Emit a structured envelope (id · caption · quote).',
1784
+ ' --ascii-only Plain stdout (no box, no dim accents) for scripting.',
1785
+ '',
1786
+ 'Also available as /stickers from inside the REPL.',
1787
+ ],
1788
+ feedback: [
1789
+ 'pugi feedback — file a bug / feature / general comment from the CLI.',
1790
+ '',
1791
+ 'Interactive five-step wizard:',
1792
+ ' 1. category (bug / feature / general / praise)',
1793
+ ' 2. rating (1-5 stars)',
1794
+ ' 3. comment (multi-line, Ctrl-D submits)',
1795
+ ' 4. include redacted last 5 turns? (y/n, default n)',
1796
+ ' 5. confirm submit (y/n, default y)',
1797
+ '',
1798
+ 'On network failure the envelope is appended to',
1799
+ '.pugi/feedback-queue.jsonl and drained on the next online session.',
1800
+ '',
1801
+ 'Also available as /feedback from inside the REPL.',
1802
+ ],
1803
+ 'release-notes': [
1804
+ 'pugi release-notes — show what changed since you last upgraded.',
1805
+ '',
1806
+ 'Reads the bundled CHANGELOG.md, slices to sections strictly newer than',
1807
+ '~/.pugi/.last-seen-version, renders Markdown to stdout, then bumps the',
1808
+ 'last-seen marker to the installed CLI version. Re-running is a no-op',
1809
+ 'until you upgrade again.',
1810
+ '',
1811
+ ' --json Emit a structured envelope (sections + meta).',
1812
+ ' --reset Clear last-seen marker; re-render every section.',
1813
+ '',
1814
+ 'Also available as /release-notes from inside the REPL.',
1815
+ ],
1816
+ deploy: [
1817
+ 'pugi deploy — trigger a vendor deployment from the bound Git source.',
1818
+ '',
1819
+ ' --target vercel <vercelProject> --project <id> Vercel deploy.',
1820
+ ' --target render <renderService> --project <id> Render deploy (Sprint 2 stub).',
1821
+ ' --status <id> Vendor-agnostic status snapshot.',
1822
+ ' --logs <id> [--tail] Build-log tail.',
1823
+ '',
1824
+ 'Optional: --target-env production|preview, --ref <ref>, --integration <id>.',
1825
+ ],
1826
+ };
1827
+ async function help(args, flags, _session) {
1828
+ // 2026-05-27 task #100: per-command help bodies. When dispatcher
1829
+ // routed `pugi <cmd> --help` here it passes `args = [cmd]`; if we
1830
+ // have a focused body, print that. Falls through to the global
1831
+ // summary on unknown / new commands so the dispatcher's redirect
1832
+ // never produces a worse-than-baseline response.
1833
+ const requested = args[0];
1834
+ if (requested && COMMAND_HELP_BODIES[requested]) {
1835
+ const body = COMMAND_HELP_BODIES[requested];
1836
+ writeOutput(flags, { command: requested, lines: body }, body.join('\n'));
1837
+ return;
1838
+ }
542
1839
  const commands = Object.keys(handlers).sort();
543
1840
  writeOutput(flags, { commands }, [
544
1841
  'Pugi CLI',
@@ -558,6 +1855,9 @@ async function help(_args, flags, _session) {
558
1855
  '',
559
1856
  'Review gate:',
560
1857
  ' pugi review --triple Prepare the Anvil-backed triple-review gate.',
1858
+ ' pugi review --triple --commit <SHA>',
1859
+ ' 3-model consensus via Anvil (Anthropic · OpenAI · Google).',
1860
+ ' Optional: --base <ref> | "<prompt>". Quota: 1 slot per call.',
561
1861
  ' pugi review --consensus 3-model consensus review (codex · claude · deepseek).',
562
1862
  ' Optional: --commit <sha> | --pr <num> | --branch <name>.',
563
1863
  ' Exits 0 PASS · 1 WARN · 2 BLOCK.',
@@ -573,6 +1873,17 @@ async function help(_args, flags, _session) {
573
1873
  ' pugi ask "<question>" Surface a yes/no question modal locally.',
574
1874
  ' pugi plan-review <task> Generate + present a plan-review modal.',
575
1875
  '',
1876
+ 'Persona dispatch (α7.5):',
1877
+ ' pugi roster List the live Tier 1 personas + roles.',
1878
+ ' pugi delegate <slug> "<brief>" Dispatch a brief to one specialist.',
1879
+ ' pugi dispatch list-cache-refs Inspect fork-subagent prompt-cache inherit refs.',
1880
+ ' pugi dispatch clear-cache-refs GC stale cache refs (--older-than 1h).',
1881
+ '',
1882
+ 'Plan decomposition (α6.8):',
1883
+ ' pugi plan --decompose <idea> Split a high-level idea into 3-7 components.',
1884
+ ' Writes .pugi/plan/<session-id>/splits/NN-<name>/spec.md',
1885
+ ' plus manifest.md with the dependency DAG.',
1886
+ '',
576
1887
  'Deploy:',
577
1888
  ' pugi deploy --target vercel <vercelProject> --project <id>',
578
1889
  ' Trigger a Vercel deployment from the bound Git source.',
@@ -595,75 +1906,302 @@ async function help(_args, flags, _session) {
595
1906
  ' PUGI_SKIP_SPLASH=1.',
596
1907
  ' --no-tool-stream Hide the live tool stream pane (α6.12).',
597
1908
  ' Pairs with PUGI_HIDE_TOOL_STREAM=1.',
1909
+ ' --no-defaults Skip bundled default-skills install on',
1910
+ ' `pugi init`. Pairs with PUGI_INIT_NO_DEFAULTS=1.',
1911
+ ' --bare Disable project auto-discovery — no PUGI.md /',
1912
+ ' AGENTS.md / CLAUDE.md / GEMINI.md walk-up, no',
1913
+ ' auto-init of .pugi/, no persona auto-load.',
1914
+ ' Pairs with PUGI_BARE=1.',
598
1915
  '',
599
1916
  PUGI_TAGLINE,
600
1917
  'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
601
1918
  ].join('\n'));
602
1919
  }
1920
+ /**
1921
+ * `pugi doctor` — Leak L17 (2026-05-27). Delegates to the diagnostics
1922
+ * probe runner in `runtime/commands/doctor.ts`. The handler stays
1923
+ * thin so the probe surface stays single-sourced between the CLI
1924
+ * shell command, the `pnpm run doctor --json` package script, and
1925
+ * the in-REPL `/doctor` slash command.
1926
+ *
1927
+ * Exit codes are set by `runDoctorCommand` (0 = healthy/warnings,
1928
+ * 2 = at least one error probe). The pre-L17 minimal doctor surface
1929
+ * (adapter capabilities + schema bundle hash) is preserved under
1930
+ * `payload.meta.legacy` so any operator scripts that grep the JSON
1931
+ * keep working through the transition; the field is marked for
1932
+ * removal in a follow-up sprint once the new shape is the
1933
+ * documented contract.
1934
+ */
603
1935
  async function doctor(_args, flags, _session) {
604
- const cwd = process.cwd();
605
- const settings = loadSettings(cwd);
606
- // `doctor` reports adapter capabilities only; we pass a no-op client
607
- // so we do not require an Anvil endpoint to run `pugi doctor`. The
608
- // adapter never invokes `client.send()` from inside `capabilities()`.
609
- const inertClient = {
610
- async send() {
611
- return {
612
- stop: 'error',
613
- code: 'failed',
614
- message: 'doctor: inert client',
615
- };
1936
+ await runDoctorCommand({
1937
+ cwd: process.cwd(),
1938
+ home: defaultDoctorHome(),
1939
+ env: process.env,
1940
+ json: flags.json,
1941
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
1942
+ });
1943
+ }
1944
+ /**
1945
+ * `pugi prd-check` — Wave 6 verified-deliverable gate (2026-05-27).
1946
+ *
1947
+ * Reads `docs/prd/<feature>.md` (or any explicit path), parses the
1948
+ * acceptance-criteria section, and runs file / test / doc / command
1949
+ * / route verifiers per criterion. Same handler powers the in-REPL
1950
+ * `/prd-check` slash via session.ts so the verdict is identical
1951
+ * between surfaces.
1952
+ *
1953
+ * The `knownCommands` set is sourced from the same `handlers` map
1954
+ * used by the CLI dispatcher (one source of truth), so a PRD that
1955
+ * mentions `pugi <name>` resolves against the EXACT registry the
1956
+ * shell exposes.
1957
+ *
1958
+ * Exit codes (from reporter.exitCodeFor):
1959
+ * 0 — healthy (every criterion PASS or SKIPPED)
1960
+ * 1 — failing (≥1 FAIL)
1961
+ * 2 — unparsed (PRD has no acceptance section) OR arg error
1962
+ */
1963
+ async function dispatchPrdCheck(args, flags, _session) {
1964
+ const parsed = parsePrdCheckArgs(args, { jsonDefault: flags.json });
1965
+ if (!parsed.ok) {
1966
+ writeOutput(flags, { ok: false, error: parsed.error }, parsed.error);
1967
+ process.exitCode = 2;
1968
+ return;
1969
+ }
1970
+ await runPrdCheckCommand({
1971
+ cwd: process.cwd(),
1972
+ ...(parsed.prdPath !== undefined ? { prdPath: parsed.prdPath } : {}),
1973
+ flags: parsed.flags,
1974
+ knownCommands: knownCommandNames(),
1975
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
1976
+ });
1977
+ }
1978
+ /**
1979
+ * Snapshot the set of registered CLI command names — used by the
1980
+ * prd-check `command:` verifier so PRD mentions of `pugi <name>`
1981
+ * resolve against the exact same registry the shell exposes.
1982
+ */
1983
+ function knownCommandNames() {
1984
+ return new Set(Object.keys(handlers));
1985
+ }
1986
+ /**
1987
+ * `pugi update` — Leak L27 (2026-05-27). Channel-aware npm registry
1988
+ * probe + optional shell-out to `npm install -g @pugi/cli@<tag>`.
1989
+ *
1990
+ * Argument grammar:
1991
+ * pugi update -> probe + offer install command
1992
+ * pugi update --check -> probe + JSON envelope (scripted)
1993
+ * pugi update --channel <name> -> persist channel + probe
1994
+ * pugi update --apply [--yes] -> probe + shell out to npm
1995
+ * pugi update --json -> JSON envelope (any subcommand)
1996
+ *
1997
+ * The handler delegates to `runUpdateCommand` in
1998
+ * `runtime/commands/update.ts` so the in-REPL `/update` slash + the
1999
+ * top-level shell command share one channel-resolution + persistence
2000
+ * + probe surface. Exit codes:
2001
+ *
2002
+ * 0 — happy path (no update OR update completed OR probe-only)
2003
+ * 1 — install / probe failure with structured error
2004
+ * 2 — argument error (unknown flag, unknown channel)
2005
+ */
2006
+ async function dispatchUpdate(args, flags, _session) {
2007
+ const { parseUpdateArgs, runUpdateCommand, defaultSpawnInstaller } = await import('./commands/update.js');
2008
+ const parsed = parseUpdateArgs(args, { jsonDefault: flags.json });
2009
+ if ('error' in parsed) {
2010
+ writeOutput(flags, { ok: false, error: parsed.error }, parsed.error);
2011
+ process.exitCode = 2;
2012
+ return;
2013
+ }
2014
+ const envelope = await runUpdateCommand({
2015
+ cwd: process.cwd(),
2016
+ home: homedir(),
2017
+ env: process.env,
2018
+ flags: parsed,
2019
+ promptConfirm: async (question) => {
2020
+ const answer = await readSingleChoice(`${question} `);
2021
+ return /^y(es)?$/i.test(answer.trim());
616
2022
  },
617
- };
618
- const adapters = [
619
- new NoopEngineAdapter(),
620
- new NativePugiEngineAdapter({ client: inertClient }),
621
- ];
622
- const capabilities = await Promise.all(adapters.map(async (adapter) => ({
623
- name: adapter.name,
624
- capabilities: await adapter.capabilities(),
625
- })));
626
- const payload = {
2023
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
2024
+ spawnInstaller: defaultSpawnInstaller,
2025
+ });
2026
+ if (!envelope.ok) {
2027
+ // `apply_cancelled_by_operator` is a benign decline; we still
2028
+ // surface a non-zero exit so scripted callers can detect that the
2029
+ // operator did not green-light the install.
2030
+ process.exitCode = 1;
2031
+ }
2032
+ }
2033
+ /**
2034
+ * `pugi status` — Leak L34 (2026-05-27). Concise session-state probe
2035
+ * mirroring Claude Code's `/status`. Distinct from `pugi doctor`
2036
+ * (environment health) — `status` answers "what is THIS Pugi
2037
+ * session doing right now?" with session id + age, cwd, permission
2038
+ * mode, CLI version, token usage, dispatch count, last command,
2039
+ * compact boundaries, and auth identity.
2040
+ *
2041
+ * The top-level shell invocation has no live REPL state — fields
2042
+ * that need a live session (`tokens`, `lastCommand`) degrade к the
2043
+ * `n/a` sentinel. The same handler powers the in-REPL `/status`
2044
+ * slash, which passes live state through `StatusCommandContext`.
2045
+ *
2046
+ * Always exits 0 — the command is informational, never a gate.
2047
+ */
2048
+ async function status(_args, flags, _session) {
2049
+ await runStatusCommand({
2050
+ cwd: process.cwd(),
2051
+ home: defaultStatusHome(),
2052
+ env: process.env,
2053
+ json: flags.json,
2054
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
2055
+ });
2056
+ }
2057
+ /**
2058
+ * `pugi stickers` — Leak L33 (2026-05-27). Brand-personality gimmick
2059
+ * mirroring Claude Code's `/stickers` easter egg. Picks one curated
2060
+ * pug-face ASCII variant at random + footers it with a rotating quote
2061
+ * from the Pugi brand corpus. Always exits 0 — never a gate.
2062
+ *
2063
+ * The handler stays thin: corpus + picker + pure renderers live in
2064
+ * `tui/stickers-art.tsx`; this wrapper just hands the resolved result
2065
+ * к the shared `writeOutput` helper so `--json` keeps producing a
2066
+ * structured envelope (id + caption + quote + meta) for scripted
2067
+ * callers. The `--ascii-only` flag drops the box decoration in the
2068
+ * non-JSON path so pipes (`pugi stickers --ascii-only | lolcat`) get
2069
+ * clean plain-text frames.
2070
+ *
2071
+ * The same handler powers the in-REPL `/stickers` slash, which routes
2072
+ * the text through the conversation system pane line-buffer.
2073
+ */
2074
+ async function stickers(_args, flags, _session) {
2075
+ runStickersCommand({
2076
+ json: flags.json,
2077
+ asciiOnly: flags.asciiOnly,
2078
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
2079
+ });
2080
+ }
2081
+ /**
2082
+ * `pugi repo-map` — Leak L28 (2026-05-27). Builds + caches the AST-
2083
+ * light symbol summary of the workspace. The handler is intentionally
2084
+ * thin: argv tail tokens are honoured for `--refresh` symmetry (the
2085
+ * global parser already sets `flags.refresh`, but accepting the flag
2086
+ * positionally lets `pugi repo-map refresh` work too — both forms
2087
+ * land в the same path). Exit code is always 0 (informational).
2088
+ *
2089
+ * The same builder is invoked lazily on engine boot when `--bare` is
2090
+ * not set; running the CLI command shows the operator EXACTLY what
2091
+ * the engine would inject into the system prompt.
2092
+ */
2093
+ async function dispatchRepoMap(args, flags, _session) {
2094
+ const refresh = flags.refresh || args.includes('--refresh') || args.includes('refresh');
2095
+ await runRepoMapCommand({
2096
+ cwd: process.cwd(),
2097
+ refresh,
2098
+ json: flags.json,
2099
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
2100
+ });
2101
+ }
2102
+ /**
2103
+ * `pugi feedback` — Leak L21 (2026-05-27). In-CLI feedback collector.
2104
+ *
2105
+ * Five-step wizard:
2106
+ * 1. category (bug / feature / general / praise)
2107
+ * 2. rating (1-5)
2108
+ * 3. comment (multi-line, Ctrl-D submits)
2109
+ * 4. include redacted session context? (y/n, default n)
2110
+ * 5. confirm submit (y/n, default y)
2111
+ *
2112
+ * POSTs to `<apiUrl>/api/pugi/feedback`. On transient failure (404,
2113
+ * 5xx, network error) the envelope is appended to
2114
+ * `<cwd>/.pugi/feedback-queue.jsonl`. On next online session the
2115
+ * background flusher drains the queue silently.
2116
+ *
2117
+ * Non-TTY callers (CI, pipes) get a one-line "non-interactive — re-run
2118
+ * in a real terminal" stub. The feedback wizard is intentionally
2119
+ * TTY-only — scripting a star-rating + multi-line comment from a
2120
+ * shell pipe would just produce low-signal noise.
2121
+ */
2122
+ async function dispatchFeedback(_args, flags, _session) {
2123
+ if (!isInteractive(flags)) {
2124
+ writeOutput(flags, {
2125
+ ok: false,
2126
+ error: 'pugi feedback requires an interactive terminal. Re-run from a real TTY.',
2127
+ }, 'pugi feedback: non-interactive shell — re-run from a real terminal.');
2128
+ process.exitCode = 2;
2129
+ return;
2130
+ }
2131
+ const { renderFeedbackPrompt } = await import('../tui/feedback-prompt.js');
2132
+ const { runFeedbackCommand, renderFeedbackToast } = await import('./commands/feedback.js');
2133
+ const { submitFeedback } = await import('../core/feedback/submitter.js');
2134
+ const verdict = await renderFeedbackPrompt();
2135
+ if (verdict.cancelled || !verdict.draft) {
2136
+ writeOutput(flags, { ok: true, kind: 'cancelled' }, 'Feedback cancelled. Nothing was sent.');
2137
+ return;
2138
+ }
2139
+ // Best-effort credential resolution. Anonymous submission is allowed
2140
+ // (the server may still accept it for ungated `/api/pugi/feedback`
2141
+ // routes); on no-credential we route the POST through an empty
2142
+ // bearer + the operator gets the 4xx → "rejected" toast if the
2143
+ // server requires auth.
2144
+ const credential = resolveActiveCredential(process.env);
2145
+ const apiUrl = credential?.apiUrl ?? (process.env.PUGI_API_URL || 'https://api.pugi.io');
2146
+ const apiKey = credential?.apiKey ?? '';
2147
+ const result = await runFeedbackCommand({
2148
+ cwd: process.cwd(),
627
2149
  cliVersion: PUGI_CLI_VERSION,
628
- nodeVersion: process.version,
629
- workspaceRoot: cwd,
630
- pugiMode: existsSync(resolve(cwd, 'CLAUDE.md')),
631
- pugiDir: existsSync(resolve(cwd, '.pugi')),
632
- eventLog: existsSync(resolve(cwd, '.pugi/events.jsonl')),
633
- permissionMode: settings.permissions.mode,
634
- approvals: settings.workflow.approvals,
635
- notAutomatic: [...settings.workflow.notAutomatic, ...settings.permissions.notAutomatic],
636
- protectedFileCheck: decidePermission({ tool: 'doctor', kind: 'edit', target: '.env' }, settings, cwd),
637
- protectedFileSafety: 'configured-in-m1',
638
- mcpTrust: 'not-configured',
639
- releaseGuard: 'scaffolded',
640
- tools: toolRegistry,
641
- engineAdapters: capabilities,
642
- schemaBundleHash: createHash('sha256')
643
- .update(toolSchemaBundleHashInput())
644
- .digest('hex'),
645
- };
646
- writeOutput(flags, payload, [
647
- 'Pugi doctor',
648
- `CLI: ${payload.cliVersion}`,
649
- `Node: ${payload.nodeVersion}`,
650
- `Workspace: ${payload.workspaceRoot}`,
651
- `Pugi mode: ${payload.pugiMode ? 'detected' : 'not detected'}`,
652
- `Pugi dir: ${payload.pugiDir ? 'present' : 'missing'}`,
653
- `Event log: ${payload.eventLog ? 'present' : 'missing'}`,
654
- `Permission mode: ${payload.permissionMode}`,
655
- `Approvals: ${payload.approvals}`,
656
- `Release guard: ${payload.releaseGuard}`,
657
- ].join('\n'));
2150
+ submit: async (env) => submitFeedback(env, { apiUrl, apiKey }),
2151
+ draft: verdict.draft,
2152
+ // `pugi feedback` from a fresh shell has no live transcript — the
2153
+ // session-context provider is omitted. The REPL slash variant
2154
+ // wires this in via `runFeedbackSlash` (session.ts).
2155
+ });
2156
+ writeOutput(flags, { ok: true, result }, renderFeedbackToast(result));
658
2157
  }
659
- async function init(_args, flags, _session) {
660
- const cwd = process.cwd();
2158
+ /**
2159
+ * `pugi release-notes` — Leak L24 (2026-05-27). Diff between the
2160
+ * last-seen + installed CLI versions, rendered from the bundled
2161
+ * `apps/pugi-cli/CHANGELOG.md`. Bumps `~/.pugi/.last-seen-version`
2162
+ * to the installed version on every successful render so the next
2163
+ * invocation is a no-op until the operator upgrades again.
2164
+ *
2165
+ * The handler stays thin: parser, slicer, and state I/O all live in
2166
+ * `core/release-notes/`. This wrapper just hands ambient state to
2167
+ * `runReleaseNotesCommand` so `--json` keeps producing the same
2168
+ * envelope from both the top-level shell + the in-REPL `/release-notes`
2169
+ * slash dispatcher.
2170
+ *
2171
+ * Always exits 0 — the command is informational, never a gate. Read
2172
+ * failures, missing CHANGELOG, and write failures all degrade to a
2173
+ * structured envelope with a human-readable footer.
2174
+ */
2175
+ async function releaseNotes(_args, flags, _session) {
2176
+ runReleaseNotesCommand({
2177
+ home: defaultReleaseNotesHome(),
2178
+ json: flags.json,
2179
+ reset: flags.reset,
2180
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
2181
+ });
2182
+ }
2183
+ /**
2184
+ * Programmatic init scaffolder. Idempotent — every helper call is a
2185
+ * `*_IfMissing` write, so re-running over an existing .pugi/ workspace
2186
+ * adds nothing to `created` and the operator sees the "Already
2187
+ * initialized" copy. Default skills install is best-effort: failure
2188
+ * does not throw, the error is appended to the result via stderr so
2189
+ * the slash dispatcher can surface it in the REPL system pane.
2190
+ *
2191
+ * Callers MUST provide `cwd` explicitly; the function does not read
2192
+ * `process.cwd()` so REPL invocations from an arbitrary workspace
2193
+ * cannot accidentally scaffold the binary's install directory.
2194
+ */
2195
+ export async function scaffoldPugiWorkspace(input) {
2196
+ const cwd = input.cwd;
2197
+ const log = input.log ?? ((line) => process.stderr.write(line));
661
2198
  const pugiDir = resolve(cwd, '.pugi');
662
2199
  const created = [];
663
2200
  const skipped = [];
664
2201
  ensureDir(pugiDir, created, skipped);
665
2202
  ensureDir(resolve(pugiDir, 'artifacts'), created, skipped);
666
2203
  ensureDir(resolve(pugiDir, 'sessions'), created, skipped);
2204
+ ensureDir(resolve(pugiDir, 'skills'), created, skipped);
667
2205
  writeJsonIfMissing(resolve(pugiDir, 'settings.json'), {
668
2206
  schema: 1,
669
2207
  workflow: {
@@ -685,6 +2223,9 @@ async function init(_args, flags, _session) {
685
2223
  mode: 'balanced',
686
2224
  telemetry: 'off',
687
2225
  },
2226
+ ui: {
2227
+ cyberZoo: 'on',
2228
+ },
688
2229
  artifacts: {
689
2230
  defaultPath: '.pugi/artifacts',
690
2231
  promoteExplicitly: true,
@@ -692,7 +2233,19 @@ async function init(_args, flags, _session) {
692
2233
  }, created, skipped);
693
2234
  writeJsonIfMissing(resolve(pugiDir, 'mcp.json'), {
694
2235
  schema: 1,
695
- servers: [],
2236
+ // 2026-05-27 dogfood: `servers` MUST be an object keyed by server
2237
+ // name (z.record(mcpServerConfigSchema) in
2238
+ // apps/pugi-cli/src/core/mcp/registry.ts:51). A bare `[]` array
2239
+ // here passed schema validation на pugi init exit но crashed
2240
+ // the next dispatch with
2241
+ // "MCP config at .pugi/mcp.json failed validation:
2242
+ // servers: Expected object, received array"
2243
+ // and the operator's first command after `pugi init` printed an
2244
+ // error banner before the actual reply. Empty object matches the
2245
+ // schema default and keeps the file forwards-compatible with
2246
+ // `pugi mcp install <name> ...` which merges into the same
2247
+ // record shape.
2248
+ servers: {},
696
2249
  }, created, skipped);
697
2250
  writeJsonIfMissing(resolve(pugiDir, 'index.json'), emptyIndex(), created, skipped);
698
2251
  writeTextIfMissing(resolve(pugiDir, 'PUGI.md'), [
@@ -733,17 +2286,67 @@ async function init(_args, flags, _session) {
733
2286
  // Ensure `.pugi/` is git-ignored so users do not accidentally commit
734
2287
  // local audit logs, artifacts, or triple-review request payloads.
735
2288
  ensurePugiGitIgnore(cwd, created, skipped);
736
- const payload = {
2289
+ // Bundled default skills (brand-voice, endpoint-probe, readme-sync).
2290
+ // Skipped when --no-defaults is passed OR when PUGI_INIT_NO_DEFAULTS=1.
2291
+ // Idempotent: a skill whose target directory already exists is left
2292
+ // alone so re-running `pugi init` after the operator customised one of
2293
+ // the defaults does not clobber their edits.
2294
+ let defaultSkills = [];
2295
+ if (!input.noDefaults) {
2296
+ try {
2297
+ defaultSkills = await installDefaultSkills({
2298
+ workspaceRoot: cwd,
2299
+ log,
2300
+ });
2301
+ }
2302
+ catch (error) {
2303
+ // Default-skills install is a convenience layer. A failure here
2304
+ // (bad sha256 hashing, permission error on .pugi/skills/) must not
2305
+ // leave `pugi init` in a half-state where settings.json exists but
2306
+ // the operator sees an unexplained crash. Log the error to stderr
2307
+ // and continue — the operator can still install skills manually.
2308
+ const message = error instanceof Error ? error.message : String(error);
2309
+ log(`[pugi init] default-skills install failed: ${message}\n`);
2310
+ }
2311
+ }
2312
+ return {
737
2313
  status: 'initialized',
738
2314
  root: cwd,
739
2315
  created,
740
2316
  skipped,
2317
+ defaultSkills,
2318
+ alreadyInitialized: created.length === 0,
741
2319
  };
742
- writeOutput(flags, payload, [
2320
+ }
2321
+ /**
2322
+ * Standalone `pugi init` CLI entry. Thin wrapper around
2323
+ * `scaffoldPugiWorkspace` that handles flag plumbing + writeOutput
2324
+ * formatting. β1a r1: extracted from the previous inline init so the
2325
+ * REPL's `/init` slash can call `scaffoldPugiWorkspace` directly.
2326
+ */
2327
+ async function init(_args, flags, _session) {
2328
+ const result = await scaffoldPugiWorkspace({
2329
+ cwd: process.cwd(),
2330
+ noDefaults: flags.noDefaults,
2331
+ });
2332
+ const defaultSkillLines = flags.noDefaults
2333
+ ? ['Default skills: skipped (--no-defaults)']
2334
+ : result.defaultSkills.length === 0
2335
+ ? ['Default skills: none installed']
2336
+ : [
2337
+ 'Default skills:',
2338
+ ...result.defaultSkills.map((entry) => ` ${entry.name.padEnd(18)} ${entry.status}`),
2339
+ ];
2340
+ writeOutput(flags, result, [
743
2341
  'Pugi initialized',
744
- `Root: ${cwd}`,
745
- created.length ? `Created:\n${created.map((path) => ` ${path}`).join('\n')}` : 'Created: none',
746
- skipped.length ? `Already present:\n${skipped.map((path) => ` ${path}`).join('\n')}` : 'Already present: none',
2342
+ `Root: ${result.root}`,
2343
+ result.created.length
2344
+ ? `Created:\n${result.created.map((path) => ` ${path}`).join('\n')}`
2345
+ : 'Created: none',
2346
+ result.skipped.length
2347
+ ? `Already present:\n${result.skipped.map((path) => ` ${path}`).join('\n')}`
2348
+ : 'Already present: none',
2349
+ ...defaultSkillLines,
747
2350
  ].join('\n'));
748
2351
  }
749
2352
  async function idea(args, flags, session) {
@@ -1064,10 +2667,20 @@ async function review(args, flags, session) {
1064
2667
  // streaming UX and rubric-driven exit codes don't disturb the existing
1065
2668
  // pugi-cli surfaces that depend on the old shape.
1066
2669
  if (flags.consensus) {
2670
+ // 2026-05-27 (Codex r0 P1 on PR #489): pass the globally-parsed
2671
+ // --commit / --base flags to consensus so `pugi review --consensus
2672
+ // --commit X` reviews the requested SHA instead of silently falling
2673
+ // back to the working-tree diff. parseConsensusArgs gives the inline
2674
+ // args (`--commit Y` after the command name) precedence; the
2675
+ // fallback only fires when `args` does not carry the token.
1067
2676
  const exitCode = await runReviewConsensus(args, {
1068
2677
  cwd: root,
1069
2678
  config: resolveRuntimeConfig(),
1070
2679
  json: flags.json,
2680
+ flagsFallback: {
2681
+ ...(flags.commit ? { commit: flags.commit } : {}),
2682
+ ...(flags.base ? { base: flags.base } : {}),
2683
+ },
1071
2684
  emit: (line) => {
1072
2685
  if (!flags.json)
1073
2686
  process.stdout.write(line);
@@ -1079,6 +2692,15 @@ async function review(args, flags, session) {
1079
2692
  process.exitCode = exitCode;
1080
2693
  return;
1081
2694
  }
2695
+ if (flags.triple && flags.commit) {
2696
+ // CEO directive 2026-05-27: `pugi review --triple --commit <SHA>`
2697
+ // dispatches to the customer-facing 3-model consensus path through
2698
+ // Anvil's already-paid Anthropic / OpenAI / Google routes. Replaces
2699
+ // the dev-only Codex/Claude/Gemini OAuth CLIs the `/triple-review`
2700
+ // skill uses.
2701
+ await performTripleProviderReview(root, session, flags, prompt);
2702
+ return;
2703
+ }
1082
2704
  if (flags.triple && flags.remote) {
1083
2705
  await performRemoteTripleReview(root, session, flags, prompt);
1084
2706
  return;
@@ -1516,6 +3138,307 @@ async function performRemoteTripleReview(root, session, flags, prompt) {
1516
3138
  .join('\n'));
1517
3139
  process.exitCode = outcome.exitCode;
1518
3140
  }
3141
+ /**
3142
+ * `pugi review --triple --commit <SHA>` — customer-facing 3-model
3143
+ * consensus review via Anvil multi-provider routing.
3144
+ *
3145
+ * Dispatches the same diff to Anthropic / OpenAI / Google models
3146
+ * (routed through Anvil's already-paid fleet, NOT OAuth-bound dev
3147
+ * CLIs) and renders the per-reviewer verdict + cross-model
3148
+ * disagreement summary at the end. Quota: one `reviewPerMonth` slot
3149
+ * per call regardless of provider count — the controller-level
3150
+ * `@QuotaGated('reviewPerMonth')` decorator enforces single-slot
3151
+ * debit (see apps/admin-api/src/pugi/pugi.controller.ts).
3152
+ *
3153
+ * CEO directive 2026-05-27: replaces the dev-only `/triple-review`
3154
+ * skill's Codex/Claude/Gemini OAuth dependency with a customer-
3155
+ * runnable Pugi product surface. Dogfood loop: Pugi reviews Pugi PRs.
3156
+ */
3157
+ async function performTripleProviderReview(root, session, flags, prompt) {
3158
+ const config = resolveRuntimeConfig();
3159
+ const artifactDir = createArtifactDir(root, prompt || 'triple-providers');
3160
+ const requestPath = resolve(artifactDir, 'triple-review-request.json');
3161
+ const resultPath = resolve(artifactDir, 'triple-review-result.json');
3162
+ const summaryPath = resolve(artifactDir, 'triple-review.md');
3163
+ const toolCallId = recordToolCall(session, 'review:triple-providers', prompt || `review ${flags.commit ?? 'HEAD'} via providers`);
3164
+ // Resolve base ref. CLI flag wins over settings → so an operator
3165
+ // can target a specific integration branch without editing settings.
3166
+ const settings = loadSettings(root);
3167
+ const baseRef = flags.base ?? resolveBaseRef(root, settings) ?? 'origin/main';
3168
+ // Normalise both the commit and the base to short SHAs so the audit
3169
+ // log stores a stable reference even if branches move.
3170
+ const commitRef = flags.commit ?? 'HEAD';
3171
+ // 2026-05-27 (Codex r0 P2 on PR #489): safeGit returns '' on a bad ref
3172
+ // (it swallows the git exit code so callers don't have to wrap every
3173
+ // probe). Without an explicit refusal, a misspelled --commit or --base
3174
+ // produced an EMPTY diff that the gate then PASSED — operators saw a
3175
+ // green review for changes that were never reviewed. Resolve both refs
3176
+ // through `rev-parse --verify` first; an empty result is a hard error.
3177
+ const verifiedCommit = safeGit(root, ['rev-parse', '--verify', commitRef]).trim();
3178
+ if (!verifiedCommit) {
3179
+ throw new Error(`pugi review --triple: cannot resolve --commit '${commitRef}' — ` +
3180
+ `check the SHA or branch name. ` +
3181
+ `Refusing to submit an empty diff for review.`);
3182
+ }
3183
+ const verifiedBase = safeGit(root, ['rev-parse', '--verify', baseRef]).trim();
3184
+ if (!verifiedBase) {
3185
+ throw new Error(`pugi review --triple: cannot resolve --base '${baseRef}' — ` +
3186
+ `check the ref or set base via 'pugi config set review.base=<ref>'. ` +
3187
+ `Refusing to submit an empty diff for review.`);
3188
+ }
3189
+ const resolvedCommit = safeGit(root, ['rev-parse', '--short', commitRef]).trim() || commitRef;
3190
+ // merge-base is intentionally a PROBE: an empty result is a valid
3191
+ // signal (orphan branch, shallow clone, moved tag) that the dispatch
3192
+ // path handles by falling back к range-notation. Use the legacy
3193
+ // `safeGit` (probe semantics) explicitly rather than the strict
3194
+ // variant.
3195
+ const mergeBase = safeGitProbe(root, ['merge-base', baseRef, commitRef]).trim() || '';
3196
+ // 2026-05-27 (Claude review followup #489): when merge-base returns empty
3197
+ // (orphan branch, shallow clone, moved tag), we MUST NOT pass the
3198
+ // `<range> <commitRef>` two-arg form to `git diff` — that combo is
3199
+ // invalid syntax, git exits 129, `safeGit` swallows the error, and the
3200
+ // diff payload ships empty. An empty diff is then classified as
3201
+ // `'code'` server-side, dispatched to reviewers who emit a trivial
3202
+ // `VERDICT: PASS` over zero lines — a SILENT GREEN REVIEW on a commit
3203
+ // nobody actually examined. Branch on `mergeBase` так что:
3204
+ // - mergeBase present → `git diff <mergeBase> <commitRef> --`
3205
+ // (both endpoints explicit, only-uncommitted-against-base ignored
3206
+ // because commitRef is a SHA, not HEAD).
3207
+ // - mergeBase empty → `git diff <baseRef>..<commitRef> --`
3208
+ // (range form encodes both endpoints; do NOT append commitRef
3209
+ // again or git rejects the args).
3210
+ const diffRange = mergeBase || `${baseRef}..${commitRef}`;
3211
+ const diffArgs = mergeBase
3212
+ ? ['diff', mergeBase, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES]
3213
+ : ['diff', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
3214
+ const diffStatArgs = mergeBase
3215
+ ? ['diff', '--shortstat', mergeBase, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES]
3216
+ : ['diff', '--shortstat', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
3217
+ // Use the strict variant — a non-empty diffPatch is load-bearing for
3218
+ // the review gate. If git fails for ANY reason (bad ref, ENOBUFS, FS
3219
+ // permission), we'd rather surface a hard error than ship a green
3220
+ // review on nothing. The `--shortstat` companion uses the same
3221
+ // helper so the throw is symmetric.
3222
+ const diffPatch = safeGitRequired(root, diffArgs, 'triple-providers diff');
3223
+ const diffStats = parseDiffStats(safeGitRequired(root, diffStatArgs, 'triple-providers diff --shortstat'));
3224
+ if (diffPatch.trim() === '') {
3225
+ throw new Error(`pugi review --triple: empty diff between '${baseRef}' and '${commitRef}'. ` +
3226
+ `Refusing to dispatch a review for zero changes — check the refs ` +
3227
+ `or commit your changes before running.`);
3228
+ }
3229
+ const requestBody = pugiTripleReviewRequestSchema.parse({
3230
+ schema: 1,
3231
+ workspace: {
3232
+ rootName: root.split('/').at(-1) ?? 'workspace',
3233
+ gitBranch: safeGit(root, ['branch', '--show-current']).trim() || null,
3234
+ gitHead: resolvedCommit || null,
3235
+ baseRef,
3236
+ dirty: Boolean(safeGit(root, ['status', '--short']).trim()),
3237
+ },
3238
+ diffPatch,
3239
+ diffStats,
3240
+ prompt: prompt || undefined,
3241
+ locale: 'en-US',
3242
+ reviewerPersona: 'oes-dev',
3243
+ commit: resolvedCommit,
3244
+ modelProviders: ['claude', 'gpt', 'gemini'],
3245
+ });
3246
+ writeFileSync(requestPath, `${JSON.stringify(requestBody, null, 2)}\n`, {
3247
+ encoding: 'utf8',
3248
+ mode: 0o600,
3249
+ });
3250
+ registerArtifact(root, {
3251
+ id: artifactIdFromDir(artifactDir),
3252
+ kind: 'triple-review',
3253
+ path: relative(root, artifactDir),
3254
+ sessionId: session.id,
3255
+ createdAt: new Date().toISOString(),
3256
+ files: ['triple-review-request.json'],
3257
+ });
3258
+ if (!config) {
3259
+ const reason = 'No active Pugi credentials. Run `pugi login --token <PAT>` or set PUGI_API_KEY for CI use.';
3260
+ recordToolResult(session, toolCallId, 'error', reason);
3261
+ writeFileSync(summaryPath, buildTripleReviewMarkdown({
3262
+ prompt,
3263
+ requestPath: relative(root, requestPath),
3264
+ verdict: null,
3265
+ reason,
3266
+ response: null,
3267
+ }), { encoding: 'utf8', mode: 0o600 });
3268
+ writeOutput(flags, {
3269
+ status: 'auth_missing',
3270
+ request: relative(root, requestPath),
3271
+ summary: relative(root, summaryPath),
3272
+ }, [
3273
+ 'Pugi triple-provider review request prepared but not sent — no active credentials.',
3274
+ `Request: ${relative(root, requestPath)}`,
3275
+ `Run \`pugi login --token <PAT>\` (or export PUGI_API_KEY for CI) then retry \`pugi review --triple --commit ${resolvedCommit}\`.`,
3276
+ ].join('\n'));
3277
+ process.exitCode = 5;
3278
+ return;
3279
+ }
3280
+ const submitResult = await submitTripleReview(config, requestBody);
3281
+ if (submitResult.status !== 'ok') {
3282
+ const outcome = describeSubmitFailure(submitResult);
3283
+ writeFileSync(summaryPath, buildTripleReviewMarkdown({
3284
+ prompt,
3285
+ requestPath: relative(root, requestPath),
3286
+ verdict: null,
3287
+ reason: outcome.message,
3288
+ response: null,
3289
+ }), { encoding: 'utf8', mode: 0o600 });
3290
+ recordToolResult(session, toolCallId, 'error', outcome.message);
3291
+ writeOutput(flags, {
3292
+ status: submitResult.status,
3293
+ code: submitResult.code,
3294
+ message: outcome.message,
3295
+ request: relative(root, requestPath),
3296
+ summary: relative(root, summaryPath),
3297
+ }, [
3298
+ outcome.headline,
3299
+ `Request: ${relative(root, requestPath)}`,
3300
+ `Summary: ${relative(root, summaryPath)}`,
3301
+ outcome.next ? `Next: ${outcome.next}` : '',
3302
+ ]
3303
+ .filter(Boolean)
3304
+ .join('\n'));
3305
+ process.exitCode = outcome.exitCode;
3306
+ return;
3307
+ }
3308
+ const response = submitResult.response;
3309
+ persistTripleReviewResult(resultPath, response);
3310
+ writeFileSync(summaryPath, buildTripleReviewMarkdown({
3311
+ prompt,
3312
+ requestPath: relative(root, requestPath),
3313
+ verdict: response.verdict,
3314
+ reason: response.reason,
3315
+ response,
3316
+ }), { encoding: 'utf8', mode: 0o600 });
3317
+ recordToolResult(session, toolCallId, response.verdict === 'BLOCK' ? 'error' : 'success', `Verdict: ${response.verdict} (${response.reason})`);
3318
+ const verdictReport = renderTripleProviderVerdict({
3319
+ response,
3320
+ commit: resolvedCommit,
3321
+ baseRef,
3322
+ });
3323
+ writeOutput(flags, {
3324
+ status: 'completed',
3325
+ verdict: response.verdict,
3326
+ reason: response.reason,
3327
+ counts: response.counts,
3328
+ reviewerCount: response.reviewerCount,
3329
+ effectiveTier: response.effectiveTier,
3330
+ commit: resolvedCommit,
3331
+ baseRef,
3332
+ reviewers: response.reviewers.map((r) => ({
3333
+ provider: r.provider ?? null,
3334
+ model: r.model,
3335
+ declaredVerdict: r.declaredVerdict,
3336
+ findings: r.findings,
3337
+ latencyMs: r.latencyMs,
3338
+ tokensUsed: r.tokensUsed,
3339
+ error: r.error,
3340
+ })),
3341
+ result: relative(root, resultPath),
3342
+ summary: relative(root, summaryPath),
3343
+ }, verdictReport);
3344
+ if (response.verdict === 'BLOCK') {
3345
+ process.exitCode = 9;
3346
+ }
3347
+ else if (response.verdict === 'WARN') {
3348
+ process.exitCode = 1;
3349
+ }
3350
+ }
3351
+ /**
3352
+ * Pretty-printer for the `pugi review --triple --commit <SHA>` verdict.
3353
+ * Mirrors the `/triple-review` skill's verdict block (per-reviewer
3354
+ * counts table → final GATE line → per-reviewer verbatim → cross-
3355
+ * model disagreement summary → tokens/cost note) so the output is
3356
+ * familiar to operators who already use the dev-only skill.
3357
+ */
3358
+ export function renderTripleProviderVerdict(input) {
3359
+ const { response, commit, baseRef } = input;
3360
+ const divider = '═'.repeat(68);
3361
+ const subDivider = '─'.repeat(68);
3362
+ // Per-reviewer counts table.
3363
+ const reviewerRows = response.reviewers.map((reviewer) => {
3364
+ const c = { P0: 0, P1: 0, P2: 0, P3: 0 };
3365
+ for (const f of reviewer.findings)
3366
+ c[f.severity] += 1;
3367
+ const status = reviewer.error
3368
+ ? 'ERROR'
3369
+ : reviewer.declaredVerdict ?? 'UNKNOWN';
3370
+ const label = reviewer.provider
3371
+ ? reviewer.provider.toUpperCase().padEnd(8)
3372
+ : reviewer.model.slice(0, 8).padEnd(8);
3373
+ return ` ${label} ${pad(c.P0)} ${pad(c.P1)} ${pad(c.P2)} ${pad(c.P3)} ${status}`;
3374
+ });
3375
+ // Cross-model disagreement: list severities flagged by 1 of N but not
3376
+ // the others. Surfaces the "highest-signal moment" per the skill.
3377
+ const disagreements = [];
3378
+ const allFindings = response.reviewers.flatMap((r) => r.findings.map((f) => ({
3379
+ provider: r.provider ?? r.model,
3380
+ severity: f.severity,
3381
+ line: f.line,
3382
+ issue: f.issue,
3383
+ })));
3384
+ const p1Flaggers = new Set(response.reviewers
3385
+ .filter((r) => r.findings.some((f) => f.severity === 'P1'))
3386
+ .map((r) => r.provider ?? r.model));
3387
+ if (p1Flaggers.size === 1) {
3388
+ const sole = [...p1Flaggers][0];
3389
+ disagreements.push(`Only ${sole} flagged a P1 — examine the disagreement, often the highest-signal moment.`);
3390
+ }
3391
+ const p0Flaggers = new Set(response.reviewers
3392
+ .filter((r) => r.findings.some((f) => f.severity === 'P0'))
3393
+ .map((r) => r.provider ?? r.model));
3394
+ if (p0Flaggers.size > 0 && p0Flaggers.size < response.reviewers.length) {
3395
+ disagreements.push(`P0 flagged by ${[...p0Flaggers].join(', ')} but not ${response.reviewers
3396
+ .filter((r) => !p0Flaggers.has(r.provider ?? r.model))
3397
+ .map((r) => r.provider ?? r.model)
3398
+ .join(', ')} — verify the finding before merging.`);
3399
+ }
3400
+ // Tokens / cost summary. Tokens are best-effort (some providers
3401
+ // return null). Cost is a placeholder pending billing wire-up; we
3402
+ // surface the quota note inline so the operator knows it counts as
3403
+ // one slot, not three.
3404
+ const totalTokens = response.reviewers.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
3405
+ // Verbatim reviewer outputs. Each section gets a header so operators
3406
+ // can scroll quickly and copy any individual reviewer's text into
3407
+ // their own notes / triage doc.
3408
+ const reviewerSections = response.reviewers.map((reviewer) => {
3409
+ const label = reviewer.provider
3410
+ ? reviewer.provider.toUpperCase()
3411
+ : reviewer.model;
3412
+ const body = reviewer.error
3413
+ ? `(reviewer errored: ${reviewer.error})`
3414
+ : reviewer.rawContent.trim() || '(empty response)';
3415
+ return [subDivider, `${label} SAYS (${reviewer.model}):`, '', body].join('\n');
3416
+ });
3417
+ return [
3418
+ `PUGI TRIPLE-PROVIDER REVIEW — commit ${commit} vs ${baseRef}`,
3419
+ divider,
3420
+ '',
3421
+ ` P0 P1 P2 P3 Status`,
3422
+ ...reviewerRows,
3423
+ '',
3424
+ `GATE: ${response.verdict}`,
3425
+ `Reason: ${response.reason}`,
3426
+ '',
3427
+ ...reviewerSections,
3428
+ '',
3429
+ subDivider,
3430
+ 'CROSS-MODEL DISAGREEMENT:',
3431
+ disagreements.length === 0
3432
+ ? ' (none — all reviewers agreed within rubric tolerance)'
3433
+ : disagreements.map((d) => ` - ${d}`).join('\n'),
3434
+ '',
3435
+ `Tokens: ~${totalTokens} total across ${response.reviewers.length} reviewers`,
3436
+ 'Quota: charged as 1 review slot (multi-provider counts as a single call).',
3437
+ ].join('\n');
3438
+ }
3439
+ function pad(n) {
3440
+ return String(n).padStart(2, ' ');
3441
+ }
1519
3442
  function describeSubmitFailure(result) {
1520
3443
  switch (result.status) {
1521
3444
  case 'endpoint_missing':
@@ -1642,6 +3565,25 @@ async function handoff(args, flags, session) {
1642
3565
  writeOutput(flags, bundle, ['Pugi handoff bundle created', `Bundle: ${bundle.path}`].join('\n'));
1643
3566
  }
1644
3567
  async function sessions(args, flags, _session) {
3568
+ // L9 (2026-05-27): `pugi sessions undo-rewind [<session-id>]` rolls
3569
+ // back the latest /rewind by appending an inverse marker. Append-only,
3570
+ // reversible. Falls through to the legacy artifact-based handler when
3571
+ // the sub-command is not recognised.
3572
+ if (args[0] === 'undo-rewind') {
3573
+ const result = await runSessionsCommand(args, {
3574
+ workspaceRoot: process.cwd(),
3575
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
3576
+ });
3577
+ if (result) {
3578
+ if (result.status === 'failed_no_session' || result.status === 'failed_store') {
3579
+ process.exitCode = 1;
3580
+ }
3581
+ else if (result.status === 'noop_no_rewind') {
3582
+ process.exitCode = 2;
3583
+ }
3584
+ return;
3585
+ }
3586
+ }
1645
3587
  // α6.4: `pugi sessions --local` / `--search "query"` route to the
1646
3588
  // local SessionStore. The default surface stays artifact-based for
1647
3589
  // backward compat — operators who relied on the index.json view get
@@ -2075,6 +4017,33 @@ let engineClientFactory = null;
2075
4017
  export function setEngineClientFactory(factory) {
2076
4018
  engineClientFactory = factory;
2077
4019
  }
4020
+ /**
4021
+ * β-headless test seam: surface the module-scoped engine client factory
4022
+ * to sibling runtime modules (`headless.ts`) so the same fixture
4023
+ * injection that `setEngineClientFactory` provides for the
4024
+ * `runEngineTask` path applies to `pugi --print` runs. Production
4025
+ * callers never read this — the factory is `null` and falls through
4026
+ * to the real `AnvilEngineLoopClient`.
4027
+ */
4028
+ export function getEngineClientFactory() {
4029
+ return engineClientFactory;
4030
+ }
4031
+ /**
4032
+ * β-headless test seam: optional stdout/stderr writers injected for
4033
+ * `pugi --print` runs. When set, the headless runner forwards every
4034
+ * NDJSON line / human-readable chunk to these closures instead of the
4035
+ * real `process.stdout.write` / `process.stderr.write`. Needed because
4036
+ * `node:test`'s worker pool hijacks `process.stdout` for a binary IPC
4037
+ * channel — a captureStdio override would race the runner's frames
4038
+ * and surface as `Unexpected token '\x0F'` JSON parse failures in spec
4039
+ * assertions. Production never sets these.
4040
+ */
4041
+ let headlessStdoutWriter = null;
4042
+ let headlessStderrWriter = null;
4043
+ export function setHeadlessWriters(writers) {
4044
+ headlessStdoutWriter = writers.stdout ?? null;
4045
+ headlessStderrWriter = writers.stderr ?? null;
4046
+ }
2078
4047
  function runEngineTask(kind) {
2079
4048
  return async (args, flags, session) => {
2080
4049
  const label = commandLabel(kind);
@@ -2088,6 +4057,26 @@ function runEngineTask(kind) {
2088
4057
  const config = credential
2089
4058
  ? buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey })
2090
4059
  : envConfig;
4060
+ // α6.8 EXTEND PR1 v2: `--decompose` gating runs BEFORE the offline
4061
+ // fallback. Two reasons:
4062
+ // 1. The flag is plan-only — surfacing the rejection for
4063
+ // `pugi build --decompose` before we drop into `offlineBuild`
4064
+ // means the operator gets a deterministic error instead of a
4065
+ // silent no-op stub.
4066
+ // 2. The decompose post-processor depends on the engine's final
4067
+ // text. The offline plan stub does not invoke the engine, so
4068
+ // `pugi plan --decompose --offline` would silently skip the
4069
+ // decomposition step. Refusing the combination up front is the
4070
+ // cheapest way to keep the contract honest.
4071
+ if (flags.decompose && kind !== 'plan') {
4072
+ throw new Error(`--decompose is only valid for \`pugi plan\` (got \`pugi ${label}\`)`);
4073
+ }
4074
+ if (flags.decompose && flags.offline) {
4075
+ throw new Error('--decompose requires the engine — drop --offline (decomposition needs the model to emit a fenced JSON block)');
4076
+ }
4077
+ if (flags.decompose && !config) {
4078
+ 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)');
4079
+ }
2091
4080
  // Offline fallback: preserves the local-first invariant. `plan` /
2092
4081
  // `build` / `explain` drop back to their pre-Sprint-2 stub
2093
4082
  // behaviour so an operator without an API key (or with --offline)
@@ -2140,214 +4129,401 @@ function runEngineTask(kind) {
2140
4129
  throw new Error(`pugi ${label} requires a prompt`);
2141
4130
  }
2142
4131
  }
4132
+ // α6.8 EXTEND PR1: when `--decompose` is set, augment the user
4133
+ // prompt with the decomposition-request suffix BEFORE the adapter
4134
+ // run. The system prompt for `plan` already constrains the model
4135
+ // to read-only tools + a plan deliverable; the suffix layers the
4136
+ // JSON-emission contract on top so the post-run parser can lift
4137
+ // the structured payload out of the final answer. The plan-only /
4138
+ // engine-required gates fired before the offline fallback above,
4139
+ // so by here we know we are on the engine path with a plan task.
4140
+ if (flags.decompose && kind === 'plan') {
4141
+ prompt = `${prompt}\n${DECOMPOSE_PROMPT_SUFFIX}`;
4142
+ }
2143
4143
  // Narrow `config` for the type checker — the offline branches above
2144
4144
  // return whenever `config` is null, so by this point it must be set.
2145
4145
  if (!config) {
2146
4146
  throw new Error('internal: engine config missing after offline gate');
2147
4147
  }
2148
4148
  const client = engineClientFactory ? engineClientFactory(config) : new AnvilEngineLoopClient(config);
2149
- const adapter = new NativePugiEngineAdapter({ client, session });
4149
+ // β1b r1 (--allow-fetch / --allow-search wiring, 2026-05-26):
4150
+ // forward operator flags to the adapter so the schema-advertise +
4151
+ // executor-dispatch gates see the OR of (settings.json flag, CLI
4152
+ // flag). PR #425 r1 Backend Architect: the comment at
4153
+ // `tool-bridge.ts:740` documented `--allow-fetch` but the flag was
4154
+ // never wired into the adapter constructor — fix lands here.
4155
+ //
4156
+ // β4 r2 P1 #3 — load the MCP registry pre-run so the engine's
4157
+ // tool-bridge advertises every trusted server's tools under
4158
+ // `mcp__<server>__<tool>`. Before this fix the registry was never
4159
+ // loaded in the CLI engine path: `pugi mcp install` + `pugi mcp
4160
+ // trust` ran successfully but `pugi code/explain/fix/build` still
4161
+ // saw zero `mcp__*` tools in the schema (so the feature was
4162
+ // non-functional at the customer-facing surface). The adapter does
4163
+ // NOT own the registry lifecycle — we tear it down in the `finally`
4164
+ // below regardless of outcome so live MCP child processes are
4165
+ // reaped before the CLI exits.
4166
+ //
4167
+ // Failure mode: a bad `.pugi/mcp.json` (corrupted JSON, schema
4168
+ // violation) bubbles as an exception from `loadMcpRegistry`. We
4169
+ // surface it as a warning on stderr and continue WITHOUT MCP — the
4170
+ // operator's `pugi code "..."` invocation should not fail just
4171
+ // because a stale MCP entry refuses to parse. They get the engine
4172
+ // run without `mcp__*` tools and a clear hint to fix the file.
4173
+ let mcpRegistry;
4174
+ try {
4175
+ mcpRegistry = await loadMcpRegistry(root);
4176
+ }
4177
+ catch (error) {
4178
+ process.stderr.write(`pugi ${label}: MCP registry load failed — ${error.message}. ` +
4179
+ `Continuing without MCP tools. Fix .pugi/mcp.json to enable.\n`);
4180
+ mcpRegistry = undefined;
4181
+ }
4182
+ // P1 fix (deep audit 2026-05-26): load the workspace HookRegistry so
4183
+ // `.pugi/hooks/` lifecycle hooks fire for model-initiated tool calls
4184
+ // from the engine loop, not just for direct CLI tool invocations.
4185
+ // SECURITY: a `PreToolUse onFailure: 'block'` hook that refuses bash
4186
+ // containing `rm` now applies to model dispatch. Before this fix the
4187
+ // hooks were INVISIBLE to the engine adapter — a workspace operator
4188
+ // who set up a block hook for destructive bash would still see the
4189
+ // model freely dispatch those calls.
4190
+ //
4191
+ // r2 fix (triple-review 2026-05-26 P2): the fail-open path is a
4192
+ // security hole. If `.pugi/hooks.json` exists but is malformed
4193
+ // (truncated write, typo, partial edit) and the operator has block
4194
+ // hooks configured, the previous `continue without hooks` silently
4195
+ // disabled the BLOCK rules — a hostile or careless mutation of the
4196
+ // file would turn off all SECURITY-CRITICAL refusals without any
4197
+ // visible signal. We now distinguish three cases:
4198
+ //
4199
+ // (a) Neither user nor project hooks file exists → no hooks. Safe.
4200
+ // (b) File(s) exist and load() succeeds → hooks live. Normal.
4201
+ // (c) File(s) exist and load() fails → REFUSE THE RUN with a
4202
+ // fatal stderr message and `process.exit(1)`. Operator must
4203
+ // fix the file OR set `PUGI_HOOKS_BYPASS=1` to override (the
4204
+ // escape hatch is logged loudly so it cannot be silent).
4205
+ //
4206
+ // The bypass env var exists for the mid-edit recovery case (the
4207
+ // operator is in the middle of fixing the file and needs to run
4208
+ // pugi to see the world state). It is NEVER a default — the
4209
+ // operator types it explicitly.
4210
+ const hookOutcome = await loadHookRegistryOrExit({
4211
+ workspaceRoot: root,
4212
+ session,
4213
+ label,
4214
+ });
4215
+ if (hookOutcome.kind === 'parse-failure-refused') {
4216
+ // The helper already emitted the fatal message on stderr. Exit
4217
+ // directly so dispatchEngineCommand's caller observes a non-zero
4218
+ // exit code without a stack trace.
4219
+ process.exit(1);
4220
+ }
4221
+ const hooks = hookOutcome.hooks;
4222
+ const adapter = new NativePugiEngineAdapter({
4223
+ client,
4224
+ session,
4225
+ allowFetch: flags.allowFetch,
4226
+ allowSearch: flags.allowSearch,
4227
+ ...(mcpRegistry ? { mcpRegistry } : {}),
4228
+ ...(hooks ? { hooks } : {}),
4229
+ // Non-interactive CLI path: the FSM prompt callback always denies
4230
+ // until the operator explicitly grants permission via
4231
+ // `pugi mcp perms` (out-of-band). A future Ink-backed REPL path
4232
+ // overrides this with a modal prompt; pipes / CI never auto-allow.
4233
+ mcpPrompt: defaultNonInteractiveMcpPrompt,
4234
+ // P1 fix (deep audit 2026-05-26): CLI dispatcher is non-interactive
4235
+ // by default — pipes, CI, and scripted `pugi code "..."` runs do
4236
+ // not have an ink modal to surface ask_user_question into. The
4237
+ // REPL layer (β2b ink modal wiring, future) overrides this with
4238
+ // `interactive: true` + a live askUserBridge.
4239
+ interactive: false,
4240
+ });
2150
4241
  const toolCallId = recordToolCall(session, `engine:${adapter.name}`, `${label}: ${prompt}`);
2151
4242
  const taskId = `${kind}-${Date.now()}`;
2152
- const events = adapter.run({
2153
- id: taskId,
2154
- kind,
2155
- prompt,
2156
- workspaceRoot: root,
2157
- allowedPaths: [root],
2158
- deniedPaths: [],
2159
- artifacts: [],
2160
- // plan mode is enforced inside the tool-bridge (read-only schema +
2161
- // executor refusal sentinel). The permission mode here is the
2162
- // workspace-level toggle and is unchanged from interactive default.
2163
- permissionMode: 'auto',
2164
- }, { sessionId: session.id });
2165
- const statusEvents = [];
2166
- let result = null;
2167
- for await (const event of events) {
2168
- if (event.type === 'status') {
2169
- statusEvents.push(event.message);
2170
- // For `explain` the spec wants status events on stderr so the
2171
- // final summary on stdout is grep-able. Other commands keep the
2172
- // events on stdout-via-final-text so the operator sees the
2173
- // chronological trace.
2174
- if (kind === 'explain' && !flags.json) {
2175
- process.stderr.write(`${event.message}\n`);
4243
+ // β4 r2 P1 #3 — try/finally so loaded MCP child processes are
4244
+ // reaped regardless of run outcome (success, blocked, failed,
4245
+ // thrown). The shutdown is best-effort; we never want a stuck
4246
+ // MCP server to mask a successful Pugi run.
4247
+ try {
4248
+ const events = adapter.run({
4249
+ id: taskId,
4250
+ kind,
4251
+ prompt,
4252
+ workspaceRoot: root,
4253
+ allowedPaths: [root],
4254
+ deniedPaths: [],
4255
+ artifacts: [],
4256
+ // plan mode is enforced inside the tool-bridge (read-only schema +
4257
+ // executor refusal sentinel). The permission mode here is the
4258
+ // workspace-level toggle and is unchanged from interactive default.
4259
+ permissionMode: 'auto',
4260
+ }, { sessionId: session.id });
4261
+ const statusEvents = [];
4262
+ let result = null;
4263
+ for await (const event of events) {
4264
+ if (event.type === 'status') {
4265
+ statusEvents.push(event.message);
4266
+ // For `explain` the spec wants status events on stderr so the
4267
+ // final summary on stdout is grep-able. Other commands keep the
4268
+ // events on stdout-via-final-text so the operator sees the
4269
+ // chronological trace.
4270
+ if (kind === 'explain' && !flags.json) {
4271
+ process.stderr.write(`${event.message}\n`);
4272
+ }
4273
+ }
4274
+ else {
4275
+ result = {
4276
+ status: event.result.status,
4277
+ summary: event.result.summary,
4278
+ filesChanged: event.result.filesChanged,
4279
+ eventRefs: event.result.eventRefs,
4280
+ risks: event.result.risks,
4281
+ };
2176
4282
  }
2177
4283
  }
2178
- else {
4284
+ if (!result) {
4285
+ // Adapter MUST emit a terminal result event. Treat the empty
4286
+ // outcome as a failure so the CLI surfaces a clear error rather
4287
+ // than exiting 0 with no output.
2179
4288
  result = {
2180
- status: event.result.status,
2181
- summary: event.result.summary,
2182
- filesChanged: event.result.filesChanged,
2183
- eventRefs: event.result.eventRefs,
2184
- risks: event.result.risks,
4289
+ status: 'failed',
4290
+ summary: 'engine adapter returned no result',
4291
+ filesChanged: [],
4292
+ eventRefs: [],
4293
+ risks: ['adapter terminated without emitting a result event'],
2185
4294
  };
2186
4295
  }
2187
- }
2188
- if (!result) {
2189
- // Adapter MUST emit a terminal result event. Treat the empty
2190
- // outcome as a failure so the CLI surfaces a clear error rather
2191
- // than exiting 0 with no output.
2192
- result = {
2193
- status: 'failed',
2194
- summary: 'engine adapter returned no result',
2195
- filesChanged: [],
2196
- eventRefs: [],
2197
- risks: ['adapter terminated without emitting a result event'],
2198
- };
2199
- }
2200
- // α6.6 diff escalation Layer A/B/C dispatcher.
2201
- //
2202
- // Some models emit file edits as inline SEARCH/REPLACE markers in
2203
- // the final response rather than through tool calls (especially
2204
- // Gemini and o1 family, which under-use tool schemas in long
2205
- // reasoning chains). We run the dispatcher against the model's
2206
- // final text so those markers still land on disk. Tool-call edits
2207
- // (Layer-A equivalent already handled by `edit`/`write` tools) are
2208
- // unaffected — the dispatcher only fires on prose blocks that
2209
- // happen to contain markers.
2210
- //
2211
- // Scope: code / fix / build / explain only. `plan` is read-only
2212
- // (the engine refuses write tools), so even a stray marker in plan
2213
- // output gets ignored to honour the plan-mode contract.
2214
- //
2215
- // Dry-run + read-only short-circuits: when the flags forbid writes
2216
- // we dispatch with `dryRun: true` so the operator still sees what
2217
- // WOULD have been written, but nothing touches disk.
2218
- let dispatchResults = [];
2219
- if (kind === 'code' || kind === 'fix' || kind === 'build_task') {
2220
- dispatchResults = await runMarkerDispatch({
2221
- root,
2222
- result: {
2223
- status: result.status,
2224
- summary: result.summary,
2225
- eventRefs: result.eventRefs,
2226
- },
2227
- dryRun: flags.dryRun,
2228
- });
2229
- // Merge dispatcher-touched files into `result.filesChanged` so the
2230
- // operator-facing summary lists them alongside tool-driven edits.
2231
- for (const dr of dispatchResults) {
2232
- if (dr.ok && dr.absPath) {
2233
- const rel = relative(root, dr.absPath);
2234
- if (!result.filesChanged.includes(rel))
2235
- result.filesChanged.push(rel);
4296
+ // α6.6 diff escalation — Layer A/B/C dispatcher.
4297
+ //
4298
+ // Some models emit file edits as inline SEARCH/REPLACE markers in
4299
+ // the final response rather than through tool calls (especially
4300
+ // Gemini and o1 family, which under-use tool schemas in long
4301
+ // reasoning chains). We run the dispatcher against the model's
4302
+ // final text so those markers still land on disk. Tool-call edits
4303
+ // (Layer-A equivalent already handled by `edit`/`write` tools) are
4304
+ // unaffected — the dispatcher only fires on prose blocks that
4305
+ // happen to contain markers.
4306
+ //
4307
+ // Scope: code / fix / build / explain only. `plan` is read-only
4308
+ // (the engine refuses write tools), so even a stray marker in plan
4309
+ // output gets ignored to honour the plan-mode contract.
4310
+ //
4311
+ // Dry-run + read-only short-circuits: when the flags forbid writes
4312
+ // we dispatch with `dryRun: true` so the operator still sees what
4313
+ // WOULD have been written, but nothing touches disk.
4314
+ let dispatchResults = [];
4315
+ if (kind === 'code' || kind === 'fix' || kind === 'build_task') {
4316
+ dispatchResults = await runMarkerDispatch({
4317
+ root,
4318
+ result: {
4319
+ status: result.status,
4320
+ summary: result.summary,
4321
+ eventRefs: result.eventRefs,
4322
+ },
4323
+ dryRun: flags.dryRun,
4324
+ });
4325
+ // Merge dispatcher-touched files into `result.filesChanged` so the
4326
+ // operator-facing summary lists them alongside tool-driven edits.
4327
+ for (const dr of dispatchResults) {
4328
+ if (dr.ok && dr.absPath) {
4329
+ const rel = relative(root, dr.absPath);
4330
+ if (!result.filesChanged.includes(rel))
4331
+ result.filesChanged.push(rel);
4332
+ }
2236
4333
  }
2237
4334
  }
2238
- }
2239
- // For `plan` we always write a plan.md artifact, regardless of
2240
- // outcome. A blocked plan (budget exhausted, tool refusal) still
2241
- // produces a reviewable artifact — the reason is recorded inline.
2242
- let planArtifact = null;
2243
- if (kind === 'plan') {
2244
- planArtifact = writePlanArtifact({
2245
- root,
2246
- session,
2247
- prompt,
2248
- result,
2249
- statusEvents,
2250
- });
2251
- }
2252
- // Pull the headline metrics out of `eventRefs` so the summary and
2253
- // JSON envelope match without re-parsing strings in two places.
2254
- const metrics = parseEventRefs(result.eventRefs);
2255
- const finalStatus = result.status === 'failed' ? 'error' : 'success';
2256
- recordToolResult(session, toolCallId, finalStatus, result.summary);
2257
- // Exit code policy (spec §1-§5):
2258
- // code/fix/build → 0 done, 8 failed, 9 blocked
2259
- // explain → same triple; read-only blocked = budget exhaustion
2260
- // plan → 0 on done OR plan-mode refusal (refusal is a
2261
- // SUCCESS for plan: the gate worked); 8 on failed
2262
- // transport; 9 on budget exhaustion.
2263
- //
2264
- // Code Reviewer P2 retro 2026-05-23: previously `plan` masked
2265
- // `budget_exhausted` as exit 0, so a CI loop with a token budget
2266
- // hit looked identical to a successful plan. We now distinguish
2267
- // via the adapter's `outcome=<status>` echo on `eventRefs` so
2268
- // shell wrappers can branch on the real cause.
2269
- if (kind === 'plan') {
2270
- if (result.status === 'failed') {
2271
- process.exitCode = ENGINE_EXIT_CODES.failed;
2272
- }
2273
- else if (result.status === 'blocked' &&
2274
- metrics.outcome === 'budget_exhausted') {
2275
- process.exitCode = ENGINE_EXIT_CODES.blocked;
4335
+ // For `plan` we always write a plan.md artifact, regardless of
4336
+ // outcome. A blocked plan (budget exhausted, tool refusal) still
4337
+ // produces a reviewable artifact the reason is recorded inline.
4338
+ let planArtifact = null;
4339
+ if (kind === 'plan') {
4340
+ planArtifact = writePlanArtifact({
4341
+ root,
4342
+ session,
4343
+ prompt,
4344
+ result,
4345
+ statusEvents,
4346
+ });
2276
4347
  }
2277
- else {
2278
- // `done`, or `blocked` with outcome=tool_refused (= the plan-mode
2279
- // gate fired, which is the contract working as designed), or
2280
- // `blocked` with no outcome echo (legacy adapter preserve the
2281
- // pre-retro 0 behaviour to avoid breaking external scripts).
2282
- process.exitCode = 0;
4348
+ // α6.8 EXTEND PR1: `--decompose` post-processing. We only attempt
4349
+ // the parse on a `done` plan (a blocked/failed plan is already
4350
+ // captured in plan.md with its reason; no JSON to extract). The
4351
+ // model's final answer arrives via `result.summary`on success
4352
+ // the adapter prefix is empty so it is the raw final text. We
4353
+ // strip any leading/trailing whitespace then run the parser
4354
+ // against the contents. On parse failure we surface a non-fatal
4355
+ // structured error in the payload — the operator still gets the
4356
+ // plan.md artifact and can re-run.
4357
+ //
4358
+ // TODO(α7.x): `result.summary` is currently a string contract that
4359
+ // doubles as both "human-readable headline" and "raw final model
4360
+ // text". Split into `{ summary, finalText }` on the adapter so the
4361
+ // parser does not have to assume the prefix is empty. Tracked in
4362
+ // PR #423 v2 retro (P2.6, Claude review).
4363
+ let decomposeArtifact = null;
4364
+ let decomposeError = null;
4365
+ if (flags.decompose && kind === 'plan' && result.status === 'done') {
4366
+ const parsed = parseDecompositionFromText(result.summary);
4367
+ if (parsed.ok) {
4368
+ decomposeArtifact = writeDecomposition({
4369
+ root,
4370
+ sessionId: session.id,
4371
+ // Persist the OPERATOR's original prompt, not the prompt+suffix
4372
+ // we sent to the engine. The suffix is plumbing; the manifest
4373
+ // header reads naturally only with the operator text.
4374
+ prompt: args.join(' ').trim() || prompt,
4375
+ decomposition: parsed.decomposition,
4376
+ rationale: parsed.rationale,
4377
+ });
4378
+ }
4379
+ else {
4380
+ decomposeError = { reason: parsed.reason, detail: parsed.detail };
4381
+ }
2283
4382
  }
2284
- }
2285
- else {
2286
- process.exitCode = ENGINE_EXIT_CODES[result.status];
2287
- }
2288
- const payload = {
2289
- command: label,
2290
- taskId,
2291
- status: result.status,
2292
- summary: result.summary,
2293
- filesChanged: result.filesChanged,
2294
- toolCalls: metrics.toolCalls,
2295
- turns: metrics.turns,
2296
- tokens: metrics.tokens,
2297
- sessionId: session.id,
2298
- sessionEventsMirror: metrics.mirror,
2299
- risks: result.risks,
2300
- plan: planArtifact ? { path: planArtifact.relPath } : undefined,
2301
- // α6.6 per-edit dispatcher trace. Empty array when no inline
2302
- // markers were detected in the model's final response.
2303
- diffEdits: dispatchResults.map((dr) => ({
2304
- layer: dr.layer,
2305
- file: dr.file,
2306
- ok: dr.ok,
2307
- bytesWritten: dr.bytesWritten,
2308
- reason: dr.reason,
2309
- detail: dr.detail,
2310
- })),
2311
- // The full event stream is useful for cabinet UI replay. We surface
2312
- // it in JSON mode only — text mode operators want the summary, not
2313
- // 30 turn-level lines.
2314
- events: flags.json ? statusEvents : undefined,
2315
- };
2316
- const textLines = [];
2317
- if (kind === 'plan' && planArtifact) {
2318
- textLines.push(`Pugi plan written to ${planArtifact.relPath}`);
2319
- }
2320
- textLines.push(`Pugi ${label}: ${result.status}`);
2321
- textLines.push(`Summary: ${result.summary}`);
2322
- if (result.filesChanged.length > 0) {
2323
- textLines.push(`Files modified (${result.filesChanged.length}):`);
2324
- for (const file of result.filesChanged)
2325
- textLines.push(` - ${file}`);
2326
- }
2327
- else if (kind !== 'explain' && kind !== 'plan') {
2328
- textLines.push('Files modified: none');
2329
- }
2330
- textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
2331
- if (dispatchResults.length > 0) {
2332
- const okCount = dispatchResults.filter((d) => d.ok).length;
2333
- const failCount = dispatchResults.length - okCount;
2334
- textLines.push(`Diff dispatch: ${okCount} applied, ${failCount} rejected (${dispatchResults.length} marker block${dispatchResults.length === 1 ? '' : 's'})`);
2335
- for (const dr of dispatchResults) {
2336
- if (dr.ok) {
2337
- textLines.push(` + ${dr.layer} ${dr.file} (${dr.bytesWritten} bytes)`);
4383
+ // Pull the headline metrics out of `eventRefs` so the summary and
4384
+ // JSON envelope match without re-parsing strings in two places.
4385
+ const metrics = parseEventRefs(result.eventRefs);
4386
+ const finalStatus = result.status === 'failed' ? 'error' : 'success';
4387
+ recordToolResult(session, toolCallId, finalStatus, result.summary);
4388
+ // Exit code policy (spec §1-§5):
4389
+ // code/fix/build → 0 done, 8 failed, 9 blocked
4390
+ // explain → same triple; read-only blocked = budget exhaustion
4391
+ // plan → 0 on done OR plan-mode refusal (refusal is a
4392
+ // SUCCESS for plan: the gate worked); 8 on failed
4393
+ // transport; 9 on budget exhaustion.
4394
+ //
4395
+ // Code Reviewer P2 retro 2026-05-23: previously `plan` masked
4396
+ // `budget_exhausted` as exit 0, so a CI loop with a token budget
4397
+ // hit looked identical to a successful plan. We now distinguish
4398
+ // via the adapter's `outcome=<status>` echo on `eventRefs` so
4399
+ // shell wrappers can branch on the real cause.
4400
+ if (kind === 'plan') {
4401
+ if (result.status === 'failed') {
4402
+ process.exitCode = ENGINE_EXIT_CODES.failed;
4403
+ }
4404
+ else if (result.status === 'blocked' &&
4405
+ metrics.outcome === 'budget_exhausted') {
4406
+ process.exitCode = ENGINE_EXIT_CODES.blocked;
2338
4407
  }
2339
4408
  else {
2340
- textLines.push(` ! ${dr.layer} ${dr.file}: ${dr.reason ?? 'failure'} ${dr.detail ?? ''}`);
4409
+ // `done`, or `blocked` with outcome=tool_refused (= the plan-mode
4410
+ // gate fired, which is the contract working as designed), or
4411
+ // `blocked` with no outcome echo (legacy adapter — preserve the
4412
+ // pre-retro 0 behaviour to avoid breaking external scripts).
4413
+ process.exitCode = 0;
4414
+ }
4415
+ }
4416
+ else {
4417
+ process.exitCode = ENGINE_EXIT_CODES[result.status];
4418
+ }
4419
+ const payload = {
4420
+ command: label,
4421
+ taskId,
4422
+ status: result.status,
4423
+ summary: result.summary,
4424
+ filesChanged: result.filesChanged,
4425
+ toolCalls: metrics.toolCalls,
4426
+ turns: metrics.turns,
4427
+ tokens: metrics.tokens,
4428
+ sessionId: session.id,
4429
+ sessionEventsMirror: metrics.mirror,
4430
+ risks: result.risks,
4431
+ plan: planArtifact ? { path: planArtifact.relPath } : undefined,
4432
+ // α6.6 — per-edit dispatcher trace. Empty array when no inline
4433
+ // markers were detected in the model's final response.
4434
+ diffEdits: dispatchResults.map((dr) => ({
4435
+ layer: dr.layer,
4436
+ file: dr.file,
4437
+ ok: dr.ok,
4438
+ bytesWritten: dr.bytesWritten,
4439
+ reason: dr.reason,
4440
+ detail: dr.detail,
4441
+ })),
4442
+ // α6.8 EXTEND PR1: decompose artifacts (only present when
4443
+ // `--decompose` was passed AND the model emitted a parseable
4444
+ // JSON block). The `error` shape lands when the model returned
4445
+ // unparseable output; the operator can re-run with a tighter
4446
+ // prompt without losing the plain plan.md artifact.
4447
+ decompose: decomposeArtifact !== null
4448
+ ? {
4449
+ manifest: relative(root, decomposeArtifact.manifestPath),
4450
+ planDir: relative(root, decomposeArtifact.planDir),
4451
+ splits: decomposeArtifact.splitPaths,
4452
+ }
4453
+ : decomposeError !== null
4454
+ ? { error: decomposeError }
4455
+ : undefined,
4456
+ // The full event stream is useful for cabinet UI replay. We surface
4457
+ // it in JSON mode only — text mode operators want the summary, not
4458
+ // 30 turn-level lines.
4459
+ events: flags.json ? statusEvents : undefined,
4460
+ };
4461
+ const textLines = [];
4462
+ if (kind === 'plan' && planArtifact) {
4463
+ textLines.push(`Pugi plan written to ${planArtifact.relPath}`);
4464
+ }
4465
+ if (decomposeArtifact !== null) {
4466
+ textLines.push(`Decomposition: ${decomposeArtifact.splitPaths.length} component spec${decomposeArtifact.splitPaths.length === 1 ? '' : 's'} under ${relative(root, decomposeArtifact.planDir)}`);
4467
+ textLines.push(`Manifest: ${relative(root, decomposeArtifact.manifestPath)}`);
4468
+ }
4469
+ else if (decomposeError !== null) {
4470
+ textLines.push(`Decomposition: skipped (${decomposeError.reason}) — plan.md still written`);
4471
+ }
4472
+ textLines.push(`Pugi ${label}: ${result.status}`);
4473
+ textLines.push(`Summary: ${result.summary}`);
4474
+ if (result.filesChanged.length > 0) {
4475
+ textLines.push(`Files modified (${result.filesChanged.length}):`);
4476
+ for (const file of result.filesChanged)
4477
+ textLines.push(` - ${file}`);
4478
+ }
4479
+ else if (kind !== 'explain' && kind !== 'plan') {
4480
+ textLines.push('Files modified: none');
4481
+ }
4482
+ textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
4483
+ if (dispatchResults.length > 0) {
4484
+ const okCount = dispatchResults.filter((d) => d.ok).length;
4485
+ const failCount = dispatchResults.length - okCount;
4486
+ textLines.push(`Diff dispatch: ${okCount} applied, ${failCount} rejected (${dispatchResults.length} marker block${dispatchResults.length === 1 ? '' : 's'})`);
4487
+ for (const dr of dispatchResults) {
4488
+ if (dr.ok) {
4489
+ textLines.push(` + ${dr.layer} ${dr.file} (${dr.bytesWritten} bytes)`);
4490
+ }
4491
+ else {
4492
+ textLines.push(` ! ${dr.layer} ${dr.file}: ${dr.reason ?? 'failure'} — ${dr.detail ?? ''}`);
4493
+ }
2341
4494
  }
2342
4495
  }
4496
+ if (result.risks.length > 0) {
4497
+ textLines.push(`Risks: ${result.risks.join('; ')}`);
4498
+ }
4499
+ textLines.push(`Session: ${session.id}`);
4500
+ if (metrics.mirror)
4501
+ textLines.push(`Events mirror: ${metrics.mirror}`);
4502
+ writeOutput(flags, payload, textLines.join('\n'));
2343
4503
  }
2344
- if (result.risks.length > 0) {
2345
- textLines.push(`Risks: ${result.risks.join('; ')}`);
4504
+ finally {
4505
+ // β4 r2 P1 #3 — tear down live MCP child processes BEFORE the
4506
+ // CLI exits. shutdown() is idempotent and swallows per-server
4507
+ // disconnect errors, so it is safe even if no servers connected.
4508
+ if (mcpRegistry) {
4509
+ await mcpRegistry.shutdown().catch((error) => {
4510
+ process.stderr.write(`pugi ${label}: MCP registry shutdown reported error — ${error.message}\n`);
4511
+ });
4512
+ }
4513
+ // Leak L15 (2026-05-27) — tear down any LSP servers warmed up
4514
+ // by the post-edit diagnostics cache. The cache is per-process
4515
+ // and survives across multiple tool calls; without this hook a
4516
+ // `pugi code ...` invocation would leak a tsserver process when
4517
+ // the Node host exits. The dynamic import keeps the cache module
4518
+ // out of the cold path for runs that never touch LSP.
4519
+ try {
4520
+ const { stopAllLspClients } = await import('../core/lsp/cache.js');
4521
+ await stopAllLspClients();
4522
+ }
4523
+ catch (error) {
4524
+ process.stderr.write(`pugi ${label}: LSP cache shutdown reported error — ${error.message}\n`);
4525
+ }
2346
4526
  }
2347
- textLines.push(`Session: ${session.id}`);
2348
- if (metrics.mirror)
2349
- textLines.push(`Events mirror: ${metrics.mirror}`);
2350
- writeOutput(flags, payload, textLines.join('\n'));
2351
4527
  };
2352
4528
  }
2353
4529
  // Exported for the α6.6.1 triple-review remediation spec
@@ -2579,7 +4755,7 @@ async function login(args, flags, _session) {
2579
4755
  if (args.includes('--help') || args.includes('-h')) {
2580
4756
  writeOutput(flags, {
2581
4757
  command: 'login',
2582
- usage: 'pugi login [--provider device|token|env] [--token <PAT>] [--token-stdin] [--label <name>] [--api-url <url>]',
4758
+ usage: 'pugi login [--provider device|token|env] [--token <PAT>] [--token-stdin] [--key <value>] [--skip-validate] [--label <name>] [--api-url <url>]',
2583
4759
  }, [
2584
4760
  'Usage: pugi login [options]',
2585
4761
  '',
@@ -2591,19 +4767,27 @@ async function login(args, flags, _session) {
2591
4767
  'Non-interactive options:',
2592
4768
  ' --provider device Run the device-flow login (recommended).',
2593
4769
  ' --provider token Store an API key passed via --token / --token-stdin / PUGI_LOGIN_TOKEN.',
2594
- ' --provider env Promote PUGI_API_KEY from the environment into the store.',
4770
+ ' --provider env Read PUGI_API_KEY (or --key) and verify it via /api/pugi/health.',
2595
4771
  ' --token <PAT> Inline API key (visible in `ps`).',
2596
4772
  ' --token-stdin Read API key from stdin (gh-CLI style).',
4773
+ ' --key <value> Explicit key for --provider env; beats PUGI_API_KEY.',
4774
+ ' --skip-validate Skip the /api/pugi/health probe for --provider env (CI bootstrap).',
2597
4775
  ' --label <name> Short label surfaced in `pugi accounts list`.',
2598
4776
  ' --api-url <url> Override the Anvil endpoint (self-hosted).',
2599
4777
  ' --no-device-flow Refuse the device flow; fail fast in CI without a token.',
2600
4778
  '',
4779
+ 'Environment variables:',
4780
+ ' PUGI_API_KEY Read by --provider env. Pass --key to override.',
4781
+ ' PUGI_LOGIN_TOKEN Read by --provider token in non-interactive shells.',
4782
+ ' PUGI_API_URL Override the Anvil endpoint (same as --api-url).',
4783
+ '',
2601
4784
  'Examples:',
2602
4785
  ' pugi login # interactive picker on a TTY',
2603
4786
  ' pugi login --provider device # explicit browser OAuth',
2604
4787
  ' pugi login --provider token --token sk-xx # paste in a key',
2605
4788
  ' echo $TOKEN | pugi login --provider token --token-stdin',
2606
- ' PUGI_API_KEY=sk-xx pugi login --provider env',
4789
+ ' PUGI_API_KEY=pugi_xxx pugi login --provider env',
4790
+ ' pugi login --provider env --key pugi_xxx # explicit key beats env',
2607
4791
  ].join('\n'));
2608
4792
  return;
2609
4793
  }
@@ -2626,6 +4810,11 @@ async function login(args, flags, _session) {
2626
4810
  const apiUrlOverride = extractApiUrlFlag(args);
2627
4811
  const labelFlag = extractLabelFlag(args);
2628
4812
  const provider = parseProviderFlag(args);
4813
+ // Leak L35 (2026-05-27): `--key` is the explicit-arg path for
4814
+ // `--provider env`; `--skip-validate` bypasses the /api/pugi/health
4815
+ // probe (CI bootstrap before the network is up).
4816
+ const envExplicitKey = extractKeyFlag(args);
4817
+ const envSkipValidate = args.includes('--skip-validate');
2629
4818
  const apiUrl = normalizeApiUrl(apiUrlOverride ?? process.env.PUGI_API_URL ?? DEFAULT_API_URL);
2630
4819
  // Path 1: explicit --provider trumps everything else.
2631
4820
  if (provider) {
@@ -2636,6 +4825,8 @@ async function login(args, flags, _session) {
2636
4825
  explicitToken: tokenFromArgs,
2637
4826
  tokenStdinFlag,
2638
4827
  noDeviceFlow,
4828
+ envExplicitKey,
4829
+ envSkipValidate,
2639
4830
  });
2640
4831
  return;
2641
4832
  }
@@ -2683,6 +4874,8 @@ async function login(args, flags, _session) {
2683
4874
  flags,
2684
4875
  label: labelFlag,
2685
4876
  noDeviceFlow,
4877
+ envExplicitKey,
4878
+ envSkipValidate,
2686
4879
  });
2687
4880
  return;
2688
4881
  }
@@ -2901,16 +5094,28 @@ async function dispatchLoginProvider(provider, ctx) {
2901
5094
  return;
2902
5095
  }
2903
5096
  case 'env': {
2904
- const envKey = process.env.PUGI_API_KEY;
2905
- if (!envKey) {
2906
- throw new Error('pugi login --provider env requires PUGI_API_KEY to be exported in the current shell.');
5097
+ // Leak L35 (2026-05-27): resolve the env / --key candidate,
5098
+ // run the local format check, then probe `/api/pugi/health`
5099
+ // BEFORE persisting. A bad token never lands on disk so the
5100
+ // next `pugi <anything>` does not silently 401 against the
5101
+ // cabinet. `--skip-validate` opts out for CI bootstrap.
5102
+ const resolved = await resolveAndValidateEnvLogin({
5103
+ apiUrl: ctx.apiUrl,
5104
+ explicitKey: ctx.envExplicitKey,
5105
+ env: process.env,
5106
+ skipValidate: ctx.envSkipValidate ?? false,
5107
+ });
5108
+ if (resolved.kind !== 'ok') {
5109
+ reportEnvLoginFailure(resolved, ctx.flags);
5110
+ return;
2907
5111
  }
2908
5112
  storeAndAnnounceToken({
2909
5113
  apiUrl: ctx.apiUrl,
2910
- apiKey: envKey,
5114
+ apiKey: resolved.token,
2911
5115
  label: ctx.label,
2912
5116
  source: 'env',
2913
5117
  flags: ctx.flags,
5118
+ validatedLatencyMs: resolved.latencyMs > 0 ? resolved.latencyMs : undefined,
2914
5119
  });
2915
5120
  return;
2916
5121
  }
@@ -2929,6 +5134,15 @@ function storeAndAnnounceToken(input) {
2929
5134
  label: input.label,
2930
5135
  source: input.source,
2931
5136
  });
5137
+ const textLines = [
5138
+ `Pugi logged in for ${record.apiUrl}`,
5139
+ `Method: ${input.source}${record.label ? ` (${record.label})` : ''}`,
5140
+ `Token: ${maskApiKey(record.apiKey)}`,
5141
+ ];
5142
+ if (typeof input.validatedLatencyMs === 'number') {
5143
+ textLines.push(`Verified via /api/pugi/health in ${input.validatedLatencyMs}ms`);
5144
+ }
5145
+ textLines.push('Stored at ~/.pugi/credentials.json (mode 0600). Run `pugi whoami` to verify.');
2932
5146
  writeOutput(input.flags, {
2933
5147
  status: 'logged_in',
2934
5148
  apiUrl: record.apiUrl,
@@ -2936,12 +5150,55 @@ function storeAndAnnounceToken(input) {
2936
5150
  label: record.label ?? null,
2937
5151
  createdAt: record.createdAt,
2938
5152
  source: input.source,
2939
- }, [
2940
- `Pugi logged in for ${record.apiUrl}`,
2941
- `Method: ${input.source}${record.label ? ` (${record.label})` : ''}`,
2942
- `Token: ${maskApiKey(record.apiKey)}`,
2943
- 'Stored at ~/.pugi/credentials.json (mode 0600). Run `pugi whoami` to verify.',
2944
- ].join('\n'));
5153
+ ...(typeof input.validatedLatencyMs === 'number'
5154
+ ? { validatedLatencyMs: input.validatedLatencyMs }
5155
+ : {}),
5156
+ }, textLines.join('\n'));
5157
+ }
5158
+ /**
5159
+ * Render a typed `EnvLoginFailure` from `resolveAndValidateEnvLogin`
5160
+ * onto the surrounding CLI surface. Maps the failure kind to:
5161
+ * - an exit code (1 by default; 2 for invalid format so a CI step
5162
+ * can disambiguate "missing key" vs "key shape wrong" without
5163
+ * parsing stderr; 4 for network / server errors so retry logic
5164
+ * can distinguish transient failures from credential failures)
5165
+ * - a structured JSON payload for `--json` consumers
5166
+ * - a human-readable stderr line for the interactive path
5167
+ *
5168
+ * The token itself is never echoed — only the validator's own message
5169
+ * (which the env-provider module composed without the secret in it).
5170
+ */
5171
+ function reportEnvLoginFailure(failure, flags) {
5172
+ const exitCode = (() => {
5173
+ switch (failure.kind) {
5174
+ case 'missing':
5175
+ return 1;
5176
+ case 'invalid-format':
5177
+ return 2;
5178
+ case 'unauthorized':
5179
+ return 3;
5180
+ case 'network-error':
5181
+ case 'server-error':
5182
+ return 4;
5183
+ case 'unexpected-status':
5184
+ return 5;
5185
+ default: {
5186
+ const exhaustive = failure;
5187
+ return Number(exhaustive) || 1;
5188
+ }
5189
+ }
5190
+ })();
5191
+ const payload = {
5192
+ status: 'login_failed',
5193
+ kind: failure.kind,
5194
+ message: failure.message,
5195
+ };
5196
+ if ('status' in failure)
5197
+ payload.httpStatus = failure.status;
5198
+ if ('cause' in failure && failure.cause)
5199
+ payload.cause = failure.cause;
5200
+ writeOutput(flags, payload, failure.message);
5201
+ process.exitCode = exitCode;
2945
5202
  }
2946
5203
  /**
2947
5204
  * OAuth 2.0 Device Authorization Grant client (RFC 8628). Renders
@@ -3697,6 +5954,17 @@ function extractApiUrlFlag(args) {
3697
5954
  function extractLabelFlag(args) {
3698
5955
  return extractNamedFlagValue(args, 'label');
3699
5956
  }
5957
+ /**
5958
+ * `pugi login --provider env --key <value>` — explicit key arg that
5959
+ * beats `PUGI_API_KEY` env. Same precedence rule as `gh auth login
5960
+ * --with-token`, `aws configure set`, and `pugi config`: the most
5961
+ * specific operator intent (a typed flag) overrides the ambient
5962
+ * environment so an operator can override a stale `PUGI_API_KEY`
5963
+ * from their shell rc without unsetting it first.
5964
+ */
5965
+ function extractKeyFlag(args) {
5966
+ return extractNamedFlagValue(args, 'key');
5967
+ }
3700
5968
  /**
3701
5969
  * `pugi jobs` — surface the persistent JobRegistry on the CLI.
3702
5970
  * Sprint α5.9 (ADR-0056 PR-PUGI-CLI-M1-GAP-J). Subcommand parsing
@@ -3975,7 +6243,31 @@ function fileBytes(path) {
3975
6243
  return 0;
3976
6244
  }
3977
6245
  }
3978
- function safeGit(root, args) {
6246
+ /**
6247
+ * Git invocation helpers — probe vs required semantics.
6248
+ *
6249
+ * 2026-05-27 (Claude review followup #489): the historical `safeGit`
6250
+ * collapsed BOTH "tell me the branch name if you can" probes AND
6251
+ * "give me the diff or fail" hard requirements into a single helper
6252
+ * that swallowed every error as an empty string. That's the correct
6253
+ * shape for the probe case (branch / status / dirty flag — empty
6254
+ * result is a valid signal) but catastrophically wrong for the diff
6255
+ * case (empty result === false PASS on a commit nobody reviewed).
6256
+ *
6257
+ * The split:
6258
+ * - `safeGitProbe` — best-effort. Returns '' on any error. Use for
6259
+ * branch name lookups, status probes, opt-in dirty detection.
6260
+ * - `safeGitRequired` — throws on non-zero exit / ENOBUFS / bad ref.
6261
+ * Use for diff, merge-base resolution, anything whose empty
6262
+ * output would silently corrupt downstream behaviour.
6263
+ *
6264
+ * Legacy `safeGit` is kept as a deprecated alias of `safeGitProbe`
6265
+ * so existing call-sites (branch detection, status, etc.) keep their
6266
+ * tolerant semantics until they are individually migrated. Diff /
6267
+ * merge-base / rev-parse-verify call-sites are migrated к
6268
+ * `safeGitRequired` in this same patch.
6269
+ */
6270
+ export function safeGitProbe(root, args) {
3979
6271
  try {
3980
6272
  return execFileSync('git', args, {
3981
6273
  cwd: root,
@@ -3993,6 +6285,38 @@ function safeGit(root, args) {
3993
6285
  return '';
3994
6286
  }
3995
6287
  }
6288
+ /**
6289
+ * Strict variant — throws on non-zero exit, ENOBUFS, or any git-side
6290
+ * failure. The thrown error carries the operation context so the
6291
+ * caller (triple-review dispatch, etc.) can fail loud rather than
6292
+ * ship an empty diff to a remote reviewer.
6293
+ */
6294
+ export function safeGitRequired(root, args, context) {
6295
+ try {
6296
+ return execFileSync('git', args, {
6297
+ cwd: root,
6298
+ encoding: 'utf8',
6299
+ stdio: ['ignore', 'pipe', 'pipe'],
6300
+ maxBuffer: 64 * 1024 * 1024,
6301
+ });
6302
+ }
6303
+ catch (err) {
6304
+ const cause = err instanceof Error ? err.message : String(err);
6305
+ throw new Error(`git ${args.slice(0, 2).join(' ')} failed (${context}): ${cause}. ` +
6306
+ `Refusing to proceed — empty git output here would corrupt downstream behaviour.`);
6307
+ }
6308
+ }
6309
+ /**
6310
+ * Deprecated alias preserved for diff / status / branch probes that
6311
+ * legitimately want a tolerant empty-string-on-error shape. New call
6312
+ * sites should pick `safeGitProbe` or `safeGitRequired` explicitly.
6313
+ *
6314
+ * @deprecated 2026-05-27 — prefer `safeGitProbe` (tolerant) or
6315
+ * `safeGitRequired` (strict, throws).
6316
+ */
6317
+ function safeGit(root, args) {
6318
+ return safeGitProbe(root, args);
6319
+ }
3996
6320
  /**
3997
6321
  * Glob patterns excluded from triple-review `diffPatch` before egress.
3998
6322
  *
@@ -4133,5 +6457,6 @@ export function packageRoot() {
4133
6457
  export const __test__ = {
4134
6458
  sleep,
4135
6459
  pollDeviceFlowUntilTerminal,
6460
+ sanitizeSemver,
4136
6461
  };
4137
6462
  //# sourceMappingURL=cli.js.map