@cstart/coldstart 2.0.0

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 (360) hide show
  1. package/README.md +236 -0
  2. package/dist/cache/disk-cache.d.ts +14 -0
  3. package/dist/cache/disk-cache.d.ts.map +1 -0
  4. package/dist/cache/disk-cache.js +371 -0
  5. package/dist/cache/disk-cache.js.map +1 -0
  6. package/dist/cli.d.ts +18 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +206 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/constants.d.ts +8 -0
  11. package/dist/constants.d.ts.map +1 -0
  12. package/dist/constants.js +111 -0
  13. package/dist/constants.js.map +1 -0
  14. package/dist/daemon-lock.d.ts +63 -0
  15. package/dist/daemon-lock.d.ts.map +1 -0
  16. package/dist/daemon-lock.js +218 -0
  17. package/dist/daemon-lock.js.map +1 -0
  18. package/dist/daemon-log.d.ts +26 -0
  19. package/dist/daemon-log.d.ts.map +1 -0
  20. package/dist/daemon-log.js +110 -0
  21. package/dist/daemon-log.js.map +1 -0
  22. package/dist/index-manager.d.ts +103 -0
  23. package/dist/index-manager.d.ts.map +1 -0
  24. package/dist/index-manager.js +361 -0
  25. package/dist/index-manager.js.map +1 -0
  26. package/dist/index.d.ts +4 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +724 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/indexer/content-tokens.d.ts +74 -0
  31. package/dist/indexer/content-tokens.d.ts.map +1 -0
  32. package/dist/indexer/content-tokens.js +406 -0
  33. package/dist/indexer/content-tokens.js.map +1 -0
  34. package/dist/indexer/cpp-include-roots.d.ts +7 -0
  35. package/dist/indexer/cpp-include-roots.d.ts.map +1 -0
  36. package/dist/indexer/cpp-include-roots.js +166 -0
  37. package/dist/indexer/cpp-include-roots.js.map +1 -0
  38. package/dist/indexer/csharp-synthetic.d.ts +9 -0
  39. package/dist/indexer/csharp-synthetic.d.ts.map +1 -0
  40. package/dist/indexer/csharp-synthetic.js +51 -0
  41. package/dist/indexer/csharp-synthetic.js.map +1 -0
  42. package/dist/indexer/django-synthetic.d.ts +12 -0
  43. package/dist/indexer/django-synthetic.d.ts.map +1 -0
  44. package/dist/indexer/django-synthetic.js +49 -0
  45. package/dist/indexer/django-synthetic.js.map +1 -0
  46. package/dist/indexer/extractors/angularjs.d.ts +16 -0
  47. package/dist/indexer/extractors/angularjs.d.ts.map +1 -0
  48. package/dist/indexer/extractors/angularjs.js +49 -0
  49. package/dist/indexer/extractors/angularjs.js.map +1 -0
  50. package/dist/indexer/extractors/cpp.d.ts +9 -0
  51. package/dist/indexer/extractors/cpp.d.ts.map +1 -0
  52. package/dist/indexer/extractors/cpp.js +254 -0
  53. package/dist/indexer/extractors/cpp.js.map +1 -0
  54. package/dist/indexer/extractors/csharp.d.ts +19 -0
  55. package/dist/indexer/extractors/csharp.d.ts.map +1 -0
  56. package/dist/indexer/extractors/csharp.js +327 -0
  57. package/dist/indexer/extractors/csharp.js.map +1 -0
  58. package/dist/indexer/extractors/env.d.ts +9 -0
  59. package/dist/indexer/extractors/env.d.ts.map +1 -0
  60. package/dist/indexer/extractors/env.js +43 -0
  61. package/dist/indexer/extractors/env.js.map +1 -0
  62. package/dist/indexer/extractors/go.d.ts +9 -0
  63. package/dist/indexer/extractors/go.d.ts.map +1 -0
  64. package/dist/indexer/extractors/go.js +227 -0
  65. package/dist/indexer/extractors/go.js.map +1 -0
  66. package/dist/indexer/extractors/graphql.d.ts +22 -0
  67. package/dist/indexer/extractors/graphql.d.ts.map +1 -0
  68. package/dist/indexer/extractors/graphql.js +88 -0
  69. package/dist/indexer/extractors/graphql.js.map +1 -0
  70. package/dist/indexer/extractors/groovy.d.ts +9 -0
  71. package/dist/indexer/extractors/groovy.d.ts.map +1 -0
  72. package/dist/indexer/extractors/groovy.js +356 -0
  73. package/dist/indexer/extractors/groovy.js.map +1 -0
  74. package/dist/indexer/extractors/java.d.ts +10 -0
  75. package/dist/indexer/extractors/java.d.ts.map +1 -0
  76. package/dist/indexer/extractors/java.js +586 -0
  77. package/dist/indexer/extractors/java.js.map +1 -0
  78. package/dist/indexer/extractors/kotlin.d.ts +10 -0
  79. package/dist/indexer/extractors/kotlin.d.ts.map +1 -0
  80. package/dist/indexer/extractors/kotlin.js +398 -0
  81. package/dist/indexer/extractors/kotlin.js.map +1 -0
  82. package/dist/indexer/extractors/node-helpers.d.ts +13 -0
  83. package/dist/indexer/extractors/node-helpers.d.ts.map +1 -0
  84. package/dist/indexer/extractors/node-helpers.js +17 -0
  85. package/dist/indexer/extractors/node-helpers.js.map +1 -0
  86. package/dist/indexer/extractors/parser-factory.d.ts +4 -0
  87. package/dist/indexer/extractors/parser-factory.d.ts.map +1 -0
  88. package/dist/indexer/extractors/parser-factory.js +28 -0
  89. package/dist/indexer/extractors/parser-factory.js.map +1 -0
  90. package/dist/indexer/extractors/php.d.ts +17 -0
  91. package/dist/indexer/extractors/php.d.ts.map +1 -0
  92. package/dist/indexer/extractors/php.js +363 -0
  93. package/dist/indexer/extractors/php.js.map +1 -0
  94. package/dist/indexer/extractors/python.d.ts +14 -0
  95. package/dist/indexer/extractors/python.d.ts.map +1 -0
  96. package/dist/indexer/extractors/python.js +449 -0
  97. package/dist/indexer/extractors/python.js.map +1 -0
  98. package/dist/indexer/extractors/ruby.d.ts +10 -0
  99. package/dist/indexer/extractors/ruby.d.ts.map +1 -0
  100. package/dist/indexer/extractors/ruby.js +996 -0
  101. package/dist/indexer/extractors/ruby.js.map +1 -0
  102. package/dist/indexer/extractors/rust.d.ts +9 -0
  103. package/dist/indexer/extractors/rust.d.ts.map +1 -0
  104. package/dist/indexer/extractors/rust.js +322 -0
  105. package/dist/indexer/extractors/rust.js.map +1 -0
  106. package/dist/indexer/extractors/toml.d.ts +9 -0
  107. package/dist/indexer/extractors/toml.d.ts.map +1 -0
  108. package/dist/indexer/extractors/toml.js +84 -0
  109. package/dist/indexer/extractors/toml.js.map +1 -0
  110. package/dist/indexer/extractors/xml.d.ts +9 -0
  111. package/dist/indexer/extractors/xml.d.ts.map +1 -0
  112. package/dist/indexer/extractors/xml.js +163 -0
  113. package/dist/indexer/extractors/xml.js.map +1 -0
  114. package/dist/indexer/extractors/yaml.d.ts +9 -0
  115. package/dist/indexer/extractors/yaml.d.ts.map +1 -0
  116. package/dist/indexer/extractors/yaml.js +117 -0
  117. package/dist/indexer/extractors/yaml.js.map +1 -0
  118. package/dist/indexer/git.d.ts +19 -0
  119. package/dist/indexer/git.d.ts.map +1 -0
  120. package/dist/indexer/git.js +55 -0
  121. package/dist/indexer/git.js.map +1 -0
  122. package/dist/indexer/graph.d.ts +7 -0
  123. package/dist/indexer/graph.d.ts.map +1 -0
  124. package/dist/indexer/graph.js +20 -0
  125. package/dist/indexer/graph.js.map +1 -0
  126. package/dist/indexer/indexed-file.d.ts +15 -0
  127. package/dist/indexer/indexed-file.d.ts.map +1 -0
  128. package/dist/indexer/indexed-file.js +50 -0
  129. package/dist/indexer/indexed-file.js.map +1 -0
  130. package/dist/indexer/invariants.d.ts +15 -0
  131. package/dist/indexer/invariants.d.ts.map +1 -0
  132. package/dist/indexer/invariants.js +96 -0
  133. package/dist/indexer/invariants.js.map +1 -0
  134. package/dist/indexer/laravel-synthetic.d.ts +10 -0
  135. package/dist/indexer/laravel-synthetic.d.ts.map +1 -0
  136. package/dist/indexer/laravel-synthetic.js +78 -0
  137. package/dist/indexer/laravel-synthetic.js.map +1 -0
  138. package/dist/indexer/parser.d.ts +4 -0
  139. package/dist/indexer/parser.d.ts.map +1 -0
  140. package/dist/indexer/parser.js +499 -0
  141. package/dist/indexer/parser.js.map +1 -0
  142. package/dist/indexer/patch.d.ts +3 -0
  143. package/dist/indexer/patch.d.ts.map +1 -0
  144. package/dist/indexer/patch.js +291 -0
  145. package/dist/indexer/patch.js.map +1 -0
  146. package/dist/indexer/rails-synthetic.d.ts +11 -0
  147. package/dist/indexer/rails-synthetic.d.ts.map +1 -0
  148. package/dist/indexer/rails-synthetic.js +80 -0
  149. package/dist/indexer/rails-synthetic.js.map +1 -0
  150. package/dist/indexer/reconcile.d.ts +9 -0
  151. package/dist/indexer/reconcile.d.ts.map +1 -0
  152. package/dist/indexer/reconcile.js +96 -0
  153. package/dist/indexer/reconcile.js.map +1 -0
  154. package/dist/indexer/resolvers/cpp.d.ts +15 -0
  155. package/dist/indexer/resolvers/cpp.d.ts.map +1 -0
  156. package/dist/indexer/resolvers/cpp.js +35 -0
  157. package/dist/indexer/resolvers/cpp.js.map +1 -0
  158. package/dist/indexer/resolvers/csharp.d.ts +2 -0
  159. package/dist/indexer/resolvers/csharp.d.ts.map +1 -0
  160. package/dist/indexer/resolvers/csharp.js +89 -0
  161. package/dist/indexer/resolvers/csharp.js.map +1 -0
  162. package/dist/indexer/resolvers/generic.d.ts +10 -0
  163. package/dist/indexer/resolvers/generic.d.ts.map +1 -0
  164. package/dist/indexer/resolvers/generic.js +35 -0
  165. package/dist/indexer/resolvers/generic.js.map +1 -0
  166. package/dist/indexer/resolvers/go.d.ts +2 -0
  167. package/dist/indexer/resolvers/go.d.ts.map +1 -0
  168. package/dist/indexer/resolvers/go.js +171 -0
  169. package/dist/indexer/resolvers/go.js.map +1 -0
  170. package/dist/indexer/resolvers/index.d.ts +26 -0
  171. package/dist/indexer/resolvers/index.d.ts.map +1 -0
  172. package/dist/indexer/resolvers/index.js +301 -0
  173. package/dist/indexer/resolvers/index.js.map +1 -0
  174. package/dist/indexer/resolvers/java.d.ts +17 -0
  175. package/dist/indexer/resolvers/java.d.ts.map +1 -0
  176. package/dist/indexer/resolvers/java.js +104 -0
  177. package/dist/indexer/resolvers/java.js.map +1 -0
  178. package/dist/indexer/resolvers/php.d.ts +2 -0
  179. package/dist/indexer/resolvers/php.d.ts.map +1 -0
  180. package/dist/indexer/resolvers/php.js +108 -0
  181. package/dist/indexer/resolvers/php.js.map +1 -0
  182. package/dist/indexer/resolvers/python.d.ts +17 -0
  183. package/dist/indexer/resolvers/python.d.ts.map +1 -0
  184. package/dist/indexer/resolvers/python.js +63 -0
  185. package/dist/indexer/resolvers/python.js.map +1 -0
  186. package/dist/indexer/resolvers/ruby.d.ts +13 -0
  187. package/dist/indexer/resolvers/ruby.d.ts.map +1 -0
  188. package/dist/indexer/resolvers/ruby.js +199 -0
  189. package/dist/indexer/resolvers/ruby.js.map +1 -0
  190. package/dist/indexer/resolvers/rust.d.ts +12 -0
  191. package/dist/indexer/resolvers/rust.d.ts.map +1 -0
  192. package/dist/indexer/resolvers/rust.js +61 -0
  193. package/dist/indexer/resolvers/rust.js.map +1 -0
  194. package/dist/indexer/resolvers/shared.d.ts +18 -0
  195. package/dist/indexer/resolvers/shared.d.ts.map +1 -0
  196. package/dist/indexer/resolvers/shared.js +71 -0
  197. package/dist/indexer/resolvers/shared.js.map +1 -0
  198. package/dist/indexer/rust-workspace.d.ts +25 -0
  199. package/dist/indexer/rust-workspace.d.ts.map +1 -0
  200. package/dist/indexer/rust-workspace.js +155 -0
  201. package/dist/indexer/rust-workspace.js.map +1 -0
  202. package/dist/indexer/symbol-edges.d.ts +19 -0
  203. package/dist/indexer/symbol-edges.d.ts.map +1 -0
  204. package/dist/indexer/symbol-edges.js +117 -0
  205. package/dist/indexer/symbol-edges.js.map +1 -0
  206. package/dist/indexer/tokenize.d.ts +26 -0
  207. package/dist/indexer/tokenize.d.ts.map +1 -0
  208. package/dist/indexer/tokenize.js +200 -0
  209. package/dist/indexer/tokenize.js.map +1 -0
  210. package/dist/indexer/ts-parser.d.ts +10 -0
  211. package/dist/indexer/ts-parser.d.ts.map +1 -0
  212. package/dist/indexer/ts-parser.js +605 -0
  213. package/dist/indexer/ts-parser.js.map +1 -0
  214. package/dist/indexer/walker.d.ts +9 -0
  215. package/dist/indexer/walker.d.ts.map +1 -0
  216. package/dist/indexer/walker.js +91 -0
  217. package/dist/indexer/walker.js.map +1 -0
  218. package/dist/init.d.ts +111 -0
  219. package/dist/init.d.ts.map +1 -0
  220. package/dist/init.js +787 -0
  221. package/dist/init.js.map +1 -0
  222. package/dist/kb/cli.d.ts +2 -0
  223. package/dist/kb/cli.d.ts.map +1 -0
  224. package/dist/kb/cli.js +377 -0
  225. package/dist/kb/cli.js.map +1 -0
  226. package/dist/kb/commit.d.ts +6 -0
  227. package/dist/kb/commit.d.ts.map +1 -0
  228. package/dist/kb/commit.js +84 -0
  229. package/dist/kb/commit.js.map +1 -0
  230. package/dist/kb/fold.d.ts +42 -0
  231. package/dist/kb/fold.d.ts.map +1 -0
  232. package/dist/kb/fold.js +285 -0
  233. package/dist/kb/fold.js.map +1 -0
  234. package/dist/kb/freshness.d.ts +9 -0
  235. package/dist/kb/freshness.d.ts.map +1 -0
  236. package/dist/kb/freshness.js +52 -0
  237. package/dist/kb/freshness.js.map +1 -0
  238. package/dist/kb/git.d.ts +4 -0
  239. package/dist/kb/git.d.ts.map +1 -0
  240. package/dist/kb/git.js +28 -0
  241. package/dist/kb/git.js.map +1 -0
  242. package/dist/kb/ids.d.ts +8 -0
  243. package/dist/kb/ids.d.ts.map +1 -0
  244. package/dist/kb/ids.js +42 -0
  245. package/dist/kb/ids.js.map +1 -0
  246. package/dist/kb/lint.d.ts +23 -0
  247. package/dist/kb/lint.d.ts.map +1 -0
  248. package/dist/kb/lint.js +102 -0
  249. package/dist/kb/lint.js.map +1 -0
  250. package/dist/kb/lookup.d.ts +55 -0
  251. package/dist/kb/lookup.d.ts.map +1 -0
  252. package/dist/kb/lookup.js +92 -0
  253. package/dist/kb/lookup.js.map +1 -0
  254. package/dist/kb/notes-index.d.ts +31 -0
  255. package/dist/kb/notes-index.d.ts.map +1 -0
  256. package/dist/kb/notes-index.js +0 -0
  257. package/dist/kb/notes-index.js.map +1 -0
  258. package/dist/kb/raw-log.d.ts +34 -0
  259. package/dist/kb/raw-log.d.ts.map +1 -0
  260. package/dist/kb/raw-log.js +106 -0
  261. package/dist/kb/raw-log.js.map +1 -0
  262. package/dist/kb/render.d.ts +17 -0
  263. package/dist/kb/render.d.ts.map +1 -0
  264. package/dist/kb/render.js +101 -0
  265. package/dist/kb/render.js.map +1 -0
  266. package/dist/kb/search.d.ts +110 -0
  267. package/dist/kb/search.d.ts.map +1 -0
  268. package/dist/kb/search.js +453 -0
  269. package/dist/kb/search.js.map +1 -0
  270. package/dist/kb/store.d.ts +35 -0
  271. package/dist/kb/store.d.ts.map +1 -0
  272. package/dist/kb/store.js +160 -0
  273. package/dist/kb/store.js.map +1 -0
  274. package/dist/kb/types.d.ts +142 -0
  275. package/dist/kb/types.d.ts.map +1 -0
  276. package/dist/kb/types.js +13 -0
  277. package/dist/kb/types.js.map +1 -0
  278. package/dist/kb/view-template.d.ts +8 -0
  279. package/dist/kb/view-template.d.ts.map +1 -0
  280. package/dist/kb/view-template.js +8 -0
  281. package/dist/kb/view-template.js.map +1 -0
  282. package/dist/kb/view.d.ts +42 -0
  283. package/dist/kb/view.d.ts.map +1 -0
  284. package/dist/kb/view.js +118 -0
  285. package/dist/kb/view.js.map +1 -0
  286. package/dist/kb/write.d.ts +72 -0
  287. package/dist/kb/write.d.ts.map +1 -0
  288. package/dist/kb/write.js +345 -0
  289. package/dist/kb/write.js.map +1 -0
  290. package/dist/keeper-state.d.ts +29 -0
  291. package/dist/keeper-state.d.ts.map +1 -0
  292. package/dist/keeper-state.js +89 -0
  293. package/dist/keeper-state.js.map +1 -0
  294. package/dist/keeper.d.ts +23 -0
  295. package/dist/keeper.d.ts.map +1 -0
  296. package/dist/keeper.js +105 -0
  297. package/dist/keeper.js.map +1 -0
  298. package/dist/migrate.d.ts +9 -0
  299. package/dist/migrate.d.ts.map +1 -0
  300. package/dist/migrate.js +102 -0
  301. package/dist/migrate.js.map +1 -0
  302. package/dist/restart.d.ts +15 -0
  303. package/dist/restart.d.ts.map +1 -0
  304. package/dist/restart.js +48 -0
  305. package/dist/restart.js.map +1 -0
  306. package/dist/server/find.d.ts +39 -0
  307. package/dist/server/find.d.ts.map +1 -0
  308. package/dist/server/find.js +979 -0
  309. package/dist/server/find.js.map +1 -0
  310. package/dist/server/mcp.d.ts +117 -0
  311. package/dist/server/mcp.d.ts.map +1 -0
  312. package/dist/server/mcp.js +301 -0
  313. package/dist/server/mcp.js.map +1 -0
  314. package/dist/server/searcher.d.ts +14 -0
  315. package/dist/server/searcher.d.ts.map +1 -0
  316. package/dist/server/searcher.js +146 -0
  317. package/dist/server/searcher.js.map +1 -0
  318. package/dist/server/tools.d.ts +17 -0
  319. package/dist/server/tools.d.ts.map +1 -0
  320. package/dist/server/tools.js +875 -0
  321. package/dist/server/tools.js.map +1 -0
  322. package/dist/status.d.ts +12 -0
  323. package/dist/status.d.ts.map +1 -0
  324. package/dist/status.js +157 -0
  325. package/dist/status.js.map +1 -0
  326. package/dist/types.d.ts +138 -0
  327. package/dist/types.d.ts.map +1 -0
  328. package/dist/types.js +3 -0
  329. package/dist/types.js.map +1 -0
  330. package/dist/watcher.d.ts +11 -0
  331. package/dist/watcher.d.ts.map +1 -0
  332. package/dist/watcher.js +67 -0
  333. package/dist/watcher.js.map +1 -0
  334. package/hooks/canonical-find-key.mjs +68 -0
  335. package/hooks/codex-find-nudge.mjs +15 -0
  336. package/hooks/codex-find-preguard.mjs +14 -0
  337. package/hooks/codex-kb-elicit.mjs +329 -0
  338. package/hooks/codex-kb-recall.mjs +151 -0
  339. package/hooks/codex-nudge-handler.mjs +370 -0
  340. package/hooks/codex-preguard-handler.mjs +75 -0
  341. package/hooks/codex-run-hook.mjs +132 -0
  342. package/hooks/coldstart-call.mjs +58 -0
  343. package/hooks/cursor-find-nudge.mjs +15 -0
  344. package/hooks/cursor-find-preguard.mjs +14 -0
  345. package/hooks/cursor-input.mjs +72 -0
  346. package/hooks/cursor-kb-elicit.mjs +335 -0
  347. package/hooks/cursor-kb-recall.mjs +138 -0
  348. package/hooks/cursor-nudge-handler.mjs +33 -0
  349. package/hooks/cursor-preguard-handler.mjs +39 -0
  350. package/hooks/cursor-run-hook.mjs +129 -0
  351. package/hooks/find-nudge.mjs +15 -0
  352. package/hooks/find-preguard.mjs +14 -0
  353. package/hooks/kb-elicit.mjs +334 -0
  354. package/hooks/kb-recall.mjs +151 -0
  355. package/hooks/nudge-handler.mjs +370 -0
  356. package/hooks/preguard-handler.mjs +75 -0
  357. package/hooks/run-hook.mjs +132 -0
  358. package/package.json +69 -0
  359. package/templates/coldstart.cli.md +60 -0
  360. package/templates/coldstart.mcp.md +59 -0
@@ -0,0 +1,370 @@
1
+ /**
2
+ * codex-nudge-handler.mjs — Codex PostToolUse nudge for `find`/`gs`, CLI and MCP.
3
+ *
4
+ * The agent may reach coldstart via the CLI (Bash `coldstart find/gs`) or the MCP
5
+ * tools (`mcp__coldstart__find/gs`). normalizeColdstartCall rewrites an MCP call
6
+ * into the equivalent CLI command string so every detector below runs unchanged on
7
+ * either surface — same logic, only the entry tool name differs.
8
+ *
9
+ * Fires advisory nudges (additionalContext) at the moments the agent's search
10
+ * behaviour goes wrong. Detectors, each fires sparingly:
11
+ *
12
+ * 1. READ-AFTER-2-FINDS — ran `coldstart find` twice with no Read/gs in between
13
+ * => stop searching, open a candidate.
14
+ * 2. EMPTY-SEARCH — a grep/glob/shell-find returned nothing
15
+ * => empty != absent; refine find or read what you have.
16
+ * 3. NONFIND-SHELL-3 — 3 non-find search/shell calls since last find/Read/gs
17
+ * => the spiral; go back to find or read.
18
+ * 3b. NO-NEW-EVIDENCE — a grep/find CONFINED to files already in context
19
+ * => re-checks surfaced data, can't reveal a new file.
20
+ * Recall-safe: any call surfacing a new file is silent.
21
+ * 4. CHECKPOINT — every CHECKPOINT_EVERY tool calls
22
+ * => can you answer from what you've read? name the gap.
23
+ * 5. GS-OVER-SLICE — sliced the SAME file with `gs` >= GS_SLICE_CAP times
24
+ * => you have its bodies + pointers; answer or move on.
25
+ * 6. GS-REGUESS — re-called `gs --symbol` on a file that just returned
26
+ * the method-menu fallback => pick from the menu.
27
+ *
28
+ * Also REGISTERS the canonical key of every SUCCESSFUL (non-empty) find into
29
+ * `seen_find_queries` — the Codex PreToolUse guard denies a re-run of
30
+ * a registered key. The key fn is shared (canonical-find-key.mjs); it MUST match
31
+ * the guard exactly or the deny-key and registration-key drift.
32
+ *
33
+ * State per (session,agent) in /tmp. Fail-open: any error → null (runHook swallows).
34
+ *
35
+ * Ported 1:1 from find-nudge.py. Thresholds below are the same defaults.
36
+ */
37
+
38
+ import { readFileSync, writeFileSync, renameSync, existsSync } from "node:fs";
39
+ import { execFileSync } from "node:child_process";
40
+ import { join } from "node:path";
41
+ import { fileURLToPath } from "node:url";
42
+ import { canonicalFindKey } from "./canonical-find-key.mjs";
43
+ import { normalizeColdstartCall } from "./coldstart-call.mjs";
44
+
45
+ // hooks/ sits beside dist/ in both the repo and the published package.
46
+ const KB_CLI = fileURLToPath(new URL("../dist/index.js", import.meta.url));
47
+
48
+ // ---- detector thresholds (tune freely) ----
49
+ const FINDS_BEFORE_READ = 2; // (1) nudge to read after this many finds w/o a Read/gs
50
+ const NONFIND_SHELL_STREAK = 3; // (3) nudge after this many non-find search/shell calls
51
+ const CHECKPOINT_EVERY = 12; // (4) checkpoint nudge cadence, in tool calls
52
+ const GS_SLICE_CAP = 3; // (5) nudge once gs has sliced the SAME file this many times
53
+ const EMPTY_NUDGE_CAP = 2; // (2) max empty-search nudges per session
54
+ const REDUNDANT_CAP = 6; // (3b) max no-new-evidence nudges per session
55
+
56
+ // coldstart find (the GOOD locator)
57
+ const FIND_RE = /coldstart\s+find\b|index\.js\s+find\b/;
58
+ // coldstart gs (the GOOD reader — slices symbol bodies): read-equivalent for the
59
+ // spiral, but with its own abuse modes (over-slice, re-guess after a menu fallback).
60
+ const GS_RE = /coldstart\s+gs\b|index\.js\s+gs\b/;
61
+ // coldstart kb (notebook search/write) — proves the session is coldstart-aware.
62
+ const KB_RE = /coldstart\s+kb\b|index\.js\s+kb\b/;
63
+ // the gs menu-fallback marker (printed when --symbol isn't a declared symbol)
64
+ const GS_FALLBACK_RE = /NOT a declared symbol here|no declared symbol matches/;
65
+ // search/shell that ISN'T coldstart find/gs — the spiral surface
66
+ const SEARCH_RE =
67
+ /(^|[;&|]|\s)(grep|egrep|fgrep|rg)\b|git\s+grep|git\s+log|(^|[;&|]|\s)find\s|(^|[;&|]|\s)ls\b|(^|[;&|]|\s)cat\b/;
68
+ const GS_FILE_RE = /(?:coldstart|index\.js)\s+gs\s+(\S+)/;
69
+
70
+ // Evidence store regexes — paths printed in ANY output, and files named as ARGS.
71
+ const PATH_RE = /[\w./-]+\.(?:py|js|jsx|ts|tsx|htm|html|vue|json|scss|css|rb|java)/g;
72
+ const FILE_ARG_RE =
73
+ /(?<![\w/])([\w][\w./-]*\.(?:py|js|jsx|ts|tsx|htm|html|vue|json|scss|css|rb|java))\b/g;
74
+
75
+ /** Mirror of find-nudge.py result_text: extract the raw text payload of a tool
76
+ * response, collapsing a present-but-empty stdout to "" (NOT to the JSON of the
77
+ * whole envelope, which would hide emptiness). */
78
+ function resultText(input) {
79
+ let r = input.tool_response;
80
+ if (r === undefined) r = input.tool_output;
81
+ if (r === undefined || r === null) return null;
82
+ if (typeof r === "string") return r;
83
+ if (Array.isArray(r)) {
84
+ return r
85
+ .map((x) => (x && typeof x === "object" ? x.text || "" : String(x)))
86
+ .join(" ");
87
+ }
88
+ if (typeof r === "object") {
89
+ const KEYS = ["stdout", "stderr", "content", "output", "result"];
90
+ if (KEYS.some((k) => k in r)) {
91
+ return KEYS.map((k) => String(r[k] ?? "")).join("");
92
+ }
93
+ return JSON.stringify(r);
94
+ }
95
+ return String(r);
96
+ }
97
+
98
+ function matchAllGroup(re, s, group) {
99
+ const found = new Set();
100
+ if (!s) return found;
101
+ for (const m of s.matchAll(re)) found.add(group ? m[group] : m[0]);
102
+ return found;
103
+ }
104
+
105
+ /** The pattern the agent is grepping for — first quoted string or bare arg
106
+ * after the grep/rg invocation. Empty when nothing sensible is extractable. */
107
+ function grepPattern(cmd) {
108
+ const m = String(cmd || "").match(
109
+ /(?:^|[;&|]\s*|\s)(?:grep|egrep|fgrep|rg|git\s+grep)\s+(?:-{1,2}[\w=-]+\s+)*(?:"([^"]+)"|'([^']+)'|([^\s'"|;&-]\S*))/,
110
+ );
111
+ return m ? (m[1] || m[2] || m[3] || "").replace(/[\\^$.*+?()[\]{}]/g, " ").trim() : "";
112
+ }
113
+
114
+ /** Hit-gated notebook probe for the spiral nudge: ONE hook-mode kb search on
115
+ * what the agent is grepping for. Returns the top note's title, or null —
116
+ * and the nudge only mentions the notebook when a note actually exists
117
+ * (a generic "try the notebook" would be boilerplate steering; a hit is a
118
+ * report of fact). Fail-open and skipped entirely when there's no notebook. */
119
+ function kbSpiralHit(root, pattern) {
120
+ try {
121
+ if (!pattern || !root || !existsSync(join(root, ".coldstart", "notebook", ".raw"))) return null;
122
+ const page = execFileSync(
123
+ "node", [KB_CLI, "kb", "search", "--hook", "--max", "1", "--root", root, pattern],
124
+ { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] },
125
+ );
126
+ const m = page.match(/^- \*\*(.+?)\*\*/m);
127
+ return m ? m[1] : null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * @param {any} input parsed PostToolUse stdin payload
135
+ * @returns {object|null} an additionalContext envelope, or null for no nudge
136
+ */
137
+ export default function handle(input) {
138
+ const sid = input.session_id || "default";
139
+ const aid = input.agent_id || ""; // set when inside a subagent
140
+ const tin = input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
141
+ // Collapse CLI and MCP coldstart calls to one shape: an MCP find/gs becomes a
142
+ // synthetic `coldstart find/gs ...` Bash command so the detectors run unchanged.
143
+ const { tool, cmd } = normalizeColdstartCall(input.tool_name || "", tin);
144
+ const out = resultText(input);
145
+
146
+ const key = aid ? `${sid}_${aid}` : sid;
147
+ const stateFile = `/tmp/find_nudge_${key}.json`;
148
+ const st = {
149
+ seen_find: false,
150
+ total: 0,
151
+ finds_since_read: 0,
152
+ nonfind_streak: 0,
153
+ empty_fired: 0,
154
+ last_checkpoint: 0,
155
+ read_fired: false,
156
+ shell_fired: false,
157
+ gs_counts: {},
158
+ gs_slice_fired: [],
159
+ last_gs_fallback: "",
160
+ gs_reguess_fired: false,
161
+ held_files: [],
162
+ redundant_fired: 0,
163
+ seen_find_queries: [],
164
+ };
165
+ try {
166
+ Object.assign(st, JSON.parse(readFileSync(stateFile, "utf8")));
167
+ } catch {
168
+ /* defaults */
169
+ }
170
+
171
+ // classify this call
172
+ const isFind = tool === "Bash" && FIND_RE.test(cmd);
173
+ const isGs = tool === "Bash" && GS_RE.test(cmd);
174
+ const isKb = tool === "Bash" && KB_RE.test(cmd);
175
+ let gsFile = "";
176
+ if (isGs) {
177
+ const gm = GS_FILE_RE.exec(cmd);
178
+ gsFile = gm ? gm[1] : "";
179
+ }
180
+ const prevFallback = st.last_gs_fallback || ""; // set by the PREVIOUS gs call's output
181
+ const isRead = tool === "Read";
182
+ // gs/find/kb are tools, not the grep-spiral, even when piped to head/grep
183
+ const isSearch =
184
+ tool === "Grep" ||
185
+ tool === "Glob" ||
186
+ (tool === "Bash" && !isFind && !isGs && !isKb && SEARCH_RE.test(cmd));
187
+ const isNonfindShell = isSearch;
188
+
189
+ const _ob = out !== null && out !== undefined ? out.trim().toLowerCase() : null;
190
+ const outEmpty =
191
+ _ob !== null &&
192
+ (_ob === "" ||
193
+ _ob.includes("completed with no output") ||
194
+ _ob === "no matches found" ||
195
+ _ob === "no files found");
196
+
197
+ st.total += 1;
198
+ // seen_find gates the spiral detectors (3/3b): they stay silent for sessions that
199
+ // never touch coldstart at all (deliberate — this hook must not nag non-users).
200
+ // Any coldstart surface proves awareness: find (set below on the find branch),
201
+ // gs, a kb call, or a kb-recall injection (kb-recall.mjs pre-seeds this flag) —
202
+ // a note-implanted session may legitimately skip `find` because the note handed
203
+ // it the files, and it still needs the anti-grep-spiral coverage (the q23 case).
204
+ if (isGs || isKb) st.seen_find = true;
205
+ const msgs = []; // [priority, text]; lower priority number = more urgent, wins
206
+
207
+ // --- evidence: what files THIS call touched, and which are genuinely NEW ---
208
+ const held = new Set(st.held_files || []);
209
+ const outFiles = matchAllGroup(PATH_RE, out, 0);
210
+ const cmdFiles = matchAllGroup(FILE_ARG_RE, cmd, 1);
211
+ if (isRead) {
212
+ const rp = typeof tin.file_path === "string" ? tin.file_path : "";
213
+ if (rp) cmdFiles.add(rp);
214
+ }
215
+ const scope = new Set([...outFiles, ...cmdFiles]);
216
+ const novel = [...scope].filter((f) => !held.has(f)); // files newly in context
217
+
218
+ if (isFind) {
219
+ st.seen_find = true;
220
+ st.finds_since_read += 1;
221
+ st.nonfind_streak = 0;
222
+ st.shell_fired = false;
223
+ // register the canonical key ONLY on a successful (non-empty) result, so the
224
+ // PreToolUse guard never blocks a retry of a find that errored/returned nothing.
225
+ if (out && !outEmpty) {
226
+ const ck = canonicalFindKey(cmd);
227
+ if (ck && !st.seen_find_queries.includes(ck)) {
228
+ st.seen_find_queries = st.seen_find_queries.concat([ck]);
229
+ }
230
+ }
231
+ if (st.finds_since_read >= FINDS_BEFORE_READ && !st.read_fired) {
232
+ st.read_fired = true;
233
+ msgs.push([
234
+ 2,
235
+ `You've run \`coldstart find\` ${st.finds_since_read}× without opening a file. ` +
236
+ "The page already ranks candidates and shows symbol + body lines WITH line numbers — " +
237
+ "stop searching and READ the 1–2 most promising now. Refine the query only if a file's " +
238
+ "actual contents send you elsewhere.",
239
+ ]);
240
+ }
241
+ } else if (isRead) {
242
+ st.finds_since_read = 0;
243
+ st.nonfind_streak = 0;
244
+ st.read_fired = false;
245
+ st.shell_fired = false;
246
+ } else if (isGs) {
247
+ // gs opens file content — credit it like a Read for the spiral/find counters.
248
+ st.finds_since_read = 0;
249
+ st.nonfind_streak = 0;
250
+ st.read_fired = false;
251
+ st.shell_fired = false;
252
+ const counts = st.gs_counts || {};
253
+ counts[gsFile] = (counts[gsFile] || 0) + 1;
254
+ st.gs_counts = counts;
255
+ const fired = st.gs_slice_fired || [];
256
+ // (6) GS-REGUESS — the previous gs on THIS file returned the method menu, and
257
+ // the agent is slicing it again instead of picking a name from that menu.
258
+ if (gsFile && prevFallback === gsFile && !st.gs_reguess_fired) {
259
+ st.gs_reguess_fired = true;
260
+ msgs.push([
261
+ 2,
262
+ `\`${gsFile}\` just returned its method MENU — the \`--symbol\` name you passed isn't declared ` +
263
+ "there. Pick a name from that menu, or `Read` the file. Re-calling `gs --symbol` with another " +
264
+ "guessed name on the same file is the single most common wasted call — you already have the menu.",
265
+ ]);
266
+ } else if (gsFile && counts[gsFile] >= GS_SLICE_CAP && !fired.includes(gsFile)) {
267
+ // (5) GS-OVER-SLICE — sliced one file enough times; you have it.
268
+ fired.push(gsFile);
269
+ st.gs_slice_fired = fired;
270
+ msgs.push([
271
+ 2,
272
+ `You've sliced \`${gsFile}\` ${counts[gsFile]}× with \`gs\` — you have its bodies plus the ` +
273
+ "`calls:`/`callers:` pointers, so you almost certainly have enough of THIS file. Answer from " +
274
+ "what you have, `Read` it whole if you need lines BETWEEN symbols, or follow a pointer to a " +
275
+ "DIFFERENT file. Stop re-slicing this one.",
276
+ ]);
277
+ }
278
+ } else if (isNonfindShell && st.seen_find) {
279
+ st.nonfind_streak += 1;
280
+ // (3b) NO-NEW-EVIDENCE — per-call: this grep/find is confined to files the
281
+ // agent ALREADY has in context, so it surfaced nothing new.
282
+ const confined = scope.size > 0 && novel.length === 0 && !outEmpty;
283
+ if (confined && (st.redundant_fired || 0) < REDUNDANT_CAP) {
284
+ st.redundant_fired = (st.redundant_fired || 0) + 1;
285
+ const heldHits = [...scope].sort().slice(0, 3);
286
+ const names = heldHits.map((f) => "`" + f + "`").join(", ");
287
+ msgs.push([
288
+ 1,
289
+ `This search only touched files you ALREADY have in context — ${names}. You surfaced or read ` +
290
+ "them earlier, so re-grepping them returns lines you already hold; it cannot reveal a new file. " +
291
+ "The answer is in the results already in your context — RE-READ those (the find/gs page, the " +
292
+ "files you opened) and answer the task now. Only search if you can name a genuinely NEW file, " +
293
+ "identifier, or directory you have not yet covered.",
294
+ ]);
295
+ } else if (st.nonfind_streak >= NONFIND_SHELL_STREAK && !st.shell_fired) {
296
+ // (3) generic spiral fallback — non-find streak, but nothing was re-checked
297
+ st.shell_fired = true;
298
+ // Hit-gated notebook pointer: if the notebook already has a note on what
299
+ // the agent is hunting, say so — that note may END the hunt.
300
+ const noteTitle = kbSpiralHit(input.cwd, grepPattern(cmd));
301
+ msgs.push([
302
+ 3,
303
+ `${st.nonfind_streak} non-find search/shell calls since your last \`coldstart find\`/Read — ` +
304
+ "this is the spiral. `coldstart find` already body-scans the top files and ranks on filenames " +
305
+ "and symbols, not just body text. Add the missing term and re-run it, or Read a candidate you " +
306
+ "already have. Reserve grep for a literal body string you KNOW exists." +
307
+ (noteTitle
308
+ ? ` Also: the repo notebook has a note matching what you're grepping for — "${noteTitle}". ` +
309
+ `Fetch it first: \`coldstart kb search ${noteTitle.split(/\s+/).slice(0, 5).join(" ")}\`.`
310
+ : ""),
311
+ ]);
312
+ }
313
+ }
314
+
315
+ // (2) empty search result — independent, capped
316
+ if (isSearch && outEmpty && st.empty_fired < EMPTY_NUDGE_CAP) {
317
+ st.empty_fired += 1;
318
+ msgs.push([
319
+ 4,
320
+ "That search returned nothing. An empty result only tells you the term doesn't appear here — it is " +
321
+ "not evidence the thing is absent. If you're grepping a specific FILE for an identifier, run " +
322
+ "`coldstart gs <file> --symbol <token>` instead: it returns the body lines where the token appears " +
323
+ "(it greps in-tool), so you don't have to guess at spelling.",
324
+ ]);
325
+ }
326
+
327
+ // record whether THIS gs call hit the menu fallback, so the NEXT call can detect a re-guess
328
+ if (isGs) {
329
+ st.last_gs_fallback = out && GS_FALLBACK_RE.test(out) ? gsFile : "";
330
+ }
331
+
332
+ // (4) checkpoint — periodic, lowest urgency
333
+ if (st.total - st.last_checkpoint >= CHECKPOINT_EVERY) {
334
+ st.last_checkpoint = st.total;
335
+ msgs.push([
336
+ 5,
337
+ `Checkpoint (${st.total} tool calls). Stop and think before the next call — answer these in order:\n` +
338
+ "1. Restate the task in one sentence — what is the ONE thing it asks for?\n" +
339
+ "2. List the files you have already surfaced or read that bear on it.\n" +
340
+ "3. Can you answer (1) from (2)? If YES — write the answer now and stop searching; you almost " +
341
+ "certainly have enough.\n" +
342
+ "4. Only if NO — name the single specific fact still missing, and make the next call target ONLY " +
343
+ "that. Do not re-run a search whose results are already in your context.",
344
+ ]);
345
+ }
346
+
347
+ // merge THIS call's evidence into the store (after novelty was computed above)
348
+ if (scope.size > 0) {
349
+ st.held_files = [...new Set([...held, ...scope])];
350
+ }
351
+
352
+ try {
353
+ const tmp = stateFile + ".tmp";
354
+ writeFileSync(tmp, JSON.stringify(st));
355
+ renameSync(tmp, stateFile);
356
+ } catch {
357
+ /* never fail on state write */
358
+ }
359
+
360
+ if (msgs.length) {
361
+ msgs.sort((a, b) => a[0] - b[0]);
362
+ return {
363
+ hookSpecificOutput: {
364
+ hookEventName: "PostToolUse",
365
+ additionalContext: msgs[0][1],
366
+ },
367
+ };
368
+ }
369
+ return null;
370
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * codex-preguard-handler.mjs — Codex PreToolUse guard for `coldstart find`.
3
+ *
4
+ * `coldstart find` is a PURE function of its term-SET: reordering terms, changing
5
+ * case, or repeating a term yields byte-identical output (every ranking stage is
6
+ * set/sum based, sort is deterministic by score then unique path). So an exact
7
+ * re-query is PROVABLY redundant on a static index: its result is already in the
8
+ * agent's context. This handler canonicalizes a PROPOSED find (lowercase → dedup
9
+ * → SORT terms, + significant flags) and, if that canonical key was already run
10
+ * SUCCESSFULLY this session, DENIES the call before it costs a generation.
11
+ *
12
+ * Safety:
13
+ * - Only EXACT term-set+flag matches are blocked. Add/drop a term, or change
14
+ * --path/--tests, and it's a different query that legitimately differs → allowed.
15
+ * - Registration happens in the PostToolUse hook (find-nudge) and ONLY on a
16
+ * successful, non-empty result — so retrying a FAILED/empty find is never blocked.
17
+ * - Flags are folded into the key, so a scoped re-query (`--path ...`) never
18
+ * collides with the unscoped one. Bias is toward specificity: a missed dup just
19
+ * falls back to the PostToolUse late-catch; a false block is the error we avoid.
20
+ * - Fail-open: any error → return null and the call proceeds (runHook swallows).
21
+ *
22
+ * State is shared with find-nudge: /tmp/find_nudge_{session}_{agent}.json,
23
+ * list key `seen_find_queries`.
24
+ *
25
+ * Ported 1:1 from find-preguard.py.
26
+ */
27
+
28
+ import { readFileSync } from "node:fs";
29
+ import { canonicalFindKey } from "./canonical-find-key.mjs";
30
+ import { normalizeColdstartCall } from "./coldstart-call.mjs";
31
+
32
+ const REASON =
33
+ "You have ALREADY run this exact `coldstart find` earlier in this session — same " +
34
+ "terms (order/case/repeats don't change the result), same scope. `find` is deterministic " +
35
+ "on a static index, so re-running it returns the IDENTICAL ranked page you already have in " +
36
+ "context. Re-read that earlier find result and answer, or search a GENUINELY different " +
37
+ "term-set (add/drop a salient identifier, or add `--path` to scope it). Do not re-run the " +
38
+ "same query.";
39
+
40
+ /**
41
+ * @param {any} input parsed PreToolUse stdin payload
42
+ * @returns {object|null} a permissionDecision:"deny" envelope, or null to allow
43
+ */
44
+ export default function handle(input) {
45
+ const tin = input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
46
+ // Normalize so a `mcp__coldstart__find` call is deduped against a CLI `coldstart
47
+ // find` (and vice-versa) using the same canonical key. Non-coldstart Bash falls
48
+ // through to canonicalFindKey, which returns null for anything that isn't a find.
49
+ const { tool, cmd } = normalizeColdstartCall(input.tool_name || "", tin);
50
+ if (tool !== "Bash") return null;
51
+ const key = canonicalFindKey(cmd);
52
+ if (!key) return null;
53
+
54
+ const sid = input.session_id || "default";
55
+ const aid = input.agent_id || "";
56
+ const skey = aid ? `${sid}_${aid}` : sid;
57
+ const stateFile = `/tmp/find_nudge_${skey}.json`;
58
+
59
+ let st = {};
60
+ try {
61
+ st = JSON.parse(readFileSync(stateFile, "utf8"));
62
+ } catch {
63
+ st = {};
64
+ }
65
+ const seen = Array.isArray(st.seen_find_queries) ? st.seen_find_queries : [];
66
+ if (!seen.includes(key)) return null; // never run before → allow
67
+
68
+ return {
69
+ hookSpecificOutput: {
70
+ hookEventName: "PreToolUse",
71
+ permissionDecision: "deny",
72
+ permissionDecisionReason: REASON,
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * codex-run-hook.mjs — crash-resilient wrapper for coldstart Codex hook entries.
4
+ *
5
+ * Adapted from context-mode's run-hook.mjs (MIT). The whole point: a coldstart
6
+ * hook must NEVER block a tool call or spam the user, no matter what breaks —
7
+ * a parse error, a missing dep, a malformed stdin payload, a thrown handler.
8
+ * Codex surfaces a non-zero hook exit as a hook error. A single bad deploy can
9
+ * therefore nag the user on every matching tool call.
10
+ * This wrapper guarantees exit 0 and fail-open (no output = no decision = the
11
+ * tool proceeds normally) on any failure.
12
+ *
13
+ * Three layers of protection:
14
+ * 1. This file imports ONLY node: built-ins, so it cannot fail at parse time.
15
+ * 2. process-level uncaughtException / unhandledRejection nets, installed
16
+ * before any handler runs, catch async throws the try/catch would miss.
17
+ * 3. The handler is invoked inside a try; any throw is logged and we exit 0.
18
+ * Load your real handler via dynamic import INSIDE the handler thunk so its
19
+ * own parse-time import errors are caught here too (see usage below).
20
+ *
21
+ * Failures log to <configDir>/coldstart/hook-errors.log, where configDir honors
22
+ * $CODEX_HOME (incl. a leading ~) and falls back to ~/.codex.
23
+ *
24
+ * Usage — keep the entry file's top-level imports to node: built-ins only:
25
+ * #!/usr/bin/env node
26
+ * import { runHook } from "./codex-run-hook.mjs";
27
+ * await runHook(async (input) => {
28
+ * const { default: handle } = await import("./codex-preguard-handler.mjs");
29
+ * return handle(input); // return an object → printed as JSON to stdout
30
+ * });
31
+ *
32
+ * The handler receives the parsed stdin payload (or {} if stdin was empty/bad)
33
+ * and may return:
34
+ * - undefined / null → no stdout (fail-open: tool proceeds)
35
+ * - a string → written to stdout verbatim
36
+ * - an object → JSON.stringify'd to stdout (e.g. a PreToolUse
37
+ * permissionDecision, or PostToolUse additionalContext)
38
+ */
39
+
40
+ import { homedir } from "node:os";
41
+ import { resolve, join } from "node:path";
42
+ import { existsSync, mkdirSync, appendFileSync } from "node:fs";
43
+
44
+ // Inlined so this wrapper stays dependency-free (parse-time-proof).
45
+ function resolveCodexConfigDir() {
46
+ const envVal = process.env.CODEX_HOME;
47
+ if (envVal) {
48
+ if (envVal.startsWith("~")) return join(homedir(), envVal.replace(/^~[/\\]?/, ""));
49
+ return envVal;
50
+ }
51
+ return resolve(homedir(), ".codex");
52
+ }
53
+
54
+ function logError(err) {
55
+ try {
56
+ const dir = resolve(resolveCodexConfigDir(), "coldstart");
57
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
58
+ const line = `[${new Date().toISOString()}] pid=${process.pid} ${err?.stack || err?.message || String(err)}\n`;
59
+ appendFileSync(resolve(dir, "hook-errors.log"), line);
60
+ } catch {
61
+ /* never fail logging */
62
+ }
63
+ }
64
+
65
+ // Safety nets BEFORE any handler code runs. Static top-level imports in the
66
+ // ENTRY file would bypass these, which is why the entry must dynamic-import its
67
+ // real handler from inside the thunk.
68
+ process.on("uncaughtException", (err) => {
69
+ logError(err);
70
+ process.exit(0);
71
+ });
72
+ process.on("unhandledRejection", (err) => {
73
+ logError(err);
74
+ process.exit(0);
75
+ });
76
+
77
+ /** Read all of stdin. Resolves to "" if stdin is closed/empty. */
78
+ function readStdin() {
79
+ return new Promise((res) => {
80
+ let data = "";
81
+ let settled = false;
82
+ const done = () => {
83
+ if (settled) return;
84
+ settled = true;
85
+ res(data);
86
+ };
87
+ try {
88
+ if (process.stdin.isTTY) return done();
89
+ process.stdin.setEncoding("utf8");
90
+ process.stdin.on("data", (c) => (data += c));
91
+ process.stdin.on("end", done);
92
+ process.stdin.on("error", done);
93
+ // Hard ceiling so a wedged pipe can never hang the tool call.
94
+ setTimeout(done, 2000).unref?.();
95
+ } catch {
96
+ done();
97
+ }
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Run a hook handler with full crash-resilience. Reads + parses stdin, invokes
103
+ * the handler, prints its return value, and ALWAYS exits 0.
104
+ *
105
+ * @param {(input: any) => Promise<unknown> | unknown} handler
106
+ */
107
+ export async function runHook(handler) {
108
+ let input = {};
109
+ try {
110
+ const raw = await readStdin();
111
+ if (raw && raw.trim()) input = JSON.parse(raw);
112
+ } catch (e) {
113
+ // Malformed/absent payload is non-fatal — hand the handler {} and let it
114
+ // decide. Most handlers no-op without the fields they need.
115
+ logError(e);
116
+ }
117
+
118
+ try {
119
+ const out = await handler(input);
120
+ if (out == null) {
121
+ // fail-open / no-op: emit nothing, tool proceeds normally.
122
+ } else if (typeof out === "string") {
123
+ process.stdout.write(out);
124
+ } else {
125
+ process.stdout.write(JSON.stringify(out));
126
+ }
127
+ } catch (e) {
128
+ logError(e);
129
+ // Swallow: no stdout means no hook decision → the tool is not blocked.
130
+ }
131
+ process.exit(0);
132
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * coldstart-call.mjs — normalize a tool call into the shape the detectors expect.
3
+ *
4
+ * coldstart is reachable two ways with IDENTICAL semantics:
5
+ * - CLI : Bash `coldstart find <terms>` / `coldstart gs <file> --symbol <s>`
6
+ * - MCP : tools `mcp__coldstart__find {query}` / `mcp__coldstart__gs {file_path,...}`
7
+ *
8
+ * The nudge/preguard detectors were written against the CLI surface: they key on
9
+ * `tool === "Bash"` and run regexes over the command STRING. Rather than fork the
10
+ * detector logic per surface, this normalizer rewrites an MCP coldstart call into
11
+ * the equivalent CLI command string and reports it as a Bash call. Every downstream
12
+ * regex (FIND_RE/GS_RE/GS_FILE_RE/SEARCH_RE) and the canonical-find-key then run
13
+ * UNCHANGED — same logic, regardless of which surface the agent used.
14
+ *
15
+ * Non-coldstart calls pass through untouched (tool name + `tool_input.command`).
16
+ *
17
+ * The MCP tool name is `mcp__<serverKey>__<tool>`; the benchmark wires coldstart
18
+ * under the server key `coldstart` (see the arm's `.mcp.json`). If you key it
19
+ * differently, change MCP_SERVER_KEY below — that is the only surface-specific knob.
20
+ */
21
+
22
+ const MCP_SERVER_KEY = "coldstart";
23
+ const MCP_FIND = `mcp__${MCP_SERVER_KEY}__find`;
24
+ const MCP_GS = `mcp__${MCP_SERVER_KEY}__gs`;
25
+
26
+ function str(v) {
27
+ return typeof v === "string" ? v : "";
28
+ }
29
+
30
+ /**
31
+ * @param {string} toolName input.tool_name
32
+ * @param {any} toolInput input.tool_input (already coerced to {} if missing)
33
+ * @returns {{ tool: string, cmd: string }}
34
+ * For an MCP coldstart find/gs call: { tool: "Bash", cmd: "<synthesized CLI command>" }.
35
+ * For anything else: { tool: <toolName>, cmd: <tool_input.command or ""> }.
36
+ */
37
+ export function normalizeColdstartCall(toolName, toolInput) {
38
+ const tin = toolInput && typeof toolInput === "object" ? toolInput : {};
39
+
40
+ if (toolName === MCP_FIND) {
41
+ // find accepts `query` (alias `domain_filter`); `path` scopes it (folded into the dedup key).
42
+ const query = str(tin.query) || str(tin.domain_filter);
43
+ const path = str(tin.path) ? ` --path ${str(tin.path)}` : "";
44
+ return { tool: "Bash", cmd: `coldstart find ${query}${path}`.trim() };
45
+ }
46
+
47
+ if (toolName === MCP_GS) {
48
+ // gs needs the file as the first token after `gs` (GS_FILE_RE), plus the flags
49
+ // that the detectors look at (--symbol drives the slice/re-guess detectors).
50
+ const file = str(tin.file_path) || str(tin.file) || str(tin.file_name);
51
+ const symbol = str(tin.symbol) ? ` --symbol ${str(tin.symbol)}` : "";
52
+ const match = str(tin.match) ? ` --match ${str(tin.match)}` : "";
53
+ const view = str(tin.view) ? ` --view ${str(tin.view)}` : "";
54
+ return { tool: "Bash", cmd: `coldstart gs ${file}${symbol}${match}${view}`.trim() };
55
+ }
56
+
57
+ return { tool: toolName || "", cmd: str(tin.command) };
58
+ }
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cursor-find-nudge.mjs — Cursor postToolUse entry. Emits search nudges + registers
4
+ * successful find keys for the preToolUse guard.
5
+ * Wire as: node <abs>/cursor-find-nudge.mjs (hook: postToolUse)
6
+ *
7
+ * Top-level imports stay node:-builtin-only (via run-hook); the real handler is
8
+ * dynamic-imported inside the thunk so its parse-time errors are caught + fail-open.
9
+ */
10
+ import { runHook } from "./cursor-run-hook.mjs";
11
+
12
+ await runHook(async (input) => {
13
+ const { default: handle } = await import("./cursor-nudge-handler.mjs");
14
+ return handle(input);
15
+ });
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cursor-find-preguard.mjs — Cursor preToolUse entry for exact find re-runs.
4
+ * Wire as: node <abs>/cursor-find-preguard.mjs (hook: preToolUse)
5
+ *
6
+ * Top-level imports stay node:-builtin-only (via run-hook); the real handler is
7
+ * dynamic-imported inside the thunk so its parse-time errors are caught + fail-open.
8
+ */
9
+ import { runHook } from "./cursor-run-hook.mjs";
10
+
11
+ await runHook(async (input) => {
12
+ const { default: handle } = await import("./cursor-preguard-handler.mjs");
13
+ return handle(input);
14
+ });