wkappbot-code 0.0.0-stage → 0.3.1

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.
@@ -0,0 +1,217 @@
1
+ // wkappbot-code -- Wisdom Kernel: the one kih plugin for OpenCode (pinned: opencode-ai 1.19.5).
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
12
+ // KNOWN GAP: no transcript_path is sent (OpenCode keeps sessions in its own store), so kih gates that
13
+ // 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
+ }
171
+
172
+ // 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.
174
+ export const WisdomKernel = async (ctx = {}, options = {}) => {
175
+ const cwd = ctx.directory || ctx.worktree || process.cwd();
176
+ const k = (options && options.kihDir) || undefined;
177
+ return {
178
+ "tool.execute.before": async (input, output) => {
179
+ let r;
180
+ 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));
184
+ },
185
+ "tool.execute.after": async (input, output) => {
186
+ try { await runHook("post", buildPostToolUsePayload(input || {}, output, cwd), { kihDir: k, timeoutMs: 30000 }); } catch {}
187
+ },
188
+ // UserPromptSubmit: arms the intent lock for the next real tool call (fail-open, never blocks the message).
189
+ "chat.message": async (input, output) => {
190
+ 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 });
195
+ } catch {}
196
+ },
197
+ event: async (arg) => {
198
+ try {
199
+ 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;
204
+ }
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
+ } catch {}
209
+ },
210
+ };
211
+ };
212
+
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 };
216
+
217
+ export default WisdomKernel;