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

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