@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.
- package/README.md +3 -3
- package/dist/agents/guide.d.ts +68 -0
- package/dist/agents/guide.d.ts.map +1 -0
- package/dist/agents/guide.js +72 -0
- package/dist/agents/guide.js.map +1 -0
- package/dist/agents/instructions.d.ts +133 -0
- package/dist/agents/instructions.d.ts.map +1 -0
- package/dist/agents/instructions.js +212 -0
- package/dist/agents/instructions.js.map +1 -0
- package/dist/agents/lint.d.ts +154 -0
- package/dist/agents/lint.d.ts.map +1 -0
- package/dist/agents/lint.js +761 -0
- package/dist/agents/lint.js.map +1 -0
- package/dist/agents/split.d.ts +113 -0
- package/dist/agents/split.d.ts.map +1 -0
- package/dist/agents/split.js +234 -0
- package/dist/agents/split.js.map +1 -0
- package/dist/apply.d.ts.map +1 -1
- package/dist/apply.js.map +1 -1
- package/dist/cache/prefix.d.ts.map +1 -1
- package/dist/cache/prefix.js.map +1 -1
- package/dist/cli/agents.d.ts +39 -0
- package/dist/cli/agents.d.ts.map +1 -0
- package/dist/cli/agents.js +121 -0
- package/dist/cli/agents.js.map +1 -0
- package/dist/cli/args.d.ts +17 -70
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +18 -338
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/bin.d.ts.map +1 -1
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/config.d.ts +60 -0
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +89 -2
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/hooks.d.ts +55 -51
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +206 -658
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/init.d.ts +30 -15
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +167 -38
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/report.d.ts +23 -0
- package/dist/cli/report.d.ts.map +1 -1
- package/dist/cli/report.js +142 -4
- package/dist/cli/report.js.map +1 -1
- package/dist/cli/run.d.ts +29 -90
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +34 -342
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/shell.d.ts +106 -0
- package/dist/cli/shell.d.ts.map +1 -0
- package/dist/cli/shell.js +100 -0
- package/dist/cli/shell.js.map +1 -0
- package/dist/cli/subcommands/agents.d.ts +72 -0
- package/dist/cli/subcommands/agents.d.ts.map +1 -0
- package/dist/cli/subcommands/agents.js +162 -0
- package/dist/cli/subcommands/agents.js.map +1 -0
- package/dist/cli/subcommands/flags.d.ts +144 -0
- package/dist/cli/subcommands/flags.d.ts.map +1 -0
- package/dist/cli/subcommands/flags.js +220 -0
- package/dist/cli/subcommands/flags.js.map +1 -0
- package/dist/cli/subcommands/hooks.d.ts +18 -0
- package/dist/cli/subcommands/hooks.d.ts.map +1 -0
- package/dist/cli/subcommands/hooks.js +80 -0
- package/dist/cli/subcommands/hooks.js.map +1 -0
- package/dist/cli/subcommands/init.d.ts +19 -0
- package/dist/cli/subcommands/init.d.ts.map +1 -0
- package/dist/cli/subcommands/init.js +44 -0
- package/dist/cli/subcommands/init.js.map +1 -0
- package/dist/cli/subcommands/map.d.ts +75 -0
- package/dist/cli/subcommands/map.d.ts.map +1 -0
- package/dist/cli/subcommands/map.js +132 -0
- package/dist/cli/subcommands/map.js.map +1 -0
- package/dist/cli/subcommands/registry.d.ts +81 -0
- package/dist/cli/subcommands/registry.d.ts.map +1 -0
- package/dist/cli/subcommands/registry.js +141 -0
- package/dist/cli/subcommands/registry.js.map +1 -0
- package/dist/cli/subcommands/retrieve.d.ts +74 -0
- package/dist/cli/subcommands/retrieve.d.ts.map +1 -0
- package/dist/cli/subcommands/retrieve.js +84 -0
- package/dist/cli/subcommands/retrieve.js.map +1 -0
- package/dist/cli/subcommands/smelt.d.ts +107 -0
- package/dist/cli/subcommands/smelt.d.ts.map +1 -0
- package/dist/cli/subcommands/smelt.js +304 -0
- package/dist/cli/subcommands/smelt.js.map +1 -0
- package/dist/cli/subcommands/stats.d.ts +43 -0
- package/dist/cli/subcommands/stats.d.ts.map +1 -0
- package/dist/cli/subcommands/stats.js +62 -0
- package/dist/cli/subcommands/stats.js.map +1 -0
- package/dist/cli/subcommands/subcommand.d.ts +113 -0
- package/dist/cli/subcommands/subcommand.d.ts.map +1 -0
- package/dist/cli/subcommands/subcommand.js +3 -0
- package/dist/cli/subcommands/subcommand.js.map +1 -0
- package/dist/cli/usage.d.ts +3 -0
- package/dist/cli/usage.d.ts.map +1 -0
- package/dist/cli/usage.js +95 -0
- package/dist/cli/usage.js.map +1 -0
- package/dist/detect.d.ts.map +1 -1
- package/dist/detect.js.map +1 -1
- package/dist/errors.d.ts +21 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +33 -1
- package/dist/errors.js.map +1 -1
- package/dist/harness/aider.d.ts +10 -0
- package/dist/harness/aider.d.ts.map +1 -0
- package/dist/harness/aider.js +21 -0
- package/dist/harness/aider.js.map +1 -0
- package/dist/harness/claude-code.d.ts +3 -0
- package/dist/harness/claude-code.d.ts.map +1 -0
- package/dist/harness/claude-code.js +68 -0
- package/dist/harness/claude-code.js.map +1 -0
- package/dist/harness/cline.d.ts +3 -0
- package/dist/harness/cline.d.ts.map +1 -0
- package/dist/harness/cline.js +54 -0
- package/dist/harness/cline.js.map +1 -0
- package/dist/harness/codex.d.ts +3 -0
- package/dist/harness/codex.d.ts.map +1 -0
- package/dist/harness/codex.js +92 -0
- package/dist/harness/codex.js.map +1 -0
- package/dist/harness/cursor.d.ts +3 -0
- package/dist/harness/cursor.d.ts.map +1 -0
- package/dist/harness/cursor.js +54 -0
- package/dist/harness/cursor.js.map +1 -0
- package/dist/harness/gemini.d.ts +3 -0
- package/dist/harness/gemini.d.ts.map +1 -0
- package/dist/harness/gemini.js +60 -0
- package/dist/harness/gemini.js.map +1 -0
- package/dist/harness/grok.d.ts +3 -0
- package/dist/harness/grok.d.ts.map +1 -0
- package/dist/harness/grok.js +47 -0
- package/dist/harness/grok.js.map +1 -0
- package/dist/harness/hermes.d.ts +3 -0
- package/dist/harness/hermes.d.ts.map +1 -0
- package/dist/harness/hermes.js +72 -0
- package/dist/harness/hermes.js.map +1 -0
- package/dist/harness/kilocode.d.ts +3 -0
- package/dist/harness/kilocode.d.ts.map +1 -0
- package/dist/harness/kilocode.js +35 -0
- package/dist/harness/kilocode.js.map +1 -0
- package/dist/harness/opencode.d.ts +3 -0
- package/dist/harness/opencode.d.ts.map +1 -0
- package/dist/harness/opencode.js +97 -0
- package/dist/harness/opencode.js.map +1 -0
- package/dist/harness/paths.d.ts +17 -0
- package/dist/harness/paths.d.ts.map +1 -0
- package/dist/harness/paths.js +45 -0
- package/dist/harness/paths.js.map +1 -0
- package/dist/harness/profile.d.ts +209 -0
- package/dist/harness/profile.d.ts.map +1 -0
- package/dist/harness/profile.js +41 -0
- package/dist/harness/profile.js.map +1 -0
- package/dist/harness/registry.d.ts +100 -0
- package/dist/harness/registry.d.ts.map +1 -0
- package/dist/harness/registry.js +144 -0
- package/dist/harness/registry.js.map +1 -0
- package/dist/harness/snippet.d.ts +19 -0
- package/dist/harness/snippet.d.ts.map +1 -0
- package/dist/harness/snippet.js +41 -0
- package/dist/harness/snippet.js.map +1 -0
- package/dist/hash.d.ts.map +1 -1
- package/dist/hash.js.map +1 -1
- package/dist/hooks/guard-core.d.ts +14 -14
- package/dist/hooks/guard-core.d.ts.map +1 -1
- package/dist/hooks/guard-core.js +15 -37
- package/dist/hooks/guard-core.js.map +1 -1
- package/dist/hooks/shim.d.ts +95 -4
- package/dist/hooks/shim.d.ts.map +1 -1
- package/dist/hooks/shim.js +87 -2
- package/dist/hooks/shim.js.map +1 -1
- package/dist/hooks/shims/claude-code.d.ts +5 -18
- package/dist/hooks/shims/claude-code.d.ts.map +1 -1
- package/dist/hooks/shims/claude-code.js +9 -56
- package/dist/hooks/shims/claude-code.js.map +1 -1
- package/dist/hooks/shims/cline.d.ts +5 -12
- package/dist/hooks/shims/cline.d.ts.map +1 -1
- package/dist/hooks/shims/cline.js +9 -34
- package/dist/hooks/shims/cline.js.map +1 -1
- package/dist/hooks/shims/codex.d.ts +5 -18
- package/dist/hooks/shims/codex.d.ts.map +1 -1
- package/dist/hooks/shims/codex.js +9 -51
- package/dist/hooks/shims/codex.js.map +1 -1
- package/dist/hooks/shims/cursor.d.ts +5 -14
- package/dist/hooks/shims/cursor.d.ts.map +1 -1
- package/dist/hooks/shims/cursor.js +9 -42
- package/dist/hooks/shims/cursor.js.map +1 -1
- package/dist/hooks/shims/gemini.d.ts +5 -18
- package/dist/hooks/shims/gemini.d.ts.map +1 -1
- package/dist/hooks/shims/gemini.js +9 -48
- package/dist/hooks/shims/gemini.js.map +1 -1
- package/dist/hooks/shims/grok.d.ts +5 -13
- package/dist/hooks/shims/grok.d.ts.map +1 -1
- package/dist/hooks/shims/grok.js +9 -32
- package/dist/hooks/shims/grok.js.map +1 -1
- package/dist/hooks/shims/hermes.d.ts +5 -17
- package/dist/hooks/shims/hermes.d.ts.map +1 -1
- package/dist/hooks/shims/hermes.js +9 -45
- package/dist/hooks/shims/hermes.js.map +1 -1
- package/dist/index.d.ts +43 -76
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -67
- package/dist/index.js.map +1 -1
- package/dist/lang/bash.d.ts.map +1 -1
- package/dist/lang/bash.js.map +1 -1
- package/dist/lang/c.d.ts.map +1 -1
- package/dist/lang/c.js.map +1 -1
- package/dist/lang/c_sharp.d.ts.map +1 -1
- package/dist/lang/c_sharp.js.map +1 -1
- package/dist/lang/cpp.d.ts.map +1 -1
- package/dist/lang/cpp.js.map +1 -1
- package/dist/lang/go.d.ts.map +1 -1
- package/dist/lang/go.js.map +1 -1
- package/dist/lang/java.d.ts.map +1 -1
- package/dist/lang/java.js.map +1 -1
- package/dist/lang/javascript.d.ts.map +1 -1
- package/dist/lang/javascript.js.map +1 -1
- package/dist/lang/kotlin.d.ts.map +1 -1
- package/dist/lang/kotlin.js.map +1 -1
- package/dist/lang/php.d.ts.map +1 -1
- package/dist/lang/php.js.map +1 -1
- package/dist/lang/profile.d.ts.map +1 -1
- package/dist/lang/profile.js.map +1 -1
- package/dist/lang/python.d.ts.map +1 -1
- package/dist/lang/python.js.map +1 -1
- package/dist/lang/registry.d.ts.map +1 -1
- package/dist/lang/registry.js.map +1 -1
- package/dist/lang/ruby.d.ts.map +1 -1
- package/dist/lang/ruby.js.map +1 -1
- package/dist/lang/rust.d.ts.map +1 -1
- package/dist/lang/rust.js.map +1 -1
- package/dist/lang/swift.d.ts.map +1 -1
- package/dist/lang/swift.js.map +1 -1
- package/dist/lang/tsx.d.ts.map +1 -1
- package/dist/lang/tsx.js.map +1 -1
- package/dist/lang/typescript.d.ts.map +1 -1
- package/dist/lang/typescript.js.map +1 -1
- package/dist/net/policy.d.ts +18 -1
- package/dist/net/policy.d.ts.map +1 -1
- package/dist/net/policy.js +6 -4
- package/dist/net/policy.js.map +1 -1
- package/dist/ops/index.d.ts +39 -0
- package/dist/ops/index.d.ts.map +1 -0
- package/dist/ops/index.js +37 -0
- package/dist/ops/index.js.map +1 -0
- package/dist/ops/inputs.d.ts +170 -0
- package/dist/ops/inputs.d.ts.map +1 -0
- package/dist/ops/inputs.js +142 -0
- package/dist/ops/inputs.js.map +1 -0
- package/dist/ops/verbs.d.ts +136 -0
- package/dist/ops/verbs.d.ts.map +1 -0
- package/dist/ops/verbs.js +76 -0
- package/dist/ops/verbs.js.map +1 -0
- package/dist/plan/auto.d.ts +69 -0
- package/dist/plan/auto.d.ts.map +1 -0
- package/dist/plan/auto.js +70 -0
- package/dist/plan/auto.js.map +1 -0
- package/dist/plan/budget.d.ts +21 -0
- package/dist/plan/budget.d.ts.map +1 -0
- package/dist/plan/budget.js +26 -0
- package/dist/plan/budget.js.map +1 -0
- package/dist/plan/grammar.d.ts +14 -0
- package/dist/plan/grammar.d.ts.map +1 -1
- package/dist/plan/grammar.js +47 -6
- package/dist/plan/grammar.js.map +1 -1
- package/dist/plan/lexical.d.ts.map +1 -1
- package/dist/plan/lexical.js +1 -7
- package/dist/plan/lexical.js.map +1 -1
- package/dist/plan/planners.d.ts +35 -2
- package/dist/plan/planners.d.ts.map +1 -1
- package/dist/plan/planners.js +35 -2
- package/dist/plan/planners.js.map +1 -1
- package/dist/plan/structural.d.ts +19 -5
- package/dist/plan/structural.d.ts.map +1 -1
- package/dist/plan/structural.js +180 -33
- package/dist/plan/structural.js.map +1 -1
- package/dist/repomap/cache.d.ts +48 -0
- package/dist/repomap/cache.d.ts.map +1 -1
- package/dist/repomap/cache.js +112 -13
- package/dist/repomap/cache.js.map +1 -1
- package/dist/repomap/io.d.ts +24 -0
- package/dist/repomap/io.d.ts.map +1 -0
- package/dist/repomap/io.js +34 -0
- package/dist/repomap/io.js.map +1 -0
- package/dist/repomap/map.d.ts +69 -6
- package/dist/repomap/map.d.ts.map +1 -1
- package/dist/repomap/map.js +104 -22
- package/dist/repomap/map.js.map +1 -1
- package/dist/repomap/rank.d.ts +26 -2
- package/dist/repomap/rank.d.ts.map +1 -1
- package/dist/repomap/rank.js +20 -0
- package/dist/repomap/rank.js.map +1 -1
- package/dist/repomap/reader.d.ts +73 -0
- package/dist/repomap/reader.d.ts.map +1 -0
- package/dist/repomap/reader.js +30 -0
- package/dist/repomap/reader.js.map +1 -0
- package/dist/repomap/tags.d.ts.map +1 -1
- package/dist/repomap/tags.js.map +1 -1
- package/dist/retrieve.d.ts +8 -0
- package/dist/retrieve.d.ts.map +1 -1
- package/dist/retrieve.js +12 -1
- package/dist/retrieve.js.map +1 -1
- package/dist/smelter.d.ts +79 -0
- package/dist/smelter.d.ts.map +1 -0
- package/dist/smelter.js +65 -0
- package/dist/smelter.js.map +1 -0
- package/dist/stages.d.ts.map +1 -1
- package/dist/stages.js +2 -2
- package/dist/stages.js.map +1 -1
- package/dist/stats.d.ts +2 -1
- package/dist/stats.d.ts.map +1 -1
- package/dist/stats.js +2 -1
- package/dist/stats.js.map +1 -1
- package/dist/store-dir.d.ts +43 -4
- package/dist/store-dir.d.ts.map +1 -1
- package/dist/store-dir.js +48 -5
- package/dist/store-dir.js.map +1 -1
- package/dist/store.d.ts +6 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +6 -0
- package/dist/store.js.map +1 -1
- package/dist/text/json-edit.d.ts +71 -0
- package/dist/text/json-edit.d.ts.map +1 -0
- package/dist/text/json-edit.js +219 -0
- package/dist/text/json-edit.js.map +1 -0
- package/dist/types.d.ts +35 -14
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/grammars/tree-sitter-bash.wasm +0 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +11 -11
- package/dist/cli/resolve.d.ts +0 -103
- package/dist/cli/resolve.d.ts.map +0 -1
- package/dist/cli/resolve.js +0 -106
- 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,
|
|
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 {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
export
|
|
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((
|
|
131
|
-
|
|
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
|
-
/**
|
|
211
|
-
|
|
212
|
-
|
|
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(),
|
|
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
|
-
[
|
|
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
|
|
227
|
-
|
|
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
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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
|
|
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 (
|
|
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
|
-
|
|
302
|
-
const
|
|
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
|
-
|
|
156
|
+
text = editTopLevelProperty(text, 'hooks', mergedHooks, style);
|
|
306
157
|
/* v8 ignore next -- unreachable: JSON.parse accepted the same text above */
|
|
307
|
-
if (
|
|
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
|
-
|
|
162
|
+
text = editTopLevelProperty(text, 'version', shape.version, style);
|
|
324
163
|
/* v8 ignore next -- unreachable: every splice above keeps the text valid JSON */
|
|
325
|
-
if (
|
|
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.
|
|
591
|
-
*
|
|
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
|
|
235
|
+
const planBlockFile = (name, block, start, end, skipWhen) => {
|
|
643
236
|
const path = join(cwd, name);
|
|
644
237
|
if (files.has(path))
|
|
645
|
-
return; // shared
|
|
646
|
-
|
|
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
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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 (
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
836
|
-
const lines =
|
|
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
|
|
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.
|
|
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
|
-
|
|
396
|
+
await lines.release();
|
|
853
397
|
}
|
|
854
398
|
}
|
|
855
399
|
function resolveHarnessFlag(flag) {
|
|
856
|
-
const
|
|
857
|
-
if (
|
|
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
|
|
405
|
+
return profile;
|
|
862
406
|
}
|
|
863
|
-
function tierLabel(
|
|
864
|
-
return `${
|
|
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
|
|
882
|
-
io.output(` ${tierLabel(
|
|
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
|
-
`
|
|
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
|
|
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
|
|
935
|
-
const mark = detected.includes(
|
|
936
|
-
io.output(` ${mark} ${tierLabel(
|
|
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((
|
|
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
|
|
503
|
+
const chosen = [];
|
|
951
504
|
let bad;
|
|
952
505
|
for (const id of ids) {
|
|
953
|
-
const
|
|
954
|
-
if (
|
|
506
|
+
const profile = harnessById(id);
|
|
507
|
+
if (profile === undefined)
|
|
955
508
|
bad = id;
|
|
956
|
-
else if (!
|
|
957
|
-
|
|
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 =
|
|
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
|
|
1039
|
-
* resetting. Harnesses that only wire the guard
|
|
1040
|
-
* opencode, cline) persist no stats/map entries, so
|
|
1041
|
-
* those toggles read back as off; the defaults below
|
|
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
|
-
|
|
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
|
|
1072
|
-
installed[event].
|
|
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(
|
|
1078
|
-
mapOnStart ||=
|
|
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)
|