@pugi/cli 0.1.0-beta.99 → 1.0.0-alpha.10

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 (448) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +11 -191
  3. package/bin/pugi +8 -0
  4. package/package.json +15 -71
  5. package/postinstall.mjs +31 -0
  6. package/CHANGELOG.md +0 -132
  7. package/THIRD_PARTY_NOTICES.md +0 -40
  8. package/assets/pugi-mascot.ansi +0 -16
  9. package/assets/pugi-prozr2-mascot.ansi +0 -9
  10. package/bin/run.js +0 -34
  11. package/dist/commands/deploy.js +0 -439
  12. package/dist/commands/flatten.js +0 -191
  13. package/dist/commands/jobs-watch.js +0 -201
  14. package/dist/commands/jobs.js +0 -260
  15. package/dist/commands/retro.js +0 -210
  16. package/dist/commands/smoke.js +0 -133
  17. package/dist/core/agent-progress/cleanup.js +0 -134
  18. package/dist/core/agent-progress/schema.js +0 -144
  19. package/dist/core/agent-progress/writer.js +0 -101
  20. package/dist/core/agents/adaptive-router.js +0 -330
  21. package/dist/core/agents/loader.js +0 -104
  22. package/dist/core/agents/query-decomposer.js +0 -297
  23. package/dist/core/agents/registry.js +0 -69
  24. package/dist/core/approvals/shortcut-resolver.js +0 -98
  25. package/dist/core/artifact-chain/dispatcher.js +0 -148
  26. package/dist/core/artifact-chain/exporter.js +0 -164
  27. package/dist/core/artifact-chain/state.js +0 -243
  28. package/dist/core/artifact-chain/steps.js +0 -169
  29. package/dist/core/ask-user/question.js +0 -92
  30. package/dist/core/audit/audit-trail.js +0 -275
  31. package/dist/core/auth/ensure-authenticated.js +0 -129
  32. package/dist/core/auth/env-provider.js +0 -238
  33. package/dist/core/auto-open-browser.js +0 -128
  34. package/dist/core/auto-update/channels.js +0 -122
  35. package/dist/core/auto-update/checker.js +0 -241
  36. package/dist/core/auto-update/state.js +0 -235
  37. package/dist/core/bare-mode/index.js +0 -107
  38. package/dist/core/bash/redirect.js +0 -281
  39. package/dist/core/bash-classifier.js +0 -1397
  40. package/dist/core/checkpoint/resumer.js +0 -149
  41. package/dist/core/checkpoint/rewinder.js +0 -291
  42. package/dist/core/checkpoints/shadow-git.js +0 -670
  43. package/dist/core/citations/parser.js +0 -109
  44. package/dist/core/classifier/yolo-classifier.js +0 -88
  45. package/dist/core/clipboard.js +0 -70
  46. package/dist/core/codegraph/decision-store.js +0 -248
  47. package/dist/core/codegraph/detect-repo.js +0 -459
  48. package/dist/core/codegraph/install.js +0 -134
  49. package/dist/core/codegraph/offer-hook.js +0 -220
  50. package/dist/core/compact/auto-trigger.js +0 -96
  51. package/dist/core/compact/buffer-rewriter.js +0 -115
  52. package/dist/core/compact/summarizer.js +0 -208
  53. package/dist/core/compact/token-counter.js +0 -108
  54. package/dist/core/consensus/anvil-fanout.js +0 -276
  55. package/dist/core/consensus/diff-capture.js +0 -491
  56. package/dist/core/consensus/rubric.js +0 -233
  57. package/dist/core/context/builder.js +0 -114
  58. package/dist/core/context/compaction-events.js +0 -99
  59. package/dist/core/context/compaction.js +0 -602
  60. package/dist/core/context/index.js +0 -28
  61. package/dist/core/context/invariants.js +0 -250
  62. package/dist/core/context/markdown-loader.js +0 -288
  63. package/dist/core/context/markdown-traverse.js +0 -255
  64. package/dist/core/context/pugiignore.js +0 -316
  65. package/dist/core/context/repo-skeleton.js +0 -533
  66. package/dist/core/context/tool-eviction.js +0 -55
  67. package/dist/core/context/watcher.js +0 -342
  68. package/dist/core/context/working-set.js +0 -165
  69. package/dist/core/coordinator/agent-tools.js +0 -77
  70. package/dist/core/coordinator/agent-toolset.js +0 -65
  71. package/dist/core/coordinator/fsm.js +0 -73
  72. package/dist/core/coordinator/mode-fsm.js +0 -70
  73. package/dist/core/cost/rate-card.js +0 -129
  74. package/dist/core/cost/tracker.js +0 -221
  75. package/dist/core/credentials.js +0 -355
  76. package/dist/core/cron/scheduler.js +0 -138
  77. package/dist/core/denial-tracking/index.js +0 -8
  78. package/dist/core/denial-tracking/state.js +0 -264
  79. package/dist/core/diagnostics/probe-runner.js +0 -93
  80. package/dist/core/diagnostics/probes/api.js +0 -46
  81. package/dist/core/diagnostics/probes/auth.js +0 -93
  82. package/dist/core/diagnostics/probes/bare-mode.js +0 -42
  83. package/dist/core/diagnostics/probes/cli-version.js +0 -127
  84. package/dist/core/diagnostics/probes/config.js +0 -72
  85. package/dist/core/diagnostics/probes/denial-tracking.js +0 -57
  86. package/dist/core/diagnostics/probes/disk.js +0 -81
  87. package/dist/core/diagnostics/probes/engine-live.js +0 -46
  88. package/dist/core/diagnostics/probes/git.js +0 -65
  89. package/dist/core/diagnostics/probes/hooks.js +0 -118
  90. package/dist/core/diagnostics/probes/mcp.js +0 -75
  91. package/dist/core/diagnostics/probes/node.js +0 -59
  92. package/dist/core/diagnostics/probes/pnpm.js +0 -36
  93. package/dist/core/diagnostics/probes/pugi-md.js +0 -89
  94. package/dist/core/diagnostics/probes/sandbox.js +0 -72
  95. package/dist/core/diagnostics/probes/session.js +0 -74
  96. package/dist/core/diagnostics/probes/status-snapshot.js +0 -488
  97. package/dist/core/diagnostics/probes/workspace.js +0 -63
  98. package/dist/core/diagnostics/types.js +0 -70
  99. package/dist/core/dispatch/cache-cleanup.js +0 -197
  100. package/dist/core/dispatch/cache-handoff.js +0 -295
  101. package/dist/core/edits/apply-patch-layer-e.js +0 -189
  102. package/dist/core/edits/dispatch.js +0 -511
  103. package/dist/core/edits/format-detector.js +0 -260
  104. package/dist/core/edits/format-matrix.js +0 -26
  105. package/dist/core/edits/fuzzy-ladder.js +0 -650
  106. package/dist/core/edits/index.js +0 -19
  107. package/dist/core/edits/journal.js +0 -199
  108. package/dist/core/edits/layer-a-apply.js +0 -217
  109. package/dist/core/edits/layer-a-fuzzy-apply.js +0 -198
  110. package/dist/core/edits/layer-b-apply.js +0 -211
  111. package/dist/core/edits/layer-c-apply.js +0 -160
  112. package/dist/core/edits/layer-d-ast.js +0 -572
  113. package/dist/core/edits/marker-parser.js +0 -401
  114. package/dist/core/edits/security-gate.js +0 -223
  115. package/dist/core/edits/verify-hook.js +0 -273
  116. package/dist/core/edits/worktree.js +0 -322
  117. package/dist/core/engine/adapter-runner.js +0 -8
  118. package/dist/core/engine/anvil-client.js +0 -344
  119. package/dist/core/engine/auto-compact.js +0 -179
  120. package/dist/core/engine/budgets.js +0 -195
  121. package/dist/core/engine/context-prefix.js +0 -155
  122. package/dist/core/engine/index.js +0 -12
  123. package/dist/core/engine/intensity.js +0 -163
  124. package/dist/core/engine/intent.js +0 -260
  125. package/dist/core/engine/native-pugi.js +0 -1616
  126. package/dist/core/engine/noop.js +0 -27
  127. package/dist/core/engine/prompts.js +0 -236
  128. package/dist/core/engine/strip-internal-fields.js +0 -124
  129. package/dist/core/engine/tool-bridge.js +0 -2173
  130. package/dist/core/engine/verification-patterns.js +0 -195
  131. package/dist/core/evaluation/golden-dataset.js +0 -293
  132. package/dist/core/feedback/queue.js +0 -177
  133. package/dist/core/feedback/submitter.js +0 -145
  134. package/dist/core/file-cache.js +0 -141
  135. package/dist/core/flatten/flatten-repo.js +0 -439
  136. package/dist/core/format/osc8-link.js +0 -28
  137. package/dist/core/hook-chains.js +0 -392
  138. package/dist/core/hooks/citation-verify-hook.js +0 -138
  139. package/dist/core/hooks/citation-verify.js +0 -112
  140. package/dist/core/hooks/events.js +0 -46
  141. package/dist/core/hooks/index.js +0 -15
  142. package/dist/core/hooks/registry.js +0 -216
  143. package/dist/core/hooks/runner.js +0 -236
  144. package/dist/core/hooks/v2/event-emitter.js +0 -115
  145. package/dist/core/hooks/v2/executor.js +0 -282
  146. package/dist/core/hooks/v2/index.js +0 -25
  147. package/dist/core/hooks/v2/lifecycle.js +0 -104
  148. package/dist/core/hooks/v2/loader.js +0 -216
  149. package/dist/core/hooks/v2/matcher.js +0 -125
  150. package/dist/core/hooks/v2/trust.js +0 -143
  151. package/dist/core/hooks/v2/types.js +0 -86
  152. package/dist/core/hooks/worktree-events.js +0 -158
  153. package/dist/core/hooks.js +0 -415
  154. package/dist/core/image/renderer.js +0 -71
  155. package/dist/core/index-store.js +0 -260
  156. package/dist/core/init/detector.js +0 -582
  157. package/dist/core/init/template-renderer.js +0 -242
  158. package/dist/core/jobs/registry.js +0 -462
  159. package/dist/core/ledger/results-tsv.js +0 -142
  160. package/dist/core/log-discipline/stdout-redirect.js +0 -51
  161. package/dist/core/lsp/cache.js +0 -105
  162. package/dist/core/lsp/client.js +0 -1229
  163. package/dist/core/lsp/language-detect.js +0 -66
  164. package/dist/core/lsp/post-edit-diagnostics.js +0 -171
  165. package/dist/core/lsp/server-detect.js +0 -173
  166. package/dist/core/lsp/symbol-cache.js +0 -162
  167. package/dist/core/lsp/symbol-tools.js +0 -664
  168. package/dist/core/mcp/client.js +0 -385
  169. package/dist/core/mcp/http-server.js +0 -553
  170. package/dist/core/mcp/orchestrator-config.js +0 -192
  171. package/dist/core/mcp/orchestrator-tools.js +0 -806
  172. package/dist/core/mcp/permission.js +0 -190
  173. package/dist/core/mcp/registry.js +0 -193
  174. package/dist/core/mcp/server-tools.js +0 -219
  175. package/dist/core/mcp/server.js +0 -397
  176. package/dist/core/mcp/trust.js +0 -91
  177. package/dist/core/memory/dual-write.js +0 -416
  178. package/dist/core/memory/passive-extract.js +0 -130
  179. package/dist/core/memory/phase1-kinds.js +0 -20
  180. package/dist/core/memory/secret-scanner.js +0 -304
  181. package/dist/core/memory-sync/queue.js +0 -170
  182. package/dist/core/metrics/extract.js +0 -113
  183. package/dist/core/modes/roo-modes.js +0 -68
  184. package/dist/core/onboarding/ensure-initialized.js +0 -133
  185. package/dist/core/onboarding/marker.js +0 -111
  186. package/dist/core/onboarding/telemetry-state.js +0 -108
  187. package/dist/core/output-style/presets.js +0 -176
  188. package/dist/core/output-style/state.js +0 -185
  189. package/dist/core/path-security.js +0 -345
  190. package/dist/core/permission.js +0 -369
  191. package/dist/core/permissions/auto-classifier.js +0 -124
  192. package/dist/core/permissions/bash-parser.js +0 -371
  193. package/dist/core/permissions/circuit-breaker.js +0 -83
  194. package/dist/core/permissions/constrained-edit.js +0 -91
  195. package/dist/core/permissions/gate.js +0 -278
  196. package/dist/core/permissions/index.js +0 -20
  197. package/dist/core/permissions/mode.js +0 -174
  198. package/dist/core/permissions/network-egress.js +0 -137
  199. package/dist/core/permissions/state.js +0 -241
  200. package/dist/core/permissions/tool-class.js +0 -107
  201. package/dist/core/plan-mode/ui-state.js +0 -51
  202. package/dist/core/plans/plan-artifact.js +0 -721
  203. package/dist/core/policy-limits/etag-store.js +0 -122
  204. package/dist/core/prd-check/parser.js +0 -215
  205. package/dist/core/prd-check/reporter.js +0 -127
  206. package/dist/core/prd-check/session-review.js +0 -557
  207. package/dist/core/prd-check/verifiers.js +0 -223
  208. package/dist/core/prompt-cache/client-cache.js +0 -99
  209. package/dist/core/prompts/assembly.js +0 -29
  210. package/dist/core/prompts/registry.js +0 -364
  211. package/dist/core/pugi-gitignore.js +0 -52
  212. package/dist/core/pugi-md/cc-compat-rules.js +0 -735
  213. package/dist/core/pugi-md/context-injector.js +0 -76
  214. package/dist/core/pugi-md/walk-up.js +0 -207
  215. package/dist/core/python/uv-installer.js +0 -270
  216. package/dist/core/python/uv-resolver.js +0 -83
  217. package/dist/core/rate-limit/narrator.js +0 -146
  218. package/dist/core/recipes/cli-types.js +0 -20
  219. package/dist/core/recipes/loader.js +0 -103
  220. package/dist/core/recipes/runner.js +0 -345
  221. package/dist/core/recipes/schema.js +0 -587
  222. package/dist/core/release-notes/parser.js +0 -241
  223. package/dist/core/release-notes/state.js +0 -116
  224. package/dist/core/repl/ask.js +0 -512
  225. package/dist/core/repl/cancellation.js +0 -98
  226. package/dist/core/repl/cap-warning.js +0 -91
  227. package/dist/core/repl/clipboard-read.js +0 -174
  228. package/dist/core/repl/dispatch-fsm.js +0 -220
  229. package/dist/core/repl/engine-bridge.js +0 -303
  230. package/dist/core/repl/history-search.js +0 -175
  231. package/dist/core/repl/history.js +0 -182
  232. package/dist/core/repl/kill-ring.js +0 -138
  233. package/dist/core/repl/model-pricing.js +0 -135
  234. package/dist/core/repl/privacy-banner.js +0 -71
  235. package/dist/core/repl/session.js +0 -4962
  236. package/dist/core/repl/slash-commands.js +0 -747
  237. package/dist/core/repl/store/index.js +0 -12
  238. package/dist/core/repl/store/jsonl-log.js +0 -321
  239. package/dist/core/repl/store/lockfile.js +0 -155
  240. package/dist/core/repl/store/session-store.js +0 -821
  241. package/dist/core/repl/store/types.js +0 -44
  242. package/dist/core/repl/store/uuid-v7.js +0 -68
  243. package/dist/core/repl/tool-route.js +0 -382
  244. package/dist/core/repl/workspace-context.js +0 -206
  245. package/dist/core/repo-map/build.js +0 -125
  246. package/dist/core/repo-map/cache.js +0 -185
  247. package/dist/core/repo-map/extractor.js +0 -254
  248. package/dist/core/repo-map/formatter.js +0 -145
  249. package/dist/core/repo-map/page-rank.js +0 -105
  250. package/dist/core/repo-map/scanner.js +0 -211
  251. package/dist/core/retro/git-collector.js +0 -251
  252. package/dist/core/retro/health-card.js +0 -25
  253. package/dist/core/retro/metrics.js +0 -342
  254. package/dist/core/retro/narrative.js +0 -249
  255. package/dist/core/retro/plane-collector.js +0 -274
  256. package/dist/core/retro/pr-issue-link.js +0 -65
  257. package/dist/core/retro/types.js +0 -16
  258. package/dist/core/retry-budget/budget.js +0 -284
  259. package/dist/core/retry-budget/index.js +0 -5
  260. package/dist/core/retry-budget/retry-cap.js +0 -74
  261. package/dist/core/routing/lead-worker.js +0 -43
  262. package/dist/core/routing/pre-flight-estimator.js +0 -108
  263. package/dist/core/runs/run-tree.js +0 -103
  264. package/dist/core/sandboxing/adapter.js +0 -29
  265. package/dist/core/sandboxing/index.js +0 -49
  266. package/dist/core/sandboxing/none.js +0 -19
  267. package/dist/core/sandboxing/seatbelt.js +0 -183
  268. package/dist/core/security/injection-scanner.js +0 -367
  269. package/dist/core/security/output-filter.js +0 -418
  270. package/dist/core/session/env-file.js +0 -105
  271. package/dist/core/session/section-budgets.js +0 -140
  272. package/dist/core/session.js +0 -377
  273. package/dist/core/settings.js +0 -400
  274. package/dist/core/share/formatter.js +0 -271
  275. package/dist/core/share/redactor.js +0 -221
  276. package/dist/core/share/uploader.js +0 -267
  277. package/dist/core/skills/defaults.js +0 -457
  278. package/dist/core/skills/loader.js +0 -454
  279. package/dist/core/skills/sources.js +0 -480
  280. package/dist/core/skills/trust.js +0 -172
  281. package/dist/core/smoke/headless-driver.js +0 -174
  282. package/dist/core/smoke/orchestrator.js +0 -194
  283. package/dist/core/smoke/runner.js +0 -238
  284. package/dist/core/smoke/scenario-parser.js +0 -316
  285. package/dist/core/statusline.js +0 -99
  286. package/dist/core/subagents/dispatcher-real.js +0 -600
  287. package/dist/core/subagents/dispatcher.js +0 -352
  288. package/dist/core/subagents/index.js +0 -39
  289. package/dist/core/subagents/isolation-matrix.js +0 -213
  290. package/dist/core/subagents/spawn.js +0 -101
  291. package/dist/core/telemetry/emitter.js +0 -229
  292. package/dist/core/telemetry/queue.js +0 -251
  293. package/dist/core/theme/context.js +0 -91
  294. package/dist/core/theme/presets.js +0 -228
  295. package/dist/core/theme/state.js +0 -181
  296. package/dist/core/todos/invariant.js +0 -10
  297. package/dist/core/todos/state.js +0 -177
  298. package/dist/core/tool-schema/compressor.js +0 -89
  299. package/dist/core/transport/version-interceptor.js +0 -166
  300. package/dist/core/trust.js +0 -109
  301. package/dist/core/tui/thinking-block.js +0 -64
  302. package/dist/core/vim/keymap.js +0 -288
  303. package/dist/core/vim/state.js +0 -92
  304. package/dist/core/watch-markers/marker-watcher.js +0 -133
  305. package/dist/core/worktree/include-parser.js +0 -249
  306. package/dist/core/worktree-manager/cleanup.js +0 -123
  307. package/dist/core/worktree-manager/manager.js +0 -303
  308. package/dist/index.js +0 -44
  309. package/dist/runtime/bootstrap.js +0 -190
  310. package/dist/runtime/cli.js +0 -8121
  311. package/dist/runtime/commands/agents.js +0 -385
  312. package/dist/runtime/commands/budget.js +0 -192
  313. package/dist/runtime/commands/cancel.js +0 -231
  314. package/dist/runtime/commands/chain.js +0 -489
  315. package/dist/runtime/commands/codegraph-status.js +0 -227
  316. package/dist/runtime/commands/compact.js +0 -297
  317. package/dist/runtime/commands/config.js +0 -595
  318. package/dist/runtime/commands/cost.js +0 -199
  319. package/dist/runtime/commands/delegate.js +0 -312
  320. package/dist/runtime/commands/dispatch.js +0 -126
  321. package/dist/runtime/commands/doctor.js +0 -579
  322. package/dist/runtime/commands/feedback.js +0 -184
  323. package/dist/runtime/commands/hooks.js +0 -187
  324. package/dist/runtime/commands/init.js +0 -254
  325. package/dist/runtime/commands/lsp.js +0 -368
  326. package/dist/runtime/commands/mcp.js +0 -935
  327. package/dist/runtime/commands/memory.js +0 -582
  328. package/dist/runtime/commands/model.js +0 -237
  329. package/dist/runtime/commands/onboarding.js +0 -275
  330. package/dist/runtime/commands/patch.js +0 -128
  331. package/dist/runtime/commands/permissions.js +0 -112
  332. package/dist/runtime/commands/plan.js +0 -143
  333. package/dist/runtime/commands/prd-check.js +0 -285
  334. package/dist/runtime/commands/privacy.js +0 -107
  335. package/dist/runtime/commands/recipe.js +0 -325
  336. package/dist/runtime/commands/redo-blob-store.js +0 -92
  337. package/dist/runtime/commands/redo.js +0 -361
  338. package/dist/runtime/commands/release-notes.js +0 -229
  339. package/dist/runtime/commands/repo-map.js +0 -95
  340. package/dist/runtime/commands/report.js +0 -299
  341. package/dist/runtime/commands/resume.js +0 -118
  342. package/dist/runtime/commands/review-consensus.js +0 -414
  343. package/dist/runtime/commands/rewind.js +0 -333
  344. package/dist/runtime/commands/roster.js +0 -117
  345. package/dist/runtime/commands/sessions.js +0 -163
  346. package/dist/runtime/commands/share.js +0 -316
  347. package/dist/runtime/commands/skills.js +0 -401
  348. package/dist/runtime/commands/status.js +0 -186
  349. package/dist/runtime/commands/stickers.js +0 -82
  350. package/dist/runtime/commands/style.js +0 -194
  351. package/dist/runtime/commands/theme.js +0 -196
  352. package/dist/runtime/commands/undo.js +0 -361
  353. package/dist/runtime/commands/update.js +0 -289
  354. package/dist/runtime/commands/vim.js +0 -140
  355. package/dist/runtime/commands/worktree.js +0 -177
  356. package/dist/runtime/commands/worktrees.js +0 -155
  357. package/dist/runtime/deprecation-warning.js +0 -69
  358. package/dist/runtime/engine-exit-code.js +0 -50
  359. package/dist/runtime/headless-repl.js +0 -195
  360. package/dist/runtime/headless.js +0 -548
  361. package/dist/runtime/load-hooks-or-exit.js +0 -71
  362. package/dist/runtime/plan-decompose.js +0 -531
  363. package/dist/runtime/sigint-guard.js +0 -272
  364. package/dist/runtime/stream-renderer.js +0 -195
  365. package/dist/runtime/update-check.js +0 -294
  366. package/dist/runtime/version.js +0 -65
  367. package/dist/runtime/worktree-bootstrap.js +0 -579
  368. package/dist/skills/bundled/batch.js +0 -617
  369. package/dist/skills/bundled/index.js +0 -45
  370. package/dist/skills/bundled/loop.js +0 -358
  371. package/dist/skills/bundled/remember.js +0 -383
  372. package/dist/skills/bundled/simplify.js +0 -289
  373. package/dist/skills/bundled/skillify.js +0 -373
  374. package/dist/skills/bundled/stuck.js +0 -558
  375. package/dist/skills/bundled/verify.js +0 -439
  376. package/dist/testing/vcr.js +0 -486
  377. package/dist/tools/agent-tool.js +0 -229
  378. package/dist/tools/apply-patch.js +0 -556
  379. package/dist/tools/ask-user-question.js +0 -337
  380. package/dist/tools/ask-user.js +0 -115
  381. package/dist/tools/bash.js +0 -1238
  382. package/dist/tools/brief.js +0 -224
  383. package/dist/tools/cron.js +0 -433
  384. package/dist/tools/enter-worktree.js +0 -250
  385. package/dist/tools/exit-worktree.js +0 -147
  386. package/dist/tools/file-tools.js +0 -553
  387. package/dist/tools/http-request.js +0 -336
  388. package/dist/tools/lsp-tools.js +0 -565
  389. package/dist/tools/mcp-tool.js +0 -260
  390. package/dist/tools/multi-edit.js +0 -361
  391. package/dist/tools/powershell.js +0 -268
  392. package/dist/tools/registry.js +0 -166
  393. package/dist/tools/server-tools.js +0 -892
  394. package/dist/tools/skill-tool.js +0 -96
  395. package/dist/tools/sleep.js +0 -99
  396. package/dist/tools/synthetic-output.js +0 -133
  397. package/dist/tools/tasks.js +0 -208
  398. package/dist/tools/todo-write.js +0 -184
  399. package/dist/tools/verify-plan-execution.js +0 -295
  400. package/dist/tools/web-fetch-injection-scanner.js +0 -207
  401. package/dist/tools/web-fetch.js +0 -720
  402. package/dist/tools/web-search.js +0 -458
  403. package/dist/tui/agent-progress-card.js +0 -111
  404. package/dist/tui/agent-tree-pane.js +0 -9
  405. package/dist/tui/agent-tree.js +0 -87
  406. package/dist/tui/ask-cli.js +0 -52
  407. package/dist/tui/ask-modal.js +0 -211
  408. package/dist/tui/ask-user-question-chips.js +0 -315
  409. package/dist/tui/ask-user-question-prompt.js +0 -203
  410. package/dist/tui/compact-banner.js +0 -81
  411. package/dist/tui/conversation-pane.js +0 -164
  412. package/dist/tui/cost-table.js +0 -111
  413. package/dist/tui/device-flow.js +0 -142
  414. package/dist/tui/doctor-table.js +0 -46
  415. package/dist/tui/feedback-prompt.js +0 -156
  416. package/dist/tui/input-box.js +0 -732
  417. package/dist/tui/login-picker.js +0 -69
  418. package/dist/tui/markdown-render.js +0 -266
  419. package/dist/tui/multi-file-diff-approval.js +0 -375
  420. package/dist/tui/onboarding-wizard.js +0 -240
  421. package/dist/tui/permissions-picker.js +0 -86
  422. package/dist/tui/render.js +0 -160
  423. package/dist/tui/repl-render.js +0 -770
  424. package/dist/tui/repl-splash-art.js +0 -64
  425. package/dist/tui/repl-splash-mascot.js +0 -154
  426. package/dist/tui/repl-splash.js +0 -117
  427. package/dist/tui/repl.js +0 -378
  428. package/dist/tui/slash-palette.js +0 -106
  429. package/dist/tui/splash-data.js +0 -61
  430. package/dist/tui/splash.js +0 -31
  431. package/dist/tui/status-bar.js +0 -209
  432. package/dist/tui/status-table.js +0 -7
  433. package/dist/tui/stickers-art.js +0 -136
  434. package/dist/tui/style-table.js +0 -28
  435. package/dist/tui/theme-table.js +0 -29
  436. package/dist/tui/thinking-spinner.js +0 -123
  437. package/dist/tui/tool-stream-pane.js +0 -140
  438. package/dist/tui/update-banner.js +0 -33
  439. package/dist/tui/vim-input.js +0 -267
  440. package/dist/tui/welcome-banner.js +0 -107
  441. package/dist/tui/welcome-data.js +0 -293
  442. package/dist/tui/workspace-context.js +0 -105
  443. package/docs/examples/codegraph.mcp.json +0 -10
  444. package/test/scenarios/codegen-create-file.scenario.txt +0 -13
  445. package/test/scenarios/compact-force.scenario.txt +0 -12
  446. package/test/scenarios/identity.scenario.txt +0 -11
  447. package/test/scenarios/persona-handoff.scenario.txt +0 -12
  448. package/test/scenarios/walkback.scenario.txt +0 -12
@@ -1,2173 +0,0 @@
1
- import { editTool, globTool, grepTool, OperatorAbortedError, readTool, StaleReadError, writeTool, } from '../../tools/file-tools.js';
2
- import { bashToolSync } from '../../tools/bash.js';
3
- import { powerShellToolSync } from '../../tools/powershell.js';
4
- import { askUser } from '../../tools/ask-user.js';
5
- import { askUserQuestionJsonSchema, dispatchAskUserQuestion, } from '../../tools/ask-user-question.js';
6
- import { skillInvoke, skillList } from '../../tools/skill-tool.js';
7
- import { taskCreate, taskGet, taskList, taskUpdate, } from '../../tools/tasks.js';
8
- import { dispatchTodoWrite, todoWriteJsonSchema, } from '../../tools/todo-write.js';
9
- // Tool gap pack : Brief/Sleep/SyntheticOutput/
10
- // EnterWorktree/ExitWorktree. Each tool exports a Zod-free hand-rolled
11
- // JSON-schema fragment + a sentinel-returning dispatcher, matching the
12
- // `todo_write` / `ask_user_question` conventions.
13
- import { briefJsonSchema, dispatchBrief } from '../../tools/brief.js';
14
- import { cronCreateJsonSchema, cronDeleteJsonSchema, cronListJsonSchema, dispatchCronCreate, dispatchCronDelete, dispatchCronList, } from '../../tools/cron.js';
15
- import { dispatchVerifyPlanExecution, verifyPlanExecutionJsonSchema, } from '../../tools/verify-plan-execution.js';
16
- import { dispatchSleep, sleepJsonSchema } from '../../tools/sleep.js';
17
- import { dispatchSyntheticOutput, syntheticOutputJsonSchema, } from '../../tools/synthetic-output.js';
18
- import { dispatchEnterWorktree, enterWorktreeJsonSchema, } from '../../tools/enter-worktree.js';
19
- import { dispatchExitWorktree, exitWorktreeJsonSchema, } from '../../tools/exit-worktree.js';
20
- import { webFetchTool } from '../../tools/web-fetch.js';
21
- import { webSearchTool } from '../../tools/web-search.js';
22
- // Phase 1 runtime evidence pack (PUGI-291..295): server_* + http_request
23
- // dispatchers + JSON schema fragments. Every primitive returns a
24
- // sentinel string on validation failure (sleep/brief convention) so
25
- // the engine adapter surfaces it as a recoverable tool result.
26
- import { dispatchHttpRequest, httpRequestJsonSchema, } from '../../tools/http-request.js';
27
- import { dispatchServerHealth, dispatchServerLogs, dispatchServerStart, dispatchServerStop, serverHealthJsonSchema, serverLogsJsonSchema, serverStartJsonSchema, serverStopJsonSchema, } from '../../tools/server-tools.js';
28
- import { agentTool } from '../../tools/agent-tool.js';
29
- import { multiEdit } from '../../tools/multi-edit.js';
30
- import { recordVerificationCall } from '../session.js';
31
- import { detectVerificationCommand, tailStderr } from './verification-patterns.js';
32
- import { buildMcpToolDefs, defaultNonInteractiveMcpPrompt, dispatchMcpTool, MCP_TOOL_PREFIX, } from '../../tools/mcp-tool.js';
33
- import { firePostToolUseFailureChain } from '../hook-chains.js';
34
- import { buildDenialContext, DENIAL_REMINDER_THRESHOLD, } from '../denial-tracking/state.js';
35
- import { stripInternalFields } from './strip-internal-fields.js';
36
- import { applyAskAnswer, gate as permissionGate, getToolClass, PermissionDenied, } from '../permissions/index.js';
37
- import { RetryBudget, RetryBudgetExhausted, hashArgs } from '../retry-budget/index.js';
38
- import { runPostEditDiagnostics, } from '../lsp/post-edit-diagnostics.js';
39
- // PUGI-78 Phase 1: symbols.* tool wrappers + LSP client cache. The
40
- // dispatcher resolves the LSP client by `lang` via `getOrStartLspClient`
41
- // (cold-start cost amortised across the session) and hands it to the
42
- // matching wrapper. The wrappers serialise the result for the engine
43
- // envelope.
44
- import { getOrStartLspClient } from '../lsp/cache.js';
45
- import { symbolsCallHierarchyTool, symbolsCodeActionsTool, symbolsDiagnosticsTool, symbolsFindDefinitionTool, symbolsFindReferencesTool, symbolsFormatTool, symbolsHoverTool, symbolsImplementationsTool, symbolsListInFileTool, symbolsRenameTool, symbolsSignatureTool, symbolsTypeDefinitionTool, symbolsWorkspaceSymbolsTool, } from '../../tools/lsp-tools.js';
46
- import { getGlobalSymbolCache } from '../lsp/symbol-cache.js';
47
- /**
48
- * Tool-bridge: turns the abstract tool registry into:
49
- * 1. An OpenAI-shaped tools schema for `EngineLoopClient.send`.
50
- * 2. A single executor callback that dispatches each tool_call to the
51
- * concrete `file-tools.ts` handler under workspace permissions.
52
- *
53
- * The bridge enforces two CLI-side invariants that the runtime cannot:
54
- * - Plan-mode refusal. When `kind === 'plan'`, the executor refuses
55
- * write/edit/bash by throwing `PLAN_MODE_REFUSED:<tool>` (sentinel
56
- * recognised by `runEngineLoop` to terminate with status
57
- * `tool_refused`). The schema also omits the mutating tools so the
58
- * model is unlikely to attempt them in the first place.
59
- * - Argument validation. Each call's `arguments` string is JSON-parsed
60
- * and shape-checked here; bad JSON or missing fields are surfaced
61
- * to the model as a tool error string so it can correct itself.
62
- *
63
- * The bridge does NOT touch session.ts directly — `file-tools.ts`
64
- * already records every call. The engine adapter wires a hook layer on
65
- * top that surfaces tool events into the engine's status stream.
66
- */
67
- /**
68
- * Read-only subset surfaced to plan-mode. Mutating tools (write, edit,
69
- * bash) are intentionally absent so the model rarely tries them.
70
- *
71
- * β1: task_* + skill + ask_user_question + web_fetch are all read-only
72
- * from the workspace's perspective (no file writes), so they stay
73
- * available in plan mode. The ledger writes for `task_*` land in
74
- * `.pugi/sessions/<id>/tasks.jsonl` which is metadata, not source.
75
- */
76
- const READ_ONLY_TOOLS = new Set([
77
- 'read',
78
- 'grep',
79
- 'glob',
80
- 'ask_user_question',
81
- 'skill',
82
- 'skills_list',
83
- 'task_create',
84
- 'task_get',
85
- 'task_list',
86
- 'task_update',
87
- // `todo_write` writes to `.pugi/todos.json`
88
- // — metadata, not source. From the workspace's perspective it is
89
- // read-only (no source mutation), so plan-mode keeps the tool
90
- // available: planning a refactor frequently means writing the
91
- // todo board BEFORE picking which file to touch.
92
- 'todo_write',
93
- // Tool gap pack : `brief` persists structured
94
- // status notes to `.pugi/briefs/<session>.jsonl`. Like `todo_write`
95
- // the writes land in metadata, not source, so plan mode keeps the
96
- // tool available — planning frequently means emitting a "planning"
97
- // brief first.
98
- 'brief',
99
- // Backlog #5 P0 : verify_plan_execution reads session
100
- // audit log (metadata, not source). Safe in plan mode — a planning
101
- // loop needs to verify its plan-capture steps before any writes.
102
- 'verify_plan_execution',
103
- // Backlog PUGI-7: cron_* tool family persists to `.pugi/cron/<name>.json`
104
- // — metadata, not source. Plan mode keeps these available because
105
- // planning a recurring workflow ("every Monday morning run the triple-
106
- // review") is a configuration step the model should be able to take
107
- // before any source mutation. The actual scheduler runner is gated by
108
- // an explicit `pugi routines run` opt-in OUTSIDE the model surface, so
109
- // even an aggressive plan-mode loop can only EDIT the routine registry
110
- // here, never spawn a tick.
111
- 'cron_create',
112
- 'cron_delete',
113
- 'cron_list',
114
- // Tool gap pack : `sleep` is a no-op as far as the
115
- // workspace is concerned (wall-clock delay only). Plan mode keeps it
116
- // available so a planning loop can throttle its own polling.
117
- 'sleep',
118
- 'web_fetch',
119
- // β1b T4 : web_search is read-only from the workspace's
120
- // perspective (no file writes, no shell). Egress goes through the
121
- // Anvil-proxied Brave Search API, gated by the same opt-in posture as
122
- // web_fetch. Plan mode keeps the tool available because reading the
123
- // web is part of how a plan is researched.
124
- 'web_search',
125
- // PUGI-78 Phase 1: symbols.* namespace is read-only in Phase 1
126
- // (rename / format / code_actions return PREVIEW edits, the
127
- // dispatcher applies via apply_patch in Phase 2). Plan-mode KEEPS
128
- // these tools available — navigation / outline / call-hierarchy
129
- // questions are the bread-and-butter of a planning loop, and the
130
- // surface explicitly never mutates source in this phase. When Phase
131
- // 2 lifts to "apply on confirm", the apply path moves OUT of
132
- // symbols.* and into apply_patch, which is already plan-mode-gated.
133
- 'symbols_call_hierarchy',
134
- 'symbols_code_actions',
135
- 'symbols_diagnostics',
136
- 'symbols_find_definition',
137
- 'symbols_find_references',
138
- 'symbols_format',
139
- 'symbols_hover',
140
- 'symbols_implementations',
141
- 'symbols_list_in_file',
142
- 'symbols_rename',
143
- 'symbols_signature',
144
- 'symbols_type_definition',
145
- 'symbols_workspace_symbols',
146
- ]);
147
- /**
148
- * Tools the engine loop dispatches. β1 expands the M1 cornerstone six
149
- * (read/write/edit/grep/glob/bash) with task_* + ask_user_question +
150
- * skill + skill list + web_fetch. The registry advertises these slots
151
- * to the runtime; without dispatcher entries the model would call
152
- * "unknown tool" errors.
153
- */
154
- const WIRED_TOOLS = new Set([
155
- 'read',
156
- 'write',
157
- 'edit',
158
- 'grep',
159
- 'glob',
160
- 'bash',
161
- // PowerShell tool for Windows-first workflows.
162
- // Same bash permission class — destructive-pattern classifier applies.
163
- // Plan mode excludes shell tools by design (read-only); the planMode
164
- // check on the schema side already handles that, so we just list it
165
- // alongside 'bash' here.
166
- 'powershell',
167
- 'ask_user_question',
168
- 'skill',
169
- 'skills_list',
170
- 'task_create',
171
- 'task_get',
172
- 'task_list',
173
- 'task_update',
174
- // see READ_ONLY_TOOLS above for the rationale.
175
- 'todo_write',
176
- // Tool gap pack: see READ_ONLY_TOOLS above for `brief` / `sleep`.
177
- 'brief',
178
- // Backlog #5 P0 : verify_plan_execution anti-fake-dispatch gate.
179
- 'verify_plan_execution',
180
- // Backlog PUGI-7 : cron_* tool family (see READ_ONLY_TOOLS rationale).
181
- 'cron_create',
182
- 'cron_delete',
183
- 'cron_list',
184
- 'sleep',
185
- // Tool gap pack: scratch-worktree primitives. Not in
186
- // READ_ONLY_TOOLS — they mutate workspace state (a new git worktree
187
- // is a workspace change even though the touched subtree is
188
- // `.pugi`-scoped). Plan mode excludes them just like write/edit/bash.
189
- 'enter_worktree',
190
- 'exit_worktree',
191
- // Tool gap pack: experimental engine-only echo helper. Gated
192
- // behind allowSyntheticOutput; the schema layer omits it unless the
193
- // caller opted in, but we list the name here so a deliberately-opted
194
- // executor passes the WIRED_TOOLS guard.
195
- 'synthetic_output',
196
- 'web_fetch',
197
- // β1b T4: see READ_ONLY_TOOLS above.
198
- 'web_search',
199
- // β2 S3 : real subagent spawn primitive. Only advertised
200
- // when buildToolsSchema is called with allowAgent=true (orchestrator
201
- // / root Pugi context); plan-mode also excludes it because spawning
202
- // a write-capable child violates plan-mode's read-only contract.
203
- 'agent',
204
- // β7 L5+T11 : transactional multi-file edit. Routes
205
- // through the same security gate as Layer A/B/C; not advertised in
206
- // plan mode (mutation surface).
207
- 'multi_edit',
208
- // PUGI-78 Phase 1: symbols.* namespace (13 LSP-bridged tools). All
209
- // read-only in Phase 1 — `rename` / `format` / `code_actions`
210
- // return PREVIEW edits; the dispatcher applies via apply_patch in
211
- // Phase 2 (PUGI-134). The executor dispatch wires each name to the
212
- // matching `symbols*Tool` wrapper in `src/tools/lsp-tools.ts`.
213
- 'symbols_call_hierarchy',
214
- 'symbols_code_actions',
215
- 'symbols_diagnostics',
216
- 'symbols_find_definition',
217
- 'symbols_find_references',
218
- 'symbols_format',
219
- 'symbols_hover',
220
- 'symbols_implementations',
221
- 'symbols_list_in_file',
222
- 'symbols_rename',
223
- 'symbols_signature',
224
- 'symbols_type_definition',
225
- 'symbols_workspace_symbols',
226
- // Phase 1 runtime evidence pack (PUGI-291..295): server_* + http_request.
227
- // Off by default in plan mode (mutation surface for start/stop; even
228
- // health/logs/http_request cross the plan-mode read-only contract
229
- // because they are runtime evidence primitives, not source reads).
230
- 'http_request',
231
- 'server_health',
232
- 'server_logs',
233
- 'server_start',
234
- 'server_stop',
235
- ]);
236
- export function buildToolsSchema(kind, options = { allowFetch: false, allowSearch: false }) {
237
- const planMode = kind === 'plan';
238
- // β4 M1/M3: splice MCP tools BEFORE the native list assembly so the
239
- // engine-loop sees them in stable alphabetical order alongside native
240
- // tools. We keep the entries appended after the native push so plan-
241
- // mode can be filtered by namespace prefix in one place at the end.
242
- const mcpDefs = buildMcpToolDefs(options.mcpRegistry);
243
- const toolDefs = [
244
- {
245
- name: 'read',
246
- description: 'Read the contents of a workspace file. Required before edit on a file. Returns the full UTF-8 text. Workspace-scoped: paths must be relative to the workspace root.',
247
- parameters: {
248
- type: 'object',
249
- additionalProperties: false,
250
- required: ['path'],
251
- properties: {
252
- path: { type: 'string', description: 'Workspace-relative file path.' },
253
- },
254
- },
255
- },
256
- {
257
- name: 'grep',
258
- description: 'Substring-match every workspace file. Returns up to 200 matches with {path, line, text}. Canonical arg: `query`. Aliases accepted: text, pattern, q, search.',
259
- parameters: {
260
- type: 'object',
261
- additionalProperties: true,
262
- properties: {
263
- query: { type: 'string', description: 'Substring to search for (canonical).' },
264
- text: { type: 'string', description: 'Alias for query.' },
265
- pattern: { type: 'string', description: 'Alias for query.' },
266
- q: { type: 'string', description: 'Alias for query.' },
267
- search: { type: 'string', description: 'Alias for query.' },
268
- },
269
- },
270
- },
271
- {
272
- name: 'glob',
273
- description: 'List files matching a glob pattern (workspace-scoped, node_modules / dist / .git / .pugi excluded). Up to 500 paths.',
274
- parameters: {
275
- type: 'object',
276
- additionalProperties: false,
277
- required: ['pattern'],
278
- properties: {
279
- pattern: { type: 'string', description: 'Glob pattern, e.g. "src/**/*.ts".' },
280
- },
281
- },
282
- },
283
- ];
284
- // β1 T1/T6: TodoWrite (Pugi grammar = `task_*`). Append-only ledger
285
- // at `.pugi/sessions/<id>/tasks.jsonl`.
286
- toolDefs.push({
287
- name: 'task_create',
288
- description: 'Append a new task to the session todo ledger. Returns the assigned task id and full record. Mirrors the standard tool TodoWrite/create.',
289
- parameters: {
290
- type: 'object',
291
- additionalProperties: false,
292
- required: ['title'],
293
- properties: {
294
- title: { type: 'string', description: 'Short imperative summary, max 2000 chars.' },
295
- status: {
296
- type: 'string',
297
- enum: ['pending', 'in_progress', 'completed', 'cancelled'],
298
- description: 'Initial status. Default pending.',
299
- },
300
- notes: { type: 'string', description: 'Optional free-form context.' },
301
- },
302
- },
303
- }, {
304
- name: 'task_get',
305
- description: 'Fetch a single task record by id. Returns null when absent.',
306
- parameters: {
307
- type: 'object',
308
- additionalProperties: false,
309
- required: ['id'],
310
- properties: { id: { type: 'string' } },
311
- },
312
- }, {
313
- name: 'task_list',
314
- description: 'List all tasks for the current session ordered by createdAt ascending.',
315
- parameters: { type: 'object', additionalProperties: false, properties: {} },
316
- }, {
317
- name: 'task_update',
318
- description: 'Mutate status/title/notes on an existing task. Throws on unknown id. Append-only journal.',
319
- parameters: {
320
- type: 'object',
321
- additionalProperties: false,
322
- required: ['id'],
323
- properties: {
324
- id: { type: 'string' },
325
- title: { type: 'string' },
326
- status: {
327
- type: 'string',
328
- enum: ['pending', 'in_progress', 'completed', 'cancelled'],
329
- },
330
- notes: { type: 'string' },
331
- },
332
- },
333
- });
334
- // `todo_write` — batch TodoWrite mirror of
335
- // the upstream tool's upstream tool. Whereas `task_*` above is granular
336
- // (one mutation per call, JSONL append, session-scoped),
337
- // `todo_write` snapshots the FULL board in one call, JSON snapshot
338
- // at `.pugi/todos.json`, workspace-scoped. Enforces the single-
339
- // in-progress invariant at dispatch time: a batch with >1
340
- // `in_progress` rejects with `TODO_INVARIANT_VIOLATED` and the
341
- // on-disk board is left unchanged.
342
- toolDefs.push({
343
- name: 'todo_write',
344
- description: 'Replace the workspace todo board (batch snapshot, not incremental). Emit the FULL todo list every call. ' +
345
- 'At most ONE item may carry status="in_progress" — violations reject with TODO_INVARIANT_VIOLATED. ' +
346
- 'Persisted atomically to .pugi/todos.json. Mirrors the standard tool TodoWrite verbatim.',
347
- parameters: todoWriteJsonSchema,
348
- });
349
- // Tool gap pack : `brief` — structured operator
350
- // progress note. JSONL-append к `.pugi/briefs/<session>.jsonl` via
351
- // the atomic tmp+rename pattern. Plan-mode safe (metadata only, no
352
- // source mutation).
353
- toolDefs.push({
354
- name: 'brief',
355
- description: 'Emit a short structured progress note to the operator. Use INSTEAD of narrating in prose ' +
356
- 'when you want to surface "what I am doing now". One JSON record per call, persisted к ' +
357
- '.pugi/briefs/<session>.jsonl. Required: headline (<=120 chars), status (planning|working|blocked|done). ' +
358
- 'Optional: detail (<=2000 chars).',
359
- parameters: briefJsonSchema,
360
- });
361
- // Backlog PUGI-7: cron_* tool family. Three tools that expose the
362
- // local routine registry to the persona:
363
- // - cron_create — register a recurring routine. Writes one JSON
364
- // document per routine to `.pugi/cron/<name>.json` via atomic
365
- // tmp+rename. Replacing an existing name is intentional (the
366
- // name is the idempotency key).
367
- // - cron_delete — remove a routine by name. Idempotent.
368
- // - cron_list — list every registered routine, sorted by name.
369
- // The scheduler runner (CronScheduler in core/cron/scheduler.ts) is
370
- // OUT of this surface — these tools only EDIT the registry; ticking
371
- // is gated by an explicit `pugi routines run` opt-in.
372
- toolDefs.push({
373
- name: 'cron_create',
374
- description: 'Register a recurring routine. Required: name (slug), cronExpression (5-field), command (shell string). ' +
375
- 'Optional: args (string[]), description. Re-registering the same name REPLACES the prior routine. ' +
376
- 'Persisted atomically to .pugi/cron/<name>.json. Returns {ok, routine, replaced}.',
377
- parameters: cronCreateJsonSchema,
378
- }, {
379
- name: 'cron_delete',
380
- description: 'Remove a routine by name. Idempotent — deleting an unknown name returns ok:true with removed:false. ' +
381
- 'Returns {ok, removed, name}.',
382
- parameters: cronDeleteJsonSchema,
383
- }, {
384
- name: 'cron_list',
385
- description: 'List every registered routine, sorted by name. Zero routines returns {routines:[]} — never an error. ' +
386
- 'No arguments.',
387
- parameters: cronListJsonSchema,
388
- });
389
- // Backlog #5 P0 : verify_plan_execution — anti-fake-dispatch gate.
390
- // Reads the session audit log (metadata only, no source mutation). Plan-mode
391
- // safe: a plan-loop frequently needs к verify its plan-capture steps before
392
- // any write turn fires. Surface это as a tool the model can call right
393
- // before emitting а "done" message on multi-step turns.
394
- toolDefs.push({
395
- name: 'verify_plan_execution',
396
- description: 'Assert that every step in a previously-stated plan actually executed. ' +
397
- 'Reads the session audit log (tool calls + file mutations recorded this session) ' +
398
- 'and checks each step\'s declared requirements. Returns { status: "verified" | "gap", gaps: [...] }. ' +
399
- 'When status is "gap" the engine loop continues so the model can fill the missing ' +
400
- 'steps or explicitly explain why they were skipped. Call this BEFORE emitting а ' +
401
- 'final "done" message when you stated а multi-step plan at the start of the turn.',
402
- parameters: verifyPlanExecutionJsonSchema,
403
- });
404
- // Tool gap pack : `sleep` — wall-clock pause primitive.
405
- // Counts against --max-turns like any other dispatch; the model should
406
- // prefer a real poll loop (read + grep + retry) over blind sleep.
407
- toolDefs.push({
408
- name: 'sleep',
409
- description: 'Pause the engine loop for an integer number of seconds (1..600). ' +
410
- 'Counts against the turn budget. Prefer a real poll loop over blind sleep — ' +
411
- 'this tool exists only for fixed cooldowns the operator owns.',
412
- parameters: sleepJsonSchema,
413
- });
414
- if (!planMode) {
415
- // Tool gap pack : scratch-worktree primitives.
416
- // `enter_worktree` materialises a fresh git worktree at
417
- // `.pugi/worktrees/<taskId>/` so a long task can land its edits in
418
- // isolation; `exit_worktree` is the cleanup primitive. Both are
419
- // workspace mutations, so plan mode excludes them.
420
- toolDefs.push({
421
- name: 'enter_worktree',
422
- description: 'Open a scratch git worktree at .pugi/worktrees/<taskId>/ for write-isolated work. ' +
423
- 'Required: taskId (lowercase slug). Optional: baseRef (defaults to main). ' +
424
- 'Returns { worktreePath, branchName }. Pair with exit_worktree for cleanup.',
425
- parameters: enterWorktreeJsonSchema,
426
- });
427
- toolDefs.push({
428
- name: 'exit_worktree',
429
- description: 'Tear down a scratch worktree previously opened by enter_worktree. ' +
430
- 'The worktreePath MUST live under <workspaceRoot>/.pugi/worktrees/ — anything else refuses. ' +
431
- 'Runs `git worktree remove --force` then rmSync. Idempotent.',
432
- parameters: exitWorktreeJsonSchema,
433
- });
434
- }
435
- // Tool gap pack : experimental engine-only echo
436
- // helper. Advertised only when the caller explicitly opted in via
437
- // `allowSyntheticOutput: true`. Off-by-default mirrors the privacy
438
- // posture used for `allowFetch` / `allowSearch` — every customer-side
439
- // CLI omits this so the model cannot use it as a side-channel that
440
- // bypasses the normal tool-result logging.
441
- if (options.allowSyntheticOutput) {
442
- toolDefs.push({
443
- name: 'synthetic_output',
444
- description: 'Engine-only echo helper. Writes verbatim text to the requested stream (stdout|stderr). ' +
445
- `Capped at ${(16 * 1024).toString()} bytes per call. Test fixture, not for production agent flows.`,
446
- parameters: syntheticOutputJsonSchema,
447
- });
448
- }
449
- // β1 T2 → structured AskUserQuestion bridge.
450
- // Schema upgraded to 's multi-choice form: header chip +
451
- // {label, description} per option. Dispatcher accepts the structured
452
- // form (preferred) AND the legacy string-array form so existing
453
- // callers / tests keep working until the next major bump.
454
- //
455
- // Interactive TTY → returns the picked label(s).
456
- // Non-TTY / no bridge → `[user_input_required]` envelope.
457
- toolDefs.push({
458
- name: 'ask_user_question',
459
- description: 'Clarifying multi-choice question to the operator. Use INSTEAD of asking in prose when one parameter is missing. Required: question (?-ended), header (≤12 chars), 2-4 options each with {label, description}. NEVER include "Other" — UI auto-adds. Budget: max 1 per turn.',
460
- parameters: askUserQuestionJsonSchema,
461
- });
462
- // β1 T3: Skill tool — discover + invoke locally-installed skills.
463
- toolDefs.push({
464
- name: 'skills_list',
465
- description: 'List installed skills (global + workspace). Returns name+description+scope.',
466
- parameters: {
467
- type: 'object',
468
- additionalProperties: false,
469
- properties: {
470
- scope: { type: 'string', enum: ['all', 'global', 'workspace'] },
471
- },
472
- },
473
- }, {
474
- name: 'skill',
475
- description: 'Load a skill body by name. Workspace scope wins over global. Body capped at 32KB.',
476
- parameters: {
477
- type: 'object',
478
- additionalProperties: false,
479
- required: ['name'],
480
- properties: { name: { type: 'string' } },
481
- },
482
- });
483
- // PUGI-78 Phase 1: symbols.* namespace (13 tools). LSP-bridged
484
- // symbol-aware operations that replace whole-file reads for
485
- // navigation questions. Each tool returns 1-2 KB of shaped JSON
486
- // vs 5-50 KB for the raw file read it replaces — 10-100x token
487
- // savings per refactor turn. All read-only in Phase 1 — `rename`
488
- // / `format` / `code_actions` return PREVIEW edits the dispatcher
489
- // applies via apply_patch in a future ticket.
490
- //
491
- // Schemas use the same JSON-Schema shape as the rest of the
492
- // toolbox: required positional args, additionalProperties: false.
493
- // The `lang` field is a closed enum of the 5 supported language
494
- // server slugs (ts/js/py/go/rust). The dispatcher resolves the
495
- // LSP client by lang BEFORE calling the underlying primitive.
496
- const symbolsLangEnum = { type: 'string', enum: ['ts', 'js', 'py', 'go', 'rust'], description: 'LSP language slug.' };
497
- const symbolsPosArgs = {
498
- lang: symbolsLangEnum,
499
- file: { type: 'string', description: 'Workspace-relative file path.' },
500
- line: { type: 'integer', minimum: 0, description: 'Zero-based line index.' },
501
- col: { type: 'integer', minimum: 0, description: 'Zero-based character index.' },
502
- };
503
- toolDefs.push({
504
- name: 'symbols_find_definition',
505
- description: 'Locate the definition of the symbol at (line, col) in <file>. Returns {file, line, character}. PREFER over read for "where is X defined?" questions.',
506
- parameters: {
507
- type: 'object',
508
- additionalProperties: false,
509
- required: ['lang', 'file', 'line', 'col'],
510
- properties: symbolsPosArgs,
511
- },
512
- }, {
513
- name: 'symbols_find_references',
514
- description: 'List every reference to the symbol at (line, col). Returns {file, line, character}[]. PREFER over grep for "find callers of X" questions.',
515
- parameters: {
516
- type: 'object',
517
- additionalProperties: false,
518
- required: ['lang', 'file', 'line', 'col'],
519
- properties: symbolsPosArgs,
520
- },
521
- }, {
522
- name: 'symbols_list_in_file',
523
- description: 'Outline the symbols (functions, classes, methods) defined in <file>. Returns flat {name, kind, line, character, containerName}[]. PREFER over read for "outline" questions.',
524
- parameters: {
525
- type: 'object',
526
- additionalProperties: false,
527
- required: ['lang', 'file'],
528
- properties: { lang: symbolsLangEnum, file: { type: 'string' } },
529
- },
530
- }, {
531
- name: 'symbols_rename',
532
- description: 'Preview a rename refactor for the symbol at (line, col) to <newName>. Returns {files, edits}[]. PREVIEW ONLY in Phase 1 — operator confirms then dispatches apply_patch.',
533
- parameters: {
534
- type: 'object',
535
- additionalProperties: false,
536
- required: ['lang', 'file', 'line', 'col', 'newName'],
537
- properties: {
538
- ...symbolsPosArgs,
539
- newName: { type: 'string', minLength: 1, description: 'New symbol name.' },
540
- },
541
- },
542
- }, {
543
- name: 'symbols_hover',
544
- description: 'Type info + docstring at (line, col). Returns {content, range?}. Body capped at 4 KB.',
545
- parameters: {
546
- type: 'object',
547
- additionalProperties: false,
548
- required: ['lang', 'file', 'line', 'col'],
549
- properties: symbolsPosArgs,
550
- },
551
- }, {
552
- name: 'symbols_signature',
553
- description: 'Function signature at a call site. Returns {label, parameters, activeParameter?}. NULL when cursor is not inside a call.',
554
- parameters: {
555
- type: 'object',
556
- additionalProperties: false,
557
- required: ['lang', 'file', 'line', 'col'],
558
- properties: symbolsPosArgs,
559
- },
560
- }, {
561
- name: 'symbols_workspace_symbols',
562
- description: 'Workspace-wide fuzzy symbol search. Server-defined match (substring / fuzzy / prefix). Returns {name, file, line, kind, containerName?}[].',
563
- parameters: {
564
- type: 'object',
565
- additionalProperties: false,
566
- required: ['lang', 'query'],
567
- properties: {
568
- lang: symbolsLangEnum,
569
- query: { type: 'string', minLength: 1, description: 'Symbol query.' },
570
- },
571
- },
572
- }, {
573
- name: 'symbols_call_hierarchy',
574
- description: 'Incoming + outgoing callers for the symbol at (line, col). Returns {incoming, outgoing}[].',
575
- parameters: {
576
- type: 'object',
577
- additionalProperties: false,
578
- required: ['lang', 'file', 'line', 'col'],
579
- properties: symbolsPosArgs,
580
- },
581
- }, {
582
- name: 'symbols_implementations',
583
- description: 'Concrete implementations of the interface / abstract method at (line, col). Returns flat references list.',
584
- parameters: {
585
- type: 'object',
586
- additionalProperties: false,
587
- required: ['lang', 'file', 'line', 'col'],
588
- properties: symbolsPosArgs,
589
- },
590
- }, {
591
- name: 'symbols_type_definition',
592
- description: 'Type definition (vs value definition) of the symbol at (line, col). Returns the type declaration location.',
593
- parameters: {
594
- type: 'object',
595
- additionalProperties: false,
596
- required: ['lang', 'file', 'line', 'col'],
597
- properties: symbolsPosArgs,
598
- },
599
- }, {
600
- name: 'symbols_code_actions',
601
- description: 'Quick-fix list at the given range. Returns {title, kind?, isPreferred?}[].',
602
- parameters: {
603
- type: 'object',
604
- additionalProperties: false,
605
- required: ['lang', 'file', 'startLine', 'startChar', 'endLine', 'endChar'],
606
- properties: {
607
- lang: symbolsLangEnum,
608
- file: { type: 'string' },
609
- startLine: { type: 'integer', minimum: 0 },
610
- startChar: { type: 'integer', minimum: 0 },
611
- endLine: { type: 'integer', minimum: 0 },
612
- endChar: { type: 'integer', minimum: 0 },
613
- },
614
- },
615
- }, {
616
- name: 'symbols_format',
617
- description: 'Formatter — returns the text edits the LSP server would apply. PREVIEW ONLY in Phase 1.',
618
- parameters: {
619
- type: 'object',
620
- additionalProperties: false,
621
- required: ['lang', 'file'],
622
- properties: {
623
- lang: symbolsLangEnum,
624
- file: { type: 'string' },
625
- tabSize: { type: 'integer', minimum: 1 },
626
- insertSpaces: { type: 'boolean' },
627
- },
628
- },
629
- }, {
630
- name: 'symbols_diagnostics',
631
- description: 'Cached LSP diagnostics (error / warning / info / hint) for <file>. Returns up to ~50 entries.',
632
- parameters: {
633
- type: 'object',
634
- additionalProperties: false,
635
- required: ['lang', 'file'],
636
- properties: { lang: symbolsLangEnum, file: { type: 'string' } },
637
- },
638
- });
639
- // β1 T5 → β1a r1 (gating fix): WebFetch wire-in. Schema
640
- // mirrors the existing tool surface in
641
- // `apps/pugi-cli/src/tools/web-fetch.ts`. SSRF guard runs inside the
642
- // tool itself, but advertising the tool to the model when the tenant
643
- // has not opted in is itself a privacy leak — the model could infer
644
- // URL patterns and try to exfiltrate via the refused call's argument
645
- // bytes. Only push the schema entry when the operator has explicitly
646
- // enabled fetch (either via `.pugi/settings.json::web.fetch.enabled`
647
- // or via `--allow-fetch`).
648
- if (options.allowFetch) {
649
- toolDefs.push({
650
- name: 'web_fetch',
651
- description: 'One-shot HTTP GET against an operator-supplied URL. Response is parsed to Markdown and wrapped in <untrusted-content> sentinel. Gated off by default.',
652
- parameters: {
653
- type: 'object',
654
- additionalProperties: false,
655
- required: ['url'],
656
- properties: {
657
- url: { type: 'string', description: 'Fully-qualified http(s) URL.' },
658
- },
659
- },
660
- });
661
- }
662
- // β1b T4 : web_search advertisement. Same off-by-default
663
- // privacy posture as web_fetch — the query string itself is an egress
664
- // event that can leak operator intent to the upstream Brave Search
665
- // backend. The tool dispatcher applies SSRF guards (no localhost via
666
- // the Anvil proxy URL), rate-limits (5 req/min per session), and caps
667
- // the result payload at 1 MiB. Sentinel-wrapped results so the model
668
- // treats every snippet as data, not instructions.
669
- if (options.allowSearch) {
670
- toolDefs.push({
671
- name: 'web_search',
672
- description: 'Search the web via Brave Search (Anvil-proxied). Returns up to 10 sentinel-wrapped {title, url, snippet} results. Rate-limited to 5 calls/min per session. Gated off by default.',
673
- parameters: {
674
- type: 'object',
675
- additionalProperties: false,
676
- required: ['query'],
677
- properties: {
678
- query: {
679
- type: 'string',
680
- description: 'Search query, max 256 chars. Plain text — no operators.',
681
- },
682
- count: {
683
- type: 'integer',
684
- description: 'Optional result count (1..10, default 10).',
685
- },
686
- },
687
- },
688
- });
689
- }
690
- // β2 S3 : `agent` tool — subagent spawn primitive.
691
- // Off by default; surfaced only when the caller explicitly opts in
692
- // (orchestrator parents pass allowAgent=true via the engine adapter).
693
- // Plan mode FORCES the tool off regardless because a write-capable
694
- // child would violate plan-mode's read-only contract.
695
- if (options.allowAgent && !planMode) {
696
- toolDefs.push({
697
- name: 'agent',
698
- description: 'Spawn a specialist subagent under a Cyber-Zoo brand persona. '
699
- + 'Role selects the persona + isolation tier: '
700
- + 'researcher/reviewer/architect are read-only, verifier reads + runs tests, '
701
- + 'coder/release/devops/design_qa get write + bash. '
702
- + 'The child runs a fresh Anvil engine loop with its own transcript and '
703
- + 'returns a JSON envelope (filesChanged, toolCallCount, status, summary). '
704
- + 'Use this when the work needs a specialist persona OR write isolation via a scratch worktree.',
705
- parameters: {
706
- type: 'object',
707
- additionalProperties: false,
708
- required: ['role', 'brief'],
709
- properties: {
710
- role: {
711
- type: 'string',
712
- enum: [
713
- 'orchestrator',
714
- 'architect',
715
- 'coder',
716
- 'verifier',
717
- 'reviewer',
718
- 'researcher',
719
- 'release',
720
- 'devops',
721
- 'design_qa',
722
- ],
723
- description: 'SubagentRole — selects persona + isolation tier.',
724
- },
725
- brief: {
726
- type: 'string',
727
- maxLength: 8000,
728
- description: 'One-paragraph task description forwarded to the child as the user prompt. '
729
- + 'Be concrete: include filenames, expected behavior, and acceptance criteria.',
730
- },
731
- isolation: {
732
- type: 'string',
733
- enum: ['worktree', 'shared_fs', 'auto'],
734
- description: 'Optional override. `worktree` forces a scratch git worktree for write isolation; '
735
- + '`shared_fs` forces same-tree execution; `auto` (default) defers to the role tier.',
736
- },
737
- },
738
- },
739
- });
740
- }
741
- if (!planMode) {
742
- toolDefs.push({
743
- name: 'write',
744
- description: 'Create or overwrite a workspace file. Prefer edit for existing files. ' +
745
- 'For OVERWRITE of an existing file, you MUST read the file first in this session — ' +
746
- 'write refuses with STALE_READ if the file changed since your last read, or if you ' +
747
- 'never read it. New-file creation (path does not exist) skips that gate. Workspace-scoped.',
748
- parameters: {
749
- type: 'object',
750
- additionalProperties: false,
751
- required: ['path', 'content'],
752
- properties: {
753
- path: { type: 'string', description: 'Workspace-relative file path.' },
754
- content: { type: 'string', description: 'Full new file contents (UTF-8).' },
755
- },
756
- },
757
- }, {
758
- name: 'edit',
759
- description: 'Replace exactly one occurrence of oldString with newString inside an already-read file. ' +
760
- 'Refuses with STALE_READ if the file was never read this session or the on-disk contents ' +
761
- 'drifted since the read (mtime+sha gate). Recovery: re-read with the `read` tool, then ' +
762
- 'retry the edit. Also fails if oldString is missing or duplicate.',
763
- parameters: {
764
- type: 'object',
765
- additionalProperties: false,
766
- required: ['path', 'oldString', 'newString'],
767
- properties: {
768
- path: { type: 'string' },
769
- oldString: { type: 'string' },
770
- newString: { type: 'string' },
771
- },
772
- },
773
- }, {
774
- name: 'bash',
775
- description: 'Run a shell command inside the workspace root via /bin/sh -c. Inherits a sanitized env (PUGI_API_KEY/PUGI_LOGIN_TOKEN stripped). 60s timeout. Output capped at 32KB combined stdout+stderr. ' +
776
- 'Optional `redirect` opts the call into log-discipline mode: stdout+stderr are written to a file on disk (default `.pugi/runs/<sessionId>/bash-<hash>.log` or a workspace-relative override) and the response carries the path + last N lines (default 20, max 200) instead of the full output. Use redirect for long-running scripts (builds, training loops, agentic stdout dumps) where the trailing lines + a path to the full log saves thousands of tokens vs the truncated head. ' +
777
- 'Returns {exitCode, stdout, stderr, truncated} on the buffered path, or {exitCode, stdout:\'\', stderr:\'\', logPath, tail, truncated:false} when redirect is set.',
778
- parameters: {
779
- type: 'object',
780
- additionalProperties: false,
781
- required: ['command'],
782
- properties: {
783
- command: { type: 'string', description: 'Single shell command to execute.' },
784
- redirect: {
785
- type: 'object',
786
- additionalProperties: false,
787
- description: 'When set, redirect stdout+stderr to a file instead of returning content. Use for long-running scripts that produce thousands of lines.',
788
- properties: {
789
- path: {
790
- type: 'string',
791
- description: 'Workspace-relative path to write the log file. Defaults to `.pugi/runs/<sessionId>/bash-<commandHash>.log`. Absolute paths or `..` traversal are rejected.',
792
- },
793
- tailLines: {
794
- type: 'number',
795
- description: 'Number of trailing lines to fold into the response tail. Default 20, max 200.',
796
- },
797
- },
798
- },
799
- },
800
- },
801
- }, {
802
- name: 'powershell',
803
- description: 'Run a PowerShell command via `pwsh -NoProfile -Command` (or `powershell.exe` fallback on Windows). Same security posture as bash — destructive pattern gate applies. 30s default timeout, 120s max. Output capped at 64KB. Returns {exitCode, stdout, stderr, truncated, shellBinary}. Prefer the dedicated bash tool for /bin/sh scripts; use this when the operator needs native pwsh cmdlets or *.ps1 syntax.',
804
- parameters: {
805
- type: 'object',
806
- additionalProperties: false,
807
- required: ['command'],
808
- properties: {
809
- command: { type: 'string', description: 'Single PowerShell command or script.' },
810
- cwd: { type: 'string', description: 'Optional cwd; defaults to workspace root.' },
811
- timeoutMs: { type: 'number', description: 'Hard timeout (default 30000, max 120000).' },
812
- },
813
- },
814
- },
815
- // β7 L5+T11 : transactional multi-file edit. Either
816
- // all entries land or none do — failures roll the workspace back
817
- // via the same journal + snapshot machinery the dispatcher uses.
818
- // Cap is 50 entries; beyond that the operator (or model) should
819
- // split the refactor or use Layer C rewrites.
820
- {
821
- name: 'multi_edit',
822
- description: 'Apply an ordered batch of single-occurrence file edits as one transaction. ' +
823
- 'Each entry is {file, oldString, newString} like the `edit` tool. Either every ' +
824
- 'edit lands or none do — a failure rolls the workspace back to the pre-dispatch ' +
825
- 'state via journal + snapshot. Cap 50 edits per call. Use this for coordinated ' +
826
- 'refactors (rename across files, add an import to many modules).',
827
- parameters: {
828
- type: 'object',
829
- additionalProperties: false,
830
- required: ['edits'],
831
- properties: {
832
- edits: {
833
- type: 'array',
834
- minItems: 1,
835
- maxItems: 50,
836
- items: {
837
- type: 'object',
838
- additionalProperties: false,
839
- required: ['file', 'oldString', 'newString'],
840
- properties: {
841
- file: { type: 'string', description: 'Workspace-relative file path.' },
842
- oldString: { type: 'string', description: 'Verbatim substring; must be unique in the pre-edit file.' },
843
- newString: { type: 'string', description: 'Replacement string. Empty string means delete.' },
844
- },
845
- },
846
- },
847
- },
848
- },
849
- },
850
- // Phase 1 runtime evidence pack (PUGI-291..295) - server lifecycle
851
- // primitives + generic HTTP request. Off in plan mode because each
852
- // tool produces side effects (a spawned process, an outbound HTTP
853
- // call) the plan-mode read-only contract refuses.
854
- {
855
- name: 'server_start',
856
- description: 'Spawn a server via /bin/sh -c <command> in the workspace and poll the health URL until it returns the expected status (default 200) or the timeout elapses. Persists pid + meta to `.pugi/runs/<runId>/server.json` and a stdout/stderr tail to `server.log`. Returns {ok, pid, port, healthStatus, logPath, durationMs, error?}. Use to materialise concrete pid + health 200 evidence rather than asserting a server is up in prose.',
857
- parameters: serverStartJsonSchema,
858
- }, {
859
- name: 'server_stop',
860
- description: 'Send SIGTERM to the supplied pid, wait graceMs (default 5000ms), then escalate to SIGKILL. Idempotent - a pid that already exited returns ok=true with signal=undefined. Returns {ok, pid, exitCode?, signal?, durationMs, error?}.',
861
- parameters: serverStopJsonSchema,
862
- }, {
863
- name: 'server_health',
864
- description: 'One-shot HTTP GET against the supplied URL with a short timeout (default 5000ms, max 60000ms). Returns {ok, status, durationMs, body?, error?} where ok=true only when the response status equals expectStatus (default 200). Body is capped at 1 KB so the envelope stays compact.',
865
- parameters: serverHealthJsonSchema,
866
- }, {
867
- name: 'server_logs',
868
- description: 'Read the trailing N lines of `.pugi/runs/<runId>/server.log`. Defaults to the most recent run when runId is omitted; pid-based lookup walks every run dir. Returns {ok, logPath, lines, totalLines, error?}.',
869
- parameters: serverLogsJsonSchema,
870
- }, {
871
- name: 'http_request',
872
- description: 'Issue an HTTP request (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS) against a URL. Loopback hosts (localhost / 127.0.0.0/8 / ::1) always pass; non-loopback hosts require allowExternal: true so the default posture stays private. Body objects/arrays are JSON-serialised. Returns {ok, status, headers, body, json?, durationMs, error?, truncated?}.',
873
- parameters: httpRequestJsonSchema,
874
- });
875
- }
876
- // β4 M1/M3: append MCP tools last. Plan mode skips them because every
877
- // MCP tool is treated as medium-risk until per-tool annotations land
878
- // in the MCP spec; treating MCP read-as-read would require server-
879
- // side metadata we cannot trust today (a misconfigured server could
880
- // claim `read` while running a destructive op).
881
- if (!planMode) {
882
- for (const def of mcpDefs) {
883
- toolDefs.push({
884
- name: def.name,
885
- description: def.description,
886
- parameters: def.parameters,
887
- });
888
- }
889
- }
890
- // L3 : leak-parity underscore-prefix filter. Every
891
- // tool's parameter schema is scrubbed of `_`-prefixed fields before
892
- // the model ever sees it. Native tool schemas above currently declare
893
- // no `_*` fields, but MCP tools surfaced through buildMcpToolDefs
894
- // come from third-party servers whose authors may follow the same
895
- // convention (an MCP tool can declare `_sessionId` knowing the CLI
896
- // dispatcher will inject it before forwarding). The dispatcher
897
- // (buildExecutor below) does NOT strip these from the args record at
898
- // call time — `_internal*` keys still flow through to tool handlers
899
- // when an upstream layer populates them.
900
- return toolDefs.map((tool) => ({
901
- name: tool.name,
902
- description: tool.description,
903
- parameters: stripInternalFields(tool.parameters),
904
- }));
905
- }
906
- /**
907
- * L11: tolerant args-parse for the denial fingerprint. Unlike
908
- * `parseArgs` (which throws on malformed JSON so the model sees a
909
- * parse error), this swallows failures and returns `{}` — the denial
910
- * tracker needs SOME key even when the raw payload is unparseable,
911
- * because malformed-args spam is itself a pattern operators want to
912
- * see in `/permissions denials`.
913
- */
914
- function safeParseForTracking(raw) {
915
- if (!raw || raw.trim() === '')
916
- return {};
917
- try {
918
- return JSON.parse(raw);
919
- }
920
- catch {
921
- // Use the raw string as the fingerprint payload so repeated
922
- // identical malformed dispatches still cluster.
923
- return { _rawArgs: raw.slice(0, 512) };
924
- }
925
- }
926
- function parseArgs(raw) {
927
- if (!raw || raw.trim() === '')
928
- return {};
929
- try {
930
- const parsed = JSON.parse(raw);
931
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
932
- throw new Error('tool arguments must be a JSON object');
933
- }
934
- return parsed;
935
- }
936
- catch (error) {
937
- throw new Error(`invalid JSON in tool arguments: ${error.message}`);
938
- }
939
- }
940
- /**
941
- * Strict canonical-only argument coercion (leak P0 L2).
942
- *
943
- * Reverts the beta.17 alias acceptance (`file` / `filename` / `filepath`
944
- * / `file_path` → `path`). The alias shim was the wrong direction: it
945
- * paved over a model-side prompt-drift bug at the runtime layer, weakened
946
- * the strict JSON-Schema contract one layer up (`additionalProperties:
947
- * false`), and drifted away from the upstream reference (research memo
948
- * §1.1 — `z.strictObject` rejects aliased fields).
949
- *
950
- * The compensating change ships in the persona prompts: Pugi's system
951
- * prompt and Hiroshi's persona body now declare canonical parameter
952
- * names with few-shot wrong/right contrasts so the model learns the
953
- * grammar upstream of the bridge.
954
- */
955
- function requireString(obj, key) {
956
- const v = obj[key];
957
- if (typeof v === 'string')
958
- return v;
959
- throw new Error(`tool argument "${key}" must be a string`);
960
- }
961
- /**
962
- * Accept `path` (canonical) or `filePath` (the upstream tool convention) for
963
- * write/edit/read tool arguments. Models trained on CC system prompts
964
- * emit `filePath`; insisting on `path` only forces 2-3 retry waste on
965
- * every file write (CEO live smoke: snake.html dispatch
966
- * burned 2 turns retrying `{filePath: ...}` payloads before falling
967
- * back к bash heredoc). Defense-in-depth alias keeps canonical name
968
- * (so persona prompts can still teach `path` as the right answer) AND
969
- * tolerates the CC-trained variant without operator-visible failure.
970
- */
971
- function requirePathArg(obj) {
972
- if (typeof obj['path'] === 'string')
973
- return obj['path'];
974
- if (typeof obj['filePath'] === 'string')
975
- return obj['filePath'];
976
- throw new Error('tool argument "path" must be a string (alias "filePath" also accepted)');
977
- }
978
- export function buildExecutor(input) {
979
- const { kind, ctx, hooks, mvpHooksConfig, sessionId, askUserBridge, interactive, allowFetch, allowSearch, allowSyntheticOutput, agentDispatch, mcpRegistry, permissionMode, permissionAlwaysCache, permissionAsk, } = input;
980
- // per-cycle budget. Default to a fresh instance scoped to
981
- // this executor's closure lifetime; tests pass their own.
982
- const retryBudget = input.retryBudget ?? new RetryBudget();
983
- const mcpPrompt = input.mcpPrompt ?? defaultNonInteractiveMcpPrompt;
984
- const workspaceRoot = input.workspaceRoot ?? ctx.root;
985
- const planMode = kind === 'plan';
986
- const denialTracking = input.denialTracking;
987
- // L11: helper that records a denial (when tracking is wired) and
988
- // ALWAYS returns an Error whose message includes a compact
989
- // `<denial-context>` reminder when the same (tool, args) pair has
990
- // already been refused at least once before in this session.
991
- //
992
- // The reminder is appended to the THROWN message — the engine loop
993
- // appends thrown messages to the transcript as tool-result strings,
994
- // so the model sees the aggregate the next time it considers a
995
- // dispatch. Without this every retry would only see the latest
996
- // single-turn reason and could loop indefinitely.
997
- //
998
- // Best-effort: a hash/clone failure inside the tracker MUST NOT
999
- // mask the original refusal. The catch path falls back to a bare
1000
- // Error with the reason text.
1001
- const recordDenial = (toolName, args, reason) => {
1002
- if (!denialTracking)
1003
- return new Error(reason);
1004
- try {
1005
- const record = denialTracking.recordDenial(toolName, args, reason);
1006
- // Only inject the reminder once the threshold is hit — the very
1007
- // first denial is the model's first chance to learn, no need to
1008
- // shout. From the 2nd repeat onwards the model has demonstrated
1009
- // it is not learning from the single-turn sentinel, so we splice
1010
- // the aggregate context.
1011
- if (record.count >= DENIAL_REMINDER_THRESHOLD) {
1012
- const reminder = buildDenialContext(denialTracking);
1013
- if (reminder.length > 0) {
1014
- return new Error(`${reason}\n\n${reminder}`);
1015
- }
1016
- }
1017
- }
1018
- catch {
1019
- // Tracking is best-effort. Fall through to the bare Error so
1020
- // the refusal still propagates.
1021
- }
1022
- return new Error(reason);
1023
- };
1024
- return async ({ name, arguments: argsRaw }) => {
1025
- // β4 M1/M3: MCP tool names live outside WIRED_TOOLS. They are
1026
- // validated lazily by the dispatcher (the registry knows which
1027
- // names are actually exposed). The namespace check happens FIRST
1028
- // so a bad `mcp__bogus__foo` does not collide with the native
1029
- // unknown-tool branch.
1030
- const isMcpName = name.startsWith(MCP_TOOL_PREFIX);
1031
- // L11: parse-or-empty args once up-front so every deny path
1032
- // below can fingerprint the call against the denial tracker. We
1033
- // tolerate parse failure — `{}` keys still produce a stable hash
1034
- // (the model may have sent malformed JSON, but the refusal is
1035
- // semantic, not parse-driven).
1036
- const argsForTracking = safeParseForTracking(argsRaw);
1037
- if (!isMcpName && !WIRED_TOOLS.has(name)) {
1038
- throw recordDenial(name, argsForTracking, `unknown tool: ${name}`);
1039
- }
1040
- // — canonical 4-mode permission gate. Routes the dispatch
1041
- // decision BEFORE the legacy plan-mode-only enforcement so the new
1042
- // surface is the source of truth when the caller opted in. Absent
1043
- // `permissionMode` falls through to the legacy plan-mode branch
1044
- // (existing semantics preserved for callsites that have not
1045
- // migrated yet).
1046
- let hooksBypassed = false;
1047
- if (permissionMode) {
1048
- const decision = permissionGate(name, argsRaw, {
1049
- permissionMode,
1050
- ...(permissionAlwaysCache ? { alwaysCache: permissionAlwaysCache } : {}),
1051
- });
1052
- if (decision.decision === 'deny') {
1053
- throw new PermissionDenied(name, getToolClass(name), permissionMode, decision.reason);
1054
- }
1055
- if (decision.decision === 'ask') {
1056
- if (!permissionAsk) {
1057
- // Non-interactive caller (CI / pipes / agent-as-tool) cannot
1058
- // surface a prompt. Collapse to deny so the loop receives a
1059
- // deterministic refusal instead of hanging.
1060
- throw new PermissionDenied(name, decision.toolClass, permissionMode, `Ask mode: no operator prompt available for ${name} (non-interactive caller)`);
1061
- }
1062
- const answer = await permissionAsk({
1063
- toolName: name,
1064
- toolClass: decision.toolClass,
1065
- question: decision.question,
1066
- options: decision.options,
1067
- });
1068
- const verdict = permissionAlwaysCache
1069
- ? applyAskAnswer(permissionAlwaysCache, name, answer)
1070
- : applyAskAnswer({ alwaysAllowed: new Set(), alwaysDenied: new Set() }, name, answer);
1071
- if (verdict.decision === 'deny') {
1072
- throw new PermissionDenied(name, decision.toolClass, permissionMode, verdict.reason);
1073
- }
1074
- // verdict.decision === 'allow' falls through to dispatch.
1075
- }
1076
- else {
1077
- // allow — honour the bypass flag for the hook layer below.
1078
- hooksBypassed = decision.hooksBypassed === true;
1079
- }
1080
- }
1081
- else if (planMode) {
1082
- // Legacy plan-mode enforcement (kind === 'plan') stays in place
1083
- // for callers that have not opted into the canonical gate.
1084
- // MCP tools are uniformly refused in plan mode (see schema-side
1085
- // rationale in buildToolsSchema). Native tools split via
1086
- // READ_ONLY_TOOLS as before.
1087
- if (isMcpName || !READ_ONLY_TOOLS.has(name)) {
1088
- throw recordDenial(name, argsForTracking, `PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
1089
- }
1090
- }
1091
- // : refuse cancelled-token tool dispatch BEFORE PreToolUse
1092
- // hooks fire so a cancelled brief never reaches user-defined
1093
- // hook scripts. Sentinel `OPERATOR_ABORTED:<tool>` is recognised
1094
- // by `runEngineLoop` as a terminal-cancel signal so the loop
1095
- // returns control to the caller rather than retrying the model.
1096
- if (ctx.cancellation && ctx.cancellation.isAborted) {
1097
- throw recordDenial(name, argsForTracking, `OPERATOR_ABORTED: ${name} refused — operator cancelled the dispatch.`);
1098
- }
1099
- // — per-cycle tool retry budget. Same tool + same canonical
1100
- // args = same bucket. Once the cap is hit we throw a typed sentinel
1101
- // so the model is forced out of a repair loop. We gate AFTER
1102
- // permission (denied calls do not burn budget) and BEFORE PreToolUse
1103
- // hooks (hook-blocked retries DO count — the model still issued the
1104
- // same call). The `recordAttempt` fires unconditionally so warn-only
1105
- // mode (PUGI_RETRY_BUDGET_DISABLED=1) still tracks the pattern for
1106
- // diagnostics.
1107
- const argHash = hashArgs(argsRaw);
1108
- const budgetDecision = retryBudget.shouldAllow(name, argHash);
1109
- retryBudget.recordAttempt(name, argHash);
1110
- if (!budgetDecision.allowed) {
1111
- throw new RetryBudgetExhausted(name, budgetDecision.cap, argHash);
1112
- }
1113
- // Fire PreToolUse hooks. The match grammar takes the tool name and
1114
- // (when extractable) the target path. Each new tool dispatch starts a
1115
- // fresh dedup batch so a hook fires once per dispatch, not once per
1116
- // session.
1117
- //
1118
- // — bypass mode skips the entire hook layer (PreToolUse +
1119
- // PostToolUse + PostToolUseFailure). The gate's allow decision
1120
- // carries the `hooksBypassed` flag; we honour it here so the
1121
- // executor stays single-pass.
1122
- if (hooks && sessionId && !hooksBypassed) {
1123
- hooks.resetBatch();
1124
- const path = extractToolPath(name, argsRaw);
1125
- const preCtx = {
1126
- sessionId,
1127
- event: 'PreToolUse',
1128
- tool: name,
1129
- path,
1130
- payload: { tool: name, arguments: argsRaw },
1131
- };
1132
- // List the matching hooks BEFORE firing so we can correlate
1133
- // hook[i] with result[i] when checking onFailure: 'block'. The
1134
- // ordering of listMatching and fire is stable: fire iterates the
1135
- // same listMatching output internally.
1136
- const matchingPreHooks = hooks.listMatching(preCtx);
1137
- const preResults = await hooks.fire(preCtx);
1138
- for (let i = 0; i < matchingPreHooks.length; i += 1) {
1139
- const hook = matchingPreHooks[i];
1140
- const result = preResults[i];
1141
- if (hook && result && hook.onFailure === 'block' && !result.ok) {
1142
- // L11: record the PreToolUse hook denial so the model
1143
- // sees the pattern reminder on subsequent turns. Without
1144
- // this the model would re-issue the same refused call and
1145
- // burn a turn each time before noticing the loop.
1146
- throw recordDenial(name, argsForTracking, `HOOK_BLOCKED: PreToolUse hook (${hook.run.slice(0, 80)}) refused ${name} (exit=${result.exitCode})`);
1147
- }
1148
- }
1149
- }
1150
- // MVP: fire `hooks-mvp.json` PreToolUse hooks. Distinct
1151
- // config file from the legacy `hooks.json` system so operator
1152
- // configs do not collide. Same blocking semantics — a non-zero
1153
- // exit from a hook declared `blocking: true` refuses the dispatch
1154
- // with `HOOK_BLOCKED:` sentinel. Bypass mode skips this surface
1155
- // identically to the legacy hooks block above.
1156
- if (mvpHooksConfig && sessionId && !hooksBypassed && !mvpHooksConfig.isEmpty()) {
1157
- const { fireHooks } = await import('../hooks/index.js');
1158
- const outcome = await fireHooks({
1159
- config: mvpHooksConfig,
1160
- event: 'PreToolUse',
1161
- payload: {
1162
- event: 'PreToolUse',
1163
- sessionId,
1164
- toolName: name,
1165
- toolInputSummary: hashArgs(argsRaw),
1166
- },
1167
- toolName: name,
1168
- workspaceRoot: ctx.root,
1169
- });
1170
- if (outcome.anyBlocked) {
1171
- const blocking = outcome.results.find((r) => r.blocked);
1172
- const sentinel = blocking?.blockSentinel ??
1173
- `HOOK_BLOCKED: PreToolUse MVP-hook refused ${name}`;
1174
- throw recordDenial(name, argsForTracking, sentinel);
1175
- }
1176
- }
1177
- // β4 M1/M3: MCP dispatch deferred to the `dispatch` closure below so
1178
- // PostToolUse / PostToolUseFailure hooks observe MCP calls just like
1179
- // native calls. The dispatcher does its own argument parsing — MCP
1180
- // arg errors surface as model-visible `[MCP dispatch error] ...`
1181
- // strings, not throws.
1182
- const args = isMcpName ? {} : parseArgs(argsRaw);
1183
- const dispatch = async () => {
1184
- if (isMcpName) {
1185
- return dispatchMcpTool({
1186
- name,
1187
- argumentsRaw: argsRaw,
1188
- registry: mcpRegistry,
1189
- prompt: mcpPrompt,
1190
- });
1191
- }
1192
- // β1 T1/T2/T3/T5/T6: async-dispatch the new tool surface.
1193
- // task_*, skill, ask_user_question, web_fetch all live behind
1194
- // an async or async-compatible boundary.
1195
- if (name === 'task_create' || name === 'task_get' || name === 'task_list' || name === 'task_update') {
1196
- return dispatchTaskTool(name, args, { workspaceRoot, sessionId });
1197
- }
1198
- if (name === 'todo_write') {
1199
- // batch TodoWrite. The dispatcher delegates the
1200
- // Zod validation + atomic persist to the tool module — any
1201
- // ZodError or `TODO_INVARIANT_VIOLATED` sentinel surfaces here
1202
- // as a thrown Error and lands on the catch arm below, which
1203
- // re-emits it through the PostToolUseFailure hook.
1204
- return dispatchTodoWrite({ workspaceRoot }, args);
1205
- }
1206
- // Tool gap pack : brief / sleep / synthetic_output /
1207
- // enter_worktree / exit_worktree dispatchers. Each tool returns a
1208
- // sentinel string on recoverable validation failures (no throw)
1209
- // so the engine adapter surfaces them as plain tool results and
1210
- // the model can self-correct.
1211
- if (name === 'brief') {
1212
- return dispatchBrief({
1213
- workspaceRoot,
1214
- // Fallback when running outside an audit session (CI, smoke
1215
- // tests, one-shot CLI commands) — keep the JSONL writes
1216
- // grouped under a stable basename instead of dropping them.
1217
- sessionId: sessionId ?? 'no-session',
1218
- }, args);
1219
- }
1220
- if (name === 'verify_plan_execution') {
1221
- // Backlog #5 P0 : anti-fake-dispatch gate. Reads
1222
- // the session audit log accumulated during this dispatch (and
1223
- // earlier turns in the same engine loop invocation).
1224
- return dispatchVerifyPlanExecution(ctx.session, args);
1225
- }
1226
- if (name === 'cron_create') {
1227
- // Backlog PUGI-7: ScheduleCronTool — register a routine. The
1228
- // dispatcher returns a JSON string envelope (success) or a
1229
- // CRON_INVALID_ARGS / CRON_PERSIST_FAILED sentinel (recoverable
1230
- // failures). Sentinels surface as plain tool results so the
1231
- // model can self-correct.
1232
- return dispatchCronCreate({ workspaceRoot }, args);
1233
- }
1234
- if (name === 'cron_delete') {
1235
- // Backlog PUGI-7: idempotent routine removal.
1236
- return dispatchCronDelete({ workspaceRoot }, args);
1237
- }
1238
- if (name === 'cron_list') {
1239
- // Backlog PUGI-7: routine registry snapshot. Read-only; safe
1240
- // for parallel dispatch.
1241
- return dispatchCronList({ workspaceRoot }, args);
1242
- }
1243
- if (name === 'sleep') {
1244
- return dispatchSleep({}, args);
1245
- }
1246
- if (name === 'synthetic_output') {
1247
- if (!allowSyntheticOutput) {
1248
- // Mirrors the `web_fetch` / `agent` defense-in-depth posture:
1249
- // a stale schema must never let the model invoke an opt-in
1250
- // tool. Surface a clear refusal sentinel the dispatcher can
1251
- // record for denial tracking.
1252
- throw new Error('synthetic_output: tool not enabled in this executor. Engine-only fixture; opt in via allowSyntheticOutput.');
1253
- }
1254
- return dispatchSyntheticOutput({}, args);
1255
- }
1256
- if (name === 'enter_worktree') {
1257
- return dispatchEnterWorktree({ workspaceRoot }, args);
1258
- }
1259
- if (name === 'exit_worktree') {
1260
- return dispatchExitWorktree({ workspaceRoot }, args);
1261
- }
1262
- if (name === 'ask_user_question') {
1263
- return dispatchAskUser(args, { interactive: Boolean(interactive), bridge: askUserBridge });
1264
- }
1265
- if (name === 'skill' || name === 'skills_list') {
1266
- return dispatchSkillTool(name, args, { workspaceRoot });
1267
- }
1268
- if (name === 'web_fetch') {
1269
- return dispatchWebFetch(args, { ctx, allowFetch: Boolean(allowFetch) });
1270
- }
1271
- if (name === 'web_search') {
1272
- return dispatchWebSearch(args, {
1273
- ctx,
1274
- allowSearch: Boolean(allowSearch),
1275
- sessionId,
1276
- });
1277
- }
1278
- // Phase 1 runtime evidence pack (PUGI-291..295). Each tool returns
1279
- // a JSON-string envelope or a sentinel string on validation
1280
- // failure - both paths are recoverable from the model's POV, so
1281
- // the engine adapter routes them as plain tool results and the
1282
- // model can self-correct.
1283
- if (name === 'server_start') {
1284
- return dispatchServerStart({ workspaceRoot }, args);
1285
- }
1286
- if (name === 'server_stop') {
1287
- return dispatchServerStop({ workspaceRoot }, args);
1288
- }
1289
- if (name === 'server_health') {
1290
- return dispatchServerHealth({ workspaceRoot }, args);
1291
- }
1292
- if (name === 'server_logs') {
1293
- return dispatchServerLogs({ workspaceRoot }, args);
1294
- }
1295
- if (name === 'http_request') {
1296
- return dispatchHttpRequest({}, args);
1297
- }
1298
- if (name === 'multi_edit') {
1299
- return dispatchMultiEdit(args, ctx);
1300
- }
1301
- if (name === 'agent') {
1302
- // β2a r1 (Backend Architect P1): defense in depth.
1303
- // `WIRED_TOOLS` includes `agent`, so a plan-mode model that
1304
- // fabricates an `agent` tool call would otherwise be routed
1305
- // here. The plan-mode refusal at the top of the executor only
1306
- // fires for tools NOT in READ_ONLY_TOOLS; `agent` is
1307
- // intentionally absent from both sets, so we explicitly refuse
1308
- // it here. This pairs with `native-pugi.ts` hard-gating
1309
- // `agentDispatch` itself off in plan mode — without this
1310
- // defensive throw a future schema bug could let a plan-mode
1311
- // model spawn a write-capable child and break the read-only
1312
- // contract.
1313
- if (planMode) {
1314
- throw recordDenial(name, argsForTracking, 'PLAN_MODE_REFUSED: agent is not allowed in plan mode');
1315
- }
1316
- return dispatchAgent(args, agentDispatch);
1317
- }
1318
- // PUGI-78 Phase 1: symbols.* namespace dispatch. Every name in
1319
- // SYMBOLS_TOOL_NAMES routes through `dispatchSymbolsTool` which
1320
- // resolves the LSP client by `lang`, calls the matching
1321
- // `symbols*Tool` wrapper in `src/tools/lsp-tools.ts`, and
1322
- // serialises the result for the engine envelope. Read-only —
1323
- // matches the registry posture; the post-edit diagnostics hook
1324
- // below is the only mutation the symbols.* surface participates in.
1325
- if (SYMBOLS_TOOL_NAMES.has(name)) {
1326
- return dispatchSymbolsTool(name, args, ctx);
1327
- }
1328
- return dispatchTool(name, args, ctx);
1329
- };
1330
- try {
1331
- const result = await dispatch();
1332
- // post-edit LSP diagnostics. After a
1333
- // successful `edit` / `write` / `multi_edit`, ask the cached
1334
- // language server for diagnostics on the touched file(s) and
1335
- // append the result to the tool envelope so the model can
1336
- // self-correct in the same turn. Silent skip when the language
1337
- // is unsupported, no server is installed, or the request times
1338
- // out — agent throughput beats diagnostic recall.
1339
- const augmented = await appendPostEditDiagnostics(name, args, ctx, result);
1340
- if (hooks && sessionId && !hooksBypassed) {
1341
- const path = extractToolPath(name, argsRaw);
1342
- await hooks.fire({
1343
- sessionId,
1344
- event: 'PostToolUse',
1345
- tool: name,
1346
- path,
1347
- payload: { tool: name, arguments: argsRaw, ok: true, result: augmented.slice(0, 1024) },
1348
- });
1349
- }
1350
- return augmented;
1351
- }
1352
- catch (error) {
1353
- // #24 (CEO P1) — hook chains. After the legacy
1354
- // PostToolUseFailure registry fire (per-error-class block below),
1355
- // ALSO fire the settings.json hook chain. Chains are best-effort:
1356
- // a chain command crash never propagates back here so the engine
1357
- // loop sees the original throw unchanged.
1358
- const fireFailureChain = async (errorMessage) => {
1359
- try {
1360
- await firePostToolUseFailureChain(ctx.root, {
1361
- toolName: name,
1362
- args: argsForTracking,
1363
- error: errorMessage,
1364
- exitCode: 1,
1365
- });
1366
- }
1367
- catch (chainError) {
1368
- process.stderr.write(`[pugi hook-chains] PostToolUseFailure chain crashed: ${chainError.message}\n`);
1369
- }
1370
- };
1371
- // — surface the PermissionDenied sentinel as a model-
1372
- // readable message instead of leaking the raw Error type. The
1373
- // string format is stable so the engine adapter / spec layer
1374
- // can pattern-match against it.
1375
- if (error instanceof PermissionDenied) {
1376
- // PostToolUseFailure fires for visibility unless bypass is on.
1377
- if (hooks && sessionId && !hooksBypassed) {
1378
- await hooks.fire({
1379
- sessionId,
1380
- event: 'PostToolUseFailure',
1381
- tool: name,
1382
- payload: {
1383
- tool: name,
1384
- arguments: argsRaw,
1385
- ok: false,
1386
- error: error.toModelMessage(),
1387
- },
1388
- });
1389
- }
1390
- await fireFailureChain(error.toModelMessage());
1391
- throw new Error(error.toModelMessage());
1392
- }
1393
- // : re-shape OperatorAbortedError throws from the
1394
- // file-tools layer into the same `OPERATOR_ABORTED:` sentinel
1395
- // the upstream cancellation gate uses so `runEngineLoop` sees
1396
- // a consistent terminal-cancel signal regardless of whether
1397
- // the abort landed pre-dispatch or mid-tool (e.g. inside the
1398
- // grep file-loop).
1399
- if (error instanceof OperatorAbortedError) {
1400
- if (hooks && sessionId && !hooksBypassed) {
1401
- const path = extractToolPath(name, argsRaw);
1402
- await hooks.fire({
1403
- sessionId,
1404
- event: 'PostToolUseFailure',
1405
- tool: name,
1406
- path,
1407
- payload: {
1408
- tool: name,
1409
- arguments: argsRaw,
1410
- ok: false,
1411
- error: `OPERATOR_ABORTED: ${name}`,
1412
- },
1413
- });
1414
- }
1415
- await fireFailureChain(`OPERATOR_ABORTED: ${name}`);
1416
- throw recordDenial(name, argsForTracking, `OPERATOR_ABORTED: ${name} aborted mid-execution.`);
1417
- }
1418
- // re-shape StaleReadError into a
1419
- // deterministic STALE_READ:<reason> sentinel so the model's
1420
- // retry policy can pattern-match on a stable prefix instead of
1421
- // free-form prose. The model is expected to re-read the file and
1422
- // retry the edit — the message points it at exactly that recovery
1423
- // path. PostToolUseFailure hooks observe the typed error so an
1424
- // operator can build a "warn me when stale edits keep happening"
1425
- // hook (likely a concurrency / multi-agent indicator).
1426
- if (error instanceof StaleReadError) {
1427
- if (hooks && sessionId && !hooksBypassed) {
1428
- const path = extractToolPath(name, argsRaw);
1429
- await hooks.fire({
1430
- sessionId,
1431
- event: 'PostToolUseFailure',
1432
- tool: name,
1433
- path,
1434
- payload: {
1435
- tool: name,
1436
- arguments: argsRaw,
1437
- ok: false,
1438
- error: `STALE_READ: ${error.reason} on ${error.path}`,
1439
- },
1440
- });
1441
- }
1442
- await fireFailureChain(`STALE_READ: ${error.reason} on ${error.path}`);
1443
- throw recordDenial(name, argsForTracking, `STALE_READ: ${name} on ${error.path} refused (${error.reason}). Re-read the file with the \`read\` tool, then retry the ${name}.`);
1444
- }
1445
- if (hooks && sessionId && !hooksBypassed) {
1446
- const path = extractToolPath(name, argsRaw);
1447
- await hooks.fire({
1448
- sessionId,
1449
- event: 'PostToolUseFailure',
1450
- tool: name,
1451
- path,
1452
- payload: {
1453
- tool: name,
1454
- arguments: argsRaw,
1455
- ok: false,
1456
- error: error instanceof Error ? error.message : String(error),
1457
- },
1458
- });
1459
- }
1460
- await fireFailureChain(error instanceof Error ? error.message : String(error));
1461
- throw error;
1462
- }
1463
- };
1464
- }
1465
- /**
1466
- * Best-effort extraction of the file path a tool targets. Returns
1467
- * undefined when the tool does not take a path or the path cannot be
1468
- * parsed cleanly — match rules with a `pathGlob` will then skip this
1469
- * dispatch, which is the safe default.
1470
- */
1471
- function extractToolPath(name, argsRaw) {
1472
- if (name !== 'read' && name !== 'write' && name !== 'edit')
1473
- return undefined;
1474
- try {
1475
- const parsed = JSON.parse(argsRaw);
1476
- const path = parsed.path;
1477
- return typeof path === 'string' ? path : undefined;
1478
- }
1479
- catch {
1480
- return undefined;
1481
- }
1482
- }
1483
- function dispatchTool(name, args, ctx) {
1484
- switch (name) {
1485
- case 'read': {
1486
- const { path } = { path: requirePathArg(args) };
1487
- const content = readTool(ctx, path);
1488
- // Cap the content surfaced back to the model so a 10MB file
1489
- // does not blow the context window. The model sees the head
1490
- // and a truncation marker; if it needs more it can grep.
1491
- const CAP = 32 * 1024;
1492
- if (content.length > CAP) {
1493
- return `${content.slice(0, CAP)}\n(...truncated at ${CAP} bytes; use grep or glob to narrow the read)`;
1494
- }
1495
- return content;
1496
- }
1497
- case 'write': {
1498
- const wargs = {
1499
- path: requirePathArg(args),
1500
- content: requireString(args, 'content'),
1501
- };
1502
- writeTool(ctx, wargs.path, wargs.content);
1503
- return `wrote ${wargs.path} (${wargs.content.length} bytes)`;
1504
- }
1505
- case 'edit': {
1506
- const eargs = {
1507
- path: requirePathArg(args),
1508
- oldString: requireString(args, 'oldString'),
1509
- newString: requireString(args, 'newString'),
1510
- };
1511
- editTool(ctx, eargs.path, eargs.oldString, eargs.newString);
1512
- return `edited ${eargs.path}`;
1513
- }
1514
- case 'grep': {
1515
- const queryRaw = args.query ?? args.text ?? args.pattern ?? args.q ?? args.search;
1516
- if (typeof queryRaw !== 'string' || queryRaw.length === 0) {
1517
- throw new Error('tool argument "query" must be a non-empty string (aliases: text/pattern/q/search)');
1518
- }
1519
- const gargs = { query: queryRaw };
1520
- const matches = grepTool(ctx, gargs.query);
1521
- if (matches.length === 0)
1522
- return `no matches for ${gargs.query}`;
1523
- const head = matches.slice(0, 50);
1524
- const rendered = head.map((m) => `${m.path}:${m.line}: ${m.text}`).join('\n');
1525
- const more = matches.length > head.length ? `\n(... ${matches.length - head.length} more)` : '';
1526
- return `${matches.length} match(es):\n${rendered}${more}`;
1527
- }
1528
- case 'glob': {
1529
- const gargs = { pattern: requireString(args, 'pattern') };
1530
- const results = globTool(ctx, gargs.pattern);
1531
- if (results.length === 0)
1532
- return `no paths match ${gargs.pattern}`;
1533
- return `${results.length} path(s):\n${results.slice(0, 100).join('\n')}${results.length > 100 ? `\n(... ${results.length - 100} more)` : ''}`;
1534
- }
1535
- case 'bash': {
1536
- const command = requireString(args, 'command');
1537
- // Pugi backlog P2 — parse the optional redirect block. We
1538
- // accept either no field, an empty object (== "use defaults"),
1539
- // or `{path?, tailLines?}`. The bash tool's helper layer
1540
- // normalises both values; we only do the outer-shape parse
1541
- // here so a malformed arg surfaces as a model-readable error.
1542
- const rawRedirect = args['redirect'];
1543
- let redirect;
1544
- if (rawRedirect !== undefined && rawRedirect !== null) {
1545
- if (typeof rawRedirect !== 'object' || Array.isArray(rawRedirect)) {
1546
- throw new Error('tool argument "redirect" must be an object when present');
1547
- }
1548
- const r = rawRedirect;
1549
- const pathArg = r['path'];
1550
- const tailArg = r['tailLines'];
1551
- if (pathArg !== undefined && typeof pathArg !== 'string') {
1552
- throw new Error('redirect.path must be a string when present');
1553
- }
1554
- if (tailArg !== undefined && (typeof tailArg !== 'number' || !Number.isFinite(tailArg))) {
1555
- throw new Error('redirect.tailLines must be a finite number when present');
1556
- }
1557
- redirect = {
1558
- ...(pathArg !== undefined ? { path: pathArg } : {}),
1559
- ...(tailArg !== undefined ? { tailLines: tailArg } : {}),
1560
- };
1561
- }
1562
- // The class-aware bash tool (sprint ) replaces the legacy
1563
- // file-tools entry point. We use the sync variant here because
1564
- // dispatchTool's signature is sync; the async tool is reserved
1565
- // for the REPL path (sprint ) where promises are first class.
1566
- const result = bashToolSync({ cmd: command, ...(redirect !== undefined ? { redirect } : {}) }, {
1567
- root: ctx.root,
1568
- settings: ctx.settings,
1569
- session: ctx.session,
1570
- source: 'agent',
1571
- });
1572
- // PUGI-VERIFY-GATE: tag verification commands and record them
1573
- // on the session ledger so the engine outcome assembler can
1574
- // gate the final `status` on test/lint/build pass. The check
1575
- // is pure — `detectVerificationCommand` matches the regex
1576
- // allowlist in `verification-patterns.ts`. Record BEFORE
1577
- // building the model-facing envelope so the ledger is durable
1578
- // even if the model stops the loop on this turn.
1579
- const detection = detectVerificationCommand(command);
1580
- const verificationFailed = detection.isVerification && result.exitCode !== 0;
1581
- if (detection.isVerification && detection.tool !== null) {
1582
- recordVerificationCall(ctx.session, {
1583
- command,
1584
- tool: detection.tool,
1585
- exitCode: result.exitCode,
1586
- tailStderr: tailStderr(
1587
- // Prefer buffered stderr; fall back to redirect tail
1588
- // when stdout/stderr lives on disk (`logPath` mode).
1589
- result.stderr === '' && typeof result.tail === 'string'
1590
- ? result.tail
1591
- : result.stderr),
1592
- timestamp: new Date().toISOString(),
1593
- });
1594
- }
1595
- const parts = [
1596
- `exit=${result.exitCode}`,
1597
- result.stdout ? `stdout:\n${result.stdout}` : '',
1598
- result.stderr ? `stderr:\n${result.stderr}` : '',
1599
- ];
1600
- if (result.artifactRef)
1601
- parts.push(`artifactRef=${result.artifactRef}`);
1602
- if (result.logPath)
1603
- parts.push(`logPath=${result.logPath}`);
1604
- if (result.tail)
1605
- parts.push(`tail:\n${result.tail}`);
1606
- if (result.truncated)
1607
- parts.push('truncated=true');
1608
- if (result.timedOut)
1609
- parts.push('timedOut=true');
1610
- // PUGI-VERIFY-GATE: when a verification command exited non-zero,
1611
- // tag the envelope so the model cannot honestly claim "tests
1612
- // pass" — and so the engine outcome assembler can scan the
1613
- // ledger and gate `done`. The stringified envelope keeps
1614
- // `exit=N` for legacy parsers; the new `verification.tool=` /
1615
- // `verification.ok=` lines surface the gate state explicitly.
1616
- if (detection.isVerification) {
1617
- parts.push(`verification.tool=${detection.tool}`);
1618
- parts.push(`verification.ok=${verificationFailed ? 'false' : 'true'}`);
1619
- }
1620
- const body = parts.filter(Boolean).join('\n');
1621
- return body || '(no output)';
1622
- }
1623
- case 'powershell': {
1624
- // pwsh dispatcher. Permission gate reuses the
1625
- // bash classifier so destructive patterns block the same way.
1626
- const command = requireString(args, 'command');
1627
- const cwd = optionalString(args, 'cwd');
1628
- const timeoutMs = optionalNumber(args, 'timeoutMs');
1629
- const psResult = powerShellToolSync({ cmd: command, ...(cwd !== undefined ? { cwd } : {}), ...(timeoutMs !== undefined ? { timeoutMs } : {}) }, {
1630
- root: ctx.root,
1631
- settings: ctx.settings,
1632
- session: ctx.session,
1633
- source: 'agent',
1634
- });
1635
- const parts = [
1636
- `exit=${psResult.exitCode}`,
1637
- `shell=${psResult.shellBinary}`,
1638
- psResult.stdout ? `stdout:\n${psResult.stdout}` : '',
1639
- psResult.stderr ? `stderr:\n${psResult.stderr}` : '',
1640
- ];
1641
- if (psResult.truncated)
1642
- parts.push('truncated=true');
1643
- if (psResult.timedOut)
1644
- parts.push('timedOut=true');
1645
- return parts.filter(Boolean).join('\n') || '(no output)';
1646
- }
1647
- default:
1648
- // Exhaustive; unreachable because of the WIRED_TOOLS guard above.
1649
- throw new Error(`unhandled tool: ${name}`);
1650
- }
1651
- }
1652
- /* ----------------------------- β1 dispatchers ----------------------------- */
1653
- function dispatchTaskTool(name, args, opts) {
1654
- if (!opts.sessionId) {
1655
- throw new Error(`${name}: no sessionId in scope — task ledger requires a session`);
1656
- }
1657
- const tctx = { workspaceRoot: opts.workspaceRoot, sessionId: opts.sessionId };
1658
- switch (name) {
1659
- case 'task_create': {
1660
- const title = requireString(args, 'title');
1661
- const status = optionalString(args, 'status');
1662
- const notes = optionalString(args, 'notes');
1663
- const record = taskCreate(tctx, {
1664
- title,
1665
- ...(status !== undefined ? { status: status } : {}),
1666
- ...(notes !== undefined ? { notes } : {}),
1667
- });
1668
- return JSON.stringify(record);
1669
- }
1670
- case 'task_get': {
1671
- const id = requireString(args, 'id');
1672
- const record = taskGet(tctx, id);
1673
- return record ? JSON.stringify(record) : 'null';
1674
- }
1675
- case 'task_list': {
1676
- const list = taskList(tctx);
1677
- return JSON.stringify(list);
1678
- }
1679
- case 'task_update': {
1680
- const id = requireString(args, 'id');
1681
- const title = optionalString(args, 'title');
1682
- const status = optionalString(args, 'status');
1683
- const notes = optionalString(args, 'notes');
1684
- const record = taskUpdate(tctx, {
1685
- id,
1686
- ...(title !== undefined ? { title } : {}),
1687
- ...(status !== undefined ? { status: status } : {}),
1688
- ...(notes !== undefined ? { notes } : {}),
1689
- });
1690
- return JSON.stringify(record);
1691
- }
1692
- }
1693
- }
1694
- async function dispatchAskUser(args, opts) {
1695
- const rawOptions = args['options'];
1696
- if (!Array.isArray(rawOptions)) {
1697
- throw new Error('ask_user_question: options must be an array');
1698
- }
1699
- // detect structured vs legacy form. Structured
1700
- // entries are objects with {label, description}; legacy entries are
1701
- // plain strings. The structured path validates via Zod and emits the
1702
- // [ask_user_question:answered|cancelled|timeout] envelope. The legacy
1703
- // path stays for back-compat with the existing β1 T2 tests + the
1704
- // <pugi-ask> prompt envelope (which still feeds string options).
1705
- const looksStructured = rawOptions.length > 0
1706
- && typeof rawOptions[0] === 'object'
1707
- && rawOptions[0] !== null
1708
- && !Array.isArray(rawOptions[0]);
1709
- if (looksStructured) {
1710
- const result = await dispatchAskUserQuestion({ interactive: opts.interactive, ...(opts.bridge ? { bridge: opts.bridge } : {}) }, args);
1711
- return result.envelope;
1712
- }
1713
- // Legacy string-array form.
1714
- const question = requireString(args, 'question');
1715
- const options = rawOptions.map((o, i) => {
1716
- if (typeof o !== 'string') {
1717
- throw new Error(`ask_user_question: options[${i}] must be a string`);
1718
- }
1719
- return o;
1720
- });
1721
- const multiSelect = args['multiSelect'] === true;
1722
- const result = await askUser({ interactive: opts.interactive, ...(opts.bridge ? { bridge: opts.bridge } : {}) }, { question, options, multiSelect });
1723
- return result.envelope;
1724
- }
1725
- async function dispatchSkillTool(name, args, opts) {
1726
- if (name === 'skills_list') {
1727
- const scopeArg = optionalString(args, 'scope');
1728
- const scope = scopeArg === 'global' || scopeArg === 'workspace' ? scopeArg : 'all';
1729
- const list = skillList({ workspaceRoot: opts.workspaceRoot }, { scope });
1730
- return JSON.stringify(list);
1731
- }
1732
- // name === 'skill' (invoke).
1733
- // β1a r1 : `skillInvoke` is now async — it re-verifies
1734
- // the trust manifest sha256 against the on-disk body on every call.
1735
- // Bubble up `await` so a post-install tamper surfaces as a tool
1736
- // error the model sees, not a swallowed Promise<SkillInvokeResult>.
1737
- const skName = requireString(args, 'name');
1738
- const result = await skillInvoke({ workspaceRoot: opts.workspaceRoot }, { name: skName });
1739
- return JSON.stringify(result);
1740
- }
1741
- async function dispatchWebFetch(args, opts) {
1742
- const url = requireString(args, 'url');
1743
- const result = await webFetchTool({ url }, {
1744
- settings: opts.ctx.settings,
1745
- allowFetch: opts.allowFetch,
1746
- });
1747
- return JSON.stringify(result);
1748
- }
1749
- async function dispatchWebSearch(args, opts) {
1750
- const query = requireString(args, 'query');
1751
- // `count` is optional integer 1..10. Validate here so the tool layer
1752
- // gets a clean value (the tool clamps internally too — defense in
1753
- // depth, since the model can pass anything).
1754
- let count;
1755
- if (args['count'] !== undefined && args['count'] !== null) {
1756
- const n = args['count'];
1757
- if (typeof n !== 'number' || !Number.isInteger(n)) {
1758
- throw new Error('web_search: count must be an integer');
1759
- }
1760
- count = n;
1761
- }
1762
- const result = await webSearchTool({ query, ...(count !== undefined ? { count } : {}) }, {
1763
- settings: opts.ctx.settings,
1764
- allowSearch: opts.allowSearch,
1765
- sessionId: opts.sessionId,
1766
- });
1767
- return JSON.stringify(result);
1768
- }
1769
- /**
1770
- * β2 S3 dispatch — wire the model-emitted `agent` tool call to the
1771
- * real subagent spawn primitive. When the executor was built without
1772
- * `agentDispatch` (e.g. a child loop, or a parent that explicitly
1773
- * disabled subagent spawn), the call is refused with a structured
1774
- * envelope so the model can adapt instead of crashing the parent loop.
1775
- */
1776
- async function dispatchAgent(args, opts) {
1777
- if (!opts) {
1778
- // No dispatch context — return a structured refusal envelope.
1779
- // This matches the agent-tool.ts no-engine-client path and lets
1780
- // the model decide whether to retry inline or abandon the
1781
- // delegation. Throwing here would terminate the parent on a tool
1782
- // error frame which is the wrong UX when the issue is config.
1783
- return JSON.stringify({
1784
- ok: false,
1785
- status: 'failed',
1786
- summary: 'agent tool refused: dispatch not wired in this engine adapter. '
1787
- + 'Re-run from a parent loop with agentDispatch configured.',
1788
- });
1789
- }
1790
- const parsed = parseAgentArgs(args);
1791
- const result = await agentTool(parsed, {
1792
- session: opts.parentSession,
1793
- engineClient: opts.engineClient,
1794
- ...(opts.parentBudgetRemaining
1795
- ? { parentBudgetRemaining: opts.parentBudgetRemaining }
1796
- : {}),
1797
- });
1798
- return JSON.stringify(result);
1799
- }
1800
- function parseAgentArgs(args) {
1801
- // Surface a clean error message to the model when the args don't
1802
- // match the schema. agentTool itself also validates via Zod; this
1803
- // pre-parse layer keeps the error stack short.
1804
- const role = requireString(args, 'role');
1805
- const brief = requireString(args, 'brief');
1806
- const isolationRaw = optionalString(args, 'isolation');
1807
- const out = {
1808
- role: role,
1809
- brief,
1810
- ...(isolationRaw ? { isolation: isolationRaw } : {}),
1811
- };
1812
- return out;
1813
- }
1814
- function optionalString(obj, key) {
1815
- const v = obj[key];
1816
- if (v === undefined || v === null)
1817
- return undefined;
1818
- if (typeof v !== 'string') {
1819
- throw new Error(`tool argument "${key}" must be a string when present`);
1820
- }
1821
- return v;
1822
- }
1823
- function optionalNumber(obj, key) {
1824
- const v = obj[key];
1825
- if (v === undefined || v === null)
1826
- return undefined;
1827
- if (typeof v !== 'number' || !Number.isFinite(v)) {
1828
- throw new Error(`tool argument "${key}" must be a finite number when present`);
1829
- }
1830
- return v;
1831
- }
1832
- /**
1833
- * β7 L5+T11: dispatch the model-emitted `multi_edit` tool call. The
1834
- * tool returns a structured result envelope; we serialize it to JSON
1835
- * for the engine loop. A refused dispatch (security, no_match,
1836
- * ambiguous_match, etc.) surfaces as `ok: false` in the envelope —
1837
- * the model can re-strategise rather than crashing the loop.
1838
- */
1839
- function dispatchMultiEdit(args, ctx) {
1840
- const raw = args['edits'];
1841
- if (!Array.isArray(raw)) {
1842
- throw new Error('multi_edit: edits must be an array');
1843
- }
1844
- const edits = raw.map((item, i) => {
1845
- if (!item || typeof item !== 'object') {
1846
- throw new Error(`multi_edit: edits[${i}] must be an object`);
1847
- }
1848
- const obj = item;
1849
- const file = obj['file'];
1850
- const oldString = obj['oldString'];
1851
- const newString = obj['newString'];
1852
- if (typeof file !== 'string') {
1853
- throw new Error(`multi_edit: edits[${i}].file must be a string`);
1854
- }
1855
- if (typeof oldString !== 'string') {
1856
- throw new Error(`multi_edit: edits[${i}].oldString must be a string`);
1857
- }
1858
- if (typeof newString !== 'string') {
1859
- throw new Error(`multi_edit: edits[${i}].newString must be a string`);
1860
- }
1861
- return { file, oldString, newString };
1862
- });
1863
- const result = multiEdit(ctx, edits);
1864
- return JSON.stringify(result);
1865
- }
1866
- /* ---------------------------- hook ---------------------------- */
1867
- /**
1868
- * Tool names that mutate workspace files. After a successful dispatch
1869
- * of any of these, the L15 post-edit diagnostics hook fires. The set
1870
- * is intentionally tight — `task_*` / `todo_write` write to ledger
1871
- * files (not workspace source) so they stay out, and `bash` is too
1872
- * coarse (a `bash` call can write any path, and we'd need to parse
1873
- * the command to know which — out of scope for L15).
1874
- */
1875
- const POST_EDIT_TOOLS = new Set(['edit', 'write', 'multi_edit']);
1876
- /**
1877
- * Append LSP diagnostics to the tool envelope after a successful
1878
- * edit / write / multi_edit. Silent skip is the default — missing
1879
- * binary, unsupported language, request timeout, and "no diagnostics"
1880
- * all leave the envelope unchanged.
1881
- *
1882
- * Opt-in via `.pugi/settings.json::lsp.postEditDiagnostics = true`
1883
- * OR `PUGI_LSP_POST_EDIT=1`. Off by default until dogfood validates
1884
- * the cold-start cost vs the model-loop benefit ().
1885
- */
1886
- /**
1887
- * PUGI-78 Phase 1: dispatched-name allowlist for the symbols.* router.
1888
- * Sourced from the same list as the JSON-schema additions in
1889
- * `buildToolsSchema` + the registry entries in `tools/registry.ts` — a
1890
- * mismatch would surface as either an advertised-but-unrouted tool
1891
- * (codex review P1 from this PR) or an unknown-tool denial.
1892
- */
1893
- const SYMBOLS_TOOL_NAMES = new Set([
1894
- 'symbols_call_hierarchy',
1895
- 'symbols_code_actions',
1896
- 'symbols_diagnostics',
1897
- 'symbols_find_definition',
1898
- 'symbols_find_references',
1899
- 'symbols_format',
1900
- 'symbols_hover',
1901
- 'symbols_implementations',
1902
- 'symbols_list_in_file',
1903
- 'symbols_rename',
1904
- 'symbols_signature',
1905
- 'symbols_type_definition',
1906
- 'symbols_workspace_symbols',
1907
- ]);
1908
- /**
1909
- * PUGI-78 Phase 1: dispatch a symbols.* tool call. Common pre-flight
1910
- * (validate `lang`, resolve / spawn the LSP client via the warm cache,
1911
- * build an `LspToolContext` from the engine `ToolContext`) lives here so
1912
- * each per-tool branch stays a thin shim around the matching wrapper.
1913
- *
1914
- * Failure shape: the wrappers return a structured `{ok, value, reason}`
1915
- * record — we JSON-stringify it for the engine envelope. The engine
1916
- * adapter treats a string return as the tool result text; the wrappers
1917
- * never throw (errors are caught and folded into the structured
1918
- * `ok: false` record), so the dispatch path is safe to call without a
1919
- * try/catch wrapper here.
1920
- */
1921
- async function dispatchSymbolsTool(name, args, ctx) {
1922
- const langRaw = args['lang'];
1923
- const validLangs = ['ts', 'js', 'py', 'go', 'rust'];
1924
- if (typeof langRaw !== 'string' || !validLangs.includes(langRaw)) {
1925
- return JSON.stringify({
1926
- ok: false,
1927
- reason: 'invalid_argument',
1928
- detail: `${name}: 'lang' must be one of ts | js | py | go | rust (got: ${langRaw})`,
1929
- });
1930
- }
1931
- const lang = langRaw;
1932
- // Resolve (and lazily start) the LSP client. The cache holds the
1933
- // client across the session so the cold start is paid once.
1934
- const lspOpts = {
1935
- cwd: ctx.root,
1936
- ...(ctx.settings.lsp ? { lspSettings: ctx.settings.lsp } : {}),
1937
- };
1938
- const lspResult = await getOrStartLspClient(lang, lspOpts);
1939
- const lspToolCtx = {
1940
- ...ctx,
1941
- ...(lspResult.ok
1942
- ? { lspClients: new Map([[lang, lspResult.client]]) }
1943
- : {}),
1944
- symbolCache: getGlobalSymbolCache(),
1945
- };
1946
- // Validate required positional args per tool. Each tool that needs
1947
- // a position rejects missing / non-finite / negative coordinates
1948
- // with a structured `invalid_argument` envelope so the model sees
1949
- // a clear correction signal instead of a silent (0,0) coercion that
1950
- // would otherwise yield `lsp_not_found`.
1951
- const requiresFile = name !== 'symbols_workspace_symbols';
1952
- const requiresPosition = name === 'symbols_find_definition' ||
1953
- name === 'symbols_find_references' ||
1954
- name === 'symbols_hover' ||
1955
- name === 'symbols_signature' ||
1956
- name === 'symbols_implementations' ||
1957
- name === 'symbols_type_definition' ||
1958
- name === 'symbols_call_hierarchy' ||
1959
- name === 'symbols_rename';
1960
- const file = typeof args['file'] === 'string' ? args['file'] : '';
1961
- if (requiresFile && file.length === 0) {
1962
- return JSON.stringify({
1963
- ok: false,
1964
- reason: 'invalid_argument',
1965
- detail: `${name}: 'file' must be a non-empty workspace-relative path`,
1966
- });
1967
- }
1968
- const lineRaw = args['line'];
1969
- const colRaw = args['col'];
1970
- if (requiresPosition) {
1971
- if (typeof lineRaw !== 'number' || !Number.isFinite(lineRaw) || lineRaw < 0) {
1972
- return JSON.stringify({
1973
- ok: false,
1974
- reason: 'invalid_argument',
1975
- detail: `${name}: 'line' must be a non-negative integer`,
1976
- });
1977
- }
1978
- if (typeof colRaw !== 'number' || !Number.isFinite(colRaw) || colRaw < 0) {
1979
- return JSON.stringify({
1980
- ok: false,
1981
- reason: 'invalid_argument',
1982
- detail: `${name}: 'col' must be a non-negative integer`,
1983
- });
1984
- }
1985
- }
1986
- const line = typeof lineRaw === 'number' ? lineRaw : 0;
1987
- const col = typeof colRaw === 'number' ? colRaw : 0;
1988
- try {
1989
- switch (name) {
1990
- case 'symbols_find_definition': {
1991
- const result = await symbolsFindDefinitionTool(lspToolCtx, lang, file, line, col);
1992
- return JSON.stringify(result);
1993
- }
1994
- case 'symbols_find_references': {
1995
- const result = await symbolsFindReferencesTool(lspToolCtx, lang, file, line, col);
1996
- return JSON.stringify(result);
1997
- }
1998
- case 'symbols_list_in_file': {
1999
- const result = await symbolsListInFileTool(lspToolCtx, lang, file);
2000
- return JSON.stringify(result);
2001
- }
2002
- case 'symbols_rename': {
2003
- const newName = typeof args['newName'] === 'string' ? args['newName'] : '';
2004
- const result = await symbolsRenameTool(lspToolCtx, lang, file, line, col, newName);
2005
- return JSON.stringify(result);
2006
- }
2007
- case 'symbols_hover': {
2008
- const result = await symbolsHoverTool(lspToolCtx, lang, file, line, col);
2009
- return JSON.stringify(result);
2010
- }
2011
- case 'symbols_signature': {
2012
- const result = await symbolsSignatureTool(lspToolCtx, lang, file, line, col);
2013
- return JSON.stringify(result);
2014
- }
2015
- case 'symbols_workspace_symbols': {
2016
- const query = typeof args['query'] === 'string' ? args['query'] : '';
2017
- const result = await symbolsWorkspaceSymbolsTool(lspToolCtx, lang, query);
2018
- return JSON.stringify(result);
2019
- }
2020
- case 'symbols_call_hierarchy': {
2021
- const result = await symbolsCallHierarchyTool(lspToolCtx, lang, file, line, col);
2022
- return JSON.stringify(result);
2023
- }
2024
- case 'symbols_implementations': {
2025
- const result = await symbolsImplementationsTool(lspToolCtx, lang, file, line, col);
2026
- return JSON.stringify(result);
2027
- }
2028
- case 'symbols_type_definition': {
2029
- const result = await symbolsTypeDefinitionTool(lspToolCtx, lang, file, line, col);
2030
- return JSON.stringify(result);
2031
- }
2032
- case 'symbols_code_actions': {
2033
- // Validate every coordinate of the range. Same gate as the
2034
- // position-args check above — silent (0,0,0,0) coercion would
2035
- // hide schema-noncompliant calls and yield an empty action
2036
- // list instead of a clear correction signal.
2037
- const coords = ['startLine', 'startChar', 'endLine', 'endChar'];
2038
- for (const k of coords) {
2039
- const v = args[k];
2040
- if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
2041
- return JSON.stringify({
2042
- ok: false,
2043
- reason: 'invalid_argument',
2044
- detail: `symbols_code_actions: '${k}' must be a non-negative integer`,
2045
- });
2046
- }
2047
- }
2048
- const startLine = args['startLine'];
2049
- const startChar = args['startChar'];
2050
- const endLine = args['endLine'];
2051
- const endChar = args['endChar'];
2052
- const result = await symbolsCodeActionsTool(lspToolCtx, lang, file, startLine, startChar, endLine, endChar);
2053
- return JSON.stringify(result);
2054
- }
2055
- case 'symbols_format': {
2056
- const tabSize = typeof args['tabSize'] === 'number' ? args['tabSize'] : undefined;
2057
- const insertSpaces = typeof args['insertSpaces'] === 'boolean' ? args['insertSpaces'] : undefined;
2058
- const options = {};
2059
- if (typeof tabSize === 'number')
2060
- options.tabSize = tabSize;
2061
- if (typeof insertSpaces === 'boolean')
2062
- options.insertSpaces = insertSpaces;
2063
- const result = await symbolsFormatTool(lspToolCtx, lang, file, options);
2064
- return JSON.stringify(result);
2065
- }
2066
- case 'symbols_diagnostics': {
2067
- const result = await symbolsDiagnosticsTool(lspToolCtx, lang, file);
2068
- return JSON.stringify(result);
2069
- }
2070
- default:
2071
- return JSON.stringify({
2072
- ok: false,
2073
- reason: 'unknown_symbols_tool',
2074
- detail: `${name} is not a recognised symbols.* tool`,
2075
- });
2076
- }
2077
- }
2078
- catch (error) {
2079
- // The wrappers fold errors into structured ok:false records, so
2080
- // this branch is defense-in-depth for a refactor regression. If
2081
- // any wrapper ever throws, we surface a stable shape to the model
2082
- // instead of leaking the raw exception type.
2083
- return JSON.stringify({
2084
- ok: false,
2085
- reason: 'symbols_dispatch_error',
2086
- detail: error instanceof Error ? error.message : String(error),
2087
- });
2088
- }
2089
- }
2090
- async function appendPostEditDiagnostics(name, args, ctx, result) {
2091
- if (!POST_EDIT_TOOLS.has(name))
2092
- return result;
2093
- // PUGI-78 Phase 1 (codex P2 fix): a successful edit / write / multi_edit
2094
- // invalidates the symbol cache for this workspace. Without this, a
2095
- // subsequent symbols.* query inside the same 5-minute TTL window could
2096
- // return stale line numbers / outlines / references / hover from
2097
- // before the edit. We invalidate BEFORE the post-edit diagnostics
2098
- // gate so the invalidation fires even when post-edit-diagnostics is
2099
- // disabled (the cache concern is independent of the diagnostics
2100
- // surface). The invalidation is process-global; subagents that share
2101
- // the same Node process share the cache, so the invalidation
2102
- // propagates without an additional hop.
2103
- try {
2104
- const cache = getGlobalSymbolCache();
2105
- cache.invalidateWorkspace(ctx.root);
2106
- }
2107
- catch {
2108
- // Defense-in-depth — cache invalidation must never block the
2109
- // engine's tool envelope. A throw here is a soft contract
2110
- // violation but recoverable.
2111
- }
2112
- if (!isPostEditEnabled(ctx))
2113
- return result;
2114
- const paths = extractEditedPaths(name, args);
2115
- if (paths.length === 0)
2116
- return result;
2117
- const tails = [];
2118
- for (const filePath of paths) {
2119
- const opts = {
2120
- cwd: ctx.root,
2121
- ...(ctx.settings.lsp ? { lspSettings: ctx.settings.lsp } : {}),
2122
- };
2123
- try {
2124
- const diag = await runPostEditDiagnostics(filePath, opts);
2125
- if (!diag.skip) {
2126
- tails.push(diag.tail);
2127
- }
2128
- }
2129
- catch {
2130
- // Belt-and-suspenders: any unexpected throw from the hook is
2131
- // swallowed. The model never blocks on LSP.
2132
- }
2133
- }
2134
- if (tails.length === 0)
2135
- return result;
2136
- return `${result}\n${tails.join('\n')}`;
2137
- }
2138
- function isPostEditEnabled(ctx) {
2139
- const envFlag = process.env.PUGI_LSP_POST_EDIT;
2140
- if (envFlag === '1' || envFlag === 'true')
2141
- return true;
2142
- if (envFlag === '0' || envFlag === 'false')
2143
- return false;
2144
- return ctx.settings.lsp?.postEditDiagnostics === true;
2145
- }
2146
- /**
2147
- * Pull the workspace-relative file path(s) the tool just touched.
2148
- * Each branch mirrors the args shape its `dispatch*` handler reads;
2149
- * a deformed args object yields an empty list so the hook silently
2150
- * skips instead of throwing inside the augmentation layer.
2151
- */
2152
- function extractEditedPaths(name, args) {
2153
- if (name === 'edit' || name === 'write') {
2154
- const path = args['path'];
2155
- return typeof path === 'string' && path.length > 0 ? [path] : [];
2156
- }
2157
- if (name === 'multi_edit') {
2158
- const edits = args['edits'];
2159
- if (!Array.isArray(edits))
2160
- return [];
2161
- const seen = new Set();
2162
- for (const entry of edits) {
2163
- if (!entry || typeof entry !== 'object')
2164
- continue;
2165
- const file = entry['file'];
2166
- if (typeof file === 'string' && file.length > 0)
2167
- seen.add(file);
2168
- }
2169
- return Array.from(seen);
2170
- }
2171
- return [];
2172
- }
2173
- //# sourceMappingURL=tool-bridge.js.map