wkappbot-code 0.3.3 → 0.3.7

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/lib/wk-hook.js ADDED
@@ -0,0 +1,281 @@
1
+ // wk-hook.js -- the helpers of the wisdom-kernel OpenCode plugin (wkappbot-code 0.3.1).
2
+ // WHY A SEPARATE MODULE: OpenCode 1.19.5 calls EVERY named export of a plugin file as a plugin, so a helper or a
3
+ // constant exported from plugin/wisdom-kernel.js made it fail with "Plugin export is not a function" and no hook
4
+ // ever fired. The plugin file now exports only the plugin function; everything here is imported by it and by the
5
+ // selftest. install copies this file beside the installed plugin.
6
+ // Spec: wkappbot skill read agent-harness-landscape-2026-howto
7
+ import { spawn } from "node:child_process";
8
+ import { appendFileSync, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { dirname, join, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ // OpenCode tool id -> Claude tool name the kih guards already know.
14
+ export const TOOL_MAP = {
15
+ bash: "Bash", edit: "Edit", write: "Write", read: "Read",
16
+ glob: "Glob", grep: "Grep", list: "LS", patch: "Edit", multiedit: "MultiEdit",
17
+ webfetch: "WebFetch", websearch: "WebSearch", task: "Agent", todowrite: "TodoWrite",
18
+ };
19
+
20
+ // pre arm budget: a hung hook is killed. A timeout is NO verdict: allowed, unless the call is in a risky class.
21
+ export const PRE_TIMEOUT_MS = Number(process.env.WK_APPBOT_PRE_TIMEOUT_MS || 60000);
22
+
23
+ // OpenCode args (camelCase) -> Claude tool_input (snake_case) for the tools the guards inspect.
24
+ export function toClaudeInput(tool, args) {
25
+ const a = args || {};
26
+ switch (tool) {
27
+ case "bash": {
28
+ const o = { command: a.command ?? "" };
29
+ if (a.description !== undefined) o.description = a.description;
30
+ if (a.timeout !== undefined) o.timeout = a.timeout;
31
+ return o;
32
+ }
33
+ case "edit":
34
+ return { file_path: a.filePath ?? a.file_path, old_string: a.oldString ?? a.old_string,
35
+ new_string: a.newString ?? a.new_string, replace_all: a.replaceAll ?? a.replace_all ?? false };
36
+ case "write":
37
+ return { file_path: a.filePath ?? a.file_path, content: a.content ?? "" };
38
+ case "read": {
39
+ const o = { file_path: a.filePath ?? a.file_path };
40
+ if (a.offset !== undefined) o.offset = a.offset;
41
+ if (a.limit !== undefined) o.limit = a.limit;
42
+ return o;
43
+ }
44
+ default: {
45
+ const o = { ...a };
46
+ if (o.filePath !== undefined && o.file_path === undefined) o.file_path = o.filePath;
47
+ return o;
48
+ }
49
+ }
50
+ }
51
+
52
+ export function claudeToolName(tool) {
53
+ return TOOL_MAP[tool] || tool;
54
+ }
55
+
56
+ // The Claude-shaped hook payload for one OpenCode tool call.
57
+ export function buildPreToolUsePayload(input, args, cwd) {
58
+ return {
59
+ hook_event_name: "PreToolUse",
60
+ session_id: input.sessionID,
61
+ cwd: (args && args.workdir) || cwd,
62
+ tool_name: claudeToolName(input.tool),
63
+ tool_input: toClaudeInput(input.tool, args),
64
+ tool_use_id: input.callID,
65
+ agent_family: "opencode",
66
+ };
67
+ }
68
+
69
+ export function buildPostToolUsePayload(input, output, cwd) {
70
+ const p = buildPreToolUsePayload(input, input.args, cwd);
71
+ p.hook_event_name = "PostToolUse";
72
+ p.tool_response = { output: output && output.output, title: output && output.title };
73
+ return p;
74
+ }
75
+
76
+ // chat.message -> UserPromptSubmit (the intent lock arm). The prompt is the text parts of the user message.
77
+ export function buildPromptPayload(input, output, cwd) {
78
+ const parts = (output && output.parts) || [];
79
+ const prompt = parts.filter((p) => p && p.type === "text" && typeof p.text === "string").map((p) => p.text).join("\n");
80
+ const sid = (input && input.sessionID) || (output && output.message && output.message.sessionID);
81
+ return { hook_event_name: "UserPromptSubmit", session_id: sid, cwd, prompt, agent_family: "opencode" };
82
+ }
83
+
84
+ // kih root: WK_KIH_DIR, else the checkout this file sits in (wkappbot-code/lib -> ../..).
85
+ export function resolveKihDir() {
86
+ if (process.env.WK_KIH_DIR && process.env.WK_KIH_DIR !== "none") return process.env.WK_KIH_DIR;
87
+ try {
88
+ const up = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
89
+ if (existsSync(join(up, ".claude", "hooks", "wkharness-run.sh"))) return up;
90
+ } catch {}
91
+ return "";
92
+ }
93
+
94
+ // Pipe JSON into "wkharness-run.sh <arm>"; resolves {code, stdout, stderr}. Never rejects.
95
+ // No hook found = {skipped}: no verdict (allowed unless the call is in a risky class).
96
+ export function runHook(arm, payload, opts = {}) {
97
+ const kih = opts.kihDir ?? resolveKihDir();
98
+ const hook = opts.hook || process.env.WK_APPBOT_HOOK || (kih && join(kih, ".claude", "hooks", "wkharness-run.sh"));
99
+ return new Promise((done) => {
100
+ let settled = false;
101
+ const finish = (r) => { if (!settled) { settled = true; if (timer) clearTimeout(timer); done(r); } };
102
+ let timer = null, out = "", err = "", child;
103
+ if (!hook || !existsSync(hook)) return finish({ code: 0, stdout: "", stderr: "", skipped: true });
104
+ let body;
105
+ try { body = JSON.stringify(payload); } catch (e) { return finish({ code: 0, stdout: "", stderr: String(e), skipped: true }); }
106
+ try {
107
+ // Windows: Git Bash runs the hook, so every path it gets (the script, CLAUDE_PROJECT_DIR, HOME) is the MSYS
108
+ // form /d/a/x; a "D:\\a\\x" path fails the harness's absolute-path tests (SDK all-OS QA 2026-11-06).
109
+ const W = process.platform === "win32";
110
+ const sp = (p) => (W && /^[A-Za-z]:[\\/]/.test(p) ? "/" + p[0].toLowerCase() + p.slice(2).replace(/\\/g, "/") : p);
111
+ child = spawn("bash", [sp(hook), arm], {
112
+ // The plugin's harness wins: wkharness-run.sh prefers WKHARNESS_KIH_DIR over CLAUDE_PROJECT_DIR, so an ambient
113
+ // WKHARNESS_KIH_DIR (a dev seat's shell profile, a CI job) sent every arm to ANOTHER tree, whose lite and ledger
114
+ // then ran or did not run (SDK QA 2026-11-06: x8 null|null|null on Linux and macOS). Both point at kihDir.
115
+ env: { ...process.env, ...(W ? { HOME: sp(homedir()) } : {}), ...(kih ? { CLAUDE_PROJECT_DIR: sp(kih), WKHARNESS_KIH_DIR: sp(kih) } : {}) },
116
+ stdio: ["pipe", "pipe", "pipe"],
117
+ });
118
+ } catch (e) {
119
+ return finish({ code: 0, stdout: "", stderr: String(e), skipped: true });
120
+ }
121
+ if (opts.timeoutMs) {
122
+ timer = setTimeout(() => {
123
+ try { child.kill("SIGKILL"); } catch {}
124
+ finish({ code: 0, stdout: out, stderr: err, timedOut: true });
125
+ }, opts.timeoutMs);
126
+ }
127
+ child.stdout.on("data", (d) => (out += d));
128
+ child.stderr.on("data", (d) => (err += d));
129
+ child.on("error", (e) => finish({ code: 0, stdout: out, stderr: err + String(e), skipped: true }));
130
+ child.on("close", (code) => finish({ code: code ?? 0, stdout: out, stderr: err }));
131
+ child.stdin.on("error", () => {});
132
+ child.stdin.end(body);
133
+ });
134
+ }
135
+
136
+ // Claude contract: exit 2 refuses; so does a stdout JSON deny/block verdict.
137
+ export function isRefusal(r) {
138
+ if (r.timedOut || r.skipped) return false;
139
+ if (r.code === 2) return true;
140
+ try {
141
+ const j = JSON.parse(r.stdout);
142
+ return (j.hookSpecificOutput && j.hookSpecificOutput.permissionDecision === "deny") || j.decision === "block";
143
+ } catch { return false; }
144
+ }
145
+
146
+ // A VERDICT is rc 0 or rc 2 from a hook that ran to the end. Anything else (pwsh missing = 127, a crash, a kill at
147
+ // the budget, no hook at all) is NO verdict.
148
+ export function hasVerdict(r) {
149
+ return !r.timedOut && !r.skipped && (r.code === 0 || r.code === 2);
150
+ }
151
+
152
+ // A stdout deny JSON names its own reason; stderr then may hold only warn lines (wkharness-run.sh wk_pre_emit).
153
+ export function refusalReason(r) {
154
+ try {
155
+ const j = JSON.parse(r.stdout);
156
+ const why = (j.hookSpecificOutput && j.hookSpecificOutput.permissionDecisionReason) || j.reason;
157
+ if (why) return why;
158
+ } catch {}
159
+ return (r.stderr || "").trim() || (r.stdout || "").trim() || "refused by the kih harness";
160
+ }
161
+
162
+ // ---- fail-closed risk classes ----------------------------------------------------------------------------
163
+ // The SAME classes tools/wkharness-lite.sh refuses by itself (wk_lite_risk there). With NO verdict from the harness,
164
+ // a call in one of these classes is REFUSED instead of allowed: a public package must never let rm -rf HOME through
165
+ // because pwsh happened to be missing. Returns the class label, or "".
166
+ const HOME_T = /^(~|\$HOME|\$\{HOME\}|\/root|\/home\/[^/]+|\/Users\/[^/]+)\/?(\*|\.\*)?$/;
167
+ export function riskClass(tool, input, cwd) {
168
+ const t = claudeToolName(tool);
169
+ const i = input || {};
170
+ if (t === "Bash") {
171
+ const c = String(i.command || "");
172
+ for (const seg of c.split(/&&|\|\||[;|\n]/)) {
173
+ const w = seg.trim().split(/\s+/).map((x) => x.replace(/^['"]|['"]$/g, ""));
174
+ const gi = w.indexOf("git");
175
+ if (gi >= 0) {
176
+ const g = w.slice(gi + 1);
177
+ const sub = g.find((x) => !x.startsWith("-") && !/^[\w.]+=/.test(x) && g[g.indexOf(x) - 1] !== "-C" && g[g.indexOf(x) - 1] !== "-c");
178
+ if (g.includes("--no-verify") || (sub === "commit" && g.some((x) => /^-[A-Za-z]*n[A-Za-z]*$/.test(x)))) return "git --no-verify";
179
+ if (sub === "push" && g.some((x) => /^--force(-with-lease)?(=.*)?$|^--force-if-includes$/.test(x) || /^-[A-Za-z]*f[A-Za-z]*$/.test(x) || /^\+\S/.test(x))) return "git push --force";
180
+ if (g.some((x) => /^core\.hookspath(=.*)?$/i.test(x)) && (sub === "config" || g.includes("-c")) && !g.some((x) => /^--(get|get-all|get-regexp|list|show-origin)$/.test(x))) return "git core.hooksPath change";
181
+ }
182
+ const ri = w.indexOf("rm");
183
+ if (ri >= 0) {
184
+ const a = w.slice(ri + 1);
185
+ if (a.some((x) => /^-[A-Za-z]*[rR][A-Za-z]*$|^--recursive$/.test(x))) {
186
+ const tg = a.filter((x) => !x.startsWith("-"));
187
+ const proj = cwd ? String(cwd).replace(/\/+$/, "") : "";
188
+ if (tg.some((x) => HOME_T.test(x) || x === "/" || x === "/*")) return "rm -r of HOME or /";
189
+ if (tg.some((x) => [".", "./", "./*", "*", "..", "../"].includes(x) || (proj && x.replace(/\/+$/, "") === proj))) return "rm -r of the project source";
190
+ if (tg.some((x) => /(^|\/)\.git(\/|$)/.test(x))) return "write under .git";
191
+ }
192
+ }
193
+ if (/(^|[\s/'"=>])\.git(\/|\s|$)/.test(seg) && (/>/.test(seg) || /^(rm|mv|cp|tee|truncate|dd|ln|chmod|sed)\b/.test(seg.trim()))) return "write under .git";
194
+ }
195
+ return "";
196
+ }
197
+ if (["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(t)) {
198
+ const f = String(i.file_path || i.notebook_path || "");
199
+ if (/(^|[\\/])\.git[\\/]/.test(f)) return "write under .git";
200
+ }
201
+ return "";
202
+ }
203
+
204
+ // ---- read-only fast path ---------------------------------------------------------------------------------
205
+ // These calls never start pwsh (5.3 s measured for echo hello): read tools, and ONE plain read-only shell command
206
+ // with no chain, pipe, redirect or substitution. A path that looks like a secret is never fast (the full harness
207
+ // keeps its say on it).
208
+ const SECRET_RE = /(^|[\s/'"=])(\.ssh|\.aws|\.gnupg|\.netrc|\.env(\.[\w-]+)?|id_rsa\w*|id_ed25519\w*|credentials?(\.\w+)?|\.npmrc|\.pypirc|auth\.json|[\w.-]*(token|secret|passw)[\w.-]*)(?=$|[\s/'"])/i;
209
+ const RO_CMD = /^\s*(ls|cat|grep|rg|head|tail|wc|pwd|echo|which|stat|git\s+(status|log|diff|show|branch|rev-parse))(\s|$)/;
210
+ export function isFastRead(tool, input) {
211
+ const t = claudeToolName(tool);
212
+ const i = input || {};
213
+ if (t === "Read" || t === "Glob" || t === "Grep" || t === "LS") return !SECRET_RE.test(" " + String(i.file_path || i.path || "") + " " + String(i.pattern || ""));
214
+ if (t !== "Bash") return false;
215
+ const c = String(i.command || "");
216
+ if (!c.trim() || c.length > 400 || /[;&|<>`$\n\r\\(){}]/.test(c)) return false;
217
+ if (!RO_CMD.test(c) || SECRET_RE.test(c)) return false;
218
+ if (/^\s*git\b/.test(c) && /\s(--output\S*|--ext-diff|--exec\S*|-o)(\s|$)/.test(c)) return false;
219
+ return true;
220
+ }
221
+
222
+ // PER-CALL INJECTION (owner 2026-11-06: "so we must force our settings onto every model call OpenCode makes").
223
+ // experimental.chat.system.transform appends RULES to the system array of EVERY model request, so a model that never
224
+ // saw kih (Qwen, a fresh provider) still gets the reflex body. Short, fixed, no secrets. RULES_MARK makes the append
225
+ // idempotent when OpenCode hands the same array back. Spec: agent-harness-landscape-2026 step PER-CALL INJECTION.
226
+ export const RULES_MARK = "[wisdom-kernel rules]";
227
+ export const RULES = [
228
+ RULES_MARK + " You run under the Wisdom Kernel harness (wkappbot-code). On every turn:",
229
+ "1. One command per shell call; never chain a write with anything else.",
230
+ "2. A refusal names a skill: run wkskill read <id> (or wkappbot skill read <id>) and apply its remedy; never route around it.",
231
+ "3. Never use git --no-verify or git push --force, never change core.hooksPath, never rm -r a home directory, / or the project source.",
232
+ "4. Never edit or delete files under .git.",
233
+ "5. After a new user prompt, run wkskill search <three or more words> before the first write; read-only tools stay open.",
234
+ ].join("\n");
235
+
236
+ // The top step lines of the on-load skill, fetched ONCE per process (one OpenCode server = its sessions) in the
237
+ // background; the transform waits at most ONLOAD_WAIT_MS for it and otherwise sends the fixed rules alone.
238
+ const ONLOAD_WAIT_MS = Number(process.env.WK_APPBOT_ONLOAD_WAIT_MS || 1500);
239
+ let _onload = null;
240
+ export function onLoadLines(kihDir) {
241
+ if (_onload) return _onload;
242
+ _onload = new Promise((res) => {
243
+ const k = kihDir || resolveKihDir();
244
+ const cands = [process.env.WK_APPBOT_SKILL_CMD, k && join(k, "bin", "wkskill"), "wkskill"].filter(Boolean);
245
+ let out = "", done = false;
246
+ const finish = () => {
247
+ if (done) return; done = true;
248
+ // no secrets reach the model: a secret-file name (SECRET_RE), a key=value or key: value pair, or a URL drops the line
249
+ const lines = out.split("\n").map((l) => l.trim()).filter((l) => /^\d+\.\s/.test(l) && !SECRET_RE.test(l) &&
250
+ !/(token|secret|passw\w*|api[_-]?key|auth)\s*[=:]/i.test(l) && !/https?:\/\//.test(l));
251
+ res(lines.slice(0, 3).map((l) => l.slice(0, 240)));
252
+ };
253
+ try {
254
+ const c = spawn(cands.find((p) => p === "wkskill" || existsSync(p)), ["read", "on-load"], { stdio: ["ignore", "pipe", "ignore"] });
255
+ c.stdout.on("data", (d) => { out += d; if (out.length > 65536) c.kill("SIGKILL"); });
256
+ c.on("error", finish); c.on("close", finish);
257
+ setTimeout(() => { try { c.kill("SIGKILL"); } catch {} finish(); }, 8000).unref();
258
+ } catch { finish(); }
259
+ });
260
+ return _onload;
261
+ }
262
+ export async function rulesBlock(kihDir) {
263
+ let top = [];
264
+ try { top = await Promise.race([onLoadLines(kihDir), new Promise((r) => setTimeout(() => r([]), ONLOAD_WAIT_MS))]); } catch {}
265
+ return top.length ? RULES + "\nFrom the on-load skill:\n" + top.join("\n") : RULES;
266
+ }
267
+ export function injectRules(system, block) {
268
+ if (!Array.isArray(system) || system.some((s) => String(s).includes(RULES_MARK))) return false;
269
+ system.push(block);
270
+ return true;
271
+ }
272
+
273
+ // One line per model-call hook into ~/.wkappbot-code/plugin.log (chat.params is logged, never changed). Capped at 1 MB.
274
+ export function hookLog(hook, fields) {
275
+ try {
276
+ const f = process.env.WK_APPBOT_LOG || join(homedir(), ".wkappbot-code", "plugin.log");
277
+ mkdirSync(dirname(f), { recursive: true });
278
+ try { if (statSync(f).size > 1048576) writeFileSync(f, ""); } catch {}
279
+ appendFileSync(f, new Date().toISOString() + " " + hook + " " + JSON.stringify(fields || {}) + "\n");
280
+ } catch {}
281
+ }
package/opencode.json CHANGED
@@ -2,30 +2,5 @@
2
2
  "$schema": "https://opencode.ai/config.json",
3
3
  "plugin": [
4
4
  ["__WK_PLUGIN_URL__", { "kihDir": "__WK_KIH_DIR__" }]
5
- ],
6
- "permission": {
7
- "edit": "ask",
8
- "webfetch": "ask",
9
- "bash": {
10
- "*": "ask",
11
- "ls*": "allow",
12
- "pwd": "allow",
13
- "git status*": "allow",
14
- "git diff*": "allow",
15
- "git log*": "allow"
16
- }
17
- },
18
- "provider": {
19
- "qwen-inhouse": {
20
- "npm": "@ai-sdk/openai-compatible",
21
- "name": "In-house Qwen (OpenAI-compatible)",
22
- "options": {
23
- "baseURL": "{env:QWEN_BASE_URL}",
24
- "apiKey": "{env:QWEN_API_KEY}"
25
- },
26
- "models": {
27
- "qwen3-coder": { "name": "Qwen3 Coder (in-house)" }
28
- }
29
- }
30
- }
5
+ ]
31
6
  }
package/package.json CHANGED
@@ -1,16 +1,20 @@
1
1
  {
2
2
  "name": "wkappbot-code",
3
- "version": "0.3.3",
3
+ "version": "0.3.7",
4
4
  "description": "wkappbot-code -- Wisdom Kernel: the pinned OpenCode bridge plus the reflex harness plugin, the wkappbot core, the web harness and the latest public skills in one install",
5
5
  "type": "module",
6
+ "scripts": {
7
+ "postinstall": "node bin/postinstall.js"
8
+ },
6
9
  "bin": {
7
10
  "wkappbot-code": "bin/wkappbot-code.js",
8
11
  "wk-appbot-code": "bin/wkappbot-code.js"
9
12
  },
10
13
  "files": [
11
- "bin/",
12
- "lib/",
13
- "plugin/",
14
+ "bin/*.js",
15
+ "lib/*.js",
16
+ "lib/*.sh",
17
+ "plugin/*.js",
14
18
  "opencode.json",
15
19
  "install.sh",
16
20
  "README.md"
@@ -1,217 +1,95 @@
1
1
  // wkappbot-code -- Wisdom Kernel: the one kih plugin for OpenCode (pinned: opencode-ai 1.19.5).
2
2
  // OpenCode is the BRIDGE (model <-> tools); kih is the HARNESS riding on it. This file only maps
3
- // OpenCode plugin events onto the existing kih hook launcher, .claude/hooks/wkharness-run.sh:
4
- // tool.execute.before -> "pre" (exit 2 = refuse: throw with the hook stderr as the reason)
5
- // tool.execute.after -> "post" (fail-open, never changes the tool result)
6
- // event session.idle -> "stop-archive" (archive only, fail-open, no verdict)
7
- // Fail direction matches Claude Code: only a hook VERDICT refuses; a plugin error, a missing hook or a
8
- // hung hook (killed at PRE_TIMEOUT_MS) lets the call through.
9
- // API verified 2026-11-05 from @opencode-ai/plugin 1.19.5 dist/index.d.ts (interface Hooks) and
10
- // @opencode-ai/sdk 1.19.5 dist/gen/types.gen.d.ts:413 (EventSessionIdle). Spec:
11
- // wkappbot skill read agent-harness-landscape-2026-howto
3
+ // OpenCode plugin events onto the kih hook launcher, .claude/hooks/wkharness-run.sh:
4
+ // tool.execute.before -> "pre" (exit 2 = refuse: throw with the hook's own message as the reason)
5
+ // tool.execute.after -> "post" (fail-open, never changes the tool result)
6
+ // chat.message -> "prompt" (UserPromptSubmit: the intent lock owes a skill search for the prompt)
7
+ // event session.created -> "session" (SessionStart)
8
+ // event session.idle -> "stop-archive" (archive only, fail-open, no verdict)
9
+ // experimental.chat.system.transform -> the reflex RULES appended to EVERY model request (lib/wk-hook.js injectRules)
10
+ // chat.params -> logged only (~/.wkappbot-code/plugin.log); the user's model settings are never changed
11
+ // FAST PATH: a read tool, or one plain read-only shell command (ls, cat, grep, git status/log/diff ...), runs no hook
12
+ // at all, so it never starts pwsh (measured 5.3 s for echo hello). lib/wk-hook.js isFastRead.
13
+ // FAIL DIRECTION: a hook VERDICT (rc 0 allow, rc 2 refuse) decides. NO verdict (pwsh missing = 127, a crash, a hook
14
+ // killed at PRE_TIMEOUT_MS, no harness found) allows the call -- EXCEPT a call in a risky class (git --no-verify,
15
+ // git push --force, a core.hooksPath change, rm -r of HOME, / or the project, a write under .git), which is REFUSED
16
+ // (fail-closed). lib/wk-hook.js riskClass.
17
+ // EXPORTS: OpenCode 1.19.5 calls EVERY named export of this file as a plugin ("Plugin export is not a function"
18
+ // otherwise), so this file exports ONLY the plugin function, as WisdomKernel and as default. Every helper lives in
19
+ // lib/wk-hook.js (installed beside this file as wk-hook.js).
20
+ // API verified 2026-11-06 from @opencode-ai/plugin 1.19.5 dist/index.d.ts (interface Hooks: chat.message, event,
21
+ // tool.execute.before/after). Spec: wkappbot skill read agent-harness-landscape-2026-howto
12
22
  // KNOWN GAP: no transcript_path is sent (OpenCode keeps sessions in its own store), so kih gates that
13
23
  // resolve the model or read the transcript see an unknown model and fall back to their defaults.
14
- import { spawn } from "node:child_process";
15
- import { existsSync } from "node:fs";
16
- import { dirname, join, resolve } from "node:path";
17
- import { fileURLToPath } from "node:url";
18
-
19
- // OpenCode tool id -> Claude tool name the kih guards already know.
20
- const TOOL_MAP = {
21
- bash: "Bash", edit: "Edit", write: "Write", read: "Read",
22
- glob: "Glob", grep: "Grep", list: "LS", patch: "Edit", multiedit: "MultiEdit",
23
- webfetch: "WebFetch", websearch: "WebSearch", task: "Agent", todowrite: "TodoWrite",
24
- };
25
-
26
- // pre arm budget: a hung hook is killed and the call fails OPEN, like a Claude hook timeout.
27
- const PRE_TIMEOUT_MS = Number(process.env.WK_APPBOT_PRE_TIMEOUT_MS || 60000);
28
-
29
- // OpenCode args (camelCase) -> Claude tool_input (snake_case) for the tools the guards inspect.
30
- function toClaudeInput(tool, args) {
31
- const a = args || {};
32
- switch (tool) {
33
- case "bash": {
34
- const o = { command: a.command ?? "" };
35
- if (a.description !== undefined) o.description = a.description;
36
- if (a.timeout !== undefined) o.timeout = a.timeout;
37
- return o;
38
- }
39
- case "edit":
40
- return { file_path: a.filePath ?? a.file_path, old_string: a.oldString ?? a.old_string,
41
- new_string: a.newString ?? a.new_string, replace_all: a.replaceAll ?? a.replace_all ?? false };
42
- case "write":
43
- return { file_path: a.filePath ?? a.file_path, content: a.content ?? "" };
44
- case "read": {
45
- const o = { file_path: a.filePath ?? a.file_path };
46
- if (a.offset !== undefined) o.offset = a.offset;
47
- if (a.limit !== undefined) o.limit = a.limit;
48
- return o;
49
- }
50
- default: {
51
- const o = { ...a };
52
- if (o.filePath !== undefined && o.file_path === undefined) o.file_path = o.filePath;
53
- return o;
54
- }
55
- }
56
- }
57
-
58
- function claudeToolName(tool) {
59
- return TOOL_MAP[tool] || tool;
60
- }
61
-
62
- // The Claude-shaped hook payload for one OpenCode tool call.
63
- function buildPreToolUsePayload(input, args, cwd) {
64
- return {
65
- hook_event_name: "PreToolUse",
66
- session_id: input.sessionID,
67
- cwd: (args && args.workdir) || cwd,
68
- tool_name: claudeToolName(input.tool),
69
- tool_input: toClaudeInput(input.tool, args),
70
- tool_use_id: input.callID,
71
- agent_family: "opencode",
72
- };
73
- }
74
-
75
- function buildPostToolUsePayload(input, output, cwd) {
76
- const p = buildPreToolUsePayload(input, input.args, cwd);
77
- p.hook_event_name = "PostToolUse";
78
- p.tool_response = { output: output && output.output, title: output && output.title };
79
- return p;
80
- }
81
-
82
- // kih root: WK_KIH_DIR, else the checkout this file sits in (wkappbot-code/plugin -> ../..).
83
- function resolveKihDir() {
84
- if (process.env.WK_KIH_DIR) return process.env.WK_KIH_DIR;
85
- try {
86
- const up = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
87
- if (existsSync(join(up, ".claude", "hooks", "wkharness-run.sh"))) return up;
88
- } catch {}
89
- return "";
90
- }
91
-
92
- // Pipe JSON into "<hook> <arm>" (wkharness-run.sh, or wkharness-lite.sh without PowerShell); resolves {code, stdout, stderr}. Never rejects.
93
- // No hook found = fail-open (code 0, skipped) so a missing kih never bricks OpenCode.
94
- // PowerShell on PATH? The full harness (wkharness-run.sh) runs its guards in PowerShell; without one it exits 127,
95
- // which used to read as "no verdict" and let EVERY call through (QA 2026-11-06, fresh Linux HOME).
96
- function onPath(names) {
97
- const dirs = String(process.env.PATH || "").split(process.platform === "win32" ? ";" : ":");
98
- for (const d of dirs) for (const n of names) { try { if (d && existsSync(join(d, n))) return true; } catch {} }
99
- return false;
100
- }
101
- let _ps = null;
102
- function hasPowerShell() {
103
- if (process.env.WK_APPBOT_FORCE_LITE === "1") return false;
104
- if (_ps === null) _ps = onPath(["pwsh", "pwsh.exe", "powershell.exe", "powershell"]);
105
- return _ps;
106
- }
107
-
108
- // Which hook script serves an arm: the full harness when PowerShell is there, else the bash lite harness
109
- // (tools/wkharness-lite.sh: study lock, intent lock, stop lint). The prompt and session arms exist only in lite.
110
- function pickHook(arm, kih) {
111
- if (!kih) return "";
112
- const full = join(kih, ".claude", "hooks", "wkharness-run.sh"), lite = join(kih, "tools", "wkharness-lite.sh");
113
- const liteArm = arm === "prompt" || arm === "session";
114
- if (!liteArm && hasPowerShell() && existsSync(full)) return full;
115
- if (existsSync(lite)) return lite;
116
- return liteArm ? "" : full;
117
- }
118
-
119
- function runHook(arm, payload, opts = {}) {
120
- const kih = opts.kihDir ?? resolveKihDir();
121
- const hook = opts.hook || process.env.WK_APPBOT_HOOK || pickHook(arm, kih);
122
- return new Promise((done) => {
123
- let settled = false;
124
- const finish = (r) => { if (!settled) { settled = true; if (timer) clearTimeout(timer); done(r); } };
125
- let timer = null, out = "", err = "", child;
126
- if (!hook || !existsSync(hook)) return finish({ code: 0, stdout: "", stderr: "", skipped: true });
127
- let body;
128
- try { body = JSON.stringify(payload); } catch (e) { return finish({ code: 0, stdout: "", stderr: String(e), skipped: true }); }
129
- try {
130
- child = spawn("bash", [hook, arm], {
131
- env: { ...process.env, ...(kih ? { CLAUDE_PROJECT_DIR: kih } : {}) },
132
- stdio: ["pipe", "pipe", "pipe"],
133
- });
134
- } catch (e) {
135
- return finish({ code: 0, stdout: "", stderr: String(e), skipped: true });
136
- }
137
- if (opts.timeoutMs) {
138
- timer = setTimeout(() => {
139
- try { child.kill("SIGKILL"); } catch {}
140
- finish({ code: 0, stdout: out, stderr: err, timedOut: true });
141
- }, opts.timeoutMs);
142
- }
143
- child.stdout.on("data", (d) => (out += d));
144
- child.stderr.on("data", (d) => (err += d));
145
- child.on("error", (e) => finish({ code: 0, stdout: out, stderr: err + String(e), skipped: true }));
146
- child.on("close", (code) => finish({ code: code ?? 0, stdout: out, stderr: err }));
147
- child.stdin.on("error", () => {});
148
- child.stdin.end(body);
149
- });
150
- }
151
-
152
- // Claude contract: exit 2 refuses; so does a stdout JSON deny/block verdict.
153
- function isRefusal(r) {
154
- if (r.timedOut || r.skipped) return false;
155
- if (r.code === 2) return true;
156
- try {
157
- const j = JSON.parse(r.stdout);
158
- return (j.hookSpecificOutput && j.hookSpecificOutput.permissionDecision === "deny") || j.decision === "block";
159
- } catch { return false; }
160
- }
161
-
162
- // A stdout deny JSON names its own reason; stderr then may hold only warn lines (wkharness-run.sh wk_pre_emit).
163
- function refusalReason(r) {
164
- try {
165
- const j = JSON.parse(r.stdout);
166
- const why = (j.hookSpecificOutput && j.hookSpecificOutput.permissionDecisionReason) || j.reason;
167
- if (why) return why;
168
- } catch {}
169
- return (r.stderr || "").trim() || (r.stdout || "").trim() || "refused by the kih harness";
170
- }
24
+ let H;
25
+ try { H = await import("./wk-hook.js"); } catch { H = await import("../lib/wk-hook.js"); }
171
26
 
172
27
  // options = the second slot of the opencode.json plugin entry: ["file://.../wisdom-kernel.js", { "kihDir": "..." }].
173
- // install.sh fills kihDir, because the installed copy no longer sits inside the kih checkout.
28
+ // install fills kihDir, because the installed copy no longer sits inside the kih checkout.
174
29
  export const WisdomKernel = async (ctx = {}, options = {}) => {
175
30
  const cwd = ctx.directory || ctx.worktree || process.cwd();
176
31
  const k = (options && options.kihDir) || undefined;
32
+ const fast = new Set();
177
33
  return {
178
34
  "tool.execute.before": async (input, output) => {
179
- let r;
35
+ const inp = input || {};
36
+ const args = output && output.args;
37
+ let p, r;
180
38
  try {
181
- r = await runHook("pre", buildPreToolUsePayload(input || {}, output && output.args, cwd), { kihDir: k, timeoutMs: PRE_TIMEOUT_MS });
182
- } catch { return; } // a plugin bug never refuses a call
183
- if (isRefusal(r)) throw new Error("[wisdom-kernel] " + refusalReason(r));
39
+ p = H.buildPreToolUsePayload(inp, args, cwd);
40
+ if (H.isFastRead(inp.tool, p.tool_input)) { if (inp.callID) fast.add(inp.callID); return; }
41
+ r = await H.runHook("pre", p, { kihDir: k, timeoutMs: H.PRE_TIMEOUT_MS });
42
+ } catch (e) { r = { code: -1, stdout: "", stderr: String(e), skipped: true }; }
43
+ if (H.isRefusal(r)) throw new Error("[wisdom-kernel] " + H.refusalReason(r));
44
+ if (!H.hasVerdict(r)) {
45
+ let cls = "";
46
+ try { cls = H.riskClass(inp.tool, p && p.tool_input, p && p.cwd); } catch { cls = "unclassifiable call"; }
47
+ if (cls) {
48
+ const why = r.timedOut ? "the hook was killed at " + H.PRE_TIMEOUT_MS + " ms" : r.skipped ? "no kih hook ran" : "the hook exited " + r.code;
49
+ const tail = String(r.stderr || "").trim().split("\n").slice(-2).join(" | ");
50
+ throw new Error("[wisdom-kernel] fail-closed: " + cls + " is refused because the harness gave no verdict (" + why + ")" +
51
+ (tail ? ": " + tail : "") + ". Fix the harness (wkappbot-code doctor) and retry.");
52
+ }
53
+ }
184
54
  },
185
55
  "tool.execute.after": async (input, output) => {
186
- try { await runHook("post", buildPostToolUsePayload(input || {}, output, cwd), { kihDir: k, timeoutMs: 30000 }); } catch {}
56
+ try {
57
+ const id = input && input.callID;
58
+ if (id && fast.has(id)) { fast.delete(id); return; }
59
+ await H.runHook("post", H.buildPostToolUsePayload(input || {}, output, cwd), { kihDir: k, timeoutMs: 30000 });
60
+ } catch {}
187
61
  },
188
- // UserPromptSubmit: arms the intent lock for the next real tool call (fail-open, never blocks the message).
189
62
  "chat.message": async (input, output) => {
63
+ try { await H.runHook("prompt", H.buildPromptPayload(input, output, cwd), { kihDir: k, timeoutMs: 30000 }); } catch {}
64
+ },
65
+ // PER-CALL INJECTION: every model request carries the reflex rules (lib/wk-hook.js RULES), whatever the model.
66
+ // Experimental in OpenCode 1.19.5, so the package pins that version and the e2e asserts the rules reach the model.
67
+ "experimental.chat.system.transform": async (input, output) => {
190
68
  try {
191
- const parts = (output && output.parts) || [];
192
- const prompt = parts.filter((p) => p && p.type === "text" && !p.synthetic).map((p) => p.text || "").join("\n");
193
- if (!prompt) return;
194
- await runHook("prompt", { hook_event_name: "UserPromptSubmit", session_id: input && input.sessionID, cwd, prompt, agent_family: "opencode" }, { kihDir: k, timeoutMs: 30000 });
69
+ const added = H.injectRules(output && output.system, await H.rulesBlock(k));
70
+ H.hookLog("system.transform", { sid: input && input.sessionID, model: input && input.model && input.model.id, added });
195
71
  } catch {}
196
72
  },
73
+ // chat.params: the model settings stay exactly as the user configured them; the hook only logs that it ran.
74
+ "chat.params": async (input) => {
75
+ try { H.hookLog("chat.params", { sid: input && input.sessionID, agent: input && input.agent, model: input && input.model && input.model.id }); } catch {}
76
+ },
197
77
  event: async (arg) => {
198
78
  try {
199
79
  const event = arg && arg.event;
200
- if (event && event.type === "session.created") {
201
- const info = (event.properties && event.properties.info) || {};
202
- await runHook("session", { hook_event_name: "SessionStart", session_id: info.id, cwd, source: "startup", agent_family: "opencode" }, { kihDir: k, timeoutMs: 30000 });
203
- return;
80
+ if (!event) return;
81
+ if (event.type === "session.created") {
82
+ const sid = event.properties && event.properties.info && event.properties.info.id;
83
+ await H.runHook("session", { hook_event_name: "SessionStart", session_id: sid, cwd, source: "startup", agent_family: "opencode" }, { kihDir: k, timeoutMs: 30000 });
84
+ } else if (event.type === "session.idle") {
85
+ const sid = event.properties && event.properties.sessionID;
86
+ await H.runHook("stop-archive", { hook_event_name: "Stop", session_id: sid, cwd, agent_family: "opencode" }, { kihDir: k, timeoutMs: 30000 });
204
87
  }
205
- if (!event || event.type !== "session.idle") return;
206
- const sid = event.properties && event.properties.sessionID;
207
- await runHook("stop-archive", { hook_event_name: "Stop", session_id: sid, cwd, agent_family: "opencode" }, { kihDir: k, timeoutMs: 30000 });
208
88
  } catch {}
209
89
  },
210
90
  };
211
91
  };
212
92
 
213
- // Helpers for the selftest ride on the plugin function, never as module exports (OpenCode calls every export).
214
- WisdomKernel.internals = { TOOL_MAP, PRE_TIMEOUT_MS, toClaudeInput, claudeToolName, buildPreToolUsePayload,
215
- buildPostToolUsePayload, resolveKihDir, runHook, isRefusal, refusalReason, hasPowerShell, pickHook };
93
+ // Helpers live in ./wk-hook.js (lib/wk-hook.js in the package); OpenCode calls every export, so only the plugin is exported.
216
94
 
217
95
  export default WisdomKernel;