@smeltjs/core 0.2.0 → 0.4.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 (348) hide show
  1. package/README.md +3 -3
  2. package/dist/agents/guide.d.ts +68 -0
  3. package/dist/agents/guide.d.ts.map +1 -0
  4. package/dist/agents/guide.js +72 -0
  5. package/dist/agents/guide.js.map +1 -0
  6. package/dist/agents/instructions.d.ts +133 -0
  7. package/dist/agents/instructions.d.ts.map +1 -0
  8. package/dist/agents/instructions.js +212 -0
  9. package/dist/agents/instructions.js.map +1 -0
  10. package/dist/agents/lint.d.ts +154 -0
  11. package/dist/agents/lint.d.ts.map +1 -0
  12. package/dist/agents/lint.js +761 -0
  13. package/dist/agents/lint.js.map +1 -0
  14. package/dist/agents/split.d.ts +113 -0
  15. package/dist/agents/split.d.ts.map +1 -0
  16. package/dist/agents/split.js +234 -0
  17. package/dist/agents/split.js.map +1 -0
  18. package/dist/apply.d.ts.map +1 -1
  19. package/dist/apply.js.map +1 -1
  20. package/dist/cache/prefix.d.ts.map +1 -1
  21. package/dist/cache/prefix.js.map +1 -1
  22. package/dist/cli/agents.d.ts +39 -0
  23. package/dist/cli/agents.d.ts.map +1 -0
  24. package/dist/cli/agents.js +121 -0
  25. package/dist/cli/agents.js.map +1 -0
  26. package/dist/cli/args.d.ts +17 -70
  27. package/dist/cli/args.d.ts.map +1 -1
  28. package/dist/cli/args.js +18 -338
  29. package/dist/cli/args.js.map +1 -1
  30. package/dist/cli/bin.d.ts.map +1 -1
  31. package/dist/cli/bin.js.map +1 -1
  32. package/dist/cli/config.d.ts +60 -0
  33. package/dist/cli/config.d.ts.map +1 -1
  34. package/dist/cli/config.js +89 -2
  35. package/dist/cli/config.js.map +1 -1
  36. package/dist/cli/hooks.d.ts +55 -51
  37. package/dist/cli/hooks.d.ts.map +1 -1
  38. package/dist/cli/hooks.js +206 -658
  39. package/dist/cli/hooks.js.map +1 -1
  40. package/dist/cli/init.d.ts +30 -15
  41. package/dist/cli/init.d.ts.map +1 -1
  42. package/dist/cli/init.js +167 -38
  43. package/dist/cli/init.js.map +1 -1
  44. package/dist/cli/report.d.ts +23 -0
  45. package/dist/cli/report.d.ts.map +1 -1
  46. package/dist/cli/report.js +142 -4
  47. package/dist/cli/report.js.map +1 -1
  48. package/dist/cli/run.d.ts +29 -90
  49. package/dist/cli/run.d.ts.map +1 -1
  50. package/dist/cli/run.js +34 -342
  51. package/dist/cli/run.js.map +1 -1
  52. package/dist/cli/shell.d.ts +106 -0
  53. package/dist/cli/shell.d.ts.map +1 -0
  54. package/dist/cli/shell.js +100 -0
  55. package/dist/cli/shell.js.map +1 -0
  56. package/dist/cli/subcommands/agents.d.ts +72 -0
  57. package/dist/cli/subcommands/agents.d.ts.map +1 -0
  58. package/dist/cli/subcommands/agents.js +162 -0
  59. package/dist/cli/subcommands/agents.js.map +1 -0
  60. package/dist/cli/subcommands/flags.d.ts +144 -0
  61. package/dist/cli/subcommands/flags.d.ts.map +1 -0
  62. package/dist/cli/subcommands/flags.js +220 -0
  63. package/dist/cli/subcommands/flags.js.map +1 -0
  64. package/dist/cli/subcommands/hooks.d.ts +18 -0
  65. package/dist/cli/subcommands/hooks.d.ts.map +1 -0
  66. package/dist/cli/subcommands/hooks.js +80 -0
  67. package/dist/cli/subcommands/hooks.js.map +1 -0
  68. package/dist/cli/subcommands/init.d.ts +19 -0
  69. package/dist/cli/subcommands/init.d.ts.map +1 -0
  70. package/dist/cli/subcommands/init.js +44 -0
  71. package/dist/cli/subcommands/init.js.map +1 -0
  72. package/dist/cli/subcommands/map.d.ts +75 -0
  73. package/dist/cli/subcommands/map.d.ts.map +1 -0
  74. package/dist/cli/subcommands/map.js +132 -0
  75. package/dist/cli/subcommands/map.js.map +1 -0
  76. package/dist/cli/subcommands/registry.d.ts +81 -0
  77. package/dist/cli/subcommands/registry.d.ts.map +1 -0
  78. package/dist/cli/subcommands/registry.js +141 -0
  79. package/dist/cli/subcommands/registry.js.map +1 -0
  80. package/dist/cli/subcommands/retrieve.d.ts +74 -0
  81. package/dist/cli/subcommands/retrieve.d.ts.map +1 -0
  82. package/dist/cli/subcommands/retrieve.js +84 -0
  83. package/dist/cli/subcommands/retrieve.js.map +1 -0
  84. package/dist/cli/subcommands/smelt.d.ts +107 -0
  85. package/dist/cli/subcommands/smelt.d.ts.map +1 -0
  86. package/dist/cli/subcommands/smelt.js +304 -0
  87. package/dist/cli/subcommands/smelt.js.map +1 -0
  88. package/dist/cli/subcommands/stats.d.ts +43 -0
  89. package/dist/cli/subcommands/stats.d.ts.map +1 -0
  90. package/dist/cli/subcommands/stats.js +62 -0
  91. package/dist/cli/subcommands/stats.js.map +1 -0
  92. package/dist/cli/subcommands/subcommand.d.ts +113 -0
  93. package/dist/cli/subcommands/subcommand.d.ts.map +1 -0
  94. package/dist/cli/subcommands/subcommand.js +3 -0
  95. package/dist/cli/subcommands/subcommand.js.map +1 -0
  96. package/dist/cli/usage.d.ts +3 -0
  97. package/dist/cli/usage.d.ts.map +1 -0
  98. package/dist/cli/usage.js +95 -0
  99. package/dist/cli/usage.js.map +1 -0
  100. package/dist/detect.d.ts.map +1 -1
  101. package/dist/detect.js.map +1 -1
  102. package/dist/errors.d.ts +21 -1
  103. package/dist/errors.d.ts.map +1 -1
  104. package/dist/errors.js +33 -1
  105. package/dist/errors.js.map +1 -1
  106. package/dist/harness/aider.d.ts +10 -0
  107. package/dist/harness/aider.d.ts.map +1 -0
  108. package/dist/harness/aider.js +21 -0
  109. package/dist/harness/aider.js.map +1 -0
  110. package/dist/harness/claude-code.d.ts +3 -0
  111. package/dist/harness/claude-code.d.ts.map +1 -0
  112. package/dist/harness/claude-code.js +68 -0
  113. package/dist/harness/claude-code.js.map +1 -0
  114. package/dist/harness/cline.d.ts +3 -0
  115. package/dist/harness/cline.d.ts.map +1 -0
  116. package/dist/harness/cline.js +54 -0
  117. package/dist/harness/cline.js.map +1 -0
  118. package/dist/harness/codex.d.ts +3 -0
  119. package/dist/harness/codex.d.ts.map +1 -0
  120. package/dist/harness/codex.js +92 -0
  121. package/dist/harness/codex.js.map +1 -0
  122. package/dist/harness/cursor.d.ts +3 -0
  123. package/dist/harness/cursor.d.ts.map +1 -0
  124. package/dist/harness/cursor.js +54 -0
  125. package/dist/harness/cursor.js.map +1 -0
  126. package/dist/harness/gemini.d.ts +3 -0
  127. package/dist/harness/gemini.d.ts.map +1 -0
  128. package/dist/harness/gemini.js +60 -0
  129. package/dist/harness/gemini.js.map +1 -0
  130. package/dist/harness/grok.d.ts +3 -0
  131. package/dist/harness/grok.d.ts.map +1 -0
  132. package/dist/harness/grok.js +47 -0
  133. package/dist/harness/grok.js.map +1 -0
  134. package/dist/harness/hermes.d.ts +3 -0
  135. package/dist/harness/hermes.d.ts.map +1 -0
  136. package/dist/harness/hermes.js +72 -0
  137. package/dist/harness/hermes.js.map +1 -0
  138. package/dist/harness/kilocode.d.ts +3 -0
  139. package/dist/harness/kilocode.d.ts.map +1 -0
  140. package/dist/harness/kilocode.js +35 -0
  141. package/dist/harness/kilocode.js.map +1 -0
  142. package/dist/harness/opencode.d.ts +3 -0
  143. package/dist/harness/opencode.d.ts.map +1 -0
  144. package/dist/harness/opencode.js +97 -0
  145. package/dist/harness/opencode.js.map +1 -0
  146. package/dist/harness/paths.d.ts +17 -0
  147. package/dist/harness/paths.d.ts.map +1 -0
  148. package/dist/harness/paths.js +45 -0
  149. package/dist/harness/paths.js.map +1 -0
  150. package/dist/harness/profile.d.ts +209 -0
  151. package/dist/harness/profile.d.ts.map +1 -0
  152. package/dist/harness/profile.js +41 -0
  153. package/dist/harness/profile.js.map +1 -0
  154. package/dist/harness/registry.d.ts +100 -0
  155. package/dist/harness/registry.d.ts.map +1 -0
  156. package/dist/harness/registry.js +144 -0
  157. package/dist/harness/registry.js.map +1 -0
  158. package/dist/harness/snippet.d.ts +19 -0
  159. package/dist/harness/snippet.d.ts.map +1 -0
  160. package/dist/harness/snippet.js +41 -0
  161. package/dist/harness/snippet.js.map +1 -0
  162. package/dist/hash.d.ts.map +1 -1
  163. package/dist/hash.js.map +1 -1
  164. package/dist/hooks/guard-core.d.ts +14 -14
  165. package/dist/hooks/guard-core.d.ts.map +1 -1
  166. package/dist/hooks/guard-core.js +15 -37
  167. package/dist/hooks/guard-core.js.map +1 -1
  168. package/dist/hooks/shim.d.ts +95 -4
  169. package/dist/hooks/shim.d.ts.map +1 -1
  170. package/dist/hooks/shim.js +87 -2
  171. package/dist/hooks/shim.js.map +1 -1
  172. package/dist/hooks/shims/claude-code.d.ts +5 -18
  173. package/dist/hooks/shims/claude-code.d.ts.map +1 -1
  174. package/dist/hooks/shims/claude-code.js +9 -56
  175. package/dist/hooks/shims/claude-code.js.map +1 -1
  176. package/dist/hooks/shims/cline.d.ts +5 -12
  177. package/dist/hooks/shims/cline.d.ts.map +1 -1
  178. package/dist/hooks/shims/cline.js +9 -34
  179. package/dist/hooks/shims/cline.js.map +1 -1
  180. package/dist/hooks/shims/codex.d.ts +5 -18
  181. package/dist/hooks/shims/codex.d.ts.map +1 -1
  182. package/dist/hooks/shims/codex.js +9 -51
  183. package/dist/hooks/shims/codex.js.map +1 -1
  184. package/dist/hooks/shims/cursor.d.ts +5 -14
  185. package/dist/hooks/shims/cursor.d.ts.map +1 -1
  186. package/dist/hooks/shims/cursor.js +9 -42
  187. package/dist/hooks/shims/cursor.js.map +1 -1
  188. package/dist/hooks/shims/gemini.d.ts +5 -18
  189. package/dist/hooks/shims/gemini.d.ts.map +1 -1
  190. package/dist/hooks/shims/gemini.js +9 -48
  191. package/dist/hooks/shims/gemini.js.map +1 -1
  192. package/dist/hooks/shims/grok.d.ts +5 -13
  193. package/dist/hooks/shims/grok.d.ts.map +1 -1
  194. package/dist/hooks/shims/grok.js +9 -32
  195. package/dist/hooks/shims/grok.js.map +1 -1
  196. package/dist/hooks/shims/hermes.d.ts +5 -17
  197. package/dist/hooks/shims/hermes.d.ts.map +1 -1
  198. package/dist/hooks/shims/hermes.js +9 -45
  199. package/dist/hooks/shims/hermes.js.map +1 -1
  200. package/dist/index.d.ts +43 -76
  201. package/dist/index.d.ts.map +1 -1
  202. package/dist/index.js +21 -67
  203. package/dist/index.js.map +1 -1
  204. package/dist/lang/bash.d.ts.map +1 -1
  205. package/dist/lang/bash.js.map +1 -1
  206. package/dist/lang/c.d.ts.map +1 -1
  207. package/dist/lang/c.js.map +1 -1
  208. package/dist/lang/c_sharp.d.ts.map +1 -1
  209. package/dist/lang/c_sharp.js.map +1 -1
  210. package/dist/lang/cpp.d.ts.map +1 -1
  211. package/dist/lang/cpp.js.map +1 -1
  212. package/dist/lang/go.d.ts.map +1 -1
  213. package/dist/lang/go.js.map +1 -1
  214. package/dist/lang/java.d.ts.map +1 -1
  215. package/dist/lang/java.js.map +1 -1
  216. package/dist/lang/javascript.d.ts.map +1 -1
  217. package/dist/lang/javascript.js.map +1 -1
  218. package/dist/lang/kotlin.d.ts.map +1 -1
  219. package/dist/lang/kotlin.js.map +1 -1
  220. package/dist/lang/php.d.ts.map +1 -1
  221. package/dist/lang/php.js.map +1 -1
  222. package/dist/lang/profile.d.ts.map +1 -1
  223. package/dist/lang/profile.js.map +1 -1
  224. package/dist/lang/python.d.ts.map +1 -1
  225. package/dist/lang/python.js.map +1 -1
  226. package/dist/lang/registry.d.ts.map +1 -1
  227. package/dist/lang/registry.js.map +1 -1
  228. package/dist/lang/ruby.d.ts.map +1 -1
  229. package/dist/lang/ruby.js.map +1 -1
  230. package/dist/lang/rust.d.ts.map +1 -1
  231. package/dist/lang/rust.js.map +1 -1
  232. package/dist/lang/swift.d.ts.map +1 -1
  233. package/dist/lang/swift.js.map +1 -1
  234. package/dist/lang/tsx.d.ts.map +1 -1
  235. package/dist/lang/tsx.js.map +1 -1
  236. package/dist/lang/typescript.d.ts.map +1 -1
  237. package/dist/lang/typescript.js.map +1 -1
  238. package/dist/net/policy.d.ts +18 -1
  239. package/dist/net/policy.d.ts.map +1 -1
  240. package/dist/net/policy.js +6 -4
  241. package/dist/net/policy.js.map +1 -1
  242. package/dist/ops/index.d.ts +39 -0
  243. package/dist/ops/index.d.ts.map +1 -0
  244. package/dist/ops/index.js +37 -0
  245. package/dist/ops/index.js.map +1 -0
  246. package/dist/ops/inputs.d.ts +170 -0
  247. package/dist/ops/inputs.d.ts.map +1 -0
  248. package/dist/ops/inputs.js +142 -0
  249. package/dist/ops/inputs.js.map +1 -0
  250. package/dist/ops/verbs.d.ts +136 -0
  251. package/dist/ops/verbs.d.ts.map +1 -0
  252. package/dist/ops/verbs.js +76 -0
  253. package/dist/ops/verbs.js.map +1 -0
  254. package/dist/plan/auto.d.ts +69 -0
  255. package/dist/plan/auto.d.ts.map +1 -0
  256. package/dist/plan/auto.js +70 -0
  257. package/dist/plan/auto.js.map +1 -0
  258. package/dist/plan/budget.d.ts +21 -0
  259. package/dist/plan/budget.d.ts.map +1 -0
  260. package/dist/plan/budget.js +26 -0
  261. package/dist/plan/budget.js.map +1 -0
  262. package/dist/plan/grammar.d.ts +14 -0
  263. package/dist/plan/grammar.d.ts.map +1 -1
  264. package/dist/plan/grammar.js +47 -6
  265. package/dist/plan/grammar.js.map +1 -1
  266. package/dist/plan/lexical.d.ts.map +1 -1
  267. package/dist/plan/lexical.js +1 -7
  268. package/dist/plan/lexical.js.map +1 -1
  269. package/dist/plan/planners.d.ts +35 -2
  270. package/dist/plan/planners.d.ts.map +1 -1
  271. package/dist/plan/planners.js +35 -2
  272. package/dist/plan/planners.js.map +1 -1
  273. package/dist/plan/structural.d.ts +19 -5
  274. package/dist/plan/structural.d.ts.map +1 -1
  275. package/dist/plan/structural.js +180 -33
  276. package/dist/plan/structural.js.map +1 -1
  277. package/dist/repomap/cache.d.ts +48 -0
  278. package/dist/repomap/cache.d.ts.map +1 -1
  279. package/dist/repomap/cache.js +112 -13
  280. package/dist/repomap/cache.js.map +1 -1
  281. package/dist/repomap/io.d.ts +24 -0
  282. package/dist/repomap/io.d.ts.map +1 -0
  283. package/dist/repomap/io.js +34 -0
  284. package/dist/repomap/io.js.map +1 -0
  285. package/dist/repomap/map.d.ts +69 -6
  286. package/dist/repomap/map.d.ts.map +1 -1
  287. package/dist/repomap/map.js +104 -22
  288. package/dist/repomap/map.js.map +1 -1
  289. package/dist/repomap/rank.d.ts +26 -2
  290. package/dist/repomap/rank.d.ts.map +1 -1
  291. package/dist/repomap/rank.js +20 -0
  292. package/dist/repomap/rank.js.map +1 -1
  293. package/dist/repomap/reader.d.ts +73 -0
  294. package/dist/repomap/reader.d.ts.map +1 -0
  295. package/dist/repomap/reader.js +30 -0
  296. package/dist/repomap/reader.js.map +1 -0
  297. package/dist/repomap/tags.d.ts.map +1 -1
  298. package/dist/repomap/tags.js.map +1 -1
  299. package/dist/retrieve.d.ts +8 -0
  300. package/dist/retrieve.d.ts.map +1 -1
  301. package/dist/retrieve.js +12 -1
  302. package/dist/retrieve.js.map +1 -1
  303. package/dist/smelter.d.ts +79 -0
  304. package/dist/smelter.d.ts.map +1 -0
  305. package/dist/smelter.js +65 -0
  306. package/dist/smelter.js.map +1 -0
  307. package/dist/stages.d.ts.map +1 -1
  308. package/dist/stages.js +2 -2
  309. package/dist/stages.js.map +1 -1
  310. package/dist/stats.d.ts +2 -1
  311. package/dist/stats.d.ts.map +1 -1
  312. package/dist/stats.js +2 -1
  313. package/dist/stats.js.map +1 -1
  314. package/dist/store-dir.d.ts +43 -4
  315. package/dist/store-dir.d.ts.map +1 -1
  316. package/dist/store-dir.js +48 -5
  317. package/dist/store-dir.js.map +1 -1
  318. package/dist/store.d.ts +6 -0
  319. package/dist/store.d.ts.map +1 -1
  320. package/dist/store.js +6 -0
  321. package/dist/store.js.map +1 -1
  322. package/dist/text/json-edit.d.ts +71 -0
  323. package/dist/text/json-edit.d.ts.map +1 -0
  324. package/dist/text/json-edit.js +219 -0
  325. package/dist/text/json-edit.js.map +1 -0
  326. package/dist/types.d.ts +35 -14
  327. package/dist/types.d.ts.map +1 -1
  328. package/dist/types.js.map +1 -1
  329. package/grammars/tree-sitter-bash.wasm +0 -0
  330. package/grammars/tree-sitter-c.wasm +0 -0
  331. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  332. package/grammars/tree-sitter-cpp.wasm +0 -0
  333. package/grammars/tree-sitter-go.wasm +0 -0
  334. package/grammars/tree-sitter-java.wasm +0 -0
  335. package/grammars/tree-sitter-javascript.wasm +0 -0
  336. package/grammars/tree-sitter-kotlin.wasm +0 -0
  337. package/grammars/tree-sitter-php.wasm +0 -0
  338. package/grammars/tree-sitter-python.wasm +0 -0
  339. package/grammars/tree-sitter-ruby.wasm +0 -0
  340. package/grammars/tree-sitter-rust.wasm +0 -0
  341. package/grammars/tree-sitter-swift.wasm +0 -0
  342. package/grammars/tree-sitter-tsx.wasm +0 -0
  343. package/grammars/tree-sitter-typescript.wasm +0 -0
  344. package/package.json +11 -11
  345. package/dist/cli/resolve.d.ts +0 -103
  346. package/dist/cli/resolve.d.ts.map +0 -1
  347. package/dist/cli/resolve.js +0 -106
  348. package/dist/cli/resolve.js.map +0 -1
@@ -0,0 +1,100 @@
1
+ import type { HarnessId, HarnessProfile, HarnessTier } from './profile.ts';
2
+ /**
3
+ * The registry — every harness smelt knows, one {@link HarnessProfile} each.
4
+ *
5
+ * `Record<HarnessId, HarnessProfile>` on purpose: adding a `HarnessId` in `profile.ts`
6
+ * without writing its profile is a compile error, so the id list and the facts cannot
7
+ * drift. Every derived view — the `--harness` help list, the wizard's table, the
8
+ * managed event names, the files a re-run reads its toggles back from — is computed
9
+ * from this object, never written twice.
10
+ *
11
+ * This module imports **nothing from `cli/`**. That is the point: the registry used to
12
+ * live in `cli/hooks.ts`, which imports `CLI_NAME` from `cli/args.ts`, so `args.ts`
13
+ * could not import it back and the `--harness` help list was hand-typed five lines
14
+ * below two lists that were correctly derived.
15
+ *
16
+ * Key order is meaningful: it is the order every rendered harness list uses (the help
17
+ * text, the wizard's table, the "Known: …" of an unknown-harness error), so keep it
18
+ * stable and append new harnesses at the end.
19
+ */
20
+ export declare const HARNESS_PROFILES: Readonly<Record<HarnessId, HarnessProfile>>;
21
+ /** Every profile, in registry order. The list every rendered table walks. */
22
+ export declare const HARNESSES: readonly HarnessProfile[];
23
+ /** Every harness id, in registry order — the `--harness` help list, derived. */
24
+ export declare const HARNESS_IDS: readonly HarnessId[];
25
+ /** The profile for a harness id, or `undefined` for a string the user made up. */
26
+ export declare function harnessById(id: string): HarnessProfile | undefined;
27
+ /** One tier and the harnesses that ship at it — the row every tier table renders. */
28
+ export interface HarnessTierGroup {
29
+ readonly tier: HarnessTier;
30
+ /** That tier's one line of honesty ({@link TIER_HONESTY}), carried with the group. */
31
+ readonly honesty: string;
32
+ /** The profiles at this tier, in registry order. */
33
+ readonly harnesses: readonly HarnessProfile[];
34
+ }
35
+ /**
36
+ * The tier → harnesses grouping, derived — `HarnessProfile.tier` is data, and this is
37
+ * the one fold over it.
38
+ *
39
+ * It was hand-typed in five places (the `hooks` help body, two wizard sentences, the
40
+ * README's tier table and the site's), so a harness promoted from experimental to
41
+ * verified stayed listed under the old tier in four of them and nothing failed: the
42
+ * only check was that each harness's name appeared *somewhere* in the help. Every
43
+ * rendered grouping now walks this, and `test/guards/harness-registry.test.ts` asserts
44
+ * the rendered ones agree with it — each name under its own tier clause and no other.
45
+ *
46
+ * Tier order is {@link HARNESS_TIERS} (i.e. `TIER_HONESTY`'s key order); a tier no
47
+ * profile claims yields no group, because a rendered "advisory ()" is worse than a
48
+ * tier that has stopped existing.
49
+ */
50
+ export declare function harnessesByTier(): readonly HarnessTierGroup[];
51
+ /**
52
+ * Whether this harness's native schema carries the session-lifecycle events this preset
53
+ * offers — `smelt stats` on Stop, `smelt map` on SessionStart — i.e. whether it declares
54
+ * a JSON hook step with `lifecycle`.
55
+ *
56
+ * The wizard used to call these "verified-tier harnesses (Claude Code, Codex)", which
57
+ * is a tier standing in for a capability it merely correlates with: the tier is how much
58
+ * smelt is willing to *claim* about a harness, `lifecycle` is whether the harness has
59
+ * the events at all. The two sets coincide today, which is precisely why the sentence
60
+ * read correctly and why nothing could notice it was wrong — so the guard asks this
61
+ * predicate about a profile whose tier and capability *disagree*, and the mutation runner
62
+ * swaps one for the other and watches it go red.
63
+ */
64
+ export declare function wiresLifecycle(profile: HarnessProfile): boolean;
65
+ /** The harnesses the stats/map toggles actually wire, in registry order. */
66
+ export declare function lifecycleHarnesses(): readonly HarnessProfile[];
67
+ /** A list of harnesses as prose spells them — the one join, so the four lists agree. */
68
+ export declare function harnessNames(profiles: readonly HarnessProfile[]): string;
69
+ /**
70
+ * The session-lifecycle hooks this preset offers, named in each harness's schema:
71
+ * `smelt stats` when a turn ends, and — sharing one `SessionStart` event — an opening
72
+ * `smelt map` and a lint of the repository's own instruction files. Only the harnesses
73
+ * whose schema carries these events wire them (`step.lifecycle`), and `cli/hooks.ts`
74
+ * writes the entries under these exact keys — one spelling, so the managed-event list
75
+ * below cannot fall behind what the installer writes.
76
+ *
77
+ * `map` and `lint` deliberately name the **same** event. They are two independent
78
+ * toggles under one key, which is why `cli/hooks.ts` merges their entries into one
79
+ * array rather than spreading two objects (the second would silently replace the
80
+ * first) and why a re-run tells them apart by the command each entry runs.
81
+ */
82
+ export declare const LIFECYCLE_EVENTS: {
83
+ readonly stats: 'Stop';
84
+ readonly map: 'SessionStart';
85
+ readonly lint: 'SessionStart';
86
+ };
87
+ /**
88
+ * The events this installer manages, across every harness's spelling of them —
89
+ * foreign entries under them are always preserved, and events nobody claims are never
90
+ * touched. Derived, so a harness that spells its pre-tool event a new way is managed
91
+ * by existing.
92
+ */
93
+ export declare const MANAGED_EVENTS: readonly string[];
94
+ /** The managed events that wire the PreToolUse guard, across harness spellings. */
95
+ export declare const GUARD_EVENTS: readonly string[];
96
+ /** The JSON hook files a re-run reads installed toggles back from, per harness. */
97
+ export declare const JSON_HOOK_FILES: readonly string[];
98
+ /** Guard-only files whose presence means the guard toggle was installed. */
99
+ export declare const GUARD_ONLY_FILES: readonly string[];
100
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/harness/registry.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,SAAS,EAAoB,cAAc,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAE7F;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,gBAAgB,EAAE,QAAQ,CAAC,MAAM,CAAC,SAAS,EAAE,cAAc,CAAC,CAWxE,CAAC;AAEF,6EAA6E;AAC7E,eAAO,MAAM,SAAS,EAAE,SAAS,cAAc,EAAoC,CAAC;AAEpF,gFAAgF;AAChF,eAAO,MAAM,WAAW,EAAE,SAAS,SAAS,EAA2C,CAAC;AAExF,kFAAkF;AAClF,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAElE;AAED,qFAAqF;AACrF,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,sFAAsF;IACtF,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,oDAAoD;IACpD,QAAQ,CAAC,SAAS,EAAE,SAAS,cAAc,EAAE,CAAC;CAC/C;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,IAAI,SAAS,gBAAgB,EAAE,CAM7D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAE/D;AAED,4EAA4E;AAC5E,wBAAgB,kBAAkB,IAAI,SAAS,cAAc,EAAE,CAE9D;AAED,wFAAwF;AACxF,wBAAgB,YAAY,CAAC,QAAQ,EAAE,SAAS,cAAc,EAAE,GAAG,MAAM,CAExE;AASD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB;aAC3B,KAAK,EAAE,MAAM;aACb,GAAG,EAAE,cAAc;aACnB,IAAI,EAAE,cAAc;CACZ,CAAC;AAEX;;;;;GAKG;AACH,eAAO,MAAM,cAAc,EAAE,SAAS,MAAM,EAO3C,CAAC;AAEF,mFAAmF;AACnF,eAAO,MAAM,YAAY,EAAE,SAAS,MAAM,EAEzC,CAAC;AAEF,mFAAmF;AACnF,eAAO,MAAM,eAAe,EAAE,SAAS,MAAM,EAE5C,CAAC;AAEF,4EAA4E;AAC5E,eAAO,MAAM,gBAAgB,EAAE,SAAS,MAAM,EAQ7C,CAAC","sourcesContent":["import { aider } from './aider.ts';\nimport { claudeCode } from './claude-code.ts';\nimport { cline } from './cline.ts';\nimport { codex } from './codex.ts';\nimport { cursor } from './cursor.ts';\nimport { gemini } from './gemini.ts';\nimport { grok } from './grok.ts';\nimport { hermes } from './hermes.ts';\nimport { kilocode } from './kilocode.ts';\nimport { opencode } from './opencode.ts';\nimport { harnessLabel, HARNESS_TIERS, TIER_HONESTY } from './profile.ts';\nimport type { HarnessId, HarnessJsonHooks, HarnessProfile, HarnessTier } from './profile.ts';\n\n/**\n * The registry — every harness smelt knows, one {@link HarnessProfile} each.\n *\n * `Record<HarnessId, HarnessProfile>` on purpose: adding a `HarnessId` in `profile.ts`\n * without writing its profile is a compile error, so the id list and the facts cannot\n * drift. Every derived view — the `--harness` help list, the wizard's table, the\n * managed event names, the files a re-run reads its toggles back from — is computed\n * from this object, never written twice.\n *\n * This module imports **nothing from `cli/`**. That is the point: the registry used to\n * live in `cli/hooks.ts`, which imports `CLI_NAME` from `cli/args.ts`, so `args.ts`\n * could not import it back and the `--harness` help list was hand-typed five lines\n * below two lists that were correctly derived.\n *\n * Key order is meaningful: it is the order every rendered harness list uses (the help\n * text, the wizard's table, the \"Known: …\" of an unknown-harness error), so keep it\n * stable and append new harnesses at the end.\n */\nexport const HARNESS_PROFILES: Readonly<Record<HarnessId, HarnessProfile>> = {\n 'claude-code': claudeCode,\n codex,\n gemini,\n grok,\n hermes,\n cursor,\n opencode,\n cline,\n kilocode,\n aider,\n};\n\n/** Every profile, in registry order. The list every rendered table walks. */\nexport const HARNESSES: readonly HarnessProfile[] = Object.values(HARNESS_PROFILES);\n\n/** Every harness id, in registry order — the `--harness` help list, derived. */\nexport const HARNESS_IDS: readonly HarnessId[] = HARNESSES.map((profile) => profile.id);\n\n/** The profile for a harness id, or `undefined` for a string the user made up. */\nexport function harnessById(id: string): HarnessProfile | undefined {\n return HARNESSES.find((profile) => profile.id === id);\n}\n\n/** One tier and the harnesses that ship at it — the row every tier table renders. */\nexport interface HarnessTierGroup {\n readonly tier: HarnessTier;\n /** That tier's one line of honesty ({@link TIER_HONESTY}), carried with the group. */\n readonly honesty: string;\n /** The profiles at this tier, in registry order. */\n readonly harnesses: readonly HarnessProfile[];\n}\n\n/**\n * The tier → harnesses grouping, derived — `HarnessProfile.tier` is data, and this is\n * the one fold over it.\n *\n * It was hand-typed in five places (the `hooks` help body, two wizard sentences, the\n * README's tier table and the site's), so a harness promoted from experimental to\n * verified stayed listed under the old tier in four of them and nothing failed: the\n * only check was that each harness's name appeared *somewhere* in the help. Every\n * rendered grouping now walks this, and `test/guards/harness-registry.test.ts` asserts\n * the rendered ones agree with it — each name under its own tier clause and no other.\n *\n * Tier order is {@link HARNESS_TIERS} (i.e. `TIER_HONESTY`'s key order); a tier no\n * profile claims yields no group, because a rendered \"advisory ()\" is worse than a\n * tier that has stopped existing.\n */\nexport function harnessesByTier(): readonly HarnessTierGroup[] {\n return HARNESS_TIERS.map((tier) => ({\n tier,\n honesty: TIER_HONESTY[tier],\n harnesses: HARNESSES.filter((profile) => profile.tier === tier),\n })).filter((group) => group.harnesses.length > 0);\n}\n\n/**\n * Whether this harness's native schema carries the session-lifecycle events this preset\n * offers — `smelt stats` on Stop, `smelt map` on SessionStart — i.e. whether it declares\n * a JSON hook step with `lifecycle`.\n *\n * The wizard used to call these \"verified-tier harnesses (Claude Code, Codex)\", which\n * is a tier standing in for a capability it merely correlates with: the tier is how much\n * smelt is willing to *claim* about a harness, `lifecycle` is whether the harness has\n * the events at all. The two sets coincide today, which is precisely why the sentence\n * read correctly and why nothing could notice it was wrong — so the guard asks this\n * predicate about a profile whose tier and capability *disagree*, and the mutation runner\n * swaps one for the other and watches it go red.\n */\nexport function wiresLifecycle(profile: HarnessProfile): boolean {\n return profile.install.some((step) => step.kind === 'json-hooks' && step.lifecycle);\n}\n\n/** The harnesses the stats/map toggles actually wire, in registry order. */\nexport function lifecycleHarnesses(): readonly HarnessProfile[] {\n return HARNESSES.filter(wiresLifecycle);\n}\n\n/** A list of harnesses as prose spells them — the one join, so the four lists agree. */\nexport function harnessNames(profiles: readonly HarnessProfile[]): string {\n return profiles.map(harnessLabel).join(', ');\n}\n\n/** Every JSON hook step any profile declares, in registry order. */\nfunction jsonHookSteps(): readonly HarnessJsonHooks[] {\n return HARNESSES.flatMap((profile) =>\n profile.install.filter((step): step is HarnessJsonHooks => step.kind === 'json-hooks'),\n );\n}\n\n/**\n * The session-lifecycle hooks this preset offers, named in each harness's schema:\n * `smelt stats` when a turn ends, and — sharing one `SessionStart` event — an opening\n * `smelt map` and a lint of the repository's own instruction files. Only the harnesses\n * whose schema carries these events wire them (`step.lifecycle`), and `cli/hooks.ts`\n * writes the entries under these exact keys — one spelling, so the managed-event list\n * below cannot fall behind what the installer writes.\n *\n * `map` and `lint` deliberately name the **same** event. They are two independent\n * toggles under one key, which is why `cli/hooks.ts` merges their entries into one\n * array rather than spreading two objects (the second would silently replace the\n * first) and why a re-run tells them apart by the command each entry runs.\n */\nexport const LIFECYCLE_EVENTS = {\n stats: 'Stop',\n map: 'SessionStart',\n lint: 'SessionStart',\n} as const;\n\n/**\n * The events this installer manages, across every harness's spelling of them —\n * foreign entries under them are always preserved, and events nobody claims are never\n * touched. Derived, so a harness that spells its pre-tool event a new way is managed\n * by existing.\n */\nexport const MANAGED_EVENTS: readonly string[] = [\n ...new Set(\n jsonHookSteps().flatMap((step) => [\n step.event,\n ...(step.lifecycle ? Object.values(LIFECYCLE_EVENTS) : []),\n ]),\n ),\n];\n\n/** The managed events that wire the PreToolUse guard, across harness spellings. */\nexport const GUARD_EVENTS: readonly string[] = [\n ...new Set(jsonHookSteps().map((step) => step.event)),\n];\n\n/** The JSON hook files a re-run reads installed toggles back from, per harness. */\nexport const JSON_HOOK_FILES: readonly string[] = [\n ...new Set(jsonHookSteps().map((step) => step.file)),\n];\n\n/** Guard-only files whose presence means the guard toggle was installed. */\nexport const GUARD_ONLY_FILES: readonly string[] = [\n ...new Set(\n HARNESSES.flatMap((profile) =>\n profile.install\n .filter((step) => step.kind === 'own-file' && step.guardOnly)\n .map((step) => step.file),\n ),\n ),\n];\n"]}
@@ -0,0 +1,144 @@
1
+ import { aider } from './aider.js';
2
+ import { claudeCode } from './claude-code.js';
3
+ import { cline } from './cline.js';
4
+ import { codex } from './codex.js';
5
+ import { cursor } from './cursor.js';
6
+ import { gemini } from './gemini.js';
7
+ import { grok } from './grok.js';
8
+ import { hermes } from './hermes.js';
9
+ import { kilocode } from './kilocode.js';
10
+ import { opencode } from './opencode.js';
11
+ import { harnessLabel, HARNESS_TIERS, TIER_HONESTY } from './profile.js';
12
+ /**
13
+ * The registry — every harness smelt knows, one {@link HarnessProfile} each.
14
+ *
15
+ * `Record<HarnessId, HarnessProfile>` on purpose: adding a `HarnessId` in `profile.ts`
16
+ * without writing its profile is a compile error, so the id list and the facts cannot
17
+ * drift. Every derived view — the `--harness` help list, the wizard's table, the
18
+ * managed event names, the files a re-run reads its toggles back from — is computed
19
+ * from this object, never written twice.
20
+ *
21
+ * This module imports **nothing from `cli/`**. That is the point: the registry used to
22
+ * live in `cli/hooks.ts`, which imports `CLI_NAME` from `cli/args.ts`, so `args.ts`
23
+ * could not import it back and the `--harness` help list was hand-typed five lines
24
+ * below two lists that were correctly derived.
25
+ *
26
+ * Key order is meaningful: it is the order every rendered harness list uses (the help
27
+ * text, the wizard's table, the "Known: …" of an unknown-harness error), so keep it
28
+ * stable and append new harnesses at the end.
29
+ */
30
+ export const HARNESS_PROFILES = {
31
+ 'claude-code': claudeCode,
32
+ codex,
33
+ gemini,
34
+ grok,
35
+ hermes,
36
+ cursor,
37
+ opencode,
38
+ cline,
39
+ kilocode,
40
+ aider,
41
+ };
42
+ /** Every profile, in registry order. The list every rendered table walks. */
43
+ export const HARNESSES = Object.values(HARNESS_PROFILES);
44
+ /** Every harness id, in registry order — the `--harness` help list, derived. */
45
+ export const HARNESS_IDS = HARNESSES.map((profile) => profile.id);
46
+ /** The profile for a harness id, or `undefined` for a string the user made up. */
47
+ export function harnessById(id) {
48
+ return HARNESSES.find((profile) => profile.id === id);
49
+ }
50
+ /**
51
+ * The tier → harnesses grouping, derived — `HarnessProfile.tier` is data, and this is
52
+ * the one fold over it.
53
+ *
54
+ * It was hand-typed in five places (the `hooks` help body, two wizard sentences, the
55
+ * README's tier table and the site's), so a harness promoted from experimental to
56
+ * verified stayed listed under the old tier in four of them and nothing failed: the
57
+ * only check was that each harness's name appeared *somewhere* in the help. Every
58
+ * rendered grouping now walks this, and `test/guards/harness-registry.test.ts` asserts
59
+ * the rendered ones agree with it — each name under its own tier clause and no other.
60
+ *
61
+ * Tier order is {@link HARNESS_TIERS} (i.e. `TIER_HONESTY`'s key order); a tier no
62
+ * profile claims yields no group, because a rendered "advisory ()" is worse than a
63
+ * tier that has stopped existing.
64
+ */
65
+ export function harnessesByTier() {
66
+ return HARNESS_TIERS.map((tier) => ({
67
+ tier,
68
+ honesty: TIER_HONESTY[tier],
69
+ harnesses: HARNESSES.filter((profile) => profile.tier === tier),
70
+ })).filter((group) => group.harnesses.length > 0);
71
+ }
72
+ /**
73
+ * Whether this harness's native schema carries the session-lifecycle events this preset
74
+ * offers — `smelt stats` on Stop, `smelt map` on SessionStart — i.e. whether it declares
75
+ * a JSON hook step with `lifecycle`.
76
+ *
77
+ * The wizard used to call these "verified-tier harnesses (Claude Code, Codex)", which
78
+ * is a tier standing in for a capability it merely correlates with: the tier is how much
79
+ * smelt is willing to *claim* about a harness, `lifecycle` is whether the harness has
80
+ * the events at all. The two sets coincide today, which is precisely why the sentence
81
+ * read correctly and why nothing could notice it was wrong — so the guard asks this
82
+ * predicate about a profile whose tier and capability *disagree*, and the mutation runner
83
+ * swaps one for the other and watches it go red.
84
+ */
85
+ export function wiresLifecycle(profile) {
86
+ return profile.install.some((step) => step.kind === 'json-hooks' && step.lifecycle);
87
+ }
88
+ /** The harnesses the stats/map toggles actually wire, in registry order. */
89
+ export function lifecycleHarnesses() {
90
+ return HARNESSES.filter(wiresLifecycle);
91
+ }
92
+ /** A list of harnesses as prose spells them — the one join, so the four lists agree. */
93
+ export function harnessNames(profiles) {
94
+ return profiles.map(harnessLabel).join(', ');
95
+ }
96
+ /** Every JSON hook step any profile declares, in registry order. */
97
+ function jsonHookSteps() {
98
+ return HARNESSES.flatMap((profile) => profile.install.filter((step) => step.kind === 'json-hooks'));
99
+ }
100
+ /**
101
+ * The session-lifecycle hooks this preset offers, named in each harness's schema:
102
+ * `smelt stats` when a turn ends, and — sharing one `SessionStart` event — an opening
103
+ * `smelt map` and a lint of the repository's own instruction files. Only the harnesses
104
+ * whose schema carries these events wire them (`step.lifecycle`), and `cli/hooks.ts`
105
+ * writes the entries under these exact keys — one spelling, so the managed-event list
106
+ * below cannot fall behind what the installer writes.
107
+ *
108
+ * `map` and `lint` deliberately name the **same** event. They are two independent
109
+ * toggles under one key, which is why `cli/hooks.ts` merges their entries into one
110
+ * array rather than spreading two objects (the second would silently replace the
111
+ * first) and why a re-run tells them apart by the command each entry runs.
112
+ */
113
+ export const LIFECYCLE_EVENTS = {
114
+ stats: 'Stop',
115
+ map: 'SessionStart',
116
+ lint: 'SessionStart',
117
+ };
118
+ /**
119
+ * The events this installer manages, across every harness's spelling of them —
120
+ * foreign entries under them are always preserved, and events nobody claims are never
121
+ * touched. Derived, so a harness that spells its pre-tool event a new way is managed
122
+ * by existing.
123
+ */
124
+ export const MANAGED_EVENTS = [
125
+ ...new Set(jsonHookSteps().flatMap((step) => [
126
+ step.event,
127
+ ...(step.lifecycle ? Object.values(LIFECYCLE_EVENTS) : []),
128
+ ])),
129
+ ];
130
+ /** The managed events that wire the PreToolUse guard, across harness spellings. */
131
+ export const GUARD_EVENTS = [
132
+ ...new Set(jsonHookSteps().map((step) => step.event)),
133
+ ];
134
+ /** The JSON hook files a re-run reads installed toggles back from, per harness. */
135
+ export const JSON_HOOK_FILES = [
136
+ ...new Set(jsonHookSteps().map((step) => step.file)),
137
+ ];
138
+ /** Guard-only files whose presence means the guard toggle was installed. */
139
+ export const GUARD_ONLY_FILES = [
140
+ ...new Set(HARNESSES.flatMap((profile) => profile.install
141
+ .filter((step) => step.kind === 'own-file' && step.guardOnly)
142
+ .map((step) => step.file))),
143
+ ];
144
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/harness/registry.ts"],"names":[],"mappings":"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","sourcesContent":["import { aider } from './aider.ts';\nimport { claudeCode } from './claude-code.ts';\nimport { cline } from './cline.ts';\nimport { codex } from './codex.ts';\nimport { cursor } from './cursor.ts';\nimport { gemini } from './gemini.ts';\nimport { grok } from './grok.ts';\nimport { hermes } from './hermes.ts';\nimport { kilocode } from './kilocode.ts';\nimport { opencode } from './opencode.ts';\nimport { harnessLabel, HARNESS_TIERS, TIER_HONESTY } from './profile.ts';\nimport type { HarnessId, HarnessJsonHooks, HarnessProfile, HarnessTier } from './profile.ts';\n\n/**\n * The registry — every harness smelt knows, one {@link HarnessProfile} each.\n *\n * `Record<HarnessId, HarnessProfile>` on purpose: adding a `HarnessId` in `profile.ts`\n * without writing its profile is a compile error, so the id list and the facts cannot\n * drift. Every derived view — the `--harness` help list, the wizard's table, the\n * managed event names, the files a re-run reads its toggles back from — is computed\n * from this object, never written twice.\n *\n * This module imports **nothing from `cli/`**. That is the point: the registry used to\n * live in `cli/hooks.ts`, which imports `CLI_NAME` from `cli/args.ts`, so `args.ts`\n * could not import it back and the `--harness` help list was hand-typed five lines\n * below two lists that were correctly derived.\n *\n * Key order is meaningful: it is the order every rendered harness list uses (the help\n * text, the wizard's table, the \"Known: …\" of an unknown-harness error), so keep it\n * stable and append new harnesses at the end.\n */\nexport const HARNESS_PROFILES: Readonly<Record<HarnessId, HarnessProfile>> = {\n 'claude-code': claudeCode,\n codex,\n gemini,\n grok,\n hermes,\n cursor,\n opencode,\n cline,\n kilocode,\n aider,\n};\n\n/** Every profile, in registry order. The list every rendered table walks. */\nexport const HARNESSES: readonly HarnessProfile[] = Object.values(HARNESS_PROFILES);\n\n/** Every harness id, in registry order — the `--harness` help list, derived. */\nexport const HARNESS_IDS: readonly HarnessId[] = HARNESSES.map((profile) => profile.id);\n\n/** The profile for a harness id, or `undefined` for a string the user made up. */\nexport function harnessById(id: string): HarnessProfile | undefined {\n return HARNESSES.find((profile) => profile.id === id);\n}\n\n/** One tier and the harnesses that ship at it — the row every tier table renders. */\nexport interface HarnessTierGroup {\n readonly tier: HarnessTier;\n /** That tier's one line of honesty ({@link TIER_HONESTY}), carried with the group. */\n readonly honesty: string;\n /** The profiles at this tier, in registry order. */\n readonly harnesses: readonly HarnessProfile[];\n}\n\n/**\n * The tier → harnesses grouping, derived — `HarnessProfile.tier` is data, and this is\n * the one fold over it.\n *\n * It was hand-typed in five places (the `hooks` help body, two wizard sentences, the\n * README's tier table and the site's), so a harness promoted from experimental to\n * verified stayed listed under the old tier in four of them and nothing failed: the\n * only check was that each harness's name appeared *somewhere* in the help. Every\n * rendered grouping now walks this, and `test/guards/harness-registry.test.ts` asserts\n * the rendered ones agree with it — each name under its own tier clause and no other.\n *\n * Tier order is {@link HARNESS_TIERS} (i.e. `TIER_HONESTY`'s key order); a tier no\n * profile claims yields no group, because a rendered \"advisory ()\" is worse than a\n * tier that has stopped existing.\n */\nexport function harnessesByTier(): readonly HarnessTierGroup[] {\n return HARNESS_TIERS.map((tier) => ({\n tier,\n honesty: TIER_HONESTY[tier],\n harnesses: HARNESSES.filter((profile) => profile.tier === tier),\n })).filter((group) => group.harnesses.length > 0);\n}\n\n/**\n * Whether this harness's native schema carries the session-lifecycle events this preset\n * offers — `smelt stats` on Stop, `smelt map` on SessionStart — i.e. whether it declares\n * a JSON hook step with `lifecycle`.\n *\n * The wizard used to call these \"verified-tier harnesses (Claude Code, Codex)\", which\n * is a tier standing in for a capability it merely correlates with: the tier is how much\n * smelt is willing to *claim* about a harness, `lifecycle` is whether the harness has\n * the events at all. The two sets coincide today, which is precisely why the sentence\n * read correctly and why nothing could notice it was wrong — so the guard asks this\n * predicate about a profile whose tier and capability *disagree*, and the mutation runner\n * swaps one for the other and watches it go red.\n */\nexport function wiresLifecycle(profile: HarnessProfile): boolean {\n return profile.install.some((step) => step.kind === 'json-hooks' && step.lifecycle);\n}\n\n/** The harnesses the stats/map toggles actually wire, in registry order. */\nexport function lifecycleHarnesses(): readonly HarnessProfile[] {\n return HARNESSES.filter(wiresLifecycle);\n}\n\n/** A list of harnesses as prose spells them — the one join, so the four lists agree. */\nexport function harnessNames(profiles: readonly HarnessProfile[]): string {\n return profiles.map(harnessLabel).join(', ');\n}\n\n/** Every JSON hook step any profile declares, in registry order. */\nfunction jsonHookSteps(): readonly HarnessJsonHooks[] {\n return HARNESSES.flatMap((profile) =>\n profile.install.filter((step): step is HarnessJsonHooks => step.kind === 'json-hooks'),\n );\n}\n\n/**\n * The session-lifecycle hooks this preset offers, named in each harness's schema:\n * `smelt stats` when a turn ends, and — sharing one `SessionStart` event — an opening\n * `smelt map` and a lint of the repository's own instruction files. Only the harnesses\n * whose schema carries these events wire them (`step.lifecycle`), and `cli/hooks.ts`\n * writes the entries under these exact keys — one spelling, so the managed-event list\n * below cannot fall behind what the installer writes.\n *\n * `map` and `lint` deliberately name the **same** event. They are two independent\n * toggles under one key, which is why `cli/hooks.ts` merges their entries into one\n * array rather than spreading two objects (the second would silently replace the\n * first) and why a re-run tells them apart by the command each entry runs.\n */\nexport const LIFECYCLE_EVENTS = {\n stats: 'Stop',\n map: 'SessionStart',\n lint: 'SessionStart',\n} as const;\n\n/**\n * The events this installer manages, across every harness's spelling of them —\n * foreign entries under them are always preserved, and events nobody claims are never\n * touched. Derived, so a harness that spells its pre-tool event a new way is managed\n * by existing.\n */\nexport const MANAGED_EVENTS: readonly string[] = [\n ...new Set(\n jsonHookSteps().flatMap((step) => [\n step.event,\n ...(step.lifecycle ? Object.values(LIFECYCLE_EVENTS) : []),\n ]),\n ),\n];\n\n/** The managed events that wire the PreToolUse guard, across harness spellings. */\nexport const GUARD_EVENTS: readonly string[] = [\n ...new Set(jsonHookSteps().map((step) => step.event)),\n];\n\n/** The JSON hook files a re-run reads installed toggles back from, per harness. */\nexport const JSON_HOOK_FILES: readonly string[] = [\n ...new Set(jsonHookSteps().map((step) => step.file)),\n];\n\n/** Guard-only files whose presence means the guard toggle was installed. */\nexport const GUARD_ONLY_FILES: readonly string[] = [\n ...new Set(\n HARNESSES.flatMap((profile) =>\n profile.install\n .filter((step) => step.kind === 'own-file' && step.guardOnly)\n .map((step) => step.file),\n ),\n ),\n];\n"]}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The text every harness shares: the marker lines that bracket a block this installer
3
+ * owns inside somebody else's file, the token that identifies a hook entry as ours,
4
+ * and the instruction snippet itself.
5
+ */
6
+ /** Marker lines bracketing every block this installer owns inside a shared file. */
7
+ export declare const SNIPPET_START_MD = "<!-- smelt:hooks v1 start -->";
8
+ export declare const SNIPPET_END_MD = "<!-- smelt:hooks v1 end -->";
9
+ export declare const SNIPPET_START_HASH = "# smelt:hooks v1 start";
10
+ export declare const SNIPPET_END_HASH = "# smelt:hooks v1 end";
11
+ /** Substring that identifies a file (or JSON hook entry) as written by this installer. */
12
+ export declare const OURS_TOKEN = "smelt:hooks";
13
+ /**
14
+ * The instruction snippet — belt and braces under every shim, and the *only* layer
15
+ * for advisory harnesses. It teaches the three commands, and in particular what to do
16
+ * after a guard deny: run the named replacement, then `smelt retrieve` per marker.
17
+ */
18
+ export declare function instructionSnippet(thresholdBytes: number, budgetBytes: number): string;
19
+ //# sourceMappingURL=snippet.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"snippet.d.ts","sourceRoot":"","sources":["../../src/harness/snippet.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,oFAAoF;AACpF,eAAO,MAAM,gBAAgB,kCAAkC,CAAC;AAChE,eAAO,MAAM,cAAc,gCAAgC,CAAC;AAC5D,eAAO,MAAM,kBAAkB,2BAA2B,CAAC;AAC3D,eAAO,MAAM,gBAAgB,yBAAyB,CAAC;AAEvD,0FAA0F;AAC1F,eAAO,MAAM,UAAU,gBAAgB,CAAC;AAExC;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,cAAc,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAsBtF","sourcesContent":["/**\n * The text every harness shares: the marker lines that bracket a block this installer\n * owns inside somebody else's file, the token that identifies a hook entry as ours,\n * and the instruction snippet itself.\n */\n\n/** Marker lines bracketing every block this installer owns inside a shared file. */\nexport const SNIPPET_START_MD = '<!-- smelt:hooks v1 start -->';\nexport const SNIPPET_END_MD = '<!-- smelt:hooks v1 end -->';\nexport const SNIPPET_START_HASH = '# smelt:hooks v1 start';\nexport const SNIPPET_END_HASH = '# smelt:hooks v1 end';\n\n/** Substring that identifies a file (or JSON hook entry) as written by this installer. */\nexport const OURS_TOKEN = 'smelt:hooks';\n\n/**\n * The instruction snippet — belt and braces under every shim, and the *only* layer\n * for advisory harnesses. It teaches the three commands, and in particular what to do\n * after a guard deny: run the named replacement, then `smelt retrieve` per marker.\n */\nexport function instructionSnippet(thresholdBytes: number, budgetBytes: number): string {\n return `${SNIPPET_START_MD}\n\n## smelt — context discipline\n\nThis project uses [smelt](https://github.com/smeltjs/smelt) to keep large tool output\nout of the context window, reversibly.\n\n- Do not read files over ${String(thresholdBytes)} bytes raw. Run\n \\`smelt <file> --budget ${String(budgetBytes)} --focus <what you are looking for>\\`\n instead (repeat \\`--focus\\` per term). Focused regions survive verbatim; everything\n else collapses into a one-line marker stating what was removed.\n- Every marker ends in \\`retrieve(\"hash\")\\`. \\`smelt retrieve <hash>\\` prints the\n exact original bytes back. Retrieve what you actually need — retrievals are counted,\n and \\`smelt stats\\` reports the honest expansion rate.\n- For orientation, \\`smelt map . --budget ${String(budgetBytes)}\\` prints a ranked\n symbol map of the repository.\n- If a smelt guard hook denies a raw read, run the exact replacement command named in\n the denial, then \\`smelt retrieve\\` any marker you need expanded.\n\n${SNIPPET_END_MD}\n`;\n}\n"]}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The text every harness shares: the marker lines that bracket a block this installer
3
+ * owns inside somebody else's file, the token that identifies a hook entry as ours,
4
+ * and the instruction snippet itself.
5
+ */
6
+ /** Marker lines bracketing every block this installer owns inside a shared file. */
7
+ export const SNIPPET_START_MD = '<!-- smelt:hooks v1 start -->';
8
+ export const SNIPPET_END_MD = '<!-- smelt:hooks v1 end -->';
9
+ export const SNIPPET_START_HASH = '# smelt:hooks v1 start';
10
+ export const SNIPPET_END_HASH = '# smelt:hooks v1 end';
11
+ /** Substring that identifies a file (or JSON hook entry) as written by this installer. */
12
+ export const OURS_TOKEN = 'smelt:hooks';
13
+ /**
14
+ * The instruction snippet — belt and braces under every shim, and the *only* layer
15
+ * for advisory harnesses. It teaches the three commands, and in particular what to do
16
+ * after a guard deny: run the named replacement, then `smelt retrieve` per marker.
17
+ */
18
+ export function instructionSnippet(thresholdBytes, budgetBytes) {
19
+ return `${SNIPPET_START_MD}
20
+
21
+ ## smelt — context discipline
22
+
23
+ This project uses [smelt](https://github.com/smeltjs/smelt) to keep large tool output
24
+ out of the context window, reversibly.
25
+
26
+ - Do not read files over ${String(thresholdBytes)} bytes raw. Run
27
+ \`smelt <file> --budget ${String(budgetBytes)} --focus <what you are looking for>\`
28
+ instead (repeat \`--focus\` per term). Focused regions survive verbatim; everything
29
+ else collapses into a one-line marker stating what was removed.
30
+ - Every marker ends in \`retrieve("hash")\`. \`smelt retrieve <hash>\` prints the
31
+ exact original bytes back. Retrieve what you actually need — retrievals are counted,
32
+ and \`smelt stats\` reports the honest expansion rate.
33
+ - For orientation, \`smelt map . --budget ${String(budgetBytes)}\` prints a ranked
34
+ symbol map of the repository.
35
+ - If a smelt guard hook denies a raw read, run the exact replacement command named in
36
+ the denial, then \`smelt retrieve\` any marker you need expanded.
37
+
38
+ ${SNIPPET_END_MD}
39
+ `;
40
+ }
41
+ //# sourceMappingURL=snippet.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"snippet.js","sourceRoot":"","sources":["../../src/harness/snippet.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,oFAAoF;AACpF,MAAM,CAAC,MAAM,gBAAgB,GAAG,+BAA+B,CAAC;AAChE,MAAM,CAAC,MAAM,cAAc,GAAG,6BAA6B,CAAC;AAC5D,MAAM,CAAC,MAAM,kBAAkB,GAAG,wBAAwB,CAAC;AAC3D,MAAM,CAAC,MAAM,gBAAgB,GAAG,sBAAsB,CAAC;AAEvD,0FAA0F;AAC1F,MAAM,CAAC,MAAM,UAAU,GAAG,aAAa,CAAC;AAExC;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAAC,cAAsB,EAAE,WAAmB;IAC5E,OAAO,GAAG,gBAAgB;;;;;;;2BAOD,MAAM,CAAC,cAAc,CAAC;4BACrB,MAAM,CAAC,WAAW,CAAC;;;;;;4CAMH,MAAM,CAAC,WAAW,CAAC;;;;;EAK7D,cAAc;CACf,CAAC;AACF,CAAC","sourcesContent":["/**\n * The text every harness shares: the marker lines that bracket a block this installer\n * owns inside somebody else's file, the token that identifies a hook entry as ours,\n * and the instruction snippet itself.\n */\n\n/** Marker lines bracketing every block this installer owns inside a shared file. */\nexport const SNIPPET_START_MD = '<!-- smelt:hooks v1 start -->';\nexport const SNIPPET_END_MD = '<!-- smelt:hooks v1 end -->';\nexport const SNIPPET_START_HASH = '# smelt:hooks v1 start';\nexport const SNIPPET_END_HASH = '# smelt:hooks v1 end';\n\n/** Substring that identifies a file (or JSON hook entry) as written by this installer. */\nexport const OURS_TOKEN = 'smelt:hooks';\n\n/**\n * The instruction snippet — belt and braces under every shim, and the *only* layer\n * for advisory harnesses. It teaches the three commands, and in particular what to do\n * after a guard deny: run the named replacement, then `smelt retrieve` per marker.\n */\nexport function instructionSnippet(thresholdBytes: number, budgetBytes: number): string {\n return `${SNIPPET_START_MD}\n\n## smelt — context discipline\n\nThis project uses [smelt](https://github.com/smeltjs/smelt) to keep large tool output\nout of the context window, reversibly.\n\n- Do not read files over ${String(thresholdBytes)} bytes raw. Run\n \\`smelt <file> --budget ${String(budgetBytes)} --focus <what you are looking for>\\`\n instead (repeat \\`--focus\\` per term). Focused regions survive verbatim; everything\n else collapses into a one-line marker stating what was removed.\n- Every marker ends in \\`retrieve(\"hash\")\\`. \\`smelt retrieve <hash>\\` prints the\n exact original bytes back. Retrieve what you actually need — retrievals are counted,\n and \\`smelt stats\\` reports the honest expansion rate.\n- For orientation, \\`smelt map . --budget ${String(budgetBytes)}\\` prints a ranked\n symbol map of the repository.\n- If a smelt guard hook denies a raw read, run the exact replacement command named in\n the denial, then \\`smelt retrieve\\` any marker you need expanded.\n\n${SNIPPET_END_MD}\n`;\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../src/hash.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,eAAO,MAAM,WAAW,KAAK,CAAC;AAE9B,wFAAwF;AACxF,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEnD"}
1
+ {"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../src/hash.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,eAAO,MAAM,WAAW,KAAK,CAAC;AAE9B,wFAAwF;AACxF,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEnD","sourcesContent":["import { createHash } from 'node:crypto';\n\n/**\n * Length of the hex digest smelt uses as a retrieval key.\n *\n * The hash goes into every marker, so the model pays for it in tokens on every\n * elision. 16 hex characters is 64 bits — plenty for the number of elisions one\n * session produces, and cheap enough to inline. The store still verifies content on\n * collision rather than trusting the digest, so shortening it is a token decision,\n * not a correctness one.\n */\nexport const HASH_LENGTH = 16;\n\n/** Content hash of a string, over its UTF-8 bytes. Stable across runs and platforms. */\nexport function contentHash(content: string): string {\n return createHash('sha256').update(content, 'utf8').digest('hex').slice(0, HASH_LENGTH);\n}\n"]}
package/dist/hash.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"hash.js","sourceRoot":"","sources":["../src/hash.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,EAAE,CAAC;AAE9B,wFAAwF;AACxF,MAAM,UAAU,WAAW,CAAC,OAAe;IACzC,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC;AAC1F,CAAC"}
1
+ {"version":3,"file":"hash.js","sourceRoot":"","sources":["../src/hash.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,EAAE,CAAC;AAE9B,wFAAwF;AACxF,MAAM,UAAU,WAAW,CAAC,OAAe;IACzC,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC;AAC1F,CAAC","sourcesContent":["import { createHash } from 'node:crypto';\n\n/**\n * Length of the hex digest smelt uses as a retrieval key.\n *\n * The hash goes into every marker, so the model pays for it in tokens on every\n * elision. 16 hex characters is 64 bits — plenty for the number of elisions one\n * session produces, and cheap enough to inline. The store still verifies content on\n * collision rather than trusting the digest, so shortening it is a token decision,\n * not a correctness one.\n */\nexport const HASH_LENGTH = 16;\n\n/** Content hash of a string, over its UTF-8 bytes. Stable across runs and platforms. */\nexport function contentHash(content: string): string {\n return createHash('sha256').update(content, 'utf8').digest('hex').slice(0, HASH_LENGTH);\n}\n"]}
@@ -1,11 +1,13 @@
1
1
  /**
2
- * The guard core — one zero-dependency node script, shared by every harness shim.
2
+ * The guard core — one zero-dependency node module, shared by every harness shim.
3
3
  *
4
- * Contract (KOT-212): stdin carries one JSON request
5
- * `{ tool, input: { path?, command?, offsetLimited? } }`, stdout answers with one
6
- * JSON decision `{ action: "allow" | "deny", reason?, suggestion? }`, and the exit
7
- * code is always 0 the decision lives in the JSON, and each shim translates it
8
- * into its harness's own schema (exit 2, `permissionDecision`, `cancel:true`, …).
4
+ * Contract: a caller hands `decide` a {@link GuardRequest}
5
+ * (`{ tool, input: { path?, command?, offsetLimited? } }`) with the settings
6
+ * {@link readGuardSettings} read, and gets one {@link GuardDecision} back
7
+ * `{ action: "allow" | "deny", reason?, suggestion? }`. Each shim translates that into
8
+ * its harness's own schema (exit 2, `permissionDecision`, `cancel:true`, …), and the
9
+ * opencode plugin — the one harness whose hook API is JavaScript — imports this module
10
+ * at hook time and calls the same two functions.
9
11
  *
10
12
  * Two properties are load-bearing and guarded:
11
13
  *
@@ -112,7 +114,12 @@ export declare const DEFAULT_GUARD_SETTINGS: GuardSettings;
112
114
  export declare function readGuardSettings(cwd: string, warn: (text: string) => void): GuardSettings;
113
115
  /** The same upward walk `cli/config.ts` does, re-implemented to keep this module tiny. */
114
116
  export declare function findGuardConfigFile(cwd: string): string | undefined;
115
- /** Parse one stdin request. `undefined` means malformed — the caller allows and warns. */
117
+ /**
118
+ * Parse one {@link GuardRequest} *document* — the request shape as JSON, for an
119
+ * adapter that receives it over a pipe rather than building it in-process.
120
+ * `undefined` means malformed, and a caller that gets it allows and warns: the same
121
+ * fail-open rule the rest of this module lives under.
122
+ */
116
123
  export declare function parseGuardRequest(text: string): GuardRequest | undefined;
117
124
  /**
118
125
  * The decision, pure given a stat function — so tests exercise every branch without
@@ -154,11 +161,4 @@ export declare function shellQuote(value: string): string;
154
161
  export declare function isMainModule(moduleUrl: string): boolean;
155
162
  /** Every byte of fd 0 to EOF, retrying EAGAIN — the same shape `cli/bin.ts` uses. */
156
163
  export declare function readAllOfStdin(): string;
157
- /**
158
- * Run the guard over stdin and print the decision. Exit code 0 in every case —
159
- * including every failure, which becomes an allow with a stderr warning. A guard
160
- * subprocess that exits non-zero on its own bug would look like a policy decision
161
- * to some harnesses, and "my hook crashed" must never read as "denied".
162
- */
163
- export declare function runGuardMain(): void;
164
164
  //# sourceMappingURL=guard-core.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"guard-core.d.ts","sourceRoot":"","sources":["../../src/hooks/guard-core.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"guard-core.d.ts","sourceRoot":"","sources":["../../src/hooks/guard-core.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,2FAA2F;AAC3F,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C;;;;GAIG;AACH,eAAO,MAAM,+BAA+B,OAAO,CAAC;AAEpD,yFAAyF;AACzF,eAAO,MAAM,iBAAiB,YAAI,MAAM,EAAE,SAAS,CAAU,CAAC;AAC9D,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEjE,2EAA2E;AAC3E,eAAO,MAAM,sBAAsB,sBAAsB,CAAC;AAE1D;;;;;;;;;GASG;AACH,wBAAgB,eAAe,IAAI,MAAM,CAQxC;AAID,gFAAgF;AAChF,MAAM,WAAW,YAAY;IAC3B,sFAAsF;IACtF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE;QACd,mFAAmF;QACnF,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QACvB,0DAA0D;QAC1D,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAC1B;;;WAGG;QACH,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;KAClC,CAAC;CACH;AAED,sFAAsF;AACtF,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAAC;IAClC,uFAAuF;IACvF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;OAMG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,iFAAiF;AACjF,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,WAAW,EAAE,eAAe,CAAC;IACtC,4EAA4E;IAC5E,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B;;;;;OAKG;IACH,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC;CACnC;AAED,eAAO,MAAM,sBAAsB,EAAE,aAKpC,CAAC;AAEF;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAAG,aAAa,CA2C1F;AAuCD,0FAA0F;AAC1F,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CASnE;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CA2BxE;AAID;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,MAAM,CACpB,OAAO,EAAE,YAAY,EACrB,QAAQ,EAAE,aAAa,EACvB,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GAAG,SAAwB,GACvF,aAAa,CAQf;AAmID;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,GAAG,SAAS,CAuCjF;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,GAAG,SAAS,CAiD1E;AAED,oEAAoE;AACpE,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAGhD;AAMD,8EAA8E;AAC9E,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAQvD;AAED,qFAAqF;AACrF,wBAAgB,cAAc,IAAI,MAAM,CAqBvC","sourcesContent":["import { readSync, statSync, existsSync, readFileSync } from 'node:fs';\nimport { dirname, isAbsolute, join, resolve } from 'node:path';\nimport { fileURLToPath, pathToFileURL } from 'node:url';\nimport process from 'node:process';\n\n/**\n * The guard core — one zero-dependency node module, shared by every harness shim.\n *\n * Contract: a caller hands `decide` a {@link GuardRequest}\n * (`{ tool, input: { path?, command?, offsetLimited? } }`) with the settings\n * {@link readGuardSettings} read, and gets one {@link GuardDecision} back —\n * `{ action: \"allow\" | \"deny\", reason?, suggestion? }`. Each shim translates that into\n * its harness's own schema (exit 2, `permissionDecision`, `cancel:true`, …), and the\n * opencode plugin — the one harness whose hook API is JavaScript — imports this module\n * at hook time and calls the same two functions.\n *\n * Two properties are load-bearing and guarded:\n *\n * - **Fail open, loudly.** Malformed stdin, a malformed config, an unstatable path —\n * every degenerate input produces `{\"action\":\"allow\"}` plus a warning on stderr.\n * A guard that can brick a session on bad input is worse than no guard; the agent\n * loses nothing but the optimization, and the warning says so.\n * - **No library import on any path.** This module imports node builtins only —\n * never `../index.ts`, never a planner, never web-tree-sitter. The allow case is\n * a stat and an exit; the research note\n * (docs/research/2026-09-02-agent-enforcement.md § 5) budgets the always-on guard\n * at tens of milliseconds, and loading grammar machinery here would spend that\n * budget before deciding anything. The smelt run itself is only ever paid by the\n * *replacement* command the model runs after a deny (or the rewritten command).\n *\n * Config: the nearest `smelt.config.json` (walking up from the cwd, same discovery\n * as the CLI) may carry a `hooks` block — `thresholdBytes` and `enforcement` — plus\n * the `defaultBudgetBytes` the suggested command quotes. This file reads that config\n * with its own tolerant reader instead of importing `cli/config.ts`: the CLI's strict\n * parser sits on the planner import graph, and a *guard* must fail open where the CLI\n * correctly refuses. `test/hooks-guard-core.test.ts` pins the two readers to the same\n * key names and defaults, so they cannot drift apart silently.\n */\n\n/** Deny threshold when no config says otherwise: reads at or under this pass untouched. */\nexport const DEFAULT_THRESHOLD_BYTES = 8192;\n\n/**\n * The `--budget` the suggested replacement command quotes when no config carries\n * `defaultBudgetBytes`. A suggestion default, not a smelt default: the CLI itself\n * still refuses to run without an explicit budget from a flag or the config.\n */\nexport const DEFAULT_SUGGESTION_BUDGET_BYTES = 8000;\n\n/** The `hooks.enforcement` values `smelt.config.json` may carry. Deny is the default. */\nexport const ENFORCEMENT_MODES = ['deny', 'rewrite'] as const;\nexport type EnforcementMode = (typeof ENFORCEMENT_MODES)[number];\n\n/** The config file this guard discovers, by the same name the CLI uses. */\nexport const GUARD_CONFIG_FILE_NAME = 'smelt.config.json';\n\n/**\n * The runnable CLI name every reason and suggestion quotes. A local (non-global)\n * `npm install @smeltjs/core` puts no `smelt` on anyone's PATH — the installer wires\n * every shim as `node \"<dist path>\"` for exactly that reason — so a suggestion\n * saying bare `smelt` would exit 127 the moment the model (or a rewrite-mode\n * harness) ran it. When this module's sibling `cli/bin.js` exists — the shipped\n * `dist/` layout every real run executes from — the command names it through `node`\n * explicitly; the bare name is only the fallback for layouts where the sibling is\n * absent (the source tree under the test runner).\n */\nexport function smeltCliCommand(): string {\n try {\n const bin = join(dirname(fileURLToPath(import.meta.url)), '..', 'cli', 'bin.js');\n if (existsSync(bin)) return `node ${shellQuote(bin)}`;\n } catch {\n // fall through to the PATH name\n }\n return 'smelt';\n}\n\nconst SMELT_CLI = smeltCliCommand();\n\n/** What a shim hands the guard core: the harness schema already mapped away. */\nexport interface GuardRequest {\n /** `'Read'` for a file-read tool, `'Bash'` for a shell tool; anything else passes. */\n readonly tool: string;\n readonly input: {\n /** The file a Read-shaped tool targets. Relative paths resolve against the cwd. */\n readonly path?: string;\n /** The command a Bash-shaped tool would run, verbatim. */\n readonly command?: string;\n /**\n * True when the read is already windowed (offset/limit given). A windowed read\n * of a huge file is an economy move — it is always allowed, whatever the size.\n */\n readonly offsetLimited?: boolean;\n };\n}\n\n/** The guard's whole answer. `suggestion`, when present, is an executable command. */\nexport interface GuardDecision {\n readonly action: 'allow' | 'deny';\n /** Why, written to steer: names the exact replacement command and `smelt retrieve`. */\n readonly reason?: string;\n /**\n * A command that faithfully replaces the denied one — `smelt <path> --budget <n>`\n * for a raw read, the original pipeline with ` | smelt …` appended for a search.\n * Only emitted when running it preserves the intent of the original call, which is\n * exactly the condition under which a rewrite-mode shim may substitute it via\n * `updatedInput`. Absent on decisions that need the model's judgement instead.\n */\n readonly suggestion?: string;\n}\n\n/** The guard's merged settings: config values where sane, defaults where not. */\nexport interface GuardSettings {\n readonly thresholdBytes: number;\n readonly enforcement: EnforcementMode;\n /** Quoted in every suggested command, so the model runs a complete line. */\n readonly budgetBytes: number;\n /**\n * True when the config carries a directory store. `smelt retrieve <hash>` only\n * works across processes with a persistent store (the CLI's default is memory,\n * which dies with the process that elided), so a deny reason may only *promise*\n * retrieval when this is true — otherwise it says what to configure instead.\n */\n readonly persistentStore: boolean;\n}\n\nexport const DEFAULT_GUARD_SETTINGS: GuardSettings = {\n thresholdBytes: DEFAULT_THRESHOLD_BYTES,\n enforcement: 'deny',\n budgetBytes: DEFAULT_SUGGESTION_BUDGET_BYTES,\n persistentStore: false,\n};\n\n/**\n * Read the nearest `smelt.config.json`'s guard-relevant fields, tolerantly.\n *\n * Tolerant is a deliberate divergence from the CLI: `smelt` refuses a malformed\n * config because a silently skipped setting is a setting the user believed was in\n * force — but this code runs inside somebody's *session*, before every Read, and a\n * guard that turns a config typo into a hard-down harness has failed worse than the\n * typo. So: any unreadable or ill-typed field falls back to its default, and `warn`\n * receives one line saying which file and why — visible in the harness's hook debug\n * output, never fatal.\n */\nexport function readGuardSettings(cwd: string, warn: (text: string) => void): GuardSettings {\n const path = findGuardConfigFile(cwd);\n if (path === undefined) return DEFAULT_GUARD_SETTINGS;\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(readFileSync(path, 'utf8'));\n } catch (cause) {\n warn(\n `smelt guard: ${path} is not readable JSON ` +\n `(${cause instanceof Error ? cause.message : String(cause)}) — ` +\n `guarding with defaults instead. \\`smelt\\` itself will refuse this config.`,\n );\n return DEFAULT_GUARD_SETTINGS;\n }\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n warn(`smelt guard: ${path} is not a JSON object — guarding with defaults instead.`);\n return DEFAULT_GUARD_SETTINGS;\n }\n const fields = parsed as Record<string, unknown>;\n const hooks =\n typeof fields['hooks'] === 'object' &&\n fields['hooks'] !== null &&\n !Array.isArray(fields['hooks'])\n ? (fields['hooks'] as Record<string, unknown>)\n : {};\n\n return {\n thresholdBytes: positiveInteger(\n hooks['thresholdBytes'],\n DEFAULT_THRESHOLD_BYTES,\n `${path}: hooks.thresholdBytes`,\n warn,\n ),\n enforcement: enforcementMode(hooks['enforcement'], `${path}: hooks.enforcement`, warn),\n budgetBytes: positiveInteger(\n fields['defaultBudgetBytes'],\n DEFAULT_SUGGESTION_BUDGET_BYTES,\n `${path}: defaultBudgetBytes`,\n warn,\n ),\n persistentStore: isDirectoryStore(fields['store']),\n };\n}\n\n/** True for a well-formed `{\"kind\":\"directory\",\"path\":…}` store block; no warning\n * otherwise — an absent or memory store is a valid (just non-persistent) choice. */\nfunction isDirectoryStore(value: unknown): boolean {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;\n const fields = value as Record<string, unknown>;\n return fields['kind'] === 'directory' && typeof fields['path'] === 'string';\n}\n\nfunction positiveInteger(\n value: unknown,\n fallback: number,\n what: string,\n warn: (text: string) => void,\n): number {\n if (value === undefined) return fallback;\n if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;\n warn(\n `smelt guard: ${what} must be a positive integer, got ${JSON.stringify(value)} — ` +\n `using ${String(fallback)}.`,\n );\n return fallback;\n}\n\nfunction enforcementMode(\n value: unknown,\n what: string,\n warn: (text: string) => void,\n): EnforcementMode {\n if (value === undefined) return 'deny';\n if (value === 'deny' || value === 'rewrite') return value;\n warn(\n `smelt guard: ${what} must be \"deny\" or \"rewrite\", got ${JSON.stringify(value)} — ` +\n `using \"deny\".`,\n );\n return 'deny';\n}\n\n/** The same upward walk `cli/config.ts` does, re-implemented to keep this module tiny. */\nexport function findGuardConfigFile(cwd: string): string | undefined {\n let dir = resolve(cwd);\n for (;;) {\n const candidate = join(dir, GUARD_CONFIG_FILE_NAME);\n if (existsSync(candidate)) return candidate;\n const parent = dirname(dir);\n if (parent === dir) return undefined;\n dir = parent;\n }\n}\n\n/**\n * Parse one {@link GuardRequest} *document* — the request shape as JSON, for an\n * adapter that receives it over a pipe rather than building it in-process.\n * `undefined` means malformed, and a caller that gets it allows and warns: the same\n * fail-open rule the rest of this module lives under.\n */\nexport function parseGuardRequest(text: string): GuardRequest | undefined {\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch {\n return undefined;\n }\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;\n const fields = parsed as Record<string, unknown>;\n if (typeof fields['tool'] !== 'string') return undefined;\n const input = fields['input'];\n if (typeof input !== 'object' || input === null || Array.isArray(input)) return undefined;\n const inputFields = input as Record<string, unknown>;\n const path = inputFields['path'];\n const command = inputFields['command'];\n const offsetLimited = inputFields['offsetLimited'];\n if (path !== undefined && typeof path !== 'string') return undefined;\n if (command !== undefined && typeof command !== 'string') return undefined;\n if (offsetLimited !== undefined && typeof offsetLimited !== 'boolean') return undefined;\n return {\n tool: fields['tool'],\n input: {\n ...(path === undefined ? {} : { path }),\n ...(command === undefined ? {} : { command }),\n ...(offsetLimited === undefined ? {} : { offsetLimited }),\n },\n };\n}\n\nconst ALLOW: GuardDecision = { action: 'allow' };\n\n/**\n * The decision, pure given a stat function — so tests exercise every branch without\n * a filesystem, and the script wires `statSync` in.\n *\n * The shape of the rules, from the research note (§ 5, \"the ~8 KB threshold,\n * validated\", with both amendments):\n *\n * - **Read**: stat the exact target; deny only above the threshold, and never when\n * the read is already windowed (`offsetLimited`) — a windowed read of a huge file\n * is an economy move.\n * - **Bash**: only a *simple* command whose subject is a statable named file can be\n * judged pre-run. `cat <file>` above the threshold is denied with the faithful\n * replacement; `grep`/`rg` output is unknowable pre-run, so it passes in deny\n * mode and is wrapped (` | smelt --budget … --focus <pattern>`) only under\n * `hooks.enforcement: \"rewrite\"`. Pipelines, redirects, substitutions — anything\n * this parser cannot be sure about — pass untouched. Fail open, always.\n */\nexport function decide(\n request: GuardRequest,\n settings: GuardSettings,\n cwd: string,\n statFile: (path: string) => { size: number; isFile: boolean } | undefined = statFileReal,\n): GuardDecision {\n if (request.tool === 'Read') {\n return decideRead(request.input, settings, cwd, statFile);\n }\n if (request.tool === 'Bash') {\n return decideBash(request.input, settings, cwd, statFile);\n }\n return ALLOW;\n}\n\nfunction statFileReal(path: string): { size: number; isFile: boolean } | undefined {\n try {\n const stat = statSync(path);\n return { size: stat.size, isFile: stat.isFile() };\n } catch {\n return undefined;\n }\n}\n\nfunction decideRead(\n input: GuardRequest['input'],\n settings: GuardSettings,\n cwd: string,\n statFile: (path: string) => { size: number; isFile: boolean } | undefined,\n): GuardDecision {\n if (input.path === undefined) return ALLOW;\n if (input.offsetLimited === true) return ALLOW; // already windowed — an economy move\n const path = isAbsolute(input.path) ? input.path : resolve(cwd, input.path);\n const stat = statFile(path);\n if (stat === undefined || !stat.isFile) return ALLOW; // let the tool surface its own error\n if (stat.size <= settings.thresholdBytes) return ALLOW;\n return denyOversized(path, stat.size, settings, 'Reading it raw');\n}\n\nfunction decideBash(\n input: GuardRequest['input'],\n settings: GuardSettings,\n cwd: string,\n statFile: (path: string) => { size: number; isFile: boolean } | undefined,\n): GuardDecision {\n if (input.command === undefined) return ALLOW;\n const command = input.command.trim();\n // A command already using smelt is the model doing the right thing — including the\n // exact replacement a previous deny suggested. Never intercept it (and never wrap a\n // wrapped pipeline a second time).\n if (/(^|[\\s/\"'=])smelt($|[\\s\"'])/.test(command)) return ALLOW;\n\n const words = simpleCommandWords(command);\n if (words === undefined || words.length === 0) return ALLOW; // not simple — unknowable pre-run\n\n const program = words[0]!.split('/').at(-1)!;\n\n if (program === 'cat') {\n const files = words.slice(1).filter((word) => word !== '--' && !word.startsWith('-'));\n for (const file of files) {\n const path = isAbsolute(file) ? file : resolve(cwd, file);\n const stat = statFile(path);\n if (stat === undefined || !stat.isFile) continue;\n if (stat.size > settings.thresholdBytes) {\n const decision = denyOversized(path, stat.size, settings, `\\`${command}\\``);\n // The suggestion is only a *faithful* replacement when cat named exactly this\n // one file; `cat a b` replaced by `smelt a` would silently drop b, so the\n // multi-file case keeps the reason (the model decides), drops the suggestion\n // (nothing may auto-substitute it), and says out loud that the named\n // replacement covers only the oversized file — a model following the reason\n // verbatim must not silently drop the others.\n return files.length === 1\n ? decision\n : {\n action: 'deny',\n reason:\n `${decision.reason ?? ''} Note: \\`${command}\\` names ` +\n `${String(files.length)} files and the replacement above covers only ` +\n `${path} — read the other file(s) separately (cat is fine for the ones ` +\n `under the threshold).`,\n };\n }\n }\n return ALLOW;\n }\n\n if ((program === 'grep' || program === 'rg') && settings.enforcement === 'rewrite') {\n const pattern = searchPattern(words);\n if (pattern === undefined) return ALLOW;\n // Deliberately no `--focus` on the wrap: a plain grep's every output line contains\n // the searched pattern, so focusing on it would protect the entire output — zero\n // elisions exactly when the output is large, plus an over-budget exit. The wrap\n // lets smelt's lexical planner keep the head and tail and collapse the middle\n // into retrievable markers instead.\n const wrapped = `${command} | ${SMELT_CLI} --budget ${String(settings.budgetBytes)}`;\n return {\n action: 'deny',\n reason:\n `smelt guard (rewrite mode): \\`${program}\\` output size is unknowable before it runs, ` +\n `so pipe it through smelt instead. Run exactly: ${wrapped} — output within the ` +\n `budget passes through untouched; past it, elided regions leave <<smelt/v1 …>> ` +\n `markers. ${retrieveSentence(settings)}`,\n suggestion: wrapped,\n };\n }\n\n return ALLOW;\n}\n\n/**\n * The one sentence about getting elided bytes back — honest about the store: the\n * retrieval promise is only made when a persistent store is configured, because a\n * memory store dies with the process and `retrieve` then refuses (`resolveStoreRun`).\n */\nfunction retrieveSentence(settings: GuardSettings): string {\n return settings.persistentStore\n ? `\\`${SMELT_CLI} retrieve <hash>\\` prints any marker's bytes back, byte for byte.`\n : `\\`${SMELT_CLI} retrieve <hash>\\` can print a marker's bytes back once a persistent ` +\n `store is configured ({\"store\":{\"kind\":\"directory\",\"path\":…}} in smelt.config.json — ` +\n `\\`smelt hooks install\\` writes one); without it the elided bytes die with the ` +\n `smelt process.`;\n}\n\n/** The deny everything above the threshold gets: steering text plus the exact command. */\nfunction denyOversized(\n path: string,\n size: number,\n settings: GuardSettings,\n what: string,\n): GuardDecision {\n const replacement = `${SMELT_CLI} ${shellQuote(path)} --budget ${String(settings.budgetBytes)}`;\n return {\n action: 'deny',\n reason:\n `smelt guard: ${path} is ${String(size)} bytes — over the ${String(settings.thresholdBytes)}-byte ` +\n `threshold (smelt.config.json hooks.thresholdBytes). ${what} would spend context on bytes ` +\n `the task may not need. Run instead: ${replacement} --focus <what you are looking for> ` +\n `(repeat --focus per term; focused regions survive verbatim). Elided regions leave ` +\n `<<smelt/v1 …>> markers — ${retrieveSentence(settings)} A windowed read (offset/limit) ` +\n `of just the lines you need is also fine.`,\n suggestion: replacement,\n };\n}\n\n/**\n * Split a command into words IF it is one simple command: no pipes, no logic, no\n * redirects, no substitutions, no expansions this code would have to model. Anything\n * else returns `undefined` and the caller allows — the guard judges only what it can\n * see whole.\n */\nexport function simpleCommandWords(command: string): readonly string[] | undefined {\n const words: string[] = [];\n let current = '';\n let started = false;\n let i = 0;\n const push = (): void => {\n if (started) words.push(current);\n current = '';\n started = false;\n };\n while (i < command.length) {\n const ch = command[i]!;\n if ('|&;<>()`$\\\\\\n*?~{}!'.includes(ch)) return undefined; // shell would interpret it\n if (ch === \"'\" || ch === '\"') {\n const quote = ch;\n i += 1;\n started = true;\n while (i < command.length && command[i] !== quote) {\n if (quote === '\"' && (command[i] === '$' || command[i] === '`' || command[i] === '\\\\')) {\n return undefined; // expansions inside double quotes — not simple\n }\n current += command[i]!;\n i += 1;\n }\n if (i >= command.length) return undefined; // unterminated quote\n i += 1;\n continue;\n }\n if (ch === ' ' || ch === '\\t') {\n push();\n i += 1;\n continue;\n }\n current += ch;\n started = true;\n i += 1;\n }\n push();\n return words;\n}\n\n/**\n * The pattern a grep/rg invocation searches for: an explicit `-e`/`--regexp` value if\n * given, else the first word that is not a flag or a flag's value. `undefined` when\n * the parse is not sure — and unsure means allow, like everything else here.\n */\nexport function searchPattern(words: readonly string[]): string | undefined {\n const takesValue = new Set([\n '-e',\n '--regexp',\n '-f',\n '--file',\n '-m',\n '--max-count',\n '-A',\n '--after-context',\n '-B',\n '--before-context',\n '-C',\n '--context',\n '-d',\n '--directories',\n '-D',\n '--devices',\n '--include',\n '--exclude',\n '--exclude-dir',\n '-t',\n '--type',\n '-T',\n '--type-not',\n '-g',\n '--glob',\n '--iglob',\n '-j',\n '--threads',\n '--color',\n '--colour',\n ]);\n let i = 1;\n while (i < words.length) {\n const word = words[i]!;\n if (word === '--') return words[i + 1];\n if (word === '-e' || word === '--regexp') return words[i + 1];\n if (word.startsWith('--') && word.includes('=')) {\n i += 1;\n continue;\n }\n if (word.startsWith('-') && word.length > 1) {\n i += takesValue.has(word) ? 2 : 1;\n continue;\n }\n return word;\n }\n return undefined;\n}\n\n/** Single-quote a value for `sh` unless it is plainly safe bare. */\nexport function shellQuote(value: string): string {\n if (/^[A-Za-z0-9_./:=-]+$/.test(value)) return value;\n return `'${value.replaceAll(\"'\", `'\"'\"'`)}'`;\n}\n\n/* ------------------------------------------------------------------------------------\n * Process plumbing, for the shims that run as one\n * ---------------------------------------------------------------------------------- */\n\n/** True when this module is the file node was asked to run, not an import. */\nexport function isMainModule(moduleUrl: string): boolean {\n const entry = process.argv[1];\n if (entry === undefined) return false;\n try {\n return pathToFileURL(entry).href === moduleUrl;\n } catch {\n return false;\n }\n}\n\n/** Every byte of fd 0 to EOF, retrying EAGAIN — the same shape `cli/bin.ts` uses. */\nexport function readAllOfStdin(): string {\n const sleeper = new Int32Array(new SharedArrayBuffer(4));\n const chunks: Buffer[] = [];\n const chunk = Buffer.alloc(1 << 16);\n for (;;) {\n let bytesRead: number;\n try {\n bytesRead = readSync(0, chunk, 0, chunk.length, null);\n } catch (error) {\n const code = (error as { code?: string }).code;\n if (code === 'EAGAIN') {\n Atomics.wait(sleeper, 0, 0, 10);\n continue;\n }\n if (code === 'EOF') break;\n throw error;\n }\n if (bytesRead === 0) break;\n chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));\n }\n return Buffer.concat(chunks).toString('utf8');\n}\n"]}
@@ -3,13 +3,15 @@ import { dirname, isAbsolute, join, resolve } from 'node:path';
3
3
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
  import process from 'node:process';
5
5
  /**
6
- * The guard core — one zero-dependency node script, shared by every harness shim.
6
+ * The guard core — one zero-dependency node module, shared by every harness shim.
7
7
  *
8
- * Contract (KOT-212): stdin carries one JSON request
9
- * `{ tool, input: { path?, command?, offsetLimited? } }`, stdout answers with one
10
- * JSON decision `{ action: "allow" | "deny", reason?, suggestion? }`, and the exit
11
- * code is always 0 the decision lives in the JSON, and each shim translates it
12
- * into its harness's own schema (exit 2, `permissionDecision`, `cancel:true`, …).
8
+ * Contract: a caller hands `decide` a {@link GuardRequest}
9
+ * (`{ tool, input: { path?, command?, offsetLimited? } }`) with the settings
10
+ * {@link readGuardSettings} read, and gets one {@link GuardDecision} back
11
+ * `{ action: "allow" | "deny", reason?, suggestion? }`. Each shim translates that into
12
+ * its harness's own schema (exit 2, `permissionDecision`, `cancel:true`, …), and the
13
+ * opencode plugin — the one harness whose hook API is JavaScript — imports this module
14
+ * at hook time and calls the same two functions.
13
15
  *
14
16
  * Two properties are load-bearing and guarded:
15
17
  *
@@ -154,7 +156,12 @@ export function findGuardConfigFile(cwd) {
154
156
  dir = parent;
155
157
  }
156
158
  }
157
- /** Parse one stdin request. `undefined` means malformed — the caller allows and warns. */
159
+ /**
160
+ * Parse one {@link GuardRequest} *document* — the request shape as JSON, for an
161
+ * adapter that receives it over a pipe rather than building it in-process.
162
+ * `undefined` means malformed, and a caller that gets it allows and warns: the same
163
+ * fail-open rule the rest of this module lives under.
164
+ */
158
165
  export function parseGuardRequest(text) {
159
166
  let parsed;
160
167
  try {
@@ -441,7 +448,7 @@ export function shellQuote(value) {
441
448
  return `'${value.replaceAll("'", `'"'"'`)}'`;
442
449
  }
443
450
  /* ------------------------------------------------------------------------------------
444
- * The script half: `node dist/hooks/guard-core.js` stdin request, stdout decision.
451
+ * Process plumbing, for the shims that run as one
445
452
  * ---------------------------------------------------------------------------------- */
446
453
  /** True when this module is the file node was asked to run, not an import. */
447
454
  export function isMainModule(moduleUrl) {
@@ -481,33 +488,4 @@ export function readAllOfStdin() {
481
488
  }
482
489
  return Buffer.concat(chunks).toString('utf8');
483
490
  }
484
- /**
485
- * Run the guard over stdin and print the decision. Exit code 0 in every case —
486
- * including every failure, which becomes an allow with a stderr warning. A guard
487
- * subprocess that exits non-zero on its own bug would look like a policy decision
488
- * to some harnesses, and "my hook crashed" must never read as "denied".
489
- */
490
- export function runGuardMain() {
491
- let decision = ALLOW;
492
- try {
493
- const raw = readAllOfStdin();
494
- const request = parseGuardRequest(raw);
495
- if (request === undefined) {
496
- process.stderr.write(`smelt guard: stdin was not a {tool, input} JSON request — allowing the call. ` +
497
- `A malformed hook wiring must never brick the session; fix the shim, not the model.\n`);
498
- }
499
- else {
500
- const settings = readGuardSettings(process.cwd(), (text) => process.stderr.write(`${text}\n`));
501
- decision = decide(request, settings, process.cwd());
502
- }
503
- }
504
- catch (error) {
505
- process.stderr.write(`smelt guard: unexpected error — allowing the call. ` +
506
- `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
507
- decision = ALLOW;
508
- }
509
- process.stdout.write(`${JSON.stringify(decision)}\n`);
510
- }
511
- if (isMainModule(import.meta.url))
512
- runGuardMain();
513
491
  //# sourceMappingURL=guard-core.js.map