@pugi/cli 0.1.0-beta.11 → 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.
- package/dist/core/consensus/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/engine/anvil-client.js +80 -5
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +663 -249
- package/dist/core/engine/prompts.js +52 -2
- package/dist/core/engine/tool-bridge.js +311 -9
- package/dist/core/lsp/client.js +57 -0
- package/dist/core/mcp/client.js +9 -0
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/session.js +328 -12
- package/dist/core/repl/slash-commands.js +18 -4
- package/dist/core/settings.js +43 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +534 -268
- package/dist/runtime/commands/lsp.js +165 -5
- package/dist/runtime/commands/mcp.js +537 -0
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +192 -0
- package/dist/tools/apply-patch.js +62 -1
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +5 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/repl-render.js +109 -1
- package/dist/tui/repl.js +7 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/update-banner.js +20 -2
- package/package.json +5 -4
package/dist/runtime/cli.js
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.11");
|
|
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
|
-
|
|
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
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
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
|
-
|
|
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:
|
|
2430
|
-
summary:
|
|
2431
|
-
filesChanged:
|
|
2432
|
-
eventRefs:
|
|
2433
|
-
risks: event
|
|
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
|
-
|
|
2438
|
-
//
|
|
2439
|
-
//
|
|
2440
|
-
//
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
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
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
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
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
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
|
-
|
|
2825
|
+
process.exitCode = ENGINE_EXIT_CODES[result.status];
|
|
2534
2826
|
}
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
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
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
toolCalls: metrics.
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
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
|
-
|
|
2650
|
-
|
|
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
|