@pugi/cli 0.1.0-beta.92 → 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 (41) 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 +185 -11
  4. package/dist/core/engine/prompts.js +1 -1
  5. package/dist/core/engine/tool-bridge.js +35 -0
  6. package/dist/core/engine/verification-patterns.js +195 -0
  7. package/dist/core/mcp/orchestrator-config.js +192 -0
  8. package/dist/core/mcp/orchestrator-tools.js +147 -3
  9. package/dist/core/pugi-gitignore.js +52 -0
  10. package/dist/core/repl/engine-bridge.js +199 -0
  11. package/dist/core/repl/session.js +395 -6
  12. package/dist/core/repl/tool-route.js +382 -0
  13. package/dist/core/retro/git-collector.js +251 -0
  14. package/dist/core/retro/health-card.js +25 -0
  15. package/dist/core/retro/metrics.js +342 -0
  16. package/dist/core/retro/narrative.js +249 -0
  17. package/dist/core/retro/plane-collector.js +274 -0
  18. package/dist/core/retro/pr-issue-link.js +65 -0
  19. package/dist/core/retro/types.js +16 -0
  20. package/dist/core/sandboxing/adapter.js +29 -0
  21. package/dist/core/sandboxing/index.js +49 -0
  22. package/dist/core/sandboxing/none.js +19 -0
  23. package/dist/core/sandboxing/seatbelt.js +183 -0
  24. package/dist/core/session.js +27 -0
  25. package/dist/core/settings.js +22 -0
  26. package/dist/runtime/cli.js +167 -33
  27. package/dist/runtime/commands/compact.js +1 -1
  28. package/dist/runtime/commands/config.js +1 -1
  29. package/dist/runtime/commands/mcp.js +64 -8
  30. package/dist/runtime/commands/memory.js +1 -1
  31. package/dist/runtime/deprecation-warning.js +69 -0
  32. package/dist/runtime/headless.js +8 -3
  33. package/dist/runtime/stream-renderer.js +195 -0
  34. package/dist/runtime/version.js +1 -1
  35. package/dist/skills/bundled/remember.js +2 -2
  36. package/dist/tui/agent-tree.js +11 -0
  37. package/dist/tui/ask-user-question-chips.js +1 -1
  38. package/dist/tui/multi-file-diff-approval.js +3 -3
  39. package/dist/tui/repl-render.js +42 -0
  40. package/package.json +2 -2
  41. package/test/scenarios/identity.scenario.txt +0 -1
@@ -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
@@ -40,6 +40,7 @@ import { applyRewindMask } from '../checkpoint/rewinder.js';
40
40
  import { evaluateAutoCompact } from '../compact/auto-trigger.js';
41
41
  import { estimateTokensInMany } from '../compact/token-counter.js';
42
42
  import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
43
+ import { extractToolRouteTags, } from './tool-route.js';
43
44
  import { existsSync, readdirSync, statSync } from 'node:fs';
44
45
  import { resolve as resolvePath } from 'node:path';
45
46
  import { CancellationToken } from './cancellation.js';
@@ -314,6 +315,30 @@ export class ReplSession {
314
315
  * cleaned body). Codex triple-review P2 (PR).
315
316
  */
316
317
  askBufferPending = new Set();
318
+ /**
319
+ * PUGI-538b () — pending `<pugi-tool-route>` envelope per
320
+ * coordinator taskId. Captured by `consumePugiToolRouteTag` when the
321
+ * envelope's close (or self-close) arrives in the running
322
+ * `agent.step.detail` buffer. The `agent.completed` handler reads
323
+ * the entry to decide whether to fire the engine bridge — firing
324
+ * mid-stream would race with the still-streaming coordinator turn.
325
+ * Cleared on terminal events (`completed` / `blocked` / `failed`).
326
+ *
327
+ * Only one envelope per coordinator turn is honoured (the prompt
328
+ * grammar refuses more than one); a second envelope on the same
329
+ * turn is dropped via the seen-signature rolling set so the dedupe
330
+ * lives in one place.
331
+ */
332
+ pendingToolRoutes = new Map();
333
+ /**
334
+ * PUGI-538b () — abort controllers for in-flight engine
335
+ * bridges, keyed by `bridgeId`. When the REPL operator hits stop,
336
+ * `cancel()` walks this map and aborts every active bridge so the
337
+ * engine HTTP request closes promptly (the engine loop already
338
+ * honours `AbortSignal` via `EngineContext.signal`). Entries are
339
+ * deleted on bridge completion regardless of outcome.
340
+ */
341
+ bridgeAborts = new Map();
317
342
  constructor(options) {
318
343
  this.options = options;
319
344
  this.store = options.store ?? null;
@@ -533,6 +558,21 @@ export class ReplSession {
533
558
  this.currentDispatchToken.abort();
534
559
  this.currentDispatchToken = null;
535
560
  }
561
+ // PUGI-538b () — abort every in-flight engine bridge on
562
+ // close() so the engine HTTP request closes promptly when the
563
+ // REPL itself shuts down (operator quit, process exit). Same
564
+ // defensive-try block as cancel() above; already-aborted
565
+ // controllers throw on some Node builds and close() must never
566
+ // crash the caller.
567
+ for (const controller of this.bridgeAborts.values()) {
568
+ try {
569
+ controller.abort();
570
+ }
571
+ catch {
572
+ // Best-effort.
573
+ }
574
+ }
575
+ this.bridgeAborts.clear();
536
576
  if (this.streamHandle) {
537
577
  this.streamHandle.close();
538
578
  this.streamHandle = undefined;
@@ -583,13 +623,39 @@ export class ReplSession {
583
623
  */
584
624
  cancel() {
585
625
  const current = this.fsm.current;
586
- if (this.fsm.isTerminal || current === 'idle')
587
- return false;
626
+ const hasActiveBridge = this.bridgeAborts.size > 0;
627
+ // PUGI-538b () step 4 — bridge cancellation is allowed even
628
+ // when the FSM is terminal. The bridge runs ASYNC after the
629
+ // coordinator turn completes, so by the time the operator hits stop
630
+ // the FSM has already transitioned to `completed` and the legacy
631
+ // guard would short-circuit before the bridge-abort fan-out ran.
632
+ // Without this branch, Esc after the coordinator turn settles would
633
+ // not cancel an in-flight engine call.
634
+ if (this.fsm.isTerminal || current === 'idle') {
635
+ if (!hasActiveBridge)
636
+ return false;
637
+ // Fan out abort to every active bridge, then short-circuit before
638
+ // the FSM transitions (the FSM is already terminal; there is no
639
+ // dispatch token to fire, no SSE stream to tear down for the
640
+ // current dispatch). Map entries are cleared by the bridge
641
+ // promise's then/catch handlers when they unwind.
642
+ for (const controller of this.bridgeAborts.values()) {
643
+ try {
644
+ controller.abort();
645
+ }
646
+ catch {
647
+ // Defensive: already-aborted controllers throw on some Node
648
+ // builds. cancel() must never crash the caller.
649
+ }
650
+ }
651
+ this.appendSystemLine('Bridge aborted.');
652
+ return true;
653
+ }
588
654
  // Step 2: transient state (UI sees `aborting` between abort signal
589
655
  // and full shutdown).
590
656
  this.fsm.transition('aborting', 'operator_abort');
591
657
  // Step 3: fire the token so any mid-flight tool executor that
592
- // polled `isAborted` shuts down. Token is single-use clear the
658
+ // polled `isAborted` shuts down. Token is single-use; clear the
593
659
  // ref AFTER both the abort fan-out AND the stream teardown so any
594
660
  // onAbort listener calling getCurrentDispatchToken() during the
595
661
  // teardown observes the (now-aborted) token rather than null.
@@ -618,6 +684,23 @@ export class ReplSession {
618
684
  this.lastEventId = undefined;
619
685
  // Null the token AFTER stream teardown (see step 3 comment).
620
686
  this.currentDispatchToken = null;
687
+ // PUGI-538b () step 4 — abort every in-flight engine
688
+ // bridge. The bridge's AbortController is threaded through into
689
+ // the engine loop via EngineContext.signal (runEngineLoop already
690
+ // honours AbortSignal; see packages/pugi-sdk/src/engine-loop.ts
691
+ // around line 405). Aborting closes the engine HTTP request
692
+ // promptly so REPL stop cancels the bridge end-to-end, not just
693
+ // the local coordinator turn. Map entries are cleared by the
694
+ // bridge promise's then/catch handlers when they unwind.
695
+ for (const controller of this.bridgeAborts.values()) {
696
+ try {
697
+ controller.abort();
698
+ }
699
+ catch {
700
+ // Defensive: already-aborted controllers throw on some Node
701
+ // builds. cancel() must never crash the caller.
702
+ }
703
+ }
621
704
  // Mark any agents that are still "running" as failed/aborted so
622
705
  // the agent-tree pane reflects reality. We use the existing
623
706
  // `failed` status (the tree pane already knows how to render it)
@@ -3222,6 +3305,20 @@ export class ReplSession {
3222
3305
  // the green check + duration.
3223
3306
  }
3224
3307
  }
3308
+ // PUGI-538b () — after Pugi's coordinator turn settles,
3309
+ // fire the engine bridge for any pending `<pugi-tool-route>`
3310
+ // envelope stashed by `consumeAskAndPlanReviewTags`. The bridge
3311
+ // runs ASYNCHRONOUSLY (we deliberately do not await — the SSE
3312
+ // event handler must stay fast so the next frame is not
3313
+ // delayed). `runEngineBridge` is wrapped in its own try/catch
3314
+ // so a bridge failure cannot crash the REPL.
3315
+ const pendingRoute = this.pendingToolRoutes.get(event.taskId);
3316
+ if (pendingRoute) {
3317
+ this.pendingToolRoutes.delete(event.taskId);
3318
+ void this.runEngineBridge(pendingRoute).catch((err) => {
3319
+ this.appendSystemLine(`engine bridge crashed: ${this.errorMessage(err)}`);
3320
+ });
3321
+ }
3225
3322
  return;
3226
3323
  }
3227
3324
  case 'agent.blocked': {
@@ -3232,6 +3329,11 @@ export class ReplSession {
3232
3329
  }
3233
3330
  this.askBuffer.delete(event.taskId);
3234
3331
  this.askBufferPending.delete(event.taskId);
3332
+ // PUGI-538b () — drop any pending tool-route envelope on
3333
+ // an aborted coordinator turn. Firing the bridge after the
3334
+ // operator already stopped the dispatch would silently burn
3335
+ // engine tokens for work they cancelled.
3336
+ this.pendingToolRoutes.delete(event.taskId);
3235
3337
  this.patch({
3236
3338
  agents: this.state.agents.map((a) => a.taskId === event.taskId
3237
3339
  ? { ...a, status: 'blocked', detail: event.detail }
@@ -3259,6 +3361,9 @@ export class ReplSession {
3259
3361
  }
3260
3362
  this.askBuffer.delete(event.taskId);
3261
3363
  this.askBufferPending.delete(event.taskId);
3364
+ // PUGI-538b () — drop any pending tool-route envelope on
3365
+ // an aborted/failed coordinator turn. See agent.blocked rationale.
3366
+ this.pendingToolRoutes.delete(event.taskId);
3262
3367
  this.patch({
3263
3368
  agents: this.state.agents.map((a) => a.taskId === event.taskId
3264
3369
  ? { ...a, status: 'failed', detail: event.error }
@@ -3776,12 +3881,39 @@ export class ReplSession {
3776
3881
  if (planResult.hadMalformedTag) {
3777
3882
  this.appendSystemLine('Malformed <pugi-plan-review> dropped (parser refusal).');
3778
3883
  }
3884
+ // PUGI-538b () — third envelope family: `<pugi-tool-route>`.
3885
+ // Pugi emits it on the coordinator turn when the operator's brief
3886
+ // requires workspace tool use. We strip the raw XML from the
3887
+ // operator-visible body, dedupe via the seen-tag rolling set, and
3888
+ // STASH the parsed envelope keyed by taskId. The `agent.completed`
3889
+ // handler reads the stash and fires `bridgeToEngine` — firing
3890
+ // mid-stream would race with the still-streaming coordinator turn.
3891
+ const routeResult = extractToolRouteTags(working);
3892
+ working = routeResult.cleaned;
3893
+ for (const tag of routeResult.tags) {
3894
+ if (this.seenTagSignatures.includes(tag.signature))
3895
+ continue;
3896
+ this.recordSeenTag(tag.signature);
3897
+ if (this.pendingToolRoutes.has(taskId)) {
3898
+ // Grammar says one envelope per turn. A second on the same
3899
+ // taskId is dropped to a system line so the operator can see
3900
+ // why the bridge did not fire twice.
3901
+ this.appendSystemLine('Persona emitted a second <pugi-tool-route> while one was already pending. Dropped.');
3902
+ continue;
3903
+ }
3904
+ this.pendingToolRoutes.set(taskId, tag);
3905
+ }
3906
+ if (routeResult.hadMalformedTag) {
3907
+ this.appendSystemLine('Malformed <pugi-tool-route> dropped (parser refusal).');
3908
+ }
3779
3909
  // Record / clear the "pending open tag" flag so agent.completed can
3780
3910
  // emit a warning if the persona ends the turn with an unfinished
3781
- // envelope. The flag flips OFF when both parsers report no
3782
- // outstanding open tag - if either is still pending, we keep it on
3911
+ // envelope. The flag flips OFF when ALL parsers report no
3912
+ // outstanding open tag - if any is still pending, we keep it on
3783
3913
  // so the warning fires once at turn end.
3784
- if (askResult.pendingOpenTag || planResult.pendingOpenTag) {
3914
+ if (askResult.pendingOpenTag
3915
+ || planResult.pendingOpenTag
3916
+ || routeResult.pendingOpenTag) {
3785
3917
  this.askBufferPending.add(taskId);
3786
3918
  }
3787
3919
  else {
@@ -3789,6 +3921,240 @@ export class ReplSession {
3789
3921
  }
3790
3922
  return working;
3791
3923
  }
3924
+ /**
3925
+ * PUGI-538b () — public alias for the buffer-and-strip
3926
+ * routine, kept for test ergonomics and external callers that want
3927
+ * to invoke the parser without driving a full SSE replay. Mirrors
3928
+ * the per-task buffering contract the private method already obeys.
3929
+ *
3930
+ * Exposed for the new `repl-tool-route-bridge.spec.ts` so the spec
3931
+ * can assert that a streamed envelope is parsed and stripped without
3932
+ * needing to fabricate a full agent.step / agent.completed sequence.
3933
+ */
3934
+ consumePugiToolRouteTag(taskId, detail) {
3935
+ return this.consumeAskAndPlanReviewTags(taskId, detail);
3936
+ }
3937
+ /**
3938
+ * PUGI-538b () — test-only inspector for the pending-tool-
3939
+ * route stash. Spec asserts that an envelope captured mid-stream
3940
+ * lands here and is cleared once the coordinator turn completes
3941
+ * (which fires the bridge).
3942
+ */
3943
+ pendingToolRouteForTest(taskId) {
3944
+ return this.pendingToolRoutes.get(taskId);
3945
+ }
3946
+ /**
3947
+ * PUGI-538b () — fire the engine bridge for a parsed
3948
+ * `<pugi-tool-route>` envelope. This is the CLI half of
3949
+ * Path A: the coordinator turn's envelope routes the operational
3950
+ * brief through the production engine path (NativePugiEngineAdapter
3951
+ * → runEngineLoop → POST /api/pugi/engine) so workspace tool calls
3952
+ * actually write files instead of dumping prose-only heredocs.
3953
+ *
3954
+ * The actual engine adapter wiring lives in the REPL bootstrap
3955
+ * (`repl-render.tsx`); this method only:
3956
+ * 1. surfaces a "Routing to engine" system line so the operator
3957
+ * sees the handoff,
3958
+ * 2. mints a fresh AbortController and registers it in
3959
+ * `bridgeAborts` so REPL stop can cancel the bridge,
3960
+ * 3. inserts a synthetic `agent` row keyed off a `bridge-<uuid>`
3961
+ * taskId so the agent-tree pane renders the engine turn the
3962
+ * same way it renders a sub-agent,
3963
+ * 4. invokes `engineBridge` (the injected callback) and translates
3964
+ * every `BridgedEngineEvent` into a state patch on the synthetic
3965
+ * row,
3966
+ * 5. flips the synthetic row to its terminal status when the
3967
+ * bridge resolves, surfacing the engine's final reply text (if
3968
+ * any) on a persona row so the operator sees it in the
3969
+ * transcript.
3970
+ *
3971
+ * When no `engineBridge` is provided in `ReplSessionOptions` (e.g. a
3972
+ * test that opts out, or a CLI build that has not wired the adapter
3973
+ * yet) we surface a single system-line warning explaining why the
3974
+ * brief did not write files. This degradation preserves the pre-PR
3975
+ * "see code, no file" UX without adding the "see envelope, no file"
3976
+ * surprise on top.
3977
+ */
3978
+ async runEngineBridge(tag) {
3979
+ const bridge = this.options.engineBridge;
3980
+ if (!bridge) {
3981
+ // No bridge wired — fall back to the pre-PR behaviour with one
3982
+ // additional honest sentence so the operator can see WHY no
3983
+ // files appeared. Triple-review surface: makes it obvious that
3984
+ // the regression mode is "bridge not wired in this build", not
3985
+ // "engine call failed". The brief is bounded by the parser at
3986
+ // 400 chars so this line cannot blow up the transcript.
3987
+ this.appendSystemLine(`Engine bridge not configured. Brief would have routed to ${tag.command}: "${tag.brief}".`);
3988
+ return;
3989
+ }
3990
+ const bridgeId = `bridge-${randomUUID()}`;
3991
+ const abort = new AbortController();
3992
+ this.bridgeAborts.set(bridgeId, abort);
3993
+ // Surface a system line so the operator sees the handoff before
3994
+ // engine events start flowing. The wording mirrors the prompt's
3995
+ // "Routing to engine" sentence so prompt + transcript stay in
3996
+ // lockstep.
3997
+ this.appendSystemLine(`Routing to engine (${tag.command} | ${tag.persona}): ${tag.brief}`);
3998
+ // PUGI-538b — insert a synthetic agent-tree node so the existing
3999
+ // pane renders the engine turn the same way it renders a
4000
+ // sub-agent. Role is `coder` because the engine path is the write
4001
+ // surface; the slug is the parsed persona hint so the pane shows
4002
+ // "Hiroshi" (or whichever Tier-1 the envelope asked for) instead
4003
+ // of a generic label.
4004
+ const startedAt = this.now();
4005
+ const personaName = this.resolveBridgePersonaName(tag.persona);
4006
+ const syntheticNode = {
4007
+ taskId: bridgeId,
4008
+ role: 'coder',
4009
+ personaSlug: tag.persona,
4010
+ personaName,
4011
+ status: 'thinking',
4012
+ detail: tag.brief,
4013
+ startedAtEpochMs: startedAt,
4014
+ tokensIn: 0,
4015
+ tokensOut: 0,
4016
+ };
4017
+ this.patch({ agents: [syntheticNode, ...this.state.agents] });
4018
+ const onEvent = (event) => {
4019
+ // Translate the bridge's typed events onto the synthetic
4020
+ // agent-tree node. We deliberately mirror the existing
4021
+ // agent.step / agent.tool / agent.tokens consumers above so the
4022
+ // UI surface stays uniform across delegate and bridge sub-agents.
4023
+ if (event.type === 'step') {
4024
+ this.patch({
4025
+ agents: this.state.agents.map((a) => a.taskId === bridgeId
4026
+ ? { ...a, status: 'thinking', detail: event.detail }
4027
+ : a),
4028
+ });
4029
+ }
4030
+ else if (event.type === 'tool.start') {
4031
+ const mapped = normaliseBridgedToolName(event.tool);
4032
+ if (mapped !== null) {
4033
+ this.appendToolCall({
4034
+ id: `${bridgeId}-${mapped}-${this.now()}`,
4035
+ tool: mapped,
4036
+ args: (event.args ?? '').slice(0, 80),
4037
+ agent: tag.persona,
4038
+ status: 'running',
4039
+ startedAtEpochMs: this.now(),
4040
+ });
4041
+ }
4042
+ }
4043
+ else if (event.type === 'tool.result') {
4044
+ const mapped = normaliseBridgedToolName(event.tool);
4045
+ if (mapped !== null) {
4046
+ this.appendToolCall({
4047
+ id: `${bridgeId}-${mapped}-${this.now()}`,
4048
+ tool: mapped,
4049
+ args: '',
4050
+ agent: tag.persona,
4051
+ status: event.ok ? 'ok' : 'error',
4052
+ startedAtEpochMs: this.now(),
4053
+ resultPreview: (event.preview ?? '').slice(0, RESULT_PREVIEW_MAX_CHARS),
4054
+ });
4055
+ }
4056
+ }
4057
+ else if (event.type === 'tokens') {
4058
+ const deltaCostUsd = computeCostUsd(event.tokensIn, event.tokensOut, undefined);
4059
+ this.patch({
4060
+ tokensDownstreamTotal: this.state.tokensDownstreamTotal + event.tokensIn + event.tokensOut,
4061
+ sessionTokensIn: this.state.sessionTokensIn + event.tokensIn,
4062
+ sessionTokensOut: this.state.sessionTokensOut + event.tokensOut,
4063
+ sessionCostUsd: this.state.sessionCostUsd + deltaCostUsd,
4064
+ turnTokensIn: this.state.turnTokensIn + event.tokensIn,
4065
+ turnTokensOut: this.state.turnTokensOut + event.tokensOut,
4066
+ turnCostUsd: this.state.turnCostUsd + deltaCostUsd,
4067
+ agents: this.state.agents.map((a) => a.taskId === bridgeId
4068
+ ? {
4069
+ ...a,
4070
+ tokensIn: a.tokensIn + event.tokensIn,
4071
+ tokensOut: a.tokensOut + event.tokensOut,
4072
+ }
4073
+ : a),
4074
+ });
4075
+ }
4076
+ };
4077
+ let result;
4078
+ try {
4079
+ result = await bridge({
4080
+ command: tag.command,
4081
+ persona: tag.persona,
4082
+ brief: tag.brief,
4083
+ bridgeId,
4084
+ signal: abort.signal,
4085
+ onEvent,
4086
+ });
4087
+ }
4088
+ catch (err) {
4089
+ this.bridgeAborts.delete(bridgeId);
4090
+ const message = this.errorMessage(err);
4091
+ this.patch({
4092
+ agents: this.state.agents.map((a) => a.taskId === bridgeId
4093
+ ? { ...a, status: 'failed', detail: message }
4094
+ : a),
4095
+ });
4096
+ this.appendSystemLine(`Engine bridge failed: ${message}`);
4097
+ return;
4098
+ }
4099
+ this.bridgeAborts.delete(bridgeId);
4100
+ // PUGI-538c-FU-OUTCOME (2026-06-05): the bridge outcome union now
4101
+ // carries `unverified`, which maps to the same-named agent-tree
4102
+ // status so a fresh customer repo with no test infra no longer
4103
+ // false-fails on the agent-tree pane. The verify-gate contract is
4104
+ // preserved: real `verification_command_failed` runs still surface
4105
+ // as `failed`; only `needs_verification` (no command detected)
4106
+ // downgrades to advisory.
4107
+ const terminalStatus = result.outcome === 'shipped'
4108
+ ? 'shipped'
4109
+ : result.outcome === 'unverified'
4110
+ ? 'unverified'
4111
+ : result.outcome === 'blocked'
4112
+ ? 'blocked'
4113
+ : 'failed';
4114
+ this.patch({
4115
+ agents: this.state.agents.map((a) => a.taskId === bridgeId
4116
+ ? { ...a, status: terminalStatus, detail: result.detail ?? terminalStatus }
4117
+ : a),
4118
+ });
4119
+ if (result.outcome === 'unverified') {
4120
+ // Operator-visible advisory: explain why the agent-tree node
4121
+ // landed in `unverified` rather than `shipped`. Files DID write
4122
+ // (the bridge proved that) but the gate could not certify the
4123
+ // run. Keep the wording neutral and actionable: avoid the word
4124
+ // "failed" so the operator does not lose trust in the engine.
4125
+ this.appendSystemLine('Pugi shipped files. No verification command detected; run your tests manually to confirm.');
4126
+ }
4127
+ if (result.finalText && result.finalText.trim().length > 0) {
4128
+ this.appendPersonaLine(tag.persona, result.finalText.trim());
4129
+ }
4130
+ }
4131
+ /**
4132
+ * PUGI-538b () — best-effort display-name lookup for a
4133
+ * bridge persona slug. The local frontend roster has the names; we
4134
+ * keep the resolver narrow (Tier-1 slugs only) so this method does
4135
+ * not pull in the full roster cycle. Unknown slugs fall back to a
4136
+ * title-cased version of the slug, which is the same fallback the
4137
+ * agent-tree pane uses for unrecognised persona slugs.
4138
+ */
4139
+ resolveBridgePersonaName(slug) {
4140
+ const tier1 = {
4141
+ dev: 'Hiroshi',
4142
+ qa: 'Vera',
4143
+ pm: 'Olivia',
4144
+ devops: 'Diego',
4145
+ researcher: 'Anika',
4146
+ analyst: 'Liam',
4147
+ designer: 'Sofia',
4148
+ frontend: 'Mia',
4149
+ architect: 'Marcus',
4150
+ };
4151
+ const known = tier1[slug];
4152
+ if (known)
4153
+ return known;
4154
+ if (slug.length === 0)
4155
+ return 'Engine';
4156
+ return slug.charAt(0).toUpperCase() + slug.slice(1);
4157
+ }
3792
4158
  recordSeenTag(signature) {
3793
4159
  this.seenTagSignatures.push(signature);
3794
4160
  while (this.seenTagSignatures.length > 32) {
@@ -3976,6 +4342,29 @@ function looksLikeMarkdown(text) {
3976
4342
  // 2+ bullets OR 2+ numbered OR any heading = group atomically.
3977
4343
  return bulletCount >= 2 || numberedCount >= 2 || headingCount >= 1;
3978
4344
  }
4345
+ /**
4346
+ * PUGI-538b () — normalise a bridge-reported tool name onto
4347
+ * the REPL's closed `ToolCallEntry['tool']` set. The engine surface
4348
+ * has a wider tool registry (symbols.*, mcp_*, agent, …); the REPL
4349
+ * pane only renders the seven canonical names. Unknown names return
4350
+ * null so the bridge-event consumer skips the row instead of
4351
+ * crashing on an out-of-set string.
4352
+ */
4353
+ function normaliseBridgedToolName(name) {
4354
+ const normalised = name.trim().toLowerCase();
4355
+ switch (normalised) {
4356
+ case 'read':
4357
+ case 'write':
4358
+ case 'edit':
4359
+ case 'bash':
4360
+ case 'grep':
4361
+ case 'glob':
4362
+ case 'web_fetch':
4363
+ return normalised;
4364
+ default:
4365
+ return null;
4366
+ }
4367
+ }
3979
4368
  function safePersonaName(role) {
3980
4369
  try {
3981
4370
  return getPersonaForRole(role).name;