ashlr-plugin 1.36.4

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 (499) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +39 -0
  3. package/.claude-plugin/plugin.json +36 -0
  4. package/.codex/agents/README.md +9 -0
  5. package/.codex/agents/ashlr-explorer.md +36 -0
  6. package/.codex/agents/ashlr-worker.md +38 -0
  7. package/.codex-plugin/plugin.json +45 -0
  8. package/.mcp.json +14 -0
  9. package/LICENSE +21 -0
  10. package/README.md +311 -0
  11. package/agents/ashlr-code.md +69 -0
  12. package/agents/ashlr-explore.md +44 -0
  13. package/agents/ashlr-plan.md +54 -0
  14. package/bench/fixtures/notebook-large.ipynb +660 -0
  15. package/bench/fixtures/notebook-small.ipynb +39 -0
  16. package/bench/fixtures/tasklist-closed.json +62 -0
  17. package/bench/fixtures/tasklist-large-mixed.json +27 -0
  18. package/bench/fixtures/tasklist-sprint-open.json +79 -0
  19. package/bench/fixtures/websearch-bun-runtime.json +32 -0
  20. package/bench/fixtures/websearch-react-hooks-patterns.json +32 -0
  21. package/bench/fixtures/websearch-typescript-generics.json +38 -0
  22. package/bench/refs/node-sdk/.refrev +1 -0
  23. package/bench/refs/node-sdk/README.md +17 -0
  24. package/bench/refs/node-sdk/src/agent/agent.ts +163 -0
  25. package/bench/refs/node-sdk/src/agent/create-agent-ui-stream.ts +83 -0
  26. package/bench/refs/node-sdk/src/agent/tool-loop-agent.ts +238 -0
  27. package/bench/refs/node-sdk/src/embed/embed-many.ts +409 -0
  28. package/bench/refs/node-sdk/src/embed/embed-result.ts +50 -0
  29. package/bench/refs/node-sdk/src/embed/embed.ts +257 -0
  30. package/bench/refs/node-sdk/src/error/invalid-argument-error.ts +34 -0
  31. package/bench/refs/node-sdk/src/error/invalid-tool-input-error.ts +33 -0
  32. package/bench/refs/node-sdk/src/error/no-object-generated-error.ts +70 -0
  33. package/bench/refs/node-sdk/src/generate-image/generate-image-result.ts +42 -0
  34. package/bench/refs/node-sdk/src/generate-image/generate-image.ts +362 -0
  35. package/bench/refs/node-sdk/src/generate-object/generate-object-result.ts +67 -0
  36. package/bench/refs/node-sdk/src/generate-object/generate-object.ts +501 -0
  37. package/bench/refs/node-sdk/src/generate-object/inject-json-instruction.ts +30 -0
  38. package/bench/refs/node-sdk/src/generate-object/output-strategy.ts +415 -0
  39. package/bench/refs/node-sdk/src/generate-object/repair-text.ts +12 -0
  40. package/bench/refs/node-sdk/src/generate-object/stream-object.ts +960 -0
  41. package/bench/refs/node-sdk/src/generate-speech/generate-speech.ts +191 -0
  42. package/bench/refs/node-sdk/src/generate-text/generate-text.ts +1484 -0
  43. package/bench/refs/node-sdk/src/generate-text/stream-text.ts +2708 -0
  44. package/bench/refs/node-sdk/src/generate-text/tool-call.ts +59 -0
  45. package/bench/refs/node-sdk/src/generate-text/tool-result.ts +36 -0
  46. package/bench/refs/node-sdk/src/telemetry/create-telemetry-dispatcher.ts +146 -0
  47. package/bench/refs/node-sdk/src/telemetry/telemetry-registry.ts +15 -0
  48. package/bench/refs/node-sdk/src/telemetry/telemetry.ts +223 -0
  49. package/bench/refs/node-sdk/src/util/async-iterable-stream.ts +94 -0
  50. package/bench/refs/node-sdk/src/util/consume-stream.ts +31 -0
  51. package/bench/refs/node-sdk/src/util/create-stitchable-stream.ts +112 -0
  52. package/bench/refs/node-sdk/src/util/download/download.ts +74 -0
  53. package/bench/refs/node-sdk/src/util/error-handler.ts +1 -0
  54. package/bench/refs/node-sdk/src/util/fix-json.ts +401 -0
  55. package/bench/refs/node-sdk/src/util/merge-objects.ts +79 -0
  56. package/bench/refs/python-lib/.refrev +1 -0
  57. package/bench/refs/python-lib/README.md +18 -0
  58. package/bench/refs/python-lib/pandas/core/_numba/executor.py +249 -0
  59. package/bench/refs/python-lib/pandas/core/_numba/extensions.py +586 -0
  60. package/bench/refs/python-lib/pandas/core/_numba/kernels/mean_.py +198 -0
  61. package/bench/refs/python-lib/pandas/core/_numba/kernels/min_max_.py +178 -0
  62. package/bench/refs/python-lib/pandas/core/_numba/kernels/sum_.py +255 -0
  63. package/bench/refs/python-lib/pandas/core/_numba/kernels/var_.py +251 -0
  64. package/bench/refs/python-lib/pandas/core/accessor.py +520 -0
  65. package/bench/refs/python-lib/pandas/core/api.py +138 -0
  66. package/bench/refs/python-lib/pandas/core/array_algos/masked_accumulations.py +97 -0
  67. package/bench/refs/python-lib/pandas/core/array_algos/masked_reductions.py +252 -0
  68. package/bench/refs/python-lib/pandas/core/array_algos/putmask.py +150 -0
  69. package/bench/refs/python-lib/pandas/core/array_algos/quantile.py +232 -0
  70. package/bench/refs/python-lib/pandas/core/array_algos/replace.py +159 -0
  71. package/bench/refs/python-lib/pandas/core/array_algos/take.py +609 -0
  72. package/bench/refs/python-lib/pandas/core/arraylike.py +534 -0
  73. package/bench/refs/python-lib/pandas/core/arrays/_arrow_string_mixins.py +434 -0
  74. package/bench/refs/python-lib/pandas/core/arrays/_mixins.py +630 -0
  75. package/bench/refs/python-lib/pandas/core/arrays/_ranges.py +276 -0
  76. package/bench/refs/python-lib/pandas/core/arrays/_utils.py +77 -0
  77. package/bench/refs/python-lib/pandas/core/arrays/arrow/accessors.py +521 -0
  78. package/bench/refs/python-lib/pandas/core/arrays/arrow/extension_types.py +174 -0
  79. package/bench/refs/python-lib/pandas/core/arrays/boolean.py +454 -0
  80. package/bench/refs/python-lib/pandas/core/arrays/floating.py +192 -0
  81. package/bench/refs/python-lib/pandas/core/arrays/integer.py +296 -0
  82. package/bench/refs/python-lib/pandas/core/arrays/masked.py +2140 -0
  83. package/bench/refs/python-lib/pandas/core/arrays/numeric.py +305 -0
  84. package/bench/refs/python-lib/pandas/core/arrays/numpy_.py +701 -0
  85. package/bench/refs/python-lib/pandas/core/arrays/period.py +1586 -0
  86. package/bench/refs/python-lib/pandas/core/arrays/sparse/accessor.py +505 -0
  87. package/bench/refs/python-lib/pandas/core/arrays/sparse/scipy_sparse.py +208 -0
  88. package/bench/refs/rust-project/.refrev +1 -0
  89. package/bench/refs/rust-project/README.md +17 -0
  90. package/bench/refs/rust-project/tokio/src/doc/os.rs +68 -0
  91. package/bench/refs/rust-project/tokio/src/fs/dir_builder.rs +129 -0
  92. package/bench/refs/rust-project/tokio/src/fs/file.rs +1113 -0
  93. package/bench/refs/rust-project/tokio/src/fs/mocks.rs +176 -0
  94. package/bench/refs/rust-project/tokio/src/fs/mod.rs +324 -0
  95. package/bench/refs/rust-project/tokio/src/fs/open_options.rs +856 -0
  96. package/bench/refs/rust-project/tokio/src/fs/read.rs +80 -0
  97. package/bench/refs/rust-project/tokio/src/fs/read_dir.rs +357 -0
  98. package/bench/refs/rust-project/tokio/src/fs/write.rs +99 -0
  99. package/bench/refs/rust-project/tokio/src/future/maybe_done.rs +123 -0
  100. package/bench/refs/rust-project/tokio/src/future/try_join.rs +82 -0
  101. package/bench/refs/rust-project/tokio/src/io/async_buf_read.rs +117 -0
  102. package/bench/refs/rust-project/tokio/src/io/async_read.rs +133 -0
  103. package/bench/refs/rust-project/tokio/src/io/async_seek.rs +96 -0
  104. package/bench/refs/rust-project/tokio/src/io/async_write.rs +402 -0
  105. package/bench/refs/rust-project/tokio/src/io/blocking.rs +339 -0
  106. package/bench/refs/rust-project/tokio/src/io/interest.rs +345 -0
  107. package/bench/refs/rust-project/tokio/src/io/join.rs +130 -0
  108. package/bench/refs/rust-project/tokio/src/io/mod.rs +308 -0
  109. package/bench/refs/rust-project/tokio/src/io/poll_evented.rs +319 -0
  110. package/bench/refs/rust-project/tokio/src/io/read_buf.rs +344 -0
  111. package/bench/refs/rust-project/tokio/src/io/ready.rs +322 -0
  112. package/bench/refs/rust-project/tokio/src/io/split.rs +166 -0
  113. package/bench/refs/rust-project/tokio/src/io/stderr.rs +148 -0
  114. package/bench/refs/rust-project/tokio/src/io/stdin.rs +98 -0
  115. package/bench/refs/rust-project/tokio/src/io/stdio_common.rs +222 -0
  116. package/bench/refs/rust-project/tokio/src/io/stdout.rs +197 -0
  117. package/bench/refs/rust-project/tokio/src/io/util/buf_reader.rs +311 -0
  118. package/bench/refs/rust-project/tokio/src/io/util/buf_stream.rs +207 -0
  119. package/bench/refs/rust-project/tokio/src/io/util/buf_writer.rs +310 -0
  120. package/bench/refs/rust-project/tokio/src/io/util/chain.rs +144 -0
  121. package/commands/_spawn-patterns.json +32 -0
  122. package/commands/ashlr-allow.md +21 -0
  123. package/commands/ashlr-benchmark.md +53 -0
  124. package/commands/ashlr-brief.md +97 -0
  125. package/commands/ashlr-budget.md +76 -0
  126. package/commands/ashlr-compact.md +54 -0
  127. package/commands/ashlr-dashboard.md +37 -0
  128. package/commands/ashlr-doctor.md +56 -0
  129. package/commands/ashlr-eco-mode.md +92 -0
  130. package/commands/ashlr-genome-init.md +24 -0
  131. package/commands/ashlr-genome-keygen.md +36 -0
  132. package/commands/ashlr-genome-loop.md +118 -0
  133. package/commands/ashlr-genome-push.md +36 -0
  134. package/commands/ashlr-genome-rewrap.md +48 -0
  135. package/commands/ashlr-genome-team-init.md +67 -0
  136. package/commands/ashlr-handoff.md +23 -0
  137. package/commands/ashlr-help.md +86 -0
  138. package/commands/ashlr-hook-timings.md +30 -0
  139. package/commands/ashlr-leaderboard.md +50 -0
  140. package/commands/ashlr-ollama-setup.md +39 -0
  141. package/commands/ashlr-orchestrate-status.md +72 -0
  142. package/commands/ashlr-orchestrate.md +83 -0
  143. package/commands/ashlr-parallelize.md +63 -0
  144. package/commands/ashlr-report-crash.md +33 -0
  145. package/commands/ashlr-resume.md +72 -0
  146. package/commands/ashlr-savings.md +24 -0
  147. package/commands/ashlr-settings.md +50 -0
  148. package/commands/ashlr-spawn.md +61 -0
  149. package/commands/ashlr-start.md +60 -0
  150. package/commands/ashlr-status.md +64 -0
  151. package/commands/ashlr-team-invite.md +47 -0
  152. package/commands/ashlr-tier.md +122 -0
  153. package/commands/ashlr-tour.md +60 -0
  154. package/commands/ashlr-update.md +155 -0
  155. package/commands/ashlr-upgrade.md +96 -0
  156. package/commands/goal.md +58 -0
  157. package/commands/loop.md +40 -0
  158. package/docs/CNAME +1 -0
  159. package/docs/PRO_TIER.md +104 -0
  160. package/docs/README.md +21 -0
  161. package/docs/architecture.md +415 -0
  162. package/docs/ashlr-pipe.md +161 -0
  163. package/docs/assets/logo.svg +19 -0
  164. package/docs/assets/og.png +0 -0
  165. package/docs/assets/og.svg +41 -0
  166. package/docs/benchmarks-v2.json +355 -0
  167. package/docs/benchmarks.json +102 -0
  168. package/docs/benchmarks.md +211 -0
  169. package/docs/billing.md +185 -0
  170. package/docs/cloud-genome.md +155 -0
  171. package/docs/competitive-wozcode.md +92 -0
  172. package/docs/deploy.md +325 -0
  173. package/docs/email-templates.md +113 -0
  174. package/docs/embed-tuning.md +103 -0
  175. package/docs/genome.md +120 -0
  176. package/docs/github-oauth-onboarding.md +127 -0
  177. package/docs/hero-video-script.md +129 -0
  178. package/docs/index.html +2262 -0
  179. package/docs/install-prompt.md +78 -0
  180. package/docs/install-windows.md +132 -0
  181. package/docs/install.ps1 +171 -0
  182. package/docs/install.sh +146 -0
  183. package/docs/launch/README.md +114 -0
  184. package/docs/launch/hn.md +49 -0
  185. package/docs/launch/reddit.md +45 -0
  186. package/docs/launch/x.md +65 -0
  187. package/docs/launch-post.md +112 -0
  188. package/docs/legal.md +78 -0
  189. package/docs/llm-providers.md +99 -0
  190. package/docs/multi-host-mcp.md +217 -0
  191. package/docs/multi-turn-architecture.md +108 -0
  192. package/docs/operations/deployment.md +108 -0
  193. package/docs/operations/error-monitoring.md +70 -0
  194. package/docs/operations/weekly-digest.md +70 -0
  195. package/docs/operations.md +494 -0
  196. package/docs/platform-support.md +133 -0
  197. package/docs/policy-packs.md +188 -0
  198. package/docs/press-kit/fact-sheet.md +78 -0
  199. package/docs/press-kit/founder-bio.md +3 -0
  200. package/docs/press-kit/logo.svg +33 -0
  201. package/docs/press-kit/screenshots.md +146 -0
  202. package/docs/pricing.md +251 -0
  203. package/docs/pro-backend-architecture.md +523 -0
  204. package/docs/producthunt.md +52 -0
  205. package/docs/router-migration.md +110 -0
  206. package/docs/session-log-schema.md +68 -0
  207. package/docs/social.md +95 -0
  208. package/docs/team-genome.md +528 -0
  209. package/docs/telemetry.md +194 -0
  210. package/docs/test-isolation.md +100 -0
  211. package/docs/upgrade.md +159 -0
  212. package/hooks/_hook-errors.ts +87 -0
  213. package/hooks/_hook-perf-emit.ts +113 -0
  214. package/hooks/_hook-timing-ledger.ts +1133 -0
  215. package/hooks/_nudge-throttle.ts +127 -0
  216. package/hooks/_recent-blocks.ts +127 -0
  217. package/hooks/audit-upload.ts +86 -0
  218. package/hooks/codex-hooks.json +171 -0
  219. package/hooks/commit-attribution.ts +179 -0
  220. package/hooks/edit-batching-nudge.ts +154 -0
  221. package/hooks/genome-scribe-hook.ts +219 -0
  222. package/hooks/hooks.json +294 -0
  223. package/hooks/orient-nudge-hook.ts +242 -0
  224. package/hooks/policy-enforce.ts +214 -0
  225. package/hooks/post-tool-use-embedding.ts +93 -0
  226. package/hooks/post-tool-use-genome.sh +37 -0
  227. package/hooks/post-tool-use-genome.ts +69 -0
  228. package/hooks/posttooluse-correlate.ts +122 -0
  229. package/hooks/posttooluse-dedupe-output.ts +241 -0
  230. package/hooks/posttooluse-genome-refresh.ts +223 -0
  231. package/hooks/posttooluse-native-nudge.ts +205 -0
  232. package/hooks/posttooluse-stale-result.ts +277 -0
  233. package/hooks/pretooluse-bash.ts +63 -0
  234. package/hooks/pretooluse-budget-guard.ts +318 -0
  235. package/hooks/pretooluse-common.ts +985 -0
  236. package/hooks/pretooluse-eco-router.ts +196 -0
  237. package/hooks/pretooluse-edit.sh +93 -0
  238. package/hooks/pretooluse-edit.ts +197 -0
  239. package/hooks/pretooluse-glob.ts +70 -0
  240. package/hooks/pretooluse-grep.sh +85 -0
  241. package/hooks/pretooluse-grep.ts +96 -0
  242. package/hooks/pretooluse-notebookedit.ts +121 -0
  243. package/hooks/pretooluse-read.sh +118 -0
  244. package/hooks/pretooluse-read.ts +118 -0
  245. package/hooks/pretooluse-task.ts +130 -0
  246. package/hooks/pretooluse-webfetch.ts +66 -0
  247. package/hooks/pretooluse-websearch.ts +85 -0
  248. package/hooks/pretooluse-write.ts +128 -0
  249. package/hooks/pulse-emit.ts +197 -0
  250. package/hooks/session-end-consolidate.sh +35 -0
  251. package/hooks/session-end-consolidate.ts +188 -0
  252. package/hooks/session-end-stats-gc.ts +80 -0
  253. package/hooks/session-log-append.sh +94 -0
  254. package/hooks/session-log-append.ts +116 -0
  255. package/hooks/session-start.ts +993 -0
  256. package/hooks/sessionend-hook-health-nudge.ts +451 -0
  257. package/hooks/sessionend-savings-nudge.ts +318 -0
  258. package/hooks/sessionend-status-line-nudge.ts +296 -0
  259. package/hooks/sessionstart-brief.ts +140 -0
  260. package/hooks/sessionstart-cost-refactor.ts +95 -0
  261. package/hooks/sessionstart-efficient.ts +110 -0
  262. package/hooks/sessionstart-genome-author.ts +93 -0
  263. package/hooks/sessionstart-lean-tools.ts +98 -0
  264. package/hooks/sessionstart-search.ts +97 -0
  265. package/hooks/stop-accounting.ts +152 -0
  266. package/hooks/subagent-stop-rollup.ts +162 -0
  267. package/hooks/userpromptsubmit-brief-trigger.ts +156 -0
  268. package/package.json +56 -0
  269. package/ports/README.md +108 -0
  270. package/ports/cursor/mcp.json +11 -0
  271. package/ports/goose/recipe.yaml +26 -0
  272. package/scripts/ashlr-mcp.ts +40 -0
  273. package/scripts/auto-update.ts +177 -0
  274. package/scripts/baseline-scan.ts +831 -0
  275. package/scripts/bench-orchestrate.ts +377 -0
  276. package/scripts/benchmark-refs.ts +292 -0
  277. package/scripts/bootstrap.mjs +100 -0
  278. package/scripts/brief-eval.ts +234 -0
  279. package/scripts/bump-version.ts +82 -0
  280. package/scripts/bun-resolve.d.mts +11 -0
  281. package/scripts/bun-resolve.mjs +59 -0
  282. package/scripts/calibrate-grep.ts +467 -0
  283. package/scripts/capture-grep-workload.ts +260 -0
  284. package/scripts/check-tier.ts +61 -0
  285. package/scripts/check-version-sync.ts +97 -0
  286. package/scripts/cli-orchestrate-status.ts +386 -0
  287. package/scripts/cli-orchestrate.ts +303 -0
  288. package/scripts/cli.ts +478 -0
  289. package/scripts/cloud-smoke-test.ts +313 -0
  290. package/scripts/coach-report.ts +373 -0
  291. package/scripts/context-db-migrate.ts +137 -0
  292. package/scripts/context-status.ts +43 -0
  293. package/scripts/demo-run.ts +255 -0
  294. package/scripts/deploy-smoke.sh +140 -0
  295. package/scripts/doctor.ts +992 -0
  296. package/scripts/embed-file-worker.ts +104 -0
  297. package/scripts/embed-tune.ts +308 -0
  298. package/scripts/errors-report.ts +428 -0
  299. package/scripts/find-test-leak.ts +69 -0
  300. package/scripts/generate-badge.ts +357 -0
  301. package/scripts/genome-auto-consolidate.ts +533 -0
  302. package/scripts/genome-auto-propose.ts +578 -0
  303. package/scripts/genome-cloud-pull.ts +363 -0
  304. package/scripts/genome-cloud-push.ts +382 -0
  305. package/scripts/genome-cloud-sync.ts +587 -0
  306. package/scripts/genome-commit-watcher.ts +295 -0
  307. package/scripts/genome-init.ts +986 -0
  308. package/scripts/genome-key.ts +233 -0
  309. package/scripts/genome-keygen.ts +168 -0
  310. package/scripts/genome-link.ts +59 -0
  311. package/scripts/genome-refresh-worker.ts +364 -0
  312. package/scripts/genome-rewrap.ts +90 -0
  313. package/scripts/genome-synthesizer.ts +679 -0
  314. package/scripts/genome-team-init.ts +312 -0
  315. package/scripts/handoff-pack.ts +438 -0
  316. package/scripts/hook-bootstrap.mjs +75 -0
  317. package/scripts/hook-timing-reader.ts +371 -0
  318. package/scripts/hook-timings-report.ts +554 -0
  319. package/scripts/install-genome-hooks.ts +196 -0
  320. package/scripts/install-onnx-model.ts +154 -0
  321. package/scripts/install-permissions.ts +276 -0
  322. package/scripts/install-status-line.ts +150 -0
  323. package/scripts/mcp-entrypoint.sh +78 -0
  324. package/scripts/mcp-entrypoint.ts +130 -0
  325. package/scripts/mdx-angle-bracket-lint.ts +152 -0
  326. package/scripts/measure-cold-start.ts +126 -0
  327. package/scripts/migrate-stats-to-sqlite.ts +86 -0
  328. package/scripts/ollama-setup.ts +498 -0
  329. package/scripts/onboarding-wizard.ts +1691 -0
  330. package/scripts/orchestrate-emit-telemetry.ts +177 -0
  331. package/scripts/orchestrate-expand.ts +517 -0
  332. package/scripts/orchestrate-run.ts +744 -0
  333. package/scripts/print-legend.ts +50 -0
  334. package/scripts/product-metadata.ts +38 -0
  335. package/scripts/prune-old-memories.ts +208 -0
  336. package/scripts/publish.sh +88 -0
  337. package/scripts/read-calibration.ts +122 -0
  338. package/scripts/report-crash.ts +226 -0
  339. package/scripts/run-benchmark.ts +750 -0
  340. package/scripts/run-root-tests.ts +28 -0
  341. package/scripts/savings-dashboard.ts +1258 -0
  342. package/scripts/savings-report-extras.ts +447 -0
  343. package/scripts/savings-status-line.ts +873 -0
  344. package/scripts/session-event-emit.ts +266 -0
  345. package/scripts/session-greet.ts +686 -0
  346. package/scripts/session-log-report.ts +551 -0
  347. package/scripts/session-resume.ts +614 -0
  348. package/scripts/set-hook-mode.ts +83 -0
  349. package/scripts/smoke-cross-platform.ts +117 -0
  350. package/scripts/smoke-realtime.ts +257 -0
  351. package/scripts/smoke-tools.ts +21 -0
  352. package/scripts/stats-cloud-pull.ts +213 -0
  353. package/scripts/stats-cloud-sync.ts +261 -0
  354. package/scripts/telemetry-flush.ts +160 -0
  355. package/scripts/telemetry-status.ts +266 -0
  356. package/scripts/timing-budget.ts +35 -0
  357. package/scripts/ui-animation.ts +120 -0
  358. package/scripts/ui.ts +431 -0
  359. package/scripts/upgrade-flow.ts +775 -0
  360. package/scripts/weekly-digest.ts +394 -0
  361. package/servers/_accounting.ts +78 -0
  362. package/servers/_ask-router.ts +84 -0
  363. package/servers/_ast-chunker.ts +434 -0
  364. package/servers/_ast-helpers.ts +183 -0
  365. package/servers/_ast-languages.ts +153 -0
  366. package/servers/_ast-refactor/_shared.ts +221 -0
  367. package/servers/_ast-refactor/cross-file-rename.ts +560 -0
  368. package/servers/_ast-refactor/extract-function.ts +357 -0
  369. package/servers/_ast-refactor/file-local-rename.ts +172 -0
  370. package/servers/_ast-refactor.ts +41 -0
  371. package/servers/_ast-skeleton.ts +107 -0
  372. package/servers/_bash-summarizers-registry.ts +1096 -0
  373. package/servers/_crash-dump.ts +210 -0
  374. package/servers/_cwd-clamp.ts +342 -0
  375. package/servers/_edit-log.ts +28 -0
  376. package/servers/_edit-match.ts +236 -0
  377. package/servers/_edit-validate.ts +196 -0
  378. package/servers/_embed-calibration.ts +123 -0
  379. package/servers/_embedding-cache.ts +401 -0
  380. package/servers/_embedding-model.ts +329 -0
  381. package/servers/_events.ts +113 -0
  382. package/servers/_genome-cache.ts +154 -0
  383. package/servers/_genome-commits.ts +172 -0
  384. package/servers/_genome-crypto-v2.ts +242 -0
  385. package/servers/_genome-crypto.ts +259 -0
  386. package/servers/_genome-discoveries.ts +140 -0
  387. package/servers/_genome-embed-populator.ts +216 -0
  388. package/servers/_genome-freshness.ts +134 -0
  389. package/servers/_genome-issues.ts +171 -0
  390. package/servers/_genome-live.ts +263 -0
  391. package/servers/_genome-prs.ts +188 -0
  392. package/servers/_genome-search.ts +479 -0
  393. package/servers/_genome-sync.ts +297 -0
  394. package/servers/_history-tracker.ts +279 -0
  395. package/servers/_http-helpers.ts +151 -0
  396. package/servers/_identity-hash.ts +151 -0
  397. package/servers/_llm-providers/anthropic.ts +132 -0
  398. package/servers/_llm-providers/cloud.ts +195 -0
  399. package/servers/_llm-providers/index.ts +350 -0
  400. package/servers/_llm-providers/local.ts +127 -0
  401. package/servers/_llm-providers/onnx.ts +393 -0
  402. package/servers/_llm-providers/types.ts +27 -0
  403. package/servers/_manifest-v2.ts +447 -0
  404. package/servers/_mcp-host.ts +93 -0
  405. package/servers/_nudge-events.ts +451 -0
  406. package/servers/_orchestrate-executor.ts +316 -0
  407. package/servers/_prefetch.ts +435 -0
  408. package/servers/_pricing.ts +256 -0
  409. package/servers/_pro.ts +381 -0
  410. package/servers/_read-cache.ts +44 -0
  411. package/servers/_router-handlers.ts +46 -0
  412. package/servers/_router.ts +55 -0
  413. package/servers/_run-with-timeout.ts +173 -0
  414. package/servers/_savings-render.ts +214 -0
  415. package/servers/_stats-sqlite.ts +640 -0
  416. package/servers/_stats.ts +1183 -0
  417. package/servers/_status-line-cells.ts +469 -0
  418. package/servers/_streaks.ts +162 -0
  419. package/servers/_summarize.ts +208 -0
  420. package/servers/_task-graph-render.ts +217 -0
  421. package/servers/_task-graph.ts +545 -0
  422. package/servers/_telemetry.ts +719 -0
  423. package/servers/_test-parsers.ts +530 -0
  424. package/servers/_test-watch.ts +626 -0
  425. package/servers/_text-helpers.ts +37 -0
  426. package/servers/_token-measure.ts +160 -0
  427. package/servers/_tool-base.ts +248 -0
  428. package/servers/_weekly-digest.ts +391 -0
  429. package/servers/ask-server-handlers.ts +49 -0
  430. package/servers/ask-server.ts +174 -0
  431. package/servers/bash-server-handlers.ts +138 -0
  432. package/servers/bash-server.ts +1136 -0
  433. package/servers/bench.ts +186 -0
  434. package/servers/diff-semantic-server-handlers.ts +39 -0
  435. package/servers/diff-semantic-server.ts +566 -0
  436. package/servers/diff-server-handlers.ts +317 -0
  437. package/servers/diff-server.ts +20 -0
  438. package/servers/edit-server-handlers.ts +31 -0
  439. package/servers/edit-server.ts +209 -0
  440. package/servers/edit-structural-server-handlers.ts +322 -0
  441. package/servers/efficiency-server-handlers.ts +161 -0
  442. package/servers/efficiency-server.ts +1135 -0
  443. package/servers/flush-server-handlers.ts +21 -0
  444. package/servers/flush-server.ts +19 -0
  445. package/servers/genome-server-handlers.ts +118 -0
  446. package/servers/genome-server.ts +397 -0
  447. package/servers/github-server-handlers.ts +187 -0
  448. package/servers/github-server.ts +856 -0
  449. package/servers/glob-server-handlers.ts +233 -0
  450. package/servers/glob-server.ts +20 -0
  451. package/servers/grep-server-handlers.ts +61 -0
  452. package/servers/grep-server.ts +564 -0
  453. package/servers/http-server-handlers.ts +36 -0
  454. package/servers/http-server.ts +149 -0
  455. package/servers/logs-server-handlers.ts +42 -0
  456. package/servers/logs-server.ts +359 -0
  457. package/servers/ls-server-handlers.ts +166 -0
  458. package/servers/ls-server.ts +20 -0
  459. package/servers/multi-edit-server-handlers.ts +51 -0
  460. package/servers/multi-edit-server.ts +422 -0
  461. package/servers/notebook-edit-server-handlers.ts +34 -0
  462. package/servers/notebook-edit-server.ts +181 -0
  463. package/servers/orient-server-handlers.ts +52 -0
  464. package/servers/orient-server.ts +436 -0
  465. package/servers/pipe-server-handlers.ts +68 -0
  466. package/servers/pipe-server.ts +300 -0
  467. package/servers/read-server-handlers.ts +42 -0
  468. package/servers/read-server.ts +206 -0
  469. package/servers/rename-file-server-handlers.ts +67 -0
  470. package/servers/rename-file-server.ts +699 -0
  471. package/servers/savings-server-handlers.ts +27 -0
  472. package/servers/savings-server.ts +54 -0
  473. package/servers/search-replace-regex-server-handlers.ts +96 -0
  474. package/servers/search-replace-regex-server.ts +751 -0
  475. package/servers/sql-server-handlers.ts +58 -0
  476. package/servers/sql-server.ts +505 -0
  477. package/servers/task-server-handlers.ts +358 -0
  478. package/servers/task-server.ts +24 -0
  479. package/servers/test-server-handlers.ts +351 -0
  480. package/servers/test-server.ts +20 -0
  481. package/servers/tree-server-handlers.ts +503 -0
  482. package/servers/tree-server.ts +20 -0
  483. package/servers/webfetch-server-handlers.ts +166 -0
  484. package/servers/webfetch-server.ts +20 -0
  485. package/servers/websearch-server-handlers.ts +215 -0
  486. package/servers/websearch-server.ts +19 -0
  487. package/servers/write-server-handlers.ts +27 -0
  488. package/servers/write-server.ts +97 -0
  489. package/skills/ashlr-brief/SKILL.md +80 -0
  490. package/skills/ashlr-codex-doctor/SKILL.md +21 -0
  491. package/skills/ashlr-codex-efficient-tools/SKILL.md +17 -0
  492. package/skills/ashlr-codex-genome-refresh/SKILL.md +20 -0
  493. package/skills/ashlr-codex-parallel-explore/SKILL.md +20 -0
  494. package/skills/ashlr-codex-savings/SKILL.md +17 -0
  495. package/skills/ashlr-cost-refactor/SKILL.md +50 -0
  496. package/skills/ashlr-efficient/SKILL.md +64 -0
  497. package/skills/ashlr-genome-author/SKILL.md +43 -0
  498. package/skills/ashlr-lean-tools/SKILL.md +43 -0
  499. package/skills/ashlr-search/SKILL.md +46 -0
@@ -0,0 +1,985 @@
1
+ /**
2
+ * pretooluse-common.ts — Shared helpers for the pretooluse-{read,grep,edit}
3
+ * hooks. Each hook shells out via its own `bun run` entry in hooks.json, so we
4
+ * keep this module dependency-free and side-effect-free.
5
+ *
6
+ * Every helper is designed around the "fail open" contract: if anything looks
7
+ * unexpected we let the built-in tool call proceed by exiting 0, rather than
8
+ * blocking the agent with an error.
9
+ */
10
+
11
+ import { existsSync, readFileSync, realpathSync, statSync } from "fs";
12
+ import { homedir } from "os";
13
+ import { resolve, dirname, join, sep } from "path";
14
+ import { fileURLToPath } from "url";
15
+
16
+ import {
17
+ findSummarizer,
18
+ isLargeDiffCommand,
19
+ } from "../servers/_bash-summarizers-registry";
20
+ import {
21
+ appendHookTimingBatch,
22
+ HOOK_TIMING_BATCH_MAX_BYTES,
23
+ } from "./_hook-timing-ledger";
24
+
25
+ /**
26
+ * True when enforcement is disabled — the hook should exit 0 immediately.
27
+ *
28
+ * Enforcement is OFF by default. Only enable when `ASHLR_ENFORCE=1` is set,
29
+ * and still honor `ASHLR_NO_ENFORCE=1` as a kill switch for back-compat.
30
+ */
31
+ export function enforcementDisabled(): boolean {
32
+ return process.env.ASHLR_ENFORCE !== "1" || process.env.ASHLR_NO_ENFORCE === "1";
33
+ }
34
+
35
+ /** Read the full stdin payload as a UTF-8 string (trimmed). */
36
+ export function readStdin(): Promise<string> {
37
+ // Parameter name avoids shadowing the `resolve` imported from "path" above.
38
+ return new Promise((done) => {
39
+ const chunks: Buffer[] = [];
40
+ process.stdin.on("data", (c: Buffer) => chunks.push(c));
41
+ process.stdin.on("end", () => {
42
+ done(Buffer.concat(chunks).toString("utf-8").trim());
43
+ });
44
+ });
45
+ }
46
+
47
+ export interface PreToolUsePayload {
48
+ tool_name: string;
49
+ file_path: string;
50
+ pattern: string;
51
+ search_path: string;
52
+ command: string;
53
+ bypass: boolean;
54
+ // v1.22: Edit-specific fields surfaced so the micro-edit guard can size
55
+ // the proposed change without re-parsing stdin. Empty for non-Edit tools.
56
+ old_string: string;
57
+ new_string: string;
58
+ }
59
+
60
+ /**
61
+ * Parse a pretooluse JSON payload. Returns null if the input is empty or
62
+ * malformed (the caller should exit 0 in that case). Missing fields come back
63
+ * as empty strings / false so call-sites can stay uniform.
64
+ */
65
+ export function parsePayload(raw: string): PreToolUsePayload | null {
66
+ if (!raw) return null;
67
+ try {
68
+ const p = JSON.parse(raw);
69
+ const input = p?.tool_input ?? {};
70
+ const toolName =
71
+ typeof p?.tool_name === "string"
72
+ ? p.tool_name
73
+ : typeof p?.toolName === "string"
74
+ ? p.toolName
75
+ : typeof p?.tool === "string"
76
+ ? p.tool
77
+ : "";
78
+ const filePath =
79
+ typeof input.file_path === "string"
80
+ ? input.file_path
81
+ : typeof input.path === "string" && (toolName === "Read" || toolName === "Edit" || toolName === "Write")
82
+ ? input.path
83
+ : "";
84
+ return {
85
+ tool_name: toolName,
86
+ file_path: filePath,
87
+ pattern: typeof input.pattern === "string" ? input.pattern : "",
88
+ search_path: typeof input.path === "string" ? input.path : "",
89
+ command:
90
+ typeof input.command === "string"
91
+ ? input.command
92
+ : typeof input.cmd === "string"
93
+ ? input.cmd
94
+ : typeof p?.command === "string"
95
+ ? p.command
96
+ : typeof p?.cmd === "string"
97
+ ? p.cmd
98
+ : "",
99
+ bypass: input.bypassSummary === true,
100
+ old_string: typeof input.old_string === "string" ? input.old_string : "",
101
+ new_string: typeof input.new_string === "string" ? input.new_string : "",
102
+ };
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /** Absolute path to the plugin root, derived from the calling hook's URL. */
109
+ export function pluginRootFrom(moduleUrl: string): string {
110
+ return resolve(dirname(fileURLToPath(moduleUrl)), "..");
111
+ }
112
+
113
+ /** True when `p` lies inside the plugin's own directory tree. */
114
+ export function isInsidePluginRoot(p: string, pluginRoot: string): boolean {
115
+ if (!p) return false;
116
+ if (p === pluginRoot) return true;
117
+ return p.startsWith(pluginRoot + sep);
118
+ }
119
+
120
+ /**
121
+ * True when the absolute path `p` lies inside the working directory `cwd`.
122
+ * Used by redirect mode as a safety net — we never block tool calls against
123
+ * paths the user didn't explicitly bring into scope (e.g. /tmp, /etc, other
124
+ * projects). Falls back to nudge in that case.
125
+ *
126
+ * Canonicalizes via realpath when possible so symlinked roots (notably
127
+ * macOS's /tmp → /private/tmp) match correctly. Plain `resolve()` only
128
+ * normalizes `..`/`.` segments — it does NOT follow symlinks.
129
+ */
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Memoized realpath — resolves each unique path only once per hook process.
133
+ // Calling realpathSync twice per hook (once for cwd, once for the target) on
134
+ // NFS/cloud-mount drives adds measurable latency when hooks fire 3-5× per MCP
135
+ // call. The cache is process-scoped so test isolation is automatic.
136
+ // ---------------------------------------------------------------------------
137
+ const _realpathCache = new Map<string, string>();
138
+ function cachedRealpath(p: string): string {
139
+ const cached = _realpathCache.get(p);
140
+ if (cached !== undefined) return cached;
141
+ let result: string;
142
+ try {
143
+ result = realpathSync(p);
144
+ } catch {
145
+ result = p;
146
+ }
147
+ _realpathCache.set(p, result);
148
+ return result;
149
+ }
150
+
151
+ export function isInsideCwd(p: string, cwd: string = process.cwd()): boolean {
152
+ if (!p) return false;
153
+ try {
154
+ const absP = resolve(p);
155
+ const absCwd = resolve(cwd);
156
+ const realP = cachedRealpath(absP);
157
+ const realCwd = cachedRealpath(absCwd);
158
+ for (const base of new Set([absCwd, realCwd])) {
159
+ for (const target of new Set([absP, realP])) {
160
+ if (target === base) return true;
161
+ if (target.startsWith(base + sep)) return true;
162
+ }
163
+ }
164
+ return false;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Hook-mode resolution — v1.18 redirect vs. nudge vs. off
172
+ // ---------------------------------------------------------------------------
173
+ //
174
+ // "redirect" (default): PreToolUse hooks BLOCK the native Read/Grep/Edit call
175
+ // and instruct the agent to call the equivalent ashlr__* MCP tool instead.
176
+ // The block is emitted as structured JSON on stdout using the PreToolUse
177
+ // `permissionDecision: "deny"` contract so Claude Code surfaces the reason
178
+ // back to the model without raising a user permission prompt.
179
+ //
180
+ // "nudge" (escape hatch, ASHLR_HOOK_MODE=nudge): preserves the pre-v1.18
181
+ // behavior — the pretooluse hooks never block; instead they inject
182
+ // `additionalContext` as a soft suggestion the model may or may not follow.
183
+ // Previously this was the job of the now-retired hooks/tool-redirect.ts —
184
+ // the nudge text and thresholds have been absorbed into this module so we
185
+ // ship a single PreToolUse hook per tool rather than two.
186
+ //
187
+ // "off" (total pass-through): no block, no nudge. Selected by the legacy
188
+ // `~/.ashlr/settings.json { "toolRedirect": false }` kill switch, or by
189
+ // setting `ASHLR_HOOK_MODE=off` / `hookMode: "off"` in config.json.
190
+ //
191
+ // Priority order (highest first):
192
+ // 1. `ASHLR_HOOK_MODE` env var — "redirect" | "nudge" | "off"
193
+ // 2. `~/.ashlr/config.json` `hookMode` field
194
+ // 3. `~/.ashlr/settings.json` `toolRedirect: false` → resolves to "off"
195
+ // 4. Default: "redirect"
196
+ //
197
+ // Legacy back-compat: `ASHLR_ENFORCE=1` still selects the exit-code-based
198
+ // block path in the individual hooks. That flag is honored independently so
199
+ // older harness configurations (and the pretouse hook-timings tests that
200
+ // spawn with `ASHLR_ENFORCE=1`) keep working unchanged.
201
+
202
+ export type HookMode = "redirect" | "nudge" | "off";
203
+
204
+ function normalizeMode(v: unknown): HookMode | null {
205
+ if (typeof v !== "string") return null;
206
+ const s = v.trim().toLowerCase();
207
+ if (s === "redirect" || s === "nudge" || s === "off") return s;
208
+ return null;
209
+ }
210
+
211
+ /**
212
+ * True when the legacy `~/.ashlr/settings.json { toolRedirect: false }` kill
213
+ * switch is in effect. Absent file, malformed JSON, or any other error →
214
+ * treated as "not disabled" (safe default).
215
+ *
216
+ * This helper was ported from the retired hooks/tool-redirect.ts so the
217
+ * settings-based opt-out keeps working after that hook is removed.
218
+ */
219
+ export function isRedirectEnabled(home: string = process.env.HOME ?? homedir()): boolean {
220
+ try {
221
+ const settingsPath = join(home, ".ashlr", "settings.json");
222
+ if (!existsSync(settingsPath)) return true;
223
+ const raw = JSON.parse(readFileSync(settingsPath, "utf-8")) as {
224
+ toolRedirect?: boolean;
225
+ };
226
+ return raw.toolRedirect !== false;
227
+ } catch {
228
+ return true;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Read a per-hook mode override from settings.json or config.json.
234
+ * Returns the matching mode if found, or null if no override applies.
235
+ *
236
+ * Looks for `{ "hookModes": { "<hookKey>": "redirect" | "nudge" | "off" } }`
237
+ * in either file. settings.json wins over config.json (per audit feedback —
238
+ * settings is where users put intent, config is plugin-managed state).
239
+ */
240
+ function getPerHookOverride(hookKey: string, home: string): HookMode | null {
241
+ for (const file of ["settings.json", "config.json"]) {
242
+ try {
243
+ const p = join(home, ".ashlr", file);
244
+ if (!existsSync(p)) continue;
245
+ const raw = JSON.parse(readFileSync(p, "utf-8")) as {
246
+ hookModes?: Record<string, unknown>;
247
+ };
248
+ const m = normalizeMode(raw?.hookModes?.[hookKey]);
249
+ if (m) return m;
250
+ } catch {
251
+ /* ignore — try next file */
252
+ }
253
+ }
254
+ return null;
255
+ }
256
+
257
+ /**
258
+ * Resolve the active hook mode. Never throws — on any read/parse error we
259
+ * fall through to the default ("redirect").
260
+ */
261
+ export function getHookMode(home: string = process.env.HOME ?? homedir()): HookMode {
262
+ const envMode = normalizeMode(process.env.ASHLR_HOOK_MODE);
263
+ if (envMode) return envMode;
264
+ if (process.env.ASHLR_MCP_HOST === "codex-cli") {
265
+ const codexMode = normalizeMode(process.env.ASHLR_CODEX_HOOK_MODE);
266
+ return codexMode ?? "nudge";
267
+ }
268
+ try {
269
+ const cfgPath = join(home, ".ashlr", "config.json");
270
+ if (existsSync(cfgPath)) {
271
+ const raw = JSON.parse(readFileSync(cfgPath, "utf-8")) as { hookMode?: unknown };
272
+ const fileMode = normalizeMode(raw?.hookMode);
273
+ if (fileMode) return fileMode;
274
+ }
275
+ } catch {
276
+ /* ignore — fall through to next check */
277
+ }
278
+ // Legacy kill switch: ~/.ashlr/settings.json { toolRedirect: false } → "off"
279
+ if (!isRedirectEnabled(home)) return "off";
280
+ return "redirect";
281
+ }
282
+
283
+ /**
284
+ * Per-hook variant of {@link getHookMode}. When called with a hook key
285
+ * like "task" or "read", looks for a per-hook override in
286
+ * `~/.ashlr/settings.json { "hookModes": { "<key>": "redirect|nudge|off" } }`
287
+ * (or config.json) before falling back to the global resolution.
288
+ *
289
+ * `ASHLR_HOOK_MODE` env still wins as a global panic switch.
290
+ *
291
+ * Priority order:
292
+ * 1. ASHLR_HOOK_MODE env (global panic switch)
293
+ * 2. hookModes[hookKey] in settings.json
294
+ * 3. hookModes[hookKey] in config.json
295
+ * 4. hookMode (global) in config.json
296
+ * 5. toolRedirect: false in settings.json (legacy → "off")
297
+ * 6. Default: "redirect"
298
+ *
299
+ * Hooks should call this with their own key; tests/back-compat callers
300
+ * should keep using {@link getHookMode} (no key) for unchanged behavior.
301
+ */
302
+ export function getHookModeFor(
303
+ hookKey: string,
304
+ home: string = process.env.HOME ?? homedir(),
305
+ ): HookMode {
306
+ const envMode = normalizeMode(process.env.ASHLR_HOOK_MODE);
307
+ if (envMode) return envMode;
308
+ if (process.env.ASHLR_MCP_HOST === "codex-cli") {
309
+ const codexMode = normalizeMode(process.env.ASHLR_CODEX_HOOK_MODE);
310
+ if (codexMode) return codexMode;
311
+ }
312
+ const perHook = getPerHookOverride(hookKey, home);
313
+ if (perHook) return perHook;
314
+ return getHookMode(home);
315
+ }
316
+
317
+ /**
318
+ * File-first variant of {@link getHookMode}. Use from long-lived MCP server
319
+ * processes whose `process.env.ASHLR_HOOK_MODE` was inherited at spawn time
320
+ * and may be stale after the user updates their config. Reads the config
321
+ * file as the source of truth (representing the user's *intent*), falls back
322
+ * to env only when no file exists, then to legacy / default.
323
+ *
324
+ * Hooks themselves should keep using getHookMode() — they need env-first
325
+ * priority so an explicit per-invocation override (`ASHLR_HOOK_MODE=off bun
326
+ * run …`) takes effect immediately. The savings opportunity engine and
327
+ * other diagnostic surfaces should use this function so the UI reflects
328
+ * the user's current configuration without requiring a Claude Code restart.
329
+ */
330
+ export function getConfiguredHookMode(home: string = process.env.HOME ?? homedir()): HookMode {
331
+ try {
332
+ const cfgPath = join(home, ".ashlr", "config.json");
333
+ if (existsSync(cfgPath)) {
334
+ const raw = JSON.parse(readFileSync(cfgPath, "utf-8")) as { hookMode?: unknown };
335
+ const fileMode = normalizeMode(raw?.hookMode);
336
+ if (fileMode) return fileMode;
337
+ }
338
+ } catch {
339
+ /* ignore — fall through to env */
340
+ }
341
+ const envMode = normalizeMode(process.env.ASHLR_HOOK_MODE);
342
+ if (envMode) return envMode;
343
+ if (!isRedirectEnabled(home)) return "off";
344
+ return "redirect";
345
+ }
346
+
347
+ /**
348
+ * Per-hook variant of {@link getConfiguredHookMode}. Reads file as source
349
+ * of truth (intent) with per-hook override beating global. Mirrors
350
+ * {@link getHookModeFor} but file-first for use from long-lived MCP server
351
+ * processes whose env was inherited stale.
352
+ */
353
+ export function getConfiguredHookModeFor(
354
+ hookKey: string,
355
+ home: string = process.env.HOME ?? homedir(),
356
+ ): HookMode {
357
+ const perHook = getPerHookOverride(hookKey, home);
358
+ if (perHook) return perHook;
359
+ return getConfiguredHookMode(home);
360
+ }
361
+
362
+ /**
363
+ * Hook-output shape for a PreToolUse block that routes the agent to an
364
+ * ashlr__* MCP tool. Claude Code surfaces `permissionDecisionReason` back to
365
+ * the model so it can re-issue the call against the suggested tool without
366
+ * raising a user-facing permission prompt.
367
+ */
368
+ export interface RedirectBlockOutput {
369
+ hookSpecificOutput: {
370
+ hookEventName: "PreToolUse";
371
+ permissionDecision: "deny";
372
+ permissionDecisionReason: string;
373
+ };
374
+ }
375
+
376
+ /** Build a redirect-block JSON payload. Pure — no side effects. */
377
+ export function buildRedirectBlock(reason: string): RedirectBlockOutput {
378
+ return {
379
+ hookSpecificOutput: {
380
+ hookEventName: "PreToolUse",
381
+ permissionDecision: "deny",
382
+ permissionDecisionReason: reason,
383
+ },
384
+ };
385
+ }
386
+
387
+ /**
388
+ * Tool-specific redirect block builder — v1.22 Track E.
389
+ *
390
+ * Emits a structured redirect message that Claude can act on immediately:
391
+ * 1. `bypass` instruction front-loaded (first 60 chars) — v1.21 invariant.
392
+ * 2. Full canonical MCP tool name so Claude can copy-paste the call.
393
+ * 3. Pre-filled `args` JSON block ready to substitute into a tool_use.
394
+ * 4. One-line "why" explaining the savings rationale.
395
+ *
396
+ * Format:
397
+ * [ashlr bypass: ...] Token savings: ~<N>%. Call this instead:
398
+ *
399
+ * tool_use: mcp__plugin_ashlr_ashlr__ashlr__<tool>
400
+ * args: { ... }
401
+ *
402
+ * Why: <savings rationale>
403
+ *
404
+ * Style guide for hooks that don't exist in this worktree (Tracks B/C):
405
+ * - mcpToolName: full canonical name, e.g. "mcp__plugin_ashlr_ashlr__ashlr__websearch"
406
+ * - shortToolName: short form, e.g. "ashlr__websearch"
407
+ * - argsJson: single-line JSON args object, e.g. '{ "query": "<your query>" }'
408
+ * - why: one sentence, e.g. "native WebSearch returns raw HTML; ashlr__websearch filters to text"
409
+ * - savingsPct: estimated token savings (0–100), e.g. 80
410
+ * - bypassNote: how to disable this redirect, e.g. "set ASHLR_HOOK_MODE=nudge"
411
+ */
412
+ export function buildToolRedirectBlock(opts: {
413
+ mcpToolName: string;
414
+ argsJson: string;
415
+ why: string;
416
+ savingsPct: number;
417
+ bypassNote?: string;
418
+ }): RedirectBlockOutput {
419
+ const bypass = opts.bypassNote ?? "set ASHLR_HOOK_MODE=nudge in ~/.ashlr/config.json";
420
+ const reason =
421
+ `[ashlr bypass: ${bypass}] ~${opts.savingsPct}% token savings. Call this instead:\n\n` +
422
+ ` tool_use: ${opts.mcpToolName}\n` +
423
+ ` args: ${opts.argsJson}\n\n` +
424
+ `Why: ${opts.why}`;
425
+ return buildRedirectBlock(reason);
426
+ }
427
+
428
+ /**
429
+ * Empty pass-through JSON — used in "off" mode and for out-of-scope paths
430
+ * where we want Claude Code to proceed with the native tool unaltered.
431
+ */
432
+ export function buildPassThrough(): {
433
+ hookSpecificOutput: { hookEventName: "PreToolUse" };
434
+ } {
435
+ return { hookSpecificOutput: { hookEventName: "PreToolUse" } };
436
+ }
437
+
438
+ /**
439
+ * Hook-output shape for a soft nudge — inject `additionalContext` so the
440
+ * agent sees the ashlr__* suggestion, but NEVER set `permissionDecision`
441
+ * (doing so would force a permission prompt even in bypassPermissions mode).
442
+ * The native Read/Grep/Edit call proceeds unchanged.
443
+ */
444
+ export interface NudgeOutput {
445
+ hookSpecificOutput: {
446
+ hookEventName: "PreToolUse";
447
+ additionalContext: string;
448
+ };
449
+ }
450
+
451
+ /**
452
+ * Size threshold below which Read nudges are skipped — tiny files have
453
+ * nothing to compact, so we pass through silently. Matches the snipCompact
454
+ * threshold used by the efficiency-server's ashlr__read implementation.
455
+ *
456
+ * Kept as a named constant so it can be overridden in tests and shared
457
+ * across the redirect + nudge paths for consistency.
458
+ */
459
+ export const NUDGE_READ_THRESHOLD = 2048;
460
+
461
+ /**
462
+ * Pick a nudge variant index by hashing the call-site string.
463
+ * Produces values 0..N-1 stable across calls with the same key so
464
+ * repeated nudges on the same file rotate through the phrasing set
465
+ * rather than always showing the same message.
466
+ *
467
+ * @internal
468
+ */
469
+ export function nudgeVariantIndex(key: string, count: number): number {
470
+ if (count <= 0) return 0;
471
+ let h = 0;
472
+ for (let i = 0; i < key.length; i++) {
473
+ h = Math.imul(31, h) + key.charCodeAt(i) | 0;
474
+ }
475
+ return Math.abs(h) % count;
476
+ }
477
+
478
+ function nudgeToolName(shortName: string): string {
479
+ if (process.env.ASHLR_MCP_HOST === "codex-cli") return shortName;
480
+ if (process.env.ASHLR_MCP_HOST && process.env.ASHLR_MCP_HOST !== "claude-code") return shortName;
481
+ return `mcp__plugin_ashlr_ashlr__${shortName}`;
482
+ }
483
+
484
+ /**
485
+ * Build the `additionalContext` nudge payload for a given tool invocation.
486
+ * Returns null when the tool is uninteresting (unknown name, tiny file, etc.)
487
+ * so the caller can fall back to {@link buildPassThrough}.
488
+ *
489
+ * Nudge copy is action-specific: each message names the exact MCP tool to call,
490
+ * includes the file size or pattern where relevant, and stays ≤80 chars of
491
+ * signal. 3-4 phrasings per tool are rotated by hashing the call-site key
492
+ * (filePath or pattern) so repeated nudges don't read as identical.
493
+ *
494
+ * Never throws. Any filesystem / parse error → null (silent pass-through).
495
+ */
496
+ export function buildNudgeContext(
497
+ toolName: string,
498
+ toolInput: Record<string, unknown>,
499
+ ): NudgeOutput | null {
500
+ switch (toolName) {
501
+ case "Read": {
502
+ const filePath =
503
+ typeof toolInput.file_path === "string" ? toolInput.file_path : null;
504
+ if (!filePath) return null;
505
+ const size = fileSize(filePath);
506
+ if (size === null) return null;
507
+ if (size <= NUDGE_READ_THRESHOLD) return null;
508
+ const sizeKb = (size / 1024).toFixed(1);
509
+ const compactKb = ((size * 0.2) / 1024).toFixed(1);
510
+ const tool = nudgeToolName("ashlr__read");
511
+ const variants = [
512
+ `[ashlr] ashlr__read on this ${sizeKb}KB file returns ~${compactKb}KB (80% less). ` +
513
+ `Call ${tool} { "path": "${filePath}" }.`,
514
+ `[ashlr] Native Read echoes all ${sizeKb}KB; ashlr__read returns ~${compactKb}KB (head+tail). ` +
515
+ `Call ${tool} { "path": "${filePath}" }.`,
516
+ `[ashlr] ${sizeKb}KB file — prefer ashlr__read (~${compactKb}KB output, 80% savings). ` +
517
+ `${tool} { "path": "${filePath}" }.`,
518
+ ];
519
+ const text = variants[nudgeVariantIndex(filePath, variants.length)];
520
+ return {
521
+ hookSpecificOutput: {
522
+ hookEventName: "PreToolUse",
523
+ additionalContext: text,
524
+ },
525
+ };
526
+ }
527
+ case "Grep": {
528
+ const pattern =
529
+ typeof toolInput.pattern === "string" ? toolInput.pattern : "<pattern>";
530
+ const tool = nudgeToolName("ashlr__grep");
531
+ const variants = [
532
+ `[ashlr] ashlr__grep saves ~80% vs native Grep (genome-aware or truncated ripgrep). ` +
533
+ `Call ${tool} { "pattern": ${JSON.stringify(pattern)} }.`,
534
+ `[ashlr] Native Grep returns raw rg output; ashlr__grep compresses to relevant chunks. ` +
535
+ `${tool} { "pattern": ${JSON.stringify(pattern)} }.`,
536
+ `[ashlr] Prefer ashlr__grep — genome-aware retrieval or truncated ripgrep, ~80% smaller. ` +
537
+ `${tool} { "pattern": ${JSON.stringify(pattern)} }.`,
538
+ ];
539
+ const text = variants[nudgeVariantIndex(pattern, variants.length)];
540
+ return {
541
+ hookSpecificOutput: {
542
+ hookEventName: "PreToolUse",
543
+ additionalContext: text,
544
+ },
545
+ };
546
+ }
547
+ case "Edit": {
548
+ const filePath =
549
+ typeof toolInput.file_path === "string" ? toolInput.file_path : "<path>";
550
+ const tool = nudgeToolName("ashlr__edit");
551
+ const variants = [
552
+ `[ashlr] ashlr__edit returns a compact diff (~60% smaller than native Edit). ` +
553
+ `${tool} { "path": "${filePath}", "search": ..., "replace": ..., "strict": true }.`,
554
+ `[ashlr] Native Edit echoes the full file; ashlr__edit returns only the changed lines. ` +
555
+ `${tool} { "path": "${filePath}", "search": ..., "replace": ... }.`,
556
+ `[ashlr] Use ashlr__edit for 60% token savings — strict search/replace + diff output. ` +
557
+ `${tool} { "path": "${filePath}", "search": ..., "replace": ... }.`,
558
+ ];
559
+ const text = variants[nudgeVariantIndex(filePath, variants.length)];
560
+ return {
561
+ hookSpecificOutput: {
562
+ hookEventName: "PreToolUse",
563
+ additionalContext: text,
564
+ },
565
+ };
566
+ }
567
+ case "Write": {
568
+ const filePath =
569
+ typeof toolInput.file_path === "string" ? toolInput.file_path : "<path>";
570
+ // Only nudge for rewrites of existing files — Write on a new file has
571
+ // no ashlr equivalent (ashlr__edit requires a search string).
572
+ if (fileSize(filePath) === null) return null;
573
+ const tool = nudgeToolName("ashlr__edit");
574
+ const variants = [
575
+ `[ashlr] Rewriting existing file — ashlr__edit returns only a diff (~60% less). ` +
576
+ `${tool} { "path": "${filePath}", "search": ..., "replace": ... }.`,
577
+ `[ashlr] Native Write echoes the full new file; ashlr__edit returns only changed lines. ` +
578
+ `${tool} { "path": "${filePath}", "search": ..., "replace": ..., "strict": true }.`,
579
+ `[ashlr] Prefer ashlr__edit for targeted rewrites — compact diff output, 60% savings. ` +
580
+ `${tool} { "path": "${filePath}", "search": ..., "replace": ... }.`,
581
+ ];
582
+ const text = variants[nudgeVariantIndex(filePath, variants.length)];
583
+ return {
584
+ hookSpecificOutput: {
585
+ hookEventName: "PreToolUse",
586
+ additionalContext: text,
587
+ },
588
+ };
589
+ }
590
+ case "MultiEdit": {
591
+ const filePath =
592
+ typeof toolInput.file_path === "string" ? toolInput.file_path : "<path>";
593
+ const tool = nudgeToolName("ashlr__multi_edit");
594
+ const variants = [
595
+ `[ashlr] ashlr__multi_edit applies all edits atomically and returns one diff summary. ` +
596
+ `${tool} { "edits": [{ "path": "${filePath}", "search": ..., "replace": ... }] }.`,
597
+ `[ashlr] Prefer ashlr__multi_edit — atomic apply + consolidated diff, ~60% token savings. ` +
598
+ `${tool} { "edits": [{ "path": "${filePath}", "search": ..., "replace": ... }] }.`,
599
+ ];
600
+ const text = variants[nudgeVariantIndex(filePath, variants.length)];
601
+ return {
602
+ hookSpecificOutput: {
603
+ hookEventName: "PreToolUse",
604
+ additionalContext: text,
605
+ },
606
+ };
607
+ }
608
+ case "apply_patch":
609
+ case "functions.apply_patch": {
610
+ const text =
611
+ "[ashlr] For large or repeated file edits, consider ashlr__multi_edit after gathering the exact replacements. " +
612
+ "Keep apply_patch for direct patch application when it is already the shortest path.";
613
+ return {
614
+ hookSpecificOutput: {
615
+ hookEventName: "PreToolUse",
616
+ additionalContext: text,
617
+ },
618
+ };
619
+ }
620
+ case "Bash": {
621
+ const command =
622
+ typeof toolInput.command === "string" ? toolInput.command : "";
623
+ if (!command) return null;
624
+ // Only nudge for commands where ashlr__bash can actually compress output.
625
+ // Quiet commands (echo, pwd, mv, rm, simple greps) pass through silently
626
+ // so the agent isn't pestered on every shell call. The match is the
627
+ // same predicate ashlr__bash uses internally to decide whether to apply
628
+ // a structured summarizer (findSummarizer) or LLM diff compression
629
+ // (isLargeDiffCommand) — so a nudge here means "real savings are on
630
+ // the table for this exact command."
631
+ const matches =
632
+ findSummarizer(command) !== null || isLargeDiffCommand(command);
633
+ if (!matches) return null;
634
+ const tool = nudgeToolName("ashlr__bash");
635
+ const variants = [
636
+ `[ashlr] This shell command typically produces verbose output. ` +
637
+ `Prefer the MCP tool \`ashlr__bash\` — it auto-compresses long stdout ` +
638
+ `(head + tail with elided middle) and emits structured summaries for ` +
639
+ `common commands (git status/log, npm/bun install, test runners, ` +
640
+ `find, ls, ps, docker ps, tsc). Call it with { "command": ${JSON.stringify(command)} }.`,
641
+ `[ashlr] ashlr__bash compresses verbose stdout (structured summaries for git/npm/bun/tsc). ` +
642
+ `${tool} { "command": ${JSON.stringify(command)} }.`,
643
+ `[ashlr] Native Bash returns raw output; ashlr__bash applies head+tail compression + summaries. ` +
644
+ `${tool} { "command": ${JSON.stringify(command)} }.`,
645
+ ];
646
+ const text = variants[nudgeVariantIndex(command, variants.length)];
647
+ return {
648
+ hookSpecificOutput: {
649
+ hookEventName: "PreToolUse",
650
+ additionalContext: text,
651
+ },
652
+ };
653
+ }
654
+ default:
655
+ return null;
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Return the file size in bytes, or null if the path isn't a regular file
661
+ * (doesn't exist, is a dir, permission denied, etc.). Never throws.
662
+ */
663
+ export function fileSize(filePath: string): number | null {
664
+ try {
665
+ const st = statSync(filePath);
666
+ if (!st.isFile()) return null;
667
+ return st.size;
668
+ } catch {
669
+ return null;
670
+ }
671
+ }
672
+
673
+ // ---------------------------------------------------------------------------
674
+ // Hook-timing telemetry
675
+ // ---------------------------------------------------------------------------
676
+ //
677
+ // Every PreToolUse hook is a separate subprocess spawned by Claude Code.
678
+ // Users who suspect hooks are slow (common on network-drive projects) have
679
+ // no way to see which hook is the culprit. These helpers append a single
680
+ // JSONL record per invocation to `~/.ashlr/hook-timings.jsonl` so
681
+ // `/ashlr-hook-timings` can surface real p50/p95 numbers.
682
+ //
683
+ // Design rules:
684
+ // - Never throw. Telemetry that breaks the hook is worse than no telemetry.
685
+ // - Respect `ASHLR_HOOK_TIMINGS=0` as a kill switch.
686
+ // - Cap record size — no arbitrary fields from the caller.
687
+ // - Async + batched: writes are queued in memory and flushed via
688
+ // setImmediate (end of current event loop turn) + a 1-second interval
689
+ // guard. On process exit the pending queue is drained synchronously so
690
+ // short-lived subprocess hooks always persist their record before dying.
691
+
692
+ /** Resolve the timings log path at call time so tests overriding HOME work. */
693
+ function hookTimingsPath(): string {
694
+ return join(process.env.HOME ?? homedir(), ".ashlr", "hook-timings.jsonl");
695
+ }
696
+
697
+ function timingsEnabled(): boolean {
698
+ return process.env.ASHLR_HOOK_TIMINGS !== "0";
699
+ }
700
+
701
+ // ---------------------------------------------------------------------------
702
+ // Async write batcher — collects JSONL lines and flushes them as a single
703
+ // appendFile call. setImmediate drains at end of the current event-loop turn;
704
+ // a 1-second interval is a safety net for long-running hooks.
705
+ // On process exit, any remaining lines are written synchronously so that
706
+ // short-lived subprocess hooks (which call process.exit immediately after
707
+ // recording) always persist their record.
708
+ // ---------------------------------------------------------------------------
709
+ let _pending: Array<{ path: string; line: string }> = [];
710
+ let _flushScheduled = false;
711
+
712
+ const HOOK_TIMING_TEXT_MAX_BYTES = 512;
713
+ const HOOK_TIMING_RECORD_MAX_BYTES = 4096;
714
+
715
+ /**
716
+ * Drain the pending queue into a path→concatenated-lines map. Each entry
717
+ * captured its target path at RECORD time (recordHookTiming), so a deferred
718
+ * flush — or a concurrent test that swapped process.env.HOME — can never
719
+ * misroute it. In prod HOME never changes, so this is a single-entry map and
720
+ * behaviour is identical to the old join(); it only matters for test isolation
721
+ * (Bun runs test files in one process; deferred flushes used to leak across
722
+ * files via the shared HOME).
723
+ */
724
+ function _takePendingByPath(): Map<string, string[]> {
725
+ const byPath = new Map<string, string[]>();
726
+ for (const { path, line } of _pending) {
727
+ const batches = byPath.get(path) ?? [];
728
+ const last = batches[batches.length - 1];
729
+ if (
730
+ last === undefined ||
731
+ Buffer.byteLength(last, "utf8") + Buffer.byteLength(line, "utf8") > HOOK_TIMING_BATCH_MAX_BYTES
732
+ ) batches.push(line);
733
+ else batches[batches.length - 1] = last + line;
734
+ byPath.set(path, batches);
735
+ }
736
+ _pending = [];
737
+ return byPath;
738
+ }
739
+ let _flushInterval: ReturnType<typeof setInterval> | null = null;
740
+ let _exitHandlerInstalled = false;
741
+ let _activeFlush: Promise<void> | null = null;
742
+
743
+ interface TimingWorkerResponse {
744
+ contended: Array<{ path: string; batch: string }>;
745
+ }
746
+
747
+ function _requeueBatches(entries: Array<{ path: string; batch: string }>): void {
748
+ if (entries.length === 0) return;
749
+ _pending = [
750
+ ...entries.map(({ path, batch }) => ({ path, line: batch })),
751
+ ..._pending,
752
+ ];
753
+ }
754
+
755
+ function _runTimingWorker(entries: Map<string, string[]>): Promise<TimingWorkerResponse> {
756
+ return new Promise((resolve) => {
757
+ let settled = false;
758
+ const all = [...entries].flatMap(([path, batches]) =>
759
+ batches.map((batch) => ({ path, batch })),
760
+ );
761
+ const finish = (response: TimingWorkerResponse) => {
762
+ if (settled) return;
763
+ settled = true;
764
+ resolve(response);
765
+ };
766
+ try {
767
+ const workerUrl = new URL("./_hook-timing-ledger.ts", import.meta.url);
768
+ workerUrl.searchParams.set("hookTimingWorker", "1");
769
+ const worker = new Worker(workerUrl.href);
770
+ worker.onmessage = (event: MessageEvent<TimingWorkerResponse>) => {
771
+ const response = event.data;
772
+ void worker.terminate();
773
+ finish(response);
774
+ };
775
+ worker.onerror = () => {
776
+ void worker.terminate();
777
+ finish({ contended: all });
778
+ };
779
+ worker.postMessage({
780
+ entries: [...entries].map(([path, batches]) => ({ path, batches })),
781
+ });
782
+ } catch {
783
+ finish({ contended: all });
784
+ }
785
+ });
786
+ }
787
+
788
+ function _writeEntries(entries: Map<string, string[]>): Promise<void> {
789
+ const prior = _activeFlush;
790
+ const current = (async () => {
791
+ if (prior) await prior;
792
+ const response = await _runTimingWorker(entries);
793
+ _requeueBatches(response.contended);
794
+ })();
795
+ _activeFlush = current;
796
+ void current.finally(() => {
797
+ if (_activeFlush === current) _activeFlush = null;
798
+ });
799
+ return current;
800
+ }
801
+
802
+ async function _drainPending(maxAttempts: number): Promise<void> {
803
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
804
+ if (_activeFlush) await _activeFlush;
805
+ if (_pending.length === 0) return;
806
+ await _writeEntries(_takePendingByPath());
807
+ if (_pending.length === 0) return;
808
+ await new Promise((done) => setTimeout(done, 10));
809
+ }
810
+ }
811
+
812
+ function _ensureFlushInterval(): void {
813
+ if (_flushInterval !== null) return;
814
+ _flushInterval = setInterval(_flushBatchAsync, 1000);
815
+ // Don't keep the process alive for the interval alone.
816
+ if (typeof _flushInterval.unref === "function") _flushInterval.unref();
817
+ }
818
+
819
+ function _ensureExitHandler(): void {
820
+ if (_exitHandlerInstalled) return;
821
+ _exitHandlerInstalled = true;
822
+ // process.on("exit") fires synchronously on process.exit(); it is the
823
+ // last chance to do synchronous I/O before the process dies. We drain
824
+ // any pending lines here so subprocess hooks that call process.exit(0)
825
+ // immediately after recordHookTiming() always write their record.
826
+ process.on("exit", () => {
827
+ if (_pending.length === 0) return;
828
+ for (const [path, batches] of _takePendingByPath()) {
829
+ for (const batch of batches) appendHookTimingBatch(path, batch, { lockWaitMs: 0 });
830
+ }
831
+ });
832
+ }
833
+
834
+ function _flushBatchAsync(): void {
835
+ _flushScheduled = false;
836
+ if (_pending.length === 0) return;
837
+ void _drainPending(1);
838
+ }
839
+
840
+ function truncateUtf8(value: string, maxBytes: number): string {
841
+ if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
842
+ let result = "";
843
+ let bytes = 0;
844
+ for (const character of value) {
845
+ const size = Buffer.byteLength(character, "utf8");
846
+ if (bytes + size > maxBytes) break;
847
+ result += character;
848
+ bytes += size;
849
+ }
850
+ return result;
851
+ }
852
+
853
+ function serializeHookTiming(record: {
854
+ hook: string;
855
+ tool?: string;
856
+ durationMs: number;
857
+ outcome: HookOutcome;
858
+ }): string {
859
+ let hook = truncateUtf8(String(record.hook), HOOK_TIMING_TEXT_MAX_BYTES);
860
+ let tool = record.tool === undefined
861
+ ? null
862
+ : truncateUtf8(String(record.tool), HOOK_TIMING_TEXT_MAX_BYTES);
863
+ const durationMs = Number.isFinite(record.durationMs)
864
+ ? Math.max(0, Math.round(record.durationMs))
865
+ : 0;
866
+ const outcome: HookOutcome = (
867
+ record.outcome === "ok" ||
868
+ record.outcome === "bypass" ||
869
+ record.outcome === "block" ||
870
+ record.outcome === "error" ||
871
+ record.outcome === "timeout"
872
+ ) ? record.outcome : "error";
873
+ const makeLine = () => JSON.stringify({
874
+ ts: new Date().toISOString(),
875
+ hook,
876
+ tool,
877
+ durationMs,
878
+ outcome,
879
+ }) + "\n";
880
+ let line = makeLine();
881
+ while (Buffer.byteLength(line, "utf8") > HOOK_TIMING_RECORD_MAX_BYTES) {
882
+ hook = truncateUtf8(hook, Math.floor(Buffer.byteLength(hook, "utf8") * 0.75));
883
+ if (tool !== null) {
884
+ tool = truncateUtf8(tool, Math.floor(Buffer.byteLength(tool, "utf8") * 0.75));
885
+ }
886
+ line = makeLine();
887
+ }
888
+ return line;
889
+ }
890
+
891
+ /**
892
+ * Record one hook invocation to the timings log. Non-blocking — the write is
893
+ * queued and flushed asynchronously (or synchronously on process exit). Never
894
+ * throws.
895
+ */
896
+ export type HookOutcome = "ok" | "bypass" | "block" | "error" | "timeout";
897
+
898
+ export function recordHookTiming(record: {
899
+ hook: string;
900
+ tool?: string;
901
+ durationMs: number;
902
+ outcome: HookOutcome;
903
+ }): void {
904
+ if (!timingsEnabled()) return;
905
+ try {
906
+ const line = serializeHookTiming(record);
907
+ _pending.push({ path: hookTimingsPath(), line });
908
+ _ensureFlushInterval();
909
+ _ensureExitHandler();
910
+ if (!_flushScheduled) {
911
+ _flushScheduled = true;
912
+ setImmediate(_flushBatchAsync);
913
+ }
914
+ } catch {
915
+ /* swallow */
916
+ }
917
+ }
918
+
919
+ /**
920
+ * Flush any pending timing lines. Exposed for tests that need to assert on
921
+ * file contents without waiting for the async batcher or process exit.
922
+ * Returns a Promise that resolves once the write completes (or immediately if
923
+ * the queue is empty).
924
+ */
925
+ export async function flushHookTimings(): Promise<void> {
926
+ _flushScheduled = false;
927
+ await _drainPending(3);
928
+ }
929
+
930
+ /**
931
+ * Wrap a hook's main function in timing instrumentation. The callback returns
932
+ * the hook's outcome classification so the timings log distinguishes
933
+ * "took 2ms and did nothing" from "took 300ms and blocked the call".
934
+ */
935
+ export async function withHookTiming<T>(
936
+ hookName: string,
937
+ fn: () => Promise<{ value: T; outcome: HookOutcome; tool?: string }>,
938
+ ): Promise<T> {
939
+ const start = Date.now();
940
+ try {
941
+ const { value, outcome, tool } = await fn();
942
+ recordHookTiming({ hook: hookName, tool, durationMs: Date.now() - start, outcome });
943
+ return value;
944
+ } catch (err) {
945
+ recordHookTiming({ hook: hookName, durationMs: Date.now() - start, outcome: "error" });
946
+ throw err;
947
+ }
948
+ }
949
+
950
+ /**
951
+ * Install a top-level safety timeout for a hook. If the hook hasn't exited
952
+ * within `timeoutMs`, log a timeout event and force-exit 0 so the user's
953
+ * tool call isn't blocked indefinitely.
954
+ *
955
+ * Call this once at the top of each hook's entry script, before any I/O.
956
+ * The timer is unref'd so it never extends the hook's natural lifetime —
957
+ * if the hook completes on its own, the timeout silently goes away.
958
+ *
959
+ * Env override: `ASHLR_HOOK_TIMEOUT_MS` (per-process). Default 2000ms.
960
+ */
961
+ export function installHookTimeout(hookName: string, timeoutMs?: number): void {
962
+ const envMs = Number(process.env.ASHLR_HOOK_TIMEOUT_MS);
963
+ const ms = timeoutMs ?? (Number.isFinite(envMs) && envMs > 0 ? envMs : 2000);
964
+ const start = Date.now();
965
+ const timer = setTimeout(() => {
966
+ try {
967
+ process.stderr.write(
968
+ `[ashlr ${hookName}] hook timeout — exceeded ${ms}ms, forcing exit 0\n`,
969
+ );
970
+ recordHookTiming({
971
+ hook: hookName,
972
+ durationMs: Date.now() - start,
973
+ outcome: "timeout",
974
+ });
975
+ } catch {
976
+ /* never throw from the safety net */
977
+ }
978
+ // flushHookTimings is async; give it a tick, then exit unconditionally.
979
+ void flushHookTimings().finally(() => process.exit(0));
980
+ // Belt-and-suspenders: if the flush hangs too, kill the process after 200ms.
981
+ const killer = setTimeout(() => process.exit(0), 200);
982
+ if (typeof killer.unref === "function") killer.unref();
983
+ }, ms);
984
+ if (typeof timer.unref === "function") timer.unref();
985
+ }