@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
package/dist/cli/hooks.js CHANGED
@@ -1,205 +1,24 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { dirname, isAbsolute, join, relative, sep } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { createInterface } from 'node:readline/promises';
3
+ import { dirname, join } from 'node:path';
6
4
  import { CliUsageError } from '../errors.js';
5
+ import { nodeCommand, portablePath, shimScriptPath, smeltBinPath } from '../harness/paths.js';
6
+ import { hasShim, TIER_HONESTY } from '../harness/profile.js';
7
+ import { GUARD_EVENTS, GUARD_ONLY_FILES, HARNESSES, harnessById, harnessNames, JSON_HOOK_FILES, LIFECYCLE_EVENTS, lifecycleHarnesses, MANAGED_EVENTS, } from '../harness/registry.js';
8
+ import { instructionSnippet, OURS_TOKEN, SNIPPET_END_MD, SNIPPET_START_MD, } from '../harness/snippet.js';
7
9
  import { DEFAULT_SUGGESTION_BUDGET_BYTES, DEFAULT_THRESHOLD_BYTES } from '../hooks/guard-core.js';
8
- import { CLI_NAME } from './args.js';
9
- import { CONFIG_FILE_NAME, CONFIG_VERSION, findConfigFile, parseConfig } from './config.js';
10
- /** One line of honesty per tier, shown wherever a tier label appears. */
11
- export const TIER_HONESTY = {
12
- verified: 'schema verified against primary docs and pinned by fixtures',
13
- experimental: 'schema mapped from the 2026-09-02 capability matrix, not yet smoke-tested against the real binary',
14
- advisory: 'no usable hook API — instructions only, nothing enforces them',
15
- };
16
- export const HARNESSES = [
17
- {
18
- id: 'claude-code',
19
- name: 'Claude Code',
20
- tier: 'verified',
21
- detect: ['.claude'],
22
- detectHome: ['.claude'],
23
- instructionFile: 'CLAUDE.md',
24
- caveats: [],
25
- },
26
- {
27
- id: 'codex',
28
- name: 'Codex CLI',
29
- tier: 'verified',
30
- detect: ['.codex'],
31
- detectHome: ['.codex'],
32
- instructionFile: 'AGENTS.md',
33
- caveats: [
34
- 'project-level Codex hooks run only once the project is trusted (features.hooks; see docs/research/2026-09-02-agent-enforcement.md § 3)',
35
- ],
36
- },
37
- {
38
- id: 'gemini',
39
- name: 'Gemini CLI',
40
- tier: 'experimental',
41
- detect: ['.gemini'],
42
- detectHome: ['.gemini'],
43
- instructionFile: 'GEMINI.md',
44
- caveats: [
45
- 'Gemini policy-engine allow rules are ignored in non-interactive runs (google-gemini/gemini-cli#20469) — verify hook behaviour in CI before relying on it',
46
- ],
47
- },
48
- {
49
- id: 'grok',
50
- name: 'Grok CLI',
51
- tier: 'experimental',
52
- detect: ['.grok'],
53
- detectHome: ['.grok'],
54
- instructionFile: 'AGENTS.md',
55
- caveats: [
56
- 'deny-only hooks: input rewrite is not supported, so rewrite mode falls back to deny',
57
- ],
58
- },
59
- {
60
- id: 'hermes',
61
- name: 'Hermes Agent',
62
- tier: 'experimental',
63
- detect: ['.hermes', '.hermes.md'],
64
- detectHome: ['.hermes'],
65
- instructionFile: 'AGENTS.md',
66
- caveats: [
67
- 'Hermes memory tools bypass disabled_toolsets (NousResearch/hermes-agent#46171) — treat tool gating there as leaky',
68
- 'hook config may need merging into ~/.hermes/config.yaml by hand; the written file says how',
69
- ],
70
- },
71
- {
72
- id: 'cursor',
73
- name: 'Cursor',
74
- tier: 'experimental',
75
- detect: ['.cursor'],
76
- detectHome: ['.cursor'],
77
- instructionFile: 'AGENTS.md',
78
- caveats: ['Cursor has no static permission config — gating is entirely hook code'],
79
- },
80
- {
81
- id: 'opencode',
82
- name: 'opencode',
83
- tier: 'experimental',
84
- detect: ['.opencode', 'opencode.json'],
85
- detectHome: ['.config/opencode'],
86
- instructionFile: 'AGENTS.md',
87
- caveats: [
88
- 'MCP tools can bypass opencode plugin hooks (sst/opencode#2319) — the guard sees built-in tools only',
89
- ],
90
- },
91
- {
92
- id: 'cline',
93
- name: 'Cline',
94
- tier: 'experimental',
95
- detect: ['.clinerules'],
96
- detectHome: [],
97
- instructionFile: '.clinerules/smelt.md',
98
- caveats: [
99
- 'deny-only hooks: input rewrite is not supported, so rewrite mode falls back to deny',
100
- ],
101
- },
102
- {
103
- id: 'kilocode',
104
- name: 'KiloCode',
105
- tier: 'advisory',
106
- detect: ['.kilocode'],
107
- detectHome: ['.config/kilo'],
108
- instructionFile: '.kilocode/rules/smelt.md',
109
- caveats: [
110
- 'no first-class hooks (Kilo-Org/kilocode#5827): enforcement is permissions config + MCP, both manual',
111
- ],
112
- },
113
- {
114
- id: 'aider',
115
- name: 'Aider',
116
- tier: 'advisory',
117
- detect: ['.aider.conf.yml'],
118
- detectHome: ['.aider.conf.yml'],
119
- instructionFile: 'CONVENTIONS.md',
120
- caveats: [
121
- 'Aider auto-reads no rules file: add `read: CONVENTIONS.md` to .aider.conf.yml (or pass --read CONVENTIONS.md) yourself',
122
- ],
123
- },
124
- ];
125
- export function harnessById(id) {
126
- return HARNESSES.find((spec) => spec.id === id);
127
- }
10
+ import { editTopLevelProperty, jsonStyle, stripMarkerBlock, upsertMarkerBlock, } from '../text/json-edit.js';
11
+ import { answerReader, CLI_NAME } from './shell.js';
12
+ import { CONFIG_FILE_NAME, CONFIG_VERSION, findConfigFile, parseConfig, renderConfig, } from './config.js';
13
+ export { instructionSnippet, SNIPPET_END_MD, SNIPPET_START_MD };
128
14
  /** A harness whose config directory exists in the project or the home directory. */
129
15
  export function detectedHarnesses(cwd, home) {
130
- return HARNESSES.filter((spec) => spec.detect.some((path) => existsSync(join(cwd, path))) ||
131
- spec.detectHome.some((path) => existsSync(join(home, path))));
132
- }
133
- /* ------------------------------------------------------------------------------------
134
- * Paths and commands
135
- * ---------------------------------------------------------------------------------- */
136
- /**
137
- * The `dist` directory of this installed package — where the shipped guard-core and
138
- * shim scripts live. Computed from this module's own location, which is
139
- * `<pkg>/dist/cli/` in every real run (the CLI executes from `dist`); under the test
140
- * runner it is `<pkg>/src/cli/`, and the substitution still points at `dist`, which
141
- * is where the scripts will exist once built — the paths are written into config
142
- * files for *node* to execute, never imported.
143
- */
144
- function packageDistDir() {
145
- const here = dirname(fileURLToPath(import.meta.url)); // <pkg>/(dist|src)/cli
146
- return join(dirname(dirname(here)), 'dist');
147
- }
148
- function shimScriptPath(id) {
149
- return join(packageDistDir(), 'hooks', 'shims', `${id}.js`);
150
- }
151
- function guardCoreScriptPath() {
152
- return join(packageDistDir(), 'hooks', 'guard-core.js');
153
- }
154
- function smeltBinPath() {
155
- return join(packageDistDir(), 'cli', 'bin.js');
156
- }
157
- /** Inside the project, a project-relative path travels with the repo; outside, absolute. */
158
- function portablePath(cwd, absolute) {
159
- const rel = relative(cwd, absolute);
160
- return rel.startsWith('..') || isAbsolute(rel) ? absolute : rel.split(sep).join('/');
161
- }
162
- function nodeCommand(cwd, script, args = '') {
163
- return `node "${portablePath(cwd, script)}"${args === '' ? '' : ` ${args}`}`;
16
+ return HARNESSES.filter((profile) => profile.detect.some((path) => existsSync(join(cwd, path))) ||
17
+ profile.detectHome.some((path) => existsSync(join(home, path))));
164
18
  }
165
19
  /* ------------------------------------------------------------------------------------
166
20
  * Generated content
167
21
  * ---------------------------------------------------------------------------------- */
168
- /** Marker lines bracketing every block this installer owns inside a shared file. */
169
- export const SNIPPET_START_MD = '<!-- smelt:hooks v1 start -->';
170
- export const SNIPPET_END_MD = '<!-- smelt:hooks v1 end -->';
171
- const SNIPPET_START_HASH = '# smelt:hooks v1 start';
172
- const SNIPPET_END_HASH = '# smelt:hooks v1 end';
173
- /** Substring that identifies a file (or JSON hook entry) as written by this installer. */
174
- const OURS_TOKEN = 'smelt:hooks';
175
- /**
176
- * The instruction snippet — belt and braces under every shim, and the *only* layer
177
- * for advisory harnesses. It teaches the three commands, and in particular what to do
178
- * after a guard deny: run the named replacement, then `smelt retrieve` per marker.
179
- */
180
- export function instructionSnippet(thresholdBytes, budgetBytes) {
181
- return `${SNIPPET_START_MD}
182
-
183
- ## smelt — context discipline
184
-
185
- This project uses [smelt](https://github.com/smeltjs/smelt) to keep large tool output
186
- out of the context window, reversibly.
187
-
188
- - Do not read files over ${String(thresholdBytes)} bytes raw. Run
189
- \`smelt <file> --budget ${String(budgetBytes)} --focus <what you are looking for>\`
190
- instead (repeat \`--focus\` per term). Focused regions survive verbatim; everything
191
- else collapses into a one-line marker stating what was removed.
192
- - Every marker ends in \`retrieve("hash")\`. \`smelt retrieve <hash>\` prints the
193
- exact original bytes back. Retrieve what you actually need — retrievals are counted,
194
- and \`smelt stats\` reports the honest expansion rate.
195
- - For orientation, \`smelt map . --budget ${String(budgetBytes)}\` prints a ranked
196
- symbol map of the repository.
197
- - If a smelt guard hook denies a raw read, run the exact replacement command named in
198
- the denial, then \`smelt retrieve\` any marker you need expanded.
199
-
200
- ${SNIPPET_END_MD}
201
- `;
202
- }
203
22
  /** Claude-style hook entry: one command under an optional matcher. */
204
23
  function commandEntry(matcher, command) {
205
24
  return {
@@ -207,47 +26,79 @@ function commandEntry(matcher, command) {
207
26
  hooks: [{ type: 'command', command }],
208
27
  };
209
28
  }
210
- /** The three preset hooks in Claude Code's schema; Codex's hooks.json mirrors it. */
211
- function claudeStyleEvents(ctx, shim, preToolEvent, matchers) {
212
- const shimCommand = nodeCommand(ctx.cwd, shimScriptPath(shim));
29
+ /**
30
+ * The hook command a harness's entries run: its own shim script, through node.
31
+ *
32
+ * @throws {Error} when a profile declares a JSON hook file but ships no shim — a
33
+ * registry bug, pinned by `test/guards/harness-registry.test.ts`, not a user error.
34
+ */
35
+ function shimCommand(profile, cwd) {
36
+ /* v8 ignore next 5 -- unreachable: pinned by the harness-registry guard */
37
+ if (!hasShim(profile)) {
38
+ throw new Error(`smelt: harness "${profile.id}" wires a hook command but ships no shim script.`);
39
+ }
40
+ return nodeCommand(cwd, shimScriptPath(profile));
41
+ }
42
+ /**
43
+ * One harness's hook entries: the guard under each matcher its schema spells, plus
44
+ * the session-lifecycle hooks for the harnesses whose schema carries them. Every
45
+ * toggle the wizard offers is a key that is present or absent here — an absent key is
46
+ * how a re-run turns a toggle *off*, because the merge deletes what it no longer sees.
47
+ *
48
+ * The two `SessionStart` toggles — the opening map and the instruction-file lint —
49
+ * are **concatenated into one array**, not spread as two objects. Spreading would put
50
+ * the same computed key twice in one literal, and the second would silently replace
51
+ * the first: turning the lint on would turn the map off, with no error anywhere. It is
52
+ * the shape of bug this file exists to refuse, one layer up from the config it writes.
53
+ */
54
+ function jsonHookEvents(step, ctx, command) {
213
55
  // The trailing shell comment tags the entry as this installer's (see isOursEntry):
214
56
  // a bare `cli/bin.js` substring would also match some other npm CLI's built binary.
215
57
  const stats = `${nodeCommand(ctx.cwd, smeltBinPath(), 'stats')} 2>/dev/null || true # ${OURS_TOKEN}`;
216
- const map = `${nodeCommand(ctx.cwd, smeltBinPath(), `map . --budget ${String(ctx.budgetBytes)} --cache .smelt/tags`)} 2>/dev/null || true # ${OURS_TOKEN}`;
58
+ const map = `${nodeCommand(ctx.cwd, smeltBinPath(), `${MAP_ON_START_ARGS} --budget ${String(ctx.budgetBytes)} --cache .smelt/tags`)} 2>/dev/null || true # ${OURS_TOKEN}`;
59
+ const lint = `${nodeCommand(ctx.cwd, smeltBinPath(), AGENTS_LINT_ARGS)} 2>/dev/null || true # ${OURS_TOKEN}`;
60
+ const sessionStart = [
61
+ ...(ctx.mapOnStart ? [commandEntry(SESSION_START_MATCHER, map)] : []),
62
+ ...(ctx.lintOnStart ? [commandEntry(SESSION_START_MATCHER, lint)] : []),
63
+ ];
217
64
  return {
218
65
  ...(ctx.guard
219
66
  ? {
220
- [preToolEvent]: [
221
- commandEntry(matchers.read, shimCommand),
222
- commandEntry(matchers.bash, shimCommand),
223
- ],
67
+ [step.event]: step.matchers.map((matcher) => step.entry === 'bare-command' ? { command } : commandEntry(matcher, command)),
224
68
  }
225
69
  : {}),
226
- ...(ctx.statsOnStop ? { Stop: [commandEntry(undefined, stats)] } : {}),
227
- ...(ctx.mapOnStart
228
- ? { SessionStart: [commandEntry('startup|resume|clear|compact', map)] }
70
+ ...(step.lifecycle && ctx.statsOnStop
71
+ ? { [LIFECYCLE_EVENTS.stats]: [commandEntry(undefined, stats)] }
229
72
  : {}),
73
+ ...(step.lifecycle && sessionStart.length > 0 ? { [LIFECYCLE_EVENTS.map]: sessionStart } : {}),
230
74
  };
231
75
  }
76
+ /** The matcher both `SessionStart` entries fire under — a session opening, however. */
77
+ const SESSION_START_MATCHER = 'startup|resume|clear|compact';
232
78
  /**
233
- * True for a hook entry this installer wrote. Matched on the shim/guard script paths
234
- * and the `smelt:hooks` token the stats/map commands carry never on a substring as
235
- * generic as `cli/bin.js`, which another npm CLI's built binary could share: remove
236
- * and re-install may only ever touch entries that are provably smelt's.
79
+ * The two `SessionStart` commands' distinguishing arguments, and **the substrings a
80
+ * re-run recognises each entry by**. Spelled once so the writer and the reader cannot
81
+ * drift: `presetToggles` tells the two entries apart by the command each one runs, and
82
+ * a wizard that wrote `agents lint .` while its reader looked for `agents lint` would
83
+ * read every re-run's lint toggle back as off and quietly delete it.
84
+ */
85
+ const MAP_ON_START_ARGS = 'map .';
86
+ export const AGENTS_LINT_ARGS = 'agents lint .';
87
+ /**
88
+ * True for a hook entry this installer wrote. Matched on the shim script paths and the
89
+ * `smelt:hooks` token the stats/map commands carry — never on a substring as generic
90
+ * as `cli/bin.js`, which another npm CLI's built binary could share: remove and
91
+ * re-install may only ever touch entries that are provably smelt's.
237
92
  */
238
93
  function isOursEntry(entry) {
239
94
  const text = JSON.stringify(entry) ?? '';
240
- return (text.includes('hooks/shims/') ||
241
- text.includes('hooks/guard-core.js') ||
242
- text.includes(OURS_TOKEN));
95
+ return text.includes('hooks/shims/') || text.includes(OURS_TOKEN);
243
96
  }
244
- /** The events this installer manages; foreign entries under them are always preserved. */
245
- const MANAGED_EVENTS = ['PreToolUse', 'Stop', 'SessionStart', 'BeforeTool', 'preToolUse'];
246
97
  /**
247
98
  * Merge our hook entries into a JSON settings file, preserving everything foreign
248
99
  * **byte-faithfully**: the merged `hooks` value is spliced into the original text, so
249
100
  * unknown top-level keys, string escapes, number spellings, indentation and key order
250
- * outside the `hooks` property ride through verbatim (founder ruling: an installer
101
+ * outside the `hooks` property ride through verbatim (an installer
251
102
  * that reformats somebody's settings file has edited what it was never asked to).
252
103
  * Inside `hooks`, unmanaged events and other people's entries under managed events
253
104
  * are preserved; our previous entries are replaced (that is what makes a re-run edit
@@ -298,284 +149,23 @@ export function mergeJsonHooks(existingText, events, shape = {}) {
298
149
  const needsVersion = shape.version !== undefined && root['version'] === undefined;
299
150
  if (!hooksChanged && !needsVersion)
300
151
  return existingText;
301
- const newline = existingText.includes('\r\n') ? '\r\n' : '\n';
302
- const indent = /\n([ \t]+)"/.exec(existingText)?.[1] ?? ' ';
152
+ // The style is read once, off the original: a second edit must match the first.
153
+ const style = jsonStyle(existingText);
303
154
  let text = existingText;
304
155
  if (hooksChanged) {
305
- const scan = scanJsonTopLevel(text);
156
+ text = editTopLevelProperty(text, 'hooks', mergedHooks, style);
306
157
  /* v8 ignore next -- unreachable: JSON.parse accepted the same text above */
307
- if (scan === undefined)
158
+ if (text === undefined)
308
159
  return undefined;
309
- const property = scan.properties.find((candidate) => candidate.key === 'hooks');
310
- if (mergedHooks === undefined) {
311
- if (property !== undefined)
312
- text = removeJsonProperty(text, scan, property);
313
- }
314
- else {
315
- const rendered = renderJsonValue(mergedHooks, indent, newline);
316
- text =
317
- property !== undefined
318
- ? `${text.slice(0, property.valueStart)}${rendered}${text.slice(property.valueEnd)}`
319
- : insertJsonProperty(text, scan, 'hooks', rendered, indent, newline);
320
- }
321
160
  }
322
161
  if (needsVersion) {
323
- const scan = scanJsonTopLevel(text);
162
+ text = editTopLevelProperty(text, 'version', shape.version, style);
324
163
  /* v8 ignore next -- unreachable: every splice above keeps the text valid JSON */
325
- if (scan === undefined)
164
+ if (text === undefined)
326
165
  return undefined;
327
- text = insertJsonProperty(text, scan, 'version', JSON.stringify(shape.version), indent, newline);
328
166
  }
329
167
  return text;
330
168
  }
331
- /**
332
- * Locate the top-level properties of a JSON object *in its source text*, so one
333
- * property can be replaced, inserted or removed while every other byte of the file
334
- * rides through verbatim. `undefined` when the text is not an object — callers have
335
- * already `JSON.parse`d it, so that is belt and braces, not a validator.
336
- */
337
- function scanJsonTopLevel(text) {
338
- let i = skipJsonWhitespace(text, 0);
339
- if (text[i] !== '{')
340
- return undefined;
341
- const open = i;
342
- i = skipJsonWhitespace(text, i + 1);
343
- const properties = [];
344
- if (text[i] === '}')
345
- return { open, close: i, properties };
346
- for (;;) {
347
- if (text[i] !== '"')
348
- return undefined;
349
- const keyStart = i;
350
- const keyEnd = skipJsonString(text, i);
351
- if (keyEnd === undefined)
352
- return undefined;
353
- const key = JSON.parse(text.slice(keyStart, keyEnd));
354
- i = skipJsonWhitespace(text, keyEnd);
355
- if (text[i] !== ':')
356
- return undefined;
357
- const valueStart = skipJsonWhitespace(text, i + 1);
358
- const valueEnd = skipJsonValue(text, valueStart);
359
- if (valueEnd === undefined)
360
- return undefined;
361
- properties.push({ key, keyStart, valueStart, valueEnd });
362
- i = skipJsonWhitespace(text, valueEnd);
363
- if (text[i] === ',') {
364
- i = skipJsonWhitespace(text, i + 1);
365
- continue;
366
- }
367
- if (text[i] === '}')
368
- return { open, close: i, properties };
369
- return undefined;
370
- }
371
- }
372
- function skipJsonWhitespace(text, from) {
373
- let i = from;
374
- while (i < text.length && ' \t\r\n'.includes(text[i]))
375
- i += 1;
376
- return i;
377
- }
378
- /** `from` points at `"`; returns the offset one past the closing quote. */
379
- function skipJsonString(text, from) {
380
- let i = from + 1;
381
- while (i < text.length) {
382
- if (text[i] === '\\')
383
- i += 2;
384
- else if (text[i] === '"')
385
- return i + 1;
386
- else
387
- i += 1;
388
- }
389
- return undefined;
390
- }
391
- function skipJsonValue(text, from) {
392
- const first = text[from];
393
- if (first === '"')
394
- return skipJsonString(text, from);
395
- if (first === '{' || first === '[') {
396
- let depth = 0;
397
- let i = from;
398
- while (i < text.length) {
399
- const ch = text[i];
400
- if (ch === '"') {
401
- const end = skipJsonString(text, i);
402
- if (end === undefined)
403
- return undefined;
404
- i = end;
405
- continue;
406
- }
407
- if (ch === '{' || ch === '[')
408
- depth += 1;
409
- else if (ch === '}' || ch === ']') {
410
- depth -= 1;
411
- if (depth === 0)
412
- return i + 1;
413
- }
414
- i += 1;
415
- }
416
- return undefined;
417
- }
418
- // number / true / false / null
419
- let i = from;
420
- while (i < text.length && !',}] \t\r\n'.includes(text[i]))
421
- i += 1;
422
- return i > from ? i : undefined;
423
- }
424
- /** A JSON value indented for embedding at a top-level property position. */
425
- function renderJsonValue(value, indent, newline) {
426
- return JSON.stringify(value, null, indent).split('\n').join(`${newline}${indent}`);
427
- }
428
- function removeJsonProperty(text, scan, property) {
429
- const index = scan.properties.indexOf(property);
430
- const next = scan.properties[index + 1];
431
- if (next !== undefined) {
432
- // Delete through the separating comma and whitespace, up to the next key.
433
- return text.slice(0, property.keyStart) + text.slice(next.keyStart);
434
- }
435
- const previous = scan.properties[index - 1];
436
- // Last (or only) property: delete the preceding comma (if any) with it.
437
- const from = previous !== undefined ? previous.valueEnd : scan.open + 1;
438
- return text.slice(0, from) + text.slice(property.valueEnd);
439
- }
440
- function insertJsonProperty(text, scan, key, renderedValue, indent, newline) {
441
- const entry = `${JSON.stringify(key)}: ${renderedValue}`;
442
- if (scan.properties.length === 0) {
443
- return `${text.slice(0, scan.open + 1)}${newline}${indent}${entry}${newline}${text.slice(scan.close)}`;
444
- }
445
- const last = scan.properties[scan.properties.length - 1];
446
- return `${text.slice(0, last.valueEnd)},${newline}${indent}${entry}${text.slice(last.valueEnd)}`;
447
- }
448
- /** Replace this installer's marker block in `existingText`, or append it. */
449
- export function upsertMarkerBlock(existingText, block, start, end) {
450
- if (existingText === undefined || existingText.trim() === '')
451
- return block;
452
- const startIndex = existingText.indexOf(start);
453
- const endIndex = existingText.indexOf(end);
454
- if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
455
- const before = existingText.slice(0, startIndex);
456
- const after = existingText.slice(endIndex + end.length).replace(/^\n/, '');
457
- return `${before}${block}${after}`;
458
- }
459
- return `${existingText.replace(/\n*$/, '\n\n')}${block}`;
460
- }
461
- /** Remove the marker block. `undefined` when nothing (or only whitespace) remains. */
462
- export function stripMarkerBlock(existingText, start, end) {
463
- const startIndex = existingText.indexOf(start);
464
- const endIndex = existingText.indexOf(end);
465
- if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex)
466
- return existingText;
467
- const stripped = existingText.slice(0, startIndex).replace(/\n+$/, '\n') +
468
- existingText.slice(endIndex + end.length).replace(/^\n+/, '');
469
- return stripped.trim() === '' ? undefined : stripped;
470
- }
471
- /** The Codex `config.toml` block: enables the hooks feature, marker-bracketed. */
472
- function codexConfigTomlBlock() {
473
- return `${SNIPPET_START_HASH}
474
- # Enables Codex's hooks feature so .codex/hooks.json is honored. Project-level hooks
475
- # run only once this project is trusted. Written by \`smelt hooks install\`.
476
- [features]
477
- hooks = true
478
- ${SNIPPET_END_HASH}
479
- `;
480
- }
481
- /** The opencode plugin — the shim for a harness whose hooks are a JS plugin API. */
482
- function opencodePluginSource(cwd) {
483
- const guardCore = portablePath(cwd, guardCoreScriptPath());
484
- return `// smelt:hooks v1 — opencode plugin shim. EXPERIMENTAL tier: mapped from the
485
- // capability matrix (docs/research/2026-09-02-harness-capability-matrix.md, opencode
486
- // row; https://opencode.ai/docs/plugins/). This template's deny/pass/window paths
487
- // were exercised directly against the built guard core (KOT-212 verification,
488
- // 2026-09-02), but a live opencode session has not been smoke-tested — that needs
489
- // provider credentials. Caveat carried from the matrix: MCP tools can bypass plugin
490
- // hooks (sst/opencode#2319) — this guard sees built-in tools only.
491
- //
492
- // Thin adapter: maps tool.execute.before onto the smelt guard core (zero
493
- // dependencies), which owns every decision. Deny mode throws (opencode surfaces the
494
- // reason to the model); rewrite mode substitutes the faithful replacement command —
495
- // announced on stderr, because the plugin API has no reason channel on a rewrite
496
- // and a substitution must never be silent.
497
- import { pathToFileURL } from 'node:url';
498
-
499
- const GUARD_CORE = ${JSON.stringify(guardCore)};
500
- const core = await import(pathToFileURL(GUARD_CORE).href);
501
-
502
- export const SmeltGuard = async () => ({
503
- 'tool.execute.before': async (input, output) => {
504
- const tool = input?.tool;
505
- const args = output?.args ?? {};
506
- let request;
507
- if (tool === 'read' && typeof args.filePath === 'string') {
508
- request = {
509
- tool: 'Read',
510
- input: {
511
- path: args.filePath,
512
- offsetLimited: args.offset !== undefined || args.limit !== undefined,
513
- },
514
- };
515
- } else if (tool === 'bash' && typeof args.command === 'string') {
516
- request = { tool: 'Bash', input: { command: args.command } };
517
- } else {
518
- return;
519
- }
520
- const warn = (text) => process.stderr.write(text + '\\n');
521
- const settings = core.readGuardSettings(process.cwd(), warn);
522
- const decision = core.decide(request, settings, process.cwd());
523
- if (decision.action !== 'deny') return;
524
- if (
525
- settings.enforcement === 'rewrite' &&
526
- request.tool === 'Bash' &&
527
- decision.suggestion !== undefined
528
- ) {
529
- warn(
530
- 'smelt guard (rewrite mode): substituted the command in-flight with \`' +
531
- decision.suggestion +
532
- '\`. ' +
533
- (decision.reason ?? ''),
534
- );
535
- output.args.command = decision.suggestion;
536
- return;
537
- }
538
- throw new Error(decision.reason ?? 'denied by the smelt guard');
539
- },
540
- });
541
- `;
542
- }
543
- /** Cline's hook is an executable file; this two-liner hands it to the cline shim. */
544
- function clineHookSource(cwd) {
545
- return `#!/bin/sh
546
- # smelt:hooks v1 — Cline PreToolUse hook. EXPERIMENTAL tier: schema mapped from the
547
- # capability matrix (docs/research/2026-09-02-harness-capability-matrix.md, Cline row),
548
- # not yet smoke-tested against the real binary. Written by \`smelt hooks install\`.
549
- exec node "${portablePath(cwd, shimScriptPath('cline'))}"
550
- `;
551
- }
552
- /** Hermes hook config, as a mergeable snippet — their config is a home-level YAML. */
553
- function hermesHooksYaml(cwd) {
554
- return `${SNIPPET_START_HASH}
555
- # Hermes Agent hook config for the smelt guard. EXPERIMENTAL tier: schema mapped from
556
- # the capability matrix (docs/research/2026-09-02-harness-capability-matrix.md, Hermes
557
- # row), not yet smoke-tested against the real binary. If Hermes does not read this
558
- # file directly, merge the \`hooks:\` section into ~/.hermes/config.yaml.
559
- hooks:
560
- pre_tool_call:
561
- - command: node "${portablePath(cwd, shimScriptPath('hermes'))}"
562
- ${SNIPPET_END_HASH}
563
- `;
564
- }
565
- /** KiloCode's advisory rules file: the snippet plus the two manual enforcement legs. */
566
- function kilocodeRulesSource(thresholdBytes, budgetBytes) {
567
- return `${instructionSnippet(thresholdBytes, budgetBytes)}
568
- <!-- smelt:hooks v1 advisory notes -->
569
-
570
- KiloCode has no first-class hook API (Kilo-Org/kilocode#5827), so nothing above is
571
- enforced — it is advisory. Two manual legs make it harder to bypass:
572
-
573
- 1. Permissions: in your KiloCode per-tool permission config, set raw-read/execute
574
- tools to "ask" so oversized reads surface for review instead of passing silently.
575
- 2. MCP: expose smelt through an MCP server and prefer its tools; a resident server
576
- also keeps smelt's grammar cache warm across calls.
577
- `;
578
- }
579
169
  function planFile(cwd, name, content, mode) {
580
170
  const path = join(cwd, name);
581
171
  const exists = existsSync(path);
@@ -587,8 +177,9 @@ function readIfExists(path) {
587
177
  }
588
178
  /**
589
179
  * Every file `install` would write, computed against the current disk state — pure
590
- * planning, nothing written. Shared instruction files (several harnesses read
591
- * AGENTS.md) are planned once.
180
+ * planning, nothing written. A fold over each chosen profile's `install` list and its
181
+ * instruction layer; all per-harness knowledge is in the profiles. Shared instruction
182
+ * files (several harnesses read AGENTS.md) are planned once.
592
183
  *
593
184
  * @throws {CliUsageError} when an existing `smelt.config.json` is malformed — the
594
185
  * same refusal every other subcommand makes; an installer that guessed around a
@@ -621,6 +212,8 @@ export function planInstall(cwd, choices) {
621
212
  guard: choices.guard,
622
213
  statsOnStop: choices.statsOnStop,
623
214
  mapOnStart: choices.mapOnStart,
215
+ lintOnStart: choices.lintOnStart,
216
+ thresholdBytes: choices.thresholdBytes,
624
217
  budgetBytes,
625
218
  };
626
219
  const snippet = instructionSnippet(choices.thresholdBytes, budgetBytes);
@@ -639,92 +232,46 @@ export function planInstall(cwd, choices) {
639
232
  }
640
233
  files.set(path, planFile(cwd, name, merged));
641
234
  };
642
- const planSnippetFile = (name) => {
235
+ const planBlockFile = (name, block, start, end, skipWhen) => {
643
236
  const path = join(cwd, name);
644
237
  if (files.has(path))
645
- return; // shared instruction file, already planned
646
- files.set(path, planFile(cwd, name, upsertMarkerBlock(readIfExists(path), snippet, SNIPPET_START_MD, SNIPPET_END_MD)));
238
+ return; // a shared file (AGENTS.md), already planned
239
+ const existing = readIfExists(path);
240
+ // A file that already carries its owner's version of what this block does is
241
+ // theirs to edit, not ours: say so, and touch nothing.
242
+ if (skipWhen !== undefined &&
243
+ existing !== undefined &&
244
+ !existing.includes(start) &&
245
+ existing.includes(skipWhen.contains)) {
246
+ skipped.push({ name, why: skipWhen.why });
247
+ return;
248
+ }
249
+ files.set(path, planFile(cwd, name, upsertMarkerBlock(existing, block, start, end)));
647
250
  };
648
- for (const spec of choices.harnesses) {
649
- switch (spec.id) {
650
- case 'claude-code': {
651
- planJsonHooks('.claude/settings.json', claudeStyleEvents(ctx, 'claude-code', 'PreToolUse', { read: 'Read', bash: 'Bash' }));
652
- break;
251
+ for (const profile of choices.harnesses) {
252
+ for (const step of profile.install) {
253
+ switch (step.kind) {
254
+ case 'json-hooks':
255
+ planJsonHooks(step.file, jsonHookEvents(step, ctx, shimCommand(profile, cwd)), step.shape ?? {});
256
+ break;
257
+ case 'marker-block':
258
+ planBlockFile(step.file, step.block(ctx), step.start, step.end, step.skipWhen);
259
+ break;
260
+ case 'own-file':
261
+ if (step.guardOnly && !ctx.guard)
262
+ break;
263
+ files.set(join(cwd, step.file), planFile(cwd, step.file, step.content(ctx), step.mode));
264
+ break;
653
265
  }
654
- case 'codex': {
655
- planJsonHooks('.codex/hooks.json', claudeStyleEvents(ctx, 'codex', 'PreToolUse', { read: 'Read', bash: 'Bash' }));
656
- const tomlPath = join(cwd, '.codex/config.toml');
657
- const existingToml = readIfExists(tomlPath);
658
- if (existingToml !== undefined &&
659
- !existingToml.includes(SNIPPET_START_HASH) &&
660
- existingToml.includes('[features]')) {
661
- skipped.push({
662
- name: '.codex/config.toml',
663
- why: 'already has a [features] table — add `hooks = true` to it yourself',
664
- });
665
- }
666
- else {
667
- files.set(tomlPath, planFile(cwd, '.codex/config.toml', upsertMarkerBlock(existingToml, codexConfigTomlBlock(), SNIPPET_START_HASH, SNIPPET_END_HASH)));
668
- }
669
- break;
670
- }
671
- case 'gemini': {
672
- planJsonHooks('.gemini/settings.json', ctx.guard
673
- ? {
674
- BeforeTool: [
675
- commandEntry('read_file', nodeCommand(cwd, shimScriptPath('gemini'))),
676
- commandEntry('run_shell_command', nodeCommand(cwd, shimScriptPath('gemini'))),
677
- ],
678
- }
679
- : {});
680
- break;
681
- }
682
- case 'grok': {
683
- planJsonHooks('.grok/hooks.json', ctx.guard
684
- ? {
685
- PreToolUse: [
686
- commandEntry('Read', nodeCommand(cwd, shimScriptPath('grok'))),
687
- commandEntry('Bash', nodeCommand(cwd, shimScriptPath('grok'))),
688
- ],
689
- }
690
- : {});
691
- break;
692
- }
693
- case 'hermes': {
694
- if (ctx.guard) {
695
- files.set(join(cwd, '.hermes/hooks.yaml'), planFile(cwd, '.hermes/hooks.yaml', hermesHooksYaml(cwd)));
696
- }
697
- break;
698
- }
699
- case 'cursor': {
700
- planJsonHooks('.cursor/hooks.json', ctx.guard
701
- ? { preToolUse: [{ command: nodeCommand(cwd, shimScriptPath('cursor')) }] }
702
- : {}, { version: 1 });
703
- break;
704
- }
705
- case 'opencode': {
706
- if (ctx.guard) {
707
- files.set(join(cwd, '.opencode/plugin/smelt-guard.js'), planFile(cwd, '.opencode/plugin/smelt-guard.js', opencodePluginSource(cwd)));
708
- }
709
- break;
710
- }
711
- case 'cline': {
712
- if (ctx.guard) {
713
- files.set(join(cwd, '.clinerules/hooks/PreToolUse'), planFile(cwd, '.clinerules/hooks/PreToolUse', clineHookSource(cwd), 0o755));
714
- }
715
- break;
716
- }
717
- case 'kilocode': {
718
- files.set(join(cwd, spec.instructionFile), planFile(cwd, spec.instructionFile, kilocodeRulesSource(choices.thresholdBytes, budgetBytes)));
719
- break;
720
- }
721
- case 'aider':
722
- break; // instruction file only, planned below
723
266
  }
724
- if (spec.id !== 'kilocode')
725
- planSnippetFile(spec.instructionFile);
726
- for (const caveat of spec.caveats)
727
- notes.push(`${spec.name}: ${caveat}`);
267
+ if (profile.instructions === 'snippet') {
268
+ planBlockFile(profile.instructionFile, snippet, SNIPPET_START_MD, SNIPPET_END_MD);
269
+ }
270
+ else {
271
+ files.set(join(cwd, profile.instructionFile), planFile(cwd, profile.instructionFile, profile.instructions(ctx)));
272
+ }
273
+ for (const caveat of profile.caveats)
274
+ notes.push(`${profile.name}: ${caveat}`);
728
275
  }
729
276
  return { files: [...files.values()], skipped, notes };
730
277
  }
@@ -739,20 +286,32 @@ export const DEFAULT_STORE_DIR = '.smelt/store';
739
286
  * config cannot run would be the exact silent-failure shape this project refuses.
740
287
  * An *explicit* `{"kind":"memory"}` is respected; the guard then conditions its
741
288
  * retrieve promise on the store kind instead (`retrieveSentence` in guard-core).
289
+ *
290
+ * That store injection is this verb's **policy**, which is why it lives here; the
291
+ * bytes are written by `renderConfig` in `config.ts`, the one writer, so a key added
292
+ * to the schema reaches this file and `init`'s together or not at all.
293
+ *
294
+ * "Carried verbatim" is spelled as a spread rather than as a list of the fields to
295
+ * copy, and that is load-bearing: the list version silently dropped every key nobody
296
+ * remembered to add to it — `agents` was added to the schema and this function kept
297
+ * writing configs without it, which is a setting the user believed was in force,
298
+ * caught by `test/guards/config-writer.test.ts`. Only the two fields this verb
299
+ * actually decides are named.
742
300
  */
743
301
  export function renderConfigWithHooks(existing, hooks) {
744
- const config = {
302
+ return renderConfig({
303
+ ...existing,
745
304
  smeltConfig: CONFIG_VERSION,
746
- ...(existing?.defaultBudgetBytes === undefined
747
- ? {}
748
- : { defaultBudgetBytes: existing.defaultBudgetBytes }),
749
- ...(existing?.strategy === undefined ? {} : { strategy: existing.strategy }),
750
305
  store: existing?.store ?? { kind: 'directory', path: DEFAULT_STORE_DIR },
751
306
  hooks,
752
- };
753
- return `${JSON.stringify(config, null, 2)}\n`;
307
+ });
754
308
  }
755
- /** Everything `remove` would delete or strip, computed against the current disk state. */
309
+ /**
310
+ * Everything `remove` would delete or strip, computed against the current disk state.
311
+ * The mirror image of {@link planInstall}, over the same data: each install step's
312
+ * kind is also how it comes back out — a JSON hook file is strip-merged, a marker
313
+ * block is stripped, a file that is entirely ours is deleted.
314
+ */
756
315
  export function planRemove(cwd, harnesses) {
757
316
  const removals = new Map();
758
317
  const planJsonStrip = (name) => {
@@ -789,41 +348,26 @@ export function planRemove(cwd, harnesses) {
789
348
  return;
790
349
  removals.set(path, { name, path, action: 'delete' });
791
350
  };
792
- for (const spec of harnesses) {
793
- switch (spec.id) {
794
- case 'claude-code':
795
- planJsonStrip('.claude/settings.json');
796
- break;
797
- case 'codex':
798
- planJsonStrip('.codex/hooks.json');
799
- planBlockStrip('.codex/config.toml', SNIPPET_START_HASH, SNIPPET_END_HASH);
800
- break;
801
- case 'gemini':
802
- planJsonStrip('.gemini/settings.json');
803
- break;
804
- case 'grok':
805
- planJsonStrip('.grok/hooks.json');
806
- break;
807
- case 'hermes':
808
- planWholeFileDelete('.hermes/hooks.yaml');
809
- break;
810
- case 'cursor':
811
- planJsonStrip('.cursor/hooks.json');
812
- break;
813
- case 'opencode':
814
- planWholeFileDelete('.opencode/plugin/smelt-guard.js');
815
- break;
816
- case 'cline':
817
- planWholeFileDelete('.clinerules/hooks/PreToolUse');
818
- break;
819
- case 'kilocode':
820
- case 'aider':
821
- break;
351
+ for (const profile of harnesses) {
352
+ for (const step of profile.install) {
353
+ switch (step.kind) {
354
+ case 'json-hooks':
355
+ planJsonStrip(step.file);
356
+ break;
357
+ case 'marker-block':
358
+ planBlockStrip(step.file, step.start, step.end);
359
+ break;
360
+ case 'own-file':
361
+ planWholeFileDelete(step.file);
362
+ break;
363
+ }
364
+ }
365
+ if (profile.instructions === 'snippet') {
366
+ planBlockStrip(profile.instructionFile, SNIPPET_START_MD, SNIPPET_END_MD);
367
+ }
368
+ else {
369
+ planWholeFileDelete(profile.instructionFile);
822
370
  }
823
- if (spec.id === 'kilocode')
824
- planWholeFileDelete(spec.instructionFile);
825
- else
826
- planBlockStrip(spec.instructionFile, SNIPPET_START_MD, SNIPPET_END_MD);
827
371
  }
828
372
  return [...removals.values()];
829
373
  }
@@ -832,16 +376,16 @@ export function planRemove(cwd, harnesses) {
832
376
  * `runInit`: a pure function over an input/output pair, exit code returned.
833
377
  */
834
378
  export async function runHooks(action, harnessFlag, io) {
835
- const rl = createInterface({ input: io.input });
836
- const lines = rl[Symbol.asyncIterator]();
379
+ // Same adapter, same reason, as `runInit` — see the note on `answerReader`.
380
+ const lines = answerReader(io.input);
837
381
  const ask = async (prompt) => {
838
382
  io.output(prompt);
839
383
  const next = await lines.next();
840
- if (next.done === true) {
384
+ if (next === undefined) {
841
385
  throw new CliUsageError(`${CLI_NAME} hooks: input ended before the wizard finished. ` +
842
386
  `Files already confirmed and written stay; nothing further was written.`);
843
387
  }
844
- return next.value.trim();
388
+ return next.trim();
845
389
  };
846
390
  try {
847
391
  return action === 'install'
@@ -849,19 +393,19 @@ export async function runHooks(action, harnessFlag, io) {
849
393
  : await removeFlow(io, ask, harnessFlag);
850
394
  }
851
395
  finally {
852
- rl.close();
396
+ await lines.release();
853
397
  }
854
398
  }
855
399
  function resolveHarnessFlag(flag) {
856
- const spec = harnessById(flag);
857
- if (spec === undefined) {
400
+ const profile = harnessById(flag);
401
+ if (profile === undefined) {
858
402
  throw new CliUsageError(`${CLI_NAME} hooks: unknown harness "${flag}". ` +
859
403
  `Known: ${HARNESSES.map((h) => h.id).join(', ')}.`);
860
404
  }
861
- return spec;
405
+ return profile;
862
406
  }
863
- function tierLabel(spec) {
864
- return `${spec.id.padEnd(12)} ${spec.name.padEnd(14)} [${spec.tier}] — ${TIER_HONESTY[spec.tier]}`;
407
+ function tierLabel(profile) {
408
+ return `${profile.id.padEnd(12)} ${profile.name.padEnd(14)} [${profile.tier}] — ${TIER_HONESTY[profile.tier]}`;
865
409
  }
866
410
  async function installFlow(io, ask, harnessFlag) {
867
411
  const home = io.home ?? homedir();
@@ -878,8 +422,8 @@ async function installFlow(io, ask, harnessFlag) {
878
422
  // With --harness the selection step is skipped, so the tier label — and its one
879
423
  // line of honesty about what the tier means — is printed here instead.
880
424
  if (harnessFlag !== undefined) {
881
- for (const spec of choices.harnesses)
882
- io.output(` ${tierLabel(spec)}\n`);
425
+ for (const profile of choices.harnesses)
426
+ io.output(` ${tierLabel(profile)}\n`);
883
427
  }
884
428
  const steps = [
885
429
  async (io_, ask_) => harnessFlag !== undefined ? 'ok' : stepHarnesses(io_, ask_, choices, detected),
@@ -887,15 +431,24 @@ async function installFlow(io, ask, harnessFlag) {
887
431
  choices.guard = on;
888
432
  }),
889
433
  async (io_, ask_) => stepToggle(io_, ask_, 'stats on Stop', `\`smelt stats\` runs when a session ends — the honest signal (expansion rate) ` +
890
- `surfaced where the turn ends. Observation only; never blocks. Wired for ` +
891
- `verified-tier harnesses (Claude Code, Codex).`, choices.statsOnStop, (on) => {
434
+ `surfaced where the turn ends. Observation only; never blocks. Wired for the ` +
435
+ `harnesses whose hooks carry session events (${harnessNames(lifecycleHarnesses())}).`, choices.statsOnStop, (on) => {
892
436
  choices.statsOnStop = on;
893
437
  }),
894
438
  async (io_, ask_) => stepToggle(io_, ask_, 'repo map on SessionStart', `\`smelt map . --budget …\` runs at session start and its output opens the ` +
895
439
  `context — the agent starts oriented. Costs one map build per session. ` +
896
- `Wired for verified-tier harnesses (Claude Code, Codex).`, choices.mapOnStart, (on) => {
440
+ `Wired for the harnesses whose hooks carry session events ` +
441
+ `(${harnessNames(lifecycleHarnesses())}).`, choices.mapOnStart, (on) => {
897
442
  choices.mapOnStart = on;
898
443
  }),
444
+ async (io_, ask_) => stepToggle(io_, ask_, 'instruction-file lint on SessionStart', `\`smelt agents lint .\` runs at session start and reports on the AGENTS.md, ` +
445
+ `CLAUDE.md and GEMINI.md this session is about to load on every request — ` +
446
+ `bytes per level, and any path or link in them that no longer resolves. ` +
447
+ `Advisory: it never blocks, and it exits 0 unless you set ` +
448
+ `agents.budgetBytes in ${CONFIG_FILE_NAME}. Wired for verified-tier ` +
449
+ `harnesses (Claude Code, Codex).`, choices.lintOnStart, (on) => {
450
+ choices.lintOnStart = on;
451
+ }),
899
452
  async (io_, ask_) => stepEnforcement(io_, ask_, choices),
900
453
  async (io_, ask_) => stepThreshold(io_, ask_, choices),
901
454
  ];
@@ -931,13 +484,13 @@ function guardCopy() {
931
484
  }
932
485
  async function stepHarnesses(io, ask, choices, detected) {
933
486
  io.output(`\nHarnesses — detected by their config directories (project or home):\n`);
934
- for (const spec of HARNESSES) {
935
- const mark = detected.includes(spec) ? '*' : ' ';
936
- io.output(` ${mark} ${tierLabel(spec)}\n`);
487
+ for (const profile of HARNESSES) {
488
+ const mark = detected.includes(profile) ? '*' : ' ';
489
+ io.output(` ${mark} ${tierLabel(profile)}\n`);
937
490
  }
938
491
  io.output(`(* = detected here)\n`);
939
492
  for (;;) {
940
- const current = choices.harnesses.map((spec) => spec.id).join(',') || '(none)';
493
+ const current = choices.harnesses.map((profile) => profile.id).join(',') || '(none)';
941
494
  const answer = await ask(`install for which? (comma-separated ids, Enter = ${current}, or back)\n> `);
942
495
  if (answer === 'back')
943
496
  return 'back';
@@ -947,20 +500,20 @@ async function stepHarnesses(io, ask, choices, detected) {
947
500
  .split(',')
948
501
  .map((id) => id.trim())
949
502
  .filter((id) => id !== '');
950
- const specs = [];
503
+ const chosen = [];
951
504
  let bad;
952
505
  for (const id of ids) {
953
- const spec = harnessById(id);
954
- if (spec === undefined)
506
+ const profile = harnessById(id);
507
+ if (profile === undefined)
955
508
  bad = id;
956
- else if (!specs.includes(spec))
957
- specs.push(spec);
509
+ else if (!chosen.includes(profile))
510
+ chosen.push(profile);
958
511
  }
959
512
  if (bad !== undefined) {
960
513
  io.output(`Unknown harness "${bad}". Known: ${HARNESSES.map((h) => h.id).join(', ')}.\n`);
961
514
  continue;
962
515
  }
963
- choices.harnesses = specs;
516
+ choices.harnesses = chosen;
964
517
  return 'ok';
965
518
  }
966
519
  }
@@ -1017,37 +570,27 @@ async function stepThreshold(io, ask, choices) {
1017
570
  io.output(`A whole number of bytes greater than zero, e.g. 8192.\n`);
1018
571
  }
1019
572
  }
1020
- /** The JSON hook files a re-run reads installed toggles back from, per harness. */
1021
- const TOGGLE_READBACK_FILES = [
1022
- '.claude/settings.json',
1023
- '.codex/hooks.json',
1024
- '.gemini/settings.json',
1025
- '.grok/hooks.json',
1026
- '.cursor/hooks.json',
1027
- ];
1028
- /** Guard-only files whose presence means the guard toggle was installed. */
1029
- const GUARD_ONLY_FILES = [
1030
- '.hermes/hooks.yaml',
1031
- '.opencode/plugin/smelt-guard.js',
1032
- '.clinerules/hooks/PreToolUse',
1033
- ];
1034
- /** The managed events that wire the PreToolUse guard, across harness spellings. */
1035
- const GUARD_EVENTS = ['PreToolUse', 'BeforeTool', 'preToolUse'];
1036
573
  /**
1037
574
  * A re-run reads the toggles back off what is actually installed — every JSON hook
1038
- * file this installer writes, plus the guard-only shim files so it edits instead of
1039
- * resetting. Harnesses that only wire the guard (gemini, grok, cursor, hermes,
1040
- * opencode, cline) persist no stats/map entries, so after a re-run scoped to them
1041
- * those toggles read back as off; the defaults below apply only when nothing of
1042
- * smelt's is installed at all.
575
+ * file this installer writes, plus the guard-only shim files, both derived from the
576
+ * registry — so it edits instead of resetting. Harnesses that only wire the guard
577
+ * (gemini, grok, cursor, hermes, opencode, cline) persist no stats/map entries, so
578
+ * after a re-run scoped to them those toggles read back as off; the defaults below
579
+ * apply only when nothing of smelt's is installed at all.
580
+ *
581
+ * The two `SessionStart` toggles share one event, so they are told apart by **the
582
+ * command each entry runs**, not by the key it sits under. Reading `SessionStart` as
583
+ * one boolean would make a re-run with the map on and the lint off write both back —
584
+ * or neither — which is a toggle the user believed they had set.
1043
585
  */
1044
586
  function presetToggles(cwd) {
1045
- const defaults = { guard: true, statsOnStop: true, mapOnStart: false };
587
+ const defaults = { guard: true, statsOnStop: true, mapOnStart: false, lintOnStart: false };
1046
588
  let anyOurs = false;
1047
589
  let guard = false;
1048
590
  let statsOnStop = false;
1049
591
  let mapOnStart = false;
1050
- for (const name of TOGGLE_READBACK_FILES) {
592
+ let lintOnStart = false;
593
+ for (const name of JSON_HOOK_FILES) {
1051
594
  const text = readIfExists(join(cwd, name));
1052
595
  if (text === undefined)
1053
596
  continue;
@@ -1068,14 +611,19 @@ function presetToggles(cwd) {
1068
611
  if (hooks === undefined)
1069
612
  continue;
1070
613
  const installed = hooks;
1071
- const hasOurs = (event) => Array.isArray(installed[event]) &&
1072
- installed[event].some((entry) => isOursEntry(entry));
614
+ const oursUnder = (event) => Array.isArray(installed[event])
615
+ ? installed[event].filter((entry) => isOursEntry(entry))
616
+ : [];
617
+ const hasOurs = (event) => oursUnder(event).length > 0;
618
+ /** One of ours under `event` whose command carries `needle`. */
619
+ const hasOursRunning = (event, needle) => oursUnder(event).some((entry) => (JSON.stringify(entry) ?? '').includes(needle));
1073
620
  if (!MANAGED_EVENTS.some((event) => hasOurs(event)))
1074
621
  continue;
1075
622
  anyOurs = true;
1076
623
  guard ||= GUARD_EVENTS.some((event) => hasOurs(event));
1077
- statsOnStop ||= hasOurs('Stop');
1078
- mapOnStart ||= hasOurs('SessionStart');
624
+ statsOnStop ||= hasOurs(LIFECYCLE_EVENTS.stats);
625
+ mapOnStart ||= hasOursRunning(LIFECYCLE_EVENTS.map, MAP_ON_START_ARGS);
626
+ lintOnStart ||= hasOursRunning(LIFECYCLE_EVENTS.lint, AGENTS_LINT_ARGS);
1079
627
  }
1080
628
  for (const name of GUARD_ONLY_FILES) {
1081
629
  const text = readIfExists(join(cwd, name));
@@ -1084,7 +632,7 @@ function presetToggles(cwd) {
1084
632
  guard = true;
1085
633
  }
1086
634
  }
1087
- return anyOurs ? { guard, statsOnStop, mapOnStart } : defaults;
635
+ return anyOurs ? { guard, statsOnStop, mapOnStart, lintOnStart } : defaults;
1088
636
  }
1089
637
  const fileLabel = (file) => {
1090
638
  if (file.unchanged)