@pugi/cli 0.1.0-beta.93 → 0.1.0-beta.94

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 (35) hide show
  1. package/dist/commands/retro.js +210 -0
  2. package/dist/core/diagnostics/probes/sandbox.js +65 -33
  3. package/dist/core/engine/native-pugi.js +184 -10
  4. package/dist/core/engine/tool-bridge.js +35 -0
  5. package/dist/core/engine/verification-patterns.js +9 -9
  6. package/dist/core/mcp/orchestrator-config.js +192 -0
  7. package/dist/core/mcp/orchestrator-tools.js +147 -3
  8. package/dist/core/pugi-gitignore.js +52 -0
  9. package/dist/core/repl/engine-bridge.js +199 -0
  10. package/dist/core/repl/session.js +395 -6
  11. package/dist/core/repl/tool-route.js +382 -0
  12. package/dist/core/retro/git-collector.js +251 -0
  13. package/dist/core/retro/health-card.js +25 -0
  14. package/dist/core/retro/metrics.js +342 -0
  15. package/dist/core/retro/narrative.js +249 -0
  16. package/dist/core/retro/plane-collector.js +274 -0
  17. package/dist/core/retro/pr-issue-link.js +65 -0
  18. package/dist/core/retro/types.js +16 -0
  19. package/dist/core/sandboxing/adapter.js +29 -0
  20. package/dist/core/sandboxing/index.js +49 -0
  21. package/dist/core/sandboxing/none.js +19 -0
  22. package/dist/core/sandboxing/seatbelt.js +183 -0
  23. package/dist/core/session.js +27 -0
  24. package/dist/core/settings.js +22 -0
  25. package/dist/runtime/cli.js +167 -33
  26. package/dist/runtime/commands/mcp.js +64 -8
  27. package/dist/runtime/deprecation-warning.js +69 -0
  28. package/dist/runtime/headless.js +8 -3
  29. package/dist/runtime/stream-renderer.js +195 -0
  30. package/dist/runtime/version.js +1 -1
  31. package/dist/tui/agent-tree.js +11 -0
  32. package/dist/tui/ask-user-question-chips.js +1 -1
  33. package/dist/tui/multi-file-diff-approval.js +3 -3
  34. package/dist/tui/repl-render.js +42 -0
  35. 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
- exitCode: 0,
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: typeof e.code === 'number' ? e.code : 1,
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