@pugi/cli 0.1.0-beta.8 → 0.1.0-beta.88

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 (405) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/LICENSE +1 -1
  3. package/THIRD_PARTY_NOTICES.md +40 -0
  4. package/assets/pugi-prozr2-mascot.ansi +9 -0
  5. package/bin/run.js +33 -1
  6. package/dist/commands/deploy.js +40 -40
  7. package/dist/commands/flatten.js +191 -0
  8. package/dist/commands/jobs-watch.js +201 -0
  9. package/dist/commands/jobs.js +42 -27
  10. package/dist/commands/smoke.js +133 -0
  11. package/dist/core/agent-progress/cleanup.js +134 -0
  12. package/dist/core/agent-progress/schema.js +144 -0
  13. package/dist/core/agent-progress/writer.js +101 -0
  14. package/dist/core/agents/adaptive-router.js +330 -0
  15. package/dist/core/agents/query-decomposer.js +297 -0
  16. package/dist/core/agents/registry.js +3 -3
  17. package/dist/core/approvals/shortcut-resolver.js +98 -0
  18. package/dist/core/artifact-chain/dispatcher.js +148 -0
  19. package/dist/core/artifact-chain/exporter.js +164 -0
  20. package/dist/core/artifact-chain/state.js +243 -0
  21. package/dist/core/artifact-chain/steps.js +169 -0
  22. package/dist/core/ask-user/question.js +92 -0
  23. package/dist/core/audit/audit-trail.js +275 -0
  24. package/dist/core/auth/ensure-authenticated.js +129 -0
  25. package/dist/core/auth/env-provider.js +238 -0
  26. package/dist/core/auto-open-browser.js +4 -4
  27. package/dist/core/auto-update/channels.js +122 -0
  28. package/dist/core/auto-update/checker.js +241 -0
  29. package/dist/core/auto-update/state.js +235 -0
  30. package/dist/core/bare-mode/index.js +107 -0
  31. package/dist/core/bash/redirect.js +281 -0
  32. package/dist/core/bash-classifier.js +436 -40
  33. package/dist/core/checkpoint/resumer.js +149 -0
  34. package/dist/core/checkpoint/rewinder.js +291 -0
  35. package/dist/core/checkpoints/shadow-git.js +670 -0
  36. package/dist/core/citations/parser.js +109 -0
  37. package/dist/core/classifier/yolo-classifier.js +88 -0
  38. package/dist/core/codegraph/decision-store.js +248 -0
  39. package/dist/core/codegraph/detect-repo.js +459 -0
  40. package/dist/core/codegraph/install.js +134 -0
  41. package/dist/core/codegraph/offer-hook.js +220 -0
  42. package/dist/core/compact/auto-trigger.js +96 -0
  43. package/dist/core/compact/buffer-rewriter.js +115 -0
  44. package/dist/core/compact/summarizer.js +208 -0
  45. package/dist/core/compact/token-counter.js +108 -0
  46. package/dist/core/consensus/anvil-fanout.js +25 -25
  47. package/dist/core/consensus/diff-capture.js +121 -12
  48. package/dist/core/consensus/rubric.js +21 -21
  49. package/dist/core/context/builder.js +6 -6
  50. package/dist/core/context/compaction-events.js +8 -8
  51. package/dist/core/context/compaction.js +31 -31
  52. package/dist/core/context/index.js +15 -8
  53. package/dist/core/context/invariants.js +51 -51
  54. package/dist/core/context/markdown-loader.js +28 -10
  55. package/dist/core/context/markdown-traverse.js +255 -0
  56. package/dist/core/context/pugiignore.js +41 -41
  57. package/dist/core/context/repo-skeleton.js +37 -37
  58. package/dist/core/context/tool-eviction.js +55 -0
  59. package/dist/core/context/watcher.js +32 -32
  60. package/dist/core/context/working-set.js +23 -23
  61. package/dist/core/coordinator/agent-tools.js +77 -0
  62. package/dist/core/coordinator/agent-toolset.js +65 -0
  63. package/dist/core/coordinator/fsm.js +73 -0
  64. package/dist/core/coordinator/mode-fsm.js +70 -0
  65. package/dist/core/cost/rate-card.js +129 -0
  66. package/dist/core/cost/tracker.js +221 -0
  67. package/dist/core/credentials.js +12 -12
  68. package/dist/core/cron/scheduler.js +138 -0
  69. package/dist/core/denial-tracking/index.js +8 -0
  70. package/dist/core/denial-tracking/state.js +264 -0
  71. package/dist/core/diagnostics/probe-runner.js +93 -0
  72. package/dist/core/diagnostics/probes/api.js +46 -0
  73. package/dist/core/diagnostics/probes/auth.js +93 -0
  74. package/dist/core/diagnostics/probes/bare-mode.js +42 -0
  75. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  76. package/dist/core/diagnostics/probes/config.js +72 -0
  77. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  78. package/dist/core/diagnostics/probes/disk.js +81 -0
  79. package/dist/core/diagnostics/probes/engine-live.js +46 -0
  80. package/dist/core/diagnostics/probes/git.js +65 -0
  81. package/dist/core/diagnostics/probes/hooks.js +118 -0
  82. package/dist/core/diagnostics/probes/mcp.js +75 -0
  83. package/dist/core/diagnostics/probes/node.js +59 -0
  84. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  85. package/dist/core/diagnostics/probes/pugi-md.js +89 -0
  86. package/dist/core/diagnostics/probes/sandbox.js +40 -0
  87. package/dist/core/diagnostics/probes/session.js +74 -0
  88. package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
  89. package/dist/core/diagnostics/probes/workspace.js +63 -0
  90. package/dist/core/diagnostics/types.js +70 -0
  91. package/dist/core/dispatch/cache-cleanup.js +197 -0
  92. package/dist/core/dispatch/cache-handoff.js +295 -0
  93. package/dist/core/edits/apply-patch-layer-e.js +189 -0
  94. package/dist/core/edits/dispatch.js +293 -7
  95. package/dist/core/edits/format-matrix.js +26 -0
  96. package/dist/core/edits/fuzzy-ladder.js +650 -0
  97. package/dist/core/edits/index.js +3 -1
  98. package/dist/core/edits/journal.js +199 -0
  99. package/dist/core/edits/layer-a-apply.js +15 -15
  100. package/dist/core/edits/layer-a-fuzzy-apply.js +198 -0
  101. package/dist/core/edits/layer-b-apply.js +9 -9
  102. package/dist/core/edits/layer-c-apply.js +6 -6
  103. package/dist/core/edits/layer-d-ast.js +557 -14
  104. package/dist/core/edits/marker-parser.js +12 -12
  105. package/dist/core/edits/security-gate.js +27 -27
  106. package/dist/core/edits/verify-hook.js +273 -0
  107. package/dist/core/edits/worktree.js +322 -0
  108. package/dist/core/engine/anvil-client.js +151 -26
  109. package/dist/core/engine/auto-compact.js +179 -0
  110. package/dist/core/engine/budgets.js +186 -0
  111. package/dist/core/engine/context-prefix.js +155 -0
  112. package/dist/core/engine/index.js +1 -1
  113. package/dist/core/engine/intensity.js +158 -0
  114. package/dist/core/engine/intent.js +260 -0
  115. package/dist/core/engine/native-pugi.js +1295 -227
  116. package/dist/core/engine/prompts.js +134 -16
  117. package/dist/core/engine/strip-internal-fields.js +124 -0
  118. package/dist/core/engine/tool-bridge.js +1295 -59
  119. package/dist/core/evaluation/golden-dataset.js +293 -0
  120. package/dist/core/feedback/queue.js +177 -0
  121. package/dist/core/feedback/submitter.js +145 -0
  122. package/dist/core/file-cache.js +113 -1
  123. package/dist/core/flatten/flatten-repo.js +439 -0
  124. package/dist/core/format/osc8-link.js +28 -0
  125. package/dist/core/hook-chains.js +392 -0
  126. package/dist/core/hooks/citation-verify-hook.js +138 -0
  127. package/dist/core/hooks/citation-verify.js +112 -0
  128. package/dist/core/hooks/events.js +44 -0
  129. package/dist/core/hooks/index.js +15 -0
  130. package/dist/core/hooks/registry.js +213 -0
  131. package/dist/core/hooks/runner.js +236 -0
  132. package/dist/core/hooks/v2/event-emitter.js +115 -0
  133. package/dist/core/hooks/v2/executor.js +282 -0
  134. package/dist/core/hooks/v2/index.js +25 -0
  135. package/dist/core/hooks/v2/lifecycle.js +104 -0
  136. package/dist/core/hooks/v2/loader.js +216 -0
  137. package/dist/core/hooks/v2/matcher.js +125 -0
  138. package/dist/core/hooks/v2/trust.js +143 -0
  139. package/dist/core/hooks/v2/types.js +86 -0
  140. package/dist/core/image/renderer.js +71 -0
  141. package/dist/core/init/detector.js +582 -0
  142. package/dist/core/init/template-renderer.js +242 -0
  143. package/dist/core/jobs/registry.js +18 -18
  144. package/dist/core/ledger/results-tsv.js +142 -0
  145. package/dist/core/log-discipline/stdout-redirect.js +51 -0
  146. package/dist/core/lsp/cache.js +105 -0
  147. package/dist/core/lsp/client.js +776 -0
  148. package/dist/core/lsp/language-detect.js +66 -0
  149. package/dist/core/lsp/post-edit-diagnostics.js +171 -0
  150. package/dist/core/lsp/symbol-tools.js +372 -0
  151. package/dist/core/mcp/client.js +97 -28
  152. package/dist/core/mcp/http-server.js +553 -0
  153. package/dist/core/mcp/orchestrator-tools.js +662 -0
  154. package/dist/core/mcp/permission.js +190 -0
  155. package/dist/core/mcp/registry.js +39 -17
  156. package/dist/core/mcp/server-tools.js +219 -0
  157. package/dist/core/mcp/server.js +397 -0
  158. package/dist/core/mcp/trust.js +10 -10
  159. package/dist/core/memory/dual-write.js +416 -0
  160. package/dist/core/memory/passive-extract.js +130 -0
  161. package/dist/core/memory/phase1-kinds.js +20 -0
  162. package/dist/core/memory/secret-scanner.js +304 -0
  163. package/dist/core/memory-sync/queue.js +170 -0
  164. package/dist/core/metrics/extract.js +113 -0
  165. package/dist/core/modes/roo-modes.js +68 -0
  166. package/dist/core/onboarding/ensure-initialized.js +133 -0
  167. package/dist/core/onboarding/marker.js +111 -0
  168. package/dist/core/onboarding/telemetry-state.js +108 -0
  169. package/dist/core/output-style/presets.js +176 -0
  170. package/dist/core/output-style/state.js +185 -0
  171. package/dist/core/path-security.js +287 -5
  172. package/dist/core/permission.js +82 -22
  173. package/dist/core/permissions/auto-classifier.js +124 -0
  174. package/dist/core/permissions/bash-parser.js +371 -0
  175. package/dist/core/permissions/circuit-breaker.js +83 -0
  176. package/dist/core/permissions/constrained-edit.js +91 -0
  177. package/dist/core/permissions/gate.js +278 -0
  178. package/dist/core/permissions/index.js +20 -0
  179. package/dist/core/permissions/mode.js +174 -0
  180. package/dist/core/permissions/network-egress.js +137 -0
  181. package/dist/core/permissions/state.js +241 -0
  182. package/dist/core/permissions/tool-class.js +93 -0
  183. package/dist/core/plan-mode/ui-state.js +51 -0
  184. package/dist/core/plans/plan-artifact.js +721 -0
  185. package/dist/core/policy-limits/etag-store.js +122 -0
  186. package/dist/core/prd-check/parser.js +215 -0
  187. package/dist/core/prd-check/reporter.js +127 -0
  188. package/dist/core/prd-check/session-review.js +557 -0
  189. package/dist/core/prd-check/verifiers.js +223 -0
  190. package/dist/core/prompt-cache/client-cache.js +99 -0
  191. package/dist/core/prompts/assembly.js +29 -0
  192. package/dist/core/prompts/registry.js +364 -0
  193. package/dist/core/pugi-md/cc-compat-rules.js +735 -0
  194. package/dist/core/pugi-md/context-injector.js +76 -0
  195. package/dist/core/pugi-md/walk-up.js +207 -0
  196. package/dist/core/python/uv-installer.js +270 -0
  197. package/dist/core/python/uv-resolver.js +83 -0
  198. package/dist/core/rate-limit/narrator.js +146 -0
  199. package/dist/core/recipes/cli-types.js +20 -0
  200. package/dist/core/recipes/loader.js +103 -0
  201. package/dist/core/recipes/runner.js +345 -0
  202. package/dist/core/recipes/schema.js +587 -0
  203. package/dist/core/release-notes/parser.js +241 -0
  204. package/dist/core/release-notes/state.js +116 -0
  205. package/dist/core/repl/ask.js +37 -37
  206. package/dist/core/repl/cancellation.js +26 -26
  207. package/dist/core/repl/cap-warning.js +4 -4
  208. package/dist/core/repl/clipboard-read.js +11 -11
  209. package/dist/core/repl/dispatch-fsm.js +12 -12
  210. package/dist/core/repl/history-search.js +15 -15
  211. package/dist/core/repl/history.js +28 -18
  212. package/dist/core/repl/kill-ring.js +5 -5
  213. package/dist/core/repl/model-pricing.js +135 -0
  214. package/dist/core/repl/privacy-banner.js +22 -22
  215. package/dist/core/repl/session.js +2157 -214
  216. package/dist/core/repl/slash-commands.js +533 -40
  217. package/dist/core/repl/store/index.js +1 -1
  218. package/dist/core/repl/store/jsonl-log.js +22 -22
  219. package/dist/core/repl/store/lockfile.js +10 -10
  220. package/dist/core/repl/store/session-store.js +136 -107
  221. package/dist/core/repl/store/types.js +15 -15
  222. package/dist/core/repl/store/uuid-v7.js +12 -12
  223. package/dist/core/repl/workspace-context.js +43 -21
  224. package/dist/core/repo-map/build.js +125 -0
  225. package/dist/core/repo-map/cache.js +185 -0
  226. package/dist/core/repo-map/extractor.js +254 -0
  227. package/dist/core/repo-map/formatter.js +145 -0
  228. package/dist/core/repo-map/page-rank.js +105 -0
  229. package/dist/core/repo-map/scanner.js +211 -0
  230. package/dist/core/retry-budget/budget.js +284 -0
  231. package/dist/core/retry-budget/index.js +5 -0
  232. package/dist/core/retry-budget/retry-cap.js +74 -0
  233. package/dist/core/routing/lead-worker.js +43 -0
  234. package/dist/core/routing/pre-flight-estimator.js +108 -0
  235. package/dist/core/runs/run-tree.js +103 -0
  236. package/dist/core/security/injection-scanner.js +367 -0
  237. package/dist/core/security/output-filter.js +418 -0
  238. package/dist/core/session/env-file.js +105 -0
  239. package/dist/core/session/section-budgets.js +140 -0
  240. package/dist/core/session.js +92 -0
  241. package/dist/core/settings.js +298 -5
  242. package/dist/core/share/formatter.js +271 -0
  243. package/dist/core/share/redactor.js +221 -0
  244. package/dist/core/share/uploader.js +267 -0
  245. package/dist/core/skills/defaults.js +457 -0
  246. package/dist/core/skills/loader.js +22 -22
  247. package/dist/core/skills/sources.js +27 -27
  248. package/dist/core/smoke/headless-driver.js +174 -0
  249. package/dist/core/smoke/orchestrator.js +194 -0
  250. package/dist/core/smoke/runner.js +238 -0
  251. package/dist/core/smoke/scenario-parser.js +316 -0
  252. package/dist/core/statusline.js +99 -0
  253. package/dist/core/subagents/dispatcher-real.js +600 -0
  254. package/dist/core/subagents/dispatcher.js +132 -43
  255. package/dist/core/subagents/index.js +19 -6
  256. package/dist/core/subagents/isolation-matrix.js +213 -0
  257. package/dist/core/subagents/spawn.js +19 -4
  258. package/dist/core/telemetry/emitter.js +229 -0
  259. package/dist/core/telemetry/queue.js +251 -0
  260. package/dist/core/theme/context.js +91 -0
  261. package/dist/core/theme/presets.js +228 -0
  262. package/dist/core/theme/state.js +181 -0
  263. package/dist/core/todos/invariant.js +10 -0
  264. package/dist/core/todos/state.js +177 -0
  265. package/dist/core/tool-schema/compressor.js +89 -0
  266. package/dist/core/transport/version-interceptor.js +166 -0
  267. package/dist/core/trust.js +2 -2
  268. package/dist/core/tui/thinking-block.js +64 -0
  269. package/dist/core/vim/keymap.js +288 -0
  270. package/dist/core/vim/state.js +92 -0
  271. package/dist/core/watch-markers/marker-watcher.js +133 -0
  272. package/dist/core/worktree-manager/cleanup.js +123 -0
  273. package/dist/core/worktree-manager/manager.js +303 -0
  274. package/dist/index.js +36 -0
  275. package/dist/runtime/bootstrap.js +190 -0
  276. package/dist/runtime/cli.js +4203 -493
  277. package/dist/runtime/commands/agents.js +30 -30
  278. package/dist/runtime/commands/budget.js +5 -5
  279. package/dist/runtime/commands/cancel.js +231 -0
  280. package/dist/runtime/commands/chain.js +489 -0
  281. package/dist/runtime/commands/codegraph-status.js +227 -0
  282. package/dist/runtime/commands/compact.js +297 -0
  283. package/dist/runtime/commands/config.js +73 -39
  284. package/dist/runtime/commands/cost.js +199 -0
  285. package/dist/runtime/commands/delegate.js +244 -13
  286. package/dist/runtime/commands/dispatch.js +126 -0
  287. package/dist/runtime/commands/doctor.js +579 -0
  288. package/dist/runtime/commands/feedback.js +184 -0
  289. package/dist/runtime/commands/hooks.js +184 -0
  290. package/dist/runtime/commands/init.js +254 -0
  291. package/dist/runtime/commands/lsp.js +368 -0
  292. package/dist/runtime/commands/mcp.js +879 -0
  293. package/dist/runtime/commands/memory.js +582 -0
  294. package/dist/runtime/commands/model.js +237 -0
  295. package/dist/runtime/commands/onboarding.js +275 -0
  296. package/dist/runtime/commands/patch.js +128 -0
  297. package/dist/runtime/commands/permissions.js +112 -0
  298. package/dist/runtime/commands/plan.js +143 -0
  299. package/dist/runtime/commands/prd-check.js +285 -0
  300. package/dist/runtime/commands/privacy.js +17 -17
  301. package/dist/runtime/commands/recipe.js +325 -0
  302. package/dist/runtime/commands/redo-blob-store.js +92 -0
  303. package/dist/runtime/commands/redo.js +361 -0
  304. package/dist/runtime/commands/release-notes.js +229 -0
  305. package/dist/runtime/commands/repo-map.js +95 -0
  306. package/dist/runtime/commands/report.js +299 -0
  307. package/dist/runtime/commands/resume.js +118 -0
  308. package/dist/runtime/commands/review-consensus.js +68 -53
  309. package/dist/runtime/commands/rewind.js +333 -0
  310. package/dist/runtime/commands/roster.js +14 -14
  311. package/dist/runtime/commands/sessions.js +163 -0
  312. package/dist/runtime/commands/share.js +316 -0
  313. package/dist/runtime/commands/skills.js +31 -31
  314. package/dist/runtime/commands/status.js +186 -0
  315. package/dist/runtime/commands/stickers.js +82 -0
  316. package/dist/runtime/commands/style.js +194 -0
  317. package/dist/runtime/commands/theme.js +196 -0
  318. package/dist/runtime/commands/undo.js +54 -22
  319. package/dist/runtime/commands/update.js +289 -0
  320. package/dist/runtime/commands/vim.js +140 -0
  321. package/dist/runtime/commands/worktree.js +177 -0
  322. package/dist/runtime/commands/worktrees.js +155 -0
  323. package/dist/runtime/headless-repl.js +195 -0
  324. package/dist/runtime/headless.js +543 -0
  325. package/dist/runtime/load-hooks-or-exit.js +71 -0
  326. package/dist/runtime/plan-decompose.js +531 -0
  327. package/dist/runtime/sigint-guard.js +272 -0
  328. package/dist/runtime/update-check.js +28 -28
  329. package/dist/runtime/version.js +65 -0
  330. package/dist/skills/bundled/batch.js +617 -0
  331. package/dist/skills/bundled/index.js +45 -0
  332. package/dist/skills/bundled/loop.js +358 -0
  333. package/dist/skills/bundled/remember.js +383 -0
  334. package/dist/skills/bundled/simplify.js +289 -0
  335. package/dist/skills/bundled/skillify.js +373 -0
  336. package/dist/skills/bundled/stuck.js +558 -0
  337. package/dist/skills/bundled/verify.js +439 -0
  338. package/dist/testing/vcr.js +486 -0
  339. package/dist/tools/agent-tool.js +229 -0
  340. package/dist/tools/apply-patch.js +556 -0
  341. package/dist/tools/ask-user-question.js +288 -0
  342. package/dist/tools/ask-user.js +115 -0
  343. package/dist/tools/bash.js +624 -46
  344. package/dist/tools/brief.js +224 -0
  345. package/dist/tools/enter-worktree.js +250 -0
  346. package/dist/tools/exit-worktree.js +147 -0
  347. package/dist/tools/file-tools.js +161 -44
  348. package/dist/tools/lsp-tools.js +189 -0
  349. package/dist/tools/mcp-tool.js +260 -0
  350. package/dist/tools/multi-edit.js +361 -0
  351. package/dist/tools/powershell.js +268 -0
  352. package/dist/tools/registry.js +85 -0
  353. package/dist/tools/skill-tool.js +96 -0
  354. package/dist/tools/sleep.js +99 -0
  355. package/dist/tools/synthetic-output.js +133 -0
  356. package/dist/tools/tasks.js +208 -0
  357. package/dist/tools/todo-write.js +184 -0
  358. package/dist/tools/verify-plan-execution.js +295 -0
  359. package/dist/tools/web-fetch-injection-scanner.js +207 -0
  360. package/dist/tools/web-fetch.js +195 -10
  361. package/dist/tools/web-search.js +458 -0
  362. package/dist/tui/agent-progress-card.js +111 -0
  363. package/dist/tui/agent-tree.js +11 -1
  364. package/dist/tui/ask-modal.js +14 -14
  365. package/dist/tui/ask-user-question-chips.js +257 -0
  366. package/dist/tui/ask-user-question-prompt.js +203 -0
  367. package/dist/tui/compact-banner.js +81 -0
  368. package/dist/tui/conversation-pane.js +85 -11
  369. package/dist/tui/cost-table.js +111 -0
  370. package/dist/tui/device-flow.js +2 -2
  371. package/dist/tui/doctor-table.js +46 -0
  372. package/dist/tui/feedback-prompt.js +156 -0
  373. package/dist/tui/input-box.js +247 -32
  374. package/dist/tui/login-picker.js +3 -3
  375. package/dist/tui/markdown-render.js +6 -6
  376. package/dist/tui/onboarding-wizard.js +240 -0
  377. package/dist/tui/permissions-picker.js +86 -0
  378. package/dist/tui/render.js +35 -0
  379. package/dist/tui/repl-render.js +332 -54
  380. package/dist/tui/repl-splash-art.js +16 -16
  381. package/dist/tui/repl-splash-mascot.js +48 -24
  382. package/dist/tui/repl-splash.js +22 -22
  383. package/dist/tui/repl.js +124 -44
  384. package/dist/tui/slash-palette.js +6 -6
  385. package/dist/tui/splash.js +2 -2
  386. package/dist/tui/status-bar.js +109 -31
  387. package/dist/tui/status-table.js +7 -0
  388. package/dist/tui/stickers-art.js +136 -0
  389. package/dist/tui/style-table.js +28 -0
  390. package/dist/tui/theme-table.js +29 -0
  391. package/dist/tui/thinking-spinner.js +123 -0
  392. package/dist/tui/tool-stream-pane.js +53 -4
  393. package/dist/tui/update-banner.js +27 -2
  394. package/dist/tui/vim-input.js +267 -0
  395. package/dist/tui/welcome-banner.js +107 -0
  396. package/dist/tui/welcome-data.js +293 -0
  397. package/dist/tui/workspace-context.js +2 -2
  398. package/docs/examples/codegraph.mcp.json +10 -0
  399. package/package.json +25 -7
  400. package/test/scenarios/codegen-create-file.scenario.txt +13 -0
  401. package/test/scenarios/compact-force.scenario.txt +11 -0
  402. package/test/scenarios/identity.scenario.txt +11 -0
  403. package/test/scenarios/persona-handoff.scenario.txt +11 -0
  404. package/test/scenarios/walkback.scenario.txt +12 -0
  405. package/dist/core/engine/compaction-hook.js +0 -154
@@ -1,25 +1,54 @@
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';
11
+ import { writeAuditEvent } from '../audit/audit-trail.js';
7
12
  import { buildExecutor, buildToolsSchema } from './tool-bridge.js';
8
13
  import { personaSlugFor, systemPromptFor } from './prompts.js';
14
+ import { CancellationToken } from '../repl/cancellation.js';
15
+ import { fireTaskCompletedChain } from '../hook-chains.js';
16
+ // β5a R5+R6 + P1 : per-turn `<context>` prefix + intent
17
+ // classifier marker. Both pure functions, no fs cost at adapter init.
18
+ // Per-dir markdown traverse fires once per `run()`; budget capped so
19
+ // it never dominates the prompt budget.
20
+ import { buildContextPrefix, spliceContextPrefix } from './context-prefix.js';
21
+ import { applyIntentMarker, classifyIntent } from './intent.js';
22
+ import { loadTraversedMarkdown } from '../context/markdown-traverse.js';
23
+ import { isBareMode } from '../bare-mode/index.js';
24
+ import { walkUpPugiMd } from '../pugi-md/walk-up.js';
25
+ import { renderAmbientContext } from '../pugi-md/context-injector.js';
26
+ // Backlog : `@import` + `paths:` glob loader.
27
+ // Runs over each `HierarchyFile` the walker returns to expand imports
28
+ // (capped + cycle-safe) and capture per-rule `paths:` frontmatter. The
29
+ // loader is pure-fs so it cannot break the engine loop — any failure
30
+ // degrades to "no expansion for this file" and the un-expanded walker
31
+ // body is used as the rule body.
32
+ import { loadRulesFile } from '../pugi-md/cc-compat-rules.js';
33
+ import { homedir as osHomedir } from 'node:os';
34
+ // L11 : per-session DenialTrackingState. One instance
35
+ // per `run()` so denials cluster by (tool, args) within the same
36
+ // command but do NOT leak across CLI invocations.
37
+ import { DenialTrackingState } from '../denial-tracking/state.js';
9
38
  /**
10
39
  * Real `NativePugiEngineAdapter`. Drives the Pugi CLI's tool-use loop:
11
40
  *
12
- * 1. Pick a system prompt + persona based on the task kind
13
- * (code/explain/fix/plan/build).
14
- * 2. Build an OpenAI-shaped tools schema from the local tool registry,
15
- * gated by plan-mode (read-only).
16
- * 3. Open a workspace tool context (settings, session, read cache).
17
- * 4. Drive `runEngineLoop` against an `EngineLoopClient` until the
18
- * model returns a final text answer or the per-command budget is
19
- * exhausted.
20
- * 5. Surface every turn / tool call into both the engine event stream
21
- * (consumer-visible status events) and the existing session log
22
- * (`.pugi/events.jsonl`) so audit replay sees every step.
41
+ * 1. Pick a system prompt + persona based on the task kind
42
+ * (code/explain/fix/plan/build).
43
+ * 2. Build an OpenAI-shaped tools schema from the local tool registry,
44
+ * gated by plan-mode (read-only).
45
+ * 3. Open a workspace tool context (settings, session, read cache).
46
+ * 4. Drive `runEngineLoop` against an `EngineLoopClient` until the
47
+ * model returns a final text answer or the per-command budget is
48
+ * exhausted.
49
+ * 5. Surface every turn / tool call into both the engine event stream
50
+ * (consumer-visible status events) and the existing session log
51
+ * (`.pugi/events.jsonl`) so audit replay sees every step.
23
52
  *
24
53
  * The adapter is intentionally transport-agnostic. `client` is required
25
54
  * at construction; the CLI builds an `AnvilEngineLoopClient` from the
@@ -28,12 +57,12 @@ import { personaSlugFor, systemPromptFor } from './prompts.js';
28
57
  * up so unit tests can construct the adapter with an in-memory client.
29
58
  *
30
59
  * The engine task → loop mapping:
31
- * - `task.kind === 'build_task'` is mapped to the `build` command.
32
- * - `task.prompt` is the user message.
33
- * - `task.workspaceRoot` pins the workspace root for tool execution.
34
- * - `task.permissionMode` is read by the existing permission module;
35
- * the adapter itself only enforces the plan-mode tool gate which is
36
- * keyed on `kind`, not on permissionMode.
60
+ * - `task.kind === 'build_task'` is mapped to the `build` command.
61
+ * - `task.prompt` is the user message.
62
+ * - `task.workspaceRoot` pins the workspace root for tool execution.
63
+ * - `task.permissionMode` is read by the existing permission module;
64
+ * the adapter itself only enforces the plan-mode tool gate which is
65
+ * keyed on `kind`, not on permissionMode.
37
66
  */
38
67
  export class NativePugiEngineAdapter {
39
68
  options;
@@ -41,7 +70,7 @@ export class NativePugiEngineAdapter {
41
70
  /**
42
71
  * Per-adapter scratch map: links the loop's tool_call id to the
43
72
  * audit record id returned by `recordToolCall`. Code Reviewer P2
44
- * retro 2026-05-23 moved this off the module scope — two adapters
73
+ * retro moved this off the module scope — two adapters
45
74
  * driven concurrently (cabinet UI + CLI on the same process) would
46
75
  * otherwise share the same Map and a fast turn from adapter A
47
76
  * could `.delete()` an entry that belonged to adapter B before its
@@ -50,8 +79,30 @@ export class NativePugiEngineAdapter {
50
79
  * to a single `run()` invocation.
51
80
  */
52
81
  engineToolCallIds = new Map();
82
+ /**
83
+ * β3 streaming additive: optional typed event emitter that mirrors
84
+ * every async-queue event so external consumers (admin-api SSE
85
+ * controller, future cabinet WebSocket relay) can attach without
86
+ * holding the async iterator. The CLI itself only consumes the
87
+ * `AsyncIterable<EngineEvent>` returned by `run()`; the emitter is
88
+ * a fan-out point for additional subscribers.
89
+ */
90
+ streamEmitter = new EngineEventEmitter();
53
91
  constructor(options) {
54
92
  this.options = options;
93
+ // β2a r1 (Backend Architect P1): kick off the real
94
+ // dispatcher's module import at adapter init so the first
95
+ // `agent` tool call does not pay 50-200ms cold-start. We fire
96
+ // the promise without awaiting — by the time the engine loop
97
+ // runs and the model issues an `agent` call, the import has
98
+ // resolved. The promise is swallowed because a failed prewarm
99
+ // would surface again at dispatch time with the real error.
100
+ void prewarmRealDispatch().catch(() => {
101
+ // Intentional no-op: the actual dispatch call will surface
102
+ // the import failure (if any) with the right call stack. A
103
+ // prewarm-time failure is just a missed optimization, not a
104
+ // correctness issue.
105
+ });
55
106
  }
56
107
  async capabilities() {
57
108
  return {
@@ -59,7 +110,13 @@ export class NativePugiEngineAdapter {
59
110
  supportsFileEdits: true,
60
111
  supportsShell: true,
61
112
  supportsLsp: false,
62
- supportsSubagents: false,
113
+ // β2 S2 : real subagent dispatch shipped via the
114
+ // `agent` tool (apps/pugi-cli/src/tools/agent-tool.ts) plus the
115
+ // genuine `runEngineLoop`-backed dispatcher
116
+ // (apps/pugi-cli/src/core/subagents/dispatcher-real.ts). The
117
+ // capability flag flips after S1 + S3 + S4 land so cabinet UI +
118
+ // remote orchestrators can rely on the advertised contract.
119
+ supportsSubagents: true,
63
120
  };
64
121
  }
65
122
  async *run(task, ctx) {
@@ -67,235 +124,993 @@ export class NativePugiEngineAdapter {
67
124
  const root = task.workspaceRoot;
68
125
  const session = this.options.session ?? openSession(root);
69
126
  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,
127
+ // P1 fix (deep audit): wire ctx.signal (AbortSignal) into
128
+ // a CancellationToken so the tool-bridge cancellation gate
129
+ // (`ctx.cancellation?.isAborted` check at tool-bridge.ts:656 +
130
+ // file-tools `gateOnCancellation` calls) fires when the operator
131
+ // aborts mid-tool. Before this fix `toolCtx` carried no cancellation
132
+ // field — only the next runEngineLoop iteration via `ctx.signal`
133
+ // aborted at the turn boundary, so a long-running tool (a sleeping
134
+ // bash command, a slow grep across the repo) could not be cancelled
135
+ // mid-call.
136
+ //
137
+ // The token is wired one-way: ctx.signal -> token. Aborting the
138
+ // token directly does NOT propagate back to the AbortSignal; the
139
+ // engine's own cancellation already lives upstream via the signal
140
+ // so the back-edge is unnecessary.
141
+ //
142
+ // r2 fix (triple-review P1): the abort listener was
143
+ // registered with `{ once: true }` — on actual abort it auto-detaches
144
+ // and disappears, but on the (common) NON-abort path where `run()`
145
+ // completes cleanly the listener stays attached to `ctx.signal`
146
+ // forever. Over a long REPL session (one shared AbortController per
147
+ // session, many run() invocations) listeners accumulate one per
148
+ // run, leaking memory and CPU on `dispatchEvent`. We now track the
149
+ // detach handle and call it unconditionally in the run()'s finally
150
+ // block so cleanup happens on both the success and abort paths.
151
+ const cancellation = new CancellationToken();
152
+ let detachAbortListener;
153
+ if (ctx.signal) {
154
+ if (ctx.signal.aborted) {
155
+ cancellation.abort();
156
+ }
157
+ else {
158
+ const handler = () => cancellation.abort();
159
+ ctx.signal.addEventListener('abort', handler, { once: true });
160
+ detachAbortListener = () => {
161
+ ctx.signal.removeEventListener('abort', handler);
162
+ };
163
+ }
164
+ }
165
+ // r2 (triple-review P1): everything below runs inside a
166
+ // try/finally so the AbortSignal listener detaches on BOTH the
167
+ // success and abort paths. Without this wrap a long REPL session
168
+ // (one persistent AbortController, many run() invocations) leaked
169
+ // one abort listener per non-aborted run.
170
+ //
171
+ // #24 (CEO P1) — TaskCompleted chain. We
172
+ // capture `taskStartedAt` BEFORE the try block so the duration
173
+ // measured by the chain payload covers the full dispatch wall
174
+ // time (including the abort-listener wiring above). The
175
+ // `fireTaskCompletedOnce` guard ensures the chain fires at most
176
+ // once per `run()` invocation even when multiple `yield result`
177
+ // sites are reached (defensive — the existing flow yields exactly
178
+ // one result, but a future code path that yields twice would
179
+ // double-fire otherwise).
180
+ const taskStartedAt = Date.now();
181
+ let taskCompletedFired = false;
182
+ const fireTaskCompletedOnce = async (exitCode, toolCalls, filesChangedList) => {
183
+ if (taskCompletedFired)
184
+ return;
185
+ taskCompletedFired = true;
186
+ try {
187
+ await fireTaskCompletedChain(root, {
188
+ command: kind,
189
+ exitCode,
190
+ durationMs: Date.now() - taskStartedAt,
191
+ toolCalls,
192
+ filesChanged: [...filesChangedList],
193
+ });
194
+ }
195
+ catch (chainError) {
196
+ process.stderr.write(`[pugi hook-chains] TaskCompleted chain crashed: ${chainError.message}\n`);
83
197
  }
84
- : defaultEngineBudgets[kind];
85
- yield {
86
- type: 'status',
87
- message: `Pugi engine starting: kind=${kind} budget=${budget.maxToolCalls} calls / ${budget.maxTokens} tokens`,
88
198
  };
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
- });
199
+ try {
200
+ const toolCtx = {
201
+ root,
202
+ settings,
203
+ session,
204
+ readCache: new FileReadCache(),
205
+ cancellation,
206
+ };
207
+ // L11 : instantiate per-`run()` denial tracker. The
208
+ // executor records every refusal (PLAN_MODE_REFUSED, HOOK_BLOCKED,
209
+ // OPERATOR_ABORTED, STALE_READ, unknown-tool, plan-mode agent) and
210
+ // the user-prompt assembler below splices a compact reminder when
211
+ // the same (tool, args) pair has been denied twice or more. The
212
+ // tracker is in-memory only the audit ledger at
213
+ // `.pugi/events.jsonl` already captures the full per-event log for
214
+ // forensic replay; this surface is the model-facing aggregate.
215
+ const denialTracking = new DenialTrackingState();
216
+ // β1a r1 (budget wiring): swap the legacy SDK per-
217
+ // command budget lookup for the Pl9 `resolveBudget()` pipeline so
218
+ // `.pugi/settings.json::budgets.<command>` overrides actually take
219
+ // effect at runtime + the HARD_MAX_* caps guard misconfigured
220
+ // envelopes pre-flight. Before this fix the β1 Pl9 module
221
+ // (`core/engine/budgets.ts`) was dead code — the adapter still
222
+ // read the per-command defaults from the SDK, so operators who
223
+ // set `budgets.code.maxTokens = 50000` in settings.json got the
224
+ // legacy 30k anyway and `assertBudgetWithinTier` never ran.
225
+ //
226
+ // Task-level token override (e.g. CLI `--max-tokens`) keeps
227
+ // precedence; tool-call ceiling falls through to the resolved
228
+ // budget so a careless caller cannot disable the call-count
229
+ // guard by setting only token count.
230
+ //
231
+ // Triple-review P1 follow-up : forward `task.budget.turns`
232
+ // through the resolver so `EngineBudget.maxTurns` actually lands on
233
+ // the SDK's `runEngineLoop`. The CLI seam packs both `--max-turns`
234
+ // (explicit operator override) and the intensity profile's per-tier
235
+ // cap into this field with explicit-flag-wins precedence.
236
+ const taskBudgetOverride = {};
237
+ if (task.budget?.tokens)
238
+ taskBudgetOverride.maxTokens = task.budget.tokens;
239
+ if (task.budget?.turns !== undefined)
240
+ taskBudgetOverride.maxTurns = task.budget.turns;
241
+ const budget = resolveBudget(kind, settings, Object.keys(taskBudgetOverride).length > 0 ? taskBudgetOverride : undefined);
242
+ // CEO P1 #14 (auto-compact): resolve the per-workspace
243
+ // override of the 75% threshold gate. Default is `{ enabled: true,
244
+ // thresholdRatio: 0.75 }`; operators kill it via
245
+ // `.pugi/settings.json::autoCompact.enabled = false` или retune the
246
+ // ratio. The resolved config is captured by the closure that
247
+ // `runEngineLoop` invokes pre-send on every turn.
248
+ const autoCompactConfig = resolveAutoCompactConfig(settings);
249
+ // β3 streaming: pre-build the typed stream event queue so the hook
250
+ // callbacks below can push live events that this async generator
251
+ // yields IMMEDIATELY (instead of buffering until `runEngineLoop`
252
+ // completes). Operator now sees the first `tool.start` within
253
+ // ~tens of ms of the model emitting it, not 30+ s after the loop
254
+ // settles.
255
+ const streamQueue = new AsyncEventQueue();
256
+ const emitter = this.streamEmitter;
257
+ const supportsThinking = modelSupportsThinking(this.options.model);
258
+ /**
259
+ * Push one typed stream event into BOTH the per-run async queue
260
+ * (the CLI's iterator) and the long-lived emitter (the multiplex
261
+ * fan-out for admin-api SSE / cabinet WebSocket subscribers).
262
+ * The function stamps `timestamp` once so both consumers see the
263
+ * same wall clock.
264
+ */
265
+ const emitStream = (event) => {
266
+ const stamped = {
267
+ ...event,
268
+ timestamp: new Date().toISOString(),
269
+ };
270
+ streamQueue.push(stamped);
271
+ emitter.emit('event', stamped);
272
+ };
273
+ // r1 fix per triple-review Backend Architect P1: unify yield path via
274
+ // emitStream + streamQueue drain so the iterator consumer does NOT
275
+ // see this status frame twice. Pre-fix did both bare yield + emitStream
276
+ // → iterator got 2 copies, emitter got 1.
277
+ emitStream({
278
+ type: 'status',
279
+ message: `Pugi engine starting: kind=${kind} budget=${budget.maxToolCalls} calls / ${budget.maxTokens} tokens`,
280
+ });
281
+ // #21 : emit `dispatch_start` to the
282
+ // tenant-wide audit trail at `~/.pugi/audit/<tenant>/<slug>-<hash>
283
+ // .jsonl`. Append-only, never throws — a misconfigured audit
284
+ // surface must not block a dispatch. The per-session mirror under
285
+ // `.pugi/sessions/<id>/events.jsonl` remains as a redundant copy.
286
+ writeAuditEvent({
287
+ event: 'dispatch_start',
288
+ sessionId: session.id,
289
+ workspaceRoot: root,
290
+ data: {
291
+ kind,
292
+ promptLength: task.prompt.length,
293
+ maxToolCalls: budget.maxToolCalls,
294
+ maxTokens: budget.maxTokens,
295
+ model: this.options.model ?? null,
296
+ },
297
+ });
298
+ // β5a R1+R4+R5+R6+P1 : build the per-turn `<context>`
299
+ // prefix and apply the intent marker so the model sees:
300
+ // 1. cwd + open-files + per-dir-conventions block (R5+R6)
301
+ // 2. a `<intent kind="definitional">` wrapper when the operator
302
+ // asked a knowledge question (P1) — fixes the "What is grep?
303
+ // → bash man grep" loss mode flagged by the .X eval.
304
+ //
305
+ // All caps enforced inside the builders (5 KB block + 50 entries
306
+ // + top-3 markdown). Worst-case prompt growth is ~5 KB, well
307
+ // inside any per-command token budget.
308
+ //
309
+ // cwd is sourced from `process.cwd()` — the operator's shell pwd
310
+ // when they invoked `pugi`. For non-REPL CLI paths this is
311
+ // accurate; the REPL session retains the launch cwd for the
312
+ // lifetime of the session which is what the operator expects.
313
+ const cwdForTraverse = process.cwd();
314
+ // cwd → homedir walk-up that picks up every
315
+ // ambient `PUGI.md` (or `CLAUDE.md` as a fallback) the operator
316
+ // has placed above their workspace. This is the cross-project
317
+ // hierarchy walk — distinct from the workspace-bounded
318
+ // `loadTraversedMarkdown` below which only sees files INSIDE the
319
+ // workspace root. Render the concatenation once at session boot
320
+ // and prepend to the system prompt so the model treats the
321
+ // operator's personal guidance as ambient context for the whole
322
+ // session. `--bare` () skips this walk entirely.
323
+ let ambientContextBlock = '';
324
+ if (!isBareMode()) {
325
+ try {
326
+ const hierarchy = walkUpPugiMd(cwdForTraverse);
327
+ // Backlog : expand `@import` directives and
328
+ // capture `paths:` frontmatter for each ambient file. The
329
+ // walker already returned the raw bodies; the loader replaces
330
+ // each body with its `@import`-expanded variant + appends any
331
+ // imported children at the same hierarchy level. Failures are
332
+ // localised per-file so one malformed `~/CLAUDE.md` cannot
333
+ // break the rest of the chain.
334
+ const expanded = await expandHierarchyWithImports(hierarchy, cwdForTraverse);
335
+ ambientContextBlock = renderAmbientContext(expanded);
132
336
  }
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)`,
337
+ catch {
338
+ // Pure FS surface — if it throws (programmer error in the
339
+ // walker, not a per-file fs error which is already swallowed
340
+ // inside) we drop ambient context for this session rather
341
+ // than crashing the engine loop. Doctor probe still surfaces
342
+ // the hierarchy state for operator triage.
343
+ ambientContextBlock = '';
344
+ }
345
+ }
346
+ // AST-light repo-map injection. We build a
347
+ // compact `## Repo map` block (capped at the formatter's default
348
+ // 8 KB ≈ 2K tokens) from the workspace source tree + splice it
349
+ // onto the system prompt alongside the ambient PUGI.md block.
350
+ // `--bare` skips this exactly like the PUGI.md walk — the engine
351
+ // sees nothing the operator did not explicitly hand it. The build
352
+ // is deferred к `setImmediate` semantics by being a sync call
353
+ // AFTER the boot probes; the cost is one stat per source file
354
+ // (the cache catches mtime-unchanged files и skips re-extraction).
355
+ // Failures are swallowed: repo-map is enrichment, never a gate.
356
+ let repoMapBlock = '';
357
+ if (!isBareMode()) {
358
+ try {
359
+ const { buildAndFormatRepoMap } = await import('../repo-map/build.js');
360
+ const verdict = buildAndFormatRepoMap({
361
+ root,
362
+ // Boot path is best-effort: never refresh during engine boot
363
+ // (the operator can `pugi repo-map --refresh` manually). The
364
+ // cache freshness check catches every realistic edit pattern
365
+ // and avoids walking the tree on every engine invocation.
366
+ refresh: false,
367
+ // Persist the cache so the next boot reuses extracts. Engine
368
+ // boot runs on every command, so missing the persist would
369
+ // hot-loop the extractor on each invocation.
370
+ writeCache: true,
371
+ // Omit the formatter's section header — the system prompt
372
+ // already structures the ambient blocks, и a second `##`
373
+ // would fragment the prompt cache на a model-by-model basis.
374
+ omitHeader: false,
137
375
  });
138
- appendSessionMirror(sessionEventsPath, {
139
- type: 'turn_complete',
140
- turn: turnIndex + 1,
141
- stop: 'text',
142
- contentLength: response.content.length,
143
- tokensUsed: response.tokensUsed,
376
+ if (verdict.build.ok && verdict.format && verdict.format.bytes > 0) {
377
+ repoMapBlock = verdict.format.text;
378
+ }
379
+ }
380
+ catch {
381
+ // Any failure in the repo-map pipeline drops the block. The
382
+ // engine continues without enrichment — the failure mode is
383
+ // identical to the cold-boot path before L28 landed.
384
+ repoMapBlock = '';
385
+ }
386
+ }
387
+ let traverseResult;
388
+ // `--bare` skips the parent-dir PUGI.md /
389
+ // AGENTS.md / CLAUDE.md / GEMINI.md walk-up. The engine sees only
390
+ // the operator's prompt + working-set + intent marker, with no
391
+ // ambient project context injection. Mirrors the standard tool's
392
+ // --bare semantics.
393
+ if (isBareMode()) {
394
+ traverseResult = { loaded: [], warnings: [], totalBytes: 0 };
395
+ }
396
+ else {
397
+ try {
398
+ traverseResult = await loadTraversedMarkdown({
399
+ cwd: cwdForTraverse,
400
+ workspaceRoot: root,
144
401
  });
145
402
  }
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);
403
+ catch {
404
+ // Per-dir markdown is a NICE-TO-HAVE; a fs error here must
405
+ // never break the engine loop. Fall back to an empty result
406
+ // so the prefix block still surfaces cwd + working set.
407
+ traverseResult = { loaded: [], warnings: [], totalBytes: 0 };
161
408
  }
162
- buffer.push({
409
+ }
410
+ const intentClassification = classifyIntent(task.prompt);
411
+ const intentHint = intentClassification.intent !== 'ambiguous' ? intentClassification.intent : undefined;
412
+ const cwdRelative = relativeOrAbsolute(root, cwdForTraverse);
413
+ const prefix = buildContextPrefix({
414
+ cwdRelative,
415
+ // β5a defers wiring the live WorkingSet snapshot to the REPL
416
+ // session integration (R5+R6 here only covers the engine-side
417
+ // builder). When the REPL passes its working set down, the
418
+ // engine surface fills in. For now the prefix carries cwd +
419
+ // per-dir conventions + intent which are the two biggest
420
+ // win-rate moves per the .X eval.
421
+ traversedMarkdown: traverseResult.loaded,
422
+ intentHint,
423
+ });
424
+ if (prefix.bytes > 0 || intentClassification.intent === 'definitional') {
425
+ emitStream({
163
426
  type: 'status',
164
- message: `tool_call: ${call.name}(${call.arguments.slice(0, 80)}${call.arguments.length > 80 ? '...' : ''})`,
427
+ message: `context: cwd=${cwdRelative} per-dir-md=${prefix.counts.markdownIncluded}/${prefix.counts.markdownTotal} intent=${intentClassification.intent}`,
165
428
  });
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));
429
+ }
430
+ const decoratedPrompt = applyIntentMarker(task.prompt, intentClassification.intent);
431
+ const finalUserPrompt = spliceContextPrefix(prefix.block, decoratedPrompt);
432
+ // Track files mutated by the loop. We extract the path from the JSON
433
+ // arguments of every successful write/edit tool call; `bash` is left
434
+ // out because its filesystem footprint is opaque (a single command
435
+ // can touch dozens of paths via `make`, `pnpm build`, etc). The
436
+ // per-session events.jsonl already carries every file_mutation event
437
+ // for replay; this set is only the headline summary the CLI prints.
438
+ const filesChanged = new Set();
439
+ // Pending lookup: call.id → path extracted from arguments. We only
440
+ // commit to `filesChanged` when the corresponding onToolResult fires
441
+ // with `ok: true`, so a refused or failed edit does not surface as
442
+ // a phantom change in the operator summary.
443
+ const pendingMutations = new Map();
444
+ // Per-session events mirror — `.pugi/sessions/<id>/events.jsonl`.
445
+ // The existing global log at `.pugi/events.jsonl` is preserved as
446
+ // the audit-replay source of truth; this mirror is the easy-to-find
447
+ // per-run log for operators and the cabinet UI (Sprint 2B).
448
+ const sessionEventsPath = openSessionMirror(root, session.id);
449
+ const hooks = {
450
+ // CEO P1 #14 (auto-compact): single operator-visible
451
+ // line on stderr — keep parity with the upstream tool's
452
+ // `Compacted N turns into Y tokens; continuing.` message. We mirror
453
+ // the event into the session log + stream emitter as a `status`
454
+ // frame так that admin-api SSE consumers + the cabinet UI render
455
+ // it without a schema change.
456
+ onAutoCompact: (event) => {
457
+ const pct = Math.round((event.preUsedTokens / Math.max(1, event.maxTokens)) * 100);
458
+ const line = `engine: auto-compacted ${event.droppedCount} turns at ${event.preUsedTokens}/${event.maxTokens} (${pct}%)`;
459
+ // Single-line stderr write — operator-visible per spec.
460
+ process.stderr.write(`${line}\n`);
461
+ emitStream({ type: 'status', message: line });
462
+ appendSessionMirror(sessionEventsPath, {
463
+ type: 'auto_compact',
464
+ droppedCount: event.droppedCount,
465
+ preUsedTokens: event.preUsedTokens,
466
+ postUsedTokens: event.postUsedTokens,
467
+ maxTokens: event.maxTokens,
468
+ gist: event.gist,
469
+ });
470
+ // #21: tenant-wide audit trail mirror.
471
+ writeAuditEvent({
472
+ event: 'auto_compact',
473
+ sessionId: session.id,
474
+ workspaceRoot: root,
475
+ data: {
476
+ droppedCount: event.droppedCount,
477
+ preUsedTokens: event.preUsedTokens,
478
+ postUsedTokens: event.postUsedTokens,
479
+ maxTokens: event.maxTokens,
480
+ },
481
+ });
482
+ },
483
+ onTurnStart: (turnIndex, messageCount) => {
484
+ const msg = `turn ${turnIndex + 1}: requesting model (transcript=${messageCount} messages)`;
485
+ emitStream({ type: 'status', message: msg });
486
+ appendSessionMirror(sessionEventsPath, { type: 'turn_start', turn: turnIndex + 1, transcript: messageCount });
487
+ },
488
+ onTurnComplete: (turnIndex, response) => {
489
+ if (response.stop === 'tool_use') {
490
+ const calls = response.assistantMessage.toolCalls ?? [];
491
+ emitStream({
492
+ type: 'status',
493
+ message: `turn ${turnIndex + 1}: model requested ${calls.length} tool call(s)`,
494
+ });
495
+ appendSessionMirror(sessionEventsPath, {
496
+ type: 'turn_complete',
497
+ turn: turnIndex + 1,
498
+ stop: 'tool_use',
499
+ toolCalls: calls.length,
500
+ tokensUsed: response.tokensUsed,
501
+ });
178
502
  }
179
- else {
180
- recordToolResult(session, auditId, 'error', result.error.slice(0, 200));
503
+ else if (response.stop === 'text') {
504
+ emitStream({
505
+ type: 'status',
506
+ message: `turn ${turnIndex + 1}: model returned final text (${response.content.length} chars)`,
507
+ });
508
+ appendSessionMirror(sessionEventsPath, {
509
+ type: 'turn_complete',
510
+ turn: turnIndex + 1,
511
+ stop: 'text',
512
+ contentLength: response.content.length,
513
+ tokensUsed: response.tokensUsed,
514
+ });
515
+ // β3 E4 thinking-block surface: only Claude / Gemini families
516
+ // advertise structured thinking today. The model resolver may
517
+ // return a slug we don't recognise; in that case we skip the
518
+ // split silently. When we DO recognise it, every `<thinking>`
519
+ // / `<thought>` block becomes a separate `thinking.start`/
520
+ // `thinking.delta`/`thinking.end` triplet so the TUI can
521
+ // render one collapsed pane row per block. The visible text
522
+ // (post-strip) flows to the regular `text.delta` channel so
523
+ // the conversation pane never shows raw <thinking> markup.
524
+ if (supportsThinking && response.content.length > 0) {
525
+ const split = splitThinkingBlocks(response.content);
526
+ for (const block of split.thinkingBlocks) {
527
+ const blockId = `think-${randomUUID().slice(0, 8)}`;
528
+ emitStream({ type: 'thinking.start', blockId });
529
+ emitStream({ type: 'thinking.delta', blockId, chunk: block });
530
+ emitStream({ type: 'thinking.end', blockId });
531
+ }
532
+ if (split.visibleText.length > 0) {
533
+ emitStream({ type: 'text.delta', chunk: split.visibleText });
534
+ }
535
+ }
536
+ else if (response.content.length > 0) {
537
+ emitStream({ type: 'text.delta', chunk: response.content });
538
+ }
539
+ }
540
+ },
541
+ onToolCall: (call) => {
542
+ // Record under an `engine_tool` prefix so the audit log can
543
+ // distinguish loop-driven calls from direct CLI tool calls.
544
+ const id = recordToolCall(session, `engine:${call.name}`, call.arguments.slice(0, 200));
545
+ // Stash the audit id on the call for `onToolResult` to close.
546
+ this.engineToolCallIds.set(call.id, id);
547
+ // Extract a candidate path for write/edit so we can build the
548
+ // filesChanged summary if (and only if) the call succeeds. Bad
549
+ // JSON is harmless here — we ignore it and the executor surfaces
550
+ // the actual parse error to the model.
551
+ if (call.name === 'write' || call.name === 'edit') {
552
+ const path = extractPathArg(call.arguments);
553
+ if (path)
554
+ pendingMutations.set(call.id, path);
181
555
  }
182
- this.engineToolCallIds.delete(call.id);
556
+ emitStream({
557
+ type: 'tool.start',
558
+ callId: call.id,
559
+ name: call.name,
560
+ arguments: call.arguments,
561
+ });
562
+ emitStream({
563
+ type: 'status',
564
+ message: `tool_call: ${call.name}(${call.arguments.slice(0, 80)}${call.arguments.length > 80 ? '...' : ''})`,
565
+ });
566
+ appendSessionMirror(sessionEventsPath, {
567
+ type: 'tool_call',
568
+ tool: call.name,
569
+ callId: call.id,
570
+ argsPreview: call.arguments.slice(0, 200),
571
+ });
572
+ // #21: tenant-wide audit trail mirror. Same payload
573
+ // shape as the session mirror but flattened so a `jq` query
574
+ // across all sessions for one (tenant, workspace) reads
575
+ // cleanly.
576
+ writeAuditEvent({
577
+ event: 'tool_call',
578
+ sessionId: session.id,
579
+ workspaceRoot: root,
580
+ data: {
581
+ tool: call.name,
582
+ callId: call.id,
583
+ argsPreview: call.arguments.slice(0, 200),
584
+ },
585
+ });
586
+ },
587
+ onToolResult: (call, result) => {
588
+ const auditId = this.engineToolCallIds.get(call.id);
589
+ if (auditId) {
590
+ if (result.ok) {
591
+ recordToolResult(session, auditId, 'success', result.content.slice(0, 200));
592
+ }
593
+ else {
594
+ recordToolResult(session, auditId, 'error', result.error.slice(0, 200));
595
+ }
596
+ this.engineToolCallIds.delete(call.id);
597
+ }
598
+ const pendingPath = pendingMutations.get(call.id);
599
+ if (pendingPath) {
600
+ if (result.ok)
601
+ filesChanged.add(pendingPath);
602
+ pendingMutations.delete(call.id);
603
+ }
604
+ emitStream({
605
+ type: 'tool.end',
606
+ callId: call.id,
607
+ ok: result.ok,
608
+ summary: result.ok
609
+ ? result.content.slice(0, 200)
610
+ : result.error.slice(0, 200),
611
+ });
612
+ emitStream({
613
+ type: 'status',
614
+ message: result.ok
615
+ ? `tool_result: ${call.name} ok`
616
+ : `tool_result: ${call.name} error: ${result.error.slice(0, 120)}`,
617
+ });
618
+ appendSessionMirror(sessionEventsPath, {
619
+ type: 'tool_result',
620
+ tool: call.name,
621
+ callId: call.id,
622
+ ok: result.ok,
623
+ summary: result.ok ? result.content.slice(0, 200) : result.error.slice(0, 200),
624
+ });
625
+ // #21: tenant-wide audit trail mirror.
626
+ writeAuditEvent({
627
+ event: 'tool_result',
628
+ sessionId: session.id,
629
+ workspaceRoot: root,
630
+ data: {
631
+ tool: call.name,
632
+ callId: call.id,
633
+ ok: result.ok,
634
+ summary: result.ok ? result.content.slice(0, 200) : result.error.slice(0, 200),
635
+ },
636
+ });
637
+ },
638
+ };
639
+ // β1b r1 (--allow-fetch / --allow-search wiring):
640
+ // compute the effective gate as OR of (a) the persisted
641
+ // settings.json opt-in and (b) the runtime CLI flag passed via
642
+ // the constructor. Before this fix the adapter only honored (a),
643
+ // so `pugi code --allow-fetch` against a default-privacy workspace
644
+ // silently fell back to "tool not advertised" even though the
645
+ // operator opted in for one invocation. The CLI flag was wired
646
+ // through to the legacy `pugi web` sub-command but not to the
647
+ // engine adapter — Backend Architect review (PR r1) caught
648
+ // the gap.
649
+ const allowFetchEffective = this.options.allowFetch === true || settings.web?.fetch?.enabled === true;
650
+ const allowSearchEffective = this.options.allowSearch === true || settings.web?.search?.enabled === true;
651
+ // β2 S3 → β2a r1 (Backend Architect P1):
652
+ // expose the `agent` tool to the parent loop ONLY for non-plan
653
+ // commands. `buildToolsSchema` also strips the agent tool from
654
+ // plan-mode schemas, but a model that fabricates an `agent` call
655
+ // would still hit the executor with `agentDispatch` wired and
656
+ // could spawn a coder that mutates the workspace — breaking the
657
+ // plan-mode read-only contract. Hard-gate `allowAgent` on the
658
+ // command kind so plan mode never wires the dispatch block in
659
+ // the first place; tool-bridge.ts also throws ToolRefused on a
660
+ // fabricated `agent` call in plan mode as defense in depth.
661
+ //
662
+ // Why only the top-level parent and not children: the dispatcher-
663
+ // real.ts module builds the CHILD's executor without an
664
+ // `agentDispatch` block so children cannot recursively spawn
665
+ // grandchildren. The isolation-matrix capability set then refuses
666
+ // the `agent` tool for every non-orchestrator role anyway, but
667
+ // the executor-level gate is the load-bearing chokepoint.
668
+ // Pugi backlog — intensity dial gates the `agent` tool surface.
669
+ // Plan-mode hard gate keeps its precedence (read-only contract);
670
+ // the intensity layer OR-s on top so `--intensity quick|standard`
671
+ // suppresses the dispatch block even on non-plan kinds.
672
+ const intensityAllowsAgent = this.options.intensityProfile?.allowParallelAgents ?? true;
673
+ const allowAgent = kind !== 'plan' && intensityAllowsAgent;
674
+ // Pugi backlog — resolve the effective model hint. Operator-
675
+ // pinned `model` option wins outright. Otherwise the intensity
676
+ // profile's `modelTag` resolves to a concrete slug via the
677
+ // `PUGI_INTENSITY_MODEL_<TAG>` env (LIGHT / STANDARD / HEAVY) so
678
+ // ops can pin "what does 'standard' mean on this machine" without
679
+ // a code change. Absent profile + absent env => undefined (legacy
680
+ // per-persona resolution path).
681
+ const effectiveModel = resolveIntensityModel(this.options.model, this.options.intensityProfile);
682
+ // β3 streaming: kick off `runEngineLoop` IN PARALLEL with the queue
683
+ // drain. The loop's hook callbacks push events onto `streamQueue`
684
+ // synchronously; this generator yields them live by awaiting the
685
+ // queue's iterator. When the loop settles (success or crash) we
686
+ // close the queue, which lets the iterator return cleanly and the
687
+ // generator falls through to the terminal `result` frame.
688
+ //
689
+ // Why concurrent instead of serial:
690
+ //
691
+ // The β1 adapter awaited `runEngineLoop` to completion, then
692
+ // drained an in-memory `EngineEvent[]` buffer. Operator saw
693
+ // nothing for 30+ seconds (the full LLM round-trip + tool exec
694
+ // wall time), then the entire log dumped at once. The TUI tool-
695
+ // stream pane was a no-op because no event ever reached it
696
+ // before the loop completed.
697
+ //
698
+ // `Promise.race`-based interleaving lets us yield the next queue
699
+ // event OR detect loop settlement on each tick. The settlement
700
+ // flag (`loopSettled`) gates the final drain so we never miss
701
+ // tail events that the hooks pushed in the same microtask as
702
+ // the loop's terminal `return`.
703
+ // Boxed via single-element tuple so TypeScript does not narrow the
704
+ // outer `outcome` binding to `null` after the closure mutation.
705
+ // Async-closure mutations are invisible to TS control-flow analysis;
706
+ // wrapping in a tuple defeats the narrowing without an unsafe cast.
707
+ const outcomeBox = [null];
708
+ let loopError = null;
709
+ const loopPromise = (async () => {
710
+ try {
711
+ outcomeBox[0] = await runEngineLoop({
712
+ client: this.options.client,
713
+ executor: buildExecutor({
714
+ kind,
715
+ ctx: toolCtx,
716
+ sessionId: session.id,
717
+ workspaceRoot: root,
718
+ // P1 fix (deep audit): forward optional REPL
719
+ // ask-modal bridge. Default `interactive: false` preserves
720
+ // backward compat — non-TTY callers (CI, pipes, scripted
721
+ // CLI runs) keep the `[user_input_required]` envelope path.
722
+ // The REPL layer passes `interactive: true` + a real
723
+ // `askUserBridge` so model-initiated `ask_user_question`
724
+ // calls round-trip to the ink modal and return the
725
+ // operator's choice as a tool result.
726
+ interactive: this.options.interactive === true,
727
+ ...(this.options.askUserBridge
728
+ ? { askUserBridge: this.options.askUserBridge }
729
+ : {}),
730
+ // P1 fix (deep audit): forward the workspace
731
+ // HookRegistry so `.pugi/hooks/` lifecycle hooks fire for
732
+ // model-initiated tool calls. SECURITY: a `PreToolUse
733
+ // onFailure: 'block'` hook that refuses bash containing
734
+ // `rm` now applies to model dispatch — before this fix
735
+ // such a hook only applied to direct CLI tool calls.
736
+ ...(this.options.hooks ? { hooks: this.options.hooks } : {}),
737
+ // β1a r1 (web_fetch gating) + β1b r1 (--allow-fetch wiring):
738
+ // executor allowFetch matches the schema-advertise gate so a
739
+ // settings.json opt-in OR a --allow-fetch flag enables the
740
+ // call. Without this the model would not even see the
741
+ // `web_fetch` tool. `allowSearch` covers the new T4
742
+ // `web_search` tool with the same OR semantics.
743
+ allowFetch: allowFetchEffective,
744
+ allowSearch: allowSearchEffective,
745
+ // β2 S3 → β2a r1 : parent-level agentDispatch
746
+ // wiring. When the model emits a `tool_call: agent(role,
747
+ // brief)`, the executor forwards it to dispatcher-real.ts
748
+ // which spawns a child engine loop against the same Anvil
749
+ // client. Gated by `allowAgent` so plan mode does not even
750
+ // wire the dispatch block — defense in depth on top of the
751
+ // schema-filter and the tool-bridge plan-mode refusal.
752
+ ...(allowAgent
753
+ ? {
754
+ agentDispatch: {
755
+ parentSession: session,
756
+ engineClient: this.options.client,
757
+ },
758
+ }
759
+ : {}),
760
+ // β4 M1/M3/M5: pass the loaded MCP registry through so the
761
+ // executor can route `mcp__server__tool` calls + run the
762
+ // first-call permission prompt before dispatching upstream.
763
+ ...(this.options.mcpRegistry ? { mcpRegistry: this.options.mcpRegistry } : {}),
764
+ ...(this.options.mcpPrompt ? { mcpPrompt: this.options.mcpPrompt } : {}),
765
+ // L11 : per-`run()` denial tracker. Every
766
+ // refusal sentinel (PLAN_MODE_REFUSED, HOOK_BLOCKED,
767
+ // OPERATOR_ABORTED, STALE_READ, unknown-tool, plan-mode
768
+ // agent) is fingerprinted by (toolName, sha256(canonical
769
+ // args)) so the model's next-turn reminder surfaces the
770
+ // pattern instead of re-issuing the same refused call.
771
+ denialTracking,
772
+ }),
773
+ // ambient `PUGI.md` hierarchy block
774
+ // prepended once at session boot. When the walk found
775
+ // nothing OR bare mode is on, `ambientContextBlock === ''`
776
+ // and the system prompt is unchanged — no leading blank
777
+ // line, no empty wrapper tag.
778
+ //
779
+ // task #19 : static / dynamic
780
+ // split via `__PUGI_DYNAMIC_BOUNDARY__` sentinel. The persona
781
+ // prompt (`systemPromptFor(kind)`) is byte-stable across
782
+ // sessions of the same command kind — it goes BEFORE the
783
+ // boundary so Anvil's prefix cache hits on the common
784
+ // prefix. Per-workspace blocks (PUGI.md hierarchy, repo
785
+ // map) live AFTER the boundary because they change with
786
+ // the user's checkout state.
787
+ //
788
+ // ORDERING CHANGE — pre-#19 the model saw
789
+ // ambient → repoMap → persona
790
+ // post-#19 the model sees
791
+ // persona → ambient → repoMap
792
+ // This is INTENTIONAL — the cache prefix MUST be byte-stable
793
+ // and the persona is the only byte-stable block. Operators
794
+ // who relied on ambient guidance "fronting" the persona prompt
795
+ // () should now place that guidance inside
796
+ // the persona via `systemPromptFor(kind)` instead of PUGI.md.
797
+ // The empirical impact on model behaviour is bounded: persona
798
+ // prompts are tight directives; ambient PUGI.md is operator
799
+ // context. Either order is interpretable; the cache hit
800
+ // outweighs the front-loading.
801
+ systemPrompt: composeSystemPromptWithBoundary([systemPromptFor(kind)], [ambientContextBlock, repoMapBlock]),
802
+ // β5a R5+R6+P1: per-turn `<context>` prefix + intent marker
803
+ // applied above. Falls back to verbatim `task.prompt` when
804
+ // both the prefix block is empty AND the intent classifier
805
+ // returned ambiguous (the splice + apply functions handle
806
+ // that case as identity).
807
+ userPrompt: finalUserPrompt,
808
+ // β1a r1 (web_fetch gating) + β1b r1 (--allow-fetch wiring):
809
+ // pass the OR of `.pugi/settings.json::web.fetch.enabled` and
810
+ // the runtime `--allow-fetch` flag. When neither is true the
811
+ // `web_fetch` tool is not advertised to the model at all.
812
+ // `allowSearch` does the same for the new `web_search` tool.
813
+ // β2 S3: allowAgent surfaces the `agent` tool in the schema
814
+ // so the model sees it as a valid tool call option; the
815
+ // capability-matrix layer (S4) still gates which roles can
816
+ // actually USE it. Plan mode strips it via β2a r1 gate.
817
+ tools: buildToolsSchema(kind, {
818
+ allowFetch: allowFetchEffective,
819
+ allowSearch: allowSearchEffective,
820
+ allowAgent,
821
+ // β4 M1/M3: same registry the executor saw. Schema +
822
+ // dispatcher must agree on which MCP names are advertised
823
+ // and which are dispatchable; passing identical references
824
+ // makes that invariant impossible to break.
825
+ ...(this.options.mcpRegistry ? { mcpRegistry: this.options.mcpRegistry } : {}),
826
+ }),
827
+ budget,
828
+ personaSlug: personaSlugFor(kind),
829
+ hooks,
830
+ temperature: this.options.temperature ?? 0.2,
831
+ signal: ctx.signal,
832
+ // β1 (audit E2): forward CLI sub-command + routing tag +
833
+ // operator-pinned model so the runtime controller's DTO sees
834
+ // all three. `tag` derives 1:1 from `command` for now
835
+ // (`code → code`, `build → build_task`, etc.); future routing
836
+ // changes flip the mapping table without touching the call
837
+ // site. `model` is left undefined here — operator-pinned model
838
+ // pinning ships in β6 with persona routing.
839
+ command: kind,
840
+ tag: dispatchTagFor(kind),
841
+ model: effectiveModel,
842
+ // Task — 1M context tier opt-in. Forwarded к the SDK
843
+ // driver which threads it through every `client.send` call to
844
+ // the runtime gate. `undefined` (the default) preserves
845
+ // legacy routing.
846
+ contextTier: this.options.contextTier,
847
+ // CEO P1 #14 (auto-compact): pluggable compactor
848
+ // hook. The SDK driver invokes this pre-`client.send` on every
849
+ // turn. `maybeCompact` returns `null` below the 75% threshold
850
+ // или when the transcript is too short to drop history — the
851
+ // loop continues unchanged on the cold path. When it returns
852
+ // a result, the driver swaps the transcript + fires the
853
+ // `onAutoCompact` hook above which emits the stderr line.
854
+ autoCompact: ({ transcript, maxTokens }) => maybeCompact(transcript, maxTokens, autoCompactConfig),
855
+ });
183
856
  }
184
- const pendingPath = pendingMutations.get(call.id);
185
- if (pendingPath) {
186
- if (result.ok)
187
- filesChanged.add(pendingPath);
188
- pendingMutations.delete(call.id);
857
+ catch (err) {
858
+ loopError = err;
189
859
  }
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)}`,
860
+ finally {
861
+ // Close the queue so the iterator below returns `done: true`.
862
+ // Any tail events the hooks pushed in the same microtask still
863
+ // drain because `AsyncEventQueue.close()` only resolves
864
+ // PENDING awaiters buffered items stay readable.
865
+ streamQueue.close();
866
+ }
867
+ })();
868
+ // Drain the queue live. Each iteration yields one EngineEvent the
869
+ // moment its hook fired. Operator sees `tool.start` within tens of
870
+ // ms of the model emitting it.
871
+ for await (const event of streamQueue) {
872
+ yield streamEventToEngineEvent(event);
873
+ }
874
+ // Loop has settled (queue closed). Surface its outcome — either an
875
+ // unhandled crash from the (rare) executor exception path or the
876
+ // structured EngineLoopOutcome.
877
+ await loopPromise;
878
+ if (loopError !== null) {
879
+ const message = loopError instanceof Error ? loopError.message : String(loopError);
880
+ // #21: surface the crash to the audit trail before
881
+ // returning. Mirrors the `failed` arm of the structured path
882
+ // below so a SOC pipeline sees one `dispatch_end` per dispatch
883
+ // regardless of which code path produced it.
884
+ writeAuditEvent({
885
+ event: 'dispatch_end',
886
+ sessionId: session.id,
887
+ workspaceRoot: root,
888
+ data: {
889
+ status: 'crashed',
890
+ error: message,
891
+ },
195
892
  });
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),
893
+ // #24 (CEO P1): TaskCompleted chain fires
894
+ // even on engine-loop crash so an operator hook can surface the
895
+ // failure to Slack / a dashboard. Best-effort — chain crashes
896
+ // never propagate.
897
+ await fireTaskCompletedOnce(1, 0, []);
898
+ yield {
899
+ type: 'result',
900
+ result: {
901
+ status: 'failed',
902
+ summary: `engine loop crashed: ${message}`,
903
+ filesChanged: [],
904
+ patchRefs: [],
905
+ testsRun: [],
906
+ risks: [`unhandled error in engine adapter: ${message}`],
907
+ eventRefs: [],
908
+ },
909
+ };
910
+ return;
911
+ }
912
+ const finalOutcome = outcomeBox[0];
913
+ if (finalOutcome === null) {
914
+ // Defensive — should never hit. `runEngineLoop` always either
915
+ // resolves with an outcome or throws (and we catch that above).
916
+ writeAuditEvent({
917
+ event: 'dispatch_end',
918
+ sessionId: session.id,
919
+ workspaceRoot: root,
920
+ data: { status: 'no_outcome' },
202
921
  });
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,
922
+ // #24: fire TaskCompleted chain on the defensive path too.
923
+ await fireTaskCompletedOnce(1, 0, []);
924
+ yield {
925
+ type: 'result',
926
+ result: {
927
+ status: 'failed',
928
+ summary: 'engine loop returned no outcome',
929
+ filesChanged: [],
930
+ patchRefs: [],
931
+ testsRun: [],
932
+ risks: ['runEngineLoop resolved without an outcome value'],
933
+ eventRefs: [],
934
+ },
935
+ };
936
+ return;
937
+ }
938
+ // Translate the loop outcome into an EngineResult.
939
+ // `aborted` maps to `blocked`
940
+ // because the operator chose the outcome, same shape as
941
+ // budget_exhausted / tool_refused.
942
+ const status = finalOutcome.status === 'completed'
943
+ ? 'done'
944
+ : finalOutcome.status === 'failed'
945
+ ? 'failed'
946
+ : 'blocked';
947
+ const summaryPrefix = finalOutcome.status === 'completed'
948
+ ? ''
949
+ : finalOutcome.status === 'budget_exhausted'
950
+ ? '[budget_exhausted] '
951
+ : finalOutcome.status === 'tool_refused'
952
+ ? '[plan_mode_refused] '
953
+ : finalOutcome.status === 'aborted'
954
+ ? '[operator_aborted] '
955
+ : '[failed] ';
956
+ const filesChangedList = Array.from(filesChanged).sort();
957
+ appendSessionMirror(sessionEventsPath, {
958
+ type: 'outcome',
959
+ status: finalOutcome.status,
960
+ toolCallCount: finalOutcome.toolCallCount,
961
+ turnsUsed: finalOutcome.turnsUsed,
962
+ tokensUsed: finalOutcome.tokensUsed,
963
+ filesChanged: filesChangedList,
964
+ reason: finalOutcome.reason,
218
965
  });
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);
966
+ // #21: emit `dispatch_end` to the tenant-wide audit trail.
967
+ // When the loop tripped the per-command budget we ALSO emit a
968
+ // dedicated `budget_exhausted` row so a SOC query can filter on
969
+ // event type alone without parsing the `data.status` payload.
970
+ if (finalOutcome.status === 'budget_exhausted') {
971
+ writeAuditEvent({
972
+ event: 'budget_exhausted',
973
+ sessionId: session.id,
974
+ workspaceRoot: root,
975
+ data: {
976
+ toolCallCount: finalOutcome.toolCallCount,
977
+ turnsUsed: finalOutcome.turnsUsed,
978
+ tokensUsed: finalOutcome.tokensUsed,
979
+ reason: finalOutcome.reason ?? null,
980
+ },
981
+ });
982
+ }
983
+ writeAuditEvent({
984
+ event: 'dispatch_end',
985
+ sessionId: session.id,
986
+ workspaceRoot: root,
987
+ data: {
988
+ status: finalOutcome.status,
989
+ toolCallCount: finalOutcome.toolCallCount,
990
+ turnsUsed: finalOutcome.turnsUsed,
991
+ tokensUsed: finalOutcome.tokensUsed,
992
+ filesChangedCount: filesChangedList.length,
993
+ reason: finalOutcome.reason ?? null,
994
+ },
995
+ });
996
+ // #24 (CEO P1): TaskCompleted chain on the
997
+ // primary success path. `exitCode` maps to 0 for `completed`,
998
+ // 1 otherwise so chain hooks can branch on success vs blocked /
999
+ // failed / aborted via a single integer test.
1000
+ await fireTaskCompletedOnce(finalOutcome.status === 'completed' ? 0 : 1, finalOutcome.toolCallCount, filesChangedList);
1001
+ // PUGI-467: when the model finishes с tool_use-only turns (common
1002
+ // на OSS coder models that emit no final assistant text after the
1003
+ // last edit), `finalText` is empty even though work landed. Fall
1004
+ // back к a synthesised summary derived from `filesChangedList` so
1005
+ // the CLI never reports "no answer returned" when files were
1006
+ // demonstrably modified.
1007
+ //
1008
+ // Order: finalText → reason → file-list synthesis → literal placeholder.
1009
+ // Reason precedes synthesis so failure modes (budget_exhausted,
1010
+ // tool_refused, aborted) preserve their explanation when files were
1011
+ // also touched — operator must see WHY the loop terminated before
1012
+ // the "what landed" hint. Synthesis only kicks in when there is no
1013
+ // reason at all (pure tool_use-only completed turn).
1014
+ const synthesisedFromFiles = finalOutcome.finalText.trim() === '' && filesChangedList.length > 0
1015
+ ? `Updated ${filesChangedList.length} file(s): ${filesChangedList.slice(0, 5).join(', ')}${filesChangedList.length > 5 ? ` (+${filesChangedList.length - 5} more)` : ''}`
1016
+ : '';
226
1017
  yield {
227
1018
  type: 'result',
228
1019
  result: {
229
- status: 'failed',
230
- summary: `engine loop crashed: ${message}`,
231
- filesChanged: [],
1020
+ status,
1021
+ summary: `${summaryPrefix}${finalOutcome.finalText || finalOutcome.reason || synthesisedFromFiles || 'no answer returned'}`,
1022
+ filesChanged: filesChangedList,
232
1023
  patchRefs: [],
233
1024
  testsRun: [],
234
- risks: [`unhandled error in engine adapter: ${message}`],
235
- eventRefs: [],
1025
+ risks: finalOutcome.status === 'completed'
1026
+ ? []
1027
+ : [finalOutcome.reason ?? `outcome=${finalOutcome.status}`],
1028
+ eventRefs: [
1029
+ `tool_calls=${finalOutcome.toolCallCount}`,
1030
+ `turns=${finalOutcome.turnsUsed}`,
1031
+ `tokens=${finalOutcome.tokensUsed}`,
1032
+ // `outcome=<status>` is a machine-readable echo so callers
1033
+ // (cli.ts plan exit code, cabinet UI) can distinguish
1034
+ // `budget_exhausted` from `tool_refused` without parsing
1035
+ // the human-readable summary prefix. Code Reviewer P2
1036
+ // retro: plan exit code previously collapsed
1037
+ // both blocked reasons into 0, which masked budget hits.
1038
+ `outcome=${finalOutcome.status}`,
1039
+ `session=${session.id}`,
1040
+ `ctx=${ctx.sessionId}`,
1041
+ `mirror=${sessionEventsPath}`,
1042
+ ],
236
1043
  },
237
1044
  };
238
- return;
239
1045
  }
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
- };
1046
+ finally {
1047
+ // r2 (triple-review P1): detach the abort listener so
1048
+ // long REPL sessions sharing one AbortController across many
1049
+ // run() invocations do not accumulate one listener per run on
1050
+ // `ctx.signal`. Called on success, abort, and uncaught throw.
1051
+ detachAbortListener?.();
1052
+ // #24 (CEO P1): safety net — if `run()` threw
1053
+ // BEFORE reaching any yield-result site, the chain still fires.
1054
+ // `fireTaskCompletedOnce` is idempotent so the happy-path fire
1055
+ // above wins. Exit code 1 because the throw path is by
1056
+ // definition non-clean.
1057
+ await fireTaskCompletedOnce(1, 0, []);
1058
+ }
1059
+ }
1060
+ }
1061
+ /**
1062
+ * β3 streaming: translate one typed `EngineStreamEvent` from the
1063
+ * adapter's internal queue into the SDK's lossier `EngineEvent` shape
1064
+ * the public adapter contract exposes. The SDK contract only declares
1065
+ * `status | result` today; richer events (`tool.start`, `thinking.delta`,
1066
+ * etc.) collapse to a structured `status` message until the SDK widens
1067
+ * the discriminated union (β3b — paired with an admin-api SSE schema
1068
+ * bump so the wire format stays stable).
1069
+ *
1070
+ * The full typed payload is still available to richer consumers via
1071
+ * `adapter.streamEmitter.on('event', ...)`. The CLI's TUI tool-stream
1072
+ * pane consumes that emitter directly; this function is the safe
1073
+ * bridge for legacy SDK consumers that only know `EngineEvent`.
1074
+ */
1075
+ function streamEventToEngineEvent(stream) {
1076
+ switch (stream.type) {
1077
+ case 'status':
1078
+ return { type: 'status', message: stream.message };
1079
+ case 'tool.start':
1080
+ return {
1081
+ type: 'status',
1082
+ message: `tool.start ${stream.name} call=${stream.callId} args=${stream.arguments.slice(0, 80)}${stream.arguments.length > 80 ? '...' : ''}`,
1083
+ };
1084
+ case 'tool.delta':
1085
+ return {
1086
+ type: 'status',
1087
+ message: `tool.delta call=${stream.callId} chunk=${stream.chunk.slice(0, 120)}`,
1088
+ };
1089
+ case 'tool.end':
1090
+ return {
1091
+ type: 'status',
1092
+ message: `tool.end call=${stream.callId} ok=${stream.ok} summary=${stream.summary.slice(0, 120)}`,
1093
+ };
1094
+ case 'thinking.start':
1095
+ return { type: 'status', message: `thinking.start block=${stream.blockId}` };
1096
+ case 'thinking.delta':
1097
+ return {
1098
+ type: 'status',
1099
+ message: `thinking.delta block=${stream.blockId} chunk=${stream.chunk.slice(0, 120)}`,
1100
+ };
1101
+ case 'thinking.end':
1102
+ return { type: 'status', message: `thinking.end block=${stream.blockId}` };
1103
+ case 'text.delta':
1104
+ return {
1105
+ type: 'status',
1106
+ message: `text.delta chunk=${stream.chunk.slice(0, 200)}`,
1107
+ };
1108
+ default: {
1109
+ // Exhaustiveness — TS catches a missing variant at compile time.
1110
+ const exhaustive = stream;
1111
+ void exhaustive;
1112
+ return { type: 'status', message: 'unknown stream event' };
1113
+ }
299
1114
  }
300
1115
  }
301
1116
  /**
@@ -311,7 +1126,14 @@ function extractPathArg(raw) {
311
1126
  try {
312
1127
  const parsed = JSON.parse(raw);
313
1128
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
314
- const path = parsed.path;
1129
+ const obj = parsed;
1130
+ // Accept canonical `path` OR the Claude-Code-trained `filePath`
1131
+ // alias so the filesChanged summary captures writes regardless of
1132
+ // which key the model emitted. Without the alias the operator
1133
+ // sees "Files modified: none" even when a write actually landed,
1134
+ // because the dispatcher accepted the alias but the tracker did
1135
+ // not (CEO live smoke).
1136
+ const path = obj['path'] ?? obj['filePath'];
315
1137
  if (typeof path === 'string' && path.length > 0)
316
1138
  return path;
317
1139
  }
@@ -367,8 +1189,254 @@ function toCommandKind(kind) {
367
1189
  return 'build';
368
1190
  return kind;
369
1191
  }
1192
+ /**
1193
+ * β1 (audit E2) → β1a r1 (engine tag contract fix): map a
1194
+ * CLI command kind to its dispatch tag.
1195
+ *
1196
+ * The admin-api controller (`pugi-engine.controller.ts`) routes per-tag
1197
+ * to a model/persona pair via
1198
+ * `apps/admin-api/src/mira/routing/dispatch-tag.ts::DISPATCH_TAGS`. The
1199
+ * closed `EngineChatTag` vocabulary is
1200
+ * `classify | reason | codegen | summarize | vision` — note that
1201
+ * `code`, `fix`, `plan`, `build`, `explain` (CLI command names) are NOT
1202
+ * in this set.
1203
+ *
1204
+ * Before this fix `dispatchTagFor()` returned the CLI command names
1205
+ * as-is and the runtime DTO rejected the payload with HTTP 400
1206
+ * (`tag must be one of: classify, reason, codegen, summarize, vision`)
1207
+ * before ever reaching the routing layer. Every `pugi code/fix/plan/
1208
+ * build/explain` against the live runtime returned `failed: HTTP 400`.
1209
+ *
1210
+ * Mapping rationale (each row keeps the most informative `tag` value
1211
+ * for cost telemetry / model selection):
1212
+ *
1213
+ * - `code`, `fix` → `codegen` (edits / diffs / patches)
1214
+ * - `build_task`/`build` → `codegen` + `budget_hint: 'max'`
1215
+ * (scaffolding hits the 30-call / 80k-token ceiling — give the
1216
+ * router permission to pick the largest model in the tier)
1217
+ * - `plan` → `reason` (no mutations, long-form thought)
1218
+ * - `explain` → `summarize` (read-only walkthrough)
1219
+ *
1220
+ * `priority: 'realtime'` for every command — Pugi is an interactive
1221
+ * CLI; background dispatch is reserved for the cabinet's RAG ingest
1222
+ * cron path. `budget_hint: 'std'` is the default for the cost-balanced
1223
+ * router row; only `build_task` opts up to `'max'`.
1224
+ */
1225
+ export function dispatchTagFor(kind) {
1226
+ switch (kind) {
1227
+ case 'code':
1228
+ case 'fix':
1229
+ return { tag: 'codegen', priority: 'realtime', budget_hint: 'std' };
1230
+ case 'build':
1231
+ // `build_task` on the engine task kind side is the heavy
1232
+ // scaffolding lane — biggest budget envelope, biggest model
1233
+ // permitted via `budget_hint: 'max'`.
1234
+ return { tag: 'codegen', priority: 'realtime', budget_hint: 'max' };
1235
+ case 'plan':
1236
+ return { tag: 'reason', priority: 'realtime', budget_hint: 'std' };
1237
+ case 'explain':
1238
+ return { tag: 'summarize', priority: 'realtime', budget_hint: 'std' };
1239
+ default: {
1240
+ // Exhaustiveness check — `EngineCommandKind` is a closed union,
1241
+ // so the switch above covers every case. If a new command kind
1242
+ // is added the compiler flags this branch and the map must be
1243
+ // extended. Fall back to `reason` as the most conservative
1244
+ // routing choice so a future kind addition cannot accidentally
1245
+ // unlock a write-heavy model lane.
1246
+ const exhaustive = kind;
1247
+ void exhaustive;
1248
+ return { tag: 'reason', priority: 'realtime', budget_hint: 'std' };
1249
+ }
1250
+ }
1251
+ }
370
1252
  // The per-adapter `engineToolCallIds` Map lives on the
371
1253
  // `NativePugiEngineAdapter` instance above — Code Reviewer P2 retro
372
- // 2026-05-23 lifted it off the module scope to prevent collisions
1254
+ // lifted it off the module scope to prevent collisions
373
1255
  // under parallel adapter runs (cabinet UI + CLI sharing one process).
1256
+ /**
1257
+ * β5a R5+R6: render a cwd path as either a workspace-root-relative
1258
+ * string (when cwd is inside the workspace) or a `.` token (when cwd
1259
+ * equals workspaceRoot). Falls back to the absolute cwd if it lives
1260
+ * outside the workspace — the traverse loader already refuses to
1261
+ * read off-tree files so the abs path is purely a breadcrumb for
1262
+ * the SSE status line.
1263
+ */
1264
+ function relativeOrAbsolute(workspaceRoot, cwd) {
1265
+ const absRoot = resolve(workspaceRoot);
1266
+ const absCwd = resolve(cwd);
1267
+ if (absCwd === absRoot)
1268
+ return '.';
1269
+ const rel = absCwd.startsWith(absRoot + '/') ? absCwd.slice(absRoot.length + 1) : null;
1270
+ return rel ?? absCwd;
1271
+ }
1272
+ /**
1273
+ * helper — splice multiple ambient blocks onto a persona
1274
+ * system prompt, dropping empty entries cleanly. The join character
1275
+ * is `\n\n` so each block renders as a discrete paragraph the model
1276
+ * can attend к without bleeding into its neighbour.
1277
+ *
1278
+ * Empty blocks return the base prompt unchanged — no leading
1279
+ * separators, no trailing whitespace. Mirrors the original
1280
+ * `ambientContextBlock ? ... : ...` shape so the single-block path
1281
+ * before L28 stays byte-identical (prompt cache friendliness).
1282
+ */
1283
+ export function composeSystemPrompt(blocks) {
1284
+ const nonEmpty = blocks.map((b) => b.trim()).filter((b) => b.length > 0);
1285
+ return nonEmpty.join('\n\n');
1286
+ }
1287
+ /**
1288
+ * task #19 — boundary marker between cache-friendly
1289
+ * static blocks (persona, capability matrix, tool schema) and dynamic
1290
+ * per-session blocks (ambient PUGI.md, repo map, recent turns). The
1291
+ * marker is a literal sentinel string the Anvil prefix-cache layer
1292
+ * can locate to find the split point without parsing prompt semantics.
1293
+ *
1294
+ * Why: Anthropic's prompt cache works by hashing prefix bytes. Static
1295
+ * content placed BEFORE dynamic content guarantees the cache hits on
1296
+ * the common prefix even when the per-session tail varies. CC's
1297
+ * proven pattern uses a single sentinel; Pugi adopts the same shape
1298
+ * so cache infra is trivially interoperable.
1299
+ *
1300
+ * Output shape:
1301
+ * <staticBlock1>
1302
+ * <staticBlock2>
1303
+ * __PUGI_DYNAMIC_BOUNDARY__
1304
+ * <dynamicBlock1>
1305
+ * <dynamicBlock2>
1306
+ *
1307
+ * Empty blocks drop cleanly. If EITHER side ends up empty after the
1308
+ * filter, the marker is omitted so the prompt has no orphan sentinel
1309
+ * — caches treat "no boundary" as "everything is static / dynamic"
1310
+ * with deterministic behaviour.
1311
+ */
1312
+ export const PUGI_DYNAMIC_BOUNDARY = '__PUGI_DYNAMIC_BOUNDARY__';
1313
+ /**
1314
+ * Sentinel-injection guard. The Anvil cache layer locates the split
1315
+ * via grep на the literal `__PUGI_DYNAMIC_BOUNDARY__`. If either half
1316
+ * already contains the sentinel — most likely via a PUGI.md fragment
1317
+ * that documents the boundary mechanism itself, or через operator
1318
+ * @import-pulled content — the grep would mis-split and corrupt the
1319
+ * cache key. Hard-fail loud rather than silently emit a poisoned
1320
+ * prompt. Operators who legitimately need the literal string in
1321
+ * prompt context can rename their copy (e.g. `PUGI_DYNAMIC_BOUNDARY_LITERAL`)
1322
+ * or use the runtime constant export directly via code.
1323
+ */
1324
+ export class SentinelInjectionError extends Error {
1325
+ side;
1326
+ constructor(side) {
1327
+ super(`Refusing to compose system prompt: ${side} side contains the ` +
1328
+ `literal sentinel "${PUGI_DYNAMIC_BOUNDARY}". This would corrupt ` +
1329
+ `the Anvil prefix-cache split. Rename the offending occurrence ` +
1330
+ `или strip it before composing.`);
1331
+ this.side = side;
1332
+ this.name = 'SentinelInjectionError';
1333
+ }
1334
+ }
1335
+ export function composeSystemPromptWithBoundary(staticBlocks, dynamicBlocks) {
1336
+ const staticPart = composeSystemPrompt(staticBlocks);
1337
+ const dynamicPart = composeSystemPrompt(dynamicBlocks);
1338
+ // Sentinel-injection guard — refuse loud rather than mis-split cache.
1339
+ if (staticPart.includes(PUGI_DYNAMIC_BOUNDARY)) {
1340
+ throw new SentinelInjectionError('static');
1341
+ }
1342
+ if (dynamicPart.includes(PUGI_DYNAMIC_BOUNDARY)) {
1343
+ throw new SentinelInjectionError('dynamic');
1344
+ }
1345
+ if (staticPart.length === 0)
1346
+ return dynamicPart;
1347
+ if (dynamicPart.length === 0)
1348
+ return staticPart;
1349
+ return `${staticPart}\n\n${PUGI_DYNAMIC_BOUNDARY}\n\n${dynamicPart}`;
1350
+ }
1351
+ /**
1352
+ * Pugi backlog — resolve the effective model hint forwarded to
1353
+ * the runtime. Precedence:
1354
+ *
1355
+ * 1. Operator-pinned `model` option (constructor arg) wins outright.
1356
+ * `pugi code --model foo` always takes precedence over the dial.
1357
+ * 2. Intensity profile's `modelTag` resolves via
1358
+ * `PUGI_INTENSITY_MODEL_<TAG>` env (LIGHT / STANDARD / HEAVY).
1359
+ * Operators pin "what does 'standard' mean on this machine" via
1360
+ * env so the dial stays portable across providers.
1361
+ * 3. Absent both => undefined; the admin-api falls back to the
1362
+ * persona's default model (the legacy pre-#163 path).
1363
+ *
1364
+ * Returns undefined when no hint is available so the runtime sees the
1365
+ * absence of the field rather than an empty string — matches the
1366
+ * `engineLoopServerRequestSchema.model.optional()` contract.
1367
+ */
1368
+ export function resolveIntensityModel(operatorPin, profile) {
1369
+ if (operatorPin !== undefined && operatorPin !== '')
1370
+ return operatorPin;
1371
+ if (!profile)
1372
+ return undefined;
1373
+ const envKey = `PUGI_INTENSITY_MODEL_${profile.modelTag.toUpperCase()}`;
1374
+ const fromEnv = process.env[envKey];
1375
+ if (fromEnv !== undefined && fromEnv !== '')
1376
+ return fromEnv;
1377
+ return undefined;
1378
+ }
1379
+ /**
1380
+ * Backlog : expand `@import` directives across every
1381
+ * file the `walkUpPugiMd` walker discovered. Each parent file's body
1382
+ * is replaced with its post-import body (frontmatter stripped, import
1383
+ * lines removed); imported children are appended to the hierarchy at
1384
+ * the same `level` as their parent so the existing render order
1385
+ * (shallow-to-deep) stays intact and the model sees the operator's
1386
+ * `@import`-pulled rules in source order.
1387
+ *
1388
+ * Failures are localised: if a single file's load throws (cycle, hop
1389
+ * cap, byte cap, etc.) we keep the walker's original body for that
1390
+ * level and move on. Ambient context is enrichment, not a gate — one
1391
+ * malformed CLAUDE.md must never break the engine boot.
1392
+ */
1393
+ async function expandHierarchyWithImports(hierarchy, cwd) {
1394
+ const out = [];
1395
+ const home = osHomedir();
1396
+ for (const file of hierarchy) {
1397
+ try {
1398
+ const rules = await loadRulesFile(file.path, {
1399
+ cwd,
1400
+ homedir: home,
1401
+ });
1402
+ // First rule is always the entry file itself. Replace the body
1403
+ // with the post-expansion body so the rendered ambient block
1404
+ // omits the `@import` directives but keeps everything else.
1405
+ const head = rules[0];
1406
+ if (head) {
1407
+ out.push({
1408
+ ...file,
1409
+ content: head.body,
1410
+ });
1411
+ }
1412
+ else {
1413
+ out.push(file);
1414
+ }
1415
+ // Append imported children at the same level. They are not on
1416
+ // disk in the parent dir, but the operator authored the link so
1417
+ // surfacing them at the parent's specificity matches the
1418
+ // ambient-context render contract.
1419
+ for (let i = 1; i < rules.length; i += 1) {
1420
+ const child = rules[i];
1421
+ if (!child)
1422
+ continue;
1423
+ out.push({
1424
+ path: child.path,
1425
+ content: child.body,
1426
+ level: file.level,
1427
+ source: file.source,
1428
+ truncated: false,
1429
+ rawBytes: Buffer.byteLength(child.body, 'utf8'),
1430
+ });
1431
+ }
1432
+ }
1433
+ catch {
1434
+ // Localised failure: keep the walker's original body for this
1435
+ // file and skip its imports. The next file in the hierarchy is
1436
+ // tried independently.
1437
+ out.push(file);
1438
+ }
1439
+ }
1440
+ return out;
1441
+ }
374
1442
  //# sourceMappingURL=native-pugi.js.map