@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
@@ -3,7 +3,7 @@ import { dirname, join, resolve } from 'node:path';
3
3
  import { CliUsageError } from '../errors.js';
4
4
  import { ENFORCEMENT_MODES } from '../hooks/guard-core.js';
5
5
  import { isStrategy, STRATEGIES } from '../plan/planners.js';
6
- import { CLI_NAME } from './args.js';
6
+ import { CLI_NAME } from './shell.js';
7
7
  /**
8
8
  * `smelt.config.json` — CLI defaults, and nothing more.
9
9
  *
@@ -85,7 +85,7 @@ export function parseConfig(text, path) {
85
85
  `${String(CONFIG_VERSION)}. The schema is versioned so a mismatch is visible ` +
86
86
  `instead of half-understood.`);
87
87
  }
88
- const known = ['smeltConfig', 'defaultBudgetBytes', 'strategy', 'store', 'hooks'];
88
+ const known = ['smeltConfig', 'defaultBudgetBytes', 'strategy', 'store', 'hooks', 'agents'];
89
89
  const unknown = Object.keys(fields).filter((key) => !known.includes(key));
90
90
  if (unknown.length > 0) {
91
91
  throw bad(`unknown key${unknown.length === 1 ? '' : 's'} ${unknown.map((k) => `"${k}"`).join(', ')}. ` +
@@ -106,14 +106,60 @@ export function parseConfig(text, path) {
106
106
  }
107
107
  const store = parseStore(fields['store'], bad);
108
108
  const hooks = parseHooks(fields['hooks'], bad);
109
+ const agents = parseAgents(fields['agents'], bad);
109
110
  return {
110
111
  smeltConfig: CONFIG_VERSION,
111
112
  ...(budget === undefined ? {} : { defaultBudgetBytes: budget }),
112
113
  ...(strategy === undefined ? {} : { strategy }),
113
114
  ...(store === undefined ? {} : { store }),
114
115
  ...(hooks === undefined ? {} : { hooks }),
116
+ ...(agents === undefined ? {} : { agents }),
115
117
  };
116
118
  }
119
+ /**
120
+ * Serialize a config back to the bytes of a `smelt.config.json` — {@link parseConfig}'s
121
+ * inverse, and the **only** writer of this file.
122
+ *
123
+ * The module that owns the schema owns both directions. It used to own only the read:
124
+ * two modules hand-built a config object and stringified it — the `init` wizard and
125
+ * `hooks install` — and they had already drifted, one always emitting `strategy` and
126
+ * the other only when it was carried. A sixth key would have been written by whichever
127
+ * module its author was editing and silently dropped by the other, which is the
128
+ * "setting the user believed was in force" failure this file refuses everywhere else.
129
+ *
130
+ * Key order is fixed here — the reader's own key order, top level and inside `store`
131
+ * and `hooks` — so a re-run diffs cleanly no matter which verb wrote the file, and two
132
+ * writers can never disagree about shape. What goes *into* the config stays each
133
+ * verb's policy: the wizard's choices, and `hooks install`'s directory-store injection.
134
+ * Absent keys are omitted rather than written as `null`, so
135
+ * `parseConfig(renderConfig(c))` returns `c` field for field —
136
+ * `test/guards/config-writer.test.ts` pins that round trip.
137
+ */
138
+ export function renderConfig(config) {
139
+ const ordered = {
140
+ smeltConfig: CONFIG_VERSION,
141
+ ...(config.defaultBudgetBytes === undefined
142
+ ? {}
143
+ : { defaultBudgetBytes: config.defaultBudgetBytes }),
144
+ ...(config.strategy === undefined ? {} : { strategy: config.strategy }),
145
+ ...(config.store === undefined ? {} : { store: renderStore(config.store) }),
146
+ ...(config.hooks === undefined ? {} : { hooks: renderHooks(config.hooks) }),
147
+ ...(config.agents === undefined ? {} : { agents: renderAgents(config.agents) }),
148
+ };
149
+ return `${JSON.stringify(ordered, null, 2)}\n`;
150
+ }
151
+ function renderStore(store) {
152
+ return store.kind === 'memory' ? { kind: 'memory' } : { kind: 'directory', path: store.path };
153
+ }
154
+ function renderHooks(hooks) {
155
+ return {
156
+ ...(hooks.thresholdBytes === undefined ? {} : { thresholdBytes: hooks.thresholdBytes }),
157
+ ...(hooks.enforcement === undefined ? {} : { enforcement: hooks.enforcement }),
158
+ };
159
+ }
160
+ function renderAgents(agents) {
161
+ return agents.budgetBytes === undefined ? {} : { budgetBytes: agents.budgetBytes };
162
+ }
117
163
  function parseStore(value, bad) {
118
164
  if (value === undefined)
119
165
  return undefined;
@@ -168,8 +214,49 @@ function parseHooks(value, bad) {
168
214
  ...(enforcement === undefined ? {} : { enforcement: enforcement }),
169
215
  };
170
216
  }
217
+ /**
218
+ * The `agents` block, parsed as strictly as every other: one key, a whole number of
219
+ * bytes greater than zero.
220
+ *
221
+ * A malformed `agents.budgetBytes` is refused rather than ignored for the reason the
222
+ * whole file is parsed strictly — an ignored budget is a ceiling the user believed was
223
+ * in force. And there is no coercion from a string: `"budgetBytes": "4kb"` is a
224
+ * mistake with a right answer, and guessing it would be smelt inventing a number.
225
+ */
226
+ function parseAgents(value, bad) {
227
+ if (value === undefined)
228
+ return undefined;
229
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
230
+ throw bad(`"agents" must be an object like {"budgetBytes":2000}.`);
231
+ }
232
+ const fields = value;
233
+ const extra = Object.keys(fields).filter((key) => key !== 'budgetBytes');
234
+ if (extra.length > 0) {
235
+ throw bad(`"agents" takes only "budgetBytes", got ${extra.map((k) => `"${k}"`).join(', ')}.`);
236
+ }
237
+ const budget = fields['budgetBytes'];
238
+ if (budget !== undefined &&
239
+ (typeof budget !== 'number' || !Number.isInteger(budget) || budget <= 0)) {
240
+ throw bad(`"agents".budgetBytes must be a whole number of bytes greater than zero.`);
241
+ }
242
+ return budget === undefined ? {} : { budgetBytes: budget };
243
+ }
171
244
  /** `store.path` is relative to the config file, so the config works from any cwd. */
172
245
  export function resolveStorePath(loaded, path) {
173
246
  return resolve(dirname(loaded.path), path);
174
247
  }
248
+ /**
249
+ * The store leg of every merge, read once here rather than in each verb that needs it.
250
+ *
251
+ * There is no store flag, so this leg is config-or-built-in only — which is exactly
252
+ * why it belongs to the config module and not to a verb: `smelt` builds a store from
253
+ * it, and `retrieve`/`stats` refuse when it is not a directory (see `resolveStoreRun`
254
+ * in `subcommands/retrieve.ts`). Three verbs, one reading of the same key.
255
+ */
256
+ export function configuredStore(config) {
257
+ if (config?.config.store === undefined || config.config.store.kind === 'memory') {
258
+ return { kind: 'memory' };
259
+ }
260
+ return { kind: 'directory', path: resolveStorePath(config, config.config.store.path) };
261
+ }
175
262
  //# sourceMappingURL=config.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/cli/config.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/cli/config.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, readFileSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\n\nimport { CliUsageError } from '../errors.ts';\nimport { ENFORCEMENT_MODES } from '../hooks/guard-core.ts';\nimport type { EnforcementMode } from '../hooks/guard-core.ts';\nimport { isStrategy, STRATEGIES } from '../plan/planners.ts';\nimport type { Strategy } from '../plan/planners.ts';\n\nimport { CLI_NAME } from './shell.ts';\n\n/**\n * `smelt.config.json` — CLI defaults, and nothing more.\n *\n * This file exists so `smelt` can be run without retyping the same flags, and it is\n * deliberately a *CLI* concern: the programmatic API never reads it. A library that\n * reads config files off the caller's disk is a library whose behaviour depends on\n * where it was invoked from, which is exactly the class of invisible input smelt\n * refuses. `createSmelter()` takes explicit arguments; the CLI translates this file\n * into those arguments, and explicit flags always win over it.\n *\n * The schema is versioned (`\"smeltConfig\": 1`) for the same reason the marker and the\n * `--json` envelope are: files outlive the binaries that wrote them, and a mismatch\n * must be identifiable rather than half-understood. Parsing is strict — an unknown\n * key, a wrong type, or an unknown version is a {@link CliUsageError}, never a shrug.\n * A config the CLI silently ignored would be a budget the user *thought* they set.\n */\n\n/** The file name looked for, from the working directory upward. */\nexport const CONFIG_FILE_NAME = 'smelt.config.json';\n\n/** The schema version this build reads and writes. */\nexport const CONFIG_VERSION = 1;\n\n/** Where the CLI's elision store lives between runs. */\nexport type SmeltConfigStore =\n | { readonly kind: 'memory' }\n | {\n readonly kind: 'directory';\n /** Resolved relative to the directory holding the config file, not the cwd. */\n readonly path: string;\n };\n\n/**\n * The `hooks` block — settings for the harness guard (`smelt hooks install`).\n *\n * Parsed strictly here, like every other key: the CLI refuses a malformed config.\n * The guard core (`src/hooks/guard-core.ts`) reads the same file with its own\n * *tolerant* reader — a guard running inside somebody's session fails open where the\n * CLI correctly refuses — and `test/hooks-guard-core.test.ts` pins the two readers to\n * the same key names and defaults so they cannot drift apart.\n */\nexport interface SmeltConfigHooks {\n /** Reads at or under this many bytes pass the guard untouched. Default 8192. */\n readonly thresholdBytes?: number;\n /**\n * `'deny'` (default): oversized raw reads are refused with a reason naming the\n * exact replacement command. `'rewrite'`: on harnesses whose hooks can modify tool\n * input, the command is substituted instead (never silently — the reason says so).\n */\n readonly enforcement?: EnforcementMode;\n}\n\n/**\n * The `agents` block — settings for `smelt agents lint`.\n *\n * One key, and it is the user's number: how many UTF-8 bytes of instruction file this\n * project is willing to pay on every request. **There is no default and there never\n * will be** — the same ruling as `--budget`, for the same reason. A budget smelt\n * invented would be smelt deciding how much of somebody's context window their own\n * house rules deserve, at a number nobody chose. The guide's cited \"~150-200\n * instructions\" figure is printed by the lint as a citation and compared to nothing.\n */\nexport interface SmeltConfigAgents {\n /**\n * The merged set's byte ceiling — every `AGENTS.md`/`CLAUDE.md`/`GEMINI.md` in the\n * tree, summed. Absent means unbudgeted: the lint measures and reports, and no\n * number can fail.\n */\n readonly budgetBytes?: number;\n}\n\n/** The parsed shape of `smelt.config.json`. Every field beyond the version is optional. */\nexport interface SmeltConfig {\n readonly smeltConfig: typeof CONFIG_VERSION;\n /** Used when a `smelt` run omits `--budget`. UTF-8 bytes, like every smelt budget. */\n readonly defaultBudgetBytes?: number;\n /** Used when a run omits `--strategy`. Validated against the {@link PLANNERS} registry. */\n readonly strategy?: Strategy;\n /** Used for every run; there is no store flag. Defaults to memory when absent. */\n readonly store?: SmeltConfigStore;\n /** Settings for the harness guard. See {@link SmeltConfigHooks}. */\n readonly hooks?: SmeltConfigHooks;\n /** Settings for `smelt agents lint`. See {@link SmeltConfigAgents}. */\n readonly agents?: SmeltConfigAgents;\n}\n\n/** A config plus where it was found — the path matters for resolving `store.path`. */\nexport interface LoadedConfig {\n readonly path: string;\n readonly config: SmeltConfig;\n}\n\n/**\n * The nearest `smelt.config.json`, walking up from `cwd` to the filesystem root —\n * the same discovery shape as `package.json`, so a config at a repo root covers the\n * whole tree. `undefined` when there is none, which is not an error: flags alone are\n * a complete interface.\n */\nexport function findConfigFile(cwd: string): string | undefined {\n let dir = resolve(cwd);\n for (;;) {\n const candidate = join(dir, 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 * Find and parse the nearest config. `undefined` when no file exists; a file that\n * exists but is malformed **throws** — see {@link parseConfig}. The distinction is the\n * point: \"no config\" is a fine state, \"a config you cannot have meant\" is not.\n *\n * @throws {CliUsageError} when a config file exists and is malformed.\n */\nexport function loadNearestConfig(cwd: string): LoadedConfig | undefined {\n const path = findConfigFile(cwd);\n if (path === undefined) return undefined;\n return { path, config: parseConfig(readFileSync(path, 'utf8'), path) };\n}\n\n/**\n * Parse and validate one config file, strictly.\n *\n * Strict means: unknown keys are refused, not skipped. A typo'd `defaultBudgetByte`\n * that parsed cleanly would leave the user believing a default was set while every\n * run ignored it — a config error whose failure mode is *silence*, which is the one\n * failure shape this project refuses everywhere else too.\n *\n * @throws {CliUsageError} naming the file and the exact problem.\n */\nexport function parseConfig(text: string, path: string): SmeltConfig {\n const bad = (why: string): CliUsageError =>\n new CliUsageError(\n `${CLI_NAME}: malformed ${CONFIG_FILE_NAME} at ${path}: ${why}\\n` +\n `Fix it or delete it — a config smelt cannot read is never silently ignored. ` +\n `\\`${CLI_NAME} init\\` can rewrite it.`,\n );\n\n let value: unknown;\n try {\n value = JSON.parse(text);\n } catch (cause) {\n throw bad(`not JSON (${cause instanceof Error ? cause.message : String(cause)}).`);\n }\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n throw bad('expected a JSON object at the top level.');\n }\n const fields = value as Record<string, unknown>;\n\n if (fields['smeltConfig'] !== CONFIG_VERSION) {\n throw bad(\n `\"smeltConfig\" is ${JSON.stringify(fields['smeltConfig'])}; this build reads version ` +\n `${String(CONFIG_VERSION)}. The schema is versioned so a mismatch is visible ` +\n `instead of half-understood.`,\n );\n }\n\n const known = ['smeltConfig', 'defaultBudgetBytes', 'strategy', 'store', 'hooks', 'agents'];\n const unknown = Object.keys(fields).filter((key) => !known.includes(key));\n if (unknown.length > 0) {\n throw bad(\n `unknown key${unknown.length === 1 ? '' : 's'} ${unknown.map((k) => `\"${k}\"`).join(', ')}. ` +\n `Known keys: ${known.join(', ')}. Unknown keys are refused because a typo that ` +\n `parsed cleanly would be a setting you believed was set.`,\n );\n }\n\n const budget = fields['defaultBudgetBytes'];\n if (budget !== undefined && (typeof budget !== 'number' || !Number.isInteger(budget))) {\n throw bad(`\"defaultBudgetBytes\" must be a whole number of UTF-8 bytes.`);\n }\n if (typeof budget === 'number' && budget <= 0) {\n throw bad(`\"defaultBudgetBytes\" must be greater than zero, got ${String(budget)}.`);\n }\n\n const strategy = fields['strategy'];\n if (strategy !== undefined && (typeof strategy !== 'string' || !isStrategy(strategy))) {\n throw bad(\n `\"strategy\" must be ${STRATEGIES.map((s) => `\"${s}\"`).join(' or ')}, ` +\n `got ${JSON.stringify(strategy)}.`,\n );\n }\n\n const store = parseStore(fields['store'], bad);\n const hooks = parseHooks(fields['hooks'], bad);\n const agents = parseAgents(fields['agents'], bad);\n\n return {\n smeltConfig: CONFIG_VERSION,\n ...(budget === undefined ? {} : { defaultBudgetBytes: budget }),\n ...(strategy === undefined ? {} : { strategy }),\n ...(store === undefined ? {} : { store }),\n ...(hooks === undefined ? {} : { hooks }),\n ...(agents === undefined ? {} : { agents }),\n };\n}\n\n/**\n * Serialize a config back to the bytes of a `smelt.config.json` — {@link parseConfig}'s\n * inverse, and the **only** writer of this file.\n *\n * The module that owns the schema owns both directions. It used to own only the read:\n * two modules hand-built a config object and stringified it — the `init` wizard and\n * `hooks install` — and they had already drifted, one always emitting `strategy` and\n * the other only when it was carried. A sixth key would have been written by whichever\n * module its author was editing and silently dropped by the other, which is the\n * \"setting the user believed was in force\" failure this file refuses everywhere else.\n *\n * Key order is fixed here — the reader's own key order, top level and inside `store`\n * and `hooks` — so a re-run diffs cleanly no matter which verb wrote the file, and two\n * writers can never disagree about shape. What goes *into* the config stays each\n * verb's policy: the wizard's choices, and `hooks install`'s directory-store injection.\n * Absent keys are omitted rather than written as `null`, so\n * `parseConfig(renderConfig(c))` returns `c` field for field —\n * `test/guards/config-writer.test.ts` pins that round trip.\n */\nexport function renderConfig(config: SmeltConfig): string {\n const ordered: SmeltConfig = {\n smeltConfig: CONFIG_VERSION,\n ...(config.defaultBudgetBytes === undefined\n ? {}\n : { defaultBudgetBytes: config.defaultBudgetBytes }),\n ...(config.strategy === undefined ? {} : { strategy: config.strategy }),\n ...(config.store === undefined ? {} : { store: renderStore(config.store) }),\n ...(config.hooks === undefined ? {} : { hooks: renderHooks(config.hooks) }),\n ...(config.agents === undefined ? {} : { agents: renderAgents(config.agents) }),\n };\n return `${JSON.stringify(ordered, null, 2)}\\n`;\n}\n\nfunction renderStore(store: SmeltConfigStore): SmeltConfigStore {\n return store.kind === 'memory' ? { kind: 'memory' } : { kind: 'directory', path: store.path };\n}\n\nfunction renderHooks(hooks: SmeltConfigHooks): SmeltConfigHooks {\n return {\n ...(hooks.thresholdBytes === undefined ? {} : { thresholdBytes: hooks.thresholdBytes }),\n ...(hooks.enforcement === undefined ? {} : { enforcement: hooks.enforcement }),\n };\n}\n\nfunction renderAgents(agents: SmeltConfigAgents): SmeltConfigAgents {\n return agents.budgetBytes === undefined ? {} : { budgetBytes: agents.budgetBytes };\n}\n\nfunction parseStore(\n value: unknown,\n bad: (why: string) => CliUsageError,\n): SmeltConfigStore | undefined {\n if (value === undefined) return undefined;\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n throw bad(`\"store\" must be an object like {\"kind\":\"memory\"} or {\"kind\":\"directory\",\"path\":…}.`);\n }\n const fields = value as Record<string, unknown>;\n const kind = fields['kind'];\n if (kind === 'memory') {\n const extra = Object.keys(fields).filter((key) => key !== 'kind');\n if (extra.length > 0) throw bad(`\"store\" of kind \"memory\" takes no other keys.`);\n return { kind: 'memory' };\n }\n if (kind === 'directory') {\n const path = fields['path'];\n if (typeof path !== 'string' || path === '') {\n throw bad(`\"store\" of kind \"directory\" needs a non-empty \"path\".`);\n }\n const extra = Object.keys(fields).filter((key) => key !== 'kind' && key !== 'path');\n if (extra.length > 0) throw bad(`\"store\" of kind \"directory\" takes only \"kind\" and \"path\".`);\n return { kind: 'directory', path };\n }\n throw bad(`\"store\".kind must be \"memory\" or \"directory\", got ${JSON.stringify(kind)}.`);\n}\n\nfunction parseHooks(\n value: unknown,\n bad: (why: string) => CliUsageError,\n): SmeltConfigHooks | undefined {\n if (value === undefined) return undefined;\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n throw bad(`\"hooks\" must be an object like {\"thresholdBytes\":8192,\"enforcement\":\"deny\"}.`);\n }\n const fields = value as Record<string, unknown>;\n const extra = Object.keys(fields).filter(\n (key) => key !== 'thresholdBytes' && key !== 'enforcement',\n );\n if (extra.length > 0) {\n throw bad(\n `\"hooks\" takes only \"thresholdBytes\" and \"enforcement\", ` +\n `got ${extra.map((k) => `\"${k}\"`).join(', ')}.`,\n );\n }\n const threshold = fields['thresholdBytes'];\n if (\n threshold !== undefined &&\n (typeof threshold !== 'number' || !Number.isInteger(threshold) || threshold <= 0)\n ) {\n throw bad(`\"hooks\".thresholdBytes must be a whole number of bytes greater than zero.`);\n }\n const enforcement = fields['enforcement'];\n if (\n enforcement !== undefined &&\n !(ENFORCEMENT_MODES as readonly unknown[]).includes(enforcement)\n ) {\n throw bad(\n `\"hooks\".enforcement must be ${ENFORCEMENT_MODES.map((m) => `\"${m}\"`).join(' or ')}, ` +\n `got ${JSON.stringify(enforcement)}.`,\n );\n }\n return {\n ...(threshold === undefined ? {} : { thresholdBytes: threshold as number }),\n ...(enforcement === undefined ? {} : { enforcement: enforcement as EnforcementMode }),\n };\n}\n\n/**\n * The `agents` block, parsed as strictly as every other: one key, a whole number of\n * bytes greater than zero.\n *\n * A malformed `agents.budgetBytes` is refused rather than ignored for the reason the\n * whole file is parsed strictly — an ignored budget is a ceiling the user believed was\n * in force. And there is no coercion from a string: `\"budgetBytes\": \"4kb\"` is a\n * mistake with a right answer, and guessing it would be smelt inventing a number.\n */\nfunction parseAgents(\n value: unknown,\n bad: (why: string) => CliUsageError,\n): SmeltConfigAgents | undefined {\n if (value === undefined) return undefined;\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n throw bad(`\"agents\" must be an object like {\"budgetBytes\":2000}.`);\n }\n const fields = value as Record<string, unknown>;\n const extra = Object.keys(fields).filter((key) => key !== 'budgetBytes');\n if (extra.length > 0) {\n throw bad(`\"agents\" takes only \"budgetBytes\", got ${extra.map((k) => `\"${k}\"`).join(', ')}.`);\n }\n const budget = fields['budgetBytes'];\n if (\n budget !== undefined &&\n (typeof budget !== 'number' || !Number.isInteger(budget) || budget <= 0)\n ) {\n throw bad(`\"agents\".budgetBytes must be a whole number of bytes greater than zero.`);\n }\n return budget === undefined ? {} : { budgetBytes: budget as number };\n}\n\n/** `store.path` is relative to the config file, so the config works from any cwd. */\nexport function resolveStorePath(loaded: LoadedConfig, path: string): string {\n return resolve(dirname(loaded.path), path);\n}\n/**\n * The store decision a config carries, with `path` already resolved against the config\n * file's directory — one config serves every subdirectory it covers without scattering\n * store roots. `'memory'` is the built-in default: a fresh in-memory store per run.\n */\nexport type ConfiguredStore =\n { readonly kind: 'memory' } | { readonly kind: 'directory'; readonly path: string };\n\n/**\n * The store leg of every merge, read once here rather than in each verb that needs it.\n *\n * There is no store flag, so this leg is config-or-built-in only — which is exactly\n * why it belongs to the config module and not to a verb: `smelt` builds a store from\n * it, and `retrieve`/`stats` refuse when it is not a directory (see `resolveStoreRun`\n * in `subcommands/retrieve.ts`). Three verbs, one reading of the same key.\n */\nexport function configuredStore(config: LoadedConfig | undefined): ConfiguredStore {\n if (config?.config.store === undefined || config.config.store.kind === 'memory') {\n return { kind: 'memory' };\n }\n return { kind: 'directory', path: resolveStorePath(config, config.config.store.path) };\n}\n"]}
@@ -1,24 +1,37 @@
1
+ import type { HarnessProfile } from '../harness/profile.ts';
2
+ import { instructionSnippet, SNIPPET_END_MD, SNIPPET_START_MD } from '../harness/snippet.ts';
1
3
  import type { EnforcementMode } from '../hooks/guard-core.ts';
4
+ import type { AnswerStream } from './shell.ts';
2
5
  import type { SmeltConfig, SmeltConfigHooks } from './config.ts';
3
6
  /**
4
7
  * `smelt hooks install` / `smelt hooks remove` — the multi-harness guard preset.
5
8
  *
6
- * The design (KOT-212, founder rulings 2026-09-02): one zero-dependency guard core
9
+ * The design: one zero-dependency guard core
7
10
  * (`src/hooks/guard-core.ts`), thin per-harness shims mapping each harness's native
8
11
  * hook schema onto it, and this installer, which writes the harness config that wires
9
12
  * a shim in — plus an instruction-file snippet as belt and braces, because the
10
13
  * snippet is also what teaches the model to run `smelt retrieve` after a deny.
11
14
  *
12
- * Harnesses come in three honesty tiers (docs/research/2026-09-02-harness-capability-matrix.md):
15
+ * Every per-harness fact lives in that harness's {@link HarnessProfile}
16
+ * (`src/harness/<id>.ts`), including what to write and how to take it back out. This
17
+ * module owns only what is the *same* for every harness: the hooks merge (which entries
18
+ * are ours, what a re-run replaces), the wizard, and the two plans below — folds over
19
+ * `profile.install`, with no case list of its own. The byte-faithful editing itself —
20
+ * one top-level JSON property, one delimited text block — is `src/text/json-edit.ts`,
21
+ * which knows nothing about harnesses.
13
22
  *
14
- * - **verified** Claude Code, Codex: schemas verified against primary docs and
15
- * exercised against recorded fixtures; first-class targets.
16
- * - **experimental** Gemini, Grok, Hermes, Cursor, opencode, Cline: hook schemas
17
- * mapped from the capability matrix but not yet smoke-tested green against the
18
- * real binary. Labelled as such in code, docs, and this installer's output.
19
- * - **advisory** KiloCode, Aider: no usable hook API, so what ships is
20
- * instructions (and, for KiloCode, a permissions/MCP sketch). Nothing enforces
21
- * them, and the output says so rather than implying a guard exists.
23
+ * Harnesses come in three honesty tiers (docs/research/2026-09-02-harness-capability-matrix.md),
24
+ * and which harness sits at which is `HarnessProfile.tier` — read through
25
+ * `harnessesByTier()`, never listed again here:
26
+ *
27
+ * - **verified** schemas verified against primary docs and exercised against
28
+ * recorded fixtures; first-class targets.
29
+ * - **experimental** hook schemas mapped from the capability matrix but not yet
30
+ * smoke-tested green against the real binary. Labelled as such in code, docs, and
31
+ * this installer's output.
32
+ * - **advisory** — no usable hook API, so what ships is instructions (and, for
33
+ * KiloCode, a permissions/MCP sketch). Nothing enforces them, and the output says
34
+ * so rather than implying a guard exists.
22
35
  *
23
36
  * The wizard discipline is `smelt init`'s, verbatim: every step accepts `back`,
24
37
  * nothing is written until a final confirm that lists every file, and an existing
@@ -28,48 +41,26 @@ import type { SmeltConfig, SmeltConfigHooks } from './config.ts';
28
41
  */
29
42
  /** Where the wizard's bytes come from and go. Injected so `runHooks` tests in-process. */
30
43
  export interface HooksIo {
31
- readonly input: NodeJS.ReadableStream;
44
+ /**
45
+ * Scripted answers in, one line at a time. Structural on purpose; see
46
+ * {@link AnswerStream}.
47
+ */
48
+ readonly input: AnswerStream;
32
49
  readonly output: (text: string) => void;
33
50
  /** Project directory: detection, config discovery, and every write are relative to it. */
34
51
  readonly cwd: string;
35
52
  /** Home directory for detection only. Tests point it at a temp dir; nothing writes here. */
36
53
  readonly home?: string;
37
54
  }
38
- export type HarnessTier = 'verified' | 'experimental' | 'advisory';
39
- export type HarnessId = 'claude-code' | 'codex' | 'gemini' | 'grok' | 'hermes' | 'cursor' | 'opencode' | 'cline' | 'kilocode' | 'aider';
40
- export interface HarnessSpec {
41
- readonly id: HarnessId;
42
- readonly name: string;
43
- readonly tier: HarnessTier;
44
- /** Paths (relative to the project) whose existence means "this harness is in use here". */
45
- readonly detect: readonly string[];
46
- /** Paths relative to the user's home directory — "installed on this machine". */
47
- readonly detectHome: readonly string[];
48
- /** The standing-instructions file this harness reads (capability matrix column d). */
49
- readonly instructionFile: string;
50
- /** Caveats carried from the capability matrix, shown at install time. */
51
- readonly caveats: readonly string[];
52
- }
53
- /** One line of honesty per tier, shown wherever a tier label appears. */
54
- export declare const TIER_HONESTY: Record<HarnessTier, string>;
55
- export declare const HARNESSES: readonly HarnessSpec[];
56
- export declare function harnessById(id: string): HarnessSpec | undefined;
55
+ export { instructionSnippet, SNIPPET_END_MD, SNIPPET_START_MD };
57
56
  /** A harness whose config directory exists in the project or the home directory. */
58
- export declare function detectedHarnesses(cwd: string, home: string): readonly HarnessSpec[];
59
- /** Marker lines bracketing every block this installer owns inside a shared file. */
60
- export declare const SNIPPET_START_MD = "<!-- smelt:hooks v1 start -->";
61
- export declare const SNIPPET_END_MD = "<!-- smelt:hooks v1 end -->";
62
- /**
63
- * The instruction snippet — belt and braces under every shim, and the *only* layer
64
- * for advisory harnesses. It teaches the three commands, and in particular what to do
65
- * after a guard deny: run the named replacement, then `smelt retrieve` per marker.
66
- */
67
- export declare function instructionSnippet(thresholdBytes: number, budgetBytes: number): string;
57
+ export declare function detectedHarnesses(cwd: string, home: string): readonly HarnessProfile[];
58
+ export declare const AGENTS_LINT_ARGS = "agents lint .";
68
59
  /**
69
60
  * Merge our hook entries into a JSON settings file, preserving everything foreign
70
61
  * **byte-faithfully**: the merged `hooks` value is spliced into the original text, so
71
62
  * unknown top-level keys, string escapes, number spellings, indentation and key order
72
- * outside the `hooks` property ride through verbatim (founder ruling: an installer
63
+ * outside the `hooks` property ride through verbatim (an installer
73
64
  * that reformats somebody's settings file has edited what it was never asked to).
74
65
  * Inside `hooks`, unmanaged events and other people's entries under managed events
75
66
  * are preserved; our previous entries are replaced (that is what makes a re-run edit
@@ -81,10 +72,6 @@ export declare function instructionSnippet(thresholdBytes: number, budgetBytes:
81
72
  export declare function mergeJsonHooks(existingText: string | undefined, events: Record<string, readonly unknown[]>, shape?: {
82
73
  readonly version?: number;
83
74
  }): string | undefined;
84
- /** Replace this installer's marker block in `existingText`, or append it. */
85
- export declare function upsertMarkerBlock(existingText: string | undefined, block: string, start: string, end: string): string;
86
- /** Remove the marker block. `undefined` when nothing (or only whitespace) remains. */
87
- export declare function stripMarkerBlock(existingText: string, start: string, end: string): string | undefined;
88
75
  interface PlannedFile {
89
76
  /** Display path, relative to the project. */
90
77
  readonly name: string;
@@ -107,10 +94,11 @@ interface PlannedRemoval {
107
94
  readonly content?: string;
108
95
  }
109
96
  export interface HooksChoices {
110
- harnesses: HarnessSpec[];
97
+ harnesses: HarnessProfile[];
111
98
  guard: boolean;
112
99
  statsOnStop: boolean;
113
100
  mapOnStart: boolean;
101
+ lintOnStart: boolean;
114
102
  enforcement: EnforcementMode;
115
103
  thresholdBytes: number;
116
104
  }
@@ -121,8 +109,9 @@ interface InstallPlan {
121
109
  }
122
110
  /**
123
111
  * Every file `install` would write, computed against the current disk state — pure
124
- * planning, nothing written. Shared instruction files (several harnesses read
125
- * AGENTS.md) are planned once.
112
+ * planning, nothing written. A fold over each chosen profile's `install` list and its
113
+ * instruction layer; all per-harness knowledge is in the profiles. Shared instruction
114
+ * files (several harnesses read AGENTS.md) are planned once.
126
115
  *
127
116
  * @throws {CliUsageError} when an existing `smelt.config.json` is malformed — the
128
117
  * same refusal every other subcommand makes; an installer that guessed around a
@@ -140,14 +129,29 @@ export declare const DEFAULT_STORE_DIR = ".smelt/store";
140
129
  * config cannot run would be the exact silent-failure shape this project refuses.
141
130
  * An *explicit* `{"kind":"memory"}` is respected; the guard then conditions its
142
131
  * retrieve promise on the store kind instead (`retrieveSentence` in guard-core).
132
+ *
133
+ * That store injection is this verb's **policy**, which is why it lives here; the
134
+ * bytes are written by `renderConfig` in `config.ts`, the one writer, so a key added
135
+ * to the schema reaches this file and `init`'s together or not at all.
136
+ *
137
+ * "Carried verbatim" is spelled as a spread rather than as a list of the fields to
138
+ * copy, and that is load-bearing: the list version silently dropped every key nobody
139
+ * remembered to add to it — `agents` was added to the schema and this function kept
140
+ * writing configs without it, which is a setting the user believed was in force,
141
+ * caught by `test/guards/config-writer.test.ts`. Only the two fields this verb
142
+ * actually decides are named.
143
143
  */
144
144
  export declare function renderConfigWithHooks(existing: SmeltConfig | undefined, hooks: SmeltConfigHooks): string;
145
- /** Everything `remove` would delete or strip, computed against the current disk state. */
146
- export declare function planRemove(cwd: string, harnesses: readonly HarnessSpec[]): readonly PlannedRemoval[];
145
+ /**
146
+ * Everything `remove` would delete or strip, computed against the current disk state.
147
+ * The mirror image of {@link planInstall}, over the same data: each install step's
148
+ * kind is also how it comes back out — a JSON hook file is strip-merged, a marker
149
+ * block is stripped, a file that is entirely ours is deleted.
150
+ */
151
+ export declare function planRemove(cwd: string, harnesses: readonly HarnessProfile[]): readonly PlannedRemoval[];
147
152
  /**
148
153
  * `smelt hooks <install|remove>`, start to finish. The same testability pattern as
149
154
  * `runInit`: a pure function over an input/output pair, exit code returned.
150
155
  */
151
156
  export declare function runHooks(action: 'install' | 'remove', harnessFlag: string | undefined, io: HooksIo): Promise<number>;
152
- export {};
153
157
  //# sourceMappingURL=hooks.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../src/cli/hooks.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../src/cli/hooks.ts"],"names":[],"mappings":"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","sourcesContent":["import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { dirname, join } from 'node:path';\n\nimport { CliUsageError } from '../errors.ts';\nimport { nodeCommand, portablePath, shimScriptPath, smeltBinPath } from '../harness/paths.ts';\nimport { hasShim, TIER_HONESTY } from '../harness/profile.ts';\nimport type {\n HarnessInstallContext,\n HarnessJsonHooks,\n HarnessProfile,\n} from '../harness/profile.ts';\nimport {\n GUARD_EVENTS,\n GUARD_ONLY_FILES,\n HARNESSES,\n harnessById,\n harnessNames,\n JSON_HOOK_FILES,\n LIFECYCLE_EVENTS,\n lifecycleHarnesses,\n MANAGED_EVENTS,\n} from '../harness/registry.ts';\nimport {\n instructionSnippet,\n OURS_TOKEN,\n SNIPPET_END_MD,\n SNIPPET_START_MD,\n} from '../harness/snippet.ts';\nimport { DEFAULT_SUGGESTION_BUDGET_BYTES, DEFAULT_THRESHOLD_BYTES } from '../hooks/guard-core.ts';\nimport type { EnforcementMode } from '../hooks/guard-core.ts';\nimport {\n editTopLevelProperty,\n jsonStyle,\n stripMarkerBlock,\n upsertMarkerBlock,\n} from '../text/json-edit.ts';\n\nimport { answerReader, CLI_NAME } from './shell.ts';\nimport type { AnswerStream } from './shell.ts';\nimport {\n CONFIG_FILE_NAME,\n CONFIG_VERSION,\n findConfigFile,\n parseConfig,\n renderConfig,\n} from './config.ts';\nimport type { SmeltConfig, SmeltConfigHooks } from './config.ts';\n\n/**\n * `smelt hooks install` / `smelt hooks remove` — the multi-harness guard preset.\n *\n * The design: one zero-dependency guard core\n * (`src/hooks/guard-core.ts`), thin per-harness shims mapping each harness's native\n * hook schema onto it, and this installer, which writes the harness config that wires\n * a shim in — plus an instruction-file snippet as belt and braces, because the\n * snippet is also what teaches the model to run `smelt retrieve` after a deny.\n *\n * Every per-harness fact lives in that harness's {@link HarnessProfile}\n * (`src/harness/<id>.ts`), including what to write and how to take it back out. This\n * module owns only what is the *same* for every harness: the hooks merge (which entries\n * are ours, what a re-run replaces), the wizard, and the two plans below — folds over\n * `profile.install`, with no case list of its own. The byte-faithful editing itself —\n * one top-level JSON property, one delimited text block — is `src/text/json-edit.ts`,\n * which knows nothing about harnesses.\n *\n * Harnesses come in three honesty tiers (docs/research/2026-09-02-harness-capability-matrix.md),\n * and which harness sits at which is `HarnessProfile.tier` — read through\n * `harnessesByTier()`, never listed again here:\n *\n * - **verified** — schemas verified against primary docs and exercised against\n * recorded fixtures; first-class targets.\n * - **experimental** — hook schemas mapped from the capability matrix but not yet\n * smoke-tested green against the real binary. Labelled as such in code, docs, and\n * this installer's output.\n * - **advisory** — no usable hook API, so what ships is instructions (and, for\n * KiloCode, a permissions/MCP sketch). Nothing enforces them, and the output says\n * so rather than implying a guard exists.\n *\n * The wizard discipline is `smelt init`'s, verbatim: every step accepts `back`,\n * nothing is written until a final confirm that lists every file, and an existing\n * file is never overwritten without an explicit per-file `yes` — guarded by\n * `test/guards/hooks-preset.test.ts`, with mutation `hooks-install-overwrite-without-consent`\n * proving the guard goes red.\n */\n\n/** Where the wizard's bytes come from and go. Injected so `runHooks` tests in-process. */\nexport interface HooksIo {\n /**\n * Scripted answers in, one line at a time. Structural on purpose; see\n * {@link AnswerStream}.\n */\n readonly input: AnswerStream;\n readonly output: (text: string) => void;\n /** Project directory: detection, config discovery, and every write are relative to it. */\n readonly cwd: string;\n /** Home directory for detection only. Tests point it at a temp dir; nothing writes here. */\n readonly home?: string;\n}\n\nexport { instructionSnippet, SNIPPET_END_MD, SNIPPET_START_MD };\n\n/** A harness whose config directory exists in the project or the home directory. */\nexport function detectedHarnesses(cwd: string, home: string): readonly HarnessProfile[] {\n return HARNESSES.filter(\n (profile) =>\n profile.detect.some((path) => existsSync(join(cwd, path))) ||\n profile.detectHome.some((path) => existsSync(join(home, path))),\n );\n}\n\n/* ------------------------------------------------------------------------------------\n * Generated content\n * ---------------------------------------------------------------------------------- */\n\n/** Claude-style hook entry: one command under an optional matcher. */\nfunction commandEntry(matcher: string | undefined, command: string): unknown {\n return {\n ...(matcher === undefined ? {} : { matcher }),\n hooks: [{ type: 'command', command }],\n };\n}\n\n/**\n * The hook command a harness's entries run: its own shim script, through node.\n *\n * @throws {Error} when a profile declares a JSON hook file but ships no shim — a\n * registry bug, pinned by `test/guards/harness-registry.test.ts`, not a user error.\n */\nfunction shimCommand(profile: HarnessProfile, cwd: string): string {\n /* v8 ignore next 5 -- unreachable: pinned by the harness-registry guard */\n if (!hasShim(profile)) {\n throw new Error(\n `smelt: harness \"${profile.id}\" wires a hook command but ships no shim script.`,\n );\n }\n return nodeCommand(cwd, shimScriptPath(profile));\n}\n\n/**\n * One harness's hook entries: the guard under each matcher its schema spells, plus\n * the session-lifecycle hooks for the harnesses whose schema carries them. Every\n * toggle the wizard offers is a key that is present or absent here — an absent key is\n * how a re-run turns a toggle *off*, because the merge deletes what it no longer sees.\n *\n * The two `SessionStart` toggles — the opening map and the instruction-file lint —\n * are **concatenated into one array**, not spread as two objects. Spreading would put\n * the same computed key twice in one literal, and the second would silently replace\n * the first: turning the lint on would turn the map off, with no error anywhere. It is\n * the shape of bug this file exists to refuse, one layer up from the config it writes.\n */\nfunction jsonHookEvents(\n step: HarnessJsonHooks,\n ctx: HarnessInstallContext,\n command: string,\n): Record<string, readonly unknown[]> {\n // The trailing shell comment tags the entry as this installer's (see isOursEntry):\n // a bare `cli/bin.js` substring would also match some other npm CLI's built binary.\n const stats = `${nodeCommand(ctx.cwd, smeltBinPath(), 'stats')} 2>/dev/null || true # ${OURS_TOKEN}`;\n const map = `${nodeCommand(\n ctx.cwd,\n smeltBinPath(),\n `${MAP_ON_START_ARGS} --budget ${String(ctx.budgetBytes)} --cache .smelt/tags`,\n )} 2>/dev/null || true # ${OURS_TOKEN}`;\n const lint = `${nodeCommand(\n ctx.cwd,\n smeltBinPath(),\n AGENTS_LINT_ARGS,\n )} 2>/dev/null || true # ${OURS_TOKEN}`;\n\n const sessionStart = [\n ...(ctx.mapOnStart ? [commandEntry(SESSION_START_MATCHER, map)] : []),\n ...(ctx.lintOnStart ? [commandEntry(SESSION_START_MATCHER, lint)] : []),\n ];\n\n return {\n ...(ctx.guard\n ? {\n [step.event]: step.matchers.map((matcher) =>\n step.entry === 'bare-command' ? { command } : commandEntry(matcher, command),\n ),\n }\n : {}),\n ...(step.lifecycle && ctx.statsOnStop\n ? { [LIFECYCLE_EVENTS.stats]: [commandEntry(undefined, stats)] }\n : {}),\n ...(step.lifecycle && sessionStart.length > 0 ? { [LIFECYCLE_EVENTS.map]: sessionStart } : {}),\n };\n}\n\n/** The matcher both `SessionStart` entries fire under — a session opening, however. */\nconst SESSION_START_MATCHER = 'startup|resume|clear|compact';\n\n/**\n * The two `SessionStart` commands' distinguishing arguments, and **the substrings a\n * re-run recognises each entry by**. Spelled once so the writer and the reader cannot\n * drift: `presetToggles` tells the two entries apart by the command each one runs, and\n * a wizard that wrote `agents lint .` while its reader looked for `agents lint` would\n * read every re-run's lint toggle back as off and quietly delete it.\n */\nconst MAP_ON_START_ARGS = 'map .';\nexport const AGENTS_LINT_ARGS = 'agents lint .';\n\n/**\n * True for a hook entry this installer wrote. Matched on the shim script paths and the\n * `smelt:hooks` token the stats/map commands carry — never on a substring as generic\n * as `cli/bin.js`, which another npm CLI's built binary could share: remove and\n * re-install may only ever touch entries that are provably smelt's.\n */\nfunction isOursEntry(entry: unknown): boolean {\n const text = JSON.stringify(entry) ?? '';\n return text.includes('hooks/shims/') || text.includes(OURS_TOKEN);\n}\n\n/**\n * Merge our hook entries into a JSON settings file, preserving everything foreign\n * **byte-faithfully**: the merged `hooks` value is spliced into the original text, so\n * unknown top-level keys, string escapes, number spellings, indentation and key order\n * outside the `hooks` property ride through verbatim (an installer\n * that reformats somebody's settings file has edited what it was never asked to).\n * Inside `hooks`, unmanaged events and other people's entries under managed events\n * are preserved; our previous entries are replaced (that is what makes a re-run edit\n * toggles), and events left with no entries disappear. A semantic no-op returns the\n * input text unchanged. Returns `undefined` when the existing file is not a JSON\n * object — the caller skips the file rather than clobbering something it cannot\n * understand.\n */\nexport function mergeJsonHooks(\n existingText: string | undefined,\n events: Record<string, readonly unknown[]>,\n shape: { readonly version?: number } = {},\n): string | undefined {\n let root: Record<string, unknown> = {};\n if (existingText !== undefined) {\n try {\n const parsed: unknown = JSON.parse(existingText);\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;\n root = parsed as Record<string, unknown>;\n } catch {\n return undefined;\n }\n }\n const hooksValue = root['hooks'];\n const existingHooks =\n typeof hooksValue === 'object' && hooksValue !== null && !Array.isArray(hooksValue)\n ? (hooksValue as Record<string, unknown>)\n : undefined;\n const hooks = { ...existingHooks };\n\n for (const event of MANAGED_EVENTS) {\n const existing = Array.isArray(hooks[event]) ? (hooks[event] as unknown[]) : [];\n const foreign = existing.filter((entry) => !isOursEntry(entry));\n const ours = events[event] ?? [];\n const merged = [...foreign, ...ours];\n if (merged.length > 0) hooks[event] = merged;\n else delete hooks[event];\n }\n\n const mergedHooks = Object.keys(hooks).length > 0 ? hooks : undefined;\n\n // A brand-new file: nothing to preserve, render fresh two-space JSON.\n if (existingText === undefined) {\n const fresh: Record<string, unknown> = {};\n if (mergedHooks !== undefined) fresh['hooks'] = mergedHooks;\n if (shape.version !== undefined) fresh['version'] = shape.version;\n return `${JSON.stringify(fresh, null, 2)}\\n`;\n }\n\n const hooksChanged =\n JSON.stringify(existingHooks ?? null) !== JSON.stringify(mergedHooks ?? null);\n const needsVersion = shape.version !== undefined && root['version'] === undefined;\n if (!hooksChanged && !needsVersion) return existingText;\n\n // The style is read once, off the original: a second edit must match the first.\n const style = jsonStyle(existingText);\n let text: string | undefined = existingText;\n\n if (hooksChanged) {\n text = editTopLevelProperty(text, 'hooks', mergedHooks, style);\n /* v8 ignore next -- unreachable: JSON.parse accepted the same text above */\n if (text === undefined) return undefined;\n }\n if (needsVersion) {\n text = editTopLevelProperty(text, 'version', shape.version, style);\n /* v8 ignore next -- unreachable: every splice above keeps the text valid JSON */\n if (text === undefined) return undefined;\n }\n return text;\n}\n\n/* ------------------------------------------------------------------------------------\n * Planning\n * ---------------------------------------------------------------------------------- */\n\ninterface PlannedFile {\n /** Display path, relative to the project. */\n readonly name: string;\n readonly path: string;\n readonly content: string;\n readonly exists: boolean;\n readonly unchanged: boolean;\n /** chmod after writing (the cline hook must be executable). */\n readonly mode?: number;\n}\n\ninterface SkippedFile {\n readonly name: string;\n readonly why: string;\n}\n\ninterface PlannedRemoval {\n readonly name: string;\n readonly path: string;\n /** `'delete'` removes the file; `'modify'` writes `content` (ours stripped out). */\n readonly action: 'delete' | 'modify';\n readonly content?: string;\n}\n\nexport interface HooksChoices {\n harnesses: HarnessProfile[];\n guard: boolean;\n statsOnStop: boolean;\n mapOnStart: boolean;\n lintOnStart: boolean;\n enforcement: EnforcementMode;\n thresholdBytes: number;\n}\n\ninterface InstallPlan {\n readonly files: readonly PlannedFile[];\n readonly skipped: readonly SkippedFile[];\n readonly notes: readonly string[];\n}\n\nfunction planFile(cwd: string, name: string, content: string, mode?: number): PlannedFile {\n const path = join(cwd, name);\n const exists = existsSync(path);\n const unchanged = exists && readFileSync(path, 'utf8') === content;\n return { name, path, content, exists, unchanged, ...(mode === undefined ? {} : { mode }) };\n}\n\nfunction readIfExists(path: string): string | undefined {\n return existsSync(path) ? readFileSync(path, 'utf8') : undefined;\n}\n\n/**\n * Every file `install` would write, computed against the current disk state — pure\n * planning, nothing written. A fold over each chosen profile's `install` list and its\n * instruction layer; all per-harness knowledge is in the profiles. Shared instruction\n * files (several harnesses read AGENTS.md) are planned once.\n *\n * @throws {CliUsageError} when an existing `smelt.config.json` is malformed — the\n * same refusal every other subcommand makes; an installer that guessed around a\n * broken config would write settings the guard then ignores.\n */\nexport function planInstall(cwd: string, choices: HooksChoices): InstallPlan {\n const files = new Map<string, PlannedFile>();\n const skipped: SkippedFile[] = [];\n const notes: string[] = [];\n\n // -- smelt.config.json: the guard's runtime settings live here, not in any harness\n // file, so every shim reads one source of truth.\n const configPath = findConfigFile(cwd) ?? join(cwd, CONFIG_FILE_NAME);\n const existingConfig =\n readIfExists(configPath) === undefined\n ? undefined\n : parseConfig(readFileSync(configPath, 'utf8'), configPath);\n const hooksBlock: SmeltConfigHooks = {\n thresholdBytes: choices.thresholdBytes,\n enforcement: choices.enforcement,\n };\n const budgetBytes = existingConfig?.defaultBudgetBytes ?? DEFAULT_SUGGESTION_BUDGET_BYTES;\n files.set(configPath, {\n name: portablePath(cwd, configPath),\n path: configPath,\n content: renderConfigWithHooks(existingConfig, hooksBlock),\n exists: existsSync(configPath),\n unchanged: readIfExists(configPath) === renderConfigWithHooks(existingConfig, hooksBlock),\n });\n\n const ctx: HarnessInstallContext = {\n cwd,\n guard: choices.guard,\n statsOnStop: choices.statsOnStop,\n mapOnStart: choices.mapOnStart,\n lintOnStart: choices.lintOnStart,\n thresholdBytes: choices.thresholdBytes,\n budgetBytes,\n };\n const snippet = instructionSnippet(choices.thresholdBytes, budgetBytes);\n\n const planJsonHooks = (\n name: string,\n events: Record<string, readonly unknown[]>,\n shape: { readonly version?: number } = {},\n ): void => {\n const path = join(cwd, name);\n // Nothing to install and nothing to strip: don't create an empty hooks file.\n if (Object.keys(events).length === 0 && !existsSync(path)) return;\n const merged = mergeJsonHooks(readIfExists(path), events, shape);\n if (merged === undefined) {\n skipped.push({\n name,\n why: 'exists but is not a JSON object — fix or remove it, then re-run',\n });\n return;\n }\n files.set(path, planFile(cwd, name, merged));\n };\n\n const planBlockFile = (\n name: string,\n block: string,\n start: string,\n end: string,\n skipWhen?: { readonly contains: string; readonly why: string },\n ): void => {\n const path = join(cwd, name);\n if (files.has(path)) return; // a shared file (AGENTS.md), already planned\n const existing = readIfExists(path);\n // A file that already carries its owner's version of what this block does is\n // theirs to edit, not ours: say so, and touch nothing.\n if (\n skipWhen !== undefined &&\n existing !== undefined &&\n !existing.includes(start) &&\n existing.includes(skipWhen.contains)\n ) {\n skipped.push({ name, why: skipWhen.why });\n return;\n }\n files.set(path, planFile(cwd, name, upsertMarkerBlock(existing, block, start, end)));\n };\n\n for (const profile of choices.harnesses) {\n for (const step of profile.install) {\n switch (step.kind) {\n case 'json-hooks':\n planJsonHooks(\n step.file,\n jsonHookEvents(step, ctx, shimCommand(profile, cwd)),\n step.shape ?? {},\n );\n break;\n case 'marker-block':\n planBlockFile(step.file, step.block(ctx), step.start, step.end, step.skipWhen);\n break;\n case 'own-file':\n if (step.guardOnly && !ctx.guard) break;\n files.set(join(cwd, step.file), planFile(cwd, step.file, step.content(ctx), step.mode));\n break;\n }\n }\n\n if (profile.instructions === 'snippet') {\n planBlockFile(profile.instructionFile, snippet, SNIPPET_START_MD, SNIPPET_END_MD);\n } else {\n files.set(\n join(cwd, profile.instructionFile),\n planFile(cwd, profile.instructionFile, profile.instructions(ctx)),\n );\n }\n\n for (const caveat of profile.caveats) notes.push(`${profile.name}: ${caveat}`);\n }\n\n return { files: [...files.values()], skipped, notes };\n}\n\n/** Where the installed config points the persistent store, relative to the config file. */\nexport const DEFAULT_STORE_DIR = '.smelt/store';\n\n/**\n * Existing config re-rendered with the hooks block, other fields carried verbatim —\n * except that a config with **no** store block gains a directory store. The deny\n * reasons and the instruction snippet teach `smelt retrieve <hash>`, and retrieval\n * across processes needs a persistent store (`smelt retrieve` refuses a memory\n * store, exit 2) — an install whose own guard promises a command the installed\n * config cannot run would be the exact silent-failure shape this project refuses.\n * An *explicit* `{\"kind\":\"memory\"}` is respected; the guard then conditions its\n * retrieve promise on the store kind instead (`retrieveSentence` in guard-core).\n *\n * That store injection is this verb's **policy**, which is why it lives here; the\n * bytes are written by `renderConfig` in `config.ts`, the one writer, so a key added\n * to the schema reaches this file and `init`'s together or not at all.\n *\n * \"Carried verbatim\" is spelled as a spread rather than as a list of the fields to\n * copy, and that is load-bearing: the list version silently dropped every key nobody\n * remembered to add to it — `agents` was added to the schema and this function kept\n * writing configs without it, which is a setting the user believed was in force,\n * caught by `test/guards/config-writer.test.ts`. Only the two fields this verb\n * actually decides are named.\n */\nexport function renderConfigWithHooks(\n existing: SmeltConfig | undefined,\n hooks: SmeltConfigHooks,\n): string {\n return renderConfig({\n ...existing,\n smeltConfig: CONFIG_VERSION,\n store: existing?.store ?? { kind: 'directory', path: DEFAULT_STORE_DIR },\n hooks,\n });\n}\n\n/**\n * Everything `remove` would delete or strip, computed against the current disk state.\n * The mirror image of {@link planInstall}, over the same data: each install step's\n * kind is also how it comes back out — a JSON hook file is strip-merged, a marker\n * block is stripped, a file that is entirely ours is deleted.\n */\nexport function planRemove(\n cwd: string,\n harnesses: readonly HarnessProfile[],\n): readonly PlannedRemoval[] {\n const removals = new Map<string, PlannedRemoval>();\n\n const planJsonStrip = (name: string): void => {\n const path = join(cwd, name);\n const existing = readIfExists(path);\n if (existing === undefined) return;\n const stripped = mergeJsonHooks(existing, {});\n if (stripped === undefined || stripped === existing) return;\n const remains: unknown = JSON.parse(stripped);\n const empty =\n typeof remains === 'object' &&\n remains !== null &&\n Object.keys(remains as Record<string, unknown>).filter((key) => key !== 'version').length ===\n 0;\n removals.set(\n path,\n empty && existing.includes('hooks')\n ? { name, path, action: 'delete' }\n : { name, path, action: 'modify', content: stripped },\n );\n };\n\n const planBlockStrip = (name: string, start: string, end: string): void => {\n const path = join(cwd, name);\n const existing = readIfExists(path);\n if (existing === undefined || !existing.includes(start)) return;\n const stripped = stripMarkerBlock(existing, start, end);\n removals.set(\n path,\n stripped === undefined\n ? { name, path, action: 'delete' }\n : { name, path, action: 'modify', content: stripped },\n );\n };\n\n const planWholeFileDelete = (name: string): void => {\n const path = join(cwd, name);\n const existing = readIfExists(path);\n if (existing === undefined || !existing.includes(OURS_TOKEN)) return;\n removals.set(path, { name, path, action: 'delete' });\n };\n\n for (const profile of harnesses) {\n for (const step of profile.install) {\n switch (step.kind) {\n case 'json-hooks':\n planJsonStrip(step.file);\n break;\n case 'marker-block':\n planBlockStrip(step.file, step.start, step.end);\n break;\n case 'own-file':\n planWholeFileDelete(step.file);\n break;\n }\n }\n if (profile.instructions === 'snippet') {\n planBlockStrip(profile.instructionFile, SNIPPET_START_MD, SNIPPET_END_MD);\n } else {\n planWholeFileDelete(profile.instructionFile);\n }\n }\n\n return [...removals.values()];\n}\n\n/* ------------------------------------------------------------------------------------\n * The wizard\n * ---------------------------------------------------------------------------------- */\n\ntype Asker = (prompt: string) => Promise<string>;\n\n/**\n * `smelt hooks <install|remove>`, start to finish. The same testability pattern as\n * `runInit`: a pure function over an input/output pair, exit code returned.\n */\nexport async function runHooks(\n action: 'install' | 'remove',\n harnessFlag: string | undefined,\n io: HooksIo,\n): Promise<number> {\n // Same adapter, same reason, as `runInit` — see the note on `answerReader`.\n const lines = answerReader(io.input);\n const ask = async (prompt: string): Promise<string> => {\n io.output(prompt);\n const next = await lines.next();\n if (next === undefined) {\n throw new CliUsageError(\n `${CLI_NAME} hooks: input ended before the wizard finished. ` +\n `Files already confirmed and written stay; nothing further was written.`,\n );\n }\n return next.trim();\n };\n\n try {\n return action === 'install'\n ? await installFlow(io, ask, harnessFlag)\n : await removeFlow(io, ask, harnessFlag);\n } finally {\n await lines.release();\n }\n}\n\nfunction resolveHarnessFlag(flag: string): HarnessProfile {\n const profile = harnessById(flag);\n if (profile === undefined) {\n throw new CliUsageError(\n `${CLI_NAME} hooks: unknown harness \"${flag}\". ` +\n `Known: ${HARNESSES.map((h) => h.id).join(', ')}.`,\n );\n }\n return profile;\n}\n\nfunction tierLabel(profile: HarnessProfile): string {\n return `${profile.id.padEnd(12)} ${profile.name.padEnd(14)} [${profile.tier}] — ${TIER_HONESTY[profile.tier]}`;\n}\n\nasync function installFlow(\n io: HooksIo,\n ask: Asker,\n harnessFlag: string | undefined,\n): Promise<number> {\n const home = io.home ?? homedir();\n const detected = detectedHarnesses(io.cwd, home);\n\n io.output(\n `${CLI_NAME} hooks install — wires the smelt guard into agent-harness hooks.\\n` +\n `Answer \\`back\\` at any step to return to the previous one. Nothing is written ` +\n `until you confirm at the end.\\n\\n`,\n );\n\n const choices: HooksChoices = {\n harnesses: harnessFlag !== undefined ? [resolveHarnessFlag(harnessFlag)] : [...detected],\n ...presetToggles(io.cwd),\n enforcement: 'deny',\n thresholdBytes: DEFAULT_THRESHOLD_BYTES,\n };\n\n // With --harness the selection step is skipped, so the tier label — and its one\n // line of honesty about what the tier means — is printed here instead.\n if (harnessFlag !== undefined) {\n for (const profile of choices.harnesses) io.output(` ${tierLabel(profile)}\\n`);\n }\n\n const steps: readonly ((io_: HooksIo, ask_: Asker) => Promise<'ok' | 'back'>)[] = [\n async (io_, ask_) =>\n harnessFlag !== undefined ? 'ok' : stepHarnesses(io_, ask_, choices, detected),\n async (io_, ask_) =>\n stepToggle(io_, ask_, 'PreToolUse size-guard', guardCopy(), choices.guard, (on) => {\n choices.guard = on;\n }),\n async (io_, ask_) =>\n stepToggle(\n io_,\n ask_,\n 'stats on Stop',\n `\\`smelt stats\\` runs when a session ends — the honest signal (expansion rate) ` +\n `surfaced where the turn ends. Observation only; never blocks. Wired for the ` +\n `harnesses whose hooks carry session events (${harnessNames(lifecycleHarnesses())}).`,\n choices.statsOnStop,\n (on) => {\n choices.statsOnStop = on;\n },\n ),\n async (io_, ask_) =>\n stepToggle(\n io_,\n ask_,\n 'repo map on SessionStart',\n `\\`smelt map . --budget …\\` runs at session start and its output opens the ` +\n `context — the agent starts oriented. Costs one map build per session. ` +\n `Wired for the harnesses whose hooks carry session events ` +\n `(${harnessNames(lifecycleHarnesses())}).`,\n choices.mapOnStart,\n (on) => {\n choices.mapOnStart = on;\n },\n ),\n async (io_, ask_) =>\n stepToggle(\n io_,\n ask_,\n 'instruction-file lint on SessionStart',\n `\\`smelt agents lint .\\` runs at session start and reports on the AGENTS.md, ` +\n `CLAUDE.md and GEMINI.md this session is about to load on every request — ` +\n `bytes per level, and any path or link in them that no longer resolves. ` +\n `Advisory: it never blocks, and it exits 0 unless you set ` +\n `agents.budgetBytes in ${CONFIG_FILE_NAME}. Wired for verified-tier ` +\n `harnesses (Claude Code, Codex).`,\n choices.lintOnStart,\n (on) => {\n choices.lintOnStart = on;\n },\n ),\n async (io_, ask_) => stepEnforcement(io_, ask_, choices),\n async (io_, ask_) => stepThreshold(io_, ask_, choices),\n ];\n\n let index = 0;\n for (;;) {\n while (index < steps.length) {\n const outcome = await steps[index]!(io, ask);\n if (outcome === 'back') {\n if (index === 0) io.output(`This is the first step — there is nothing before it.\\n`);\n else index -= 1;\n } else {\n index += 1;\n }\n }\n if (choices.harnesses.length === 0) {\n io.output(`No harness selected. Nothing to do; nothing was written.\\n`);\n return 0;\n }\n const verdict = await confirmAndInstall(io, ask, choices);\n if (verdict !== 'back') return 0;\n index = steps.length - 1;\n }\n}\n\nfunction guardCopy(): string {\n return (\n `Denies raw Reads (and simple \\`cat\\`s) of files over the size threshold, with a ` +\n `reason naming the exact \\`smelt\\` replacement — the model still sees everything: ` +\n `smelted first, \\`smelt retrieve\\` for the rest. Windowed reads (offset/limit) ` +\n `always pass.`\n );\n}\n\nasync function stepHarnesses(\n io: HooksIo,\n ask: Asker,\n choices: HooksChoices,\n detected: readonly HarnessProfile[],\n): Promise<'ok' | 'back'> {\n io.output(`\\nHarnesses — detected by their config directories (project or home):\\n`);\n for (const profile of HARNESSES) {\n const mark = detected.includes(profile) ? '*' : ' ';\n io.output(` ${mark} ${tierLabel(profile)}\\n`);\n }\n io.output(`(* = detected here)\\n`);\n for (;;) {\n const current = choices.harnesses.map((profile) => profile.id).join(',') || '(none)';\n const answer = await ask(\n `install for which? (comma-separated ids, Enter = ${current}, or back)\\n> `,\n );\n if (answer === 'back') return 'back';\n if (answer === '') return 'ok';\n const ids = answer\n .split(',')\n .map((id) => id.trim())\n .filter((id) => id !== '');\n const chosen: HarnessProfile[] = [];\n let bad: string | undefined;\n for (const id of ids) {\n const profile = harnessById(id);\n if (profile === undefined) bad = id;\n else if (!chosen.includes(profile)) chosen.push(profile);\n }\n if (bad !== undefined) {\n io.output(`Unknown harness \"${bad}\". Known: ${HARNESSES.map((h) => h.id).join(', ')}.\\n`);\n continue;\n }\n choices.harnesses = chosen;\n return 'ok';\n }\n}\n\nasync function stepToggle(\n io: HooksIo,\n ask: Asker,\n name: string,\n copy: string,\n current: boolean,\n set: (on: boolean) => void,\n): Promise<'ok' | 'back'> {\n io.output(`\\n${name} — ${copy}\\n`);\n for (;;) {\n const answer = await ask(`${name}? (on/off) [${current ? 'on' : 'off'}] (or back)> `);\n if (answer === 'back') return 'back';\n if (answer === '') return 'ok';\n if (answer === 'on' || answer === 'off') {\n set(answer === 'on');\n return 'ok';\n }\n io.output(`on, off, or back.\\n`);\n }\n}\n\nasync function stepEnforcement(\n io: HooksIo,\n ask: Asker,\n choices: HooksChoices,\n): Promise<'ok' | 'back'> {\n io.output(\n `\\nEnforcement — what happens when the guard catches an oversized raw read:\\n` +\n ` 1. deny — refuse with a reason naming the exact replacement command. The\\n` +\n ` transcript stays truthful; the model runs the replacement itself.\\n` +\n ` 2. rewrite — on harnesses whose hooks can modify tool input, substitute the\\n` +\n ` replacement in-flight (grep/cat piped through smelt). Never\\n` +\n ` silent: the substitution is announced in the decision reason\\n` +\n ` where the harness has one, on stderr where it does not.\\n` +\n ` Harnesses that cannot rewrite fall back to deny.\\n`,\n );\n for (;;) {\n const current = choices.enforcement === 'deny' ? '1' : '2';\n const answer = await ask(`enforcement (1/2) [${current}] (or back)> `);\n if (answer === 'back') return 'back';\n const pick = answer === '' ? current : answer;\n if (pick === '1' || pick === '2') {\n choices.enforcement = pick === '1' ? 'deny' : 'rewrite';\n return 'ok';\n }\n io.output(`1 for deny, 2 for rewrite, or back.\\n`);\n }\n}\n\nasync function stepThreshold(\n io: HooksIo,\n ask: Asker,\n choices: HooksChoices,\n): Promise<'ok' | 'back'> {\n io.output(\n `\\nSize threshold — reads at or under this many bytes always pass. The ${String(\n DEFAULT_THRESHOLD_BYTES,\n )}-byte default comes from the measured validation in ` +\n `docs/research/2026-09-02-agent-enforcement.md § 5.\\n`,\n );\n for (;;) {\n const answer = await ask(`threshold in bytes [${String(choices.thresholdBytes)}] (or back)> `);\n if (answer === 'back') return 'back';\n if (answer === '') return 'ok';\n if (/^\\d+$/.test(answer) && Number(answer) > 0) {\n choices.thresholdBytes = Number(answer);\n return 'ok';\n }\n io.output(`A whole number of bytes greater than zero, e.g. 8192.\\n`);\n }\n}\n\n/**\n * A re-run reads the toggles back off what is actually installed — every JSON hook\n * file this installer writes, plus the guard-only shim files, both derived from the\n * registry — so it edits instead of resetting. Harnesses that only wire the guard\n * (gemini, grok, cursor, hermes, opencode, cline) persist no stats/map entries, so\n * after a re-run scoped to them those toggles read back as off; the defaults below\n * apply only when nothing of smelt's is installed at all.\n *\n * The two `SessionStart` toggles share one event, so they are told apart by **the\n * command each entry runs**, not by the key it sits under. Reading `SessionStart` as\n * one boolean would make a re-run with the map on and the lint off write both back —\n * or neither — which is a toggle the user believed they had set.\n */\nfunction presetToggles(\n cwd: string,\n): Pick<HooksChoices, 'guard' | 'statsOnStop' | 'mapOnStart' | 'lintOnStart'> {\n const defaults = { guard: true, statsOnStop: true, mapOnStart: false, lintOnStart: false };\n let anyOurs = false;\n let guard = false;\n let statsOnStop = false;\n let mapOnStart = false;\n let lintOnStart = false;\n\n for (const name of JSON_HOOK_FILES) {\n const text = readIfExists(join(cwd, name));\n if (text === undefined) continue;\n let hooks: Record<string, unknown> | undefined;\n try {\n const parsed: unknown = JSON.parse(text);\n const hooksValue =\n typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)['hooks']\n : undefined;\n hooks =\n typeof hooksValue === 'object' && hooksValue !== null && !Array.isArray(hooksValue)\n ? (hooksValue as Record<string, unknown>)\n : undefined;\n } catch {\n hooks = undefined;\n }\n if (hooks === undefined) continue;\n const installed = hooks;\n const oursUnder = (event: string): readonly unknown[] =>\n Array.isArray(installed[event])\n ? (installed[event] as unknown[]).filter((entry) => isOursEntry(entry))\n : [];\n const hasOurs = (event: string): boolean => oursUnder(event).length > 0;\n /** One of ours under `event` whose command carries `needle`. */\n const hasOursRunning = (event: string, needle: string): boolean =>\n oursUnder(event).some((entry) => (JSON.stringify(entry) ?? '').includes(needle));\n if (!MANAGED_EVENTS.some((event) => hasOurs(event))) continue;\n anyOurs = true;\n guard ||= GUARD_EVENTS.some((event) => hasOurs(event));\n statsOnStop ||= hasOurs(LIFECYCLE_EVENTS.stats);\n mapOnStart ||= hasOursRunning(LIFECYCLE_EVENTS.map, MAP_ON_START_ARGS);\n lintOnStart ||= hasOursRunning(LIFECYCLE_EVENTS.lint, AGENTS_LINT_ARGS);\n }\n\n for (const name of GUARD_ONLY_FILES) {\n const text = readIfExists(join(cwd, name));\n if (text !== undefined && text.includes(OURS_TOKEN)) {\n anyOurs = true;\n guard = true;\n }\n }\n\n return anyOurs ? { guard, statsOnStop, mapOnStart, lintOnStart } : defaults;\n}\n\nconst fileLabel = (file: PlannedFile): string => {\n if (file.unchanged) return 'unchanged — nothing to write';\n return file.exists ? 'exists — will ask before overwriting' : 'new';\n};\n\nasync function confirmAndInstall(\n io: HooksIo,\n ask: Asker,\n choices: HooksChoices,\n): Promise<'done' | 'back'> {\n const plan = planInstall(io.cwd, choices);\n\n io.output(\n `\\nAbout to write, into ${io.cwd}:\\n` +\n plan.files.map((file) => ` ${file.name.padEnd(32)} (${fileLabel(file)})\\n`).join('') +\n plan.skipped.map((skip) => ` ${skip.name.padEnd(32)} (SKIPPED: ${skip.why})\\n`).join('') +\n `Nothing has been written yet.\\n`,\n );\n\n for (;;) {\n const answer = await ask(`confirm (yes / no / back)> `);\n if (answer === 'back') return 'back';\n if (answer === 'no') {\n io.output(`Nothing was written.\\n`);\n return 'done';\n }\n if (answer === 'yes') break;\n io.output(`yes to write, no to leave everything untouched, back to change a setting.\\n`);\n }\n\n for (const file of plan.files) {\n if (file.unchanged) {\n io.output(` ${file.name} — unchanged, not rewritten\\n`);\n continue;\n }\n if (file.exists) {\n // The one hard rule, same as `smelt init`: an existing file is never touched\n // without an explicit per-file yes — not `y`, not Enter, a literal `yes`.\n const answer = await ask(` ${file.name} exists — overwrite it? (yes/no)> `);\n if (answer !== 'yes') {\n io.output(` skipped ${file.name} — the existing file was not touched\\n`);\n continue;\n }\n }\n mkdirSync(dirname(file.path), { recursive: true });\n writeFileSync(file.path, file.content);\n if (file.mode !== undefined) chmodSync(file.path, file.mode);\n io.output(` wrote ${file.name}\\n`);\n }\n\n for (const note of plan.notes) io.output(`note: ${note}\\n`);\n io.output(\n `Done. Re-run \\`${CLI_NAME} hooks install\\` to edit toggles; ` +\n `\\`${CLI_NAME} hooks remove\\` takes it all back out.\\n`,\n );\n return 'done';\n}\n\nasync function removeFlow(\n io: HooksIo,\n ask: Asker,\n harnessFlag: string | undefined,\n): Promise<number> {\n const harnesses = harnessFlag !== undefined ? [resolveHarnessFlag(harnessFlag)] : [...HARNESSES];\n const removals = planRemove(io.cwd, harnesses);\n\n if (removals.length === 0) {\n io.output(`${CLI_NAME} hooks remove: nothing of smelt's found to remove in ${io.cwd}.\\n`);\n return 0;\n }\n\n io.output(\n `${CLI_NAME} hooks remove — takes smelt's hook wiring back out.\\n\\nPlanned:\\n` +\n removals\n .map(\n (removal) =>\n ` ${removal.name.padEnd(32)} (${\n removal.action === 'delete' ? 'delete' : 'remove smelt entries, keep the rest'\n })\\n`,\n )\n .join('') +\n `${CONFIG_FILE_NAME} is left untouched — its hooks block is your config now; ` +\n `edit or remove it there.\\nNothing has been changed yet.\\n`,\n );\n\n for (;;) {\n const answer = await ask(`confirm (yes / no)> `);\n if (answer === 'no') {\n io.output(`Nothing was changed.\\n`);\n return 0;\n }\n if (answer === 'yes') break;\n io.output(`yes to proceed, no to leave everything untouched.\\n`);\n }\n\n for (const removal of removals) {\n const verb = removal.action === 'delete' ? 'delete' : 'modify';\n const answer = await ask(` ${removal.name} — ${verb} it? (yes/no)> `);\n if (answer !== 'yes') {\n io.output(` skipped ${removal.name} — not touched\\n`);\n continue;\n }\n if (removal.action === 'delete') {\n unlinkSync(removal.path);\n io.output(` deleted ${removal.name}\\n`);\n } else {\n writeFileSync(removal.path, removal.content ?? '');\n io.output(` cleaned ${removal.name}\\n`);\n }\n }\n io.output(`Done.\\n`);\n return 0;\n}\n"]}