@pugi/cli 0.1.0-beta.12 → 0.1.0-beta.13

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 (57) hide show
  1. package/dist/core/consensus/diff-capture.js +73 -0
  2. package/dist/core/context/index.js +7 -0
  3. package/dist/core/context/markdown-traverse.js +255 -0
  4. package/dist/core/edits/dispatch.js +218 -2
  5. package/dist/core/edits/journal.js +199 -0
  6. package/dist/core/edits/layer-d-ast.js +557 -14
  7. package/dist/core/edits/verify-hook.js +273 -0
  8. package/dist/core/engine/anvil-client.js +80 -5
  9. package/dist/core/engine/context-prefix.js +155 -0
  10. package/dist/core/engine/intent.js +260 -0
  11. package/dist/core/engine/native-pugi.js +663 -249
  12. package/dist/core/engine/prompts.js +52 -2
  13. package/dist/core/engine/tool-bridge.js +311 -9
  14. package/dist/core/lsp/client.js +57 -0
  15. package/dist/core/mcp/client.js +9 -0
  16. package/dist/core/mcp/http-server.js +553 -0
  17. package/dist/core/mcp/permission.js +190 -0
  18. package/dist/core/mcp/server-tools.js +219 -0
  19. package/dist/core/mcp/server.js +397 -0
  20. package/dist/core/repl/history.js +11 -1
  21. package/dist/core/repl/model-pricing.js +135 -0
  22. package/dist/core/repl/session.js +328 -12
  23. package/dist/core/repl/slash-commands.js +18 -4
  24. package/dist/core/settings.js +43 -0
  25. package/dist/core/subagents/dispatcher-real.js +600 -0
  26. package/dist/core/subagents/dispatcher.js +113 -24
  27. package/dist/core/subagents/index.js +18 -5
  28. package/dist/core/subagents/isolation-matrix.js +213 -0
  29. package/dist/core/subagents/spawn.js +19 -4
  30. package/dist/core/transport/version-interceptor.js +166 -0
  31. package/dist/index.js +28 -0
  32. package/dist/runtime/bootstrap.js +190 -0
  33. package/dist/runtime/cli.js +534 -268
  34. package/dist/runtime/commands/lsp.js +165 -5
  35. package/dist/runtime/commands/mcp.js +537 -0
  36. package/dist/runtime/headless.js +543 -0
  37. package/dist/runtime/load-hooks-or-exit.js +71 -0
  38. package/dist/runtime/version.js +65 -0
  39. package/dist/tools/agent-tool.js +192 -0
  40. package/dist/tools/apply-patch.js +62 -1
  41. package/dist/tools/mcp-tool.js +260 -0
  42. package/dist/tools/multi-edit.js +361 -0
  43. package/dist/tools/registry.js +5 -0
  44. package/dist/tools/web-fetch.js +147 -2
  45. package/dist/tools/web-search.js +458 -0
  46. package/dist/tui/agent-tree.js +10 -0
  47. package/dist/tui/ask-modal.js +2 -2
  48. package/dist/tui/conversation-pane.js +1 -1
  49. package/dist/tui/input-box.js +1 -1
  50. package/dist/tui/markdown-render.js +4 -4
  51. package/dist/tui/repl-render.js +105 -15
  52. package/dist/tui/repl-splash.js +2 -2
  53. package/dist/tui/repl.js +10 -4
  54. package/dist/tui/splash.js +1 -1
  55. package/dist/tui/status-bar.js +94 -16
  56. package/dist/tui/update-banner.js +20 -2
  57. package/package.json +5 -4
@@ -8,6 +8,9 @@ import { AnvilEngineLoopClient } from '../core/engine/anvil-client.js';
8
8
  import { NoopEngineAdapter } from '../core/engine/noop.js';
9
9
  import { NativePugiEngineAdapter } from '../core/engine/native-pugi.js';
10
10
  import { decidePermission } from '../core/permission.js';
11
+ import { loadMcpRegistry } from '../core/mcp/registry.js';
12
+ import { loadHookRegistryOrExit } from './load-hooks-or-exit.js';
13
+ import { defaultNonInteractiveMcpPrompt } from '../tools/mcp-tool.js';
11
14
  import { openSession, recordCommandCompleted, recordCommandStarted, recordToolCall, recordToolResult, } from '../core/session.js';
12
15
  import { loadSettings } from '../core/settings.js';
13
16
  import { FileReadCache } from '../core/file-cache.js';
@@ -36,6 +39,7 @@ import { runPatchCommand } from './commands/patch.js';
36
39
  import { runWorktreeCommand } from './commands/worktree.js';
37
40
  import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
38
41
  import { runReviewConsensus } from './commands/review-consensus.js';
42
+ import { runMcpCommand } from './commands/mcp.js';
39
43
  import { DECOMPOSE_PROMPT_SUFFIX, parseDecompositionFromText, writeDecomposition, } from './plan-decompose.js';
40
44
  import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
41
45
  import { slugForCwd } from '../core/repl/history.js';
@@ -51,37 +55,15 @@ import { dispatchEdit, } from '../core/edits/index.js';
51
55
  * packages/pugi-sdk/package.json); the publish workflow validates the
52
56
  * three are in lockstep.
53
57
  */
54
- /**
55
- * β1 housekeeping (#51): defensive semver sanitizer. If a future
56
- * refactor moves PUGI_CLI_VERSION reading to a JSON import (resolveJson)
57
- * the npm publish pipeline can leak `workspace:*` from a partially-bumped
58
- * package.json — `npm publish` rewrites these but a local `pnpm pack`
59
- * does not, and the failure mode is silently shipping an unsemver
60
- * version that breaks `pugi --version` JSON consumers. Sanitize at the
61
- * read site so even a leaked literal lands as a deterministic
62
- * "0.0.0-unknown" rather than `workspace:*`.
63
- */
64
- function sanitizeSemver(raw) {
65
- if (typeof raw !== 'string')
66
- return '0.0.0-unknown';
67
- const trimmed = raw.trim();
68
- if (!trimmed)
69
- return '0.0.0-unknown';
70
- // Strip a `workspace:` / `npm:` / `file:` protocol prefix that pnpm
71
- // can emit when a partial publish runs.
72
- const stripped = trimmed.replace(/^(workspace:|npm:|file:)/, '');
73
- // Accept anything that begins with major.minor.patch + optional
74
- // prerelease/build per semver 2.0. Reject `*`, `^x`, `~x`, ranges, etc.
75
- if (/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(stripped)) {
76
- return stripped;
77
- }
78
- return '0.0.0-unknown';
79
- }
80
- // Main bumped to 0.1.0-beta.9 (PR #430 REPL-hang fix). β1a r1 rebase
81
- // preserves the main bump and runs it through the β1 sanitizer added
82
- // here so a future workspace:* leak from a partial publish lands as
83
- // "0.0.0-unknown" instead of corrupting `pugi --version` JSON output.
84
- const PUGI_CLI_VERSION = sanitizeSemver("0.1.0-beta.12");
58
+ // PR-CLI-SERVER-VERSION-HANDSHAKE (#225). PUGI_CLI_VERSION lives in
59
+ // `runtime/version.ts` now so the engine transport interceptor can
60
+ // import it without dragging in the cli.ts module graph. Re-exported
61
+ // here under the original name so every existing reader (`pugi version`,
62
+ // `pugi doctor --json`, splash render, telemetry) keeps working with
63
+ // zero churn. Bumping the CLI version is still a single-file edit —
64
+ // just on `runtime/version.ts` instead of here. The β1 sanitizer that
65
+ // guarded against `workspace:*` leaks moved with the constant.
66
+ import { PUGI_CLI_VERSION, sanitizeSemver } from './version.js';
85
67
  const handlers = {
86
68
  accounts,
87
69
  agents: dispatchAgents,
@@ -103,6 +85,7 @@ const handlers = {
103
85
  login,
104
86
  logout,
105
87
  lsp: dispatchLsp,
88
+ mcp: dispatchMcp,
106
89
  patch: dispatchPatch,
107
90
  plan: runEngineTask('plan'),
108
91
  'plan-review': dispatchPlanReview,
@@ -421,6 +404,25 @@ async function dispatchLsp(args, flags, _session) {
421
404
  if (result.exitCode !== 0)
422
405
  process.exitCode = result.exitCode;
423
406
  }
407
+ /**
408
+ * β4 M6 + M7 + Sl7 (2026-05-26): `pugi mcp <sub>` — MCP execution +
409
+ * server. `list / trust / deny / install` manage the client-side
410
+ * registry (the same surface `pugi config mcp ...` exposes); `serve`
411
+ * boots Pugi-as-MCP-server over stdio (default) or HTTP+SSE; `perms`
412
+ * inspects + resets the per-(server, tool) permission cache that
413
+ * gates engine-loop dispatch.
414
+ *
415
+ * The serve sub-command never returns under normal conditions — the
416
+ * stdio path runs until stdin closes (parent agent disconnect) and the
417
+ * HTTP path runs until SIGINT/SIGTERM. Both honour the optional
418
+ * AbortSignal we pass through from the REPL slash bridge in β4b.
419
+ */
420
+ async function dispatchMcp(args, flags, _session) {
421
+ await runMcpCommand(args, {
422
+ workspaceRoot: process.cwd(),
423
+ writeOutput: (payload, text) => writeOutput(flags, payload, text),
424
+ });
425
+ }
424
426
  /**
425
427
  * α7.7: `pugi patch` — apply a unified-diff patch from stdin or a file.
426
428
  * Routes through the same security gate as the Layer A/B/C applicators
@@ -464,6 +466,37 @@ async function dispatchWorktree(args, flags, _session) {
464
466
  }
465
467
  export async function runCli(argv) {
466
468
  const { command, args, flags, isBareInvocation } = parseArgs(argv);
469
+ // β-headless dispatch (CEO directive 2026-05-27 "нужно тестирование по
470
+ // кругу"): when `--print <brief>` is set we route to the headless
471
+ // runner BEFORE the REPL / splash / command branches. The runner
472
+ // never mounts Ink, never opens raw stdin, never prints the splash
473
+ // — only the structured event stream lands on stdout. Same engine
474
+ // adapter path the REPL uses (no fork), only the output sink
475
+ // differs.
476
+ if (typeof flags.print === 'string') {
477
+ const { runHeadlessPrint } = await import('./headless.js');
478
+ // Default to NDJSON when stdout is not a TTY OR when --json is set
479
+ // explicitly. A human running `pugi --print "..."` in their
480
+ // terminal without flags gets the readable text sink; a pipe gets
481
+ // the machine-readable stream.
482
+ const wantJson = flags.json || !process.stdout.isTTY;
483
+ const headlessFactory = getEngineClientFactory();
484
+ const exitCode = await runHeadlessPrint({
485
+ prompt: flags.print,
486
+ json: wantJson,
487
+ cwd: flags.cwd ?? process.cwd(),
488
+ ...(flags.workspace ? { workspace: flags.workspace } : {}),
489
+ ...(flags.sessionId ? { sessionIdOverride: flags.sessionId } : {}),
490
+ ...(flags.timeoutSeconds ? { timeoutSeconds: flags.timeoutSeconds } : {}),
491
+ noTools: flags.noTools,
492
+ ...(flags.maxTurns ? { maxTurns: flags.maxTurns } : {}),
493
+ ...(headlessFactory ? { engineClientFactory: headlessFactory } : {}),
494
+ ...(headlessStdoutWriter ? { stdoutWrite: headlessStdoutWriter } : {}),
495
+ ...(headlessStderrWriter ? { stderrWrite: headlessStderrWriter } : {}),
496
+ });
497
+ process.exitCode = exitCode;
498
+ return;
499
+ }
467
500
  // Bare `pugi` on a TTY enters the REPL-by-default agentic session
468
501
  // (Sprint α5.7, ADR-0056). The REPL is the customer-facing surface
469
502
  // that brings Pugi to parity with Claude Code / Codex CLI. When the
@@ -538,6 +571,7 @@ function parseArgs(argv) {
538
571
  offline: false,
539
572
  noTty: false,
540
573
  allowFetch: false,
574
+ allowSearch: false,
541
575
  noUpdateCheck: false,
542
576
  noSplash: process.env.PUGI_SKIP_SPLASH === '1',
543
577
  // Claude triple-review P1 PR #369: default tool-stream pane HIDDEN
@@ -554,6 +588,9 @@ function parseArgs(argv) {
554
588
  : true,
555
589
  noDefaults: process.env.PUGI_INIT_NO_DEFAULTS === '1',
556
590
  decompose: false,
591
+ // β-headless: --no-tools default OFF so existing flag-free invocations
592
+ // keep tool advertisement. Flipped only by explicit operator opt-in.
593
+ noTools: false,
557
594
  };
558
595
  const args = [];
559
596
  // Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
@@ -599,6 +636,12 @@ function parseArgs(argv) {
599
636
  else if (arg === '--allow-fetch') {
600
637
  flags.allowFetch = true;
601
638
  }
639
+ else if (arg === '--allow-search') {
640
+ // β1b T4 (2026-05-26): unlock the `web_search` tool for one
641
+ // invocation, mirroring the `--allow-fetch` gate. Distinct flag
642
+ // because an operator may want to query without fetching pages.
643
+ flags.allowSearch = true;
644
+ }
602
645
  else if (arg === '--no-update-check') {
603
646
  flags.noUpdateCheck = true;
604
647
  }
@@ -634,6 +677,92 @@ function parseArgs(argv) {
634
677
  flags.privacy = parsePrivacyMode(next);
635
678
  index += 1;
636
679
  }
680
+ else if (arg === '--print') {
681
+ // β-headless: top-level `--print <brief>` runs a single
682
+ // non-interactive engine turn. Consumes the next argv token as
683
+ // the brief — refusing if it looks like another flag so a
684
+ // dangling `--print --json` does not silently swallow `--json`.
685
+ const next = argv[index + 1];
686
+ if (!next || next.startsWith('--')) {
687
+ throw new Error('--print requires a brief (e.g. --print "create word_counter.py")');
688
+ }
689
+ flags.print = next;
690
+ index += 1;
691
+ }
692
+ else if (arg.startsWith('--print=')) {
693
+ flags.print = arg.slice('--print='.length);
694
+ }
695
+ else if (arg === '--cwd') {
696
+ const next = argv[index + 1];
697
+ if (!next || next.startsWith('--'))
698
+ throw new Error('--cwd requires a path');
699
+ flags.cwd = next;
700
+ index += 1;
701
+ }
702
+ else if (arg.startsWith('--cwd=')) {
703
+ flags.cwd = arg.slice('--cwd='.length);
704
+ }
705
+ else if (arg === '--workspace') {
706
+ const next = argv[index + 1];
707
+ if (!next || next.startsWith('--'))
708
+ throw new Error('--workspace requires a slug');
709
+ flags.workspace = next;
710
+ index += 1;
711
+ }
712
+ else if (arg.startsWith('--workspace=')) {
713
+ flags.workspace = arg.slice('--workspace='.length);
714
+ }
715
+ else if (arg === '--session') {
716
+ const next = argv[index + 1];
717
+ if (!next || next.startsWith('--'))
718
+ throw new Error('--session requires an id');
719
+ flags.sessionId = next;
720
+ index += 1;
721
+ }
722
+ else if (arg.startsWith('--session=')) {
723
+ flags.sessionId = arg.slice('--session='.length);
724
+ }
725
+ else if (arg === '--timeout') {
726
+ const next = argv[index + 1];
727
+ if (!next || next.startsWith('--'))
728
+ throw new Error('--timeout requires seconds');
729
+ const parsed = Number(next);
730
+ if (!Number.isFinite(parsed) || parsed <= 0) {
731
+ throw new Error(`--timeout requires positive seconds, got "${next}"`);
732
+ }
733
+ flags.timeoutSeconds = parsed;
734
+ index += 1;
735
+ }
736
+ else if (arg.startsWith('--timeout=')) {
737
+ const raw = arg.slice('--timeout='.length);
738
+ const parsed = Number(raw);
739
+ if (!Number.isFinite(parsed) || parsed <= 0) {
740
+ throw new Error(`--timeout requires positive seconds, got "${raw}"`);
741
+ }
742
+ flags.timeoutSeconds = parsed;
743
+ }
744
+ else if (arg === '--no-tools') {
745
+ flags.noTools = true;
746
+ }
747
+ else if (arg === '--max-turns') {
748
+ const next = argv[index + 1];
749
+ if (!next || next.startsWith('--'))
750
+ throw new Error('--max-turns requires an integer');
751
+ const parsed = Number(next);
752
+ if (!Number.isInteger(parsed) || parsed <= 0) {
753
+ throw new Error(`--max-turns requires positive integer, got "${next}"`);
754
+ }
755
+ flags.maxTurns = parsed;
756
+ index += 1;
757
+ }
758
+ else if (arg.startsWith('--max-turns=')) {
759
+ const raw = arg.slice('--max-turns='.length);
760
+ const parsed = Number(raw);
761
+ if (!Number.isInteger(parsed) || parsed <= 0) {
762
+ throw new Error(`--max-turns requires positive integer, got "${raw}"`);
763
+ }
764
+ flags.maxTurns = parsed;
765
+ }
637
766
  else {
638
767
  args.push(arg);
639
768
  }
@@ -853,6 +982,9 @@ export async function scaffoldPugiWorkspace(input) {
853
982
  mode: 'balanced',
854
983
  telemetry: 'off',
855
984
  },
985
+ ui: {
986
+ cyberZoo: 'on',
987
+ },
856
988
  artifacts: {
857
989
  defaultPath: '.pugi/artifacts',
858
990
  promoteExplicitly: true,
@@ -2293,6 +2425,33 @@ let engineClientFactory = null;
2293
2425
  export function setEngineClientFactory(factory) {
2294
2426
  engineClientFactory = factory;
2295
2427
  }
2428
+ /**
2429
+ * β-headless test seam: surface the module-scoped engine client factory
2430
+ * to sibling runtime modules (`headless.ts`) so the same fixture
2431
+ * injection that `setEngineClientFactory` provides for the
2432
+ * `runEngineTask` path applies to `pugi --print` runs. Production
2433
+ * callers never read this — the factory is `null` and falls through
2434
+ * to the real `AnvilEngineLoopClient`.
2435
+ */
2436
+ export function getEngineClientFactory() {
2437
+ return engineClientFactory;
2438
+ }
2439
+ /**
2440
+ * β-headless test seam: optional stdout/stderr writers injected for
2441
+ * `pugi --print` runs. When set, the headless runner forwards every
2442
+ * NDJSON line / human-readable chunk to these closures instead of the
2443
+ * real `process.stdout.write` / `process.stderr.write`. Needed because
2444
+ * `node:test`'s worker pool hijacks `process.stdout` for a binary IPC
2445
+ * channel — a captureStdio override would race the runner's frames
2446
+ * and surface as `Unexpected token '\x0F'` JSON parse failures in spec
2447
+ * assertions. Production never sets these.
2448
+ */
2449
+ let headlessStdoutWriter = null;
2450
+ let headlessStderrWriter = null;
2451
+ export function setHeadlessWriters(writers) {
2452
+ headlessStdoutWriter = writers.stdout ?? null;
2453
+ headlessStderrWriter = writers.stderr ?? null;
2454
+ }
2296
2455
  function runEngineTask(kind) {
2297
2456
  return async (args, flags, session) => {
2298
2457
  const label = commandLabel(kind);
@@ -2395,264 +2554,371 @@ function runEngineTask(kind) {
2395
2554
  throw new Error('internal: engine config missing after offline gate');
2396
2555
  }
2397
2556
  const client = engineClientFactory ? engineClientFactory(config) : new AnvilEngineLoopClient(config);
2398
- const adapter = new NativePugiEngineAdapter({ client, session });
2557
+ // β1b r1 (--allow-fetch / --allow-search wiring, 2026-05-26):
2558
+ // forward operator flags to the adapter so the schema-advertise +
2559
+ // executor-dispatch gates see the OR of (settings.json flag, CLI
2560
+ // flag). PR #425 r1 Backend Architect: the comment at
2561
+ // `tool-bridge.ts:740` documented `--allow-fetch` but the flag was
2562
+ // never wired into the adapter constructor — fix lands here.
2563
+ //
2564
+ // β4 r2 P1 #3 — load the MCP registry pre-run so the engine's
2565
+ // tool-bridge advertises every trusted server's tools under
2566
+ // `mcp__<server>__<tool>`. Before this fix the registry was never
2567
+ // loaded in the CLI engine path: `pugi mcp install` + `pugi mcp
2568
+ // trust` ran successfully but `pugi code/explain/fix/build` still
2569
+ // saw zero `mcp__*` tools in the schema (so the feature was
2570
+ // non-functional at the customer-facing surface). The adapter does
2571
+ // NOT own the registry lifecycle — we tear it down in the `finally`
2572
+ // below regardless of outcome so live MCP child processes are
2573
+ // reaped before the CLI exits.
2574
+ //
2575
+ // Failure mode: a bad `.pugi/mcp.json` (corrupted JSON, schema
2576
+ // violation) bubbles as an exception from `loadMcpRegistry`. We
2577
+ // surface it as a warning on stderr and continue WITHOUT MCP — the
2578
+ // operator's `pugi code "..."` invocation should not fail just
2579
+ // because a stale MCP entry refuses to parse. They get the engine
2580
+ // run without `mcp__*` tools and a clear hint to fix the file.
2581
+ let mcpRegistry;
2582
+ try {
2583
+ mcpRegistry = await loadMcpRegistry(root);
2584
+ }
2585
+ catch (error) {
2586
+ process.stderr.write(`pugi ${label}: MCP registry load failed — ${error.message}. ` +
2587
+ `Continuing without MCP tools. Fix .pugi/mcp.json to enable.\n`);
2588
+ mcpRegistry = undefined;
2589
+ }
2590
+ // P1 fix (deep audit 2026-05-26): load the workspace HookRegistry so
2591
+ // `.pugi/hooks/` lifecycle hooks fire for model-initiated tool calls
2592
+ // from the engine loop, not just for direct CLI tool invocations.
2593
+ // SECURITY: a `PreToolUse onFailure: 'block'` hook that refuses bash
2594
+ // containing `rm` now applies to model dispatch. Before this fix the
2595
+ // hooks were INVISIBLE to the engine adapter — a workspace operator
2596
+ // who set up a block hook for destructive bash would still see the
2597
+ // model freely dispatch those calls.
2598
+ //
2599
+ // r2 fix (triple-review 2026-05-26 P2): the fail-open path is a
2600
+ // security hole. If `.pugi/hooks.json` exists but is malformed
2601
+ // (truncated write, typo, partial edit) and the operator has block
2602
+ // hooks configured, the previous `continue without hooks` silently
2603
+ // disabled the BLOCK rules — a hostile or careless mutation of the
2604
+ // file would turn off all SECURITY-CRITICAL refusals without any
2605
+ // visible signal. We now distinguish three cases:
2606
+ //
2607
+ // (a) Neither user nor project hooks file exists → no hooks. Safe.
2608
+ // (b) File(s) exist and load() succeeds → hooks live. Normal.
2609
+ // (c) File(s) exist and load() fails → REFUSE THE RUN with a
2610
+ // fatal stderr message and `process.exit(1)`. Operator must
2611
+ // fix the file OR set `PUGI_HOOKS_BYPASS=1` to override (the
2612
+ // escape hatch is logged loudly so it cannot be silent).
2613
+ //
2614
+ // The bypass env var exists for the mid-edit recovery case (the
2615
+ // operator is in the middle of fixing the file and needs to run
2616
+ // pugi to see the world state). It is NEVER a default — the
2617
+ // operator types it explicitly.
2618
+ const hookOutcome = await loadHookRegistryOrExit({
2619
+ workspaceRoot: root,
2620
+ session,
2621
+ label,
2622
+ });
2623
+ if (hookOutcome.kind === 'parse-failure-refused') {
2624
+ // The helper already emitted the fatal message on stderr. Exit
2625
+ // directly so dispatchEngineCommand's caller observes a non-zero
2626
+ // exit code without a stack trace.
2627
+ process.exit(1);
2628
+ }
2629
+ const hooks = hookOutcome.hooks;
2630
+ const adapter = new NativePugiEngineAdapter({
2631
+ client,
2632
+ session,
2633
+ allowFetch: flags.allowFetch,
2634
+ allowSearch: flags.allowSearch,
2635
+ ...(mcpRegistry ? { mcpRegistry } : {}),
2636
+ ...(hooks ? { hooks } : {}),
2637
+ // Non-interactive CLI path: the FSM prompt callback always denies
2638
+ // until the operator explicitly grants permission via
2639
+ // `pugi mcp perms` (out-of-band). A future Ink-backed REPL path
2640
+ // overrides this with a modal prompt; pipes / CI never auto-allow.
2641
+ mcpPrompt: defaultNonInteractiveMcpPrompt,
2642
+ // P1 fix (deep audit 2026-05-26): CLI dispatcher is non-interactive
2643
+ // by default — pipes, CI, and scripted `pugi code "..."` runs do
2644
+ // not have an ink modal to surface ask_user_question into. The
2645
+ // REPL layer (β2b ink modal wiring, future) overrides this with
2646
+ // `interactive: true` + a live askUserBridge.
2647
+ interactive: false,
2648
+ });
2399
2649
  const toolCallId = recordToolCall(session, `engine:${adapter.name}`, `${label}: ${prompt}`);
2400
2650
  const taskId = `${kind}-${Date.now()}`;
2401
- const events = adapter.run({
2402
- id: taskId,
2403
- kind,
2404
- prompt,
2405
- workspaceRoot: root,
2406
- allowedPaths: [root],
2407
- deniedPaths: [],
2408
- artifacts: [],
2409
- // plan mode is enforced inside the tool-bridge (read-only schema +
2410
- // executor refusal sentinel). The permission mode here is the
2411
- // workspace-level toggle and is unchanged from interactive default.
2412
- permissionMode: 'auto',
2413
- }, { sessionId: session.id });
2414
- const statusEvents = [];
2415
- let result = null;
2416
- for await (const event of events) {
2417
- if (event.type === 'status') {
2418
- statusEvents.push(event.message);
2419
- // For `explain` the spec wants status events on stderr so the
2420
- // final summary on stdout is grep-able. Other commands keep the
2421
- // events on stdout-via-final-text so the operator sees the
2422
- // chronological trace.
2423
- if (kind === 'explain' && !flags.json) {
2424
- process.stderr.write(`${event.message}\n`);
2651
+ // β4 r2 P1 #3 — try/finally so loaded MCP child processes are
2652
+ // reaped regardless of run outcome (success, blocked, failed,
2653
+ // thrown). The shutdown is best-effort; we never want a stuck
2654
+ // MCP server to mask a successful Pugi run.
2655
+ try {
2656
+ const events = adapter.run({
2657
+ id: taskId,
2658
+ kind,
2659
+ prompt,
2660
+ workspaceRoot: root,
2661
+ allowedPaths: [root],
2662
+ deniedPaths: [],
2663
+ artifacts: [],
2664
+ // plan mode is enforced inside the tool-bridge (read-only schema +
2665
+ // executor refusal sentinel). The permission mode here is the
2666
+ // workspace-level toggle and is unchanged from interactive default.
2667
+ permissionMode: 'auto',
2668
+ }, { sessionId: session.id });
2669
+ const statusEvents = [];
2670
+ let result = null;
2671
+ for await (const event of events) {
2672
+ if (event.type === 'status') {
2673
+ statusEvents.push(event.message);
2674
+ // For `explain` the spec wants status events on stderr so the
2675
+ // final summary on stdout is grep-able. Other commands keep the
2676
+ // events on stdout-via-final-text so the operator sees the
2677
+ // chronological trace.
2678
+ if (kind === 'explain' && !flags.json) {
2679
+ process.stderr.write(`${event.message}\n`);
2680
+ }
2681
+ }
2682
+ else {
2683
+ result = {
2684
+ status: event.result.status,
2685
+ summary: event.result.summary,
2686
+ filesChanged: event.result.filesChanged,
2687
+ eventRefs: event.result.eventRefs,
2688
+ risks: event.result.risks,
2689
+ };
2425
2690
  }
2426
2691
  }
2427
- else {
2692
+ if (!result) {
2693
+ // Adapter MUST emit a terminal result event. Treat the empty
2694
+ // outcome as a failure so the CLI surfaces a clear error rather
2695
+ // than exiting 0 with no output.
2428
2696
  result = {
2429
- status: event.result.status,
2430
- summary: event.result.summary,
2431
- filesChanged: event.result.filesChanged,
2432
- eventRefs: event.result.eventRefs,
2433
- risks: event.result.risks,
2697
+ status: 'failed',
2698
+ summary: 'engine adapter returned no result',
2699
+ filesChanged: [],
2700
+ eventRefs: [],
2701
+ risks: ['adapter terminated without emitting a result event'],
2434
2702
  };
2435
2703
  }
2436
- }
2437
- if (!result) {
2438
- // Adapter MUST emit a terminal result event. Treat the empty
2439
- // outcome as a failure so the CLI surfaces a clear error rather
2440
- // than exiting 0 with no output.
2441
- result = {
2442
- status: 'failed',
2443
- summary: 'engine adapter returned no result',
2444
- filesChanged: [],
2445
- eventRefs: [],
2446
- risks: ['adapter terminated without emitting a result event'],
2447
- };
2448
- }
2449
- // α6.6 diff escalation Layer A/B/C dispatcher.
2450
- //
2451
- // Some models emit file edits as inline SEARCH/REPLACE markers in
2452
- // the final response rather than through tool calls (especially
2453
- // Gemini and o1 family, which under-use tool schemas in long
2454
- // reasoning chains). We run the dispatcher against the model's
2455
- // final text so those markers still land on disk. Tool-call edits
2456
- // (Layer-A equivalent already handled by `edit`/`write` tools) are
2457
- // unaffected — the dispatcher only fires on prose blocks that
2458
- // happen to contain markers.
2459
- //
2460
- // Scope: code / fix / build / explain only. `plan` is read-only
2461
- // (the engine refuses write tools), so even a stray marker in plan
2462
- // output gets ignored to honour the plan-mode contract.
2463
- //
2464
- // Dry-run + read-only short-circuits: when the flags forbid writes
2465
- // we dispatch with `dryRun: true` so the operator still sees what
2466
- // WOULD have been written, but nothing touches disk.
2467
- let dispatchResults = [];
2468
- if (kind === 'code' || kind === 'fix' || kind === 'build_task') {
2469
- dispatchResults = await runMarkerDispatch({
2470
- root,
2471
- result: {
2472
- status: result.status,
2473
- summary: result.summary,
2474
- eventRefs: result.eventRefs,
2475
- },
2476
- dryRun: flags.dryRun,
2477
- });
2478
- // Merge dispatcher-touched files into `result.filesChanged` so the
2479
- // operator-facing summary lists them alongside tool-driven edits.
2480
- for (const dr of dispatchResults) {
2481
- if (dr.ok && dr.absPath) {
2482
- const rel = relative(root, dr.absPath);
2483
- if (!result.filesChanged.includes(rel))
2484
- result.filesChanged.push(rel);
2704
+ // α6.6 diff escalation — Layer A/B/C dispatcher.
2705
+ //
2706
+ // Some models emit file edits as inline SEARCH/REPLACE markers in
2707
+ // the final response rather than through tool calls (especially
2708
+ // Gemini and o1 family, which under-use tool schemas in long
2709
+ // reasoning chains). We run the dispatcher against the model's
2710
+ // final text so those markers still land on disk. Tool-call edits
2711
+ // (Layer-A equivalent already handled by `edit`/`write` tools) are
2712
+ // unaffected — the dispatcher only fires on prose blocks that
2713
+ // happen to contain markers.
2714
+ //
2715
+ // Scope: code / fix / build / explain only. `plan` is read-only
2716
+ // (the engine refuses write tools), so even a stray marker in plan
2717
+ // output gets ignored to honour the plan-mode contract.
2718
+ //
2719
+ // Dry-run + read-only short-circuits: when the flags forbid writes
2720
+ // we dispatch with `dryRun: true` so the operator still sees what
2721
+ // WOULD have been written, but nothing touches disk.
2722
+ let dispatchResults = [];
2723
+ if (kind === 'code' || kind === 'fix' || kind === 'build_task') {
2724
+ dispatchResults = await runMarkerDispatch({
2725
+ root,
2726
+ result: {
2727
+ status: result.status,
2728
+ summary: result.summary,
2729
+ eventRefs: result.eventRefs,
2730
+ },
2731
+ dryRun: flags.dryRun,
2732
+ });
2733
+ // Merge dispatcher-touched files into `result.filesChanged` so the
2734
+ // operator-facing summary lists them alongside tool-driven edits.
2735
+ for (const dr of dispatchResults) {
2736
+ if (dr.ok && dr.absPath) {
2737
+ const rel = relative(root, dr.absPath);
2738
+ if (!result.filesChanged.includes(rel))
2739
+ result.filesChanged.push(rel);
2740
+ }
2485
2741
  }
2486
2742
  }
2487
- }
2488
- // For `plan` we always write a plan.md artifact, regardless of
2489
- // outcome. A blocked plan (budget exhausted, tool refusal) still
2490
- // produces a reviewable artifact — the reason is recorded inline.
2491
- let planArtifact = null;
2492
- if (kind === 'plan') {
2493
- planArtifact = writePlanArtifact({
2494
- root,
2495
- session,
2496
- prompt,
2497
- result,
2498
- statusEvents,
2499
- });
2500
- }
2501
- // α6.8 EXTEND PR1: `--decompose` post-processing. We only attempt
2502
- // the parse on a `done` plan (a blocked/failed plan is already
2503
- // captured in plan.md with its reason; no JSON to extract). The
2504
- // model's final answer arrives via `result.summary` — on success
2505
- // the adapter prefix is empty so it is the raw final text. We
2506
- // strip any leading/trailing whitespace then run the parser
2507
- // against the contents. On parse failure we surface a non-fatal
2508
- // structured error in the payload — the operator still gets the
2509
- // plan.md artifact and can re-run.
2510
- //
2511
- // TODO(α7.x): `result.summary` is currently a string contract that
2512
- // doubles as both "human-readable headline" and "raw final model
2513
- // text". Split into `{ summary, finalText }` on the adapter so the
2514
- // parser does not have to assume the prefix is empty. Tracked in
2515
- // PR #423 v2 retro (P2.6, Claude review).
2516
- let decomposeArtifact = null;
2517
- let decomposeError = null;
2518
- if (flags.decompose && kind === 'plan' && result.status === 'done') {
2519
- const parsed = parseDecompositionFromText(result.summary);
2520
- if (parsed.ok) {
2521
- decomposeArtifact = writeDecomposition({
2743
+ // For `plan` we always write a plan.md artifact, regardless of
2744
+ // outcome. A blocked plan (budget exhausted, tool refusal) still
2745
+ // produces a reviewable artifact the reason is recorded inline.
2746
+ let planArtifact = null;
2747
+ if (kind === 'plan') {
2748
+ planArtifact = writePlanArtifact({
2522
2749
  root,
2523
- sessionId: session.id,
2524
- // Persist the OPERATOR's original prompt, not the prompt+suffix
2525
- // we sent to the engine. The suffix is plumbing; the manifest
2526
- // header reads naturally only with the operator text.
2527
- prompt: args.join(' ').trim() || prompt,
2528
- decomposition: parsed.decomposition,
2529
- rationale: parsed.rationale,
2750
+ session,
2751
+ prompt,
2752
+ result,
2753
+ statusEvents,
2530
2754
  });
2531
2755
  }
2756
+ // α6.8 EXTEND PR1: `--decompose` post-processing. We only attempt
2757
+ // the parse on a `done` plan (a blocked/failed plan is already
2758
+ // captured in plan.md with its reason; no JSON to extract). The
2759
+ // model's final answer arrives via `result.summary` — on success
2760
+ // the adapter prefix is empty so it is the raw final text. We
2761
+ // strip any leading/trailing whitespace then run the parser
2762
+ // against the contents. On parse failure we surface a non-fatal
2763
+ // structured error in the payload — the operator still gets the
2764
+ // plan.md artifact and can re-run.
2765
+ //
2766
+ // TODO(α7.x): `result.summary` is currently a string contract that
2767
+ // doubles as both "human-readable headline" and "raw final model
2768
+ // text". Split into `{ summary, finalText }` on the adapter so the
2769
+ // parser does not have to assume the prefix is empty. Tracked in
2770
+ // PR #423 v2 retro (P2.6, Claude review).
2771
+ let decomposeArtifact = null;
2772
+ let decomposeError = null;
2773
+ if (flags.decompose && kind === 'plan' && result.status === 'done') {
2774
+ const parsed = parseDecompositionFromText(result.summary);
2775
+ if (parsed.ok) {
2776
+ decomposeArtifact = writeDecomposition({
2777
+ root,
2778
+ sessionId: session.id,
2779
+ // Persist the OPERATOR's original prompt, not the prompt+suffix
2780
+ // we sent to the engine. The suffix is plumbing; the manifest
2781
+ // header reads naturally only with the operator text.
2782
+ prompt: args.join(' ').trim() || prompt,
2783
+ decomposition: parsed.decomposition,
2784
+ rationale: parsed.rationale,
2785
+ });
2786
+ }
2787
+ else {
2788
+ decomposeError = { reason: parsed.reason, detail: parsed.detail };
2789
+ }
2790
+ }
2791
+ // Pull the headline metrics out of `eventRefs` so the summary and
2792
+ // JSON envelope match without re-parsing strings in two places.
2793
+ const metrics = parseEventRefs(result.eventRefs);
2794
+ const finalStatus = result.status === 'failed' ? 'error' : 'success';
2795
+ recordToolResult(session, toolCallId, finalStatus, result.summary);
2796
+ // Exit code policy (spec §1-§5):
2797
+ // code/fix/build → 0 done, 8 failed, 9 blocked
2798
+ // explain → same triple; read-only blocked = budget exhaustion
2799
+ // plan → 0 on done OR plan-mode refusal (refusal is a
2800
+ // SUCCESS for plan: the gate worked); 8 on failed
2801
+ // transport; 9 on budget exhaustion.
2802
+ //
2803
+ // Code Reviewer P2 retro 2026-05-23: previously `plan` masked
2804
+ // `budget_exhausted` as exit 0, so a CI loop with a token budget
2805
+ // hit looked identical to a successful plan. We now distinguish
2806
+ // via the adapter's `outcome=<status>` echo on `eventRefs` so
2807
+ // shell wrappers can branch on the real cause.
2808
+ if (kind === 'plan') {
2809
+ if (result.status === 'failed') {
2810
+ process.exitCode = ENGINE_EXIT_CODES.failed;
2811
+ }
2812
+ else if (result.status === 'blocked' &&
2813
+ metrics.outcome === 'budget_exhausted') {
2814
+ process.exitCode = ENGINE_EXIT_CODES.blocked;
2815
+ }
2816
+ else {
2817
+ // `done`, or `blocked` with outcome=tool_refused (= the plan-mode
2818
+ // gate fired, which is the contract working as designed), or
2819
+ // `blocked` with no outcome echo (legacy adapter — preserve the
2820
+ // pre-retro 0 behaviour to avoid breaking external scripts).
2821
+ process.exitCode = 0;
2822
+ }
2823
+ }
2532
2824
  else {
2533
- decomposeError = { reason: parsed.reason, detail: parsed.detail };
2825
+ process.exitCode = ENGINE_EXIT_CODES[result.status];
2534
2826
  }
2535
- }
2536
- // Pull the headline metrics out of `eventRefs` so the summary and
2537
- // JSON envelope match without re-parsing strings in two places.
2538
- const metrics = parseEventRefs(result.eventRefs);
2539
- const finalStatus = result.status === 'failed' ? 'error' : 'success';
2540
- recordToolResult(session, toolCallId, finalStatus, result.summary);
2541
- // Exit code policy (spec §1-§5):
2542
- // code/fix/build → 0 done, 8 failed, 9 blocked
2543
- // explain → same triple; read-only blocked = budget exhaustion
2544
- // plan → 0 on done OR plan-mode refusal (refusal is a
2545
- // SUCCESS for plan: the gate worked); 8 on failed
2546
- // transport; 9 on budget exhaustion.
2547
- //
2548
- // Code Reviewer P2 retro 2026-05-23: previously `plan` masked
2549
- // `budget_exhausted` as exit 0, so a CI loop with a token budget
2550
- // hit looked identical to a successful plan. We now distinguish
2551
- // via the adapter's `outcome=<status>` echo on `eventRefs` so
2552
- // shell wrappers can branch on the real cause.
2553
- if (kind === 'plan') {
2554
- if (result.status === 'failed') {
2555
- process.exitCode = ENGINE_EXIT_CODES.failed;
2827
+ const payload = {
2828
+ command: label,
2829
+ taskId,
2830
+ status: result.status,
2831
+ summary: result.summary,
2832
+ filesChanged: result.filesChanged,
2833
+ toolCalls: metrics.toolCalls,
2834
+ turns: metrics.turns,
2835
+ tokens: metrics.tokens,
2836
+ sessionId: session.id,
2837
+ sessionEventsMirror: metrics.mirror,
2838
+ risks: result.risks,
2839
+ plan: planArtifact ? { path: planArtifact.relPath } : undefined,
2840
+ // α6.6 per-edit dispatcher trace. Empty array when no inline
2841
+ // markers were detected in the model's final response.
2842
+ diffEdits: dispatchResults.map((dr) => ({
2843
+ layer: dr.layer,
2844
+ file: dr.file,
2845
+ ok: dr.ok,
2846
+ bytesWritten: dr.bytesWritten,
2847
+ reason: dr.reason,
2848
+ detail: dr.detail,
2849
+ })),
2850
+ // α6.8 EXTEND PR1: decompose artifacts (only present when
2851
+ // `--decompose` was passed AND the model emitted a parseable
2852
+ // JSON block). The `error` shape lands when the model returned
2853
+ // unparseable output; the operator can re-run with a tighter
2854
+ // prompt without losing the plain plan.md artifact.
2855
+ decompose: decomposeArtifact !== null
2856
+ ? {
2857
+ manifest: relative(root, decomposeArtifact.manifestPath),
2858
+ planDir: relative(root, decomposeArtifact.planDir),
2859
+ splits: decomposeArtifact.splitPaths,
2860
+ }
2861
+ : decomposeError !== null
2862
+ ? { error: decomposeError }
2863
+ : undefined,
2864
+ // The full event stream is useful for cabinet UI replay. We surface
2865
+ // it in JSON mode only — text mode operators want the summary, not
2866
+ // 30 turn-level lines.
2867
+ events: flags.json ? statusEvents : undefined,
2868
+ };
2869
+ const textLines = [];
2870
+ if (kind === 'plan' && planArtifact) {
2871
+ textLines.push(`Pugi plan written to ${planArtifact.relPath}`);
2556
2872
  }
2557
- else if (result.status === 'blocked' &&
2558
- metrics.outcome === 'budget_exhausted') {
2559
- process.exitCode = ENGINE_EXIT_CODES.blocked;
2873
+ if (decomposeArtifact !== null) {
2874
+ textLines.push(`Decomposition: ${decomposeArtifact.splitPaths.length} component spec${decomposeArtifact.splitPaths.length === 1 ? '' : 's'} under ${relative(root, decomposeArtifact.planDir)}`);
2875
+ textLines.push(`Manifest: ${relative(root, decomposeArtifact.manifestPath)}`);
2560
2876
  }
2561
- else {
2562
- // `done`, or `blocked` with outcome=tool_refused (= the plan-mode
2563
- // gate fired, which is the contract working as designed), or
2564
- // `blocked` with no outcome echo (legacy adapter — preserve the
2565
- // pre-retro 0 behaviour to avoid breaking external scripts).
2566
- process.exitCode = 0;
2877
+ else if (decomposeError !== null) {
2878
+ textLines.push(`Decomposition: skipped (${decomposeError.reason}) plan.md still written`);
2567
2879
  }
2568
- }
2569
- else {
2570
- process.exitCode = ENGINE_EXIT_CODES[result.status];
2571
- }
2572
- const payload = {
2573
- command: label,
2574
- taskId,
2575
- status: result.status,
2576
- summary: result.summary,
2577
- filesChanged: result.filesChanged,
2578
- toolCalls: metrics.toolCalls,
2579
- turns: metrics.turns,
2580
- tokens: metrics.tokens,
2581
- sessionId: session.id,
2582
- sessionEventsMirror: metrics.mirror,
2583
- risks: result.risks,
2584
- plan: planArtifact ? { path: planArtifact.relPath } : undefined,
2585
- // α6.6 per-edit dispatcher trace. Empty array when no inline
2586
- // markers were detected in the model's final response.
2587
- diffEdits: dispatchResults.map((dr) => ({
2588
- layer: dr.layer,
2589
- file: dr.file,
2590
- ok: dr.ok,
2591
- bytesWritten: dr.bytesWritten,
2592
- reason: dr.reason,
2593
- detail: dr.detail,
2594
- })),
2595
- // α6.8 EXTEND PR1: decompose artifacts (only present when
2596
- // `--decompose` was passed AND the model emitted a parseable
2597
- // JSON block). The `error` shape lands when the model returned
2598
- // unparseable output; the operator can re-run with a tighter
2599
- // prompt without losing the plain plan.md artifact.
2600
- decompose: decomposeArtifact !== null
2601
- ? {
2602
- manifest: relative(root, decomposeArtifact.manifestPath),
2603
- planDir: relative(root, decomposeArtifact.planDir),
2604
- splits: decomposeArtifact.splitPaths,
2605
- }
2606
- : decomposeError !== null
2607
- ? { error: decomposeError }
2608
- : undefined,
2609
- // The full event stream is useful for cabinet UI replay. We surface
2610
- // it in JSON mode only — text mode operators want the summary, not
2611
- // 30 turn-level lines.
2612
- events: flags.json ? statusEvents : undefined,
2613
- };
2614
- const textLines = [];
2615
- if (kind === 'plan' && planArtifact) {
2616
- textLines.push(`Pugi plan written to ${planArtifact.relPath}`);
2617
- }
2618
- if (decomposeArtifact !== null) {
2619
- textLines.push(`Decomposition: ${decomposeArtifact.splitPaths.length} component spec${decomposeArtifact.splitPaths.length === 1 ? '' : 's'} under ${relative(root, decomposeArtifact.planDir)}`);
2620
- textLines.push(`Manifest: ${relative(root, decomposeArtifact.manifestPath)}`);
2621
- }
2622
- else if (decomposeError !== null) {
2623
- textLines.push(`Decomposition: skipped (${decomposeError.reason}) — plan.md still written`);
2624
- }
2625
- textLines.push(`Pugi ${label}: ${result.status}`);
2626
- textLines.push(`Summary: ${result.summary}`);
2627
- if (result.filesChanged.length > 0) {
2628
- textLines.push(`Files modified (${result.filesChanged.length}):`);
2629
- for (const file of result.filesChanged)
2630
- textLines.push(` - ${file}`);
2631
- }
2632
- else if (kind !== 'explain' && kind !== 'plan') {
2633
- textLines.push('Files modified: none');
2634
- }
2635
- textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
2636
- if (dispatchResults.length > 0) {
2637
- const okCount = dispatchResults.filter((d) => d.ok).length;
2638
- const failCount = dispatchResults.length - okCount;
2639
- textLines.push(`Diff dispatch: ${okCount} applied, ${failCount} rejected (${dispatchResults.length} marker block${dispatchResults.length === 1 ? '' : 's'})`);
2640
- for (const dr of dispatchResults) {
2641
- if (dr.ok) {
2642
- textLines.push(` + ${dr.layer} ${dr.file} (${dr.bytesWritten} bytes)`);
2643
- }
2644
- else {
2645
- textLines.push(` ! ${dr.layer} ${dr.file}: ${dr.reason ?? 'failure'} — ${dr.detail ?? ''}`);
2880
+ textLines.push(`Pugi ${label}: ${result.status}`);
2881
+ textLines.push(`Summary: ${result.summary}`);
2882
+ if (result.filesChanged.length > 0) {
2883
+ textLines.push(`Files modified (${result.filesChanged.length}):`);
2884
+ for (const file of result.filesChanged)
2885
+ textLines.push(` - ${file}`);
2886
+ }
2887
+ else if (kind !== 'explain' && kind !== 'plan') {
2888
+ textLines.push('Files modified: none');
2889
+ }
2890
+ textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
2891
+ if (dispatchResults.length > 0) {
2892
+ const okCount = dispatchResults.filter((d) => d.ok).length;
2893
+ const failCount = dispatchResults.length - okCount;
2894
+ textLines.push(`Diff dispatch: ${okCount} applied, ${failCount} rejected (${dispatchResults.length} marker block${dispatchResults.length === 1 ? '' : 's'})`);
2895
+ for (const dr of dispatchResults) {
2896
+ if (dr.ok) {
2897
+ textLines.push(` + ${dr.layer} ${dr.file} (${dr.bytesWritten} bytes)`);
2898
+ }
2899
+ else {
2900
+ textLines.push(` ! ${dr.layer} ${dr.file}: ${dr.reason ?? 'failure'} — ${dr.detail ?? ''}`);
2901
+ }
2646
2902
  }
2647
2903
  }
2904
+ if (result.risks.length > 0) {
2905
+ textLines.push(`Risks: ${result.risks.join('; ')}`);
2906
+ }
2907
+ textLines.push(`Session: ${session.id}`);
2908
+ if (metrics.mirror)
2909
+ textLines.push(`Events mirror: ${metrics.mirror}`);
2910
+ writeOutput(flags, payload, textLines.join('\n'));
2648
2911
  }
2649
- if (result.risks.length > 0) {
2650
- textLines.push(`Risks: ${result.risks.join('; ')}`);
2912
+ finally {
2913
+ // β4 r2 P1 #3 — tear down live MCP child processes BEFORE the
2914
+ // CLI exits. shutdown() is idempotent and swallows per-server
2915
+ // disconnect errors, so it is safe even if no servers connected.
2916
+ if (mcpRegistry) {
2917
+ await mcpRegistry.shutdown().catch((error) => {
2918
+ process.stderr.write(`pugi ${label}: MCP registry shutdown reported error — ${error.message}\n`);
2919
+ });
2920
+ }
2651
2921
  }
2652
- textLines.push(`Session: ${session.id}`);
2653
- if (metrics.mirror)
2654
- textLines.push(`Events mirror: ${metrics.mirror}`);
2655
- writeOutput(flags, payload, textLines.join('\n'));
2656
2922
  };
2657
2923
  }
2658
2924
  // Exported for the α6.6.1 triple-review remediation spec