@pugi/cli 0.1.0-beta.93 → 0.1.0-beta.95
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/commands/retro.js +210 -0
- package/dist/core/diagnostics/probes/sandbox.js +65 -33
- package/dist/core/engine/native-pugi.js +184 -10
- package/dist/core/engine/tool-bridge.js +35 -0
- package/dist/core/engine/verification-patterns.js +9 -9
- package/dist/core/mcp/orchestrator-config.js +192 -0
- package/dist/core/mcp/orchestrator-tools.js +147 -3
- package/dist/core/pugi-gitignore.js +52 -0
- package/dist/core/repl/engine-bridge.js +199 -0
- package/dist/core/repl/session.js +395 -6
- package/dist/core/repl/tool-route.js +382 -0
- package/dist/core/retro/git-collector.js +251 -0
- package/dist/core/retro/health-card.js +25 -0
- package/dist/core/retro/metrics.js +342 -0
- package/dist/core/retro/narrative.js +249 -0
- package/dist/core/retro/plane-collector.js +274 -0
- package/dist/core/retro/pr-issue-link.js +65 -0
- package/dist/core/retro/types.js +16 -0
- package/dist/core/sandboxing/adapter.js +29 -0
- package/dist/core/sandboxing/index.js +49 -0
- package/dist/core/sandboxing/none.js +19 -0
- package/dist/core/sandboxing/seatbelt.js +183 -0
- package/dist/core/session.js +27 -0
- package/dist/core/settings.js +22 -0
- package/dist/runtime/cli.js +167 -33
- package/dist/runtime/commands/mcp.js +64 -8
- package/dist/runtime/deprecation-warning.js +69 -0
- package/dist/runtime/headless.js +8 -3
- package/dist/runtime/stream-renderer.js +195 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/agent-tree.js +11 -0
- package/dist/tui/ask-user-question-chips.js +1 -1
- package/dist/tui/multi-file-diff-approval.js +3 -3
- package/dist/tui/repl-render.js +42 -0
- package/package.json +2 -2
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pugi MCP orchestrator config (Trust Sprint item 7).
|
|
3
|
+
*
|
|
4
|
+
* BEFORE this module the customer had to wire three independent gates
|
|
5
|
+
* to actually run the orchestrator surface successfully:
|
|
6
|
+
*
|
|
7
|
+
* PUGI_MCP_EXEC_ENABLED=1 (env) — gates pugi.run + pugi.dispatch
|
|
8
|
+
* --allow-bash (flag) — surfaces the bash permission class
|
|
9
|
+
* PUGI_MCP_PUGI_BIN=... (env) — points the dispatcher at a binary
|
|
10
|
+
*
|
|
11
|
+
* Missing any one of the three produced an opaque "tool refused" or
|
|
12
|
+
* "binary not found" error that nobody could correlate back to the
|
|
13
|
+
* three-knob configuration. Codex deep-research 2026-06-04 logged this
|
|
14
|
+
* as a discipline gap.
|
|
15
|
+
*
|
|
16
|
+
* AFTER this module the customer flips ONE switch:
|
|
17
|
+
*
|
|
18
|
+
* PUGI_MCP_ORCHESTRATOR=1
|
|
19
|
+
*
|
|
20
|
+
* Or one flag:
|
|
21
|
+
*
|
|
22
|
+
* pugi mcp serve --orchestrator-bundle
|
|
23
|
+
*
|
|
24
|
+
* Either form:
|
|
25
|
+
* - enables exec gating on pugi.run + pugi.dispatch,
|
|
26
|
+
* - turns on the bash permission class (equivalent to --allow-bash),
|
|
27
|
+
* - auto-resolves `pugi` from PATH (or the local dev binary when
|
|
28
|
+
* invoked from a monorepo checkout).
|
|
29
|
+
*
|
|
30
|
+
* The legacy multi-knob configuration continues to work as deprecated
|
|
31
|
+
* aliases. When the legacy combo is observed at boot we emit a stderr
|
|
32
|
+
* warning so operators know they should migrate.
|
|
33
|
+
*
|
|
34
|
+
* Scope rule: this module is ADDITIVE. It does not modify
|
|
35
|
+
* orchestrator-tools.ts or the dispatch handler (other agent owns
|
|
36
|
+
* those files in PUGI-VERIFY-GATE).
|
|
37
|
+
*/
|
|
38
|
+
import { existsSync } from 'node:fs';
|
|
39
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { execFileSync } from 'node:child_process';
|
|
42
|
+
import { warnDeprecation } from '../../runtime/deprecation-warning.js';
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the effective orchestrator configuration from inputs.
|
|
45
|
+
*
|
|
46
|
+
* Single source of truth for the gate-collapse logic. `runtime/commands/
|
|
47
|
+
* mcp.ts` consumes the result; tests call this function directly.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveOrchestratorConfig(input) {
|
|
50
|
+
const env = input.env;
|
|
51
|
+
const bundleViaEnv = env.PUGI_MCP_ORCHESTRATOR === '1';
|
|
52
|
+
const bundleViaFlag = input.bundleFlag;
|
|
53
|
+
const bundleEnabled = bundleViaEnv || bundleViaFlag;
|
|
54
|
+
const execViaLegacyEnv = env.PUGI_MCP_EXEC_ENABLED === '1';
|
|
55
|
+
const publishViaLegacyEnv = env.PUGI_MCP_PUBLISH_ENABLED === '1';
|
|
56
|
+
const deployViaLegacyEnv = env.PUGI_MCP_DEPLOY_ENABLED === '1';
|
|
57
|
+
const bashViaFlag = input.bashFlag;
|
|
58
|
+
// Bundle implies exec + bash. Publish + deploy stay opt-in even under
|
|
59
|
+
// bundle because they cross network boundaries (npm publish, ssh
|
|
60
|
+
// deploy) — operator should still flip them deliberately.
|
|
61
|
+
const execEnabled = bundleEnabled || execViaLegacyEnv;
|
|
62
|
+
const publishEnabled = publishViaLegacyEnv;
|
|
63
|
+
const deployEnabled = deployViaLegacyEnv;
|
|
64
|
+
const bashAllowed = bundleEnabled || bashViaFlag;
|
|
65
|
+
// Emit deprecation warning the first time we see the OLD multi-flag
|
|
66
|
+
// combo without the bundle flag. We only warn if the operator clearly
|
|
67
|
+
// intended the orchestrator surface (at least one legacy env set) so
|
|
68
|
+
// bare engine-surface runs stay silent.
|
|
69
|
+
if (!bundleEnabled && execViaLegacyEnv) {
|
|
70
|
+
warnDeprecation('PUGI_MCP_EXEC_ENABLED', 'PUGI_MCP_ORCHESTRATOR=1', 'Single flag enables exec + bash + auto-resolves the pugi binary in one step.');
|
|
71
|
+
}
|
|
72
|
+
const { pugiBin, pugiBinSource } = resolvePugiBin({
|
|
73
|
+
envOverride: env.PUGI_MCP_PUGI_BIN ?? null,
|
|
74
|
+
workspaceRoot: input.workspaceRoot,
|
|
75
|
+
resolveBinary: input.resolveBinary ?? defaultBinaryResolver,
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
bundleEnabled,
|
|
79
|
+
execEnabled,
|
|
80
|
+
publishEnabled,
|
|
81
|
+
deployEnabled,
|
|
82
|
+
bashAllowed,
|
|
83
|
+
pugiBin,
|
|
84
|
+
source: {
|
|
85
|
+
bundleViaEnv,
|
|
86
|
+
bundleViaFlag,
|
|
87
|
+
execViaLegacyEnv,
|
|
88
|
+
publishViaLegacyEnv,
|
|
89
|
+
deployViaLegacyEnv,
|
|
90
|
+
bashViaFlag,
|
|
91
|
+
pugiBinSource,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the `pugi` binary path. Layered fallback so operators get a
|
|
97
|
+
* working dispatcher whether they `npm i -g @pugi/cli`-ed it (PATH),
|
|
98
|
+
* baked it into env (CI), or are running from a monorepo checkout
|
|
99
|
+
* (where `pugi` is not on PATH but `dist/index.js` exists).
|
|
100
|
+
*/
|
|
101
|
+
function resolvePugiBin(input) {
|
|
102
|
+
if (input.envOverride && input.envOverride.length > 0) {
|
|
103
|
+
return { pugiBin: input.envOverride, pugiBinSource: 'env' };
|
|
104
|
+
}
|
|
105
|
+
const fromPath = input.resolveBinary('pugi');
|
|
106
|
+
if (fromPath && fromPath.length > 0) {
|
|
107
|
+
return { pugiBin: fromPath, pugiBinSource: 'path-lookup' };
|
|
108
|
+
}
|
|
109
|
+
const devBinary = findMonorepoDevBinary(input.workspaceRoot);
|
|
110
|
+
if (devBinary) {
|
|
111
|
+
return { pugiBin: devBinary, pugiBinSource: 'monorepo-dev-binary' };
|
|
112
|
+
}
|
|
113
|
+
// Last-resort fallback. The downstream `execFile` will throw ENOENT
|
|
114
|
+
// with a path readable error; better that than us swallowing the
|
|
115
|
+
// intent of "operator wanted orchestrator mode" silently.
|
|
116
|
+
return { pugiBin: 'pugi', pugiBinSource: 'fallback' };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Walk up from the workspace root looking for the monorepo pugi-cli
|
|
120
|
+
* checkout. Used when an operator runs `pugi mcp serve --orchestrator-
|
|
121
|
+
* bundle` from inside a freshly-cloned `pugi-io/pugi` repo without a
|
|
122
|
+
* global install. Skipped for non-monorepo workspaces (returns null
|
|
123
|
+
* quickly when the expected marker files are absent).
|
|
124
|
+
*/
|
|
125
|
+
function findMonorepoDevBinary(workspaceRoot) {
|
|
126
|
+
const candidates = [];
|
|
127
|
+
// Common shapes: workspaceRoot IS the monorepo, or workspaceRoot is
|
|
128
|
+
// a sibling sub-app of pugi-cli. Limit the walk to two levels up.
|
|
129
|
+
let current = workspaceRoot;
|
|
130
|
+
for (let i = 0; i < 4; i += 1) {
|
|
131
|
+
candidates.push(resolve(current, 'apps/pugi-cli/dist/index.js'));
|
|
132
|
+
candidates.push(resolve(current, 'apps/pugi-cli/bin/run.js'));
|
|
133
|
+
const parent = dirname(current);
|
|
134
|
+
if (parent === current)
|
|
135
|
+
break;
|
|
136
|
+
current = parent;
|
|
137
|
+
}
|
|
138
|
+
for (const candidate of candidates) {
|
|
139
|
+
if (existsSync(candidate))
|
|
140
|
+
return candidate;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Default `which`-backed binary resolver. Mirrors the pattern used by
|
|
146
|
+
* `resolveExecutablePath` in `runtime/commands/mcp.ts`. Returns null
|
|
147
|
+
* on any failure — the caller falls back to the next layer.
|
|
148
|
+
*/
|
|
149
|
+
function defaultBinaryResolver(command) {
|
|
150
|
+
if (isAbsolute(command)) {
|
|
151
|
+
return existsSync(command) ? command : null;
|
|
152
|
+
}
|
|
153
|
+
if (command.includes('/') || command.includes('\\'))
|
|
154
|
+
return null;
|
|
155
|
+
try {
|
|
156
|
+
const out = execFileSync('/usr/bin/which', [command], {
|
|
157
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
158
|
+
encoding: 'utf8',
|
|
159
|
+
timeout: 5000,
|
|
160
|
+
}).trim();
|
|
161
|
+
return out.length > 0 ? out : null;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Compose a human-readable diagnostic line per resolved config. Used
|
|
169
|
+
* by `pugi mcp serve --orchestrator*` startup output and `pugi doctor`
|
|
170
|
+
* so operators can verify which gates ended up live.
|
|
171
|
+
*/
|
|
172
|
+
export function describeOrchestratorConfig(config) {
|
|
173
|
+
const lines = [];
|
|
174
|
+
lines.push(`bundle: ${config.bundleEnabled ? 'on' : 'off'}`);
|
|
175
|
+
lines.push(`exec: ${config.execEnabled ? 'on' : 'off'}`);
|
|
176
|
+
lines.push(`publish: ${config.publishEnabled ? 'on' : 'off'}`);
|
|
177
|
+
lines.push(`deploy: ${config.deployEnabled ? 'on' : 'off'}`);
|
|
178
|
+
lines.push(`bash surfaced: ${config.bashAllowed ? 'on' : 'off'}`);
|
|
179
|
+
lines.push(`pugi binary: ${config.pugiBin} (source: ${config.source.pugiBinSource})`);
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Exported for tests so the dev-binary fallback can be exercised
|
|
184
|
+
* without invoking the full resolver. Kept internal to the module
|
|
185
|
+
* otherwise.
|
|
186
|
+
*/
|
|
187
|
+
export const _internalForTests = {
|
|
188
|
+
findMonorepoDevBinary,
|
|
189
|
+
resolvePugiBin,
|
|
190
|
+
currentModuleDir: () => dirname(fileURLToPath(import.meta.url)),
|
|
191
|
+
};
|
|
192
|
+
//# sourceMappingURL=orchestrator-config.js.map
|
|
@@ -358,8 +358,15 @@ export function buildOrchestratorTools(ctx) {
|
|
|
358
358
|
: ctx.workspaceRoot;
|
|
359
359
|
const timeoutMs = optionalNumber(args, 'timeoutMs', 180000);
|
|
360
360
|
const started = Date.now();
|
|
361
|
+
// PUGI-VERIFY-GATE: dispatch the child with `--json` so we
|
|
362
|
+
// can parse its structured outcome envelope. The CLI's JSON
|
|
363
|
+
// mode includes verified / verificationCommands /
|
|
364
|
+
// verificationFailures / unverifiedReason /
|
|
365
|
+
// regressionOwnershipDispute. The MCP response now carries
|
|
366
|
+
// those fields through alongside the honest exit code so
|
|
367
|
+
// callers see the gate state, not just a "ran" boolean.
|
|
361
368
|
try {
|
|
362
|
-
const { stdout, stderr } = await execImpl(ctx.pugiBin, [command, prompt, '--no-tty'], {
|
|
369
|
+
const { stdout, stderr } = await execImpl(ctx.pugiBin, [command, prompt, '--no-tty', '--json'], {
|
|
363
370
|
cwd,
|
|
364
371
|
timeout: timeoutMs,
|
|
365
372
|
maxBuffer: 8 * 1024 * 1024,
|
|
@@ -370,13 +377,33 @@ export function buildOrchestratorTools(ctx) {
|
|
|
370
377
|
// `pugi login` state when both are present.
|
|
371
378
|
env: dispatchEnv(),
|
|
372
379
|
});
|
|
380
|
+
// Codex dogfood 2026-06-04: the prior implementation
|
|
381
|
+
// hardcoded `exitCode: 0` on the happy execImpl path even
|
|
382
|
+
// when the child surfaced a verification failure through
|
|
383
|
+
// `--json`. The child's `--json` envelope is the source of
|
|
384
|
+
// truth — parse it and mirror `verified` / `status` back
|
|
385
|
+
// to the MCP caller. The execImpl-level "no throw" signal
|
|
386
|
+
// is no longer trusted as "exit 0".
|
|
387
|
+
const parsed = parseDispatchEnvelope(stdout);
|
|
388
|
+
const dispatchExitCode = resolveDispatchExitCode(parsed);
|
|
373
389
|
return JSON.stringify({
|
|
374
390
|
command,
|
|
375
391
|
cwd,
|
|
376
|
-
|
|
392
|
+
// CRITICAL: derived from parsed envelope, not constant 0.
|
|
393
|
+
exitCode: dispatchExitCode,
|
|
377
394
|
durationMs: Date.now() - started,
|
|
378
395
|
stdout: clamp(stdout, 16 * 1024),
|
|
379
396
|
stderr: clamp(stderr, 4 * 1024),
|
|
397
|
+
...(parsed
|
|
398
|
+
? {
|
|
399
|
+
status: parsed.status,
|
|
400
|
+
verified: parsed.verified,
|
|
401
|
+
verificationCommands: parsed.verificationCommands,
|
|
402
|
+
verificationFailures: parsed.verificationFailures,
|
|
403
|
+
unverifiedReason: parsed.unverifiedReason,
|
|
404
|
+
regressionOwnershipDispute: parsed.regressionOwnershipDispute,
|
|
405
|
+
}
|
|
406
|
+
: {}),
|
|
380
407
|
});
|
|
381
408
|
}
|
|
382
409
|
catch (err) {
|
|
@@ -386,15 +413,35 @@ export function buildOrchestratorTools(ctx) {
|
|
|
386
413
|
// ENOENT"`. Operators need to distinguish "pugi binary missing"
|
|
387
414
|
// from "pugi ran and exited 1 silently."
|
|
388
415
|
const stderrText = e.stderr || e.message || '';
|
|
416
|
+
// PUGI-VERIFY-GATE: even on the throw path, parse stdout
|
|
417
|
+
// when present so the verification gate state surfaces.
|
|
418
|
+
// The child's CLI exits non-zero for failed / blocked /
|
|
419
|
+
// needs_verification, which puts execImpl on this path.
|
|
420
|
+
const parsed = parseDispatchEnvelope(e.stdout ?? '');
|
|
421
|
+
const dispatchExitCode = typeof e.code === 'number'
|
|
422
|
+
? e.code
|
|
423
|
+
: parsed
|
|
424
|
+
? resolveDispatchExitCode(parsed)
|
|
425
|
+
: 1;
|
|
389
426
|
return JSON.stringify({
|
|
390
427
|
command,
|
|
391
428
|
cwd,
|
|
392
|
-
exitCode:
|
|
429
|
+
exitCode: dispatchExitCode,
|
|
393
430
|
durationMs: Date.now() - started,
|
|
394
431
|
stdout: clamp(e.stdout ?? '', 16 * 1024),
|
|
395
432
|
stderr: clamp(stderrText, 4 * 1024),
|
|
396
433
|
...(e.signal ? { signal: e.signal } : {}),
|
|
397
434
|
...(e.killed ? { killed: true } : {}),
|
|
435
|
+
...(parsed
|
|
436
|
+
? {
|
|
437
|
+
status: parsed.status,
|
|
438
|
+
verified: parsed.verified,
|
|
439
|
+
verificationCommands: parsed.verificationCommands,
|
|
440
|
+
verificationFailures: parsed.verificationFailures,
|
|
441
|
+
unverifiedReason: parsed.unverifiedReason,
|
|
442
|
+
regressionOwnershipDispute: parsed.regressionOwnershipDispute,
|
|
443
|
+
}
|
|
444
|
+
: {}),
|
|
398
445
|
});
|
|
399
446
|
}
|
|
400
447
|
},
|
|
@@ -659,4 +706,101 @@ export const ORCHESTRATOR_TOOLS_MODULE_FILE = (() => {
|
|
|
659
706
|
return '';
|
|
660
707
|
}
|
|
661
708
|
})();
|
|
709
|
+
/**
|
|
710
|
+
* Try to extract the JSON envelope from the child CLI's stdout.
|
|
711
|
+
* The CLI prints a single JSON object on the trailing line when
|
|
712
|
+
* `--json` is passed; older builds may interleave status events on
|
|
713
|
+
* stderr but always emit the final JSON on stdout. Scan from the
|
|
714
|
+
* end of stdout backwards looking for the first balanced JSON
|
|
715
|
+
* object so a mixed stdout (e.g. with leading banner) still
|
|
716
|
+
* parses.
|
|
717
|
+
*
|
|
718
|
+
* Returns null on any parse failure; the caller falls back to
|
|
719
|
+
* legacy behaviour (no verification fields surfaced).
|
|
720
|
+
*/
|
|
721
|
+
export function parseDispatchEnvelope(stdout) {
|
|
722
|
+
if (typeof stdout !== 'string' || stdout.trim() === '')
|
|
723
|
+
return null;
|
|
724
|
+
const trimmed = stdout.trim();
|
|
725
|
+
// Fast path: stdout is a single JSON object (most common).
|
|
726
|
+
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
727
|
+
try {
|
|
728
|
+
const parsed = JSON.parse(trimmed);
|
|
729
|
+
return normaliseEnvelope(parsed);
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
// fall through to multi-line scan
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
// Slow path: scan trailing lines for the last JSON-looking line.
|
|
736
|
+
const lines = trimmed.split('\n');
|
|
737
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
738
|
+
const line = lines[i]?.trim();
|
|
739
|
+
if (!line || !line.startsWith('{') || !line.endsWith('}'))
|
|
740
|
+
continue;
|
|
741
|
+
try {
|
|
742
|
+
const parsed = JSON.parse(line);
|
|
743
|
+
return normaliseEnvelope(parsed);
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
// try the next line up
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
function normaliseEnvelope(raw) {
|
|
752
|
+
if (typeof raw['status'] !== 'string')
|
|
753
|
+
return null;
|
|
754
|
+
const result = { status: raw['status'] };
|
|
755
|
+
if (typeof raw['verified'] === 'boolean')
|
|
756
|
+
result.verified = raw['verified'];
|
|
757
|
+
if (Array.isArray(raw['verificationCommands'])) {
|
|
758
|
+
result.verificationCommands = raw['verificationCommands'].filter((item) => typeof item === 'string');
|
|
759
|
+
}
|
|
760
|
+
if (Array.isArray(raw['verificationFailures'])) {
|
|
761
|
+
const failures = [];
|
|
762
|
+
for (const item of raw['verificationFailures']) {
|
|
763
|
+
if (item && typeof item === 'object') {
|
|
764
|
+
const r = item;
|
|
765
|
+
if (typeof r['command'] === 'string' &&
|
|
766
|
+
typeof r['exitCode'] === 'number') {
|
|
767
|
+
failures.push({
|
|
768
|
+
command: r['command'],
|
|
769
|
+
exitCode: r['exitCode'],
|
|
770
|
+
tailStderr: typeof r['tailStderr'] === 'string' ? r['tailStderr'] : '',
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
result.verificationFailures = failures;
|
|
776
|
+
}
|
|
777
|
+
if (typeof raw['unverifiedReason'] === 'string') {
|
|
778
|
+
result.unverifiedReason = raw['unverifiedReason'];
|
|
779
|
+
}
|
|
780
|
+
if (typeof raw['regressionOwnershipDispute'] === 'boolean') {
|
|
781
|
+
result.regressionOwnershipDispute = raw['regressionOwnershipDispute'];
|
|
782
|
+
}
|
|
783
|
+
return result;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Honest exit code derivation from the parsed envelope. Mirrors
|
|
787
|
+
* `resolveEngineExitCode` in `cli.ts` so the MCP wrapper's
|
|
788
|
+
* propagation matches what the child CLI actually exits with — a
|
|
789
|
+
* test can assert on either surface and see consistent codes.
|
|
790
|
+
*/
|
|
791
|
+
export function resolveDispatchExitCode(envelope) {
|
|
792
|
+
if (envelope === null)
|
|
793
|
+
return 0;
|
|
794
|
+
if (envelope.status === 'needs_verification')
|
|
795
|
+
return 2;
|
|
796
|
+
if (envelope.unverifiedReason === 'verification_command_failed')
|
|
797
|
+
return 1;
|
|
798
|
+
if (envelope.status === 'done')
|
|
799
|
+
return 0;
|
|
800
|
+
if (envelope.status === 'failed')
|
|
801
|
+
return 1;
|
|
802
|
+
if (envelope.status === 'blocked')
|
|
803
|
+
return 1;
|
|
804
|
+
return 1;
|
|
805
|
+
}
|
|
662
806
|
//# sourceMappingURL=orchestrator-tools.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ensure `.gitignore` covers Pugi's local persistence directories.
|
|
3
|
+
*
|
|
4
|
+
* Triple-review P1 (2026-06-04, ): commands that write under
|
|
5
|
+
* `.pugi/` must guarantee a `.gitignore` entry exists BEFORE the first
|
|
6
|
+
* write. Otherwise the first customer run of e.g. `pugi retro` in a
|
|
7
|
+
* fresh repo leaves `.pugi/retros/` and any future `.pugi/settings.json`
|
|
8
|
+
* (secret store) tracked by git on the next `git add -A`.
|
|
9
|
+
*
|
|
10
|
+
* Extracted from `runtime/cli.ts` so any command that mutates `.pugi/`
|
|
11
|
+
* can share the same guarantee without depending on the heavy cli.ts
|
|
12
|
+
* module graph.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { resolve } from 'node:path';
|
|
16
|
+
export const DEFAULT_PUGI_GITIGNORE_MARKERS = [
|
|
17
|
+
'.pugi/',
|
|
18
|
+
'.claude/worktrees/',
|
|
19
|
+
];
|
|
20
|
+
const EQUIVALENTS = {
|
|
21
|
+
'.pugi/': ['.pugi/', '/.pugi/', '.pugi'],
|
|
22
|
+
'.claude/worktrees/': ['.claude/worktrees/', '/.claude/worktrees/', '.claude/worktrees'],
|
|
23
|
+
};
|
|
24
|
+
export function ensurePugiGitIgnore(cwd, created, skipped, markers = DEFAULT_PUGI_GITIGNORE_MARKERS) {
|
|
25
|
+
const gitignorePath = resolve(cwd, '.gitignore');
|
|
26
|
+
if (!existsSync(gitignorePath)) {
|
|
27
|
+
writeFileSync(gitignorePath, `${markers.join('\n')}\n`, {
|
|
28
|
+
encoding: 'utf8',
|
|
29
|
+
mode: 0o600,
|
|
30
|
+
});
|
|
31
|
+
created.push(gitignorePath);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const current = readFileSync(gitignorePath, 'utf8');
|
|
35
|
+
const lines = current.split('\n').map((line) => line.trim());
|
|
36
|
+
const toAppend = [];
|
|
37
|
+
for (const marker of markers) {
|
|
38
|
+
const variants = EQUIVALENTS[marker] ?? [marker];
|
|
39
|
+
const present = variants.some((variant) => lines.includes(variant));
|
|
40
|
+
if (!present)
|
|
41
|
+
toAppend.push(marker);
|
|
42
|
+
}
|
|
43
|
+
if (toAppend.length === 0) {
|
|
44
|
+
skipped.push(gitignorePath);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const trailing = current.endsWith('\n') ? '' : '\n';
|
|
48
|
+
const next = `${current}${trailing}${toAppend.join('\n')}\n`;
|
|
49
|
+
writeFileSync(gitignorePath, next, { encoding: 'utf8' });
|
|
50
|
+
created.push(`${gitignorePath} (+${toAppend.join(', ')})`);
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=pugi-gitignore.js.map
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { AnvilEngineLoopClient } from '../engine/anvil-client.js';
|
|
2
|
+
import { NativePugiEngineAdapter } from '../engine/native-pugi.js';
|
|
3
|
+
import { openSession } from '../session.js';
|
|
4
|
+
/**
|
|
5
|
+
* Translate `pugi-tool-route command="..."` into the SDK's
|
|
6
|
+
* `EngineTaskKind`. `code` and `fix` pass through verbatim; `build`
|
|
7
|
+
* maps to `build_task` per `apps/pugi-cli/src/core/engine/native-pugi.ts:1444`
|
|
8
|
+
* (`toCommandKind`).
|
|
9
|
+
*/
|
|
10
|
+
function commandToTaskKind(command) {
|
|
11
|
+
if (command === 'build')
|
|
12
|
+
return 'build_task';
|
|
13
|
+
return command;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Translate one `EngineStreamEvent` (rich, adapter-internal vocabulary)
|
|
17
|
+
* into the REPL bridge's narrow `BridgedEngineEvent` shape (four
|
|
18
|
+
* variants -- step / tool.start / tool.result / tokens).
|
|
19
|
+
*
|
|
20
|
+
* Returns `null` for events the bridge surface deliberately ignores
|
|
21
|
+
* (`tool.delta` payloads are surfaced via `tool.result` summary;
|
|
22
|
+
* `thinking.*` and `text.delta` deltas are not part of the bridge
|
|
23
|
+
* UX contract -- the synthetic agent-tree node renders a single
|
|
24
|
+
* `detail` line, not a streaming thinking block).
|
|
25
|
+
*
|
|
26
|
+
* Mutates `names` so a follow-up `tool.end` can resolve its callId
|
|
27
|
+
* back to the recorded tool name (`tool.end` carries only `callId`).
|
|
28
|
+
*/
|
|
29
|
+
function translateStreamEvent(event, names) {
|
|
30
|
+
if (event.type === 'status') {
|
|
31
|
+
return { type: 'step', detail: event.message };
|
|
32
|
+
}
|
|
33
|
+
if (event.type === 'tool.start') {
|
|
34
|
+
names.set(event.callId, event.name);
|
|
35
|
+
return {
|
|
36
|
+
type: 'tool.start',
|
|
37
|
+
tool: event.name,
|
|
38
|
+
args: event.arguments,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (event.type === 'tool.end') {
|
|
42
|
+
const name = names.get(event.callId) ?? '';
|
|
43
|
+
names.delete(event.callId);
|
|
44
|
+
return {
|
|
45
|
+
type: 'tool.result',
|
|
46
|
+
tool: name,
|
|
47
|
+
ok: event.ok,
|
|
48
|
+
preview: event.summary,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// tool.delta / thinking.* / text.delta intentionally dropped.
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Production factory. Returns an `EngineBridge` the REPL bootstrap
|
|
56
|
+
* passes straight to `new ReplSession({ engineBridge })`.
|
|
57
|
+
*
|
|
58
|
+
* Per invocation lifecycle:
|
|
59
|
+
* 1. `openSession(cwd)` mints a fresh session id and ensures the
|
|
60
|
+
* `.pugi/events.jsonl` ledger captures audit lines for the
|
|
61
|
+
* bridged turn alongside direct `pugi code` invocations.
|
|
62
|
+
* 2. `NativePugiEngineAdapter` is constructed eagerly with the
|
|
63
|
+
* injected (or default) `EngineLoopClient`. The adapter prewarms
|
|
64
|
+
* the real-dispatch import on construction; this happens here for
|
|
65
|
+
* free.
|
|
66
|
+
* 3. `attachStreamListener` subscribes to the adapter's
|
|
67
|
+
* `streamEmitter`, fans every translatable `EngineStreamEvent`
|
|
68
|
+
* to `input.onEvent`, and detaches on bridge exit.
|
|
69
|
+
* 4. `adapter.run(task, ctx)` drives the engine loop; the terminal
|
|
70
|
+
* `result` event maps to the bridge outcome the REPL renders.
|
|
71
|
+
* 5. The bridge's `signal` is threaded straight through `ctx.signal`
|
|
72
|
+
* so a REPL `/stop` aborts the engine loop mid-turn.
|
|
73
|
+
*
|
|
74
|
+
* Failure modes (each must be observable, never silent):
|
|
75
|
+
* - Adapter constructor throws (e.g. invalid config) -- the bridge
|
|
76
|
+
* promise rejects with the underlying error. The REPL's
|
|
77
|
+
* `runEngineBridge` catch surfaces the message on a system line.
|
|
78
|
+
* - Network error mid-loop -- adapter yields `result.status='failed'`,
|
|
79
|
+
* bridge returns `{ outcome: 'failed', detail: result.summary }`.
|
|
80
|
+
* - Operator abort -- adapter honours `ctx.signal`, surfaces an
|
|
81
|
+
* `AbortError`; we propagate the rejection so the REPL flips the
|
|
82
|
+
* synthetic node to `failed` with a clear detail line.
|
|
83
|
+
*/
|
|
84
|
+
export function createEngineBridge(deps) {
|
|
85
|
+
const buildClient = deps.clientFactory ?? ((config) => new AnvilEngineLoopClient(config));
|
|
86
|
+
return async (input) => {
|
|
87
|
+
const root = deps.cwd();
|
|
88
|
+
const session = openSession(root);
|
|
89
|
+
const client = buildClient(deps.config);
|
|
90
|
+
const adapter = new NativePugiEngineAdapter({
|
|
91
|
+
client,
|
|
92
|
+
session,
|
|
93
|
+
});
|
|
94
|
+
// Per-call name map for matching `tool.end` -> `tool.start`. New
|
|
95
|
+
// every invocation so concurrent bridges never cross-pollute.
|
|
96
|
+
const callNames = new Map();
|
|
97
|
+
// Subscribe BEFORE `adapter.run()` so the first `tool.start` is
|
|
98
|
+
// captured. Detach in `finally` regardless of outcome so the
|
|
99
|
+
// listener does not accumulate across REPL turns (long sessions
|
|
100
|
+
// would otherwise leak one listener per bridged turn).
|
|
101
|
+
const onStreamEvent = (streamEvent) => {
|
|
102
|
+
const translated = translateStreamEvent(streamEvent, callNames);
|
|
103
|
+
if (translated === null)
|
|
104
|
+
return;
|
|
105
|
+
try {
|
|
106
|
+
input.onEvent(translated);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Fire-and-forget contract -- a broken consumer must never
|
|
110
|
+
// tear down the engine loop.
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
adapter.streamEmitter.on('event', onStreamEvent);
|
|
114
|
+
const taskKind = commandToTaskKind(input.command);
|
|
115
|
+
const task = {
|
|
116
|
+
id: input.bridgeId,
|
|
117
|
+
kind: taskKind,
|
|
118
|
+
prompt: input.brief,
|
|
119
|
+
workspaceRoot: root,
|
|
120
|
+
allowedPaths: [root],
|
|
121
|
+
deniedPaths: [],
|
|
122
|
+
artifacts: [],
|
|
123
|
+
permissionMode: 'auto',
|
|
124
|
+
};
|
|
125
|
+
let terminalSummary = '';
|
|
126
|
+
let terminalStatus = 'failed';
|
|
127
|
+
let filesChangedCount = 0;
|
|
128
|
+
let detail;
|
|
129
|
+
try {
|
|
130
|
+
const events = adapter.run(task, {
|
|
131
|
+
sessionId: session.id,
|
|
132
|
+
signal: input.signal,
|
|
133
|
+
});
|
|
134
|
+
for await (const event of events) {
|
|
135
|
+
if (event.type === 'status') {
|
|
136
|
+
// Already fanned out via streamEmitter above. The toplevel
|
|
137
|
+
// `EngineEvent` `status` event predates the rich emitter and
|
|
138
|
+
// is kept for backwards-compat with older adapters; we
|
|
139
|
+
// intentionally do not double-fire `onEvent` here.
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// event.type === 'result' -- terminal.
|
|
143
|
+
terminalStatus = event.result.status;
|
|
144
|
+
terminalSummary = event.result.summary;
|
|
145
|
+
filesChangedCount = event.result.filesChanged.length;
|
|
146
|
+
if (event.result.status !== 'done') {
|
|
147
|
+
detail = event.result.summary;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
adapter.streamEmitter.off('event', onStreamEvent);
|
|
153
|
+
}
|
|
154
|
+
// Map engine `EngineResult.status` -> bridge `EngineBridgeOutcome`.
|
|
155
|
+
//
|
|
156
|
+
// `needs_verification` ( PUGI-VERIFY-GATE) downgrades a
|
|
157
|
+
// `completed` engine loop with no verification command to a status
|
|
158
|
+
// distinct from `done`. PUGI-538c-FU-OUTCOME (2026-06-05) split
|
|
159
|
+
// the bridge's failure surface: `needs_verification` now maps to
|
|
160
|
+
// the dedicated `unverified` outcome so the REPL agent-tree pane
|
|
161
|
+
// surfaces a yellow advisory instead of a red false-fail. Real
|
|
162
|
+
// verification regressions (`verification_command_failed` -> still
|
|
163
|
+
// `failed` here via the engine's `failed` status) are unchanged.
|
|
164
|
+
//
|
|
165
|
+
// Why the split matters: a fresh customer repo with no Makefile /
|
|
166
|
+
// no `package.json` test script trips `needs_verification` on
|
|
167
|
+
// every routed brief. Collapsing that to `failed` (the pre-538c
|
|
168
|
+
// mapping) produced the trust regression the CEO escalation 2026
|
|
169
|
+
// -06-04 demanded we fix: customer dogfood saw files land on
|
|
170
|
+
// disk yet read "failed" on the agent-tree, lost trust, walked
|
|
171
|
+
// away. The verify-gate contract is preserved: real test failures
|
|
172
|
+
// ALSO reach this branch via engine status `failed` (gated by
|
|
173
|
+
// `computeVerificationOutcome` when a verification command ran
|
|
174
|
+
// and exited non-zero) and still map to `failed`. Only the
|
|
175
|
+
// "no command available" path softens.
|
|
176
|
+
//
|
|
177
|
+
// `blocked` carries through (operator chose the abort / budget
|
|
178
|
+
// exhausted). `done` -> `shipped` -- the only path that produces
|
|
179
|
+
// a clean shipped status is a verified engine loop.
|
|
180
|
+
const outcome = terminalStatus === 'done'
|
|
181
|
+
? 'shipped'
|
|
182
|
+
: terminalStatus === 'needs_verification'
|
|
183
|
+
? 'unverified'
|
|
184
|
+
: terminalStatus === 'blocked'
|
|
185
|
+
? 'blocked'
|
|
186
|
+
: 'failed';
|
|
187
|
+
return {
|
|
188
|
+
outcome,
|
|
189
|
+
filesChanged: filesChangedCount,
|
|
190
|
+
// PUGI-538c scope guard: stream emitter has no typed `tokens`
|
|
191
|
+
// event today; reporting `0` keeps the bridge contract honest
|
|
192
|
+
// until the emitter gains a tokens variant (separate follow-up).
|
|
193
|
+
tokensUsed: 0,
|
|
194
|
+
finalText: terminalSummary.length > 0 ? terminalSummary : undefined,
|
|
195
|
+
...(detail !== undefined ? { detail } : {}),
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
//# sourceMappingURL=engine-bridge.js.map
|