@pugi/cli 0.1.0-beta.2 → 0.1.0-beta.20

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