@pugi/cli 0.1.0-beta.5 → 0.1.0-beta.51

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 (264) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -25
  3. package/assets/pugi-prozr2-mascot.ansi +9 -0
  4. package/bin/run.js +33 -1
  5. package/dist/commands/jobs-watch.js +201 -0
  6. package/dist/commands/jobs.js +15 -0
  7. package/dist/commands/smoke.js +133 -0
  8. package/dist/core/agent-progress/cleanup.js +134 -0
  9. package/dist/core/agent-progress/schema.js +144 -0
  10. package/dist/core/agent-progress/writer.js +101 -0
  11. package/dist/core/artifact-chain/dispatcher.js +148 -0
  12. package/dist/core/artifact-chain/exporter.js +164 -0
  13. package/dist/core/artifact-chain/state.js +243 -0
  14. package/dist/core/artifact-chain/steps.js +169 -0
  15. package/dist/core/auth/ensure-authenticated.js +129 -0
  16. package/dist/core/auth/env-provider.js +238 -0
  17. package/dist/core/auto-update/channels.js +122 -0
  18. package/dist/core/auto-update/checker.js +241 -0
  19. package/dist/core/auto-update/state.js +235 -0
  20. package/dist/core/bare-mode/index.js +107 -0
  21. package/dist/core/bash-classifier.js +400 -4
  22. package/dist/core/checkpoint/resumer.js +149 -0
  23. package/dist/core/checkpoint/rewinder.js +291 -0
  24. package/dist/core/codegraph/decision-store.js +248 -0
  25. package/dist/core/codegraph/detect-repo.js +459 -0
  26. package/dist/core/codegraph/install.js +134 -0
  27. package/dist/core/codegraph/offer-hook.js +220 -0
  28. package/dist/core/compact/auto-trigger.js +96 -0
  29. package/dist/core/compact/buffer-rewriter.js +115 -0
  30. package/dist/core/compact/summarizer.js +208 -0
  31. package/dist/core/compact/token-counter.js +108 -0
  32. package/dist/core/consensus/diff-capture.js +112 -3
  33. package/dist/core/context/index.js +7 -0
  34. package/dist/core/context/markdown-traverse.js +255 -0
  35. package/dist/core/cost/rate-card.js +129 -0
  36. package/dist/core/cost/tracker.js +221 -0
  37. package/dist/core/denial-tracking/index.js +8 -0
  38. package/dist/core/denial-tracking/state.js +264 -0
  39. package/dist/core/diagnostics/probe-runner.js +93 -0
  40. package/dist/core/diagnostics/probes/api.js +46 -0
  41. package/dist/core/diagnostics/probes/auth.js +86 -0
  42. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  43. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  44. package/dist/core/diagnostics/probes/config.js +72 -0
  45. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  46. package/dist/core/diagnostics/probes/disk.js +81 -0
  47. package/dist/core/diagnostics/probes/git.js +65 -0
  48. package/dist/core/diagnostics/probes/hooks.js +118 -0
  49. package/dist/core/diagnostics/probes/mcp.js +75 -0
  50. package/dist/core/diagnostics/probes/node.js +59 -0
  51. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  52. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  53. package/dist/core/diagnostics/probes/sandbox.js +40 -0
  54. package/dist/core/diagnostics/probes/session.js +74 -0
  55. package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
  56. package/dist/core/diagnostics/probes/workspace.js +63 -0
  57. package/dist/core/diagnostics/types.js +70 -0
  58. package/dist/core/dispatch/cache-cleanup.js +197 -0
  59. package/dist/core/dispatch/cache-handoff.js +295 -0
  60. package/dist/core/edits/dispatch.js +218 -2
  61. package/dist/core/edits/journal.js +199 -0
  62. package/dist/core/edits/layer-d-ast.js +557 -14
  63. package/dist/core/edits/verify-hook.js +273 -0
  64. package/dist/core/edits/worktree.js +322 -0
  65. package/dist/core/engine/anvil-client.js +115 -5
  66. package/dist/core/engine/auto-compact.js +179 -0
  67. package/dist/core/engine/budgets.js +155 -0
  68. package/dist/core/engine/context-prefix.js +155 -0
  69. package/dist/core/engine/intent.js +260 -0
  70. package/dist/core/engine/native-pugi.js +897 -211
  71. package/dist/core/engine/prompts.js +88 -2
  72. package/dist/core/engine/strip-internal-fields.js +124 -0
  73. package/dist/core/engine/tool-bridge.js +1045 -36
  74. package/dist/core/feedback/queue.js +177 -0
  75. package/dist/core/feedback/submitter.js +145 -0
  76. package/dist/core/file-cache.js +113 -1
  77. package/dist/core/hooks/events.js +44 -0
  78. package/dist/core/hooks/index.js +15 -0
  79. package/dist/core/hooks/registry.js +213 -0
  80. package/dist/core/hooks/runner.js +236 -0
  81. package/dist/core/hooks/v2/event-emitter.js +115 -0
  82. package/dist/core/hooks/v2/executor.js +282 -0
  83. package/dist/core/hooks/v2/index.js +25 -0
  84. package/dist/core/hooks/v2/lifecycle.js +104 -0
  85. package/dist/core/hooks/v2/loader.js +216 -0
  86. package/dist/core/hooks/v2/matcher.js +125 -0
  87. package/dist/core/hooks/v2/trust.js +143 -0
  88. package/dist/core/hooks/v2/types.js +86 -0
  89. package/dist/core/lsp/cache.js +105 -0
  90. package/dist/core/lsp/client.js +776 -0
  91. package/dist/core/lsp/language-detect.js +66 -0
  92. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  93. package/dist/core/mcp/client.js +75 -6
  94. package/dist/core/mcp/http-server.js +553 -0
  95. package/dist/core/mcp/orchestrator-tools.js +662 -0
  96. package/dist/core/mcp/permission.js +190 -0
  97. package/dist/core/mcp/registry.js +24 -2
  98. package/dist/core/mcp/server-tools.js +219 -0
  99. package/dist/core/mcp/server.js +397 -0
  100. package/dist/core/memory/dual-write.js +416 -0
  101. package/dist/core/memory/phase1-kinds.js +20 -0
  102. package/dist/core/memory-sync/queue.js +158 -0
  103. package/dist/core/onboarding/ensure-initialized.js +133 -0
  104. package/dist/core/onboarding/marker.js +111 -0
  105. package/dist/core/onboarding/telemetry-state.js +108 -0
  106. package/dist/core/output-style/presets.js +176 -0
  107. package/dist/core/output-style/state.js +185 -0
  108. package/dist/core/path-security.js +284 -2
  109. package/dist/core/permissions/auto-classifier.js +124 -0
  110. package/dist/core/permissions/circuit-breaker.js +83 -0
  111. package/dist/core/permissions/gate.js +278 -0
  112. package/dist/core/permissions/index.js +20 -0
  113. package/dist/core/permissions/mode.js +174 -0
  114. package/dist/core/permissions/state.js +241 -0
  115. package/dist/core/permissions/tool-class.js +93 -0
  116. package/dist/core/prd-check/parser.js +215 -0
  117. package/dist/core/prd-check/reporter.js +127 -0
  118. package/dist/core/prd-check/session-review.js +557 -0
  119. package/dist/core/prd-check/verifiers.js +223 -0
  120. package/dist/core/pugi-md/context-injector.js +76 -0
  121. package/dist/core/pugi-md/walk-up.js +207 -0
  122. package/dist/core/release-notes/parser.js +241 -0
  123. package/dist/core/release-notes/state.js +116 -0
  124. package/dist/core/repl/history.js +11 -1
  125. package/dist/core/repl/model-pricing.js +135 -0
  126. package/dist/core/repl/session.js +1897 -37
  127. package/dist/core/repl/slash-commands.js +430 -15
  128. package/dist/core/repl/store/session-store.js +31 -2
  129. package/dist/core/repl/workspace-context.js +22 -0
  130. package/dist/core/repo-map/build.js +125 -0
  131. package/dist/core/repo-map/cache.js +185 -0
  132. package/dist/core/repo-map/extractor.js +254 -0
  133. package/dist/core/repo-map/formatter.js +145 -0
  134. package/dist/core/repo-map/scanner.js +211 -0
  135. package/dist/core/retry-budget/budget.js +284 -0
  136. package/dist/core/retry-budget/index.js +5 -0
  137. package/dist/core/session.js +92 -0
  138. package/dist/core/settings.js +80 -0
  139. package/dist/core/share/formatter.js +271 -0
  140. package/dist/core/share/redactor.js +221 -0
  141. package/dist/core/share/uploader.js +267 -0
  142. package/dist/core/skills/defaults.js +457 -0
  143. package/dist/core/smoke/headless-driver.js +174 -0
  144. package/dist/core/smoke/orchestrator.js +194 -0
  145. package/dist/core/smoke/runner.js +238 -0
  146. package/dist/core/smoke/scenario-parser.js +316 -0
  147. package/dist/core/subagents/dispatcher-real.js +600 -0
  148. package/dist/core/subagents/dispatcher.js +113 -24
  149. package/dist/core/subagents/index.js +18 -5
  150. package/dist/core/subagents/isolation-matrix.js +213 -0
  151. package/dist/core/subagents/spawn.js +19 -4
  152. package/dist/core/telemetry/emitter.js +229 -0
  153. package/dist/core/telemetry/queue.js +251 -0
  154. package/dist/core/theme/context.js +91 -0
  155. package/dist/core/theme/presets.js +228 -0
  156. package/dist/core/theme/state.js +181 -0
  157. package/dist/core/todos/invariant.js +10 -0
  158. package/dist/core/todos/state.js +177 -0
  159. package/dist/core/transport/version-interceptor.js +166 -0
  160. package/dist/core/vim/keymap.js +288 -0
  161. package/dist/core/vim/state.js +92 -0
  162. package/dist/core/worktree-manager/cleanup.js +123 -0
  163. package/dist/core/worktree-manager/manager.js +303 -0
  164. package/dist/index.js +28 -0
  165. package/dist/runtime/bootstrap.js +190 -0
  166. package/dist/runtime/cli.js +3241 -343
  167. package/dist/runtime/commands/cancel.js +231 -0
  168. package/dist/runtime/commands/chain.js +489 -0
  169. package/dist/runtime/commands/codegraph-status.js +227 -0
  170. package/dist/runtime/commands/compact.js +297 -0
  171. package/dist/runtime/commands/cost.js +199 -0
  172. package/dist/runtime/commands/delegate.js +242 -11
  173. package/dist/runtime/commands/dispatch.js +126 -0
  174. package/dist/runtime/commands/doctor.js +412 -0
  175. package/dist/runtime/commands/feedback.js +184 -0
  176. package/dist/runtime/commands/hooks.js +184 -0
  177. package/dist/runtime/commands/lsp.js +368 -0
  178. package/dist/runtime/commands/mcp.js +879 -0
  179. package/dist/runtime/commands/memory.js +508 -0
  180. package/dist/runtime/commands/model.js +237 -0
  181. package/dist/runtime/commands/onboarding.js +275 -0
  182. package/dist/runtime/commands/patch.js +128 -0
  183. package/dist/runtime/commands/permissions.js +112 -0
  184. package/dist/runtime/commands/plan.js +143 -0
  185. package/dist/runtime/commands/prd-check.js +285 -0
  186. package/dist/runtime/commands/redo-blob-store.js +92 -0
  187. package/dist/runtime/commands/redo.js +361 -0
  188. package/dist/runtime/commands/release-notes.js +229 -0
  189. package/dist/runtime/commands/repo-map.js +95 -0
  190. package/dist/runtime/commands/report.js +299 -0
  191. package/dist/runtime/commands/resume.js +118 -0
  192. package/dist/runtime/commands/review-consensus.js +17 -2
  193. package/dist/runtime/commands/rewind.js +333 -0
  194. package/dist/runtime/commands/sessions.js +163 -0
  195. package/dist/runtime/commands/share.js +316 -0
  196. package/dist/runtime/commands/status.js +186 -0
  197. package/dist/runtime/commands/stickers.js +82 -0
  198. package/dist/runtime/commands/style.js +194 -0
  199. package/dist/runtime/commands/theme.js +196 -0
  200. package/dist/runtime/commands/undo.js +32 -0
  201. package/dist/runtime/commands/update.js +289 -0
  202. package/dist/runtime/commands/vim.js +140 -0
  203. package/dist/runtime/commands/worktree.js +177 -0
  204. package/dist/runtime/commands/worktrees.js +155 -0
  205. package/dist/runtime/headless-repl.js +195 -0
  206. package/dist/runtime/headless.js +543 -0
  207. package/dist/runtime/load-hooks-or-exit.js +71 -0
  208. package/dist/runtime/plan-decompose.js +531 -0
  209. package/dist/runtime/version.js +65 -0
  210. package/dist/tools/agent-tool.js +229 -0
  211. package/dist/tools/apply-patch.js +556 -0
  212. package/dist/tools/ask-user-question.js +213 -0
  213. package/dist/tools/ask-user.js +115 -0
  214. package/dist/tools/bash.js +203 -4
  215. package/dist/tools/file-tools.js +85 -14
  216. package/dist/tools/lsp-tools.js +189 -0
  217. package/dist/tools/mcp-tool.js +260 -0
  218. package/dist/tools/multi-edit.js +361 -0
  219. package/dist/tools/powershell.js +268 -0
  220. package/dist/tools/registry.js +51 -0
  221. package/dist/tools/skill-tool.js +96 -0
  222. package/dist/tools/tasks.js +208 -0
  223. package/dist/tools/todo-write.js +184 -0
  224. package/dist/tools/web-fetch.js +147 -2
  225. package/dist/tools/web-search.js +458 -0
  226. package/dist/tui/agent-progress-card.js +111 -0
  227. package/dist/tui/agent-tree.js +10 -0
  228. package/dist/tui/ask-modal.js +2 -2
  229. package/dist/tui/ask-user-question-prompt.js +192 -0
  230. package/dist/tui/compact-banner.js +81 -0
  231. package/dist/tui/conversation-pane.js +82 -8
  232. package/dist/tui/cost-table.js +111 -0
  233. package/dist/tui/doctor-table.js +46 -0
  234. package/dist/tui/feedback-prompt.js +156 -0
  235. package/dist/tui/input-box.js +218 -3
  236. package/dist/tui/markdown-render.js +4 -4
  237. package/dist/tui/onboarding-wizard.js +240 -0
  238. package/dist/tui/permissions-picker.js +86 -0
  239. package/dist/tui/render.js +35 -0
  240. package/dist/tui/repl-render.js +313 -35
  241. package/dist/tui/repl-splash-art.js +1 -1
  242. package/dist/tui/repl-splash-mascot.js +32 -8
  243. package/dist/tui/repl-splash.js +2 -2
  244. package/dist/tui/repl.js +85 -5
  245. package/dist/tui/splash.js +1 -1
  246. package/dist/tui/status-bar.js +94 -16
  247. package/dist/tui/status-table.js +7 -0
  248. package/dist/tui/stickers-art.js +136 -0
  249. package/dist/tui/style-table.js +28 -0
  250. package/dist/tui/theme-table.js +29 -0
  251. package/dist/tui/thinking-spinner.js +123 -0
  252. package/dist/tui/tool-stream-pane.js +52 -3
  253. package/dist/tui/update-banner.js +27 -2
  254. package/dist/tui/vim-input.js +267 -0
  255. package/dist/tui/welcome-banner.js +107 -0
  256. package/dist/tui/welcome-data.js +293 -0
  257. package/docs/examples/codegraph.mcp.json +10 -0
  258. package/package.json +13 -7
  259. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  260. package/test/scenarios/compact-force.scenario.txt +11 -0
  261. package/test/scenarios/identity.scenario.txt +11 -0
  262. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  263. package/test/scenarios/walkback.scenario.txt +12 -0
  264. package/dist/core/engine/compaction-hook.js +0 -154
@@ -1,40 +1,76 @@
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
- import { statSync } from 'node:fs';
4
+ import { realpathSync, statSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
5
6
  import { dirname, relative, resolve } from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import { AnvilEngineLoopClient } from '../core/engine/anvil-client.js';
8
- import { NoopEngineAdapter } from '../core/engine/noop.js';
9
9
  import { NativePugiEngineAdapter } from '../core/engine/native-pugi.js';
10
- import { 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 { ensureInitialized as ensureInitializedHelper } from '../core/onboarding/ensure-initialized.js';
36
+ import { ensureAuthenticated as ensureAuthenticatedHelper } from '../core/auth/ensure-authenticated.js';
26
37
  import { runPrivacyCommand } from './commands/privacy.js';
38
+ import { runReport } from './commands/report.js';
39
+ import { runDoctorCommand, defaultHome as defaultDoctorHome } from './commands/doctor.js';
40
+ import { parsePrdCheckArgs, runPrdCheckCommand, } from './commands/prd-check.js';
41
+ import { runChainCommand, } from './commands/chain.js';
42
+ import { runStatusCommand, defaultStatusHome, } from './commands/status.js';
43
+ import { runStickersCommand } from './commands/stickers.js';
44
+ import { runRepoMapCommand } from './commands/repo-map.js';
45
+ import { runReleaseNotesCommand, defaultReleaseNotesHome, } from './commands/release-notes.js';
27
46
  import { runUndoCommand } from './commands/undo.js';
47
+ import { runCompactCommand } from './commands/compact.js';
48
+ import { runRewindCommand } from './commands/rewind.js';
49
+ import { runSessionsCommand } from './commands/sessions.js';
50
+ // Day 4 ADR-0063: persona-memory operator surface (list / recall / write /
51
+ // forget / sync). The runner is shared by `pugi memory` top-level and the
52
+ // in-REPL `/memory` slash so the two surfaces stay single-sourced.
53
+ import { runMemoryCommand } from './commands/memory.js';
28
54
  import { runBudgetCommand } from './commands/budget.js';
55
+ import { BARE_MODE_BANNER, isBareMode, setBareMode, } from '../core/bare-mode/index.js';
56
+ import { runCostCommand } from './commands/cost.js';
57
+ import { runShareCommand } from './commands/share.js';
29
58
  import { runSkillsCommand } from './commands/skills.js';
59
+ import { runHooksCommand } from './commands/hooks.js';
60
+ import { installDefaultSkills } from '../core/skills/defaults.js';
30
61
  import { runAgentsCommand } from './commands/agents.js';
31
- // α7.7 lsp/patch/worktree command modules ship behind the α7.7
32
- // implementer PR (in-flight). The dispatchers below print a clean
33
- // "deferred" message so `pugi --help` still lists them without the
34
- // REPL crashing at module load. When α7.7 lands, restore the real
35
- // imports + delete the inline stubs.
62
+ import { runLspCommand } from './commands/lsp.js';
63
+ import { runPatchCommand } from './commands/patch.js';
64
+ import { runWorktreeCommand } from './commands/worktree.js';
65
+ import { runWorktreesCommand } from './commands/worktrees.js';
36
66
  import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
37
67
  import { runReviewConsensus } from './commands/review-consensus.js';
68
+ import { runMcpCommand } from './commands/mcp.js';
69
+ import { runPermissionsCommand } from './commands/permissions.js';
70
+ import { runPlanCommand } from './commands/plan.js';
71
+ import { parsePermissionMode } from '../core/permissions/index.js';
72
+ import { protectedTargetReason } from '../core/permission.js';
73
+ import { DECOMPOSE_PROMPT_SUFFIX, parseDecompositionFromText, writeDecomposition, } from './plan-decompose.js';
38
74
  import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
39
75
  import { slugForCwd } from '../core/repl/history.js';
40
76
  import { dispatchEdit, } from '../core/edits/index.js';
@@ -49,18 +85,39 @@ import { dispatchEdit, } from '../core/edits/index.js';
49
85
  * packages/pugi-sdk/package.json); the publish workflow validates the
50
86
  * three are in lockstep.
51
87
  */
52
- const PUGI_CLI_VERSION = "0.1.0-beta.5";
88
+ // PR-CLI-SERVER-VERSION-HANDSHAKE (#225). PUGI_CLI_VERSION lives in
89
+ // `runtime/version.ts` now so the engine transport interceptor can
90
+ // import it without dragging in the cli.ts module graph. Re-exported
91
+ // here under the original name so every existing reader (`pugi version`,
92
+ // `pugi doctor --json`, splash render, telemetry) keeps working with
93
+ // zero churn. Bumping the CLI version is still a single-file edit —
94
+ // just on `runtime/version.ts` instead of here. The β1 sanitizer that
95
+ // guarded against `workspace:*` leaks moved with the constant.
96
+ import { PUGI_CLI_VERSION, sanitizeSemver } from './version.js';
53
97
  const handlers = {
54
98
  accounts,
55
99
  agents: dispatchAgents,
56
100
  ask: dispatchAsk,
57
101
  build: runEngineTask('build_task'),
58
102
  budget: dispatchBudget,
103
+ // Wave 6 (2026-05-27): `pugi chain` walks the deterministic 7-step
104
+ // artifact pipeline (PRD → ADR → mindmap → ER → sequence → tests →
105
+ // code). Subcommands: new / status / next / show / export / list.
106
+ // Same handler powers the in-REPL `/chain` slash via session.ts.
107
+ chain: dispatchChain,
59
108
  code: runEngineTask('code'),
60
109
  config: dispatchConfig,
110
+ cost: dispatchCost,
111
+ delegate: dispatchDelegate,
112
+ // Leak L10 (2026-05-27): `pugi dispatch list-cache-refs` /
113
+ // `clear-cache-refs` operate on `.pugi/cache-refs/` — the persisted
114
+ // prompt-cache inheritance handles for fork-subagent dispatches. The
115
+ // handler module lives in commands/dispatch.ts so the table stays narrow.
116
+ dispatch: dispatchSubagentCacheRefs,
61
117
  deploy: dispatchDeploy,
62
118
  doctor,
63
119
  explain: runEngineTask('explain'),
120
+ hooks: dispatchHooks,
64
121
  fix: runEngineTask('fix'),
65
122
  handoff,
66
123
  help,
@@ -70,20 +127,97 @@ const handlers = {
70
127
  login,
71
128
  logout,
72
129
  lsp: dispatchLsp,
130
+ mcp: dispatchMcp,
131
+ // ADR-0063 Day 4: `pugi memory list|recall|write|forget|sync`. Routes
132
+ // to `runMemoryCommand` (admin-api `/api/persona-memory` + offline
133
+ // queue at `~/.pugi/memory-queue.jsonl`).
134
+ memory: dispatchMemory,
73
135
  patch: dispatchPatch,
74
- plan: runEngineTask('plan'),
136
+ permissions: dispatchPermissions,
137
+ perms: dispatchPermissions,
138
+ plan: dispatchPlan,
75
139
  'plan-review': dispatchPlanReview,
140
+ // Wave 6 (2026-05-27): `pugi prd-check` verifies PRD acceptance
141
+ // criteria against committed code/tests/docs/commands BEFORE an
142
+ // operator (or autonomous agent) claims a feature done. Same
143
+ // handler powers the in-REPL `/prd-check` slash via session.ts.
144
+ 'prd-check': dispatchPrdCheck,
76
145
  privacy: dispatchPrivacy,
146
+ // L24 (2026-05-27): `pugi release-notes` shows the bundled CHANGELOG
147
+ // diff between the operator's last-seen version + installed version.
148
+ // The slash counterpart `/release-notes` shares this handler via the
149
+ // shared `runReleaseNotesCommand` runner.
150
+ 'release-notes': releaseNotes,
151
+ releaseNotes,
152
+ // PAVF-7 (2026-05-27): `pugi report --from-error` captures the
153
+ // most-recent failed session as a redacted bundle so operators can
154
+ // file clean bug reports without manual log-grepping.
155
+ report: dispatchReport,
77
156
  review,
78
157
  resume,
158
+ roster: dispatchRoster,
79
159
  sessions,
160
+ share: dispatchShare,
80
161
  skills: dispatchSkills,
162
+ status,
163
+ stickers,
164
+ // Leak L28 (2026-05-27): `pugi repo-map` walks the source tree,
165
+ // extracts top-level function / class / interface / type / enum
166
+ // declarations + JSDoc summaries, caches the result in
167
+ // `.pugi/repo-map.json`, and renders the compact markdown listing.
168
+ // Same builder powers the engine boot-time system-prompt injection
169
+ // — running the CLI command shows the operator EXACTLY what the
170
+ // engine would see.
171
+ 'repo-map': dispatchRepoMap,
172
+ // Leak L21 (2026-05-27): in-CLI feedback collector. Shares the
173
+ // same handler as the in-REPL `/feedback` slash; the wrapper just
174
+ // routes TTY vs non-TTY before mounting Ink.
175
+ feedback: dispatchFeedback,
176
+ // BIG TRACK 10 Phase 1 (2026-05-27): `pugi smoke` runs the scenario
177
+ // corpus through `pugi --headless` and reports pass/fail per
178
+ // scenario. Subcommand-only — no slash counterpart per the Phase 1
179
+ // scope ("no new slash commands; harness is CLI subcommand only").
180
+ smoke: dispatchSmoke,
81
181
  sync,
182
+ style: dispatchStyle,
183
+ // Leak L30 (2026-05-27): `pugi theme` flips the local TUI color
184
+ // palette (orthogonal to `pugi style` — that one steers engine
185
+ // prose register). 4 presets: default / dark / light / colorblind.
186
+ theme: dispatchTheme,
187
+ // Leak L25 (2026-05-27): `pugi onboarding` walks the new operator
188
+ // through auth / mode / style / MCP / telemetry. Idempotent;
189
+ // `--reset` clears the marker file so the bare-invocation hint
190
+ // re-arms without nuking persisted defaults.
191
+ onboarding: dispatchOnboarding,
192
+ // Leak L26 (2026-05-27): `pugi vim` toggles vim-style modal editing
193
+ // in the REPL input buffer. Bare invocation toggles, `on`/`off`
194
+ // sets explicitly; preference persists in ~/.pugi/config.json.
195
+ vim: dispatchVim,
82
196
  undo: dispatchUndo,
197
+ compact: dispatchCompact,
198
+ // Leak L9 (2026-05-27): `pugi rewind [N | --to <id>]` rolls the
199
+ // conversation back to a checkpoint by appending a tombstone marker
200
+ // to the NDJSON event log. The slash counterpart `/rewind` forwards
201
+ // to the same runner via session.ts.
202
+ rewind: dispatchRewind,
203
+ // L19 (2026-05-27): `pugi usage` is an alias of `pugi cost` — same
204
+ // handler, same flags. Operators trained on Claude Code expect either
205
+ // verb to surface the per-model token + USD table.
206
+ usage: dispatchCost,
207
+ // Leak L27 (2026-05-27): `pugi update` — channel-aware npm registry
208
+ // probe + optional npm install shell-out. Same handler powers the
209
+ // in-REPL `/update` slash via the session module. R2 atomic swap
210
+ // deferred to Phase 2 per the sprint plan; npm is the single
211
+ // distribution channel today.
212
+ update: dispatchUpdate,
83
213
  version,
84
214
  web: dispatchWeb,
85
215
  whoami,
86
216
  worktree: dispatchWorktree,
217
+ // L23 (2026-05-27): `pugi worktrees <op>` (plural) — agent-bound
218
+ // worktree manager: `list`, `cleanup <agent-id>`, `cleanup --all-stale`.
219
+ // Distinct from the singular `pugi worktree` (UUID-keyed scratch).
220
+ worktrees: dispatchWorktrees,
87
221
  };
88
222
  /**
89
223
  * α6.3 `pugi ask "<question>"` — surface the office-hours forcing-question
@@ -254,6 +388,296 @@ async function dispatchPrivacy(args, flags, _session) {
254
388
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
255
389
  });
256
390
  }
391
+ /**
392
+ * ADR-0063 Day 4 — `pugi memory <sub>` top-level dispatcher.
393
+ *
394
+ * Forwards to the shared `runMemoryCommand` runner. Exit codes:
395
+ *
396
+ * - 0 — happy paths (listed / recalled / written / forgot / synced /
397
+ * queued_offline / sync_noop / sync_partial)
398
+ * - 1 — unauthenticated / feature_disabled / unknown_sub
399
+ * - 2 — invalid_args
400
+ *
401
+ * `forget_not_found` exits 0 because the operator-visible behaviour
402
+ * (the memory is gone) matches their intent; the JSON envelope still
403
+ * carries the `forget_not_found` status flag for scripted callers.
404
+ */
405
+ async function dispatchMemory(args, flags, _session) {
406
+ const result = await runMemoryCommand(args, {
407
+ workspaceRoot: process.cwd(),
408
+ json: flags.json,
409
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
410
+ });
411
+ switch (result.status) {
412
+ case 'unauthenticated':
413
+ case 'feature_disabled':
414
+ case 'unknown_sub':
415
+ process.exitCode = 1;
416
+ return;
417
+ case 'invalid_args':
418
+ process.exitCode = 2;
419
+ return;
420
+ default:
421
+ // 'listed' | 'recalled' | 'written' | 'queued_offline' | 'forgot' |
422
+ // 'forget_not_found' | 'synced' | 'sync_partial' | 'sync_noop' — exit 0.
423
+ return;
424
+ }
425
+ }
426
+ /**
427
+ * Leak L18 (2026-05-27) — `pugi style` top-level dispatcher.
428
+ *
429
+ * Forwards to the shared `runStyleCommand` runner. The REPL `/style`
430
+ * slash uses the same runner via a dynamic import inside
431
+ * `core/repl/session.ts` so the two surfaces stay single-sourced.
432
+ *
433
+ * Exit-code policy:
434
+ * - 0 — show / switch / reset / list happy paths
435
+ * - 1 — unknown preset slug
436
+ * - 2 — conflicting flags (`--reset` + positional / `--reset --persist`)
437
+ *
438
+ * The runner returns the code; we attach it to `process.exitCode` so
439
+ * subsequent dispatch wrappers do not clobber it on success.
440
+ */
441
+ async function dispatchStyle(args, flags, _session) {
442
+ const rc = await runStyleCommand(args, {
443
+ workspaceRoot: process.cwd(),
444
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
445
+ });
446
+ if (rc !== 0)
447
+ process.exitCode = rc;
448
+ }
449
+ /**
450
+ * Leak L30 (2026-05-27) — `pugi theme` top-level dispatcher.
451
+ *
452
+ * Forwards to the shared `runThemeCommand` runner. The REPL `/theme`
453
+ * slash uses the same runner via a dynamic import inside
454
+ * `core/repl/session.ts` so the two surfaces stay single-sourced.
455
+ *
456
+ * Exit-code policy mirrors `dispatchStyle`:
457
+ * - 0 — show / switch / reset / list happy paths
458
+ * - 1 — unknown preset slug
459
+ * - 2 — conflicting flags (`--reset` + positional / `--reset --persist`)
460
+ *
461
+ * The runner returns the code; we attach it to `process.exitCode` so
462
+ * subsequent dispatch wrappers do not clobber it on success.
463
+ */
464
+ /**
465
+ * Leak L12 (2026-05-27) — `pugi hooks` top-level dispatcher (MVP).
466
+ *
467
+ * Two subcommands:
468
+ * - `pugi hooks list` — show configured hooks per event.
469
+ * - `pugi hooks doctor` — validate `~/.pugi/hooks-mvp.json`.
470
+ *
471
+ * MVP scope: 2 events of 8 (SessionStart + PreToolUse). Remaining 6
472
+ * events (PostToolUse, UserPromptSubmit, Stop, SubagentStop,
473
+ * PreCompact, Notification) deferred to fast-follow PR. The runner
474
+ * pattern established here is reusable for those events without
475
+ * touching this dispatcher.
476
+ *
477
+ * Exit codes:
478
+ * 0 -> happy path.
479
+ * 1 -> config present but invalid (doctor only).
480
+ * 2 -> argument error / unknown subcommand.
481
+ */
482
+ async function dispatchHooks(args, flags, _session) {
483
+ const rc = await runHooksCommand(args, {
484
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
485
+ });
486
+ if (rc !== 0)
487
+ process.exitCode = rc;
488
+ }
489
+ async function dispatchTheme(args, flags, _session) {
490
+ const rc = await runThemeCommand(args, {
491
+ workspaceRoot: process.cwd(),
492
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
493
+ });
494
+ if (rc !== 0)
495
+ process.exitCode = rc;
496
+ }
497
+ /**
498
+ * BIG TRACK 10 Phase 1 (2026-05-27) — `pugi smoke` top-level dispatcher.
499
+ *
500
+ * Loads the bundled scenario corpus (`apps/pugi-cli/test/scenarios/`),
501
+ * runs each scenario through `pugi --headless` via the smoke
502
+ * orchestrator, and surfaces the pass/fail summary. `--filter <pat>`
503
+ * subsets the corpus; `--scenarios-dir <path>` swaps in an external
504
+ * dir (handy for project-local scenarios in customer repos).
505
+ *
506
+ * Exit-code policy:
507
+ * 0 — every scenario passed (or filter matched nothing)
508
+ * 1 — at least one scenario failed (assertion, parse error, executor crash)
509
+ * 2 — invalid CLI args (--filter without a value, unknown flag)
510
+ */
511
+ async function dispatchSmoke(args, flags, _session) {
512
+ const { runSmokeCommand } = await import('../commands/smoke.js');
513
+ const ctx = {
514
+ args,
515
+ json: flags.json,
516
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
517
+ };
518
+ if (flags.smokeFilter !== undefined)
519
+ ctx.filter = flags.smokeFilter;
520
+ const rc = await runSmokeCommand(ctx);
521
+ if (rc !== 0)
522
+ process.exitCode = rc;
523
+ }
524
+ /**
525
+ * Leak L25 (2026-05-27) — `pugi onboarding` top-level dispatcher.
526
+ *
527
+ * Walks the new operator through auth / permission mode / output
528
+ * style / MCP / telemetry consent. The Ink wizard mounts only when
529
+ * stdin is a TTY and `--json` is not set; otherwise we dump the
530
+ * current snapshot + hints in the non-interactive envelope so
531
+ * scripted callers see the same structured payload.
532
+ *
533
+ * Auth status: we resolve credentials once up front and pass the
534
+ * boolean to the runner; the wizard surfaces a `pugi login` hint
535
+ * when auth is missing but DOES NOT block — local defaults are still
536
+ * configurable without an active credential.
537
+ *
538
+ * Exit-code policy:
539
+ * 0 — completed / cancelled / non-interactive / reset
540
+ * 2 — conflicting / unknown flags
541
+ */
542
+ async function dispatchOnboarding(args, flags, _session) {
543
+ const credential = resolveActiveCredential();
544
+ const rc = await runOnboardingCommand(args, {
545
+ workspaceRoot: process.cwd(),
546
+ env: process.env,
547
+ authPresent: credential !== null,
548
+ interactive: isInteractive(flags) && !flags.json,
549
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
550
+ });
551
+ if (rc !== 0)
552
+ process.exitCode = rc;
553
+ }
554
+ /**
555
+ * Leak L26 (2026-05-27) — `pugi vim` top-level dispatcher.
556
+ *
557
+ * Forwards to the shared `runVimCommand` runner. The REPL `/vim` slash
558
+ * uses the same runner via a dynamic import inside
559
+ * `core/repl/session.ts` so the two surfaces stay single-sourced.
560
+ *
561
+ * Exit-code policy:
562
+ * - 0 — show / enable / disable / toggle happy paths
563
+ * - 2 — unknown subcommand (e.g. `pugi vim chaos`) or too many args
564
+ */
565
+ async function dispatchVim(args, flags, _session) {
566
+ const rc = await runVimCommand(args, {
567
+ env: process.env,
568
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
569
+ });
570
+ if (rc !== 0)
571
+ process.exitCode = rc;
572
+ }
573
+ /**
574
+ * PAVF-7 (2026-05-27): `pugi report --from-error` — bundle the most-
575
+ * recent failed session into a redacted local report so operators can
576
+ * file clean bug tickets without manual log-grepping. v1 is local-only
577
+ * (no auto-upload — see commands/report.ts header for the rationale).
578
+ */
579
+ async function dispatchReport(args, flags, _session) {
580
+ const rc = runReport(args, {
581
+ cwd: process.cwd(),
582
+ json: flags.json,
583
+ emit: (line) => {
584
+ if (!flags.json)
585
+ process.stdout.write(line);
586
+ },
587
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
588
+ });
589
+ if (rc !== 0)
590
+ process.exitCode = rc;
591
+ }
592
+ /**
593
+ * `pugi roster` - α7.5 Phase 1.
594
+ *
595
+ * List the live Tier 1 personas with display name, role, and routing
596
+ * tag. Walks the remote /api/pugi/sessions/roster endpoint when a
597
+ * credential is available; falls back to the local @pugi/personas
598
+ * roster when offline so the operator can still see who is on the team.
599
+ */
600
+ async function dispatchRoster(_args, flags, _session) {
601
+ const credential = resolveActiveCredential();
602
+ const config = credential
603
+ ? buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey })
604
+ : null;
605
+ const { rows, warning } = await resolveRoster(config);
606
+ const payload = {
607
+ ok: true,
608
+ personas: rows,
609
+ warning,
610
+ };
611
+ const text = (warning ? `# warning: ${warning}\n\n` : '') +
612
+ renderRosterTable(rows);
613
+ writeOutput(flags, payload, text);
614
+ }
615
+ /**
616
+ * `pugi delegate <slug> "<brief>"` - α7.5 Phase 1.
617
+ *
618
+ * Open a fresh REPL session and POST the brief to one Tier 1 persona,
619
+ * bypassing Mira's coordinator pass. Non-interactive: the CLI prints
620
+ * the dispatch id on success and exits; the operator (or a script) can
621
+ * subscribe to the session stream separately if they want the live
622
+ * lifecycle. Interactive operators use `/delegate` from inside the REPL
623
+ * instead so the dispatch lifecycle surfaces inline.
624
+ */
625
+ async function dispatchDelegate(args, flags, _session) {
626
+ await runDelegateCommand(args, {
627
+ workspaceCwd: process.cwd(),
628
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
629
+ resolveConfig: () => {
630
+ const credential = resolveActiveCredential();
631
+ if (!credential)
632
+ return null;
633
+ return buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey });
634
+ },
635
+ fetchRoster: fetchPersonaRoster,
636
+ submitDelegate,
637
+ openSession: async (config, workspaceCwd) => {
638
+ const result = await openPugiSession(config, { workspaceCwd });
639
+ if (result.status === 'ok')
640
+ return { sessionId: result.response.sessionId };
641
+ return { error: `${result.status}: ${result.message}` };
642
+ },
643
+ });
644
+ }
645
+ /**
646
+ * `pugi chain` — Wave 6 artifact chain dispatcher (2026-05-27).
647
+ * Forwards to `runChainCommand` with the live credential + session
648
+ * opener wired so the dispatcher can hit Anvil. The slash counterpart
649
+ * `/chain` shares the same handler via session.ts so the surface
650
+ * stays single-sourced.
651
+ */
652
+ async function dispatchChain(args, flags, _session) {
653
+ const root = process.cwd();
654
+ // Wave 6 UX: chain reads / writes `.pugi/chains/*` so the auto-init
655
+ // pre-flight matches the engine commands. Auto-login resolves so a
656
+ // first-run `pugi chain new` from a cold cwd surfaces a login prompt
657
+ // instead of a silent unauthenticated error one layer deeper.
658
+ await runAutoInitPreflight(root, flags);
659
+ const auth = await runAutoAuthPreflight(flags);
660
+ const cachedCred = auth.status === 'ready' ? auth.credential : null;
661
+ await runChainCommand(args, {
662
+ cwd: root,
663
+ json: flags.json,
664
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
665
+ resolveConfig: () => {
666
+ // Prefer the pre-flight cached credential to avoid the second
667
+ // disk read (resolveActiveCredential reads ~/.pugi/credentials.json).
668
+ const credential = cachedCred ?? resolveActiveCredential();
669
+ if (!credential)
670
+ return null;
671
+ return buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey });
672
+ },
673
+ openSession: async (config, workspaceCwd) => {
674
+ const result = await openPugiSession(config, { workspaceCwd });
675
+ if (result.status === 'ok')
676
+ return { sessionId: result.response.sessionId };
677
+ return { error: `${result.status}: ${result.message}` };
678
+ },
679
+ });
680
+ }
257
681
  async function dispatchUndo(args, flags, session) {
258
682
  await runUndoCommand(args, {
259
683
  workspaceRoot: process.cwd(),
@@ -261,12 +685,261 @@ async function dispatchUndo(args, flags, session) {
261
685
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
262
686
  });
263
687
  }
688
+ /**
689
+ * Leak L8 (2026-05-27) — `pugi compact` summarises older REPL turns
690
+ * into a single boundary marker, freeing context for the next `pugi
691
+ * resume <id>`. The slash `/compact` inside a live REPL forwards
692
+ * through the same runner via session.ts so the surface stays single-
693
+ * sourced.
694
+ */
695
+ async function dispatchCompact(args, flags, _session) {
696
+ // Wave 6 BT 8 (Claude Code parity): parse `--force` / `-f` so the
697
+ // operator can produce a marker against a short session. Auto-trigger
698
+ // paths never pass this flag — only the explicit CLI / slash invocation.
699
+ const force = args.some((t) => t === '--force' || t === '-f');
700
+ const result = await runCompactCommand(args, {
701
+ workspaceRoot: process.cwd(),
702
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
703
+ force,
704
+ });
705
+ if (result.status === 'failed_no_session'
706
+ || result.status === 'failed_transport'
707
+ || result.status === 'failed_store') {
708
+ process.exitCode = 1;
709
+ return;
710
+ }
711
+ if (result.status === 'noop_empty' || result.status === 'noop_recent_marker') {
712
+ process.exitCode = 2;
713
+ }
714
+ }
264
715
  async function dispatchBudget(args, flags, _session) {
265
716
  await runBudgetCommand(args, {
266
717
  workspaceRoot: process.cwd(),
267
718
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
268
719
  });
269
720
  }
721
+ /**
722
+ * Leak L9 (2026-05-27) — `pugi rewind [N | --to <id>]` rolls the
723
+ * conversation back to a checkpoint by appending a tombstone marker to
724
+ * the NDJSON event log. Append-only: events stay durable; `pugi
725
+ * sessions undo-rewind` reverses the operation. The slash `/rewind`
726
+ * forwards through this same runner via session.ts.
727
+ */
728
+ async function dispatchRewind(args, flags, _session) {
729
+ const result = await runRewindCommand(args, {
730
+ workspaceRoot: process.cwd(),
731
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
732
+ });
733
+ if (result.status === 'failed_no_session'
734
+ || result.status === 'failed_store') {
735
+ process.exitCode = 1;
736
+ return;
737
+ }
738
+ if (result.status === 'failed_parse') {
739
+ process.exitCode = 2;
740
+ return;
741
+ }
742
+ if (result.status === 'noop_zero' || result.status === 'noop_empty') {
743
+ process.exitCode = 2;
744
+ }
745
+ }
746
+ /**
747
+ * Leak L6 — `pugi permissions [mode] [--persist] [--confirm]`.
748
+ *
749
+ * Surface the same intent as the in-REPL `/permissions` slash. Mode
750
+ * arg is positional; `--persist` and `--confirm` are zero-arg flags
751
+ * already consumed by `parseArgs` into `flags.persist` / `flags.confirm`.
752
+ *
753
+ * Examples:
754
+ * pugi permissions -> show current mode + table
755
+ * pugi permissions plan -> flip workspace state to plan
756
+ * pugi permissions allow --persist -> flip + write ~/.pugi/config.json
757
+ * pugi permissions bypass --confirm -> flip to bypass (acknowledge banner)
758
+ */
759
+ async function dispatchPermissions(args, flags, _session) {
760
+ const head = args[0];
761
+ if (head && parsePermissionMode(head) === null) {
762
+ writeOutput(flags, { error: 'unknown_mode', mode: head }, `Unknown mode '${head}'. Allowed: default, acceptEdits, plan, auto, dontAsk, bypassPermissions (α6 aliases ask/allow/bypass accepted).`);
763
+ process.exitCode = 1;
764
+ return;
765
+ }
766
+ const mode = head ? parsePermissionMode(head) : undefined;
767
+ // Wave 6 cleanup (2026-05-27): no positional mode + interactive TTY
768
+ // → mount the Ink picker so the operator can arrow-select. Falls back
769
+ // to the legacy text table on non-TTY / --json / CI so scripted
770
+ // callers (and the deferred follow-up from PR #617) keep working.
771
+ // `bypass` selected from the picker still routes through
772
+ // `runPermissionsCommand` with `confirmBypass: true` — the picker IS
773
+ // the confirm gesture (arrow + Enter is the explicit acknowledge).
774
+ if (!mode && isInteractive(flags) && !flags.json) {
775
+ const { resolveLayeredMode } = await import('./commands/permissions.js');
776
+ const layered = resolveLayeredMode(process.cwd());
777
+ const { renderPermissionsPicker, PermissionsPickerCancelledError } = await import('../tui/render.js');
778
+ try {
779
+ const chosen = await renderPermissionsPicker({
780
+ currentMode: layered.effective,
781
+ sourceLabel: layered.source,
782
+ firstRun: layered.firstRun,
783
+ });
784
+ await runPermissionsCommand({
785
+ mode: chosen,
786
+ persist: Boolean(flags.persist),
787
+ // The picker selection IS the confirm gesture for `bypassPermissions`.
788
+ confirmBypass: chosen === 'bypassPermissions' ? true : Boolean(flags.confirm),
789
+ }, {
790
+ workspaceRoot: process.cwd(),
791
+ writeOutput: (text) => writeOutput(flags, { text }, text),
792
+ });
793
+ return;
794
+ }
795
+ catch (err) {
796
+ if (err instanceof PermissionsPickerCancelledError) {
797
+ writeOutput(flags, { cancelled: true }, 'Permissions picker cancelled. No change.');
798
+ return;
799
+ }
800
+ throw err;
801
+ }
802
+ }
803
+ await runPermissionsCommand({
804
+ ...(mode ? { mode } : {}),
805
+ persist: Boolean(flags.persist),
806
+ confirmBypass: Boolean(flags.confirm),
807
+ }, {
808
+ workspaceRoot: process.cwd(),
809
+ writeOutput: (text) => writeOutput(flags, { text }, text),
810
+ });
811
+ }
812
+ /**
813
+ * L19 sprint (2026-05-27): `pugi cost` / `pugi usage` top-level surface.
814
+ *
815
+ * Aliased through the handlers table so `pugi usage` reuses the same
816
+ * implementation. The persisted store lives at `<cwd>/.pugi/cost.json`
817
+ * and is shared with the REPL `/cost` / `/usage` slash handlers.
818
+ */
819
+ async function dispatchCost(args, flags, _session) {
820
+ await runCostCommand(args, {
821
+ workspaceRoot: process.cwd(),
822
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
823
+ });
824
+ }
825
+ /**
826
+ * Leak L20 (2026-05-27): `pugi share` top-level surface. Exports the
827
+ * current session transcript as Markdown to gist (default when `gh` is
828
+ * available) or pugi.io (--pugi). The handler delegates to
829
+ * `runShareCommand` so the slash surface (`/share`) and the shell
830
+ * surface share one code path. JSON output mode is honoured via the
831
+ * shared `writeOutput` wrapper.
832
+ */
833
+ async function dispatchShare(args, flags, _session) {
834
+ await runShareCommand(args, {
835
+ workspaceRoot: process.cwd(),
836
+ cliVersion: PUGI_CLI_VERSION,
837
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
838
+ });
839
+ }
840
+ /**
841
+ * Leak L7 — `pugi plan [--back | --persist | <prompt...>]`.
842
+ *
843
+ * Quick mode-switch shortcut + optional one-shot engine dispatch. Slash
844
+ * surface `/plan` shares the same `runPlanCommand` helper so the
845
+ * workspace-state writes go through one code path. Argument grammar:
846
+ *
847
+ * pugi plan -> set workspace mode = plan + banner
848
+ * pugi plan --back -> restore the mode that was active
849
+ * before the most recent /plan entry
850
+ * pugi plan --persist -> set + also write ~/.pugi/config.json
851
+ * pugi plan <prompt...> -> set + run `runEngineTask('plan')`
852
+ * with the prompt (existing offline /
853
+ * engine path; the permission gate now
854
+ * sees plan as workspace state)
855
+ * pugi plan <prompt> --auto-back -> ALSO restore previous mode once
856
+ * the engine returns (defaults to
857
+ * leaving the operator in plan
858
+ * mode so they can iterate)
859
+ *
860
+ * The handler intentionally intercepts the mode-switch flags BEFORE
861
+ * delegating to `runEngineTask('plan')` for the prompt path. Without
862
+ * this wrapper, `pugi plan` (no args) would error out of the engine
863
+ * task ("requires a prompt") which is the legacy behaviour; the L7
864
+ * spec wants bare `pugi plan` to be the mode switch.
865
+ */
866
+ async function dispatchPlan(args, flags, session) {
867
+ // Strip `--back` / `--auto-back` from the positional args — the global
868
+ // parseArgs does not consume them (they are command-local). Anything
869
+ // else stays in `prompt` so the engine sees the operator's text
870
+ // verbatim. The flag parser keeps both `--back` and the spelling
871
+ // variants the operator might type from muscle memory after using
872
+ // `git checkout --` style flows.
873
+ let back = false;
874
+ let autoBack = false;
875
+ const remaining = [];
876
+ for (const arg of args) {
877
+ if (arg === '--back') {
878
+ back = true;
879
+ }
880
+ else if (arg === '--auto-back') {
881
+ autoBack = true;
882
+ }
883
+ else {
884
+ remaining.push(arg);
885
+ }
886
+ }
887
+ const hasPrompt = remaining.length > 0;
888
+ const persist = Boolean(flags.persist);
889
+ // --back and a prompt are mutually exclusive — back is a revert action,
890
+ // not a dispatch one. Refuse the combination with a clear hint instead
891
+ // of silently dropping one or the other.
892
+ if (back && hasPrompt) {
893
+ 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.');
894
+ process.exitCode = 2;
895
+ return;
896
+ }
897
+ // --back + --auto-back is incoherent (auto-back applies to the
898
+ // dispatch path) — refuse rather than degrade silently.
899
+ if (back && autoBack) {
900
+ writeOutput(flags, { ok: false, error: 'pugi plan --back and --auto-back cannot be combined.' }, 'pugi plan --back and --auto-back cannot be combined.');
901
+ process.exitCode = 2;
902
+ return;
903
+ }
904
+ // When a prompt is going to be dispatched in --json mode, suppress
905
+ // the human-readable banner writes so the engine task remains the
906
+ // single JSON emitter on stdout. The mode write still happens. In
907
+ // human (non --json) mode the banner prints normally so the operator
908
+ // sees the gate-state change before the engine starts thinking.
909
+ const sinkSilent = hasPrompt && flags.json;
910
+ const writeLine = (line) => {
911
+ if (sinkSilent)
912
+ return;
913
+ writeOutput(flags, { text: line }, line);
914
+ };
915
+ const result = await runPlanCommand({ back, persist }, {
916
+ workspaceRoot: process.cwd(),
917
+ writeOutput: writeLine,
918
+ });
919
+ // No prompt → mode-switch only. Done.
920
+ if (!hasPrompt)
921
+ return;
922
+ // Prompt present → fall through to the existing engine task with the
923
+ // remaining args. The workspace mode is now `plan` (or stayed `plan`
924
+ // if already there); the engine sees the same plan-task semantics it
925
+ // always has — read-only schema + executor refusal sentinel — but the
926
+ // permission GATE now also enforces plan independently.
927
+ try {
928
+ await runEngineTask('plan')(remaining, flags, session);
929
+ }
930
+ finally {
931
+ // --auto-back restores the previous mode AFTER the engine returns
932
+ // (success OR failure) so the operator's gate state mirrors a normal
933
+ // `--back` invocation. Without --auto-back the operator stays in
934
+ // plan and can iterate / inspect before acting.
935
+ if (autoBack && (result.verdict === 'entered' || result.verdict === 'persisted')) {
936
+ await runPlanCommand({ back: true, persist: false }, {
937
+ workspaceRoot: process.cwd(),
938
+ writeOutput: writeLine,
939
+ });
940
+ }
941
+ }
942
+ }
270
943
  async function dispatchSkills(args, flags, _session) {
271
944
  await runSkillsCommand(args, {
272
945
  workspaceRoot: process.cwd(),
@@ -281,6 +954,19 @@ async function dispatchAgents(args, flags, _session) {
281
954
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
282
955
  });
283
956
  }
957
+ /**
958
+ * Leak L10 (2026-05-27): `pugi dispatch <sub>` — operator-facing
959
+ * inspection + GC for fork-subagent prompt-cache inherit refs
960
+ * (.pugi/cache-refs/). Delegates to the standalone runner in
961
+ * commands/dispatch.ts so the cli.ts table stays under control.
962
+ */
963
+ async function dispatchSubagentCacheRefs(args, flags, _session) {
964
+ await runDispatchCommand(args, {
965
+ workspaceRoot: process.cwd(),
966
+ json: flags.json,
967
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
968
+ });
969
+ }
284
970
  /**
285
971
  * `pugi web <url>` — Sprint α6.15 Phase 1 quick-win subcommand.
286
972
  *
@@ -328,13 +1014,30 @@ async function dispatchWeb(args, flags, _session) {
328
1014
  * dispatch table stays narrow. The runner spawns + tears down the LSP
329
1015
  * server per invocation (no daemon yet — that ships in α7.7b).
330
1016
  */
331
- async function dispatchLsp(_args, flags, _session) {
332
- const msg = 'pugi lsp ships in alpha 7.7 (in-flight). Run `pugi --help` for current surface.';
333
- if (flags.json)
334
- console.log(JSON.stringify({ ok: false, code: 'deferred', message: msg }));
335
- else
336
- console.log(msg);
337
- process.exitCode = 6;
1017
+ async function dispatchLsp(args, flags, _session) {
1018
+ const result = await runLspCommand(args, { cwd: process.cwd(), json: flags.json });
1019
+ console.log(result.text);
1020
+ if (result.exitCode !== 0)
1021
+ process.exitCode = result.exitCode;
1022
+ }
1023
+ /**
1024
+ * β4 M6 + M7 + Sl7 (2026-05-26): `pugi mcp <sub>` — MCP execution +
1025
+ * server. `list / trust / deny / install` manage the client-side
1026
+ * registry (the same surface `pugi config mcp ...` exposes); `serve`
1027
+ * boots Pugi-as-MCP-server over stdio (default) or HTTP+SSE; `perms`
1028
+ * inspects + resets the per-(server, tool) permission cache that
1029
+ * gates engine-loop dispatch.
1030
+ *
1031
+ * The serve sub-command never returns under normal conditions — the
1032
+ * stdio path runs until stdin closes (parent agent disconnect) and the
1033
+ * HTTP path runs until SIGINT/SIGTERM. Both honour the optional
1034
+ * AbortSignal we pass through from the REPL slash bridge in β4b.
1035
+ */
1036
+ async function dispatchMcp(args, flags, _session) {
1037
+ await runMcpCommand(args, {
1038
+ workspaceRoot: process.cwd(),
1039
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
1040
+ });
338
1041
  }
339
1042
  /**
340
1043
  * α7.7: `pugi patch` — apply a unified-diff patch from stdin or a file.
@@ -342,31 +1045,139 @@ async function dispatchLsp(_args, flags, _session) {
342
1045
  * (see `src/core/edits/security-gate.ts`). Exit codes mirror the
343
1046
  * security taxonomy so CI loops can alert on hostile patches without
344
1047
  * confusing them with operator typos.
1048
+ *
1049
+ * R1 fix (2026-05-26, PR #413 r1): pass `flags.dryRun` through so the
1050
+ * top-level parser's consumption of `--dry-run` does not silently
1051
+ * disable dry-run mode on `pugi patch --dry-run < diff.patch`.
345
1052
  */
346
- async function dispatchPatch(_args, flags, _session) {
347
- const msg = 'pugi patch ships in alpha 7.7 (in-flight). Run `pugi --help` for current surface.';
348
- if (flags.json)
349
- console.log(JSON.stringify({ ok: false, code: 'deferred', message: msg }));
350
- else
351
- console.log(msg);
352
- process.exitCode = 6;
1053
+ async function dispatchPatch(args, flags, _session) {
1054
+ const result = await runPatchCommand(args, {
1055
+ cwd: process.cwd(),
1056
+ json: flags.json,
1057
+ dryRun: flags.dryRun,
1058
+ });
1059
+ console.log(result.text);
1060
+ if (result.exitCode !== 0)
1061
+ process.exitCode = result.exitCode;
353
1062
  }
354
1063
  /**
355
1064
  * α7.7: `pugi worktree <op>` — manual scratch worktree management.
356
1065
  * The `pugi build` and `pugi review --consensus` paths use the same
357
1066
  * primitives internally (`createWorktree` / `promoteWorktree`); this
358
1067
  * surface is the operator escape hatch for debug + experiment flows.
1068
+ *
1069
+ * R1 fix (2026-05-26, PR #413 r1): forward `flags.dryRun` so the
1070
+ * top-level parser's consumption of `--dry-run` does not silently
1071
+ * disable dry-run mode on `pugi worktree promote --dry-run <path>`.
359
1072
  */
360
- async function dispatchWorktree(_args, flags, _session) {
361
- const msg = 'pugi worktree ships in alpha 7.7 (in-flight). Run `pugi --help` for current surface.';
362
- if (flags.json)
363
- console.log(JSON.stringify({ ok: false, code: 'deferred', message: msg }));
364
- else
365
- console.log(msg);
366
- process.exitCode = 6;
1073
+ async function dispatchWorktree(args, flags, _session) {
1074
+ const result = await runWorktreeCommand(args, {
1075
+ cwd: process.cwd(),
1076
+ json: flags.json,
1077
+ dryRun: flags.dryRun,
1078
+ });
1079
+ console.log(result.text);
1080
+ if (result.exitCode !== 0)
1081
+ process.exitCode = result.exitCode;
1082
+ }
1083
+ /**
1084
+ * L23 (2026-05-27): `pugi worktrees <op>` — agent-bound worktree
1085
+ * manager surface. Distinct from singular `pugi worktree` which
1086
+ * manages UUID-keyed scratch worktrees for the manual create/promote/
1087
+ * drop flow. The plural surface keys worktrees by agent id (the same
1088
+ * id `.pugi/agent-progress/<id>.json` uses) so an operator can
1089
+ * correlate a worktree with the in-flight agent driving it.
1090
+ */
1091
+ async function dispatchWorktrees(args, flags, _session) {
1092
+ const result = await runWorktreesCommand(args, {
1093
+ cwd: process.cwd(),
1094
+ json: flags.json,
1095
+ dryRun: flags.dryRun,
1096
+ });
1097
+ console.log(result.text);
1098
+ if (result.exitCode !== 0)
1099
+ process.exitCode = result.exitCode;
367
1100
  }
368
1101
  export async function runCli(argv) {
369
1102
  const { command, args, flags, isBareInvocation } = parseArgs(argv);
1103
+ // Leak L22 — print the one-line bare banner once per invocation when
1104
+ // the flag is active and stdout is NOT bound for JSON consumption. The
1105
+ // banner goes to stderr so it never lands in a `--json` envelope or a
1106
+ // pipe-captured stdout stream; operators see it on the terminal,
1107
+ // scripted callers stay clean. Suppressed for `pugi version` / `pugi
1108
+ // help` (short, scripted-friendly surfaces) and when the operator
1109
+ // sets PUGI_BARE without the flag (avoids double-printing across
1110
+ // scripted nested invocations).
1111
+ if (flags.bare &&
1112
+ !flags.json &&
1113
+ command !== 'version' &&
1114
+ command !== 'help' &&
1115
+ argv.includes('--bare')) {
1116
+ process.stderr.write(`${BARE_MODE_BANNER}\n`);
1117
+ }
1118
+ // β-headless dispatch (CEO directive 2026-05-27 "нужно тестирование по
1119
+ // кругу"): when `--print <brief>` is set we route to the headless
1120
+ // runner BEFORE the REPL / splash / command branches. The runner
1121
+ // never mounts Ink, never opens raw stdin, never prints the splash
1122
+ // — only the structured event stream lands on stdout. Same engine
1123
+ // adapter path the REPL uses (no fork), only the output sink
1124
+ // differs.
1125
+ if (typeof flags.print === 'string') {
1126
+ const { runHeadlessPrint } = await import('./headless.js');
1127
+ // Default to NDJSON when stdout is not a TTY OR when --json is set
1128
+ // explicitly. A human running `pugi --print "..."` in their
1129
+ // terminal without flags gets the readable text sink; a pipe gets
1130
+ // the machine-readable stream.
1131
+ const wantJson = flags.json || !process.stdout.isTTY;
1132
+ const headlessFactory = getEngineClientFactory();
1133
+ const exitCode = await runHeadlessPrint({
1134
+ prompt: flags.print,
1135
+ json: wantJson,
1136
+ cwd: flags.cwd ?? process.cwd(),
1137
+ ...(flags.workspace ? { workspace: flags.workspace } : {}),
1138
+ ...(flags.sessionId ? { sessionIdOverride: flags.sessionId } : {}),
1139
+ ...(flags.timeoutSeconds ? { timeoutSeconds: flags.timeoutSeconds } : {}),
1140
+ noTools: flags.noTools,
1141
+ ...(flags.maxTurns ? { maxTurns: flags.maxTurns } : {}),
1142
+ ...(headlessFactory ? { engineClientFactory: headlessFactory } : {}),
1143
+ ...(headlessStdoutWriter ? { stdoutWrite: headlessStdoutWriter } : {}),
1144
+ ...(headlessStderrWriter ? { stderrWrite: headlessStderrWriter } : {}),
1145
+ });
1146
+ process.exitCode = exitCode;
1147
+ return;
1148
+ }
1149
+ // BIG TRACK 10 Phase 1 (2026-05-27) — `--headless` flag. When the
1150
+ // operator (or harness) passes `--headless` on a bare/repl
1151
+ // invocation we route into the multi-turn line-by-line headless
1152
+ // loop. Differs from `--print` (one-shot): headless reads stdin
1153
+ // until close. The dispatch lives BEFORE the REPL / splash branches
1154
+ // so the Ink TUI never mounts. Suppressed when `--print` is also
1155
+ // set (the one-shot variant wins — explicit single-turn overrides
1156
+ // the multi-turn loop).
1157
+ if (flags.headless && typeof flags.print !== 'string') {
1158
+ const { runHeadlessRepl } = await import('./headless-repl.js');
1159
+ const exitCode = await runHeadlessRepl({
1160
+ cwd: flags.cwd ?? process.cwd(),
1161
+ });
1162
+ process.exitCode = exitCode;
1163
+ return;
1164
+ }
1165
+ // CEO P0 escalation #2 (2026-05-29) — boot polish.
1166
+ //
1167
+ // Leak L25 (2026-05-27) used к drop a one-line stderr hint
1168
+ // ("Tip: run `pugi onboarding` to configure defaults.") BEFORE the
1169
+ // REPL mounted. Combined with beta.46's surfaced "(Y/n)" prompt the
1170
+ // boot read as a noisy three-line vault door instead of Claude
1171
+ // Code's silent, friendly welcome card. The new `WelcomeBanner` in
1172
+ // the REPL surfaces the "/init" tip inline (right column) so this
1173
+ // pre-Ink stderr write is redundant — suppress it on the bare REPL
1174
+ // path. The hint stays available for any future non-REPL bare entry
1175
+ // (no current callers, but keep the helper for symmetry).
1176
+ //
1177
+ // Leaving the suppression here as a hard branch (no env override)
1178
+ // because the welcome banner is the authoritative surface — a stray
1179
+ // stderr line above the alt-screen flicker would race against the
1180
+ // banner paint on slow terminals.
370
1181
  // Bare `pugi` on a TTY enters the REPL-by-default agentic session
371
1182
  // (Sprint α5.7, ADR-0056). The REPL is the customer-facing surface
372
1183
  // that brings Pugi to parity with Claude Code / Codex CLI. When the
@@ -382,6 +1193,40 @@ export async function runCli(argv) {
382
1193
  // Propagating via env keeps the session module transport-free.
383
1194
  if (flags.allowFetch)
384
1195
  process.env.PUGI_ALLOW_FETCH = '1';
1196
+ // CEO P0 escalation #2 (2026-05-29) — silent auto-init.
1197
+ //
1198
+ // PR #628 + the first P0 fix wired `runReplAutoInitPreflight`
1199
+ // into the bare REPL boot path. That helper PROMPTED
1200
+ // "Initialize a new Pugi workspace here? (Y/n)" before mounting
1201
+ // Ink, which on a TTY in a fresh dir read as a noisy gate the
1202
+ // operator had to consciously accept just к see Pugi. The CEO
1203
+ // dogfood transcript called this out as visually broken vs
1204
+ // Claude Code, which silently inits and surfaces "/init to
1205
+ // create CLAUDE.md" as an inline tip in the welcome banner.
1206
+ //
1207
+ // The fix swaps the prompt variant for `runReplSilentInitPreflight`
1208
+ // which scaffolds inline без prompt on the happy path
1209
+ // (interactive TTY, project root, no opt-out flag). The
1210
+ // welcome banner (rendered by Ink immediately after) surfaces
1211
+ // a one-line "Initialised Pugi workspace" toast so the
1212
+ // operator still sees the side-effect — just без a Y/N stop.
1213
+ //
1214
+ // Opt-outs (`--bare`, `--no-init`, `PUGI_BARE`, `PUGI_NO_AUTO_INIT`)
1215
+ // and non-TTY fall-through preserved verbatim from the prompt
1216
+ // variant. Wrapped in try/catch — a scaffold failure (read-only
1217
+ // fs, perms) still lets the REPL boot so the operator can
1218
+ // diagnose.
1219
+ let silentInitOutcome = null;
1220
+ try {
1221
+ silentInitOutcome = await runReplSilentInitPreflight(process.cwd(), flags);
1222
+ }
1223
+ catch (error) {
1224
+ // Surface the scaffold error on stderr but proceed to mount
1225
+ // the REPL. Crashing here would regress the splash-fallback
1226
+ // path the dispatcher used to take when `.pugi/` was missing.
1227
+ const message = error instanceof Error ? error.message : String(error);
1228
+ process.stderr.write(`Auto-init failed: ${message}\n`);
1229
+ }
385
1230
  // α6.2: peek the npm registry for a newer @pugi/cli before
386
1231
  // mounting Ink. Wrapped in a try/catch belt-and-braces even
387
1232
  // though `checkForUpdate` already swallows every failure mode —
@@ -401,11 +1246,22 @@ export async function runCli(argv) {
401
1246
  await renderRepl({
402
1247
  apiUrl: runtimeConfig.apiUrl,
403
1248
  apiKey: runtimeConfig.apiKey,
1249
+ // Re-resolve AFTER the auto-init pre-flight so a freshly-
1250
+ // scaffolded `.pugi/PUGI.md` flips the splash label from
1251
+ // "(not bound — run /init OR cd into project)" к the project
1252
+ // basename in the same boot cycle. `workspaceLabel` is a
1253
+ // pure function over `process.cwd()`; calling it twice is
1254
+ // cheap.
404
1255
  workspaceLabel: workspaceLabel(process.cwd()),
405
1256
  cliVersion: PUGI_CLI_VERSION,
406
1257
  updateBanner,
407
1258
  skipSplash: flags.noSplash,
408
1259
  hideToolStream: flags.noToolStream,
1260
+ // CEO P0 #2 (2026-05-29): forward the silent-init outcome so
1261
+ // the welcome banner can surface a one-line toast on the
1262
+ // "initialized" branch ("Pugi workspace initialised at .pugi/.")
1263
+ // and skip the toast on the "already" / "declined" branches.
1264
+ autoInitStatus: silentInitOutcome?.status ?? null,
409
1265
  });
410
1266
  return;
411
1267
  }
@@ -441,6 +1297,7 @@ function parseArgs(argv) {
441
1297
  offline: false,
442
1298
  noTty: false,
443
1299
  allowFetch: false,
1300
+ allowSearch: false,
444
1301
  noUpdateCheck: false,
445
1302
  noSplash: process.env.PUGI_SKIP_SPLASH === '1',
446
1303
  // Claude triple-review P1 PR #369: default tool-stream pane HIDDEN
@@ -450,13 +1307,61 @@ function parseArgs(argv) {
450
1307
  // "Mira: ..." prose, never "Read(path)" prefix) OR fires falsely on
451
1308
  // accidental `Verb(noun)` shapes producing stuck `running` rows.
452
1309
  // Hide by default; opt-in via `--tool-stream` OR PUGI_TOOL_STREAM=1
453
- // for development/testing. Will flip к default ON when backend
1310
+ // for development/testing. Will flip to default ON when backend
454
1311
  // emits real tool events (filed as α6.13.X follow-up).
455
1312
  noToolStream: process.env.PUGI_TOOL_STREAM === '1' || process.env.PUGI_HIDE_TOOL_STREAM === '1'
456
1313
  ? process.env.PUGI_HIDE_TOOL_STREAM === '1'
457
1314
  : true,
1315
+ noDefaults: process.env.PUGI_INIT_NO_DEFAULTS === '1',
1316
+ // Wave 6 UX (2026-05-27): auto-init / auto-login opt-outs. Default
1317
+ // OFF (auto-init + auto-login are on by default on an interactive
1318
+ // TTY). PUGI_NO_AUTO_* env vars provide a per-shell escape hatch
1319
+ // without needing к thread the flag through every invocation.
1320
+ noInit: process.env.PUGI_NO_AUTO_INIT === '1',
1321
+ noLogin: process.env.PUGI_NO_AUTO_LOGIN === '1',
1322
+ decompose: false,
1323
+ // β-headless: --no-tools default OFF so existing flag-free invocations
1324
+ // keep tool advertisement. Flipped only by explicit operator opt-in.
1325
+ noTools: false,
1326
+ // Leak L6 — `pugi permissions <mode> --persist/--confirm`. Default
1327
+ // false so existing invocations stay no-op on the new permission
1328
+ // surface.
1329
+ persist: false,
1330
+ confirm: false,
1331
+ // Leak L22 — `--bare` flag (skip project auto-discovery). Default
1332
+ // honors the env var so a wrapper script that exports PUGI_BARE=1
1333
+ // keeps the bit even when the operator forgets the flag, and the
1334
+ // explicit flag overrides on the way through the loop below.
1335
+ bare: isBareMode(),
1336
+ // Leak L33 — `--ascii-only` for `pugi stickers`. Default off so the
1337
+ // interactive surface keeps its boxed renderer; opt-in via flag
1338
+ // for pipe / script use.
1339
+ asciiOnly: false,
1340
+ // Leak L24 — `--reset` for `pugi release-notes`. Default off so a
1341
+ // bare invocation only surfaces new sections. Opt-in to force the
1342
+ // full bundled changelog к re-render (clears the on-disk marker).
1343
+ reset: false,
1344
+ // Leak L28 — `--refresh` for `pugi repo-map`. Default off so a
1345
+ // bare invocation hits the cache when mtime + size match; opt-in
1346
+ // for a cold rebuild from the source tree.
1347
+ refresh: false,
1348
+ // BIG TRACK 10 Phase 1 — `--headless` for multi-turn programmatic
1349
+ // drive. Default off; explicit opt-in only. The CLI ALSO honors
1350
+ // `PUGI_HEADLESS=1` so the smoke harness can pre-set the env when
1351
+ // a wrapper script forgets the flag.
1352
+ headless: process.env.PUGI_HEADLESS === '1',
458
1353
  };
459
1354
  const args = [];
1355
+ // Leak L22: scan for `--bare` BEFORE the early-return short-circuits
1356
+ // below. Operators may pass `pugi --bare --version` or `pugi --bare
1357
+ // --help` and the short-circuit return must still flip the bare bit
1358
+ // so subprocesses + env-consulting modules see the activated state.
1359
+ // The bit is idempotent — re-applied inside the main loop below for
1360
+ // non-short-circuit paths.
1361
+ if (argv.includes('--bare')) {
1362
+ flags.bare = true;
1363
+ setBareMode();
1364
+ }
460
1365
  // Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
461
1366
  // (npm uses --version on every published bin, Homebrew formula uses it in
462
1367
  // the test block). Normalize them to the `version` command so users can
@@ -487,7 +1392,7 @@ function parseArgs(argv) {
487
1392
  else if (arg === '--consensus') {
488
1393
  // α6.7: customer-facing 3-model consensus review. Routes through
489
1394
  // the SSE-based runtime gate rather than the legacy artifact
490
- // writer. The triple flag stays unset так the existing
1395
+ // writer. The triple flag stays unset so the existing
491
1396
  // performRemoteTripleReview path is never accidentally entered.
492
1397
  flags.consensus = true;
493
1398
  }
@@ -500,6 +1405,12 @@ function parseArgs(argv) {
500
1405
  else if (arg === '--allow-fetch') {
501
1406
  flags.allowFetch = true;
502
1407
  }
1408
+ else if (arg === '--allow-search') {
1409
+ // β1b T4 (2026-05-26): unlock the `web_search` tool for one
1410
+ // invocation, mirroring the `--allow-fetch` gate. Distinct flag
1411
+ // because an operator may want to query without fetching pages.
1412
+ flags.allowSearch = true;
1413
+ }
503
1414
  else if (arg === '--no-update-check') {
504
1415
  flags.noUpdateCheck = true;
505
1416
  }
@@ -510,10 +1421,51 @@ function parseArgs(argv) {
510
1421
  flags.noToolStream = true;
511
1422
  }
512
1423
  else if (arg === '--tool-stream') {
513
- // Opt-in для α6.12 dev/testing — backend tool events not live yet,
514
- // pane shows синтесайз heuristic OR empty placeholder
1424
+ // Opt-in for α6.12 dev/testing — backend tool events not live yet,
1425
+ // pane shows synthesized heuristic OR empty placeholder
515
1426
  flags.noToolStream = false;
516
1427
  }
1428
+ else if (arg === '--no-defaults') {
1429
+ // Init-only flag: skip the bundled default-skills install. Parsed
1430
+ // at the global level for consistency with --no-splash / --no-tool-stream.
1431
+ flags.noDefaults = true;
1432
+ }
1433
+ else if (arg === '--ascii-only') {
1434
+ // Leak L33 — `pugi stickers --ascii-only` skips the Ink boxed
1435
+ // renderer. Parsed globally so the dispatcher can pass the flag
1436
+ // through to runStickersCommand without per-command argv slicing.
1437
+ flags.asciiOnly = true;
1438
+ }
1439
+ else if (arg === '--reset') {
1440
+ // Leak L24 — `pugi release-notes --reset` clears the on-disk
1441
+ // `~/.pugi/.last-seen-version` marker so the full bundled
1442
+ // changelog re-renders. Parsed globally for symmetry with the
1443
+ // rest of the flag grammar; `runReleaseNotesCommand` is the
1444
+ // single consumer today.
1445
+ flags.reset = true;
1446
+ }
1447
+ else if (arg === '--refresh') {
1448
+ // Leak L28 — `pugi repo-map --refresh` busts the cache and
1449
+ // rebuilds the AST-light summary from a cold scan. Parsed
1450
+ // globally for symmetry with the rest of the flag grammar;
1451
+ // `runRepoMapCommand` is the single consumer today.
1452
+ flags.refresh = true;
1453
+ }
1454
+ else if (arg === '--format=json' || arg === '--format' && argv[index + 1] === 'json') {
1455
+ // Leak L28 — `pugi repo-map --format=json` is a per-command
1456
+ // synonym for the global `--json` flag. The L28 spec calls
1457
+ // out the `--format=json` shape explicitly so we accept it
1458
+ // verbatim and route through the existing JSON envelope.
1459
+ flags.json = true;
1460
+ if (arg === '--format')
1461
+ index += 1;
1462
+ }
1463
+ else if (arg === '--decompose') {
1464
+ // α6.8 EXTEND PR1: plan-only flag. Other engine commands ignore
1465
+ // it. Parsed globally for symmetry with the rest of the flag
1466
+ // grammar; `runEngineTask('plan')` is the single consumer.
1467
+ flags.decompose = true;
1468
+ }
517
1469
  else if (arg.startsWith('--privacy=')) {
518
1470
  flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
519
1471
  }
@@ -524,18 +1476,224 @@ function parseArgs(argv) {
524
1476
  flags.privacy = parsePrivacyMode(next);
525
1477
  index += 1;
526
1478
  }
1479
+ else if (arg === '--print') {
1480
+ // β-headless: top-level `--print <brief>` runs a single
1481
+ // non-interactive engine turn. Consumes the next argv token as
1482
+ // the brief — refusing if it looks like another flag so a
1483
+ // dangling `--print --json` does not silently swallow `--json`.
1484
+ const next = argv[index + 1];
1485
+ if (!next || next.startsWith('--')) {
1486
+ throw new Error('--print requires a brief (e.g. --print "create word_counter.py")');
1487
+ }
1488
+ flags.print = next;
1489
+ index += 1;
1490
+ }
1491
+ else if (arg.startsWith('--print=')) {
1492
+ flags.print = arg.slice('--print='.length);
1493
+ }
1494
+ else if (arg === '--cwd') {
1495
+ const next = argv[index + 1];
1496
+ if (!next || next.startsWith('--'))
1497
+ throw new Error('--cwd requires a path');
1498
+ flags.cwd = next;
1499
+ index += 1;
1500
+ }
1501
+ else if (arg.startsWith('--cwd=')) {
1502
+ flags.cwd = arg.slice('--cwd='.length);
1503
+ }
1504
+ else if (arg === '--workspace') {
1505
+ const next = argv[index + 1];
1506
+ if (!next || next.startsWith('--'))
1507
+ throw new Error('--workspace requires a slug');
1508
+ flags.workspace = next;
1509
+ index += 1;
1510
+ }
1511
+ else if (arg.startsWith('--workspace=')) {
1512
+ flags.workspace = arg.slice('--workspace='.length);
1513
+ }
1514
+ else if (arg === '--session') {
1515
+ const next = argv[index + 1];
1516
+ if (!next || next.startsWith('--'))
1517
+ throw new Error('--session requires an id');
1518
+ flags.sessionId = next;
1519
+ index += 1;
1520
+ }
1521
+ else if (arg.startsWith('--session=')) {
1522
+ flags.sessionId = arg.slice('--session='.length);
1523
+ }
1524
+ else if (arg === '--timeout') {
1525
+ const next = argv[index + 1];
1526
+ if (!next || next.startsWith('--'))
1527
+ throw new Error('--timeout requires seconds');
1528
+ const parsed = Number(next);
1529
+ if (!Number.isFinite(parsed) || parsed <= 0) {
1530
+ throw new Error(`--timeout requires positive seconds, got "${next}"`);
1531
+ }
1532
+ flags.timeoutSeconds = parsed;
1533
+ index += 1;
1534
+ }
1535
+ else if (arg.startsWith('--timeout=')) {
1536
+ const raw = arg.slice('--timeout='.length);
1537
+ const parsed = Number(raw);
1538
+ if (!Number.isFinite(parsed) || parsed <= 0) {
1539
+ throw new Error(`--timeout requires positive seconds, got "${raw}"`);
1540
+ }
1541
+ flags.timeoutSeconds = parsed;
1542
+ }
1543
+ else if (arg === '--no-tools') {
1544
+ flags.noTools = true;
1545
+ }
1546
+ else if (arg === '--max-turns') {
1547
+ const next = argv[index + 1];
1548
+ if (!next || next.startsWith('--'))
1549
+ throw new Error('--max-turns requires an integer');
1550
+ const parsed = Number(next);
1551
+ if (!Number.isInteger(parsed) || parsed <= 0) {
1552
+ throw new Error(`--max-turns requires positive integer, got "${next}"`);
1553
+ }
1554
+ flags.maxTurns = parsed;
1555
+ index += 1;
1556
+ }
1557
+ else if (arg.startsWith('--max-turns=')) {
1558
+ const raw = arg.slice('--max-turns='.length);
1559
+ const parsed = Number(raw);
1560
+ if (!Number.isInteger(parsed) || parsed <= 0) {
1561
+ throw new Error(`--max-turns requires positive integer, got "${raw}"`);
1562
+ }
1563
+ flags.maxTurns = parsed;
1564
+ }
1565
+ else if (arg.startsWith('--commit=')) {
1566
+ // `pugi review --triple --commit <SHA>` activates the multi-
1567
+ // provider routing path against a specific revision.
1568
+ flags.commit = arg.slice('--commit='.length);
1569
+ }
1570
+ else if (arg === '--commit') {
1571
+ const next = argv[index + 1];
1572
+ if (!next)
1573
+ throw new Error('--commit requires a SHA or ref');
1574
+ flags.commit = next;
1575
+ index += 1;
1576
+ }
1577
+ else if (arg.startsWith('--base=')) {
1578
+ flags.base = arg.slice('--base='.length);
1579
+ }
1580
+ else if (arg === '--base') {
1581
+ const next = argv[index + 1];
1582
+ if (!next)
1583
+ throw new Error('--base requires a ref');
1584
+ flags.base = next;
1585
+ index += 1;
1586
+ }
1587
+ else if (arg.startsWith('--mode=')) {
1588
+ // Leak L6: top-level `--mode plan|ask|allow|bypass`. Validation
1589
+ // happens at the consumer side (parsePermissionMode) so the
1590
+ // parser stays string-typed; an invalid value surfaces a clean
1591
+ // error in the dispatcher rather than blowing up here.
1592
+ flags.mode = arg.slice('--mode='.length);
1593
+ }
1594
+ else if (arg === '--mode') {
1595
+ const next = argv[index + 1];
1596
+ if (!next || next.startsWith('--')) {
1597
+ throw new Error('--mode requires default|acceptEdits|plan|auto|dontAsk|bypassPermissions (α6 aliases ask|allow|bypass accepted)');
1598
+ }
1599
+ flags.mode = next;
1600
+ index += 1;
1601
+ }
1602
+ else if (arg === '--persist') {
1603
+ // Leak L6: paired with `pugi permissions <mode>` to also write
1604
+ // the mode to ~/.pugi/config.json::defaultPermissionMode.
1605
+ flags.persist = true;
1606
+ }
1607
+ else if (arg === '--confirm') {
1608
+ // Leak L6: required for `pugi permissions bypass` (bypass
1609
+ // disables policy hooks; the gate refuses the flip without
1610
+ // acknowledgement).
1611
+ flags.confirm = true;
1612
+ }
1613
+ else if (arg === '--bare') {
1614
+ // Leak L22: disable project auto-discovery for this invocation.
1615
+ // Set BOTH the parsed flag and the process env so downstream
1616
+ // modules consulting `isBareMode()` (markdown-traverse callsite,
1617
+ // REPL auto-init gate, doctor probe, subprocess spawns) see a
1618
+ // coherent activated state without re-threading the bit through
1619
+ // every call signature.
1620
+ flags.bare = true;
1621
+ setBareMode();
1622
+ }
1623
+ else if (arg === '--no-init') {
1624
+ // Wave 6 UX (2026-05-27): opt-out for the auto-init pre-flight
1625
+ // wrapper. The flag-driven path mirrors PUGI_NO_AUTO_INIT=1 so a
1626
+ // single invocation can override the env state and vice versa.
1627
+ flags.noInit = true;
1628
+ }
1629
+ else if (arg === '--no-login') {
1630
+ // Wave 6 UX (2026-05-27): opt-out for the auto-login pre-flight
1631
+ // wrapper. The auth resolution still runs (env / file paths) —
1632
+ // only the inline device-flow launch is suppressed.
1633
+ flags.noLogin = true;
1634
+ }
1635
+ else if (arg === '--headless') {
1636
+ // BIG TRACK 10 Phase 1 — line-by-line stdin → engine → JSON
1637
+ // envelopes on stdout. Distinct from `--print` (single-shot).
1638
+ // The dispatcher routes to `runHeadlessRepl` BEFORE the Ink
1639
+ // REPL when this flag is set on a bare/repl invocation.
1640
+ flags.headless = true;
1641
+ }
1642
+ else if (arg === '--filter') {
1643
+ // BIG TRACK 10 Phase 1 — `pugi smoke --filter <pattern>`.
1644
+ // Generic flag name so future commands (e.g. `pugi sessions
1645
+ // --filter`) can reuse it without a second flag wired through
1646
+ // parseArgs.
1647
+ const next = argv[index + 1];
1648
+ if (!next || next.startsWith('--')) {
1649
+ throw new Error('--filter requires a pattern (substring or *-glob)');
1650
+ }
1651
+ flags.smokeFilter = next;
1652
+ index += 1;
1653
+ }
1654
+ else if (arg.startsWith('--filter=')) {
1655
+ flags.smokeFilter = arg.slice('--filter='.length);
1656
+ }
527
1657
  else {
528
1658
  args.push(arg);
529
1659
  }
530
1660
  }
531
1661
  const isBareInvocation = args.length === 0;
1662
+ const command = args.shift() ?? 'help';
1663
+ // Sprint α6.X CEO dogfood 2026-05-26 (P0 hot-fix): trailing `--help`
1664
+ // / `-h` on ANY sub-command must route to the help printer rather
1665
+ // than dispatching the real engine. Before this guard `pugi build
1666
+ // --help` burned 86k tokens running the actual build loop because
1667
+ // the dispatcher saw `--help` as an opaque arg and forwarded it
1668
+ // through to the engine. Re-routing here means `pugi <cmd> --help`
1669
+ // becomes `pugi help <cmd>` deterministically across the entire
1670
+ // command tree.
1671
+ //
1672
+ // β1 Tt3 carve-out: commands that ship their OWN `--help` block
1673
+ // (login, init, ...) must keep `--help` in their args so the
1674
+ // command-local printer fires. Without this carve-out
1675
+ // `pugi login --help` produces the global help and the per-variant
1676
+ // reference (`--provider device|token|env`) gets lost. The carve-out
1677
+ // list mirrors handlers whose source carries an
1678
+ // `args.includes('--help')` short-circuit.
1679
+ if ((args.includes('--help') || args.includes('-h')) && !COMMAND_LOCAL_HELP.has(command)) {
1680
+ return { command: 'help', args: [command], flags, isBareInvocation: false };
1681
+ }
532
1682
  return {
533
- command: args.shift() ?? 'help',
1683
+ command,
534
1684
  args,
535
1685
  flags,
536
1686
  isBareInvocation,
537
1687
  };
538
1688
  }
1689
+ /**
1690
+ * β1 Tt3: commands that own their `--help` rendering. The bare-help
1691
+ * redirect leaves their `--help` arg in place so the command-local
1692
+ * printer fires instead of the global summary.
1693
+ */
1694
+ const COMMAND_LOCAL_HELP = new Set([
1695
+ 'login',
1696
+ ]);
539
1697
  async function version(_args, flags, _session) {
540
1698
  const payload = {
541
1699
  name: 'pugi',
@@ -543,7 +1701,353 @@ async function version(_args, flags, _session) {
543
1701
  };
544
1702
  writeOutput(flags, payload, `pugi ${payload.version}`);
545
1703
  }
546
- async function help(_args, flags, _session) {
1704
+ /**
1705
+ * Per-command help bodies (task #100). When the operator types
1706
+ * `pugi <cmd> --help` the dispatcher routes here with `args = [cmd]`.
1707
+ * If we have a focused body for that command, print it instead of the
1708
+ * global summary. Falls back to the global summary so unknown / new
1709
+ * commands still get a useful response.
1710
+ *
1711
+ * Source of truth for each entry: the comment block at the top of the
1712
+ * command's implementation module + any flags the command declares.
1713
+ * Keep entries short — operators want the one-liner of intent + the
1714
+ * 2-5 most useful flags, not a tutorial. The global help still has the
1715
+ * full per-section reference; the per-command body is the "tell me
1716
+ * how to use this NOW" surface.
1717
+ */
1718
+ const COMMAND_HELP_BODIES = {
1719
+ init: [
1720
+ 'pugi init — bootstrap a new Pugi workspace in the current directory.',
1721
+ '',
1722
+ 'Creates .pugi/{PUGI.md, mcp.json, index.json, artifacts/, sessions/} and',
1723
+ 'seeds the 6 default skills. Idempotent — running again only fills gaps.',
1724
+ '',
1725
+ 'Flags:',
1726
+ ' --no-defaults Skip the bundled default-skills install.',
1727
+ '',
1728
+ 'Env:',
1729
+ ' PUGI_INIT_NO_DEFAULTS=1 Same as --no-defaults.',
1730
+ ],
1731
+ explain: [
1732
+ 'pugi explain "<question>" — read-only Q&A about the workspace.',
1733
+ '',
1734
+ 'Calls the engine loop in explain mode (budget: 10 calls / 40k tokens).',
1735
+ 'No file writes; safe to run against unfamiliar code.',
1736
+ '',
1737
+ 'Examples:',
1738
+ ' pugi explain "what does this package.json define?"',
1739
+ ' pugi explain "trace the auth flow in src/auth/"',
1740
+ ],
1741
+ code: [
1742
+ 'pugi code "<brief>" — engineering-mode write loop (80k token budget).',
1743
+ '',
1744
+ 'Writes files in the current workspace. Use --no-tty in CI / pipes.',
1745
+ ],
1746
+ fix: [
1747
+ 'pugi fix "<brief>" — minimal-diff bugfix loop (50k token budget).',
1748
+ '',
1749
+ 'Same as `pugi code` but the prompt biases toward the smallest patch',
1750
+ 'that closes the brief — refuses scope creep / refactor invitations.',
1751
+ ],
1752
+ build: [
1753
+ 'pugi build "<brief>" — feature-build loop (200k token budget).',
1754
+ '',
1755
+ 'Multi-turn engineering with plan-review checkpoints. Pairs with',
1756
+ 'pugi plan --decompose <idea> when the brief is bigger than one PR.',
1757
+ ],
1758
+ plan: [
1759
+ 'pugi plan --decompose <idea> — split an idea into 3-7 components.',
1760
+ '',
1761
+ 'Writes .pugi/plan/<session-id>/splits/NN-<name>/spec.md plus',
1762
+ 'manifest.md with the dependency DAG. Pass each split to `pugi build`.',
1763
+ ],
1764
+ review: [
1765
+ 'pugi review — code review surfaces.',
1766
+ '',
1767
+ ' --triple 3-model consensus via Anvil paid fleet.',
1768
+ ' --triple --commit <SHA> Review a specific commit (vs origin/main).',
1769
+ ' --consensus Customer-facing consensus review (codex + claude + deepseek).',
1770
+ ' Optional: --commit <sha> | --pr <num> | --branch <name>.',
1771
+ '',
1772
+ 'Exit codes: 0 PASS · 1 WARN · 2 BLOCK · 5 auth_missing · 7 rate_limited.',
1773
+ ],
1774
+ privacy: [
1775
+ 'pugi privacy — privacy-mode operations.',
1776
+ '',
1777
+ ' show Display effective mode + source.',
1778
+ ' set <mode> Local-only legacy values (local-only|metadata|full).',
1779
+ '',
1780
+ 'For tenant-scoped server-side modes (strict|balanced|permissive), use:',
1781
+ ' pugi config get privacy',
1782
+ ' pugi config set privacy=<mode>',
1783
+ ],
1784
+ share: [
1785
+ 'pugi share — export the current session transcript (leak L20).',
1786
+ '',
1787
+ 'Reads .pugi/events.jsonl, formats it as Markdown, and uploads to',
1788
+ 'either a GitHub Gist (`gh`-backed, default when `gh` is available)',
1789
+ 'or pugi.io (--pugi). Always prompts before upload unless --yes is',
1790
+ 'set. Refuses upload entirely if the transcript carries an active',
1791
+ '`Bearer ` credential — re-run with --redact to scrub it first.',
1792
+ '',
1793
+ 'Flags:',
1794
+ ' --gist Force gist target; refuses if gh CLI is absent.',
1795
+ ' --pugi Force pugi.io target (requires `pugi login`).',
1796
+ ' --redact Run PII scrubber before upload.',
1797
+ ' --preview Print the transcript to stdout WITHOUT upload.',
1798
+ ' --yes, -y Skip the y/n confirmation prompt.',
1799
+ ' --json Emit a structured JSON envelope only.',
1800
+ '',
1801
+ 'Examples:',
1802
+ ' pugi share Auto-pick + confirm.',
1803
+ ' pugi share --preview --redact See what would be shared.',
1804
+ ' pugi share --gist --redact --yes Scripted secret-gist upload.',
1805
+ ],
1806
+ cost: [
1807
+ 'pugi cost — token + USD breakdown for the current Pugi session.',
1808
+ '',
1809
+ 'Reads .pugi/cost.json (persisted via the in-REPL CostTracker) and',
1810
+ 'prints a per-model table plus dollar estimate. Alias: pugi usage.',
1811
+ '',
1812
+ 'Flags:',
1813
+ ' --all-sessions 30-day rolling aggregate across all sessions.',
1814
+ ' --window=<days> Override the aggregate window (max 365).',
1815
+ ' --reset --yes Clear the current-session counter. History',
1816
+ ' is preserved. Requires --yes to confirm.',
1817
+ ' --json Emit a structured JSON envelope only.',
1818
+ '',
1819
+ 'Examples:',
1820
+ ' pugi cost Current session totals.',
1821
+ ' pugi cost --all-sessions Past 30 days aggregated.',
1822
+ ' pugi cost --all-sessions --window=7',
1823
+ ' pugi cost --reset --yes Wipe the session counter.',
1824
+ ' pugi usage Alias for pugi cost.',
1825
+ ],
1826
+ config: [
1827
+ 'pugi config — read / write CLI + tenant configuration.',
1828
+ '',
1829
+ ' get <key> Local config value.',
1830
+ ' get privacy Tenant privacy snapshot (admin-api).',
1831
+ ' get routing Effective routing table.',
1832
+ ' set <key>=<value> Local config write.',
1833
+ ' set privacy=<mode> Flip tenant privacy (strict|balanced|permissive).',
1834
+ ' set routing.<tag>.<budget>=<model> Override one routing lane.',
1835
+ ' unset routing.<tag>.<budget> Revert a routing override.',
1836
+ ' mcp trust|deny|list <name> MCP server trust + visibility.',
1837
+ ],
1838
+ sync: [
1839
+ 'pugi sync — explicit-continuation handoff bundle upload.',
1840
+ '',
1841
+ ' --dry-run Print the bundle plan without uploading.',
1842
+ ' --privacy <mode> Override per-bundle privacy posture.',
1843
+ ],
1844
+ whoami: [
1845
+ 'pugi whoami — show the active credential + JWT principal + plan tier.',
1846
+ '',
1847
+ 'Reads from ~/.pugi/credentials.json. No network call unless --remote.',
1848
+ ],
1849
+ login: [
1850
+ 'pugi login — authenticate against an api.pugi.io endpoint.',
1851
+ '',
1852
+ 'Interactive picker by default (browser OAuth / PAT / env). Non-interactive:',
1853
+ ' --provider device Device-flow OAuth.',
1854
+ ' --provider token --token <jwt> Pass a JWT directly.',
1855
+ ' --provider env Read PUGI_API_KEY (or --key) + verify via /api/pugi/health.',
1856
+ ' --provider env --key <value> --skip-validate Explicit key, no probe (CI bootstrap).',
1857
+ ],
1858
+ accounts: [
1859
+ 'pugi accounts — manage stored credentials across endpoints.',
1860
+ '',
1861
+ ' pugi accounts list Every account + its endpoint + active flag.',
1862
+ ' pugi accounts switch <label> Re-point the active account.',
1863
+ ' pugi accounts remove <label> Delete a stored credential.',
1864
+ ],
1865
+ jobs: [
1866
+ 'pugi jobs — list, tail, or kill background dispatch jobs.',
1867
+ '',
1868
+ ' list All jobs in the registry.',
1869
+ ' tail <id> Stream output from one job.',
1870
+ ' kill <id> Cancel a running job.',
1871
+ ],
1872
+ delegate: [
1873
+ 'pugi delegate <slug> "<brief>" — dispatch a brief to one specialist persona.',
1874
+ '',
1875
+ 'Slugs (Tier 1 alpha 7.5): dev qa pm devops researcher analyst designer',
1876
+ 'frontend architect. `pugi roster` lists the live set.',
1877
+ ],
1878
+ chain: [
1879
+ 'pugi chain — Wave 6 artifact chain (PRD → ADR → mindmap → ER → sequence → tests → code).',
1880
+ '',
1881
+ ' new "<intent>" Start a new chain from a one-sentence intent.',
1882
+ ' status [<chain-id>] Show current cursor + per-step table.',
1883
+ ' next [<chain-id>] Approve the last step and dispatch the next.',
1884
+ ' show <step> [<chain-id>] Render one artifact (prd/adr/mindmap/er/sequence/tests/code).',
1885
+ ' export [<chain-id>] [--json] Bundle every artifact as markdown / JSON.',
1886
+ ' list Every chain in this workspace.',
1887
+ ],
1888
+ dispatch: [
1889
+ 'pugi dispatch <sub> — inspect + GC fork-subagent prompt-cache inherit refs.',
1890
+ '',
1891
+ ' list-cache-refs Table of every active ref under .pugi/cache-refs/.',
1892
+ ' clear-cache-refs [--older-than 1h] Evict refs older than the window (default 24h).',
1893
+ '',
1894
+ 'Leak L10 (2026-05-27): when Mira spawns a child via the `agent` tool,',
1895
+ 'a prompt-cache handle is persisted so the child loop can request',
1896
+ 'parent-context reuse on the wire. These commands surface + clean up',
1897
+ 'the persisted refs.',
1898
+ ],
1899
+ roster: [
1900
+ 'pugi roster — list the live Tier 1 personas + roles.',
1901
+ ],
1902
+ doctor: [
1903
+ 'pugi doctor — diagnose CLI + workspace + adapter capabilities.',
1904
+ '',
1905
+ 'Prints CLI version, Node version, workspace state (.pugi presence,',
1906
+ 'event log, settings), permission mode, and the capability matrix per',
1907
+ 'engine adapter. Safe to run anywhere; no network calls.',
1908
+ ],
1909
+ 'prd-check': [
1910
+ 'pugi prd-check <prd-path> | --all | --session — Wave 6 verified-deliverable gate.',
1911
+ '',
1912
+ 'DEFAULT MODE — verify acceptance criteria against committed artifacts.',
1913
+ 'Reads a markdown PRD, parses the acceptance-criteria section, and',
1914
+ 'runs verifiers:',
1915
+ ' file:<path> fs.existsSync',
1916
+ ' test:<spec> spec file exists + has ≥1 test()/it() block',
1917
+ ' doc:<path> doc exists + has > 100 chars',
1918
+ ' command:<name> CLI registry contains the command',
1919
+ ' route:METHOD /p best-effort grep of controllers',
1920
+ '',
1921
+ ' --all Scan docs/prd/**.md instead of one file.',
1922
+ ' --json Emit a structured envelope to stdout.',
1923
+ '',
1924
+ 'SESSION MODE (Wave 6 final) — review the live session against the PRD.',
1925
+ 'Walks up for PRD.md or apps/<app>/PRODUCT.md, reads the last 20 turns',
1926
+ 'from .pugi/events.jsonl, and dispatches a cross-review subagent to',
1927
+ 'list which requirements are SATISFIED and which remain OUTSTANDING.',
1928
+ '',
1929
+ ' --session Run the session-review mode (no <path>, no --all).',
1930
+ '',
1931
+ 'Exit codes: 0 healthy · 1 failing · 2 unparsed / arg error.',
1932
+ ],
1933
+ status: [
1934
+ 'pugi status — concise session snapshot.',
1935
+ '',
1936
+ 'Different from `pugi doctor` (environment health). Status answers',
1937
+ '"what is this Pugi session doing right now?" — session id + age,',
1938
+ 'cwd, permission mode, CLI version, token usage, active + completed',
1939
+ 'dispatches, last command, compact boundary count, auth identity.',
1940
+ '',
1941
+ ' --json Emit a structured envelope to stdout.',
1942
+ '',
1943
+ 'Live REPL state (tokens, last command) is only available via the',
1944
+ 'in-REPL `/status` slash; the shell path degrades those fields к',
1945
+ '"n/a" and exits 0.',
1946
+ ],
1947
+ report: [
1948
+ 'pugi report — capture a bug report from the most-recent session.',
1949
+ '',
1950
+ ' --from-error Bundle the most-recent failed session as a',
1951
+ ' redacted local report (default + only mode in v1).',
1952
+ '',
1953
+ 'Output: writes .pugi/reports/<timestamp>-<session-id>/{report.json, report.md}.',
1954
+ 'Secrets (bearer tokens, JWTs, named env values) are stripped before disk write.',
1955
+ 'Auto-upload to api.pugi.io planned for a follow-up; v1 keeps everything local.',
1956
+ ],
1957
+ ask: [
1958
+ 'pugi ask "<question>" — surface a yes/no question modal locally.',
1959
+ '',
1960
+ 'Useful in shell scripts that need a human-confirm before a destructive',
1961
+ 'step. Exits 0 on yes, 1 on no, 2 on cancel.',
1962
+ ],
1963
+ update: [
1964
+ 'pugi update — channel-aware @pugi/cli update check + install.',
1965
+ '',
1966
+ 'Polls npm registry dist-tags for a newer @pugi/cli on the configured',
1967
+ 'channel (stable / beta / canary). Without flags, prints the install',
1968
+ 'command and exits. With --apply, shells out to `npm install -g …`.',
1969
+ '',
1970
+ ' --check Non-interactive probe + JSON envelope.',
1971
+ ' --channel <name> Switch channel (stable | beta | canary) and probe.',
1972
+ ' Persisted to ~/.pugi/config.json::updateChannel.',
1973
+ ' --apply Shell out to `npm install -g @pugi/cli@<tag>`',
1974
+ ' after a y/n confirmation.',
1975
+ ' --yes, -y Skip the confirmation prompt on --apply.',
1976
+ ' --json Force JSON envelope (auto-on with --check).',
1977
+ '',
1978
+ 'Channel mapping: stable -> npm `latest`, beta -> npm `beta`,',
1979
+ 'canary -> npm `next`. Default channel is `beta` (Pugi currently',
1980
+ 'ships beta releases only).',
1981
+ '',
1982
+ 'Also available as /update from inside the REPL — slash form NEVER',
1983
+ 'spawns npm (would corrupt the running binary); it only prints the',
1984
+ 'install command for the operator к run after exit.',
1985
+ '',
1986
+ 'R2 atomic swap (sprint plan L27) deferred к Phase 2 — npm is the',
1987
+ 'only distribution channel today.',
1988
+ ],
1989
+ stickers: [
1990
+ 'pugi stickers — show a Pugi brand sticker (gimmick).',
1991
+ '',
1992
+ 'Picks one of the curated pug-face ASCII variants at random and footers',
1993
+ 'it with a rotating brand quote. Brand-personality surface — never a gate.',
1994
+ '',
1995
+ ' --json Emit a structured envelope (id · caption · quote).',
1996
+ ' --ascii-only Plain stdout (no box, no dim accents) for scripting.',
1997
+ '',
1998
+ 'Also available as /stickers from inside the REPL.',
1999
+ ],
2000
+ feedback: [
2001
+ 'pugi feedback — file a bug / feature / general comment from the CLI.',
2002
+ '',
2003
+ 'Interactive five-step wizard:',
2004
+ ' 1. category (bug / feature / general / praise)',
2005
+ ' 2. rating (1-5 stars)',
2006
+ ' 3. comment (multi-line, Ctrl-D submits)',
2007
+ ' 4. include redacted last 5 turns? (y/n, default n)',
2008
+ ' 5. confirm submit (y/n, default y)',
2009
+ '',
2010
+ 'On network failure the envelope is appended to',
2011
+ '.pugi/feedback-queue.jsonl and drained on the next online session.',
2012
+ '',
2013
+ 'Also available as /feedback from inside the REPL.',
2014
+ ],
2015
+ 'release-notes': [
2016
+ 'pugi release-notes — show what changed since you last upgraded.',
2017
+ '',
2018
+ 'Reads the bundled CHANGELOG.md, slices to sections strictly newer than',
2019
+ '~/.pugi/.last-seen-version, renders Markdown to stdout, then bumps the',
2020
+ 'last-seen marker to the installed CLI version. Re-running is a no-op',
2021
+ 'until you upgrade again.',
2022
+ '',
2023
+ ' --json Emit a structured envelope (sections + meta).',
2024
+ ' --reset Clear last-seen marker; re-render every section.',
2025
+ '',
2026
+ 'Also available as /release-notes from inside the REPL.',
2027
+ ],
2028
+ deploy: [
2029
+ 'pugi deploy — trigger a vendor deployment from the bound Git source.',
2030
+ '',
2031
+ ' --target vercel <vercelProject> --project <id> Vercel deploy.',
2032
+ ' --target render <renderService> --project <id> Render deploy (Sprint 2 stub).',
2033
+ ' --status <id> Vendor-agnostic status snapshot.',
2034
+ ' --logs <id> [--tail] Build-log tail.',
2035
+ '',
2036
+ 'Optional: --target-env production|preview, --ref <ref>, --integration <id>.',
2037
+ ],
2038
+ };
2039
+ async function help(args, flags, _session) {
2040
+ // 2026-05-27 task #100: per-command help bodies. When dispatcher
2041
+ // routed `pugi <cmd> --help` here it passes `args = [cmd]`; if we
2042
+ // have a focused body, print that. Falls through to the global
2043
+ // summary on unknown / new commands so the dispatcher's redirect
2044
+ // never produces a worse-than-baseline response.
2045
+ const requested = args[0];
2046
+ if (requested && COMMAND_HELP_BODIES[requested]) {
2047
+ const body = COMMAND_HELP_BODIES[requested];
2048
+ writeOutput(flags, { command: requested, lines: body }, body.join('\n'));
2049
+ return;
2050
+ }
547
2051
  const commands = Object.keys(handlers).sort();
548
2052
  writeOutput(flags, { commands }, [
549
2053
  'Pugi CLI',
@@ -563,6 +2067,9 @@ async function help(_args, flags, _session) {
563
2067
  '',
564
2068
  'Review gate:',
565
2069
  ' pugi review --triple Prepare the Anvil-backed triple-review gate.',
2070
+ ' pugi review --triple --commit <SHA>',
2071
+ ' 3-model consensus via Anvil (Anthropic · OpenAI · Google).',
2072
+ ' Optional: --base <ref> | "<prompt>". Quota: 1 slot per call.',
566
2073
  ' pugi review --consensus 3-model consensus review (codex · claude · deepseek).',
567
2074
  ' Optional: --commit <sha> | --pr <num> | --branch <name>.',
568
2075
  ' Exits 0 PASS · 1 WARN · 2 BLOCK.',
@@ -578,6 +2085,17 @@ async function help(_args, flags, _session) {
578
2085
  ' pugi ask "<question>" Surface a yes/no question modal locally.',
579
2086
  ' pugi plan-review <task> Generate + present a plan-review modal.',
580
2087
  '',
2088
+ 'Persona dispatch (α7.5):',
2089
+ ' pugi roster List the live Tier 1 personas + roles.',
2090
+ ' pugi delegate <slug> "<brief>" Dispatch a brief to one specialist.',
2091
+ ' pugi dispatch list-cache-refs Inspect fork-subagent prompt-cache inherit refs.',
2092
+ ' pugi dispatch clear-cache-refs GC stale cache refs (--older-than 1h).',
2093
+ '',
2094
+ 'Plan decomposition (α6.8):',
2095
+ ' pugi plan --decompose <idea> Split a high-level idea into 3-7 components.',
2096
+ ' Writes .pugi/plan/<session-id>/splits/NN-<name>/spec.md',
2097
+ ' plus manifest.md with the dependency DAG.',
2098
+ '',
581
2099
  'Deploy:',
582
2100
  ' pugi deploy --target vercel <vercelProject> --project <id>',
583
2101
  ' Trigger a Vercel deployment from the bound Git source.',
@@ -600,75 +2118,302 @@ async function help(_args, flags, _session) {
600
2118
  ' PUGI_SKIP_SPLASH=1.',
601
2119
  ' --no-tool-stream Hide the live tool stream pane (α6.12).',
602
2120
  ' Pairs with PUGI_HIDE_TOOL_STREAM=1.',
2121
+ ' --no-defaults Skip bundled default-skills install on',
2122
+ ' `pugi init`. Pairs with PUGI_INIT_NO_DEFAULTS=1.',
2123
+ ' --bare Disable project auto-discovery — no PUGI.md /',
2124
+ ' AGENTS.md / CLAUDE.md / GEMINI.md walk-up, no',
2125
+ ' auto-init of .pugi/, no persona auto-load.',
2126
+ ' Pairs with PUGI_BARE=1.',
603
2127
  '',
604
2128
  PUGI_TAGLINE,
605
2129
  'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
606
2130
  ].join('\n'));
607
2131
  }
2132
+ /**
2133
+ * `pugi doctor` — Leak L17 (2026-05-27). Delegates to the diagnostics
2134
+ * probe runner in `runtime/commands/doctor.ts`. The handler stays
2135
+ * thin so the probe surface stays single-sourced between the CLI
2136
+ * shell command, the `pnpm run doctor --json` package script, and
2137
+ * the in-REPL `/doctor` slash command.
2138
+ *
2139
+ * Exit codes are set by `runDoctorCommand` (0 = healthy/warnings,
2140
+ * 2 = at least one error probe). The pre-L17 minimal doctor surface
2141
+ * (adapter capabilities + schema bundle hash) is preserved under
2142
+ * `payload.meta.legacy` so any operator scripts that grep the JSON
2143
+ * keep working through the transition; the field is marked for
2144
+ * removal in a follow-up sprint once the new shape is the
2145
+ * documented contract.
2146
+ */
608
2147
  async function doctor(_args, flags, _session) {
609
- const cwd = process.cwd();
610
- const settings = loadSettings(cwd);
611
- // `doctor` reports adapter capabilities only; we pass a no-op client
612
- // so we do not require an Anvil endpoint to run `pugi doctor`. The
613
- // adapter never invokes `client.send()` from inside `capabilities()`.
614
- const inertClient = {
615
- async send() {
616
- return {
617
- stop: 'error',
618
- code: 'failed',
619
- message: 'doctor: inert client',
620
- };
2148
+ await runDoctorCommand({
2149
+ cwd: process.cwd(),
2150
+ home: defaultDoctorHome(),
2151
+ env: process.env,
2152
+ json: flags.json,
2153
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
2154
+ });
2155
+ }
2156
+ /**
2157
+ * `pugi prd-check` — Wave 6 verified-deliverable gate (2026-05-27).
2158
+ *
2159
+ * Reads `docs/prd/<feature>.md` (or any explicit path), parses the
2160
+ * acceptance-criteria section, and runs file / test / doc / command
2161
+ * / route verifiers per criterion. Same handler powers the in-REPL
2162
+ * `/prd-check` slash via session.ts so the verdict is identical
2163
+ * between surfaces.
2164
+ *
2165
+ * The `knownCommands` set is sourced from the same `handlers` map
2166
+ * used by the CLI dispatcher (one source of truth), so a PRD that
2167
+ * mentions `pugi <name>` resolves against the EXACT registry the
2168
+ * shell exposes.
2169
+ *
2170
+ * Exit codes (from reporter.exitCodeFor):
2171
+ * 0 — healthy (every criterion PASS or SKIPPED)
2172
+ * 1 — failing (≥1 FAIL)
2173
+ * 2 — unparsed (PRD has no acceptance section) OR arg error
2174
+ */
2175
+ async function dispatchPrdCheck(args, flags, _session) {
2176
+ const parsed = parsePrdCheckArgs(args, { jsonDefault: flags.json });
2177
+ if (!parsed.ok) {
2178
+ writeOutput(flags, { ok: false, error: parsed.error }, parsed.error);
2179
+ process.exitCode = 2;
2180
+ return;
2181
+ }
2182
+ await runPrdCheckCommand({
2183
+ cwd: process.cwd(),
2184
+ ...(parsed.prdPath !== undefined ? { prdPath: parsed.prdPath } : {}),
2185
+ flags: parsed.flags,
2186
+ knownCommands: knownCommandNames(),
2187
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
2188
+ });
2189
+ }
2190
+ /**
2191
+ * Snapshot the set of registered CLI command names — used by the
2192
+ * prd-check `command:` verifier so PRD mentions of `pugi <name>`
2193
+ * resolve against the exact same registry the shell exposes.
2194
+ */
2195
+ function knownCommandNames() {
2196
+ return new Set(Object.keys(handlers));
2197
+ }
2198
+ /**
2199
+ * `pugi update` — Leak L27 (2026-05-27). Channel-aware npm registry
2200
+ * probe + optional shell-out to `npm install -g @pugi/cli@<tag>`.
2201
+ *
2202
+ * Argument grammar:
2203
+ * pugi update -> probe + offer install command
2204
+ * pugi update --check -> probe + JSON envelope (scripted)
2205
+ * pugi update --channel <name> -> persist channel + probe
2206
+ * pugi update --apply [--yes] -> probe + shell out to npm
2207
+ * pugi update --json -> JSON envelope (any subcommand)
2208
+ *
2209
+ * The handler delegates to `runUpdateCommand` in
2210
+ * `runtime/commands/update.ts` so the in-REPL `/update` slash + the
2211
+ * top-level shell command share one channel-resolution + persistence
2212
+ * + probe surface. Exit codes:
2213
+ *
2214
+ * 0 — happy path (no update OR update completed OR probe-only)
2215
+ * 1 — install / probe failure with structured error
2216
+ * 2 — argument error (unknown flag, unknown channel)
2217
+ */
2218
+ async function dispatchUpdate(args, flags, _session) {
2219
+ const { parseUpdateArgs, runUpdateCommand, defaultSpawnInstaller } = await import('./commands/update.js');
2220
+ const parsed = parseUpdateArgs(args, { jsonDefault: flags.json });
2221
+ if ('error' in parsed) {
2222
+ writeOutput(flags, { ok: false, error: parsed.error }, parsed.error);
2223
+ process.exitCode = 2;
2224
+ return;
2225
+ }
2226
+ const envelope = await runUpdateCommand({
2227
+ cwd: process.cwd(),
2228
+ home: homedir(),
2229
+ env: process.env,
2230
+ flags: parsed,
2231
+ promptConfirm: async (question) => {
2232
+ const answer = await readSingleChoice(`${question} `);
2233
+ return /^y(es)?$/i.test(answer.trim());
621
2234
  },
622
- };
623
- const adapters = [
624
- new NoopEngineAdapter(),
625
- new NativePugiEngineAdapter({ client: inertClient }),
626
- ];
627
- const capabilities = await Promise.all(adapters.map(async (adapter) => ({
628
- name: adapter.name,
629
- capabilities: await adapter.capabilities(),
630
- })));
631
- const payload = {
2235
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
2236
+ spawnInstaller: defaultSpawnInstaller,
2237
+ });
2238
+ if (!envelope.ok) {
2239
+ // `apply_cancelled_by_operator` is a benign decline; we still
2240
+ // surface a non-zero exit so scripted callers can detect that the
2241
+ // operator did not green-light the install.
2242
+ process.exitCode = 1;
2243
+ }
2244
+ }
2245
+ /**
2246
+ * `pugi status` — Leak L34 (2026-05-27). Concise session-state probe
2247
+ * mirroring Claude Code's `/status`. Distinct from `pugi doctor`
2248
+ * (environment health) — `status` answers "what is THIS Pugi
2249
+ * session doing right now?" with session id + age, cwd, permission
2250
+ * mode, CLI version, token usage, dispatch count, last command,
2251
+ * compact boundaries, and auth identity.
2252
+ *
2253
+ * The top-level shell invocation has no live REPL state — fields
2254
+ * that need a live session (`tokens`, `lastCommand`) degrade к the
2255
+ * `n/a` sentinel. The same handler powers the in-REPL `/status`
2256
+ * slash, which passes live state through `StatusCommandContext`.
2257
+ *
2258
+ * Always exits 0 — the command is informational, never a gate.
2259
+ */
2260
+ async function status(_args, flags, _session) {
2261
+ await runStatusCommand({
2262
+ cwd: process.cwd(),
2263
+ home: defaultStatusHome(),
2264
+ env: process.env,
2265
+ json: flags.json,
2266
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
2267
+ });
2268
+ }
2269
+ /**
2270
+ * `pugi stickers` — Leak L33 (2026-05-27). Brand-personality gimmick
2271
+ * mirroring Claude Code's `/stickers` easter egg. Picks one curated
2272
+ * pug-face ASCII variant at random + footers it with a rotating quote
2273
+ * from the Pugi brand corpus. Always exits 0 — never a gate.
2274
+ *
2275
+ * The handler stays thin: corpus + picker + pure renderers live in
2276
+ * `tui/stickers-art.tsx`; this wrapper just hands the resolved result
2277
+ * к the shared `writeOutput` helper so `--json` keeps producing a
2278
+ * structured envelope (id + caption + quote + meta) for scripted
2279
+ * callers. The `--ascii-only` flag drops the box decoration in the
2280
+ * non-JSON path so pipes (`pugi stickers --ascii-only | lolcat`) get
2281
+ * clean plain-text frames.
2282
+ *
2283
+ * The same handler powers the in-REPL `/stickers` slash, which routes
2284
+ * the text through the conversation system pane line-buffer.
2285
+ */
2286
+ async function stickers(_args, flags, _session) {
2287
+ runStickersCommand({
2288
+ json: flags.json,
2289
+ asciiOnly: flags.asciiOnly,
2290
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
2291
+ });
2292
+ }
2293
+ /**
2294
+ * `pugi repo-map` — Leak L28 (2026-05-27). Builds + caches the AST-
2295
+ * light symbol summary of the workspace. The handler is intentionally
2296
+ * thin: argv tail tokens are honoured for `--refresh` symmetry (the
2297
+ * global parser already sets `flags.refresh`, but accepting the flag
2298
+ * positionally lets `pugi repo-map refresh` work too — both forms
2299
+ * land в the same path). Exit code is always 0 (informational).
2300
+ *
2301
+ * The same builder is invoked lazily on engine boot when `--bare` is
2302
+ * not set; running the CLI command shows the operator EXACTLY what
2303
+ * the engine would inject into the system prompt.
2304
+ */
2305
+ async function dispatchRepoMap(args, flags, _session) {
2306
+ const refresh = flags.refresh || args.includes('--refresh') || args.includes('refresh');
2307
+ await runRepoMapCommand({
2308
+ cwd: process.cwd(),
2309
+ refresh,
2310
+ json: flags.json,
2311
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
2312
+ });
2313
+ }
2314
+ /**
2315
+ * `pugi feedback` — Leak L21 (2026-05-27). In-CLI feedback collector.
2316
+ *
2317
+ * Five-step wizard:
2318
+ * 1. category (bug / feature / general / praise)
2319
+ * 2. rating (1-5)
2320
+ * 3. comment (multi-line, Ctrl-D submits)
2321
+ * 4. include redacted session context? (y/n, default n)
2322
+ * 5. confirm submit (y/n, default y)
2323
+ *
2324
+ * POSTs to `<apiUrl>/api/pugi/feedback`. On transient failure (404,
2325
+ * 5xx, network error) the envelope is appended to
2326
+ * `<cwd>/.pugi/feedback-queue.jsonl`. On next online session the
2327
+ * background flusher drains the queue silently.
2328
+ *
2329
+ * Non-TTY callers (CI, pipes) get a one-line "non-interactive — re-run
2330
+ * in a real terminal" stub. The feedback wizard is intentionally
2331
+ * TTY-only — scripting a star-rating + multi-line comment from a
2332
+ * shell pipe would just produce low-signal noise.
2333
+ */
2334
+ async function dispatchFeedback(_args, flags, _session) {
2335
+ if (!isInteractive(flags)) {
2336
+ writeOutput(flags, {
2337
+ ok: false,
2338
+ error: 'pugi feedback requires an interactive terminal. Re-run from a real TTY.',
2339
+ }, 'pugi feedback: non-interactive shell — re-run from a real terminal.');
2340
+ process.exitCode = 2;
2341
+ return;
2342
+ }
2343
+ const { renderFeedbackPrompt } = await import('../tui/feedback-prompt.js');
2344
+ const { runFeedbackCommand, renderFeedbackToast } = await import('./commands/feedback.js');
2345
+ const { submitFeedback } = await import('../core/feedback/submitter.js');
2346
+ const verdict = await renderFeedbackPrompt();
2347
+ if (verdict.cancelled || !verdict.draft) {
2348
+ writeOutput(flags, { ok: true, kind: 'cancelled' }, 'Feedback cancelled. Nothing was sent.');
2349
+ return;
2350
+ }
2351
+ // Best-effort credential resolution. Anonymous submission is allowed
2352
+ // (the server may still accept it for ungated `/api/pugi/feedback`
2353
+ // routes); on no-credential we route the POST through an empty
2354
+ // bearer + the operator gets the 4xx → "rejected" toast if the
2355
+ // server requires auth.
2356
+ const credential = resolveActiveCredential(process.env);
2357
+ const apiUrl = credential?.apiUrl ?? (process.env.PUGI_API_URL || 'https://api.pugi.io');
2358
+ const apiKey = credential?.apiKey ?? '';
2359
+ const result = await runFeedbackCommand({
2360
+ cwd: process.cwd(),
632
2361
  cliVersion: PUGI_CLI_VERSION,
633
- nodeVersion: process.version,
634
- workspaceRoot: cwd,
635
- pugiMode: existsSync(resolve(cwd, 'CLAUDE.md')),
636
- pugiDir: existsSync(resolve(cwd, '.pugi')),
637
- eventLog: existsSync(resolve(cwd, '.pugi/events.jsonl')),
638
- permissionMode: settings.permissions.mode,
639
- approvals: settings.workflow.approvals,
640
- notAutomatic: [...settings.workflow.notAutomatic, ...settings.permissions.notAutomatic],
641
- protectedFileCheck: decidePermission({ tool: 'doctor', kind: 'edit', target: '.env' }, settings, cwd),
642
- protectedFileSafety: 'configured-in-m1',
643
- mcpTrust: 'not-configured',
644
- releaseGuard: 'scaffolded',
645
- tools: toolRegistry,
646
- engineAdapters: capabilities,
647
- schemaBundleHash: createHash('sha256')
648
- .update(toolSchemaBundleHashInput())
649
- .digest('hex'),
650
- };
651
- writeOutput(flags, payload, [
652
- 'Pugi doctor',
653
- `CLI: ${payload.cliVersion}`,
654
- `Node: ${payload.nodeVersion}`,
655
- `Workspace: ${payload.workspaceRoot}`,
656
- `Pugi mode: ${payload.pugiMode ? 'detected' : 'not detected'}`,
657
- `Pugi dir: ${payload.pugiDir ? 'present' : 'missing'}`,
658
- `Event log: ${payload.eventLog ? 'present' : 'missing'}`,
659
- `Permission mode: ${payload.permissionMode}`,
660
- `Approvals: ${payload.approvals}`,
661
- `Release guard: ${payload.releaseGuard}`,
662
- ].join('\n'));
2362
+ submit: async (env) => submitFeedback(env, { apiUrl, apiKey }),
2363
+ draft: verdict.draft,
2364
+ // `pugi feedback` from a fresh shell has no live transcript — the
2365
+ // session-context provider is omitted. The REPL slash variant
2366
+ // wires this in via `runFeedbackSlash` (session.ts).
2367
+ });
2368
+ writeOutput(flags, { ok: true, result }, renderFeedbackToast(result));
663
2369
  }
664
- async function init(_args, flags, _session) {
665
- const cwd = process.cwd();
2370
+ /**
2371
+ * `pugi release-notes` — Leak L24 (2026-05-27). Diff between the
2372
+ * last-seen + installed CLI versions, rendered from the bundled
2373
+ * `apps/pugi-cli/CHANGELOG.md`. Bumps `~/.pugi/.last-seen-version`
2374
+ * to the installed version on every successful render so the next
2375
+ * invocation is a no-op until the operator upgrades again.
2376
+ *
2377
+ * The handler stays thin: parser, slicer, and state I/O all live in
2378
+ * `core/release-notes/`. This wrapper just hands ambient state to
2379
+ * `runReleaseNotesCommand` so `--json` keeps producing the same
2380
+ * envelope from both the top-level shell + the in-REPL `/release-notes`
2381
+ * slash dispatcher.
2382
+ *
2383
+ * Always exits 0 — the command is informational, never a gate. Read
2384
+ * failures, missing CHANGELOG, and write failures all degrade to a
2385
+ * structured envelope with a human-readable footer.
2386
+ */
2387
+ async function releaseNotes(_args, flags, _session) {
2388
+ runReleaseNotesCommand({
2389
+ home: defaultReleaseNotesHome(),
2390
+ json: flags.json,
2391
+ reset: flags.reset,
2392
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
2393
+ });
2394
+ }
2395
+ /**
2396
+ * Programmatic init scaffolder. Idempotent — every helper call is a
2397
+ * `*_IfMissing` write, so re-running over an existing .pugi/ workspace
2398
+ * adds nothing to `created` and the operator sees the "Already
2399
+ * initialized" copy. Default skills install is best-effort: failure
2400
+ * does not throw, the error is appended to the result via stderr so
2401
+ * the slash dispatcher can surface it in the REPL system pane.
2402
+ *
2403
+ * Callers MUST provide `cwd` explicitly; the function does not read
2404
+ * `process.cwd()` so REPL invocations from an arbitrary workspace
2405
+ * cannot accidentally scaffold the binary's install directory.
2406
+ */
2407
+ export async function scaffoldPugiWorkspace(input) {
2408
+ const cwd = input.cwd;
2409
+ const log = input.log ?? ((line) => process.stderr.write(line));
666
2410
  const pugiDir = resolve(cwd, '.pugi');
667
2411
  const created = [];
668
2412
  const skipped = [];
669
2413
  ensureDir(pugiDir, created, skipped);
670
2414
  ensureDir(resolve(pugiDir, 'artifacts'), created, skipped);
671
2415
  ensureDir(resolve(pugiDir, 'sessions'), created, skipped);
2416
+ ensureDir(resolve(pugiDir, 'skills'), created, skipped);
672
2417
  writeJsonIfMissing(resolve(pugiDir, 'settings.json'), {
673
2418
  schema: 1,
674
2419
  workflow: {
@@ -690,6 +2435,9 @@ async function init(_args, flags, _session) {
690
2435
  mode: 'balanced',
691
2436
  telemetry: 'off',
692
2437
  },
2438
+ ui: {
2439
+ cyberZoo: 'on',
2440
+ },
693
2441
  artifacts: {
694
2442
  defaultPath: '.pugi/artifacts',
695
2443
  promoteExplicitly: true,
@@ -697,7 +2445,19 @@ async function init(_args, flags, _session) {
697
2445
  }, created, skipped);
698
2446
  writeJsonIfMissing(resolve(pugiDir, 'mcp.json'), {
699
2447
  schema: 1,
700
- servers: [],
2448
+ // 2026-05-27 dogfood: `servers` MUST be an object keyed by server
2449
+ // name (z.record(mcpServerConfigSchema) in
2450
+ // apps/pugi-cli/src/core/mcp/registry.ts:51). A bare `[]` array
2451
+ // here passed schema validation на pugi init exit но crashed
2452
+ // the next dispatch with
2453
+ // "MCP config at .pugi/mcp.json failed validation:
2454
+ // servers: Expected object, received array"
2455
+ // and the operator's first command after `pugi init` printed an
2456
+ // error banner before the actual reply. Empty object matches the
2457
+ // schema default and keeps the file forwards-compatible with
2458
+ // `pugi mcp install <name> ...` which merges into the same
2459
+ // record shape.
2460
+ servers: {},
701
2461
  }, created, skipped);
702
2462
  writeJsonIfMissing(resolve(pugiDir, 'index.json'), emptyIndex(), created, skipped);
703
2463
  writeTextIfMissing(resolve(pugiDir, 'PUGI.md'), [
@@ -738,19 +2498,148 @@ async function init(_args, flags, _session) {
738
2498
  // Ensure `.pugi/` is git-ignored so users do not accidentally commit
739
2499
  // local audit logs, artifacts, or triple-review request payloads.
740
2500
  ensurePugiGitIgnore(cwd, created, skipped);
741
- const payload = {
2501
+ // Bundled default skills (brand-voice, endpoint-probe, readme-sync).
2502
+ // Skipped when --no-defaults is passed OR when PUGI_INIT_NO_DEFAULTS=1.
2503
+ // Idempotent: a skill whose target directory already exists is left
2504
+ // alone so re-running `pugi init` after the operator customised one of
2505
+ // the defaults does not clobber their edits.
2506
+ let defaultSkills = [];
2507
+ if (!input.noDefaults) {
2508
+ try {
2509
+ defaultSkills = await installDefaultSkills({
2510
+ workspaceRoot: cwd,
2511
+ log,
2512
+ });
2513
+ }
2514
+ catch (error) {
2515
+ // Default-skills install is a convenience layer. A failure here
2516
+ // (bad sha256 hashing, permission error on .pugi/skills/) must not
2517
+ // leave `pugi init` in a half-state where settings.json exists but
2518
+ // the operator sees an unexplained crash. Log the error to stderr
2519
+ // and continue — the operator can still install skills manually.
2520
+ const message = error instanceof Error ? error.message : String(error);
2521
+ log(`[pugi init] default-skills install failed: ${message}\n`);
2522
+ }
2523
+ }
2524
+ return {
742
2525
  status: 'initialized',
743
2526
  root: cwd,
744
2527
  created,
745
2528
  skipped,
2529
+ defaultSkills,
2530
+ alreadyInitialized: created.length === 0,
746
2531
  };
747
- writeOutput(flags, payload, [
2532
+ }
2533
+ /**
2534
+ * Standalone `pugi init` CLI entry. Thin wrapper around
2535
+ * `scaffoldPugiWorkspace` that handles flag plumbing + writeOutput
2536
+ * formatting. β1a r1: extracted from the previous inline init so the
2537
+ * REPL's `/init` slash can call `scaffoldPugiWorkspace` directly.
2538
+ */
2539
+ async function init(_args, flags, _session) {
2540
+ const result = await scaffoldPugiWorkspace({
2541
+ cwd: process.cwd(),
2542
+ noDefaults: flags.noDefaults,
2543
+ });
2544
+ const defaultSkillLines = flags.noDefaults
2545
+ ? ['Default skills: skipped (--no-defaults)']
2546
+ : result.defaultSkills.length === 0
2547
+ ? ['Default skills: none installed']
2548
+ : [
2549
+ 'Default skills:',
2550
+ ...result.defaultSkills.map((entry) => ` ${entry.name.padEnd(18)} ${entry.status}`),
2551
+ ];
2552
+ // Wave 6 BT 9 Phase 2 (2026-05-27): codegraph context-aware auto-install.
2553
+ // After scaffold, evaluate whether the repo looks big-enough + matches a
2554
+ // supported language. The init flow surfaces the offer copy + the docs
2555
+ // URL; the operator decides via the interactive Yes/no prompt OR (in
2556
+ // --json / --no-tty mode) explicitly via `pugi mcp install codegraph
2557
+ // codegraph serve --mcp` later. We DO NOT auto-install here on the
2558
+ // non-interactive path — silently writing к .pugi/mcp.json without a
2559
+ // visible operator confirmation would violate the trust contract.
2560
+ const codegraphLines = await maybeOfferCodegraphInline(result.root, flags);
2561
+ writeOutput(flags, { ...result, codegraph: codegraphLines.envelope }, [
748
2562
  'Pugi initialized',
749
- `Root: ${cwd}`,
750
- created.length ? `Created:\n${created.map((path) => ` ${path}`).join('\n')}` : 'Created: none',
751
- skipped.length ? `Already present:\n${skipped.map((path) => ` ${path}`).join('\n')}` : 'Already present: none',
2563
+ `Root: ${result.root}`,
2564
+ result.created.length
2565
+ ? `Created:\n${result.created.map((path) => ` ${path}`).join('\n')}`
2566
+ : 'Created: none',
2567
+ result.skipped.length
2568
+ ? `Already present:\n${result.skipped.map((path) => ` ${path}`).join('\n')}`
2569
+ : 'Already present: none',
2570
+ ...defaultSkillLines,
2571
+ ...codegraphLines.text,
752
2572
  ].join('\n'));
753
2573
  }
2574
+ /**
2575
+ * Codegraph offer inline branch for `pugi init` (Wave 6 BT 9 Phase 2).
2576
+ *
2577
+ * Pure information surface — does NOT prompt synchronously. Returns:
2578
+ *
2579
+ * - `text[]` — lines к append к the human-facing init summary
2580
+ * - `envelope` — structured JSON payload included in `--json` output
2581
+ * so a CI harness can branch на the verdict без
2582
+ * re-running detection.
2583
+ *
2584
+ * The interactive Yes/no prompt lives one layer up (the `/init` REPL
2585
+ * slash handles it). The standalone `pugi init` is intentionally non-
2586
+ * interactive — operators wanting a one-liner install can run
2587
+ * `pugi mcp install codegraph codegraph serve --mcp` after seeing the
2588
+ * hint here.
2589
+ */
2590
+ async function maybeOfferCodegraphInline(workspaceRoot, flags) {
2591
+ try {
2592
+ const { evaluateOffer, emitOfferShown } = await import('../core/codegraph/offer-hook.js');
2593
+ const verdict = evaluateOffer({ workspaceRoot });
2594
+ if (!verdict.shouldPrompt) {
2595
+ return {
2596
+ text: [],
2597
+ envelope: {
2598
+ status: 'skipped',
2599
+ reason: verdict.reason,
2600
+ },
2601
+ };
2602
+ }
2603
+ // Surface the telemetry shown-event only for surfaces that
2604
+ // actually rendered к the operator. `--json` consumers still see
2605
+ // the verdict в the envelope so we count those as shown too.
2606
+ emitOfferShown(verdict.detection);
2607
+ const noTty = flags.noTty || flags.json;
2608
+ const lines = [
2609
+ '',
2610
+ 'Codegraph context-aware install (Wave 6):',
2611
+ ` ${verdict.promptCopy}`,
2612
+ ` Docs: ${verdict.docsUrl}`,
2613
+ ];
2614
+ if (!noTty) {
2615
+ lines.push(' Accept: `pugi mcp install codegraph codegraph serve --mcp && pugi mcp trust codegraph`', ' Skip: `pugi mcp install codegraph` will not run automatically — your call.');
2616
+ }
2617
+ else {
2618
+ lines.push(' Non-interactive mode — codegraph NOT auto-installed.', ' Run `pugi mcp install codegraph codegraph serve --mcp` to opt in.');
2619
+ }
2620
+ return {
2621
+ text: lines,
2622
+ envelope: {
2623
+ status: 'offered',
2624
+ sizeCategory: verdict.detection.sizeCategory,
2625
+ languages: verdict.detection.languages,
2626
+ primarySymbolCount: verdict.detection.primarySymbolCount,
2627
+ copy: verdict.promptCopy,
2628
+ docsUrl: verdict.docsUrl,
2629
+ },
2630
+ };
2631
+ }
2632
+ catch (error) {
2633
+ // Defensive — codegraph offer is best-effort, must not fail init.
2634
+ return {
2635
+ text: [],
2636
+ envelope: {
2637
+ status: 'error',
2638
+ reason: error.message,
2639
+ },
2640
+ };
2641
+ }
2642
+ }
754
2643
  async function idea(args, flags, session) {
755
2644
  const prompt = args.join(' ').trim();
756
2645
  if (!prompt) {
@@ -891,6 +2780,7 @@ async function idea(args, flags, session) {
891
2780
  */
892
2781
  async function offlinePlan(args, flags, session) {
893
2782
  const root = process.cwd();
2783
+ await runAutoInitPreflight(root, flags);
894
2784
  ensureInitialized(root);
895
2785
  const prompt = args.join(' ').trim();
896
2786
  const latestIdea = latestArtifactDir(root);
@@ -965,6 +2855,7 @@ async function offlinePlan(args, flags, session) {
965
2855
  }
966
2856
  async function offlineBuild(args, flags, session) {
967
2857
  const root = process.cwd();
2858
+ await runAutoInitPreflight(root, flags);
968
2859
  ensureInitialized(root);
969
2860
  const prompt = args.join(' ').trim();
970
2861
  if (!prompt) {
@@ -1062,6 +2953,7 @@ async function offlineExplain(args, flags, session) {
1062
2953
  }
1063
2954
  async function review(args, flags, session) {
1064
2955
  const root = process.cwd();
2956
+ await runAutoInitPreflight(root, flags);
1065
2957
  ensureInitialized(root);
1066
2958
  const prompt = args.join(' ').trim();
1067
2959
  // α6.7: customer-facing consensus review routes here. Distinct from
@@ -1069,10 +2961,20 @@ async function review(args, flags, session) {
1069
2961
  // streaming UX and rubric-driven exit codes don't disturb the existing
1070
2962
  // pugi-cli surfaces that depend on the old shape.
1071
2963
  if (flags.consensus) {
2964
+ // 2026-05-27 (Codex r0 P1 on PR #489): pass the globally-parsed
2965
+ // --commit / --base flags to consensus so `pugi review --consensus
2966
+ // --commit X` reviews the requested SHA instead of silently falling
2967
+ // back to the working-tree diff. parseConsensusArgs gives the inline
2968
+ // args (`--commit Y` after the command name) precedence; the
2969
+ // fallback only fires when `args` does not carry the token.
1072
2970
  const exitCode = await runReviewConsensus(args, {
1073
2971
  cwd: root,
1074
2972
  config: resolveRuntimeConfig(),
1075
2973
  json: flags.json,
2974
+ flagsFallback: {
2975
+ ...(flags.commit ? { commit: flags.commit } : {}),
2976
+ ...(flags.base ? { base: flags.base } : {}),
2977
+ },
1076
2978
  emit: (line) => {
1077
2979
  if (!flags.json)
1078
2980
  process.stdout.write(line);
@@ -1084,6 +2986,15 @@ async function review(args, flags, session) {
1084
2986
  process.exitCode = exitCode;
1085
2987
  return;
1086
2988
  }
2989
+ if (flags.triple && flags.commit) {
2990
+ // CEO directive 2026-05-27: `pugi review --triple --commit <SHA>`
2991
+ // dispatches to the customer-facing 3-model consensus path through
2992
+ // Anvil's already-paid Anthropic / OpenAI / Google routes. Replaces
2993
+ // the dev-only Codex/Claude/Gemini OAuth CLIs the `/triple-review`
2994
+ // skill uses.
2995
+ await performTripleProviderReview(root, session, flags, prompt);
2996
+ return;
2997
+ }
1087
2998
  if (flags.triple && flags.remote) {
1088
2999
  await performRemoteTripleReview(root, session, flags, prompt);
1089
3000
  return;
@@ -1189,6 +3100,7 @@ async function review(args, flags, session) {
1189
3100
  }
1190
3101
  async function sync(_args, flags, session) {
1191
3102
  const root = process.cwd();
3103
+ await runAutoInitPreflight(root, flags);
1192
3104
  ensureInitialized(root);
1193
3105
  const settings = loadSettings(root);
1194
3106
  const mode = flags.privacy ?? privacyModeFromSettings(settings.privacy.mode);
@@ -1464,62 +3376,363 @@ async function performRemoteTripleReview(root, session, flags, prompt) {
1464
3376
  return;
1465
3377
  }
1466
3378
  const submitResult = await submitTripleReview(config, requestBody);
1467
- if (submitResult.status === 'ok') {
1468
- persistTripleReviewResult(resultPath, submitResult.response);
3379
+ if (submitResult.status === 'ok') {
3380
+ persistTripleReviewResult(resultPath, submitResult.response);
3381
+ writeFileSync(summaryPath, buildTripleReviewMarkdown({
3382
+ prompt,
3383
+ requestPath: relative(root, requestPath),
3384
+ verdict: submitResult.response.verdict,
3385
+ reason: submitResult.response.reason,
3386
+ response: submitResult.response,
3387
+ }), { encoding: 'utf8', mode: 0o600 });
3388
+ recordToolResult(session, toolCallId, submitResult.response.verdict === 'BLOCK' ? 'error' : 'success', `Verdict: ${submitResult.response.verdict} (${submitResult.response.reason})`);
3389
+ writeOutput(flags, {
3390
+ status: 'completed',
3391
+ verdict: submitResult.response.verdict,
3392
+ reason: submitResult.response.reason,
3393
+ counts: submitResult.response.counts,
3394
+ reviewerCount: submitResult.response.reviewerCount,
3395
+ effectiveTier: submitResult.response.effectiveTier,
3396
+ result: relative(root, resultPath),
3397
+ summary: relative(root, summaryPath),
3398
+ }, [
3399
+ `Pugi triple-review ${submitResult.response.verdict}: ${submitResult.response.reason}`,
3400
+ `Reviewers: ${submitResult.response.reviewerCount} (tier ${submitResult.response.effectiveTier})`,
3401
+ `Findings: P0=${submitResult.response.counts.P0} P1=${submitResult.response.counts.P1} P2=${submitResult.response.counts.P2} P3=${submitResult.response.counts.P3}`,
3402
+ `Result: ${relative(root, resultPath)}`,
3403
+ `Summary: ${relative(root, summaryPath)}`,
3404
+ ].join('\n'));
3405
+ if (submitResult.response.verdict === 'BLOCK') {
3406
+ process.exitCode = 9;
3407
+ }
3408
+ return;
3409
+ }
3410
+ // Non-OK paths: persist local artifact noting outcome, surface actionable error.
3411
+ const outcome = describeSubmitFailure(submitResult);
3412
+ writeFileSync(summaryPath, buildTripleReviewMarkdown({
3413
+ prompt,
3414
+ requestPath: relative(root, requestPath),
3415
+ verdict: null,
3416
+ reason: outcome.message,
3417
+ response: null,
3418
+ }), { encoding: 'utf8', mode: 0o600 });
3419
+ recordToolResult(session, toolCallId, 'error', outcome.message);
3420
+ writeOutput(flags, {
3421
+ status: submitResult.status,
3422
+ code: submitResult.code,
3423
+ message: outcome.message,
3424
+ request: relative(root, requestPath),
3425
+ summary: relative(root, summaryPath),
3426
+ }, [
3427
+ outcome.headline,
3428
+ `Request: ${relative(root, requestPath)}`,
3429
+ `Summary: ${relative(root, summaryPath)}`,
3430
+ outcome.next ? `Next: ${outcome.next}` : '',
3431
+ ]
3432
+ .filter(Boolean)
3433
+ .join('\n'));
3434
+ process.exitCode = outcome.exitCode;
3435
+ }
3436
+ /**
3437
+ * `pugi review --triple --commit <SHA>` — customer-facing 3-model
3438
+ * consensus review via Anvil multi-provider routing.
3439
+ *
3440
+ * Dispatches the same diff to Anthropic / OpenAI / Google models
3441
+ * (routed through Anvil's already-paid fleet, NOT OAuth-bound dev
3442
+ * CLIs) and renders the per-reviewer verdict + cross-model
3443
+ * disagreement summary at the end. Quota: one `reviewPerMonth` slot
3444
+ * per call regardless of provider count — the controller-level
3445
+ * `@QuotaGated('reviewPerMonth')` decorator enforces single-slot
3446
+ * debit (see apps/admin-api/src/pugi/pugi.controller.ts).
3447
+ *
3448
+ * CEO directive 2026-05-27: replaces the dev-only `/triple-review`
3449
+ * skill's Codex/Claude/Gemini OAuth dependency with a customer-
3450
+ * runnable Pugi product surface. Dogfood loop: Pugi reviews Pugi PRs.
3451
+ */
3452
+ async function performTripleProviderReview(root, session, flags, prompt) {
3453
+ const config = resolveRuntimeConfig();
3454
+ const artifactDir = createArtifactDir(root, prompt || 'triple-providers');
3455
+ const requestPath = resolve(artifactDir, 'triple-review-request.json');
3456
+ const resultPath = resolve(artifactDir, 'triple-review-result.json');
3457
+ const summaryPath = resolve(artifactDir, 'triple-review.md');
3458
+ const toolCallId = recordToolCall(session, 'review:triple-providers', prompt || `review ${flags.commit ?? 'HEAD'} via providers`);
3459
+ // Resolve base ref. CLI flag wins over settings → so an operator
3460
+ // can target a specific integration branch without editing settings.
3461
+ const settings = loadSettings(root);
3462
+ const baseRef = flags.base ?? resolveBaseRef(root, settings) ?? 'origin/main';
3463
+ // Normalise both the commit and the base to short SHAs so the audit
3464
+ // log stores a stable reference even if branches move.
3465
+ const commitRef = flags.commit ?? 'HEAD';
3466
+ // 2026-05-27 (Codex r0 P2 on PR #489): safeGit returns '' on a bad ref
3467
+ // (it swallows the git exit code so callers don't have to wrap every
3468
+ // probe). Without an explicit refusal, a misspelled --commit or --base
3469
+ // produced an EMPTY diff that the gate then PASSED — operators saw a
3470
+ // green review for changes that were never reviewed. Resolve both refs
3471
+ // through `rev-parse --verify` first; an empty result is a hard error.
3472
+ const verifiedCommit = safeGit(root, ['rev-parse', '--verify', commitRef]).trim();
3473
+ if (!verifiedCommit) {
3474
+ throw new Error(`pugi review --triple: cannot resolve --commit '${commitRef}' — ` +
3475
+ `check the SHA or branch name. ` +
3476
+ `Refusing to submit an empty diff for review.`);
3477
+ }
3478
+ const verifiedBase = safeGit(root, ['rev-parse', '--verify', baseRef]).trim();
3479
+ if (!verifiedBase) {
3480
+ throw new Error(`pugi review --triple: cannot resolve --base '${baseRef}' — ` +
3481
+ `check the ref or set base via 'pugi config set review.base=<ref>'. ` +
3482
+ `Refusing to submit an empty diff for review.`);
3483
+ }
3484
+ const resolvedCommit = safeGit(root, ['rev-parse', '--short', commitRef]).trim() || commitRef;
3485
+ // merge-base is intentionally a PROBE: an empty result is a valid
3486
+ // signal (orphan branch, shallow clone, moved tag) that the dispatch
3487
+ // path handles by falling back к range-notation. Use the legacy
3488
+ // `safeGit` (probe semantics) explicitly rather than the strict
3489
+ // variant.
3490
+ const mergeBase = safeGitProbe(root, ['merge-base', baseRef, commitRef]).trim() || '';
3491
+ // 2026-05-27 (Claude review followup #489): when merge-base returns empty
3492
+ // (orphan branch, shallow clone, moved tag), we MUST NOT pass the
3493
+ // `<range> <commitRef>` two-arg form to `git diff` — that combo is
3494
+ // invalid syntax, git exits 129, `safeGit` swallows the error, and the
3495
+ // diff payload ships empty. An empty diff is then classified as
3496
+ // `'code'` server-side, dispatched to reviewers who emit a trivial
3497
+ // `VERDICT: PASS` over zero lines — a SILENT GREEN REVIEW on a commit
3498
+ // nobody actually examined. Branch on `mergeBase` так что:
3499
+ // - mergeBase present → `git diff <mergeBase> <commitRef> --`
3500
+ // (both endpoints explicit, only-uncommitted-against-base ignored
3501
+ // because commitRef is a SHA, not HEAD).
3502
+ // - mergeBase empty → `git diff <baseRef>..<commitRef> --`
3503
+ // (range form encodes both endpoints; do NOT append commitRef
3504
+ // again or git rejects the args).
3505
+ const diffRange = mergeBase || `${baseRef}..${commitRef}`;
3506
+ const diffArgs = mergeBase
3507
+ ? ['diff', mergeBase, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES]
3508
+ : ['diff', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
3509
+ const diffStatArgs = mergeBase
3510
+ ? ['diff', '--shortstat', mergeBase, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES]
3511
+ : ['diff', '--shortstat', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
3512
+ // Use the strict variant — a non-empty diffPatch is load-bearing for
3513
+ // the review gate. If git fails for ANY reason (bad ref, ENOBUFS, FS
3514
+ // permission), we'd rather surface a hard error than ship a green
3515
+ // review on nothing. The `--shortstat` companion uses the same
3516
+ // helper so the throw is symmetric.
3517
+ const diffPatch = safeGitRequired(root, diffArgs, 'triple-providers diff');
3518
+ const diffStats = parseDiffStats(safeGitRequired(root, diffStatArgs, 'triple-providers diff --shortstat'));
3519
+ if (diffPatch.trim() === '') {
3520
+ throw new Error(`pugi review --triple: empty diff between '${baseRef}' and '${commitRef}'. ` +
3521
+ `Refusing to dispatch a review for zero changes — check the refs ` +
3522
+ `or commit your changes before running.`);
3523
+ }
3524
+ const requestBody = pugiTripleReviewRequestSchema.parse({
3525
+ schema: 1,
3526
+ workspace: {
3527
+ rootName: root.split('/').at(-1) ?? 'workspace',
3528
+ gitBranch: safeGit(root, ['branch', '--show-current']).trim() || null,
3529
+ gitHead: resolvedCommit || null,
3530
+ baseRef,
3531
+ dirty: Boolean(safeGit(root, ['status', '--short']).trim()),
3532
+ },
3533
+ diffPatch,
3534
+ diffStats,
3535
+ prompt: prompt || undefined,
3536
+ locale: 'en-US',
3537
+ reviewerPersona: 'oes-dev',
3538
+ commit: resolvedCommit,
3539
+ modelProviders: ['claude', 'gpt', 'gemini'],
3540
+ });
3541
+ writeFileSync(requestPath, `${JSON.stringify(requestBody, null, 2)}\n`, {
3542
+ encoding: 'utf8',
3543
+ mode: 0o600,
3544
+ });
3545
+ registerArtifact(root, {
3546
+ id: artifactIdFromDir(artifactDir),
3547
+ kind: 'triple-review',
3548
+ path: relative(root, artifactDir),
3549
+ sessionId: session.id,
3550
+ createdAt: new Date().toISOString(),
3551
+ files: ['triple-review-request.json'],
3552
+ });
3553
+ if (!config) {
3554
+ const reason = 'No active Pugi credentials. Run `pugi login --token <PAT>` or set PUGI_API_KEY for CI use.';
3555
+ recordToolResult(session, toolCallId, 'error', reason);
3556
+ writeFileSync(summaryPath, buildTripleReviewMarkdown({
3557
+ prompt,
3558
+ requestPath: relative(root, requestPath),
3559
+ verdict: null,
3560
+ reason,
3561
+ response: null,
3562
+ }), { encoding: 'utf8', mode: 0o600 });
3563
+ writeOutput(flags, {
3564
+ status: 'auth_missing',
3565
+ request: relative(root, requestPath),
3566
+ summary: relative(root, summaryPath),
3567
+ }, [
3568
+ 'Pugi triple-provider review request prepared but not sent — no active credentials.',
3569
+ `Request: ${relative(root, requestPath)}`,
3570
+ `Run \`pugi login --token <PAT>\` (or export PUGI_API_KEY for CI) then retry \`pugi review --triple --commit ${resolvedCommit}\`.`,
3571
+ ].join('\n'));
3572
+ process.exitCode = 5;
3573
+ return;
3574
+ }
3575
+ const submitResult = await submitTripleReview(config, requestBody);
3576
+ if (submitResult.status !== 'ok') {
3577
+ const outcome = describeSubmitFailure(submitResult);
1469
3578
  writeFileSync(summaryPath, buildTripleReviewMarkdown({
1470
3579
  prompt,
1471
3580
  requestPath: relative(root, requestPath),
1472
- verdict: submitResult.response.verdict,
1473
- reason: submitResult.response.reason,
1474
- response: submitResult.response,
3581
+ verdict: null,
3582
+ reason: outcome.message,
3583
+ response: null,
1475
3584
  }), { encoding: 'utf8', mode: 0o600 });
1476
- recordToolResult(session, toolCallId, submitResult.response.verdict === 'BLOCK' ? 'error' : 'success', `Verdict: ${submitResult.response.verdict} (${submitResult.response.reason})`);
3585
+ recordToolResult(session, toolCallId, 'error', outcome.message);
1477
3586
  writeOutput(flags, {
1478
- status: 'completed',
1479
- verdict: submitResult.response.verdict,
1480
- reason: submitResult.response.reason,
1481
- counts: submitResult.response.counts,
1482
- reviewerCount: submitResult.response.reviewerCount,
1483
- effectiveTier: submitResult.response.effectiveTier,
1484
- result: relative(root, resultPath),
3587
+ status: submitResult.status,
3588
+ code: submitResult.code,
3589
+ message: outcome.message,
3590
+ request: relative(root, requestPath),
1485
3591
  summary: relative(root, summaryPath),
1486
3592
  }, [
1487
- `Pugi triple-review ${submitResult.response.verdict}: ${submitResult.response.reason}`,
1488
- `Reviewers: ${submitResult.response.reviewerCount} (tier ${submitResult.response.effectiveTier})`,
1489
- `Findings: P0=${submitResult.response.counts.P0} P1=${submitResult.response.counts.P1} P2=${submitResult.response.counts.P2} P3=${submitResult.response.counts.P3}`,
1490
- `Result: ${relative(root, resultPath)}`,
3593
+ outcome.headline,
3594
+ `Request: ${relative(root, requestPath)}`,
1491
3595
  `Summary: ${relative(root, summaryPath)}`,
1492
- ].join('\n'));
1493
- if (submitResult.response.verdict === 'BLOCK') {
1494
- process.exitCode = 9;
1495
- }
3596
+ outcome.next ? `Next: ${outcome.next}` : '',
3597
+ ]
3598
+ .filter(Boolean)
3599
+ .join('\n'));
3600
+ process.exitCode = outcome.exitCode;
1496
3601
  return;
1497
3602
  }
1498
- // Non-OK paths: persist local artifact noting outcome, surface actionable error.
1499
- const outcome = describeSubmitFailure(submitResult);
3603
+ const response = submitResult.response;
3604
+ persistTripleReviewResult(resultPath, response);
1500
3605
  writeFileSync(summaryPath, buildTripleReviewMarkdown({
1501
3606
  prompt,
1502
3607
  requestPath: relative(root, requestPath),
1503
- verdict: null,
1504
- reason: outcome.message,
1505
- response: null,
3608
+ verdict: response.verdict,
3609
+ reason: response.reason,
3610
+ response,
1506
3611
  }), { encoding: 'utf8', mode: 0o600 });
1507
- recordToolResult(session, toolCallId, 'error', outcome.message);
3612
+ recordToolResult(session, toolCallId, response.verdict === 'BLOCK' ? 'error' : 'success', `Verdict: ${response.verdict} (${response.reason})`);
3613
+ const verdictReport = renderTripleProviderVerdict({
3614
+ response,
3615
+ commit: resolvedCommit,
3616
+ baseRef,
3617
+ });
1508
3618
  writeOutput(flags, {
1509
- status: submitResult.status,
1510
- code: submitResult.code,
1511
- message: outcome.message,
1512
- request: relative(root, requestPath),
3619
+ status: 'completed',
3620
+ verdict: response.verdict,
3621
+ reason: response.reason,
3622
+ counts: response.counts,
3623
+ reviewerCount: response.reviewerCount,
3624
+ effectiveTier: response.effectiveTier,
3625
+ commit: resolvedCommit,
3626
+ baseRef,
3627
+ reviewers: response.reviewers.map((r) => ({
3628
+ provider: r.provider ?? null,
3629
+ model: r.model,
3630
+ declaredVerdict: r.declaredVerdict,
3631
+ findings: r.findings,
3632
+ latencyMs: r.latencyMs,
3633
+ tokensUsed: r.tokensUsed,
3634
+ error: r.error,
3635
+ })),
3636
+ result: relative(root, resultPath),
1513
3637
  summary: relative(root, summaryPath),
1514
- }, [
1515
- outcome.headline,
1516
- `Request: ${relative(root, requestPath)}`,
1517
- `Summary: ${relative(root, summaryPath)}`,
1518
- outcome.next ? `Next: ${outcome.next}` : '',
1519
- ]
1520
- .filter(Boolean)
1521
- .join('\n'));
1522
- process.exitCode = outcome.exitCode;
3638
+ }, verdictReport);
3639
+ if (response.verdict === 'BLOCK') {
3640
+ process.exitCode = 9;
3641
+ }
3642
+ else if (response.verdict === 'WARN') {
3643
+ process.exitCode = 1;
3644
+ }
3645
+ }
3646
+ /**
3647
+ * Pretty-printer for the `pugi review --triple --commit <SHA>` verdict.
3648
+ * Mirrors the `/triple-review` skill's verdict block (per-reviewer
3649
+ * counts table → final GATE line → per-reviewer verbatim → cross-
3650
+ * model disagreement summary → tokens/cost note) so the output is
3651
+ * familiar to operators who already use the dev-only skill.
3652
+ */
3653
+ export function renderTripleProviderVerdict(input) {
3654
+ const { response, commit, baseRef } = input;
3655
+ const divider = '═'.repeat(68);
3656
+ const subDivider = '─'.repeat(68);
3657
+ // Per-reviewer counts table.
3658
+ const reviewerRows = response.reviewers.map((reviewer) => {
3659
+ const c = { P0: 0, P1: 0, P2: 0, P3: 0 };
3660
+ for (const f of reviewer.findings)
3661
+ c[f.severity] += 1;
3662
+ const status = reviewer.error
3663
+ ? 'ERROR'
3664
+ : reviewer.declaredVerdict ?? 'UNKNOWN';
3665
+ const label = reviewer.provider
3666
+ ? reviewer.provider.toUpperCase().padEnd(8)
3667
+ : reviewer.model.slice(0, 8).padEnd(8);
3668
+ return ` ${label} ${pad(c.P0)} ${pad(c.P1)} ${pad(c.P2)} ${pad(c.P3)} ${status}`;
3669
+ });
3670
+ // Cross-model disagreement: list severities flagged by 1 of N but not
3671
+ // the others. Surfaces the "highest-signal moment" per the skill.
3672
+ const disagreements = [];
3673
+ const allFindings = response.reviewers.flatMap((r) => r.findings.map((f) => ({
3674
+ provider: r.provider ?? r.model,
3675
+ severity: f.severity,
3676
+ line: f.line,
3677
+ issue: f.issue,
3678
+ })));
3679
+ const p1Flaggers = new Set(response.reviewers
3680
+ .filter((r) => r.findings.some((f) => f.severity === 'P1'))
3681
+ .map((r) => r.provider ?? r.model));
3682
+ if (p1Flaggers.size === 1) {
3683
+ const sole = [...p1Flaggers][0];
3684
+ disagreements.push(`Only ${sole} flagged a P1 — examine the disagreement, often the highest-signal moment.`);
3685
+ }
3686
+ const p0Flaggers = new Set(response.reviewers
3687
+ .filter((r) => r.findings.some((f) => f.severity === 'P0'))
3688
+ .map((r) => r.provider ?? r.model));
3689
+ if (p0Flaggers.size > 0 && p0Flaggers.size < response.reviewers.length) {
3690
+ disagreements.push(`P0 flagged by ${[...p0Flaggers].join(', ')} but not ${response.reviewers
3691
+ .filter((r) => !p0Flaggers.has(r.provider ?? r.model))
3692
+ .map((r) => r.provider ?? r.model)
3693
+ .join(', ')} — verify the finding before merging.`);
3694
+ }
3695
+ // Tokens / cost summary. Tokens are best-effort (some providers
3696
+ // return null). Cost is a placeholder pending billing wire-up; we
3697
+ // surface the quota note inline so the operator knows it counts as
3698
+ // one slot, not three.
3699
+ const totalTokens = response.reviewers.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
3700
+ // Verbatim reviewer outputs. Each section gets a header so operators
3701
+ // can scroll quickly and copy any individual reviewer's text into
3702
+ // their own notes / triage doc.
3703
+ const reviewerSections = response.reviewers.map((reviewer) => {
3704
+ const label = reviewer.provider
3705
+ ? reviewer.provider.toUpperCase()
3706
+ : reviewer.model;
3707
+ const body = reviewer.error
3708
+ ? `(reviewer errored: ${reviewer.error})`
3709
+ : reviewer.rawContent.trim() || '(empty response)';
3710
+ return [subDivider, `${label} SAYS (${reviewer.model}):`, '', body].join('\n');
3711
+ });
3712
+ return [
3713
+ `PUGI TRIPLE-PROVIDER REVIEW — commit ${commit} vs ${baseRef}`,
3714
+ divider,
3715
+ '',
3716
+ ` P0 P1 P2 P3 Status`,
3717
+ ...reviewerRows,
3718
+ '',
3719
+ `GATE: ${response.verdict}`,
3720
+ `Reason: ${response.reason}`,
3721
+ '',
3722
+ ...reviewerSections,
3723
+ '',
3724
+ subDivider,
3725
+ 'CROSS-MODEL DISAGREEMENT:',
3726
+ disagreements.length === 0
3727
+ ? ' (none — all reviewers agreed within rubric tolerance)'
3728
+ : disagreements.map((d) => ` - ${d}`).join('\n'),
3729
+ '',
3730
+ `Tokens: ~${totalTokens} total across ${response.reviewers.length} reviewers`,
3731
+ 'Quota: charged as 1 review slot (multi-provider counts as a single call).',
3732
+ ].join('\n');
3733
+ }
3734
+ function pad(n) {
3735
+ return String(n).padStart(2, ' ');
1523
3736
  }
1524
3737
  function describeSubmitFailure(result) {
1525
3738
  switch (result.status) {
@@ -1640,6 +3853,7 @@ function parseDiffStats(raw) {
1640
3853
  }
1641
3854
  async function handoff(args, flags, session) {
1642
3855
  const root = process.cwd();
3856
+ await runAutoInitPreflight(root, flags);
1643
3857
  ensureInitialized(root);
1644
3858
  const reason = args[0] || 'web_continue';
1645
3859
  const prompt = args.slice(1).join(' ').trim() || 'continue local Pugi session';
@@ -1647,6 +3861,25 @@ async function handoff(args, flags, session) {
1647
3861
  writeOutput(flags, bundle, ['Pugi handoff bundle created', `Bundle: ${bundle.path}`].join('\n'));
1648
3862
  }
1649
3863
  async function sessions(args, flags, _session) {
3864
+ // L9 (2026-05-27): `pugi sessions undo-rewind [<session-id>]` rolls
3865
+ // back the latest /rewind by appending an inverse marker. Append-only,
3866
+ // reversible. Falls through to the legacy artifact-based handler when
3867
+ // the sub-command is not recognised.
3868
+ if (args[0] === 'undo-rewind') {
3869
+ const result = await runSessionsCommand(args, {
3870
+ workspaceRoot: process.cwd(),
3871
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
3872
+ });
3873
+ if (result) {
3874
+ if (result.status === 'failed_no_session' || result.status === 'failed_store') {
3875
+ process.exitCode = 1;
3876
+ }
3877
+ else if (result.status === 'noop_no_rewind') {
3878
+ process.exitCode = 2;
3879
+ }
3880
+ return;
3881
+ }
3882
+ }
1650
3883
  // α6.4: `pugi sessions --local` / `--search "query"` route to the
1651
3884
  // local SessionStore. The default surface stays artifact-based for
1652
3885
  // backward compat — operators who relied on the index.json view get
@@ -1656,6 +3889,7 @@ async function sessions(args, flags, _session) {
1656
3889
  return;
1657
3890
  }
1658
3891
  const root = process.cwd();
3892
+ await runAutoInitPreflight(root, flags);
1659
3893
  ensureInitialized(root);
1660
3894
  const rebuild = args.includes('--rebuild');
1661
3895
  let index = rebuild ? null : readIndex(root);
@@ -1840,6 +4074,7 @@ async function resume(args, flags, session) {
1840
4074
  await resumeLocalSession({ flags, arg0, wantsList });
1841
4075
  return;
1842
4076
  }
4077
+ await runAutoInitPreflight(root, flags);
1843
4078
  ensureInitialized(root);
1844
4079
  const target = args[0];
1845
4080
  const artifacts = listArtifactSets(root);
@@ -2047,6 +4282,44 @@ const ENGINE_EXIT_CODES = {
2047
4282
  function commandLabel(kind) {
2048
4283
  return kind === 'build_task' ? 'build' : kind;
2049
4284
  }
4285
+ /**
4286
+ * Heuristic: does the user-supplied first arg look like a file or
4287
+ * directory path the operator wants `pugi explain` to inspect? Used to
4288
+ * decide whether to run the pre-engine path-security gate vs treat the
4289
+ * arg as a free-form natural-language prompt.
4290
+ *
4291
+ * Triggers when the arg:
4292
+ * - starts with `.` (`.env`, `./src/foo`, `..`)
4293
+ * - starts with `/` (absolute path)
4294
+ * - contains `/` (`apps/admin-api/src/index.ts`)
4295
+ * - contains no spaces AND exists on disk relative to the workspace
4296
+ *
4297
+ * Misses (treated as free-form prompts):
4298
+ * - "what does this package.json define?" (has spaces)
4299
+ * - "trace the auth flow" (has spaces)
4300
+ *
4301
+ * The pre-engine gate is a defence in depth — the bash classifier and
4302
+ * file-tools `resolveWorkspacePath` already refuse the bad paths inside
4303
+ * the engine, but failing fast at the CLI seam lets the operator see a
4304
+ * crisp permission error with exit code 8 instead of the engine
4305
+ * pretending to "explain" the protected file.
4306
+ */
4307
+ function looksLikePath(arg) {
4308
+ if (!arg)
4309
+ return false;
4310
+ if (arg.includes(' '))
4311
+ return false;
4312
+ if (arg.startsWith('.') || arg.startsWith('/') || arg.includes('/'))
4313
+ return true;
4314
+ // Last-resort check: bare-token paths that exist on disk
4315
+ // (`README.md`, `package.json`) still benefit from the gate.
4316
+ try {
4317
+ return existsSync(resolve(process.cwd(), arg));
4318
+ }
4319
+ catch {
4320
+ return false;
4321
+ }
4322
+ }
2050
4323
  /**
2051
4324
  * Sprint 2 Track A: wire `pugi code/explain/fix/plan/build` to the real
2052
4325
  * `NativePugiEngineAdapter`. Each command:
@@ -2080,19 +4353,81 @@ let engineClientFactory = null;
2080
4353
  export function setEngineClientFactory(factory) {
2081
4354
  engineClientFactory = factory;
2082
4355
  }
4356
+ /**
4357
+ * β-headless test seam: surface the module-scoped engine client factory
4358
+ * to sibling runtime modules (`headless.ts`) so the same fixture
4359
+ * injection that `setEngineClientFactory` provides for the
4360
+ * `runEngineTask` path applies to `pugi --print` runs. Production
4361
+ * callers never read this — the factory is `null` and falls through
4362
+ * to the real `AnvilEngineLoopClient`.
4363
+ */
4364
+ export function getEngineClientFactory() {
4365
+ return engineClientFactory;
4366
+ }
4367
+ /**
4368
+ * β-headless test seam: optional stdout/stderr writers injected for
4369
+ * `pugi --print` runs. When set, the headless runner forwards every
4370
+ * NDJSON line / human-readable chunk to these closures instead of the
4371
+ * real `process.stdout.write` / `process.stderr.write`. Needed because
4372
+ * `node:test`'s worker pool hijacks `process.stdout` for a binary IPC
4373
+ * channel — a captureStdio override would race the runner's frames
4374
+ * and surface as `Unexpected token '\x0F'` JSON parse failures in spec
4375
+ * assertions. Production never sets these.
4376
+ */
4377
+ let headlessStdoutWriter = null;
4378
+ let headlessStderrWriter = null;
4379
+ export function setHeadlessWriters(writers) {
4380
+ headlessStdoutWriter = writers.stdout ?? null;
4381
+ headlessStderrWriter = writers.stderr ?? null;
4382
+ }
2083
4383
  function runEngineTask(kind) {
2084
4384
  return async (args, flags, session) => {
2085
4385
  const label = commandLabel(kind);
2086
4386
  const root = process.cwd();
2087
- // `.pugi/` is created by `pugi init`. The engine writes the per-
2088
- // session events mirror under it, so we fail fast here instead of
2089
- // silently no-op'ing the mirror inside the adapter.
4387
+ // Wave 6 UX (2026-05-27): auto-init pre-flight. On an interactive
4388
+ // TTY in a workspace без `.pugi/` we prompt
4389
+ // "Initialize a new Pugi workspace here? (Y/n)" and scaffold
4390
+ // inline on Y. Falls back к the legacy strict-assert (throw `Run
4391
+ // pugi init first`) in CI / `--no-init`, keeping pinned CI
4392
+ // assertions green.
4393
+ await runAutoInitPreflight(root, flags);
4394
+ // Post-condition assertion — narrows for the type checker and
4395
+ // matches the pre-Wave-6 invariant that the engine adapter
4396
+ // expects `.pugi/` к exist before it writes the events mirror.
2090
4397
  ensureInitialized(root);
2091
- const credential = resolveActiveCredential();
4398
+ // Wave 6 UX (2026-05-27): auto-login pre-flight. Read-only
4399
+ // operators (`pugi explain` against a public repo) and `plan`/
4400
+ // `build` still have legitimate offline fallbacks below, so the
4401
+ // helper output is informational here — we capture it for the
4402
+ // engine_unavailable branch below but never bail unconditionally
4403
+ // on `missing`. `code` / `fix` reject offline runs explicitly,
4404
+ // mirroring the pre-existing contract.
4405
+ const auth = await runAutoAuthPreflight(flags);
4406
+ const credential = auth.status === 'ready' ? auth.credential : null;
2092
4407
  const envConfig = loadRuntimeConfig();
2093
4408
  const config = credential
2094
4409
  ? buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey })
2095
4410
  : envConfig;
4411
+ // α6.8 EXTEND PR1 v2: `--decompose` gating runs BEFORE the offline
4412
+ // fallback. Two reasons:
4413
+ // 1. The flag is plan-only — surfacing the rejection for
4414
+ // `pugi build --decompose` before we drop into `offlineBuild`
4415
+ // means the operator gets a deterministic error instead of a
4416
+ // silent no-op stub.
4417
+ // 2. The decompose post-processor depends on the engine's final
4418
+ // text. The offline plan stub does not invoke the engine, so
4419
+ // `pugi plan --decompose --offline` would silently skip the
4420
+ // decomposition step. Refusing the combination up front is the
4421
+ // cheapest way to keep the contract honest.
4422
+ if (flags.decompose && kind !== 'plan') {
4423
+ throw new Error(`--decompose is only valid for \`pugi plan\` (got \`pugi ${label}\`)`);
4424
+ }
4425
+ if (flags.decompose && flags.offline) {
4426
+ throw new Error('--decompose requires the engine — drop --offline (decomposition needs the model to emit a fenced JSON block)');
4427
+ }
4428
+ if (flags.decompose && !config) {
4429
+ 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)');
4430
+ }
2096
4431
  // Offline fallback: preserves the local-first invariant. `plan` /
2097
4432
  // `build` / `explain` drop back to their pre-Sprint-2 stub
2098
4433
  // behaviour so an operator without an API key (or with --offline)
@@ -2117,6 +4452,73 @@ function runEngineTask(kind) {
2117
4452
  if (kind === 'explain')
2118
4453
  return offlineExplain(args, flags, session);
2119
4454
  }
4455
+ // P0 fix 2026-05-28 (Codex audit): pre-engine path validation for
4456
+ // `pugi explain <path>`. Without this gate, when the first arg
4457
+ // resolves to an on-disk path the engine would happily forward it
4458
+ // to the model — which could then `bash cat .env` or `cat ../X` and
4459
+ // sidestep the file-tools `resolveWorkspacePath`/
4460
+ // `permissionGatedResolve` checks. The bash-classifier now refuses
4461
+ // those reads (PROTECTED_BASENAME_PATTERNS + detectParentTraversalRead),
4462
+ // but we ALSO fail fast at the CLI seam so:
4463
+ // - `pugi explain .env` exits non-zero with a permission error
4464
+ // - `pugi explain ..` exits non-zero with a path-escape error
4465
+ // - `pugi explain alias-to-env` (symlink to .env) exits non-zero
4466
+ // because `permissionGatedResolve` re-checks the realpath
4467
+ // matching the offlineExplain behaviour the spec asserts.
4468
+ if (kind === 'explain' && args.length > 0) {
4469
+ const firstArg = args[0];
4470
+ if (firstArg && looksLikePath(firstArg)) {
4471
+ const targetExists = (() => {
4472
+ try {
4473
+ // First reject parent-traversal patterns OUTRIGHT — even a
4474
+ // path that does not currently exist must not address a
4475
+ // location above the workspace.
4476
+ const resolved = resolveWorkspacePath(root, firstArg);
4477
+ // For paths that exist, run the realpath-aware permission
4478
+ // re-check so symlink aliases to protected files refuse
4479
+ // the same way the file-tools gate would.
4480
+ const settings = loadSettings(root);
4481
+ const protectedReason = protectedTargetReason({ tool: 'explain', kind: 'read', target: firstArg }, root);
4482
+ if (protectedReason) {
4483
+ throw new Error(`Permission deny for explain ${firstArg}: ${protectedReason}`);
4484
+ }
4485
+ // Symlink alias re-check: resolve to realpath and re-test
4486
+ // the basename. Mirrors `permissionGatedResolve` in
4487
+ // file-tools.ts so `alias-to-env -> .env` is refused.
4488
+ try {
4489
+ const real = realpathSync.native(resolved);
4490
+ if (real !== resolved) {
4491
+ const realProtected = protectedTargetReason({ tool: 'explain', kind: 'read', target: relative(root, real) }, root);
4492
+ if (realProtected) {
4493
+ throw new Error(`Permission deny for explain ${firstArg} (via symlink): ${realProtected}`);
4494
+ }
4495
+ }
4496
+ }
4497
+ catch (e) {
4498
+ const code = e.code;
4499
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
4500
+ throw e;
4501
+ }
4502
+ // Suppress unused-var warning while keeping settings load
4503
+ // explicit (some lint configs treat the const as dead).
4504
+ void settings;
4505
+ return true;
4506
+ }
4507
+ catch (error) {
4508
+ const message = error.message;
4509
+ writeOutput(flags, {
4510
+ command: label,
4511
+ status: 'blocked',
4512
+ reason: message,
4513
+ }, [`pugi ${label} refused: ${message}`].join('\n'));
4514
+ process.exitCode = ENGINE_EXIT_CODES.blocked;
4515
+ return false;
4516
+ }
4517
+ })();
4518
+ if (!targetExists)
4519
+ return;
4520
+ }
4521
+ }
2120
4522
  // Engine path prompt gate. (Offline `explain` accepts a path as
2121
4523
  // its first positional arg — that branch returned above before
2122
4524
  // we reach this gate.)
@@ -2145,214 +4547,401 @@ function runEngineTask(kind) {
2145
4547
  throw new Error(`pugi ${label} requires a prompt`);
2146
4548
  }
2147
4549
  }
4550
+ // α6.8 EXTEND PR1: when `--decompose` is set, augment the user
4551
+ // prompt with the decomposition-request suffix BEFORE the adapter
4552
+ // run. The system prompt for `plan` already constrains the model
4553
+ // to read-only tools + a plan deliverable; the suffix layers the
4554
+ // JSON-emission contract on top so the post-run parser can lift
4555
+ // the structured payload out of the final answer. The plan-only /
4556
+ // engine-required gates fired before the offline fallback above,
4557
+ // so by here we know we are on the engine path with a plan task.
4558
+ if (flags.decompose && kind === 'plan') {
4559
+ prompt = `${prompt}\n${DECOMPOSE_PROMPT_SUFFIX}`;
4560
+ }
2148
4561
  // Narrow `config` for the type checker — the offline branches above
2149
4562
  // return whenever `config` is null, so by this point it must be set.
2150
4563
  if (!config) {
2151
4564
  throw new Error('internal: engine config missing after offline gate');
2152
4565
  }
2153
4566
  const client = engineClientFactory ? engineClientFactory(config) : new AnvilEngineLoopClient(config);
2154
- const adapter = new NativePugiEngineAdapter({ client, session });
4567
+ // β1b r1 (--allow-fetch / --allow-search wiring, 2026-05-26):
4568
+ // forward operator flags to the adapter so the schema-advertise +
4569
+ // executor-dispatch gates see the OR of (settings.json flag, CLI
4570
+ // flag). PR #425 r1 Backend Architect: the comment at
4571
+ // `tool-bridge.ts:740` documented `--allow-fetch` but the flag was
4572
+ // never wired into the adapter constructor — fix lands here.
4573
+ //
4574
+ // β4 r2 P1 #3 — load the MCP registry pre-run so the engine's
4575
+ // tool-bridge advertises every trusted server's tools under
4576
+ // `mcp__<server>__<tool>`. Before this fix the registry was never
4577
+ // loaded in the CLI engine path: `pugi mcp install` + `pugi mcp
4578
+ // trust` ran successfully but `pugi code/explain/fix/build` still
4579
+ // saw zero `mcp__*` tools in the schema (so the feature was
4580
+ // non-functional at the customer-facing surface). The adapter does
4581
+ // NOT own the registry lifecycle — we tear it down in the `finally`
4582
+ // below regardless of outcome so live MCP child processes are
4583
+ // reaped before the CLI exits.
4584
+ //
4585
+ // Failure mode: a bad `.pugi/mcp.json` (corrupted JSON, schema
4586
+ // violation) bubbles as an exception from `loadMcpRegistry`. We
4587
+ // surface it as a warning on stderr and continue WITHOUT MCP — the
4588
+ // operator's `pugi code "..."` invocation should not fail just
4589
+ // because a stale MCP entry refuses to parse. They get the engine
4590
+ // run without `mcp__*` tools and a clear hint to fix the file.
4591
+ let mcpRegistry;
4592
+ try {
4593
+ mcpRegistry = await loadMcpRegistry(root);
4594
+ }
4595
+ catch (error) {
4596
+ process.stderr.write(`pugi ${label}: MCP registry load failed — ${error.message}. ` +
4597
+ `Continuing without MCP tools. Fix .pugi/mcp.json to enable.\n`);
4598
+ mcpRegistry = undefined;
4599
+ }
4600
+ // P1 fix (deep audit 2026-05-26): load the workspace HookRegistry so
4601
+ // `.pugi/hooks/` lifecycle hooks fire for model-initiated tool calls
4602
+ // from the engine loop, not just for direct CLI tool invocations.
4603
+ // SECURITY: a `PreToolUse onFailure: 'block'` hook that refuses bash
4604
+ // containing `rm` now applies to model dispatch. Before this fix the
4605
+ // hooks were INVISIBLE to the engine adapter — a workspace operator
4606
+ // who set up a block hook for destructive bash would still see the
4607
+ // model freely dispatch those calls.
4608
+ //
4609
+ // r2 fix (triple-review 2026-05-26 P2): the fail-open path is a
4610
+ // security hole. If `.pugi/hooks.json` exists but is malformed
4611
+ // (truncated write, typo, partial edit) and the operator has block
4612
+ // hooks configured, the previous `continue without hooks` silently
4613
+ // disabled the BLOCK rules — a hostile or careless mutation of the
4614
+ // file would turn off all SECURITY-CRITICAL refusals without any
4615
+ // visible signal. We now distinguish three cases:
4616
+ //
4617
+ // (a) Neither user nor project hooks file exists → no hooks. Safe.
4618
+ // (b) File(s) exist and load() succeeds → hooks live. Normal.
4619
+ // (c) File(s) exist and load() fails → REFUSE THE RUN with a
4620
+ // fatal stderr message and `process.exit(1)`. Operator must
4621
+ // fix the file OR set `PUGI_HOOKS_BYPASS=1` to override (the
4622
+ // escape hatch is logged loudly so it cannot be silent).
4623
+ //
4624
+ // The bypass env var exists for the mid-edit recovery case (the
4625
+ // operator is in the middle of fixing the file and needs to run
4626
+ // pugi to see the world state). It is NEVER a default — the
4627
+ // operator types it explicitly.
4628
+ const hookOutcome = await loadHookRegistryOrExit({
4629
+ workspaceRoot: root,
4630
+ session,
4631
+ label,
4632
+ });
4633
+ if (hookOutcome.kind === 'parse-failure-refused') {
4634
+ // The helper already emitted the fatal message on stderr. Exit
4635
+ // directly so dispatchEngineCommand's caller observes a non-zero
4636
+ // exit code without a stack trace.
4637
+ process.exit(1);
4638
+ }
4639
+ const hooks = hookOutcome.hooks;
4640
+ const adapter = new NativePugiEngineAdapter({
4641
+ client,
4642
+ session,
4643
+ allowFetch: flags.allowFetch,
4644
+ allowSearch: flags.allowSearch,
4645
+ ...(mcpRegistry ? { mcpRegistry } : {}),
4646
+ ...(hooks ? { hooks } : {}),
4647
+ // Non-interactive CLI path: the FSM prompt callback always denies
4648
+ // until the operator explicitly grants permission via
4649
+ // `pugi mcp perms` (out-of-band). A future Ink-backed REPL path
4650
+ // overrides this with a modal prompt; pipes / CI never auto-allow.
4651
+ mcpPrompt: defaultNonInteractiveMcpPrompt,
4652
+ // P1 fix (deep audit 2026-05-26): CLI dispatcher is non-interactive
4653
+ // by default — pipes, CI, and scripted `pugi code "..."` runs do
4654
+ // not have an ink modal to surface ask_user_question into. The
4655
+ // REPL layer (β2b ink modal wiring, future) overrides this with
4656
+ // `interactive: true` + a live askUserBridge.
4657
+ interactive: false,
4658
+ });
2155
4659
  const toolCallId = recordToolCall(session, `engine:${adapter.name}`, `${label}: ${prompt}`);
2156
4660
  const taskId = `${kind}-${Date.now()}`;
2157
- const events = adapter.run({
2158
- id: taskId,
2159
- kind,
2160
- prompt,
2161
- workspaceRoot: root,
2162
- allowedPaths: [root],
2163
- deniedPaths: [],
2164
- artifacts: [],
2165
- // plan mode is enforced inside the tool-bridge (read-only schema +
2166
- // executor refusal sentinel). The permission mode here is the
2167
- // workspace-level toggle and is unchanged from interactive default.
2168
- permissionMode: 'auto',
2169
- }, { sessionId: session.id });
2170
- const statusEvents = [];
2171
- let result = null;
2172
- for await (const event of events) {
2173
- if (event.type === 'status') {
2174
- statusEvents.push(event.message);
2175
- // For `explain` the spec wants status events on stderr so the
2176
- // final summary on stdout is grep-able. Other commands keep the
2177
- // events on stdout-via-final-text so the operator sees the
2178
- // chronological trace.
2179
- if (kind === 'explain' && !flags.json) {
2180
- process.stderr.write(`${event.message}\n`);
4661
+ // β4 r2 P1 #3 — try/finally so loaded MCP child processes are
4662
+ // reaped regardless of run outcome (success, blocked, failed,
4663
+ // thrown). The shutdown is best-effort; we never want a stuck
4664
+ // MCP server to mask a successful Pugi run.
4665
+ try {
4666
+ const events = adapter.run({
4667
+ id: taskId,
4668
+ kind,
4669
+ prompt,
4670
+ workspaceRoot: root,
4671
+ allowedPaths: [root],
4672
+ deniedPaths: [],
4673
+ artifacts: [],
4674
+ // plan mode is enforced inside the tool-bridge (read-only schema +
4675
+ // executor refusal sentinel). The permission mode here is the
4676
+ // workspace-level toggle and is unchanged from interactive default.
4677
+ permissionMode: 'auto',
4678
+ }, { sessionId: session.id });
4679
+ const statusEvents = [];
4680
+ let result = null;
4681
+ for await (const event of events) {
4682
+ if (event.type === 'status') {
4683
+ statusEvents.push(event.message);
4684
+ // For `explain` the spec wants status events on stderr so the
4685
+ // final summary on stdout is grep-able. Other commands keep the
4686
+ // events on stdout-via-final-text so the operator sees the
4687
+ // chronological trace.
4688
+ if (kind === 'explain' && !flags.json) {
4689
+ process.stderr.write(`${event.message}\n`);
4690
+ }
4691
+ }
4692
+ else {
4693
+ result = {
4694
+ status: event.result.status,
4695
+ summary: event.result.summary,
4696
+ filesChanged: event.result.filesChanged,
4697
+ eventRefs: event.result.eventRefs,
4698
+ risks: event.result.risks,
4699
+ };
2181
4700
  }
2182
4701
  }
2183
- else {
4702
+ if (!result) {
4703
+ // Adapter MUST emit a terminal result event. Treat the empty
4704
+ // outcome as a failure so the CLI surfaces a clear error rather
4705
+ // than exiting 0 with no output.
2184
4706
  result = {
2185
- status: event.result.status,
2186
- summary: event.result.summary,
2187
- filesChanged: event.result.filesChanged,
2188
- eventRefs: event.result.eventRefs,
2189
- risks: event.result.risks,
4707
+ status: 'failed',
4708
+ summary: 'engine adapter returned no result',
4709
+ filesChanged: [],
4710
+ eventRefs: [],
4711
+ risks: ['adapter terminated without emitting a result event'],
2190
4712
  };
2191
4713
  }
2192
- }
2193
- if (!result) {
2194
- // Adapter MUST emit a terminal result event. Treat the empty
2195
- // outcome as a failure so the CLI surfaces a clear error rather
2196
- // than exiting 0 with no output.
2197
- result = {
2198
- status: 'failed',
2199
- summary: 'engine adapter returned no result',
2200
- filesChanged: [],
2201
- eventRefs: [],
2202
- risks: ['adapter terminated without emitting a result event'],
2203
- };
2204
- }
2205
- // α6.6 diff escalation Layer A/B/C dispatcher.
2206
- //
2207
- // Some models emit file edits as inline SEARCH/REPLACE markers in
2208
- // the final response rather than through tool calls (especially
2209
- // Gemini and o1 family, which under-use tool schemas in long
2210
- // reasoning chains). We run the dispatcher against the model's
2211
- // final text so those markers still land on disk. Tool-call edits
2212
- // (Layer-A equivalent already handled by `edit`/`write` tools) are
2213
- // unaffected — the dispatcher only fires on prose blocks that
2214
- // happen to contain markers.
2215
- //
2216
- // Scope: code / fix / build / explain only. `plan` is read-only
2217
- // (the engine refuses write tools), so even a stray marker in plan
2218
- // output gets ignored to honour the plan-mode contract.
2219
- //
2220
- // Dry-run + read-only short-circuits: when the flags forbid writes
2221
- // we dispatch with `dryRun: true` so the operator still sees what
2222
- // WOULD have been written, but nothing touches disk.
2223
- let dispatchResults = [];
2224
- if (kind === 'code' || kind === 'fix' || kind === 'build_task') {
2225
- dispatchResults = await runMarkerDispatch({
2226
- root,
2227
- result: {
2228
- status: result.status,
2229
- summary: result.summary,
2230
- eventRefs: result.eventRefs,
2231
- },
2232
- dryRun: flags.dryRun,
2233
- });
2234
- // Merge dispatcher-touched files into `result.filesChanged` so the
2235
- // operator-facing summary lists them alongside tool-driven edits.
2236
- for (const dr of dispatchResults) {
2237
- if (dr.ok && dr.absPath) {
2238
- const rel = relative(root, dr.absPath);
2239
- if (!result.filesChanged.includes(rel))
2240
- result.filesChanged.push(rel);
4714
+ // α6.6 diff escalation — Layer A/B/C dispatcher.
4715
+ //
4716
+ // Some models emit file edits as inline SEARCH/REPLACE markers in
4717
+ // the final response rather than through tool calls (especially
4718
+ // Gemini and o1 family, which under-use tool schemas in long
4719
+ // reasoning chains). We run the dispatcher against the model's
4720
+ // final text so those markers still land on disk. Tool-call edits
4721
+ // (Layer-A equivalent already handled by `edit`/`write` tools) are
4722
+ // unaffected — the dispatcher only fires on prose blocks that
4723
+ // happen to contain markers.
4724
+ //
4725
+ // Scope: code / fix / build / explain only. `plan` is read-only
4726
+ // (the engine refuses write tools), so even a stray marker in plan
4727
+ // output gets ignored to honour the plan-mode contract.
4728
+ //
4729
+ // Dry-run + read-only short-circuits: when the flags forbid writes
4730
+ // we dispatch with `dryRun: true` so the operator still sees what
4731
+ // WOULD have been written, but nothing touches disk.
4732
+ let dispatchResults = [];
4733
+ if (kind === 'code' || kind === 'fix' || kind === 'build_task') {
4734
+ dispatchResults = await runMarkerDispatch({
4735
+ root,
4736
+ result: {
4737
+ status: result.status,
4738
+ summary: result.summary,
4739
+ eventRefs: result.eventRefs,
4740
+ },
4741
+ dryRun: flags.dryRun,
4742
+ });
4743
+ // Merge dispatcher-touched files into `result.filesChanged` so the
4744
+ // operator-facing summary lists them alongside tool-driven edits.
4745
+ for (const dr of dispatchResults) {
4746
+ if (dr.ok && dr.absPath) {
4747
+ const rel = relative(root, dr.absPath);
4748
+ if (!result.filesChanged.includes(rel))
4749
+ result.filesChanged.push(rel);
4750
+ }
2241
4751
  }
2242
4752
  }
2243
- }
2244
- // For `plan` we always write a plan.md artifact, regardless of
2245
- // outcome. A blocked plan (budget exhausted, tool refusal) still
2246
- // produces a reviewable artifact — the reason is recorded inline.
2247
- let planArtifact = null;
2248
- if (kind === 'plan') {
2249
- planArtifact = writePlanArtifact({
2250
- root,
2251
- session,
2252
- prompt,
2253
- result,
2254
- statusEvents,
2255
- });
2256
- }
2257
- // Pull the headline metrics out of `eventRefs` so the summary and
2258
- // JSON envelope match without re-parsing strings in two places.
2259
- const metrics = parseEventRefs(result.eventRefs);
2260
- const finalStatus = result.status === 'failed' ? 'error' : 'success';
2261
- recordToolResult(session, toolCallId, finalStatus, result.summary);
2262
- // Exit code policy (spec §1-§5):
2263
- // code/fix/build → 0 done, 8 failed, 9 blocked
2264
- // explain → same triple; read-only blocked = budget exhaustion
2265
- // plan → 0 on done OR plan-mode refusal (refusal is a
2266
- // SUCCESS for plan: the gate worked); 8 on failed
2267
- // transport; 9 on budget exhaustion.
2268
- //
2269
- // Code Reviewer P2 retro 2026-05-23: previously `plan` masked
2270
- // `budget_exhausted` as exit 0, so a CI loop with a token budget
2271
- // hit looked identical to a successful plan. We now distinguish
2272
- // via the adapter's `outcome=<status>` echo on `eventRefs` so
2273
- // shell wrappers can branch on the real cause.
2274
- if (kind === 'plan') {
2275
- if (result.status === 'failed') {
2276
- process.exitCode = ENGINE_EXIT_CODES.failed;
2277
- }
2278
- else if (result.status === 'blocked' &&
2279
- metrics.outcome === 'budget_exhausted') {
2280
- process.exitCode = ENGINE_EXIT_CODES.blocked;
4753
+ // For `plan` we always write a plan.md artifact, regardless of
4754
+ // outcome. A blocked plan (budget exhausted, tool refusal) still
4755
+ // produces a reviewable artifact the reason is recorded inline.
4756
+ let planArtifact = null;
4757
+ if (kind === 'plan') {
4758
+ planArtifact = writePlanArtifact({
4759
+ root,
4760
+ session,
4761
+ prompt,
4762
+ result,
4763
+ statusEvents,
4764
+ });
2281
4765
  }
2282
- else {
2283
- // `done`, or `blocked` with outcome=tool_refused (= the plan-mode
2284
- // gate fired, which is the contract working as designed), or
2285
- // `blocked` with no outcome echo (legacy adapter preserve the
2286
- // pre-retro 0 behaviour to avoid breaking external scripts).
2287
- process.exitCode = 0;
4766
+ // α6.8 EXTEND PR1: `--decompose` post-processing. We only attempt
4767
+ // the parse on a `done` plan (a blocked/failed plan is already
4768
+ // captured in plan.md with its reason; no JSON to extract). The
4769
+ // model's final answer arrives via `result.summary`on success
4770
+ // the adapter prefix is empty so it is the raw final text. We
4771
+ // strip any leading/trailing whitespace then run the parser
4772
+ // against the contents. On parse failure we surface a non-fatal
4773
+ // structured error in the payload — the operator still gets the
4774
+ // plan.md artifact and can re-run.
4775
+ //
4776
+ // TODO(α7.x): `result.summary` is currently a string contract that
4777
+ // doubles as both "human-readable headline" and "raw final model
4778
+ // text". Split into `{ summary, finalText }` on the adapter so the
4779
+ // parser does not have to assume the prefix is empty. Tracked in
4780
+ // PR #423 v2 retro (P2.6, Claude review).
4781
+ let decomposeArtifact = null;
4782
+ let decomposeError = null;
4783
+ if (flags.decompose && kind === 'plan' && result.status === 'done') {
4784
+ const parsed = parseDecompositionFromText(result.summary);
4785
+ if (parsed.ok) {
4786
+ decomposeArtifact = writeDecomposition({
4787
+ root,
4788
+ sessionId: session.id,
4789
+ // Persist the OPERATOR's original prompt, not the prompt+suffix
4790
+ // we sent to the engine. The suffix is plumbing; the manifest
4791
+ // header reads naturally only with the operator text.
4792
+ prompt: args.join(' ').trim() || prompt,
4793
+ decomposition: parsed.decomposition,
4794
+ rationale: parsed.rationale,
4795
+ });
4796
+ }
4797
+ else {
4798
+ decomposeError = { reason: parsed.reason, detail: parsed.detail };
4799
+ }
2288
4800
  }
2289
- }
2290
- else {
2291
- process.exitCode = ENGINE_EXIT_CODES[result.status];
2292
- }
2293
- const payload = {
2294
- command: label,
2295
- taskId,
2296
- status: result.status,
2297
- summary: result.summary,
2298
- filesChanged: result.filesChanged,
2299
- toolCalls: metrics.toolCalls,
2300
- turns: metrics.turns,
2301
- tokens: metrics.tokens,
2302
- sessionId: session.id,
2303
- sessionEventsMirror: metrics.mirror,
2304
- risks: result.risks,
2305
- plan: planArtifact ? { path: planArtifact.relPath } : undefined,
2306
- // α6.6 per-edit dispatcher trace. Empty array when no inline
2307
- // markers were detected in the model's final response.
2308
- diffEdits: dispatchResults.map((dr) => ({
2309
- layer: dr.layer,
2310
- file: dr.file,
2311
- ok: dr.ok,
2312
- bytesWritten: dr.bytesWritten,
2313
- reason: dr.reason,
2314
- detail: dr.detail,
2315
- })),
2316
- // The full event stream is useful for cabinet UI replay. We surface
2317
- // it in JSON mode only — text mode operators want the summary, not
2318
- // 30 turn-level lines.
2319
- events: flags.json ? statusEvents : undefined,
2320
- };
2321
- const textLines = [];
2322
- if (kind === 'plan' && planArtifact) {
2323
- textLines.push(`Pugi plan written to ${planArtifact.relPath}`);
2324
- }
2325
- textLines.push(`Pugi ${label}: ${result.status}`);
2326
- textLines.push(`Summary: ${result.summary}`);
2327
- if (result.filesChanged.length > 0) {
2328
- textLines.push(`Files modified (${result.filesChanged.length}):`);
2329
- for (const file of result.filesChanged)
2330
- textLines.push(` - ${file}`);
2331
- }
2332
- else if (kind !== 'explain' && kind !== 'plan') {
2333
- textLines.push('Files modified: none');
2334
- }
2335
- textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
2336
- if (dispatchResults.length > 0) {
2337
- const okCount = dispatchResults.filter((d) => d.ok).length;
2338
- const failCount = dispatchResults.length - okCount;
2339
- textLines.push(`Diff dispatch: ${okCount} applied, ${failCount} rejected (${dispatchResults.length} marker block${dispatchResults.length === 1 ? '' : 's'})`);
2340
- for (const dr of dispatchResults) {
2341
- if (dr.ok) {
2342
- textLines.push(` + ${dr.layer} ${dr.file} (${dr.bytesWritten} bytes)`);
4801
+ // Pull the headline metrics out of `eventRefs` so the summary and
4802
+ // JSON envelope match without re-parsing strings in two places.
4803
+ const metrics = parseEventRefs(result.eventRefs);
4804
+ const finalStatus = result.status === 'failed' ? 'error' : 'success';
4805
+ recordToolResult(session, toolCallId, finalStatus, result.summary);
4806
+ // Exit code policy (spec §1-§5):
4807
+ // code/fix/build → 0 done, 8 failed, 9 blocked
4808
+ // explain → same triple; read-only blocked = budget exhaustion
4809
+ // plan → 0 on done OR plan-mode refusal (refusal is a
4810
+ // SUCCESS for plan: the gate worked); 8 on failed
4811
+ // transport; 9 on budget exhaustion.
4812
+ //
4813
+ // Code Reviewer P2 retro 2026-05-23: previously `plan` masked
4814
+ // `budget_exhausted` as exit 0, so a CI loop with a token budget
4815
+ // hit looked identical to a successful plan. We now distinguish
4816
+ // via the adapter's `outcome=<status>` echo on `eventRefs` so
4817
+ // shell wrappers can branch on the real cause.
4818
+ if (kind === 'plan') {
4819
+ if (result.status === 'failed') {
4820
+ process.exitCode = ENGINE_EXIT_CODES.failed;
4821
+ }
4822
+ else if (result.status === 'blocked' &&
4823
+ metrics.outcome === 'budget_exhausted') {
4824
+ process.exitCode = ENGINE_EXIT_CODES.blocked;
2343
4825
  }
2344
4826
  else {
2345
- textLines.push(` ! ${dr.layer} ${dr.file}: ${dr.reason ?? 'failure'} ${dr.detail ?? ''}`);
4827
+ // `done`, or `blocked` with outcome=tool_refused (= the plan-mode
4828
+ // gate fired, which is the contract working as designed), or
4829
+ // `blocked` with no outcome echo (legacy adapter — preserve the
4830
+ // pre-retro 0 behaviour to avoid breaking external scripts).
4831
+ process.exitCode = 0;
4832
+ }
4833
+ }
4834
+ else {
4835
+ process.exitCode = ENGINE_EXIT_CODES[result.status];
4836
+ }
4837
+ const payload = {
4838
+ command: label,
4839
+ taskId,
4840
+ status: result.status,
4841
+ summary: result.summary,
4842
+ filesChanged: result.filesChanged,
4843
+ toolCalls: metrics.toolCalls,
4844
+ turns: metrics.turns,
4845
+ tokens: metrics.tokens,
4846
+ sessionId: session.id,
4847
+ sessionEventsMirror: metrics.mirror,
4848
+ risks: result.risks,
4849
+ plan: planArtifact ? { path: planArtifact.relPath } : undefined,
4850
+ // α6.6 — per-edit dispatcher trace. Empty array when no inline
4851
+ // markers were detected in the model's final response.
4852
+ diffEdits: dispatchResults.map((dr) => ({
4853
+ layer: dr.layer,
4854
+ file: dr.file,
4855
+ ok: dr.ok,
4856
+ bytesWritten: dr.bytesWritten,
4857
+ reason: dr.reason,
4858
+ detail: dr.detail,
4859
+ })),
4860
+ // α6.8 EXTEND PR1: decompose artifacts (only present when
4861
+ // `--decompose` was passed AND the model emitted a parseable
4862
+ // JSON block). The `error` shape lands when the model returned
4863
+ // unparseable output; the operator can re-run with a tighter
4864
+ // prompt without losing the plain plan.md artifact.
4865
+ decompose: decomposeArtifact !== null
4866
+ ? {
4867
+ manifest: relative(root, decomposeArtifact.manifestPath),
4868
+ planDir: relative(root, decomposeArtifact.planDir),
4869
+ splits: decomposeArtifact.splitPaths,
4870
+ }
4871
+ : decomposeError !== null
4872
+ ? { error: decomposeError }
4873
+ : undefined,
4874
+ // The full event stream is useful for cabinet UI replay. We surface
4875
+ // it in JSON mode only — text mode operators want the summary, not
4876
+ // 30 turn-level lines.
4877
+ events: flags.json ? statusEvents : undefined,
4878
+ };
4879
+ const textLines = [];
4880
+ if (kind === 'plan' && planArtifact) {
4881
+ textLines.push(`Pugi plan written to ${planArtifact.relPath}`);
4882
+ }
4883
+ if (decomposeArtifact !== null) {
4884
+ textLines.push(`Decomposition: ${decomposeArtifact.splitPaths.length} component spec${decomposeArtifact.splitPaths.length === 1 ? '' : 's'} under ${relative(root, decomposeArtifact.planDir)}`);
4885
+ textLines.push(`Manifest: ${relative(root, decomposeArtifact.manifestPath)}`);
4886
+ }
4887
+ else if (decomposeError !== null) {
4888
+ textLines.push(`Decomposition: skipped (${decomposeError.reason}) — plan.md still written`);
4889
+ }
4890
+ textLines.push(`Pugi ${label}: ${result.status}`);
4891
+ textLines.push(`Summary: ${result.summary}`);
4892
+ if (result.filesChanged.length > 0) {
4893
+ textLines.push(`Files modified (${result.filesChanged.length}):`);
4894
+ for (const file of result.filesChanged)
4895
+ textLines.push(` - ${file}`);
4896
+ }
4897
+ else if (kind !== 'explain' && kind !== 'plan') {
4898
+ textLines.push('Files modified: none');
4899
+ }
4900
+ textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
4901
+ if (dispatchResults.length > 0) {
4902
+ const okCount = dispatchResults.filter((d) => d.ok).length;
4903
+ const failCount = dispatchResults.length - okCount;
4904
+ textLines.push(`Diff dispatch: ${okCount} applied, ${failCount} rejected (${dispatchResults.length} marker block${dispatchResults.length === 1 ? '' : 's'})`);
4905
+ for (const dr of dispatchResults) {
4906
+ if (dr.ok) {
4907
+ textLines.push(` + ${dr.layer} ${dr.file} (${dr.bytesWritten} bytes)`);
4908
+ }
4909
+ else {
4910
+ textLines.push(` ! ${dr.layer} ${dr.file}: ${dr.reason ?? 'failure'} — ${dr.detail ?? ''}`);
4911
+ }
2346
4912
  }
2347
4913
  }
4914
+ if (result.risks.length > 0) {
4915
+ textLines.push(`Risks: ${result.risks.join('; ')}`);
4916
+ }
4917
+ textLines.push(`Session: ${session.id}`);
4918
+ if (metrics.mirror)
4919
+ textLines.push(`Events mirror: ${metrics.mirror}`);
4920
+ writeOutput(flags, payload, textLines.join('\n'));
2348
4921
  }
2349
- if (result.risks.length > 0) {
2350
- textLines.push(`Risks: ${result.risks.join('; ')}`);
4922
+ finally {
4923
+ // β4 r2 P1 #3 — tear down live MCP child processes BEFORE the
4924
+ // CLI exits. shutdown() is idempotent and swallows per-server
4925
+ // disconnect errors, so it is safe even if no servers connected.
4926
+ if (mcpRegistry) {
4927
+ await mcpRegistry.shutdown().catch((error) => {
4928
+ process.stderr.write(`pugi ${label}: MCP registry shutdown reported error — ${error.message}\n`);
4929
+ });
4930
+ }
4931
+ // Leak L15 (2026-05-27) — tear down any LSP servers warmed up
4932
+ // by the post-edit diagnostics cache. The cache is per-process
4933
+ // and survives across multiple tool calls; without this hook a
4934
+ // `pugi code ...` invocation would leak a tsserver process when
4935
+ // the Node host exits. The dynamic import keeps the cache module
4936
+ // out of the cold path for runs that never touch LSP.
4937
+ try {
4938
+ const { stopAllLspClients } = await import('../core/lsp/cache.js');
4939
+ await stopAllLspClients();
4940
+ }
4941
+ catch (error) {
4942
+ process.stderr.write(`pugi ${label}: LSP cache shutdown reported error — ${error.message}\n`);
4943
+ }
2351
4944
  }
2352
- textLines.push(`Session: ${session.id}`);
2353
- if (metrics.mirror)
2354
- textLines.push(`Events mirror: ${metrics.mirror}`);
2355
- writeOutput(flags, payload, textLines.join('\n'));
2356
4945
  };
2357
4946
  }
2358
4947
  // Exported for the α6.6.1 triple-review remediation spec
@@ -2584,7 +5173,7 @@ async function login(args, flags, _session) {
2584
5173
  if (args.includes('--help') || args.includes('-h')) {
2585
5174
  writeOutput(flags, {
2586
5175
  command: 'login',
2587
- usage: 'pugi login [--provider device|token|env] [--token <PAT>] [--token-stdin] [--label <name>] [--api-url <url>]',
5176
+ usage: 'pugi login [--provider device|token|env] [--token <PAT>] [--token-stdin] [--key <value>] [--skip-validate] [--label <name>] [--api-url <url>]',
2588
5177
  }, [
2589
5178
  'Usage: pugi login [options]',
2590
5179
  '',
@@ -2596,19 +5185,27 @@ async function login(args, flags, _session) {
2596
5185
  'Non-interactive options:',
2597
5186
  ' --provider device Run the device-flow login (recommended).',
2598
5187
  ' --provider token Store an API key passed via --token / --token-stdin / PUGI_LOGIN_TOKEN.',
2599
- ' --provider env Promote PUGI_API_KEY from the environment into the store.',
5188
+ ' --provider env Read PUGI_API_KEY (or --key) and verify it via /api/pugi/health.',
2600
5189
  ' --token <PAT> Inline API key (visible in `ps`).',
2601
5190
  ' --token-stdin Read API key from stdin (gh-CLI style).',
5191
+ ' --key <value> Explicit key for --provider env; beats PUGI_API_KEY.',
5192
+ ' --skip-validate Skip the /api/pugi/health probe for --provider env (CI bootstrap).',
2602
5193
  ' --label <name> Short label surfaced in `pugi accounts list`.',
2603
5194
  ' --api-url <url> Override the Anvil endpoint (self-hosted).',
2604
5195
  ' --no-device-flow Refuse the device flow; fail fast in CI without a token.',
2605
5196
  '',
5197
+ 'Environment variables:',
5198
+ ' PUGI_API_KEY Read by --provider env. Pass --key to override.',
5199
+ ' PUGI_LOGIN_TOKEN Read by --provider token in non-interactive shells.',
5200
+ ' PUGI_API_URL Override the Anvil endpoint (same as --api-url).',
5201
+ '',
2606
5202
  'Examples:',
2607
5203
  ' pugi login # interactive picker on a TTY',
2608
5204
  ' pugi login --provider device # explicit browser OAuth',
2609
5205
  ' pugi login --provider token --token sk-xx # paste in a key',
2610
5206
  ' echo $TOKEN | pugi login --provider token --token-stdin',
2611
- ' PUGI_API_KEY=sk-xx pugi login --provider env',
5207
+ ' PUGI_API_KEY=pugi_xxx pugi login --provider env',
5208
+ ' pugi login --provider env --key pugi_xxx # explicit key beats env',
2612
5209
  ].join('\n'));
2613
5210
  return;
2614
5211
  }
@@ -2631,6 +5228,11 @@ async function login(args, flags, _session) {
2631
5228
  const apiUrlOverride = extractApiUrlFlag(args);
2632
5229
  const labelFlag = extractLabelFlag(args);
2633
5230
  const provider = parseProviderFlag(args);
5231
+ // Leak L35 (2026-05-27): `--key` is the explicit-arg path for
5232
+ // `--provider env`; `--skip-validate` bypasses the /api/pugi/health
5233
+ // probe (CI bootstrap before the network is up).
5234
+ const envExplicitKey = extractKeyFlag(args);
5235
+ const envSkipValidate = args.includes('--skip-validate');
2634
5236
  const apiUrl = normalizeApiUrl(apiUrlOverride ?? process.env.PUGI_API_URL ?? DEFAULT_API_URL);
2635
5237
  // Path 1: explicit --provider trumps everything else.
2636
5238
  if (provider) {
@@ -2641,6 +5243,8 @@ async function login(args, flags, _session) {
2641
5243
  explicitToken: tokenFromArgs,
2642
5244
  tokenStdinFlag,
2643
5245
  noDeviceFlow,
5246
+ envExplicitKey,
5247
+ envSkipValidate,
2644
5248
  });
2645
5249
  return;
2646
5250
  }
@@ -2688,6 +5292,8 @@ async function login(args, flags, _session) {
2688
5292
  flags,
2689
5293
  label: labelFlag,
2690
5294
  noDeviceFlow,
5295
+ envExplicitKey,
5296
+ envSkipValidate,
2691
5297
  });
2692
5298
  return;
2693
5299
  }
@@ -2906,16 +5512,28 @@ async function dispatchLoginProvider(provider, ctx) {
2906
5512
  return;
2907
5513
  }
2908
5514
  case 'env': {
2909
- const envKey = process.env.PUGI_API_KEY;
2910
- if (!envKey) {
2911
- throw new Error('pugi login --provider env requires PUGI_API_KEY to be exported in the current shell.');
5515
+ // Leak L35 (2026-05-27): resolve the env / --key candidate,
5516
+ // run the local format check, then probe `/api/pugi/health`
5517
+ // BEFORE persisting. A bad token never lands on disk so the
5518
+ // next `pugi <anything>` does not silently 401 against the
5519
+ // cabinet. `--skip-validate` opts out for CI bootstrap.
5520
+ const resolved = await resolveAndValidateEnvLogin({
5521
+ apiUrl: ctx.apiUrl,
5522
+ explicitKey: ctx.envExplicitKey,
5523
+ env: process.env,
5524
+ skipValidate: ctx.envSkipValidate ?? false,
5525
+ });
5526
+ if (resolved.kind !== 'ok') {
5527
+ reportEnvLoginFailure(resolved, ctx.flags);
5528
+ return;
2912
5529
  }
2913
5530
  storeAndAnnounceToken({
2914
5531
  apiUrl: ctx.apiUrl,
2915
- apiKey: envKey,
5532
+ apiKey: resolved.token,
2916
5533
  label: ctx.label,
2917
5534
  source: 'env',
2918
5535
  flags: ctx.flags,
5536
+ validatedLatencyMs: resolved.latencyMs > 0 ? resolved.latencyMs : undefined,
2919
5537
  });
2920
5538
  return;
2921
5539
  }
@@ -2934,6 +5552,15 @@ function storeAndAnnounceToken(input) {
2934
5552
  label: input.label,
2935
5553
  source: input.source,
2936
5554
  });
5555
+ const textLines = [
5556
+ `Pugi logged in for ${record.apiUrl}`,
5557
+ `Method: ${input.source}${record.label ? ` (${record.label})` : ''}`,
5558
+ `Token: ${maskApiKey(record.apiKey)}`,
5559
+ ];
5560
+ if (typeof input.validatedLatencyMs === 'number') {
5561
+ textLines.push(`Verified via /api/pugi/health in ${input.validatedLatencyMs}ms`);
5562
+ }
5563
+ textLines.push('Stored at ~/.pugi/credentials.json (mode 0600). Run `pugi whoami` to verify.');
2937
5564
  writeOutput(input.flags, {
2938
5565
  status: 'logged_in',
2939
5566
  apiUrl: record.apiUrl,
@@ -2941,12 +5568,55 @@ function storeAndAnnounceToken(input) {
2941
5568
  label: record.label ?? null,
2942
5569
  createdAt: record.createdAt,
2943
5570
  source: input.source,
2944
- }, [
2945
- `Pugi logged in for ${record.apiUrl}`,
2946
- `Method: ${input.source}${record.label ? ` (${record.label})` : ''}`,
2947
- `Token: ${maskApiKey(record.apiKey)}`,
2948
- 'Stored at ~/.pugi/credentials.json (mode 0600). Run `pugi whoami` to verify.',
2949
- ].join('\n'));
5571
+ ...(typeof input.validatedLatencyMs === 'number'
5572
+ ? { validatedLatencyMs: input.validatedLatencyMs }
5573
+ : {}),
5574
+ }, textLines.join('\n'));
5575
+ }
5576
+ /**
5577
+ * Render a typed `EnvLoginFailure` from `resolveAndValidateEnvLogin`
5578
+ * onto the surrounding CLI surface. Maps the failure kind to:
5579
+ * - an exit code (1 by default; 2 for invalid format so a CI step
5580
+ * can disambiguate "missing key" vs "key shape wrong" without
5581
+ * parsing stderr; 4 for network / server errors so retry logic
5582
+ * can distinguish transient failures from credential failures)
5583
+ * - a structured JSON payload for `--json` consumers
5584
+ * - a human-readable stderr line for the interactive path
5585
+ *
5586
+ * The token itself is never echoed — only the validator's own message
5587
+ * (which the env-provider module composed without the secret in it).
5588
+ */
5589
+ function reportEnvLoginFailure(failure, flags) {
5590
+ const exitCode = (() => {
5591
+ switch (failure.kind) {
5592
+ case 'missing':
5593
+ return 1;
5594
+ case 'invalid-format':
5595
+ return 2;
5596
+ case 'unauthorized':
5597
+ return 3;
5598
+ case 'network-error':
5599
+ case 'server-error':
5600
+ return 4;
5601
+ case 'unexpected-status':
5602
+ return 5;
5603
+ default: {
5604
+ const exhaustive = failure;
5605
+ return Number(exhaustive) || 1;
5606
+ }
5607
+ }
5608
+ })();
5609
+ const payload = {
5610
+ status: 'login_failed',
5611
+ kind: failure.kind,
5612
+ message: failure.message,
5613
+ };
5614
+ if ('status' in failure)
5615
+ payload.httpStatus = failure.status;
5616
+ if ('cause' in failure && failure.cause)
5617
+ payload.cause = failure.cause;
5618
+ writeOutput(flags, payload, failure.message);
5619
+ process.exitCode = exitCode;
2950
5620
  }
2951
5621
  /**
2952
5622
  * OAuth 2.0 Device Authorization Grant client (RFC 8628). Renders
@@ -3702,6 +6372,17 @@ function extractApiUrlFlag(args) {
3702
6372
  function extractLabelFlag(args) {
3703
6373
  return extractNamedFlagValue(args, 'label');
3704
6374
  }
6375
+ /**
6376
+ * `pugi login --provider env --key <value>` — explicit key arg that
6377
+ * beats `PUGI_API_KEY` env. Same precedence rule as `gh auth login
6378
+ * --with-token`, `aws configure set`, and `pugi config`: the most
6379
+ * specific operator intent (a typed flag) overrides the ambient
6380
+ * environment so an operator can override a stale `PUGI_API_KEY`
6381
+ * from their shell rc without unsetting it first.
6382
+ */
6383
+ function extractKeyFlag(args) {
6384
+ return extractNamedFlagValue(args, 'key');
6385
+ }
3705
6386
  /**
3706
6387
  * `pugi jobs` — surface the persistent JobRegistry on the CLI.
3707
6388
  * Sprint α5.9 (ADR-0056 PR-PUGI-CLI-M1-GAP-J). Subcommand parsing
@@ -3801,11 +6482,171 @@ function ensureDir(path, created, skipped) {
3801
6482
  mkdirSync(path, { recursive: true });
3802
6483
  created.push(path);
3803
6484
  }
6485
+ /**
6486
+ * Strict assertion — the workspace MUST already be initialised. Used
6487
+ * AFTER `runAutoInitPreflight` so the surrounding engine command can
6488
+ * narrow on the precondition. Kept synchronous because the async
6489
+ * pre-flight (with the optional prompt + scaffold) is a separate
6490
+ * step at command entry; this is the post-condition assertion.
6491
+ */
3804
6492
  function ensureInitialized(root) {
3805
6493
  if (!existsSync(resolve(root, '.pugi'))) {
3806
6494
  throw new Error('Run pugi init first');
3807
6495
  }
3808
6496
  }
6497
+ /**
6498
+ * Wave 6 UX (2026-05-27): async pre-flight wrapper around the
6499
+ * `ensureInitializedHelper` from `core/onboarding/ensure-initialized.ts`.
6500
+ * Called at command entry for every command that touches `.pugi/`.
6501
+ *
6502
+ * - `.pugi/` already exists → no-op (helper short-circuits).
6503
+ * - Interactive TTY + missing → prompt "Initialize? (Y/n)". On Y,
6504
+ * scaffold inline and continue. On n, throw a clean error so the
6505
+ * surrounding command bails without dropping into a half-state.
6506
+ * - Non-interactive + missing → throw (matches the legacy
6507
+ * `ensureInitialized` strict assertion). The caller MUST run
6508
+ * `pugi init` explicitly before piping into Pugi from CI.
6509
+ *
6510
+ * Operator opt-out: `--no-init` (parsed into `flags.noInit`) OR
6511
+ * `PUGI_NO_AUTO_INIT=1` forces the strict assertion даже on TTY so
6512
+ * shells / wrappers that own init orchestration can disable us.
6513
+ */
6514
+ async function runAutoInitPreflight(root, flags) {
6515
+ const result = await ensureInitializedHelper({
6516
+ cwd: root,
6517
+ interactive: isInteractive(flags),
6518
+ skip: flags.noInit || process.env.PUGI_NO_AUTO_INIT === '1',
6519
+ prompt: async (question) => readSingleChoice(question),
6520
+ scaffold: async (input) => {
6521
+ // Forward to the real scaffolder. The helper does not import
6522
+ // `scaffoldPugiWorkspace` directly to keep its module import-
6523
+ // cycle free; threading it via the callback also lets the
6524
+ // spec swap in a fake.
6525
+ await scaffoldPugiWorkspace({ cwd: input.cwd, noDefaults: flags.noDefaults });
6526
+ },
6527
+ });
6528
+ if (result.status === 'declined') {
6529
+ if (result.reason === 'user_declined') {
6530
+ throw new Error('Initialization declined. Run `pugi init` when ready.');
6531
+ }
6532
+ // non_interactive / disabled → match the legacy strict-assert
6533
+ // message so CI scripts that grep for "Run pugi init first" keep
6534
+ // working. The helper's structured `reason` field is still
6535
+ // available via the spec for finer-grained branching.
6536
+ throw new Error('Run pugi init first');
6537
+ }
6538
+ }
6539
+ export async function runReplAutoInitPreflight(root, flags, overrides = {}) {
6540
+ const interactive = overrides.interactive ?? isInteractive(flags);
6541
+ return ensureInitializedHelper({
6542
+ cwd: root,
6543
+ interactive,
6544
+ // Leak L22 (2026-05-27): `--bare` short-circuits BEFORE the prompt.
6545
+ // Bare mode is the operator's explicit "disable project auto-
6546
+ // discovery" signal — scaffolding `.pugi/` would directly violate
6547
+ // that contract. Treat `--bare` the same as `--no-init` for the
6548
+ // pre-flight gate.
6549
+ skip: flags.noInit
6550
+ || flags.bare
6551
+ || process.env.PUGI_NO_AUTO_INIT === '1'
6552
+ || process.env.PUGI_BARE === '1',
6553
+ prompt: overrides.prompt ?? (async (question) => readSingleChoice(question)),
6554
+ scaffold: overrides.scaffold
6555
+ ?? (async (input) => {
6556
+ await scaffoldPugiWorkspace({ cwd: input.cwd, noDefaults: flags.noDefaults });
6557
+ }),
6558
+ });
6559
+ }
6560
+ export async function runReplSilentInitPreflight(root, flags, overrides = {}) {
6561
+ const interactive = overrides.interactive ?? isInteractive(flags);
6562
+ return ensureInitializedHelper({
6563
+ cwd: root,
6564
+ interactive,
6565
+ skip: flags.noInit
6566
+ || flags.bare
6567
+ || process.env.PUGI_NO_AUTO_INIT === '1'
6568
+ || process.env.PUGI_BARE === '1',
6569
+ // CC-style silent init: the prompt callback returns 'y' immediately
6570
+ // so the helper proceeds straight to scaffold. The helper's
6571
+ // contract treats empty / 'y' / 'yes' as the default-Y answer, so
6572
+ // returning 'y' is the canonical way to drive the happy path
6573
+ // without surfacing the (Y/n) string on stderr. The
6574
+ // `write` callback is silenced (no-op) so the helper does not
6575
+ // print the legacy "No Pugi workspace found at ..." line either —
6576
+ // the welcome banner surfaces the post-scaffold success toast
6577
+ // instead. Note: the helper's `write` ALSO swallows the
6578
+ // "Initialization declined" footer, but that branch never fires
6579
+ // here because our prompt always returns 'y'.
6580
+ prompt: async () => 'y',
6581
+ write: () => {
6582
+ /* silent — banner owns the operator-visible signal */
6583
+ },
6584
+ scaffold: overrides.scaffold
6585
+ ?? (async (input) => {
6586
+ // CEO P0 #2 (2026-05-29): forward а no-op `log` callback so
6587
+ // the default-skills installer ("[pugi init] installed default
6588
+ // skill …") does not leak к stderr above the Ink welcome
6589
+ // banner. The welcome banner already surfaces the one-line
6590
+ // "Pugi workspace initialised at .pugi/." toast — the per-
6591
+ // skill detail is noise on the cold-start path и kills the
6592
+ // CC-style silent boot the operator expects. The non-silent
6593
+ // `pugi init` command remains noisy by passing the default
6594
+ // stderr writer.
6595
+ await scaffoldPugiWorkspace({
6596
+ cwd: input.cwd,
6597
+ noDefaults: flags.noDefaults,
6598
+ log: () => {
6599
+ /* silent — banner owns the operator-visible signal */
6600
+ },
6601
+ });
6602
+ }),
6603
+ });
6604
+ }
6605
+ /**
6606
+ * Wave 6 UX (2026-05-27): async pre-flight wrapper around the
6607
+ * `ensureAuthenticatedHelper` from `core/auth/ensure-authenticated.ts`.
6608
+ * Called at command entry for every command that authenticates against
6609
+ * Anvil. Returns a structured envelope; the caller decides how к
6610
+ * handle the `missing` path (engine commands fall back к offline OR
6611
+ * raise `engine_unavailable`, write commands raise unauthenticated,
6612
+ * read commands MAY proceed in degraded mode).
6613
+ *
6614
+ * The inline login launches `performDeviceFlowLogin` against the
6615
+ * detected apiUrl. Operator opt-out via `--no-login` flag OR
6616
+ * `PUGI_NO_AUTO_LOGIN=1` matches the auto-init equivalent.
6617
+ */
6618
+ async function runAutoAuthPreflight(flags) {
6619
+ return ensureAuthenticatedHelper({
6620
+ resolve: () => resolveActiveCredential(),
6621
+ interactive: isInteractive(flags),
6622
+ skip: flags.noLogin || process.env.PUGI_NO_AUTO_LOGIN === '1',
6623
+ // Headless mode (`--headless` / `--print`) cannot block on a
6624
+ // browser-popup login. The helper refuses the inline branch when
6625
+ // this flag is set даже on a TTY.
6626
+ headless: Boolean(flags.headless || flags.print !== undefined),
6627
+ login: async () => {
6628
+ // Best-effort inline device-flow. Returns true on success
6629
+ // (credential persisted), false on cancel. Errors propagate up
6630
+ // and the helper converts them к `login_failed`.
6631
+ const apiUrl = normalizeApiUrl(process.env.PUGI_API_URL ?? DEFAULT_API_URL);
6632
+ const before = resolveActiveCredential();
6633
+ try {
6634
+ await performDeviceFlowLogin(apiUrl, flags, null);
6635
+ }
6636
+ catch {
6637
+ return false;
6638
+ }
6639
+ // The device-flow handler may set process.exitCode on cancel;
6640
+ // we reset it so the surrounding command does not inherit a
6641
+ // 130 from the login surface даже on success. Re-resolution
6642
+ // below is the source of truth.
6643
+ if (process.exitCode === 130)
6644
+ process.exitCode = 0;
6645
+ const after = resolveActiveCredential();
6646
+ return Boolean(after && after.apiKey !== before?.apiKey) || Boolean(after && !before);
6647
+ },
6648
+ });
6649
+ }
3809
6650
  function createArtifactDir(root, seed) {
3810
6651
  const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${slugify(seed)}`;
3811
6652
  const artifactDir = resolve(root, '.pugi', 'artifacts', id);
@@ -3980,7 +6821,31 @@ function fileBytes(path) {
3980
6821
  return 0;
3981
6822
  }
3982
6823
  }
3983
- function safeGit(root, args) {
6824
+ /**
6825
+ * Git invocation helpers — probe vs required semantics.
6826
+ *
6827
+ * 2026-05-27 (Claude review followup #489): the historical `safeGit`
6828
+ * collapsed BOTH "tell me the branch name if you can" probes AND
6829
+ * "give me the diff or fail" hard requirements into a single helper
6830
+ * that swallowed every error as an empty string. That's the correct
6831
+ * shape for the probe case (branch / status / dirty flag — empty
6832
+ * result is a valid signal) but catastrophically wrong for the diff
6833
+ * case (empty result === false PASS on a commit nobody reviewed).
6834
+ *
6835
+ * The split:
6836
+ * - `safeGitProbe` — best-effort. Returns '' on any error. Use for
6837
+ * branch name lookups, status probes, opt-in dirty detection.
6838
+ * - `safeGitRequired` — throws on non-zero exit / ENOBUFS / bad ref.
6839
+ * Use for diff, merge-base resolution, anything whose empty
6840
+ * output would silently corrupt downstream behaviour.
6841
+ *
6842
+ * Legacy `safeGit` is kept as a deprecated alias of `safeGitProbe`
6843
+ * so existing call-sites (branch detection, status, etc.) keep their
6844
+ * tolerant semantics until they are individually migrated. Diff /
6845
+ * merge-base / rev-parse-verify call-sites are migrated к
6846
+ * `safeGitRequired` in this same patch.
6847
+ */
6848
+ export function safeGitProbe(root, args) {
3984
6849
  try {
3985
6850
  return execFileSync('git', args, {
3986
6851
  cwd: root,
@@ -3998,6 +6863,38 @@ function safeGit(root, args) {
3998
6863
  return '';
3999
6864
  }
4000
6865
  }
6866
+ /**
6867
+ * Strict variant — throws on non-zero exit, ENOBUFS, or any git-side
6868
+ * failure. The thrown error carries the operation context so the
6869
+ * caller (triple-review dispatch, etc.) can fail loud rather than
6870
+ * ship an empty diff to a remote reviewer.
6871
+ */
6872
+ export function safeGitRequired(root, args, context) {
6873
+ try {
6874
+ return execFileSync('git', args, {
6875
+ cwd: root,
6876
+ encoding: 'utf8',
6877
+ stdio: ['ignore', 'pipe', 'pipe'],
6878
+ maxBuffer: 64 * 1024 * 1024,
6879
+ });
6880
+ }
6881
+ catch (err) {
6882
+ const cause = err instanceof Error ? err.message : String(err);
6883
+ throw new Error(`git ${args.slice(0, 2).join(' ')} failed (${context}): ${cause}. ` +
6884
+ `Refusing to proceed — empty git output here would corrupt downstream behaviour.`);
6885
+ }
6886
+ }
6887
+ /**
6888
+ * Deprecated alias preserved for diff / status / branch probes that
6889
+ * legitimately want a tolerant empty-string-on-error shape. New call
6890
+ * sites should pick `safeGitProbe` or `safeGitRequired` explicitly.
6891
+ *
6892
+ * @deprecated 2026-05-27 — prefer `safeGitProbe` (tolerant) or
6893
+ * `safeGitRequired` (strict, throws).
6894
+ */
6895
+ function safeGit(root, args) {
6896
+ return safeGitProbe(root, args);
6897
+ }
4001
6898
  /**
4002
6899
  * Glob patterns excluded from triple-review `diffPatch` before egress.
4003
6900
  *
@@ -4138,5 +7035,6 @@ export function packageRoot() {
4138
7035
  export const __test__ = {
4139
7036
  sleep,
4140
7037
  pollDeviceFlowUntilTerminal,
7038
+ sanitizeSemver,
4141
7039
  };
4142
7040
  //# sourceMappingURL=cli.js.map