@pugi/cli 0.1.0-beta.5 → 0.1.0-beta.51

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 (264) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -25
  3. package/assets/pugi-prozr2-mascot.ansi +9 -0
  4. package/bin/run.js +33 -1
  5. package/dist/commands/jobs-watch.js +201 -0
  6. package/dist/commands/jobs.js +15 -0
  7. package/dist/commands/smoke.js +133 -0
  8. package/dist/core/agent-progress/cleanup.js +134 -0
  9. package/dist/core/agent-progress/schema.js +144 -0
  10. package/dist/core/agent-progress/writer.js +101 -0
  11. package/dist/core/artifact-chain/dispatcher.js +148 -0
  12. package/dist/core/artifact-chain/exporter.js +164 -0
  13. package/dist/core/artifact-chain/state.js +243 -0
  14. package/dist/core/artifact-chain/steps.js +169 -0
  15. package/dist/core/auth/ensure-authenticated.js +129 -0
  16. package/dist/core/auth/env-provider.js +238 -0
  17. package/dist/core/auto-update/channels.js +122 -0
  18. package/dist/core/auto-update/checker.js +241 -0
  19. package/dist/core/auto-update/state.js +235 -0
  20. package/dist/core/bare-mode/index.js +107 -0
  21. package/dist/core/bash-classifier.js +400 -4
  22. package/dist/core/checkpoint/resumer.js +149 -0
  23. package/dist/core/checkpoint/rewinder.js +291 -0
  24. package/dist/core/codegraph/decision-store.js +248 -0
  25. package/dist/core/codegraph/detect-repo.js +459 -0
  26. package/dist/core/codegraph/install.js +134 -0
  27. package/dist/core/codegraph/offer-hook.js +220 -0
  28. package/dist/core/compact/auto-trigger.js +96 -0
  29. package/dist/core/compact/buffer-rewriter.js +115 -0
  30. package/dist/core/compact/summarizer.js +208 -0
  31. package/dist/core/compact/token-counter.js +108 -0
  32. package/dist/core/consensus/diff-capture.js +112 -3
  33. package/dist/core/context/index.js +7 -0
  34. package/dist/core/context/markdown-traverse.js +255 -0
  35. package/dist/core/cost/rate-card.js +129 -0
  36. package/dist/core/cost/tracker.js +221 -0
  37. package/dist/core/denial-tracking/index.js +8 -0
  38. package/dist/core/denial-tracking/state.js +264 -0
  39. package/dist/core/diagnostics/probe-runner.js +93 -0
  40. package/dist/core/diagnostics/probes/api.js +46 -0
  41. package/dist/core/diagnostics/probes/auth.js +86 -0
  42. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  43. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  44. package/dist/core/diagnostics/probes/config.js +72 -0
  45. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  46. package/dist/core/diagnostics/probes/disk.js +81 -0
  47. package/dist/core/diagnostics/probes/git.js +65 -0
  48. package/dist/core/diagnostics/probes/hooks.js +118 -0
  49. package/dist/core/diagnostics/probes/mcp.js +75 -0
  50. package/dist/core/diagnostics/probes/node.js +59 -0
  51. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  52. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  53. package/dist/core/diagnostics/probes/sandbox.js +40 -0
  54. package/dist/core/diagnostics/probes/session.js +74 -0
  55. package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
  56. package/dist/core/diagnostics/probes/workspace.js +63 -0
  57. package/dist/core/diagnostics/types.js +70 -0
  58. package/dist/core/dispatch/cache-cleanup.js +197 -0
  59. package/dist/core/dispatch/cache-handoff.js +295 -0
  60. package/dist/core/edits/dispatch.js +218 -2
  61. package/dist/core/edits/journal.js +199 -0
  62. package/dist/core/edits/layer-d-ast.js +557 -14
  63. package/dist/core/edits/verify-hook.js +273 -0
  64. package/dist/core/edits/worktree.js +322 -0
  65. package/dist/core/engine/anvil-client.js +115 -5
  66. package/dist/core/engine/auto-compact.js +179 -0
  67. package/dist/core/engine/budgets.js +155 -0
  68. package/dist/core/engine/context-prefix.js +155 -0
  69. package/dist/core/engine/intent.js +260 -0
  70. package/dist/core/engine/native-pugi.js +897 -211
  71. package/dist/core/engine/prompts.js +88 -2
  72. package/dist/core/engine/strip-internal-fields.js +124 -0
  73. package/dist/core/engine/tool-bridge.js +1045 -36
  74. package/dist/core/feedback/queue.js +177 -0
  75. package/dist/core/feedback/submitter.js +145 -0
  76. package/dist/core/file-cache.js +113 -1
  77. package/dist/core/hooks/events.js +44 -0
  78. package/dist/core/hooks/index.js +15 -0
  79. package/dist/core/hooks/registry.js +213 -0
  80. package/dist/core/hooks/runner.js +236 -0
  81. package/dist/core/hooks/v2/event-emitter.js +115 -0
  82. package/dist/core/hooks/v2/executor.js +282 -0
  83. package/dist/core/hooks/v2/index.js +25 -0
  84. package/dist/core/hooks/v2/lifecycle.js +104 -0
  85. package/dist/core/hooks/v2/loader.js +216 -0
  86. package/dist/core/hooks/v2/matcher.js +125 -0
  87. package/dist/core/hooks/v2/trust.js +143 -0
  88. package/dist/core/hooks/v2/types.js +86 -0
  89. package/dist/core/lsp/cache.js +105 -0
  90. package/dist/core/lsp/client.js +776 -0
  91. package/dist/core/lsp/language-detect.js +66 -0
  92. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  93. package/dist/core/mcp/client.js +75 -6
  94. package/dist/core/mcp/http-server.js +553 -0
  95. package/dist/core/mcp/orchestrator-tools.js +662 -0
  96. package/dist/core/mcp/permission.js +190 -0
  97. package/dist/core/mcp/registry.js +24 -2
  98. package/dist/core/mcp/server-tools.js +219 -0
  99. package/dist/core/mcp/server.js +397 -0
  100. package/dist/core/memory/dual-write.js +416 -0
  101. package/dist/core/memory/phase1-kinds.js +20 -0
  102. package/dist/core/memory-sync/queue.js +158 -0
  103. package/dist/core/onboarding/ensure-initialized.js +133 -0
  104. package/dist/core/onboarding/marker.js +111 -0
  105. package/dist/core/onboarding/telemetry-state.js +108 -0
  106. package/dist/core/output-style/presets.js +176 -0
  107. package/dist/core/output-style/state.js +185 -0
  108. package/dist/core/path-security.js +284 -2
  109. package/dist/core/permissions/auto-classifier.js +124 -0
  110. package/dist/core/permissions/circuit-breaker.js +83 -0
  111. package/dist/core/permissions/gate.js +278 -0
  112. package/dist/core/permissions/index.js +20 -0
  113. package/dist/core/permissions/mode.js +174 -0
  114. package/dist/core/permissions/state.js +241 -0
  115. package/dist/core/permissions/tool-class.js +93 -0
  116. package/dist/core/prd-check/parser.js +215 -0
  117. package/dist/core/prd-check/reporter.js +127 -0
  118. package/dist/core/prd-check/session-review.js +557 -0
  119. package/dist/core/prd-check/verifiers.js +223 -0
  120. package/dist/core/pugi-md/context-injector.js +76 -0
  121. package/dist/core/pugi-md/walk-up.js +207 -0
  122. package/dist/core/release-notes/parser.js +241 -0
  123. package/dist/core/release-notes/state.js +116 -0
  124. package/dist/core/repl/history.js +11 -1
  125. package/dist/core/repl/model-pricing.js +135 -0
  126. package/dist/core/repl/session.js +1897 -37
  127. package/dist/core/repl/slash-commands.js +430 -15
  128. package/dist/core/repl/store/session-store.js +31 -2
  129. package/dist/core/repl/workspace-context.js +22 -0
  130. package/dist/core/repo-map/build.js +125 -0
  131. package/dist/core/repo-map/cache.js +185 -0
  132. package/dist/core/repo-map/extractor.js +254 -0
  133. package/dist/core/repo-map/formatter.js +145 -0
  134. package/dist/core/repo-map/scanner.js +211 -0
  135. package/dist/core/retry-budget/budget.js +284 -0
  136. package/dist/core/retry-budget/index.js +5 -0
  137. package/dist/core/session.js +92 -0
  138. package/dist/core/settings.js +80 -0
  139. package/dist/core/share/formatter.js +271 -0
  140. package/dist/core/share/redactor.js +221 -0
  141. package/dist/core/share/uploader.js +267 -0
  142. package/dist/core/skills/defaults.js +457 -0
  143. package/dist/core/smoke/headless-driver.js +174 -0
  144. package/dist/core/smoke/orchestrator.js +194 -0
  145. package/dist/core/smoke/runner.js +238 -0
  146. package/dist/core/smoke/scenario-parser.js +316 -0
  147. package/dist/core/subagents/dispatcher-real.js +600 -0
  148. package/dist/core/subagents/dispatcher.js +113 -24
  149. package/dist/core/subagents/index.js +18 -5
  150. package/dist/core/subagents/isolation-matrix.js +213 -0
  151. package/dist/core/subagents/spawn.js +19 -4
  152. package/dist/core/telemetry/emitter.js +229 -0
  153. package/dist/core/telemetry/queue.js +251 -0
  154. package/dist/core/theme/context.js +91 -0
  155. package/dist/core/theme/presets.js +228 -0
  156. package/dist/core/theme/state.js +181 -0
  157. package/dist/core/todos/invariant.js +10 -0
  158. package/dist/core/todos/state.js +177 -0
  159. package/dist/core/transport/version-interceptor.js +166 -0
  160. package/dist/core/vim/keymap.js +288 -0
  161. package/dist/core/vim/state.js +92 -0
  162. package/dist/core/worktree-manager/cleanup.js +123 -0
  163. package/dist/core/worktree-manager/manager.js +303 -0
  164. package/dist/index.js +28 -0
  165. package/dist/runtime/bootstrap.js +190 -0
  166. package/dist/runtime/cli.js +3241 -343
  167. package/dist/runtime/commands/cancel.js +231 -0
  168. package/dist/runtime/commands/chain.js +489 -0
  169. package/dist/runtime/commands/codegraph-status.js +227 -0
  170. package/dist/runtime/commands/compact.js +297 -0
  171. package/dist/runtime/commands/cost.js +199 -0
  172. package/dist/runtime/commands/delegate.js +242 -11
  173. package/dist/runtime/commands/dispatch.js +126 -0
  174. package/dist/runtime/commands/doctor.js +412 -0
  175. package/dist/runtime/commands/feedback.js +184 -0
  176. package/dist/runtime/commands/hooks.js +184 -0
  177. package/dist/runtime/commands/lsp.js +368 -0
  178. package/dist/runtime/commands/mcp.js +879 -0
  179. package/dist/runtime/commands/memory.js +508 -0
  180. package/dist/runtime/commands/model.js +237 -0
  181. package/dist/runtime/commands/onboarding.js +275 -0
  182. package/dist/runtime/commands/patch.js +128 -0
  183. package/dist/runtime/commands/permissions.js +112 -0
  184. package/dist/runtime/commands/plan.js +143 -0
  185. package/dist/runtime/commands/prd-check.js +285 -0
  186. package/dist/runtime/commands/redo-blob-store.js +92 -0
  187. package/dist/runtime/commands/redo.js +361 -0
  188. package/dist/runtime/commands/release-notes.js +229 -0
  189. package/dist/runtime/commands/repo-map.js +95 -0
  190. package/dist/runtime/commands/report.js +299 -0
  191. package/dist/runtime/commands/resume.js +118 -0
  192. package/dist/runtime/commands/review-consensus.js +17 -2
  193. package/dist/runtime/commands/rewind.js +333 -0
  194. package/dist/runtime/commands/sessions.js +163 -0
  195. package/dist/runtime/commands/share.js +316 -0
  196. package/dist/runtime/commands/status.js +186 -0
  197. package/dist/runtime/commands/stickers.js +82 -0
  198. package/dist/runtime/commands/style.js +194 -0
  199. package/dist/runtime/commands/theme.js +196 -0
  200. package/dist/runtime/commands/undo.js +32 -0
  201. package/dist/runtime/commands/update.js +289 -0
  202. package/dist/runtime/commands/vim.js +140 -0
  203. package/dist/runtime/commands/worktree.js +177 -0
  204. package/dist/runtime/commands/worktrees.js +155 -0
  205. package/dist/runtime/headless-repl.js +195 -0
  206. package/dist/runtime/headless.js +543 -0
  207. package/dist/runtime/load-hooks-or-exit.js +71 -0
  208. package/dist/runtime/plan-decompose.js +531 -0
  209. package/dist/runtime/version.js +65 -0
  210. package/dist/tools/agent-tool.js +229 -0
  211. package/dist/tools/apply-patch.js +556 -0
  212. package/dist/tools/ask-user-question.js +213 -0
  213. package/dist/tools/ask-user.js +115 -0
  214. package/dist/tools/bash.js +203 -4
  215. package/dist/tools/file-tools.js +85 -14
  216. package/dist/tools/lsp-tools.js +189 -0
  217. package/dist/tools/mcp-tool.js +260 -0
  218. package/dist/tools/multi-edit.js +361 -0
  219. package/dist/tools/powershell.js +268 -0
  220. package/dist/tools/registry.js +51 -0
  221. package/dist/tools/skill-tool.js +96 -0
  222. package/dist/tools/tasks.js +208 -0
  223. package/dist/tools/todo-write.js +184 -0
  224. package/dist/tools/web-fetch.js +147 -2
  225. package/dist/tools/web-search.js +458 -0
  226. package/dist/tui/agent-progress-card.js +111 -0
  227. package/dist/tui/agent-tree.js +10 -0
  228. package/dist/tui/ask-modal.js +2 -2
  229. package/dist/tui/ask-user-question-prompt.js +192 -0
  230. package/dist/tui/compact-banner.js +81 -0
  231. package/dist/tui/conversation-pane.js +82 -8
  232. package/dist/tui/cost-table.js +111 -0
  233. package/dist/tui/doctor-table.js +46 -0
  234. package/dist/tui/feedback-prompt.js +156 -0
  235. package/dist/tui/input-box.js +218 -3
  236. package/dist/tui/markdown-render.js +4 -4
  237. package/dist/tui/onboarding-wizard.js +240 -0
  238. package/dist/tui/permissions-picker.js +86 -0
  239. package/dist/tui/render.js +35 -0
  240. package/dist/tui/repl-render.js +313 -35
  241. package/dist/tui/repl-splash-art.js +1 -1
  242. package/dist/tui/repl-splash-mascot.js +32 -8
  243. package/dist/tui/repl-splash.js +2 -2
  244. package/dist/tui/repl.js +85 -5
  245. package/dist/tui/splash.js +1 -1
  246. package/dist/tui/status-bar.js +94 -16
  247. package/dist/tui/status-table.js +7 -0
  248. package/dist/tui/stickers-art.js +136 -0
  249. package/dist/tui/style-table.js +28 -0
  250. package/dist/tui/theme-table.js +29 -0
  251. package/dist/tui/thinking-spinner.js +123 -0
  252. package/dist/tui/tool-stream-pane.js +52 -3
  253. package/dist/tui/update-banner.js +27 -2
  254. package/dist/tui/vim-input.js +267 -0
  255. package/dist/tui/welcome-banner.js +107 -0
  256. package/dist/tui/welcome-data.js +293 -0
  257. package/docs/examples/codegraph.mcp.json +10 -0
  258. package/package.json +13 -7
  259. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  260. package/test/scenarios/compact-force.scenario.txt +11 -0
  261. package/test/scenarios/identity.scenario.txt +11 -0
  262. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  263. package/test/scenarios/walkback.scenario.txt +12 -0
  264. package/dist/core/engine/compaction-hook.js +0 -154
@@ -1,11 +1,30 @@
1
1
  import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { resolve } from 'node:path';
3
- import { defaultEngineBudgets, runEngineLoop, } from '@pugi/sdk';
4
+ import { AsyncEventQueue, EngineEventEmitter, modelSupportsThinking, runEngineLoop, splitThinkingBlocks, } from '@pugi/sdk';
4
5
  import { FileReadCache } from '../file-cache.js';
5
6
  import { loadSettings } from '../settings.js';
6
7
  import { openSession, recordToolCall, recordToolResult } from '../session.js';
8
+ import { prewarmRealDispatch } from '../subagents/dispatcher.js';
9
+ import { resolveAutoCompactConfig, resolveBudget } from './budgets.js';
10
+ import { maybeCompact } from './auto-compact.js';
7
11
  import { buildExecutor, buildToolsSchema } from './tool-bridge.js';
8
12
  import { personaSlugFor, systemPromptFor } from './prompts.js';
13
+ import { CancellationToken } from '../repl/cancellation.js';
14
+ // β5a R5+R6 + P1 (2026-05-26): per-turn `<context>` prefix + intent
15
+ // classifier marker. Both pure functions, no fs cost at adapter init.
16
+ // Per-dir markdown traverse fires once per `run()`; budget capped so
17
+ // it never dominates the prompt budget.
18
+ import { buildContextPrefix, spliceContextPrefix } from './context-prefix.js';
19
+ import { applyIntentMarker, classifyIntent } from './intent.js';
20
+ import { loadTraversedMarkdown } from '../context/markdown-traverse.js';
21
+ import { isBareMode } from '../bare-mode/index.js';
22
+ import { walkUpPugiMd } from '../pugi-md/walk-up.js';
23
+ import { renderAmbientContext } from '../pugi-md/context-injector.js';
24
+ // α7 L11 (2026-05-27): per-session DenialTrackingState. One instance
25
+ // per `run()` so denials cluster by (tool, args) within the same
26
+ // command but do NOT leak across CLI invocations.
27
+ import { DenialTrackingState } from '../denial-tracking/state.js';
9
28
  /**
10
29
  * Real `NativePugiEngineAdapter`. Drives the Pugi CLI's tool-use loop:
11
30
  *
@@ -50,8 +69,30 @@ export class NativePugiEngineAdapter {
50
69
  * to a single `run()` invocation.
51
70
  */
52
71
  engineToolCallIds = new Map();
72
+ /**
73
+ * β3 streaming additive: optional typed event emitter that mirrors
74
+ * every async-queue event so external consumers (admin-api SSE
75
+ * controller, future cabinet WebSocket relay) can attach without
76
+ * holding the async iterator. The CLI itself only consumes the
77
+ * `AsyncIterable<EngineEvent>` returned by `run()`; the emitter is
78
+ * a fan-out point for additional subscribers.
79
+ */
80
+ streamEmitter = new EngineEventEmitter();
53
81
  constructor(options) {
54
82
  this.options = options;
83
+ // β2a r1 (Backend Architect P1, 2026-05-26): kick off the real
84
+ // dispatcher's module import at adapter init so the first
85
+ // `agent` tool call does not pay 50-200ms cold-start. We fire
86
+ // the promise without awaiting — by the time the engine loop
87
+ // runs and the model issues an `agent` call, the import has
88
+ // resolved. The promise is swallowed because a failed prewarm
89
+ // would surface again at dispatch time with the real error.
90
+ void prewarmRealDispatch().catch(() => {
91
+ // Intentional no-op: the actual dispatch call will surface
92
+ // the import failure (if any) with the right call stack. A
93
+ // prewarm-time failure is just a missed optimization, not a
94
+ // correctness issue.
95
+ });
55
96
  }
56
97
  async capabilities() {
57
98
  return {
@@ -59,7 +100,13 @@ export class NativePugiEngineAdapter {
59
100
  supportsFileEdits: true,
60
101
  supportsShell: true,
61
102
  supportsLsp: false,
62
- supportsSubagents: false,
103
+ // β2 S2 (2026-05-26): real subagent dispatch shipped via the
104
+ // `agent` tool (apps/pugi-cli/src/tools/agent-tool.ts) plus the
105
+ // genuine `runEngineLoop`-backed dispatcher
106
+ // (apps/pugi-cli/src/core/subagents/dispatcher-real.ts). The
107
+ // capability flag flips after S1 + S3 + S4 land so cabinet UI +
108
+ // remote orchestrators can rely on the advertised contract.
109
+ supportsSubagents: true,
63
110
  };
64
111
  }
65
112
  async *run(task, ctx) {
@@ -67,235 +114,776 @@ export class NativePugiEngineAdapter {
67
114
  const root = task.workspaceRoot;
68
115
  const session = this.options.session ?? openSession(root);
69
116
  const settings = loadSettings(root);
70
- const toolCtx = {
71
- root,
72
- settings,
73
- session,
74
- readCache: new FileReadCache(),
75
- };
76
- const budget = task.budget?.tokens
77
- ? {
78
- maxTokens: task.budget.tokens,
79
- // The task-level budget only carries tokens; tool calls keep
80
- // the per-command default so a careless caller cannot disable
81
- // the call-count guard by overriding usd/tokens.
82
- maxToolCalls: defaultEngineBudgets[kind].maxToolCalls,
117
+ // P1 fix (deep audit 2026-05-26): wire ctx.signal (AbortSignal) into
118
+ // a CancellationToken so the tool-bridge cancellation gate
119
+ // (`ctx.cancellation?.isAborted` check at tool-bridge.ts:656 +
120
+ // file-tools `gateOnCancellation` calls) fires when the operator
121
+ // aborts mid-tool. Before this fix `toolCtx` carried no cancellation
122
+ // field — only the next runEngineLoop iteration via `ctx.signal`
123
+ // aborted at the turn boundary, so a long-running tool (a sleeping
124
+ // bash command, a slow grep across the repo) could not be cancelled
125
+ // mid-call.
126
+ //
127
+ // The token is wired one-way: ctx.signal -> token. Aborting the
128
+ // token directly does NOT propagate back to the AbortSignal; the
129
+ // engine's own cancellation already lives upstream via the signal
130
+ // so the back-edge is unnecessary.
131
+ //
132
+ // r2 fix (triple-review 2026-05-26 P1): the abort listener was
133
+ // registered with `{ once: true }` — on actual abort it auto-detaches
134
+ // and disappears, but on the (common) NON-abort path where `run()`
135
+ // completes cleanly the listener stays attached to `ctx.signal`
136
+ // forever. Over a long REPL session (one shared AbortController per
137
+ // session, many run() invocations) listeners accumulate one per
138
+ // run, leaking memory and CPU on `dispatchEvent`. We now track the
139
+ // detach handle and call it unconditionally in the run()'s finally
140
+ // block so cleanup happens on both the success and abort paths.
141
+ const cancellation = new CancellationToken();
142
+ let detachAbortListener;
143
+ if (ctx.signal) {
144
+ if (ctx.signal.aborted) {
145
+ cancellation.abort();
83
146
  }
84
- : defaultEngineBudgets[kind];
85
- yield {
86
- type: 'status',
87
- message: `Pugi engine starting: kind=${kind} budget=${budget.maxToolCalls} calls / ${budget.maxTokens} tokens`,
88
- };
89
- // Buffer status events emitted from inside the loop hooks. Async
90
- // generators cannot yield from synchronous callbacks, so we collect
91
- // them in a queue and drain after the loop call completes. The loop
92
- // is short enough ( ~30 turns) that latency-to-stdout is acceptable
93
- // a follow-up PR can switch to an event emitter for true streaming.
94
- const buffer = [];
95
- // Track files mutated by the loop. We extract the path from the JSON
96
- // arguments of every successful write/edit tool call; `bash` is left
97
- // out because its filesystem footprint is opaque (a single command
98
- // can touch dozens of paths via `make`, `pnpm build`, etc). The
99
- // per-session events.jsonl already carries every file_mutation event
100
- // for replay; this set is only the headline summary the CLI prints.
101
- const filesChanged = new Set();
102
- // Pending lookup: call.id → path extracted from arguments. We only
103
- // commit to `filesChanged` when the corresponding onToolResult fires
104
- // with `ok: true`, so a refused or failed edit does not surface as
105
- // a phantom change in the operator summary.
106
- const pendingMutations = new Map();
107
- // Per-session events mirror — `.pugi/sessions/<id>/events.jsonl`.
108
- // The existing global log at `.pugi/events.jsonl` is preserved as
109
- // the audit-replay source of truth; this mirror is the easy-to-find
110
- // per-run log for operators and the cabinet UI (Sprint 2B).
111
- const sessionEventsPath = openSessionMirror(root, session.id);
112
- const hooks = {
113
- onTurnStart: (turnIndex, messageCount) => {
114
- const msg = `turn ${turnIndex + 1}: requesting model (transcript=${messageCount} messages)`;
115
- buffer.push({ type: 'status', message: msg });
116
- appendSessionMirror(sessionEventsPath, { type: 'turn_start', turn: turnIndex + 1, transcript: messageCount });
117
- },
118
- onTurnComplete: (turnIndex, response) => {
119
- if (response.stop === 'tool_use') {
120
- const calls = response.assistantMessage.toolCalls ?? [];
121
- buffer.push({
122
- type: 'status',
123
- message: `turn ${turnIndex + 1}: model requested ${calls.length} tool call(s)`,
124
- });
125
- appendSessionMirror(sessionEventsPath, {
126
- type: 'turn_complete',
127
- turn: turnIndex + 1,
128
- stop: 'tool_use',
129
- toolCalls: calls.length,
130
- tokensUsed: response.tokensUsed,
131
- });
147
+ else {
148
+ const handler = () => cancellation.abort();
149
+ ctx.signal.addEventListener('abort', handler, { once: true });
150
+ detachAbortListener = () => {
151
+ ctx.signal.removeEventListener('abort', handler);
152
+ };
153
+ }
154
+ }
155
+ // r2 (triple-review 2026-05-26 P1): everything below runs inside a
156
+ // try/finally so the AbortSignal listener detaches on BOTH the
157
+ // success and abort paths. Without this wrap a long REPL session
158
+ // (one persistent AbortController, many run() invocations) leaked
159
+ // one abort listener per non-aborted run.
160
+ try {
161
+ const toolCtx = {
162
+ root,
163
+ settings,
164
+ session,
165
+ readCache: new FileReadCache(),
166
+ cancellation,
167
+ };
168
+ // α7 L11 (2026-05-27): instantiate per-`run()` denial tracker. The
169
+ // executor records every refusal (PLAN_MODE_REFUSED, HOOK_BLOCKED,
170
+ // OPERATOR_ABORTED, STALE_READ, unknown-tool, plan-mode agent) and
171
+ // the user-prompt assembler below splices a compact reminder when
172
+ // the same (tool, args) pair has been denied twice or more. The
173
+ // tracker is in-memory only the audit ledger at
174
+ // `.pugi/events.jsonl` already captures the full per-event log for
175
+ // forensic replay; this surface is the model-facing aggregate.
176
+ const denialTracking = new DenialTrackingState();
177
+ // β1a r1 (budget wiring, 2026-05-26): swap the legacy SDK per-
178
+ // command budget lookup for the Pl9 `resolveBudget()` pipeline so
179
+ // `.pugi/settings.json::budgets.<command>` overrides actually take
180
+ // effect at runtime + the HARD_MAX_* caps guard misconfigured
181
+ // envelopes pre-flight. Before this fix the β1 Pl9 module
182
+ // (`core/engine/budgets.ts`) was dead code — the adapter still
183
+ // read the per-command defaults from the SDK, so operators who
184
+ // set `budgets.code.maxTokens = 50000` in settings.json got the
185
+ // legacy 30k anyway and `assertBudgetWithinTier` never ran.
186
+ //
187
+ // Task-level token override (e.g. CLI `--max-tokens`) keeps
188
+ // precedence; tool-call ceiling falls through to the resolved
189
+ // budget so a careless caller cannot disable the call-count
190
+ // guard by setting only token count.
191
+ const budget = resolveBudget(kind, settings, task.budget?.tokens ? { maxTokens: task.budget.tokens } : undefined);
192
+ // CEO P1 #14 (auto-compact, 2026-05-29): resolve the per-workspace
193
+ // override of the 75% threshold gate. Default is `{ enabled: true,
194
+ // thresholdRatio: 0.75 }`; operators kill it via
195
+ // `.pugi/settings.json::autoCompact.enabled = false` или retune the
196
+ // ratio. The resolved config is captured by the closure that
197
+ // `runEngineLoop` invokes pre-send on every turn.
198
+ const autoCompactConfig = resolveAutoCompactConfig(settings);
199
+ // β3 streaming: pre-build the typed stream event queue so the hook
200
+ // callbacks below can push live events that this async generator
201
+ // yields IMMEDIATELY (instead of buffering until `runEngineLoop`
202
+ // completes). Operator now sees the first `tool.start` within
203
+ // ~tens of ms of the model emitting it, not 30+ s after the loop
204
+ // settles.
205
+ const streamQueue = new AsyncEventQueue();
206
+ const emitter = this.streamEmitter;
207
+ const supportsThinking = modelSupportsThinking(this.options.model);
208
+ /**
209
+ * Push one typed stream event into BOTH the per-run async queue
210
+ * (the CLI's iterator) and the long-lived emitter (the multiplex
211
+ * fan-out for admin-api SSE / cabinet WebSocket subscribers).
212
+ * The function stamps `timestamp` once so both consumers see the
213
+ * same wall clock.
214
+ */
215
+ const emitStream = (event) => {
216
+ const stamped = {
217
+ ...event,
218
+ timestamp: new Date().toISOString(),
219
+ };
220
+ streamQueue.push(stamped);
221
+ emitter.emit('event', stamped);
222
+ };
223
+ // r1 fix per triple-review Backend Architect P1: unify yield path via
224
+ // emitStream + streamQueue drain so the iterator consumer does NOT
225
+ // see this status frame twice. Pre-fix did both bare yield + emitStream
226
+ // → iterator got 2 copies, emitter got 1.
227
+ emitStream({
228
+ type: 'status',
229
+ message: `Pugi engine starting: kind=${kind} budget=${budget.maxToolCalls} calls / ${budget.maxTokens} tokens`,
230
+ });
231
+ // β5a R1+R4+R5+R6+P1 (2026-05-26): build the per-turn `<context>`
232
+ // prefix and apply the intent marker so the model sees:
233
+ // 1. cwd + open-files + per-dir-conventions block (R5+R6)
234
+ // 2. a `<intent kind="definitional">` wrapper when the operator
235
+ // asked a knowledge question (P1) — fixes the "What is grep?
236
+ // → bash man grep" loss mode flagged by the α7.X eval.
237
+ //
238
+ // All caps enforced inside the builders (5 KB block + 50 entries
239
+ // + top-3 markdown). Worst-case prompt growth is ~5 KB, well
240
+ // inside any per-command token budget.
241
+ //
242
+ // cwd is sourced from `process.cwd()` — the operator's shell pwd
243
+ // when they invoked `pugi`. For non-REPL CLI paths this is
244
+ // accurate; the REPL session retains the launch cwd for the
245
+ // lifetime of the session which is what the operator expects.
246
+ const cwdForTraverse = process.cwd();
247
+ // Leak L32 (2026-05-27): cwd → homedir walk-up that picks up every
248
+ // ambient `PUGI.md` (or `CLAUDE.md` as a fallback) the operator
249
+ // has placed above their workspace. This is the cross-project
250
+ // hierarchy walk — distinct from the workspace-bounded
251
+ // `loadTraversedMarkdown` below which only sees files INSIDE the
252
+ // workspace root. Render the concatenation once at session boot
253
+ // and prepend to the system prompt so the model treats the
254
+ // operator's personal guidance as ambient context for the whole
255
+ // session. `--bare` (Leak L22) skips this walk entirely.
256
+ let ambientContextBlock = '';
257
+ if (!isBareMode()) {
258
+ try {
259
+ const hierarchy = walkUpPugiMd(cwdForTraverse);
260
+ ambientContextBlock = renderAmbientContext(hierarchy);
132
261
  }
133
- else if (response.stop === 'text') {
134
- buffer.push({
135
- type: 'status',
136
- message: `turn ${turnIndex + 1}: model returned final text (${response.content.length} chars)`,
262
+ catch {
263
+ // Pure FS surface — if it throws (programmer error in the
264
+ // walker, not a per-file fs error which is already swallowed
265
+ // inside) we drop ambient context for this session rather
266
+ // than crashing the engine loop. Doctor probe still surfaces
267
+ // the hierarchy state for operator triage.
268
+ ambientContextBlock = '';
269
+ }
270
+ }
271
+ // Leak L28 (2026-05-27): AST-light repo-map injection. We build a
272
+ // compact `## Repo map` block (capped at the formatter's default
273
+ // 8 KB ≈ 2K tokens) from the workspace source tree + splice it
274
+ // onto the system prompt alongside the ambient PUGI.md block.
275
+ // `--bare` skips this exactly like the PUGI.md walk — the engine
276
+ // sees nothing the operator did not explicitly hand it. The build
277
+ // is deferred к `setImmediate` semantics by being a sync call
278
+ // AFTER the boot probes; the cost is one stat per source file
279
+ // (the cache catches mtime-unchanged files и skips re-extraction).
280
+ // Failures are swallowed: repo-map is enrichment, never a gate.
281
+ let repoMapBlock = '';
282
+ if (!isBareMode()) {
283
+ try {
284
+ const { buildAndFormatRepoMap } = await import('../repo-map/build.js');
285
+ const verdict = buildAndFormatRepoMap({
286
+ root,
287
+ // Boot path is best-effort: never refresh during engine boot
288
+ // (the operator can `pugi repo-map --refresh` manually). The
289
+ // cache freshness check catches every realistic edit pattern
290
+ // and avoids walking the tree on every engine invocation.
291
+ refresh: false,
292
+ // Persist the cache so the next boot reuses extracts. Engine
293
+ // boot runs on every command, so missing the persist would
294
+ // hot-loop the extractor on each invocation.
295
+ writeCache: true,
296
+ // Omit the formatter's section header — the system prompt
297
+ // already structures the ambient blocks, и a second `##`
298
+ // would fragment the prompt cache на a model-by-model basis.
299
+ omitHeader: false,
137
300
  });
138
- appendSessionMirror(sessionEventsPath, {
139
- type: 'turn_complete',
140
- turn: turnIndex + 1,
141
- stop: 'text',
142
- contentLength: response.content.length,
143
- tokensUsed: response.tokensUsed,
301
+ if (verdict.build.ok && verdict.format && verdict.format.bytes > 0) {
302
+ repoMapBlock = verdict.format.text;
303
+ }
304
+ }
305
+ catch {
306
+ // Any failure in the repo-map pipeline drops the block. The
307
+ // engine continues without enrichment — the failure mode is
308
+ // identical to the cold-boot path before L28 landed.
309
+ repoMapBlock = '';
310
+ }
311
+ }
312
+ let traverseResult;
313
+ // Leak L22 (2026-05-27): `--bare` skips the parent-dir PUGI.md /
314
+ // AGENTS.md / CLAUDE.md / GEMINI.md walk-up. The engine sees only
315
+ // the operator's prompt + working-set + intent marker, with no
316
+ // ambient project context injection. Mirrors Claude Code's
317
+ // --bare semantics.
318
+ if (isBareMode()) {
319
+ traverseResult = { loaded: [], warnings: [], totalBytes: 0 };
320
+ }
321
+ else {
322
+ try {
323
+ traverseResult = await loadTraversedMarkdown({
324
+ cwd: cwdForTraverse,
325
+ workspaceRoot: root,
144
326
  });
145
327
  }
146
- },
147
- onToolCall: (call) => {
148
- // Record under an `engine_tool` prefix so the audit log can
149
- // distinguish loop-driven calls from direct CLI tool calls.
150
- const id = recordToolCall(session, `engine:${call.name}`, call.arguments.slice(0, 200));
151
- // Stash the audit id on the call for `onToolResult` to close.
152
- this.engineToolCallIds.set(call.id, id);
153
- // Extract a candidate path for write/edit so we can build the
154
- // filesChanged summary if (and only if) the call succeeds. Bad
155
- // JSON is harmless here — we ignore it and the executor surfaces
156
- // the actual parse error to the model.
157
- if (call.name === 'write' || call.name === 'edit') {
158
- const path = extractPathArg(call.arguments);
159
- if (path)
160
- pendingMutations.set(call.id, path);
328
+ catch {
329
+ // Per-dir markdown is a NICE-TO-HAVE; a fs error here must
330
+ // never break the engine loop. Fall back to an empty result
331
+ // so the prefix block still surfaces cwd + working set.
332
+ traverseResult = { loaded: [], warnings: [], totalBytes: 0 };
161
333
  }
162
- buffer.push({
334
+ }
335
+ const intentClassification = classifyIntent(task.prompt);
336
+ const intentHint = intentClassification.intent !== 'ambiguous' ? intentClassification.intent : undefined;
337
+ const cwdRelative = relativeOrAbsolute(root, cwdForTraverse);
338
+ const prefix = buildContextPrefix({
339
+ cwdRelative,
340
+ // β5a defers wiring the live WorkingSet snapshot to the REPL
341
+ // session integration (R5+R6 here only covers the engine-side
342
+ // builder). When the REPL passes its working set down, the
343
+ // engine surface fills in. For now the prefix carries cwd +
344
+ // per-dir conventions + intent which are the two biggest
345
+ // win-rate moves per the α7.X eval.
346
+ traversedMarkdown: traverseResult.loaded,
347
+ intentHint,
348
+ });
349
+ if (prefix.bytes > 0 || intentClassification.intent === 'definitional') {
350
+ emitStream({
163
351
  type: 'status',
164
- message: `tool_call: ${call.name}(${call.arguments.slice(0, 80)}${call.arguments.length > 80 ? '...' : ''})`,
165
- });
166
- appendSessionMirror(sessionEventsPath, {
167
- type: 'tool_call',
168
- tool: call.name,
169
- callId: call.id,
170
- argsPreview: call.arguments.slice(0, 200),
352
+ message: `context: cwd=${cwdRelative} per-dir-md=${prefix.counts.markdownIncluded}/${prefix.counts.markdownTotal} intent=${intentClassification.intent}`,
171
353
  });
172
- },
173
- onToolResult: (call, result) => {
174
- const auditId = this.engineToolCallIds.get(call.id);
175
- if (auditId) {
176
- if (result.ok) {
177
- recordToolResult(session, auditId, 'success', result.content.slice(0, 200));
354
+ }
355
+ const decoratedPrompt = applyIntentMarker(task.prompt, intentClassification.intent);
356
+ const finalUserPrompt = spliceContextPrefix(prefix.block, decoratedPrompt);
357
+ // Track files mutated by the loop. We extract the path from the JSON
358
+ // arguments of every successful write/edit tool call; `bash` is left
359
+ // out because its filesystem footprint is opaque (a single command
360
+ // can touch dozens of paths via `make`, `pnpm build`, etc). The
361
+ // per-session events.jsonl already carries every file_mutation event
362
+ // for replay; this set is only the headline summary the CLI prints.
363
+ const filesChanged = new Set();
364
+ // Pending lookup: call.id → path extracted from arguments. We only
365
+ // commit to `filesChanged` when the corresponding onToolResult fires
366
+ // with `ok: true`, so a refused or failed edit does not surface as
367
+ // a phantom change in the operator summary.
368
+ const pendingMutations = new Map();
369
+ // Per-session events mirror — `.pugi/sessions/<id>/events.jsonl`.
370
+ // The existing global log at `.pugi/events.jsonl` is preserved as
371
+ // the audit-replay source of truth; this mirror is the easy-to-find
372
+ // per-run log for operators and the cabinet UI (Sprint 2B).
373
+ const sessionEventsPath = openSessionMirror(root, session.id);
374
+ const hooks = {
375
+ // CEO P1 #14 (auto-compact, 2026-05-29): single operator-visible
376
+ // line on stderr — keep parity with Claude Code's
377
+ // `Compacted N turns into Y tokens; continuing.` message. We mirror
378
+ // the event into the session log + stream emitter as a `status`
379
+ // frame так that admin-api SSE consumers + the cabinet UI render
380
+ // it without a schema change.
381
+ onAutoCompact: (event) => {
382
+ const pct = Math.round((event.preUsedTokens / Math.max(1, event.maxTokens)) * 100);
383
+ const line = `engine: auto-compacted ${event.droppedCount} turns at ${event.preUsedTokens}/${event.maxTokens} (${pct}%)`;
384
+ // Single-line stderr write — operator-visible per spec.
385
+ process.stderr.write(`${line}\n`);
386
+ emitStream({ type: 'status', message: line });
387
+ appendSessionMirror(sessionEventsPath, {
388
+ type: 'auto_compact',
389
+ droppedCount: event.droppedCount,
390
+ preUsedTokens: event.preUsedTokens,
391
+ postUsedTokens: event.postUsedTokens,
392
+ maxTokens: event.maxTokens,
393
+ gist: event.gist,
394
+ });
395
+ },
396
+ onTurnStart: (turnIndex, messageCount) => {
397
+ const msg = `turn ${turnIndex + 1}: requesting model (transcript=${messageCount} messages)`;
398
+ emitStream({ type: 'status', message: msg });
399
+ appendSessionMirror(sessionEventsPath, { type: 'turn_start', turn: turnIndex + 1, transcript: messageCount });
400
+ },
401
+ onTurnComplete: (turnIndex, response) => {
402
+ if (response.stop === 'tool_use') {
403
+ const calls = response.assistantMessage.toolCalls ?? [];
404
+ emitStream({
405
+ type: 'status',
406
+ message: `turn ${turnIndex + 1}: model requested ${calls.length} tool call(s)`,
407
+ });
408
+ appendSessionMirror(sessionEventsPath, {
409
+ type: 'turn_complete',
410
+ turn: turnIndex + 1,
411
+ stop: 'tool_use',
412
+ toolCalls: calls.length,
413
+ tokensUsed: response.tokensUsed,
414
+ });
415
+ }
416
+ else if (response.stop === 'text') {
417
+ emitStream({
418
+ type: 'status',
419
+ message: `turn ${turnIndex + 1}: model returned final text (${response.content.length} chars)`,
420
+ });
421
+ appendSessionMirror(sessionEventsPath, {
422
+ type: 'turn_complete',
423
+ turn: turnIndex + 1,
424
+ stop: 'text',
425
+ contentLength: response.content.length,
426
+ tokensUsed: response.tokensUsed,
427
+ });
428
+ // β3 E4 thinking-block surface: only Claude / Gemini families
429
+ // advertise structured thinking today. The model resolver may
430
+ // return a slug we don't recognise; in that case we skip the
431
+ // split silently. When we DO recognise it, every `<thinking>`
432
+ // / `<thought>` block becomes a separate `thinking.start`/
433
+ // `thinking.delta`/`thinking.end` triplet so the TUI can
434
+ // render one collapsed pane row per block. The visible text
435
+ // (post-strip) flows to the regular `text.delta` channel so
436
+ // the conversation pane never shows raw <thinking> markup.
437
+ if (supportsThinking && response.content.length > 0) {
438
+ const split = splitThinkingBlocks(response.content);
439
+ for (const block of split.thinkingBlocks) {
440
+ const blockId = `think-${randomUUID().slice(0, 8)}`;
441
+ emitStream({ type: 'thinking.start', blockId });
442
+ emitStream({ type: 'thinking.delta', blockId, chunk: block });
443
+ emitStream({ type: 'thinking.end', blockId });
444
+ }
445
+ if (split.visibleText.length > 0) {
446
+ emitStream({ type: 'text.delta', chunk: split.visibleText });
447
+ }
448
+ }
449
+ else if (response.content.length > 0) {
450
+ emitStream({ type: 'text.delta', chunk: response.content });
451
+ }
178
452
  }
179
- else {
180
- recordToolResult(session, auditId, 'error', result.error.slice(0, 200));
453
+ },
454
+ onToolCall: (call) => {
455
+ // Record under an `engine_tool` prefix so the audit log can
456
+ // distinguish loop-driven calls from direct CLI tool calls.
457
+ const id = recordToolCall(session, `engine:${call.name}`, call.arguments.slice(0, 200));
458
+ // Stash the audit id on the call for `onToolResult` to close.
459
+ this.engineToolCallIds.set(call.id, id);
460
+ // Extract a candidate path for write/edit so we can build the
461
+ // filesChanged summary if (and only if) the call succeeds. Bad
462
+ // JSON is harmless here — we ignore it and the executor surfaces
463
+ // the actual parse error to the model.
464
+ if (call.name === 'write' || call.name === 'edit') {
465
+ const path = extractPathArg(call.arguments);
466
+ if (path)
467
+ pendingMutations.set(call.id, path);
181
468
  }
182
- this.engineToolCallIds.delete(call.id);
469
+ emitStream({
470
+ type: 'tool.start',
471
+ callId: call.id,
472
+ name: call.name,
473
+ arguments: call.arguments,
474
+ });
475
+ emitStream({
476
+ type: 'status',
477
+ message: `tool_call: ${call.name}(${call.arguments.slice(0, 80)}${call.arguments.length > 80 ? '...' : ''})`,
478
+ });
479
+ appendSessionMirror(sessionEventsPath, {
480
+ type: 'tool_call',
481
+ tool: call.name,
482
+ callId: call.id,
483
+ argsPreview: call.arguments.slice(0, 200),
484
+ });
485
+ },
486
+ onToolResult: (call, result) => {
487
+ const auditId = this.engineToolCallIds.get(call.id);
488
+ if (auditId) {
489
+ if (result.ok) {
490
+ recordToolResult(session, auditId, 'success', result.content.slice(0, 200));
491
+ }
492
+ else {
493
+ recordToolResult(session, auditId, 'error', result.error.slice(0, 200));
494
+ }
495
+ this.engineToolCallIds.delete(call.id);
496
+ }
497
+ const pendingPath = pendingMutations.get(call.id);
498
+ if (pendingPath) {
499
+ if (result.ok)
500
+ filesChanged.add(pendingPath);
501
+ pendingMutations.delete(call.id);
502
+ }
503
+ emitStream({
504
+ type: 'tool.end',
505
+ callId: call.id,
506
+ ok: result.ok,
507
+ summary: result.ok
508
+ ? result.content.slice(0, 200)
509
+ : result.error.slice(0, 200),
510
+ });
511
+ emitStream({
512
+ type: 'status',
513
+ message: result.ok
514
+ ? `tool_result: ${call.name} ok`
515
+ : `tool_result: ${call.name} error: ${result.error.slice(0, 120)}`,
516
+ });
517
+ appendSessionMirror(sessionEventsPath, {
518
+ type: 'tool_result',
519
+ tool: call.name,
520
+ callId: call.id,
521
+ ok: result.ok,
522
+ summary: result.ok ? result.content.slice(0, 200) : result.error.slice(0, 200),
523
+ });
524
+ },
525
+ };
526
+ // β1b r1 (--allow-fetch / --allow-search wiring, 2026-05-26):
527
+ // compute the effective gate as OR of (a) the persisted
528
+ // settings.json opt-in and (b) the runtime CLI flag passed via
529
+ // the constructor. Before this fix the adapter only honored (a),
530
+ // so `pugi code --allow-fetch` against a default-privacy workspace
531
+ // silently fell back to "tool not advertised" even though the
532
+ // operator opted in for one invocation. The CLI flag was wired
533
+ // through to the legacy `pugi web` sub-command but not to the
534
+ // engine adapter — Backend Architect review (PR #425 r1) caught
535
+ // the gap.
536
+ const allowFetchEffective = this.options.allowFetch === true || settings.web?.fetch?.enabled === true;
537
+ const allowSearchEffective = this.options.allowSearch === true || settings.web?.search?.enabled === true;
538
+ // β2 S3 (2026-05-26) → β2a r1 (Backend Architect P1, 2026-05-26):
539
+ // expose the `agent` tool to the parent loop ONLY for non-plan
540
+ // commands. `buildToolsSchema` also strips the agent tool from
541
+ // plan-mode schemas, but a model that fabricates an `agent` call
542
+ // would still hit the executor with `agentDispatch` wired and
543
+ // could spawn a coder that mutates the workspace — breaking the
544
+ // plan-mode read-only contract. Hard-gate `allowAgent` on the
545
+ // command kind so plan mode never wires the dispatch block in
546
+ // the first place; tool-bridge.ts also throws ToolRefused on a
547
+ // fabricated `agent` call in plan mode as defense in depth.
548
+ //
549
+ // Why only the top-level parent and not children: the dispatcher-
550
+ // real.ts module builds the CHILD's executor without an
551
+ // `agentDispatch` block so children cannot recursively spawn
552
+ // grandchildren. The isolation-matrix capability set then refuses
553
+ // the `agent` tool for every non-orchestrator role anyway, but
554
+ // the executor-level gate is the load-bearing chokepoint.
555
+ const allowAgent = kind !== 'plan';
556
+ // β3 streaming: kick off `runEngineLoop` IN PARALLEL with the queue
557
+ // drain. The loop's hook callbacks push events onto `streamQueue`
558
+ // synchronously; this generator yields them live by awaiting the
559
+ // queue's iterator. When the loop settles (success or crash) we
560
+ // close the queue, which lets the iterator return cleanly and the
561
+ // generator falls through to the terminal `result` frame.
562
+ //
563
+ // Why concurrent instead of serial:
564
+ //
565
+ // The β1 adapter awaited `runEngineLoop` to completion, then
566
+ // drained an in-memory `EngineEvent[]` buffer. Operator saw
567
+ // nothing for 30+ seconds (the full LLM round-trip + tool exec
568
+ // wall time), then the entire log dumped at once. The TUI tool-
569
+ // stream pane was a no-op because no event ever reached it
570
+ // before the loop completed.
571
+ //
572
+ // `Promise.race`-based interleaving lets us yield the next queue
573
+ // event OR detect loop settlement on each tick. The settlement
574
+ // flag (`loopSettled`) gates the final drain so we never miss
575
+ // tail events that the hooks pushed in the same microtask as
576
+ // the loop's terminal `return`.
577
+ // Boxed via single-element tuple so TypeScript does not narrow the
578
+ // outer `outcome` binding to `null` after the closure mutation.
579
+ // Async-closure mutations are invisible to TS control-flow analysis;
580
+ // wrapping in a tuple defeats the narrowing without an unsafe cast.
581
+ const outcomeBox = [null];
582
+ let loopError = null;
583
+ const loopPromise = (async () => {
584
+ try {
585
+ outcomeBox[0] = await runEngineLoop({
586
+ client: this.options.client,
587
+ executor: buildExecutor({
588
+ kind,
589
+ ctx: toolCtx,
590
+ sessionId: session.id,
591
+ workspaceRoot: root,
592
+ // P1 fix (deep audit 2026-05-26): forward optional REPL
593
+ // ask-modal bridge. Default `interactive: false` preserves
594
+ // backward compat — non-TTY callers (CI, pipes, scripted
595
+ // CLI runs) keep the `[user_input_required]` envelope path.
596
+ // The REPL layer passes `interactive: true` + a real
597
+ // `askUserBridge` so model-initiated `ask_user_question`
598
+ // calls round-trip to the ink modal and return the
599
+ // operator's choice as a tool result.
600
+ interactive: this.options.interactive === true,
601
+ ...(this.options.askUserBridge
602
+ ? { askUserBridge: this.options.askUserBridge }
603
+ : {}),
604
+ // P1 fix (deep audit 2026-05-26): forward the workspace
605
+ // HookRegistry so `.pugi/hooks/` lifecycle hooks fire for
606
+ // model-initiated tool calls. SECURITY: a `PreToolUse
607
+ // onFailure: 'block'` hook that refuses bash containing
608
+ // `rm` now applies to model dispatch — before this fix
609
+ // such a hook only applied to direct CLI tool calls.
610
+ ...(this.options.hooks ? { hooks: this.options.hooks } : {}),
611
+ // β1a r1 (web_fetch gating) + β1b r1 (--allow-fetch wiring):
612
+ // executor allowFetch matches the schema-advertise gate so a
613
+ // settings.json opt-in OR a --allow-fetch flag enables the
614
+ // call. Without this the model would not even see the
615
+ // `web_fetch` tool. `allowSearch` covers the new T4
616
+ // `web_search` tool with the same OR semantics.
617
+ allowFetch: allowFetchEffective,
618
+ allowSearch: allowSearchEffective,
619
+ // β2 S3 → β2a r1 (2026-05-26): parent-level agentDispatch
620
+ // wiring. When the model emits a `tool_call: agent(role,
621
+ // brief)`, the executor forwards it to dispatcher-real.ts
622
+ // which spawns a child engine loop against the same Anvil
623
+ // client. Gated by `allowAgent` so plan mode does not even
624
+ // wire the dispatch block — defense in depth on top of the
625
+ // schema-filter and the tool-bridge plan-mode refusal.
626
+ ...(allowAgent
627
+ ? {
628
+ agentDispatch: {
629
+ parentSession: session,
630
+ engineClient: this.options.client,
631
+ },
632
+ }
633
+ : {}),
634
+ // β4 M1/M3/M5: pass the loaded MCP registry through so the
635
+ // executor can route `mcp__server__tool` calls + run the
636
+ // first-call permission prompt before dispatching upstream.
637
+ ...(this.options.mcpRegistry ? { mcpRegistry: this.options.mcpRegistry } : {}),
638
+ ...(this.options.mcpPrompt ? { mcpPrompt: this.options.mcpPrompt } : {}),
639
+ // α7 L11 (2026-05-27): per-`run()` denial tracker. Every
640
+ // refusal sentinel (PLAN_MODE_REFUSED, HOOK_BLOCKED,
641
+ // OPERATOR_ABORTED, STALE_READ, unknown-tool, plan-mode
642
+ // agent) is fingerprinted by (toolName, sha256(canonical
643
+ // args)) so the model's next-turn reminder surfaces the
644
+ // pattern instead of re-issuing the same refused call.
645
+ denialTracking,
646
+ }),
647
+ // Leak L32 (2026-05-27): ambient `PUGI.md` hierarchy block
648
+ // prepended once at session boot. When the walk found
649
+ // nothing OR bare mode is on, `ambientContextBlock === ''`
650
+ // and the system prompt is unchanged — no leading blank
651
+ // line, no empty wrapper tag.
652
+ //
653
+ // Leak L28 (2026-05-27): the `repoMapBlock` is splice'd
654
+ // between the ambient PUGI.md and the persona prompt so
655
+ // the model sees the workspace structure WITH the operator's
656
+ // ambient guidance fronting it. Empty blocks drop cleanly:
657
+ // `composeSystemPrompt` filters falsy entries before joining.
658
+ systemPrompt: composeSystemPrompt([
659
+ ambientContextBlock,
660
+ repoMapBlock,
661
+ systemPromptFor(kind),
662
+ ]),
663
+ // β5a R5+R6+P1: per-turn `<context>` prefix + intent marker
664
+ // applied above. Falls back to verbatim `task.prompt` when
665
+ // both the prefix block is empty AND the intent classifier
666
+ // returned ambiguous (the splice + apply functions handle
667
+ // that case as identity).
668
+ userPrompt: finalUserPrompt,
669
+ // β1a r1 (web_fetch gating) + β1b r1 (--allow-fetch wiring):
670
+ // pass the OR of `.pugi/settings.json::web.fetch.enabled` and
671
+ // the runtime `--allow-fetch` flag. When neither is true the
672
+ // `web_fetch` tool is not advertised to the model at all.
673
+ // `allowSearch` does the same for the new `web_search` tool.
674
+ // β2 S3: allowAgent surfaces the `agent` tool in the schema
675
+ // so the model sees it as a valid tool call option; the
676
+ // capability-matrix layer (S4) still gates which roles can
677
+ // actually USE it. Plan mode strips it via β2a r1 gate.
678
+ tools: buildToolsSchema(kind, {
679
+ allowFetch: allowFetchEffective,
680
+ allowSearch: allowSearchEffective,
681
+ allowAgent,
682
+ // β4 M1/M3: same registry the executor saw. Schema +
683
+ // dispatcher must agree on which MCP names are advertised
684
+ // and which are dispatchable; passing identical references
685
+ // makes that invariant impossible to break.
686
+ ...(this.options.mcpRegistry ? { mcpRegistry: this.options.mcpRegistry } : {}),
687
+ }),
688
+ budget,
689
+ personaSlug: personaSlugFor(kind),
690
+ hooks,
691
+ temperature: this.options.temperature ?? 0.2,
692
+ signal: ctx.signal,
693
+ // β1 (audit E2): forward CLI sub-command + α6.10 routing tag +
694
+ // operator-pinned model so the runtime controller's DTO sees
695
+ // all three. `tag` derives 1:1 from `command` for now
696
+ // (`code → code`, `build → build_task`, etc.); future routing
697
+ // changes flip the mapping table without touching the call
698
+ // site. `model` is left undefined here — operator-pinned model
699
+ // pinning ships in β6 with persona routing.
700
+ command: kind,
701
+ tag: dispatchTagFor(kind),
702
+ model: this.options.model,
703
+ // CEO P1 #14 (auto-compact, 2026-05-29): pluggable compactor
704
+ // hook. The SDK driver invokes this pre-`client.send` on every
705
+ // turn. `maybeCompact` returns `null` below the 75% threshold
706
+ // или when the transcript is too short to drop history — the
707
+ // loop continues unchanged on the cold path. When it returns
708
+ // a result, the driver swaps the transcript + fires the
709
+ // `onAutoCompact` hook above which emits the stderr line.
710
+ autoCompact: ({ transcript, maxTokens }) => maybeCompact(transcript, maxTokens, autoCompactConfig),
711
+ });
183
712
  }
184
- const pendingPath = pendingMutations.get(call.id);
185
- if (pendingPath) {
186
- if (result.ok)
187
- filesChanged.add(pendingPath);
188
- pendingMutations.delete(call.id);
713
+ catch (err) {
714
+ loopError = err;
189
715
  }
190
- buffer.push({
191
- type: 'status',
192
- message: result.ok
193
- ? `tool_result: ${call.name} ok`
194
- : `tool_result: ${call.name} error: ${result.error.slice(0, 120)}`,
195
- });
196
- appendSessionMirror(sessionEventsPath, {
197
- type: 'tool_result',
198
- tool: call.name,
199
- callId: call.id,
200
- ok: result.ok,
201
- summary: result.ok ? result.content.slice(0, 200) : result.error.slice(0, 200),
202
- });
203
- },
204
- };
205
- let outcome;
206
- try {
207
- outcome = await runEngineLoop({
208
- client: this.options.client,
209
- executor: buildExecutor({ kind, ctx: toolCtx }),
210
- systemPrompt: systemPromptFor(kind),
211
- userPrompt: task.prompt,
212
- tools: buildToolsSchema(kind),
213
- budget,
214
- personaSlug: personaSlugFor(kind),
215
- hooks,
216
- temperature: this.options.temperature ?? 0.2,
217
- signal: ctx.signal,
716
+ finally {
717
+ // Close the queue so the iterator below returns `done: true`.
718
+ // Any tail events the hooks pushed in the same microtask still
719
+ // drain because `AsyncEventQueue.close()` only resolves
720
+ // PENDING awaiters buffered items stay readable.
721
+ streamQueue.close();
722
+ }
723
+ })();
724
+ // Drain the queue live. Each iteration yields one EngineEvent the
725
+ // moment its hook fired. Operator sees `tool.start` within tens of
726
+ // ms of the model emitting it.
727
+ for await (const event of streamQueue) {
728
+ yield streamEventToEngineEvent(event);
729
+ }
730
+ // Loop has settled (queue closed). Surface its outcome — either an
731
+ // unhandled crash from the (rare) executor exception path or the
732
+ // structured EngineLoopOutcome.
733
+ await loopPromise;
734
+ if (loopError !== null) {
735
+ const message = loopError instanceof Error ? loopError.message : String(loopError);
736
+ yield {
737
+ type: 'result',
738
+ result: {
739
+ status: 'failed',
740
+ summary: `engine loop crashed: ${message}`,
741
+ filesChanged: [],
742
+ patchRefs: [],
743
+ testsRun: [],
744
+ risks: [`unhandled error in engine adapter: ${message}`],
745
+ eventRefs: [],
746
+ },
747
+ };
748
+ return;
749
+ }
750
+ const finalOutcome = outcomeBox[0];
751
+ if (finalOutcome === null) {
752
+ // Defensive — should never hit. `runEngineLoop` always either
753
+ // resolves with an outcome or throws (and we catch that above).
754
+ yield {
755
+ type: 'result',
756
+ result: {
757
+ status: 'failed',
758
+ summary: 'engine loop returned no outcome',
759
+ filesChanged: [],
760
+ patchRefs: [],
761
+ testsRun: [],
762
+ risks: ['runEngineLoop resolved without an outcome value'],
763
+ eventRefs: [],
764
+ },
765
+ };
766
+ return;
767
+ }
768
+ // Translate the loop outcome into an EngineResult.
769
+ // `aborted` (α6.9: operator cancelled mid-tool) maps to `blocked`
770
+ // because the operator chose the outcome, same shape as
771
+ // budget_exhausted / tool_refused.
772
+ const status = finalOutcome.status === 'completed'
773
+ ? 'done'
774
+ : finalOutcome.status === 'failed'
775
+ ? 'failed'
776
+ : 'blocked';
777
+ const summaryPrefix = finalOutcome.status === 'completed'
778
+ ? ''
779
+ : finalOutcome.status === 'budget_exhausted'
780
+ ? '[budget_exhausted] '
781
+ : finalOutcome.status === 'tool_refused'
782
+ ? '[plan_mode_refused] '
783
+ : finalOutcome.status === 'aborted'
784
+ ? '[operator_aborted] '
785
+ : '[failed] ';
786
+ const filesChangedList = Array.from(filesChanged).sort();
787
+ appendSessionMirror(sessionEventsPath, {
788
+ type: 'outcome',
789
+ status: finalOutcome.status,
790
+ toolCallCount: finalOutcome.toolCallCount,
791
+ turnsUsed: finalOutcome.turnsUsed,
792
+ tokensUsed: finalOutcome.tokensUsed,
793
+ filesChanged: filesChangedList,
794
+ reason: finalOutcome.reason,
218
795
  });
219
- }
220
- catch (error) {
221
- // Defensive — runEngineLoop wraps errors into status: failed, so
222
- // this branch is only hit if the executor or hooks themselves
223
- // throw uncaught. Surface as a failed result so the CLI exits
224
- // non-zero rather than hanging.
225
- const message = error instanceof Error ? error.message : String(error);
226
796
  yield {
227
797
  type: 'result',
228
798
  result: {
229
- status: 'failed',
230
- summary: `engine loop crashed: ${message}`,
231
- filesChanged: [],
799
+ status,
800
+ summary: `${summaryPrefix}${finalOutcome.finalText || finalOutcome.reason || 'no answer returned'}`,
801
+ filesChanged: filesChangedList,
232
802
  patchRefs: [],
233
803
  testsRun: [],
234
- risks: [`unhandled error in engine adapter: ${message}`],
235
- eventRefs: [],
804
+ risks: finalOutcome.status === 'completed'
805
+ ? []
806
+ : [finalOutcome.reason ?? `outcome=${finalOutcome.status}`],
807
+ eventRefs: [
808
+ `tool_calls=${finalOutcome.toolCallCount}`,
809
+ `turns=${finalOutcome.turnsUsed}`,
810
+ `tokens=${finalOutcome.tokensUsed}`,
811
+ // `outcome=<status>` is a machine-readable echo so callers
812
+ // (cli.ts plan exit code, cabinet UI) can distinguish
813
+ // `budget_exhausted` from `tool_refused` without parsing
814
+ // the human-readable summary prefix. Code Reviewer P2
815
+ // retro 2026-05-23: plan exit code previously collapsed
816
+ // both blocked reasons into 0, which masked budget hits.
817
+ `outcome=${finalOutcome.status}`,
818
+ `session=${session.id}`,
819
+ `ctx=${ctx.sessionId}`,
820
+ `mirror=${sessionEventsPath}`,
821
+ ],
236
822
  },
237
823
  };
238
- return;
239
824
  }
240
- // Drain status buffer first so consumers see the chronological order.
241
- for (const event of buffer)
242
- yield event;
243
- // Translate the loop outcome into an EngineResult.
244
- // `aborted` (α6.9: operator cancelled mid-tool) maps to `blocked`
245
- // because the operator chose the outcome, same shape as
246
- // budget_exhausted / tool_refused.
247
- const status = outcome.status === 'completed'
248
- ? 'done'
249
- : outcome.status === 'failed'
250
- ? 'failed'
251
- : 'blocked';
252
- const summaryPrefix = outcome.status === 'completed'
253
- ? ''
254
- : outcome.status === 'budget_exhausted'
255
- ? '[budget_exhausted] '
256
- : outcome.status === 'tool_refused'
257
- ? '[plan_mode_refused] '
258
- : outcome.status === 'aborted'
259
- ? '[operator_aborted] '
260
- : '[failed] ';
261
- const filesChangedList = Array.from(filesChanged).sort();
262
- appendSessionMirror(sessionEventsPath, {
263
- type: 'outcome',
264
- status: outcome.status,
265
- toolCallCount: outcome.toolCallCount,
266
- turnsUsed: outcome.turnsUsed,
267
- tokensUsed: outcome.tokensUsed,
268
- filesChanged: filesChangedList,
269
- reason: outcome.reason,
270
- });
271
- yield {
272
- type: 'result',
273
- result: {
274
- status,
275
- summary: `${summaryPrefix}${outcome.finalText || outcome.reason || 'no answer returned'}`,
276
- filesChanged: filesChangedList,
277
- patchRefs: [],
278
- testsRun: [],
279
- risks: outcome.status === 'completed'
280
- ? []
281
- : [outcome.reason ?? `outcome=${outcome.status}`],
282
- eventRefs: [
283
- `tool_calls=${outcome.toolCallCount}`,
284
- `turns=${outcome.turnsUsed}`,
285
- `tokens=${outcome.tokensUsed}`,
286
- // `outcome=<status>` is a machine-readable echo so callers
287
- // (cli.ts plan exit code, cabinet UI) can distinguish
288
- // `budget_exhausted` from `tool_refused` without parsing
289
- // the human-readable summary prefix. Code Reviewer P2
290
- // retro 2026-05-23: plan exit code previously collapsed
291
- // both blocked reasons into 0, which masked budget hits.
292
- `outcome=${outcome.status}`,
293
- `session=${session.id}`,
294
- `ctx=${ctx.sessionId}`,
295
- `mirror=${sessionEventsPath}`,
296
- ],
297
- },
298
- };
825
+ finally {
826
+ // r2 (triple-review 2026-05-26 P1): detach the abort listener so
827
+ // long REPL sessions sharing one AbortController across many
828
+ // run() invocations do not accumulate one listener per run on
829
+ // `ctx.signal`. Called on success, abort, and uncaught throw.
830
+ detachAbortListener?.();
831
+ }
832
+ }
833
+ }
834
+ /**
835
+ * β3 streaming: translate one typed `EngineStreamEvent` from the
836
+ * adapter's internal queue into the SDK's lossier `EngineEvent` shape
837
+ * the public adapter contract exposes. The SDK contract only declares
838
+ * `status | result` today; richer events (`tool.start`, `thinking.delta`,
839
+ * etc.) collapse to a structured `status` message until the SDK widens
840
+ * the discriminated union (β3b — paired with an admin-api SSE schema
841
+ * bump so the wire format stays stable).
842
+ *
843
+ * The full typed payload is still available to richer consumers via
844
+ * `adapter.streamEmitter.on('event', ...)`. The CLI's TUI tool-stream
845
+ * pane consumes that emitter directly; this function is the safe
846
+ * bridge for legacy SDK consumers that only know `EngineEvent`.
847
+ */
848
+ function streamEventToEngineEvent(stream) {
849
+ switch (stream.type) {
850
+ case 'status':
851
+ return { type: 'status', message: stream.message };
852
+ case 'tool.start':
853
+ return {
854
+ type: 'status',
855
+ message: `tool.start ${stream.name} call=${stream.callId} args=${stream.arguments.slice(0, 80)}${stream.arguments.length > 80 ? '...' : ''}`,
856
+ };
857
+ case 'tool.delta':
858
+ return {
859
+ type: 'status',
860
+ message: `tool.delta call=${stream.callId} chunk=${stream.chunk.slice(0, 120)}`,
861
+ };
862
+ case 'tool.end':
863
+ return {
864
+ type: 'status',
865
+ message: `tool.end call=${stream.callId} ok=${stream.ok} summary=${stream.summary.slice(0, 120)}`,
866
+ };
867
+ case 'thinking.start':
868
+ return { type: 'status', message: `thinking.start block=${stream.blockId}` };
869
+ case 'thinking.delta':
870
+ return {
871
+ type: 'status',
872
+ message: `thinking.delta block=${stream.blockId} chunk=${stream.chunk.slice(0, 120)}`,
873
+ };
874
+ case 'thinking.end':
875
+ return { type: 'status', message: `thinking.end block=${stream.blockId}` };
876
+ case 'text.delta':
877
+ return {
878
+ type: 'status',
879
+ message: `text.delta chunk=${stream.chunk.slice(0, 200)}`,
880
+ };
881
+ default: {
882
+ // Exhaustiveness — TS catches a missing variant at compile time.
883
+ const exhaustive = stream;
884
+ void exhaustive;
885
+ return { type: 'status', message: 'unknown stream event' };
886
+ }
299
887
  }
300
888
  }
301
889
  /**
@@ -311,7 +899,14 @@ function extractPathArg(raw) {
311
899
  try {
312
900
  const parsed = JSON.parse(raw);
313
901
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
314
- const path = parsed.path;
902
+ const obj = parsed;
903
+ // Accept canonical `path` OR the Claude-Code-trained `filePath`
904
+ // alias so the filesChanged summary captures writes regardless of
905
+ // which key the model emitted. Without the alias the operator
906
+ // sees "Files modified: none" even when a write actually landed,
907
+ // because the dispatcher accepted the alias but the tracker did
908
+ // not (CEO live smoke 2026-05-28).
909
+ const path = obj['path'] ?? obj['filePath'];
315
910
  if (typeof path === 'string' && path.length > 0)
316
911
  return path;
317
912
  }
@@ -367,8 +962,99 @@ function toCommandKind(kind) {
367
962
  return 'build';
368
963
  return kind;
369
964
  }
965
+ /**
966
+ * β1 (audit E2) → β1a r1 (engine tag contract fix, 2026-05-26): map a
967
+ * CLI command kind to its α6.10 dispatch tag.
968
+ *
969
+ * The admin-api controller (`pugi-engine.controller.ts`) routes per-tag
970
+ * to a model/persona pair via
971
+ * `apps/admin-api/src/mira/routing/dispatch-tag.ts::DISPATCH_TAGS`. The
972
+ * closed `EngineChatTag` vocabulary is
973
+ * `classify | reason | codegen | summarize | vision` — note that
974
+ * `code`, `fix`, `plan`, `build`, `explain` (CLI command names) are NOT
975
+ * in this set.
976
+ *
977
+ * Before this fix `dispatchTagFor()` returned the CLI command names
978
+ * as-is and the runtime DTO rejected the payload with HTTP 400
979
+ * (`tag must be one of: classify, reason, codegen, summarize, vision`)
980
+ * before ever reaching the routing layer. Every `pugi code/fix/plan/
981
+ * build/explain` against the live runtime returned `failed: HTTP 400`.
982
+ *
983
+ * Mapping rationale (each row keeps the most informative `tag` value
984
+ * for cost telemetry / model selection):
985
+ *
986
+ * - `code`, `fix` → `codegen` (edits / diffs / patches)
987
+ * - `build_task`/`build` → `codegen` + `budget_hint: 'max'`
988
+ * (scaffolding hits the 30-call / 80k-token ceiling — give the
989
+ * router permission to pick the largest model in the tier)
990
+ * - `plan` → `reason` (no mutations, long-form thought)
991
+ * - `explain` → `summarize` (read-only walkthrough)
992
+ *
993
+ * `priority: 'realtime'` for every command — Pugi is an interactive
994
+ * CLI; background dispatch is reserved for the cabinet's RAG ingest
995
+ * cron path. `budget_hint: 'std'` is the default for the cost-balanced
996
+ * router row; only `build_task` opts up to `'max'`.
997
+ */
998
+ export function dispatchTagFor(kind) {
999
+ switch (kind) {
1000
+ case 'code':
1001
+ case 'fix':
1002
+ return { tag: 'codegen', priority: 'realtime', budget_hint: 'std' };
1003
+ case 'build':
1004
+ // `build_task` on the engine task kind side is the heavy
1005
+ // scaffolding lane — biggest budget envelope, biggest model
1006
+ // permitted via `budget_hint: 'max'`.
1007
+ return { tag: 'codegen', priority: 'realtime', budget_hint: 'max' };
1008
+ case 'plan':
1009
+ return { tag: 'reason', priority: 'realtime', budget_hint: 'std' };
1010
+ case 'explain':
1011
+ return { tag: 'summarize', priority: 'realtime', budget_hint: 'std' };
1012
+ default: {
1013
+ // Exhaustiveness check — `EngineCommandKind` is a closed union,
1014
+ // so the switch above covers every case. If a new command kind
1015
+ // is added the compiler flags this branch and the map must be
1016
+ // extended. Fall back to `reason` as the most conservative
1017
+ // routing choice so a future kind addition cannot accidentally
1018
+ // unlock a write-heavy model lane.
1019
+ const exhaustive = kind;
1020
+ void exhaustive;
1021
+ return { tag: 'reason', priority: 'realtime', budget_hint: 'std' };
1022
+ }
1023
+ }
1024
+ }
370
1025
  // The per-adapter `engineToolCallIds` Map lives on the
371
1026
  // `NativePugiEngineAdapter` instance above — Code Reviewer P2 retro
372
1027
  // 2026-05-23 lifted it off the module scope to prevent collisions
373
1028
  // under parallel adapter runs (cabinet UI + CLI sharing one process).
1029
+ /**
1030
+ * β5a R5+R6: render a cwd path as either a workspace-root-relative
1031
+ * string (when cwd is inside the workspace) or a `.` token (when cwd
1032
+ * equals workspaceRoot). Falls back to the absolute cwd if it lives
1033
+ * outside the workspace — the traverse loader already refuses to
1034
+ * read off-tree files so the abs path is purely a breadcrumb for
1035
+ * the SSE status line.
1036
+ */
1037
+ function relativeOrAbsolute(workspaceRoot, cwd) {
1038
+ const absRoot = resolve(workspaceRoot);
1039
+ const absCwd = resolve(cwd);
1040
+ if (absCwd === absRoot)
1041
+ return '.';
1042
+ const rel = absCwd.startsWith(absRoot + '/') ? absCwd.slice(absRoot.length + 1) : null;
1043
+ return rel ?? absCwd;
1044
+ }
1045
+ /**
1046
+ * Leak L28 helper — splice multiple ambient blocks onto a persona
1047
+ * system prompt, dropping empty entries cleanly. The join character
1048
+ * is `\n\n` so each block renders as a discrete paragraph the model
1049
+ * can attend к without bleeding into its neighbour.
1050
+ *
1051
+ * Empty blocks return the base prompt unchanged — no leading
1052
+ * separators, no trailing whitespace. Mirrors the original
1053
+ * `ambientContextBlock ? ... : ...` shape so the single-block path
1054
+ * before L28 stays byte-identical (prompt cache friendliness).
1055
+ */
1056
+ export function composeSystemPrompt(blocks) {
1057
+ const nonEmpty = blocks.map((b) => b.trim()).filter((b) => b.length > 0);
1058
+ return nonEmpty.join('\n\n');
1059
+ }
374
1060
  //# sourceMappingURL=native-pugi.js.map