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

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