wkappbot-code 0.3.3 → 0.3.5

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 CHANGED
@@ -1,74 +1,71 @@
1
1
  # wkappbot-code -- Wisdom Kernel (WK)
2
2
 
3
- A prototype OpenCode distribution: the upstream OpenCode release, pinned to `opencode-ai@1.19.5`, plus one kih plugin. wkappbot-code contains no coding engine of its own.
3
+ An OpenCode distribution: the upstream OpenCode release, pinned to `opencode-ai@1.19.5`, plus one harness plugin. wkappbot-code contains no coding engine of its own.
4
4
 
5
5
  ## Bridge, not harness
6
6
 
7
- OpenCode is the **bridge**. It connects any model (Claude, GPT, Qwen, Kimi, GLM and local models) to files, shell, LSP and its clients. kih is the **harness**: the discipline layer that rides on the bridge, made of refusal gates, skill learning, memory and pace. In wkappbot-code the kernel loads bridges the way an OS kernel loads drivers. OpenCode, Claude Code and Codex are the drivers, and each model is a device behind them.
7
+ OpenCode is the **bridge**. It connects any model (Claude, GPT, Qwen, Kimi, GLM and local models) to files, shell, LSP and its clients. The harness is the discipline layer that rides on the bridge: refusal gates, skill learning, memory and pace. In wkappbot-code the kernel loads bridges the way an OS kernel loads drivers. OpenCode, Claude Code and Codex are the drivers, and each model is a device behind them.
8
8
 
9
- The plugin `plugin/wisdom-kernel.js` maps OpenCode plugin events onto the existing kih hook launcher `.claude/hooks/wkharness-run.sh`:
9
+ The plugin `plugin/wisdom-kernel.js` maps OpenCode plugin events onto the harness hook launcher `.claude/hooks/wkharness-run.sh`. The file exports only the plugin function (OpenCode calls every named export as a plugin); its helpers live in `lib/wk-hook.js`, installed beside it.
10
10
 
11
- | OpenCode event | kih arm | effect |
11
+ | OpenCode event | harness arm | effect |
12
12
  |---|---|---|
13
- | `tool.execute.before` | `pre` | Claude-shaped PreToolUse JSON (`tool_name`, `tool_input`, `session_id`, `cwd`). Exit 2 throws, which refuses the tool call with the hook's stderr as the reason. |
13
+ | `tool.execute.before` | `pre` | Claude-shaped PreToolUse JSON (`tool_name`, `tool_input`, `session_id`, `cwd`). Exit 2 throws, which refuses the tool call with the guard's own message as the reason. |
14
14
  | `tool.execute.after` | `post` | fail-open |
15
+ | `chat.message` | `prompt` | UserPromptSubmit: the intent lock owes a skill search (`wkskill search ...`) before real work |
16
+ | `event` `session.created` | `session` | SessionStart |
15
17
  | `event` `session.idle` | `stop-archive` | archive only, fail-open |
18
+ | `experimental.chat.system.transform` | (none) | appends the reflex rules to the system prompt of EVERY model request, whatever the model (pinned to OpenCode 1.19.5; the e2e asserts every request carried them) |
19
+ | `chat.params` | (none) | logged to `~/.wkappbot-code/plugin.log` only; your model settings are never changed |
16
20
 
17
21
  Tool names are mapped as `bash`/`edit`/`write`/`read` -> `Bash`/`Edit`/`Write`/`Read`, with camelCase args (`filePath`, `oldString`) mapped to snake_case (`file_path`, `old_string`).
18
22
 
19
- ## Install
23
+ **Fast path.** A read tool (`read`, `glob`, `grep`, `list`) or one plain read-only shell command (`ls`, `cat`, `grep`, `head`, `echo`, `git status/log/diff/show` ... with no chain, pipe, redirect or substitution, and no secret-looking path) runs no hook at all, so it never waits for PowerShell.
20
24
 
21
- ```bash
22
- bash wkappbot-code/install.sh # npm i -g opencode-ai@1.19.5, copy plugin + config (idempotent)
23
- bash wkappbot-code/install.sh --no-npm # wire the plugin only
24
- bash wkappbot-code/selftest.sh # RESULT N/N; --synthetic-only where no kih hook can run
25
- ```
25
+ **Fail direction.** A hook verdict decides: exit 0 allows, exit 2 refuses. When no verdict comes back (no harness found, a crash, a hook killed at its time budget) the call is allowed, **except** the risky classes, which are refused (fail-closed): `git --no-verify` (and `git commit -n`), `git push --force` (`-f`, `--force-with-lease`, a `+ref`), any change of `core.hooksPath`, `rm -r` of HOME, `/` or the project, and any write under `.git`. The sh lite harness refuses the same classes on its own.
26
26
 
27
- ### One command installs everything
27
+ **Which harness gives the verdict.** A user install (the web harness, marked by `~/.wk-harness/.wk-user-install`) always uses the sh lite harness `tools/wkharness-lite.sh`: the risk floor above, the intent lock and the study lock, about 10 ms per call. The full PowerShell harness runs only on a dev seat (a harness checkout, or `install --dev-seat`) where `pwsh` is on PATH; there the lite guard still runs first for every OpenCode call.
28
28
 
29
- Get the CLI by one of three roads, then run `wkappbot-code install` once:
29
+ ## Install
30
30
 
31
31
  ```bash
32
- npm i -g https://kiexpert.github.io/wkappbot-kih/wkappbot-code/wkappbot-code.tgz # web: the scrub-gated tgz the SDK workflow ships daily (SHA256SUMS + version.json beside it)
33
- npm i -g wkappbot-code # registry (published only when the SDK has NPM_TOKEN)
34
- npm pack ./wkappbot-code && npm i -g ./wkappbot-code-0.3.3.tgz # from a checkout
35
-
36
- wkappbot-code install # every part below, idempotent
37
- wkappbot-code doctor # one PASS/WARN/FAIL/SKIP row per part
32
+ npm i -g wkappbot-code # or the web tgz below
33
+ wkappbot-code install # every part below, idempotent
34
+ wkappbot-code doctor # one PASS/WARN/FAIL/SKIP row per part
35
+ wkappbot-code uninstall # undo it (see below)
38
36
  wkappbot-code --version
39
- # the old command name wk-appbot-code (0.2.x) still works: it is a second bin entry for the same file
40
37
  ```
41
38
 
39
+ Other roads to the CLI: `npm i -g https://kiexpert.github.io/wkappbot-kih/wkappbot-code/wkappbot-code.tgz` (the scrub-gated tgz with `SHA256SUMS` and `version.json` beside it), or `npm pack ./wkappbot-code && npm i -g ./wkappbot-code-0.3.5.tgz` from a checkout (`bash wkappbot-code/install.sh` wraps the same `install`). The old command name `wk-appbot-code` (0.2.x) still works.
42
40
 
43
41
  | part | what `install` lays down | skip with |
44
42
  |---|---|---|
45
43
  | opencode | pinned `opencode-ai@1.19.5` (`npm -g`) | `--no-npm` |
46
- | plugin | `plugin/wisdom-kernel.js` plus the `opencode.json` plugin entry | -- |
47
- | bin | the `wkappbot` core for this platform from the SDK latest release asset `wkappbot-<platform>`, accepted only when it matches its `SHA256SUMS` line (`lib/wk-bin-acquire.sh`, byte-identical to kih `tools/wk-bin-acquire.sh`); placed at `~/.local/share/wkappbot/wkappbot` and linked as `wkappbot` beside `wkappbot-code` | `--no-bin` |
48
- | harness | when no kih checkout is found, the scrubbed web harness to `~/.wk-harness` through its own published `install.sh` (sha256-checked); `--web-harness` forces it | `--no-harness` |
44
+ | plugin | `plugin/wisdom-kernel.js` + `lib/wk-hook.js` plus the `opencode.json` plugin entry | -- |
45
+ | bin | the `wkappbot` core for this platform from the SDK latest release asset `wkappbot-<platform>`, accepted only when it matches its `SHA256SUMS` line (`lib/wk-bin-acquire.sh`); placed at `~/.local/share/wkappbot/wkappbot` and linked as `wkappbot` beside `wkappbot-code` | `--no-bin` |
46
+ | harness | when no harness checkout is found, the scrubbed web harness to `~/.wk-harness` through its own published `install.sh` (sha256-checked); `--web-harness` forces it | `--no-harness` |
49
47
  | skills | the latest public skills from the same published tarball (sha256-checked) into the core's HQ `<core dir>/wkappbot.hq/skills`, so `wkappbot skill read on-load` prints | `--no-skills` |
50
48
 
51
- The skills are a **read-only copy**: the HQ has no git remote, and a skill you edit yourself lands in the private home `~/.wkappbot/private-skills`, a local git repo with no remote. Skill push belongs to the owner's GitHub account only; doctor's `skill-push = owner-only` row names the login a push would use (`gh api user`, else `none`) and WARNs unless it is the owner's.
49
+ `install` prints whether `pwsh` was found and which harness will give the verdict. The harness install ends with an advisory line, `[wk-verify] WARN n/6 advisory, not a failure -- c2 ...; c5 ...; c6 ...`, naming each check that missed and why; in a good web install c2 (wkhippo), c5 (core skill read before the core is linked) and c6 (no harness git checkout) are expected to miss. The verdict file is `~/.wkappbot-code/verify.json`.
50
+
51
+ The skills are a **read-only copy**: the HQ has no git remote, and a skill you edit yourself lands in the private home `~/.wkappbot/private-skills`, a local git repo with no remote. Doctor's `skill-push = owner-only` row names the GitHub login a push would use.
52
52
 
53
- `install.sh` and `wkappbot-code install` are the same code. An existing `~/.config/opencode/opencode.json` is backed up (`opencode.json.bak-<stamp>`), and only the plugin entry is merged in; every other key you set stays. A config that is not plain JSON is left untouched, and `wkappbot-code.json` is written beside it for you to merge by hand. Pass `--kih-dir` (or set `WK_KIH_DIR`) when the kih checkout is not auto-found; without a checkout or `~/.wk-harness` the plugin lets every call through. Relocation and mock overrides: `WK_BIN_BASE`, `WK_HARNESS_BASE`, `WK_HARNESS_DIR`, `WK_BIN_DEST`, `WK_APPBOT_HQ`, `WK_LINK_DIR`, `WK_PRIVATE_SKILLS` (`selftest.sh` part (i) mocks every network part with them).
53
+ The generated `opencode.json` holds the minimum: `$schema` and the plugin entry (with `kihDir`, the harness the plugin calls). No provider and no permission block is added, because no refusal depends on one (every refusal comes from the plugin and the harness). An existing `opencode.json` is backed up (`opencode.json.bak-<stamp>`) and only the plugin entry is merged in; every other key you set stays. A config that is not plain JSON is left untouched, and `wkappbot-code.json` is written beside it for you to merge by hand. `wkappbot-code config` detects the provider and model from the API-key variables that are set (values are never read) and lets you edit them. Pass `--kih-dir` (or set `WK_KIH_DIR`) when a harness checkout is not auto-found (`~/wkappbot-kih` is searched). Relocation and mock overrides: `WK_BIN_BASE`, `WK_HARNESS_BASE`, `WK_HARNESS_DIR`, `WK_BIN_DEST`, `WK_APPBOT_HQ`, `WK_LINK_DIR`, `WK_PRIVATE_SKILLS`, `WK_SETTINGS`.
54
+
55
+ ## Uninstall
56
+
57
+ `install` records, once, the machine state it changes: the global `core.hooksPath`, whether `~/.claude/settings.json`, `~/.wk-harness` and `~/.wk-harness-hooks` existed, and which shims it created (`wkappbot` beside the CLI, `wkskill` and `wk-verify` in `~/.local/bin`). `wkappbot-code uninstall` gives exactly that back: the recorded `core.hooksPath` (only when the current value is a hook folder the install wrote), the harness hook entries leave `settings.json` while yours stay (a file the install created is removed when nothing else is left), the recorded shims go, `~/.wk-harness` and `~/.wk-harness-hooks` go when the install created them, and the plugin entry leaves `opencode.json` (backed up first). It keeps `opencode-ai`, the core and its skills; `npm rm -g wkappbot-code` removes the CLI.
54
58
 
55
59
  ## Model swap
56
60
 
57
61
  - One headless turn: `opencode run -m provider/model "prompt"`
58
62
  - Inside the TUI: `/models`
59
- - In-house Qwen: `opencode.json` already declares the `qwen-inhouse` provider (`@ai-sdk/openai-compatible`). Set `QWEN_BASE_URL` and `QWEN_API_KEY` in the environment, then run `opencode run -m qwen-inhouse/qwen3-coder "hello"`. No key is stored in git.
63
+ - Any OpenAI-compatible endpoint: declare a provider with `"npm": "@ai-sdk/openai-compatible"` and `"options": { "baseURL": "{env:MY_BASE_URL}", "apiKey": "{env:MY_API_KEY}" }` in your own `opencode.json`. No key is stored in a file.
60
64
 
61
65
  ## In the browser WebVM (no login needed)
62
66
 
63
- The x64 WebVM page (`webvm/x64/index.html`) types `wk-ai wkappbot-code` once the shell is ready, so the wkappbot-code prompt is up without typing. `?noappbot` turns this off, and an embedded page only autostarts with `?appbot`. Each question goes through the page bridge:
64
-
65
- 1. A custom OpenAI-compatible provider in `localStorage` (`webvm.appbot.provider` = `{"baseURL","model","key"}`, with the key in `webvm.key.<key>`), for example the in-house Qwen.
66
- 2. Otherwise the first family key the browser holds (claude, openai, gemini, xai).
67
- 3. Otherwise the **dummy haiku**: an OpenAI-compatible mock inside the page. It never calls a model, and its first line reads `dummy haiku (로그인 전)`.
68
-
69
- The kih lite hooks stay live in the guest. Each question is a prompt event, and `!<command>` is a Bash tool call checked by `tools/wkharness-lite.sh` first, so the intent lock refuses a command until a `!wkskill search ...` has run. Proof: `node webvm/selftest/appbot-dummy-test.js` (no VM, no network), and `webvm/selftest/x64-appbot-test.js --page ... --install ...` against the published image. The guest's `wk-ai` gets the `!cmd`/hook support only after the SDK webvm-x64 bake; until then the page's dummy and provider path already works with the old image.
67
+ The x64 WebVM page types `wk-ai wkappbot-code` once the shell is ready, so the wkappbot-code prompt is up without typing. `?noappbot` turns this off, and an embedded page only autostarts with `?appbot`. Each question goes through the page bridge: a custom OpenAI-compatible provider kept in the browser, else the first model key the browser holds, else the **dummy haiku**, an OpenAI-compatible mock inside the page that never calls a model. The lite hooks stay live in the guest: each question is a prompt event, and `!<command>` is a Bash tool call checked by `tools/wkharness-lite.sh` first.
70
68
 
71
69
  ## Floor
72
70
 
73
-
74
71
  Signing, submitting and paying stay human-only.
@@ -3,12 +3,12 @@
3
3
  // wkappbot-code --version package version and the pinned OpenCode release
4
4
  // wkappbot-code install [opts] ONE COMMAND lays down every part (idempotent):
5
5
  // opencode pinned opencode-ai (npm -g) skip: --no-npm
6
- // plugin plugin/wisdom-kernel.js + the opencode.json plugin entry (always)
6
+ // plugin plugin/wisdom-kernel.js + lib/wk-hook.js + the opencode.json plugin entry (always)
7
7
  // bin the wkappbot core for this platform, from the SDK latest release asset verified by
8
- // SHA256SUMS -- the SAME road as kih tools/wk-bin-acquire.sh, shipped byte-identical
9
- // in lib/ (the selftest pins the two copies equal) skip: --no-bin
8
+ // SHA256SUMS -- the SAME road as tools/wk-bin-acquire.sh of the harness repo, shipped
9
+ // byte-identical in lib/ (the selftest pins the two copies equal) skip: --no-bin
10
10
  // harness the scrubbed web harness to ~/.wk-harness through its own published install.sh,
11
- // only when no kih checkout is found (--web-harness forces it) skip: --no-harness
11
+ // only when no harness checkout is found (--web-harness forces it) skip: --no-harness
12
12
  // skills the latest public skills from the same published tarball (sha256-checked) into the
13
13
  // core's HQ (<core dir>/wkappbot.hq/skills), so `wkappbot skill read on-load` prints;
14
14
  // a READ-ONLY copy with no git remote. A user's own skill edits go to the private
@@ -16,12 +16,16 @@
16
16
  // wkappbot-code doctor [opts] one PASS/WARN/FAIL/SKIP row per part (exit 1 if a required part fails)
17
17
  // wkappbot-code update [opts] newer version.json -> tgz sha256-checked -> npm i -g -> install --update (lib/update.js)
18
18
  // wkappbot-code config [opts] detect provider/model/plugin/harness, edit at a TTY, merge into opencode.json (lib/config.js)
19
+ // wkappbot-code uninstall [opts] undo install: the recorded pre-install global core.hooksPath comes back, the
20
+ // harness hook arms leave ~/.claude/settings.json (your own entries stay), the recorded shims
21
+ // leave the bin dirs, ~/.wk-harness and ~/.wk-harness-hooks go, the plugin entry leaves opencode.json
19
22
  // install/doctor/config check for a newer version at most once a day (WKAPPBOT_CODE_NO_UPDATE_CHECK=1 turns it off;
20
23
  // --auto-update or WKAPPBOT_CODE_AUTO_UPDATE=1 applies it). config opts: --yes, --project, --set key=value
21
- // opts: --config-dir DIR, --kih-dir DIR, --bin-dest FILE, --hq-dir DIR
24
+ // opts: --config-dir DIR, --kih-dir DIR, --bin-dest FILE, --hq-dir DIR, --dev-seat (the full pwsh harness and its
25
+ // emergency-edit door on this seat; a user install never gets them)
22
26
  // env (mock and relocation): WK_BIN_BASE (SDK release base), WK_HARNESS_BASE (web harness base),
23
- // WK_HARNESS_DIR (~/.wk-harness), WK_BIN_DEST, WK_APPBOT_HQ, WK_LINK_DIR, WK_PRIVATE_SKILLS,
24
- // WK_KIH_DIR (a kih checkout; "none" = ignore every checkout on this machine)
27
+ // WK_HARNESS_DIR (~/.wk-harness), WK_BIN_DEST, WK_APPBOT_HQ, WK_LINK_DIR, WK_PRIVATE_SKILLS, WK_SETTINGS,
28
+ // WK_KIH_DIR (a harness checkout; "none" = ignore every checkout on this machine)
25
29
  // Installer owns all setup, surgically: an existing user opencode.json is BACKED UP and only the plugin entry is
26
30
  // merged in; every other key is kept. Spec: wkappbot skill read agent-harness-landscape-2026-howto
27
31
  // and wkappbot skill read harness-installer-owns-all-setup (step 9 ONE-COMMAND APPBOT CODE INSTALL)
@@ -43,10 +47,19 @@ const OPENCODE_VERSION = PKG.wkAppbotCode.opencodeVersion;
43
47
  const OWNER_LOGIN = "kiexpert";
44
48
  const HARNESS_BASE = () => (process.env.WK_HARNESS_BASE || "https://kiexpert.github.io/wkappbot-kih/webvm/x64/harness").replace(/\/+$/, "");
45
49
  const HARNESS_DIR = () => process.env.WK_HARNESS_DIR || join(homedir(), ".wk-harness");
50
+ const HOOKS_DIR = () => join(homedir(), ".wk-harness-hooks"); // tools/wk-global-hooks.sh install default
51
+ const SETTINGS = () => process.env.WK_SETTINGS || join(homedir(), ".claude", "settings.json");
52
+ const HARNESS_LINKS = () => process.env.WK_LINK_DIR || join(homedir(), ".local", "bin"); // harness install.sh
53
+ const STATE_HOME = () => join(homedir(), ".wkappbot-code");
54
+ const USER_MARK = ".wk-user-install";
46
55
  const IS_WIN = process.platform === "win32";
47
56
  // Windows: the sh and tar this CLI starts are Git Bash's (MSYS). They read "D:\a\x" as host "D" plus a remote path
48
57
  // ("tar: Cannot connect to C: resolve failed", SDK all-OS QA 2026-11-06), so a path handed to them is the MSYS form /d/a/x.
49
58
  const shPath = (p) => (IS_WIN && /^[A-Za-z]:[\\/]/.test(p) ? "/" + p[0].toLowerCase() + p.slice(2).replace(/\\/g, "/") : p);
59
+ // The env for an MSYS child: HOME in MSYS form too. Node hands HOME through as "D:\\a\\home", and every "$HOME/..." path
60
+ // the harness scripts build then fails their "/*" absolute-path test, so wk-verify ran the hooks from a wrong
61
+ // directory (windows-latest "hooks FAIL 1/6" in the installer, PASS 6/6 when the same verify ran from Git Bash).
62
+ const shEnv = (extra = {}) => (IS_WIN ? { ...process.env, HOME: shPath(homedir()), ...extra } : { ...process.env, ...extra });
50
63
  let _gnuTar = null;
51
64
  const tarArgs = (tgz, dir) => {
52
65
  if (!IS_WIN) return ["-xzf", tgz, "-C", dir];
@@ -56,7 +69,7 @@ const tarArgs = (tgz, dir) => {
56
69
  };
57
70
 
58
71
  function parseArgs(argv) {
59
- const o = { cmd: argv[0] || "help", npm: true, bin: true, harness: true, skills: true, webHarness: false,
72
+ const o = { cmd: argv[0] || "help", npm: true, bin: true, harness: true, skills: true, webHarness: false, devSeat: false,
60
73
  configDir: "", kihDir: "", binDest: "", hqDir: "", update: false, autoUpdate: false, yes: false, project: false, sets: [] };
61
74
  for (let i = 1; i < argv.length; i++) {
62
75
  const a = argv[i];
@@ -65,6 +78,7 @@ function parseArgs(argv) {
65
78
  else if (a === "--no-harness") o.harness = false;
66
79
  else if (a === "--no-skills") o.skills = false;
67
80
  else if (a === "--web-harness") o.webHarness = true;
81
+ else if (a === "--dev-seat") o.devSeat = true;
68
82
  else if (a === "--config-dir") o.configDir = argv[++i] || "";
69
83
  else if (a === "--kih-dir") o.kihDir = argv[++i] || "";
70
84
  else if (a === "--bin-dest") o.binDest = argv[++i] || "";
@@ -86,17 +100,19 @@ function configDir(o) {
86
100
  }
87
101
 
88
102
  const hasHook = (d) => !!d && existsSync(join(d, ".claude", "hooks", "wkharness-run.sh"));
89
- // A kih CHECKOUT (never the web harness): explicit dirs first, then the usual checkout places.
90
- // WK_KIH_DIR=none: act as on a machine with no checkout (the selftest's isolation from this VM's kih).
103
+ // A harness CHECKOUT (never the web harness): explicit dirs first, then this package's parent (a checkout's
104
+ // wkappbot-code/ folder) and the HOME-relative clone. No machine-specific absolute path is ever searched.
105
+ // WK_KIH_DIR=none: act as on a machine with no checkout (the selftest's isolation from this VM's checkout).
106
+ export function checkoutCandidates(o = {}, env = process.env, home = homedir()) {
107
+ if (env.WK_KIH_DIR === "none") return o.kihDir ? [o.kihDir] : [];
108
+ return [o.kihDir, env.WK_KIH_DIR, resolve(PKG_DIR, ".."), join(home, "wkappbot-kih")].filter(Boolean);
109
+ }
91
110
  function kihCheckout(o) {
92
- if (process.env.WK_KIH_DIR === "none") return o.kihDir && hasHook(o.kihDir) ? resolve(o.kihDir) : "";
93
- const tries = [o.kihDir, process.env.WK_KIH_DIR, resolve(PKG_DIR, ".."),
94
- join(homedir(), "wkappbot-kih"), "/home/user/wkappbot-kih", "D:/GitHub/wkappbot-kih"];
95
- for (const d of tries) if (hasHook(d)) return resolve(d);
111
+ for (const d of checkoutCandidates(o)) if (hasHook(d)) return resolve(d);
96
112
  return "";
97
113
  }
98
- // The kih root whose .claude/hooks/wkharness-run.sh the plugin calls: a checkout, else the web harness
99
- // (~/.wk-harness). Empty = plugin fails open.
114
+ // The harness root whose .claude/hooks/wkharness-run.sh the plugin calls: a checkout, else the web harness
115
+ // (~/.wk-harness). Empty = no hook: the plugin then refuses the risky classes and lets the rest through.
100
116
  function kihDir(o) {
101
117
  return kihCheckout(o) || (hasHook(HARNESS_DIR()) ? resolve(HARNESS_DIR()) : "");
102
118
  }
@@ -105,12 +121,16 @@ function opencodeVersion() {
105
121
  try { return execFileSync("opencode", ["--version"], { shell: process.platform === "win32", encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
106
122
  catch { return ""; }
107
123
  }
124
+ const which = (cmd) => { const r = spawnSync("sh", ["-c", "command -v " + cmd], { encoding: "utf8" }); return r.status === 0 ? r.stdout.trim() : ""; };
125
+ const pwshPath = () => which("pwsh") || which("powershell");
108
126
 
109
127
  const stamp = () => new Date().toISOString().replace(/[-:]/g, "").replace(/\..*/, "");
110
128
  const log = (s) => console.log("[wkappbot-code] " + s);
111
129
  const sha256File = (f) => createHash("sha256").update(readFileSync(f)).digest("hex");
112
130
  const statePath = (o) => join(configDir(o), "wkappbot-code", "install-state.json");
113
131
  function readState(o) { try { return JSON.parse(readFileSync(statePath(o), "utf8")); } catch { return {}; } }
132
+ const gitGlobal = (...a) => spawnSync("git", ["config", "--global", ...a], { encoding: "utf8" });
133
+ const globalHooksPath = () => { const r = gitGlobal("--get", "core.hooksPath"); return r.status === 0 ? r.stdout.trim() : null; };
114
134
 
115
135
  // Same rule as wk-bin-acquire.sh platform_detect (linux-x64, osx-arm64, osx-x64, win-x64).
116
136
  export function platformId(platform = process.platform, arch = process.arch) {
@@ -125,6 +145,18 @@ export function mergeConfig(user, template, url, kih) {
125
145
  return buildConfig(user, { plugin: { value: url }, harness: { value: kih } }, template);
126
146
  }
127
147
 
148
+ // The advisory verify line the harness install prints (scripts/wk-harness-verify.sh): a miss is NOT a broken install.
149
+ const VERIFY_MEANING = { c1: "wkappbot not on PATH", c2: "wkhippo not on PATH (the memory tool ships with a harness checkout only)",
150
+ c3: "no PreToolUse hook names wkharness in settings.json", c4: "no Stop hook names wkharness in settings.json",
151
+ c5: "wkappbot skill read on-load did not exit 0 (no core yet, or --no-bin)", c6: "no harness git checkout (expected for the web harness)" };
152
+ export function verifyLine(line) {
153
+ const m = /\[wk-verify\] FAIL (\d)\/6:\s*(.*)$/.exec(line);
154
+ if (!m) return line;
155
+ const ids = m[2].trim().split(/\s+/).filter(Boolean);
156
+ return line.slice(0, m.index) + "[wk-verify] WARN " + m[1] + "/6 advisory, not a failure -- " +
157
+ ids.map((c) => c + " " + (VERIFY_MEANING[c] || "missed")).join("; ") + " (verdict: " + join(STATE_HOME(), "verify.json") + ")";
158
+ }
159
+
128
160
  // ---- part: bin ------------------------------------------------------------------------------------
129
161
  function binDest(o) {
130
162
  if (o.binDest) return resolve(o.binDest);
@@ -145,13 +177,13 @@ function installBin(o) {
145
177
  const dest = binDest(o);
146
178
  const plat = platformId();
147
179
  if (!plat) return { ok: false, dest, note: "no wkappbot build for " + process.platform + "-" + process.arch };
148
- if (plat === "win-x64") return { ok: false, dest, note: "win-x64 has no single SDK asset; run install.cmd from a kih checkout" };
149
- // A kih checkout runs its own copy (same bytes, plus the MANIFEST-verified repo bin/ fallback); the package
180
+ if (plat === "win-x64") return { ok: false, dest, note: "win-x64 has no single SDK asset; run install.cmd from a harness checkout" };
181
+ // A checkout runs its own copy (same bytes, plus the MANIFEST-verified repo bin/ fallback); the package
150
182
  // copy outside a checkout takes only the SDK release asset verified by SHA256SUMS.
151
183
  const co = kihCheckout(o);
152
184
  const script = co && existsSync(join(co, "tools", "wk-bin-acquire.sh")) ? join(co, "tools", "wk-bin-acquire.sh") : join(PKG_DIR, "lib", "wk-bin-acquire.sh");
153
185
  log("bin: bash " + script + " acquire --dest " + dest);
154
- const r = spawnSync("bash", [shPath(script), "acquire", "--dest", shPath(dest)], { encoding: "utf8", env: process.env });
186
+ const r = spawnSync("bash", [shPath(script), "acquire", "--dest", shPath(dest)], { encoding: "utf8", env: shEnv() });
155
187
  for (const line of ((r.stdout || "") + (r.stderr || "")).split("\n").filter(Boolean)) log(" " + line);
156
188
  if (r.status !== 0) return { ok: false, dest, note: "wk-bin-acquire rc " + r.status };
157
189
  // Put `wkappbot` on PATH beside wkappbot-code itself, unless that name is already taken by someone else.
@@ -182,7 +214,7 @@ function curl(url, out) {
182
214
  function installHarness(o) {
183
215
  const found = kihCheckout(o);
184
216
  if (found && !o.webHarness) {
185
- log("harness: kih checkout " + found + " found; the web harness is not needed (--web-harness forces it)");
217
+ log("harness: checkout " + found + " found; the web harness is not needed (--web-harness forces it)");
186
218
  return { ok: true, mode: "checkout", dir: found };
187
219
  }
188
220
  const tmp = mkdtempSync(join(tmpdir(), "wk-appbot-harness-"));
@@ -190,11 +222,17 @@ function installHarness(o) {
190
222
  const err = curl(HARNESS_BASE() + "/install.sh", join(tmp, "install.sh"));
191
223
  if (err) return { ok: false, mode: "web", dir: HARNESS_DIR(), note: err };
192
224
  log("harness: sh " + HARNESS_BASE() + "/install.sh (to " + HARNESS_DIR() + ")");
225
+ mkdirSync(STATE_HOME(), { recursive: true });
193
226
  const r = spawnSync("sh", [shPath(join(tmp, "install.sh"))], { encoding: "utf8",
194
- env: { ...process.env, WK_HARNESS_BASE: HARNESS_BASE(), WK_HARNESS_DIR: shPath(HARNESS_DIR()) } });
195
- for (const line of ((r.stdout || "") + (r.stderr || "")).split("\n").filter(Boolean)) log(" " + line);
227
+ env: shEnv({ WK_HARNESS_BASE: HARNESS_BASE(), WK_HARNESS_DIR: shPath(HARNESS_DIR()), WK_VERIFY_OUT: shPath(join(STATE_HOME(), "verify.json")) }) });
228
+ for (const line of ((r.stdout || "") + (r.stderr || "")).split("\n").filter(Boolean)) log(" " + verifyLine(line));
196
229
  const ok = r.status === 0 && hasHook(HARNESS_DIR());
197
- return { ok, mode: "web", dir: HARNESS_DIR(), note: ok ? "" : "harness install rc " + r.status };
230
+ // USER INSTALL MARK: the launcher then takes the sh lite harness as the whole verdict and the emergency-edit door
231
+ // never grants or prints (wkharness-run.sh FULL, Test-WkEmergencyEditAllow.ps1). --dev-seat keeps the full harness.
232
+ const mark = join(HARNESS_DIR(), USER_MARK);
233
+ if (ok && !o.devSeat) writeFileSync(mark, "user install by wkappbot-code " + PKG.version + "\n");
234
+ else if (ok && o.devSeat) rmSync(mark, { force: true });
235
+ return { ok, mode: "web", dir: HARNESS_DIR(), userInstall: ok && !o.devSeat, note: ok ? "" : "harness install rc " + r.status };
198
236
  } finally { rmSync(tmp, { recursive: true, force: true }); }
199
237
  }
200
238
 
@@ -284,7 +322,8 @@ function installPlugin(o, kih, kihSource) {
284
322
  mkdirSync(join(cfg, "wkappbot-code"), { recursive: true });
285
323
  const dest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
286
324
  copyFileSync(join(PKG_DIR, "plugin", "wisdom-kernel.js"), dest);
287
- // The copy imports nothing from the package, but mark its folder ESM so Node never re-parses it.
325
+ // The plugin file exports ONLY the plugin (OpenCode calls every named export as one); its helpers ride beside it.
326
+ copyFileSync(join(PKG_DIR, "lib", "wk-hook.js"), join(cfg, "wkappbot-code", "wk-hook.js"));
288
327
  writeFileSync(join(cfg, "wkappbot-code", "package.json"), '{ "type": "module" }\n');
289
328
  const url = pathToFileURL(dest).href;
290
329
  const tmpl = JSON.parse(readFileSync(join(PKG_DIR, "opencode.json"), "utf8"));
@@ -294,7 +333,7 @@ function installPlugin(o, kih, kihSource) {
294
333
  raw = readFileSync(target, "utf8");
295
334
  try { user = JSON.parse(raw); } catch { user = null; }
296
335
  }
297
- const it = detect({ user: user || {}, pluginUrl: url, kih, kihSource: kihSource || "kih checkout" });
336
+ const it = detect({ user: user || {}, pluginUrl: url, kih, kihSource: kihSource || "harness checkout" });
298
337
  for (const k of applySets(it, o.sets)) log("config: unknown --set key " + k + " (provider|model|plugin|harness)");
299
338
  printDetected(it, log);
300
339
  if (process.stdin.isTTY && process.stdout.isTTY && !o.yes) askEach(it);
@@ -322,10 +361,29 @@ function installPlugin(o, kih, kihSource) {
322
361
  log("config: " + target + note);
323
362
  }
324
363
 
364
+ // The machine state an uninstall must give back, recorded ONCE (a re-install never overwrites the first record, or
365
+ // it would record our own dispatcher as the "prior" hooksPath). Shim paths are those install may create.
366
+ function shimCandidates() {
367
+ return [join(linkDir(), "wkappbot"), join(HARNESS_LINKS(), "wkskill"), join(HARNESS_LINKS(), "wk-verify")];
368
+ }
369
+ const PRE_SETTINGS = () => join(STATE_HOME(), "settings.pre-install.json");
370
+ function recordPrior(prev) {
371
+ if (prev && prev.prior) return prev.prior;
372
+ // The user's settings.json as it was, byte for byte: uninstall gives these bytes back when nothing else changed.
373
+ if (existsSync(SETTINGS()) && !existsSync(PRE_SETTINGS())) { mkdirSync(STATE_HOME(), { recursive: true }); copyFileSync(SETTINGS(), PRE_SETTINGS()); }
374
+ return { hooksPath: globalHooksPath(), settingsExisted: existsSync(SETTINGS()), harnessDirExisted: existsSync(HARNESS_DIR()),
375
+ hooksDirExisted: existsSync(HOOKS_DIR()), shimsExisting: shimCandidates().filter((p) => existsSync(p) || isSymlink(p)) };
376
+ }
377
+
325
378
  function install(o) {
326
- const st = { version: PKG.version, at: new Date().toISOString() };
379
+ const prev = readState(o);
380
+ const st = { version: PKG.version, at: new Date().toISOString(), prior: recordPrior(prev) };
327
381
  let bad = 0;
328
382
  if (o.update) log("install --update: refreshing the core, the harness and the public skills");
383
+ const ps = pwshPath();
384
+ log("pwsh: " + (ps || "not found") + " -- the FULL harness (PowerShell 7) runs only on a dev seat (a harness checkout, or --dev-seat);" +
385
+ " a user install and every seat without pwsh take the sh lite harness, which still refuses git --no-verify/--force/core.hooksPath," +
386
+ " rm -rf of HOME or the project, and writes under .git");
329
387
  if (o.npm) {
330
388
  const have = opencodeVersion();
331
389
  if (have === OPENCODE_VERSION) log("opencode " + have + " already installed");
@@ -345,20 +403,107 @@ function install(o) {
345
403
  // A user's own absolute global core.hooksPath is kept (the script WARNs). Skipped with --no-harness.
346
404
  st.pushGate = { skipped: true };
347
405
  if (o.harness && kih && existsSync(join(kih, "tools", "wk-global-hooks.sh"))) {
348
- const r = spawnSync("bash", [shPath(join(kih, "tools", "wk-global-hooks.sh")), "install", "--gate-dir", shPath(join(kih, ".githooks", "lib"))], { encoding: "utf8" });
406
+ const r = spawnSync("bash", [shPath(join(kih, "tools", "wk-global-hooks.sh")), "install", "--gate-dir", shPath(join(kih, ".githooks", "lib"))], { encoding: "utf8", env: shEnv() });
349
407
  for (const line of ((r.stdout || "") + (r.stderr || "")).split("\n").filter(Boolean)) log("push-gate: " + line);
350
408
  st.pushGate = { ok: r.status === 0, kih };
351
409
  }
352
410
  st.skills = o.skills ? installSkills(o, dest) : { skipped: true };
353
411
  if (o.skills && !st.skills.ok) { bad++; log("skills: FAILED -- " + (st.skills.note || "on-load missing from the HQ")); }
354
- installPlugin(o, kih, (st.harness.mode === "web" && st.harness.ok) ? "web harness" : "kih checkout");
412
+ installPlugin(o, kih, (st.harness.mode === "web" && st.harness.ok) ? "web harness" : "harness checkout");
355
413
  st.kihDir = kih;
414
+ // The shims this install created (absent before, present now): uninstall removes exactly these.
415
+ st.shims = [...new Set([...(prev.shims || []), ...shimCandidates().filter((p) => (existsSync(p) || isSymlink(p)) && !st.prior.shimsExisting.includes(p))])];
356
416
  writeFileSync(statePath(o), JSON.stringify(st, null, 2) + "\n");
357
- log("kih: " + (kih || "NOT FOUND -- plugin fails open until --kih-dir or WK_KIH_DIR points at a kih checkout"));
417
+ log("kih: " + (kih || "NOT FOUND -- no hook runs; the plugin still refuses the risky classes. Use --kih-dir or WK_KIH_DIR"));
358
418
  log((bad ? "done with " + bad + " failed part(s)" : "done") + " -- check: wkappbot-code doctor then run: opencode");
359
419
  return bad ? 1 : 0;
360
420
  }
361
421
 
422
+ // ---- uninstall ------------------------------------------------------------------------------------
423
+ // Surgical: our hook arms leave settings.json, every other entry stays. A hook is ours when its command names the
424
+ // lite harness or a file under the harness dir; a settings.json that did not exist before and is left empty goes.
425
+ export function stripOurHooks(d, harnessDir) {
426
+ const ours = (c) => /wkharness-lite\.sh|wkharness-run\.sh/.test(c) || (!!harnessDir && c.includes(harnessDir));
427
+ let removed = 0;
428
+ const hooks = d && d.hooks;
429
+ if (!hooks || typeof hooks !== "object") return { d, removed };
430
+ for (const ev of Object.keys(hooks)) {
431
+ const groups = (hooks[ev] || []).map((g) => {
432
+ const hs = (g.hooks || []).filter((h) => { const o = ours(String(h.command || "")); if (o) removed++; return !o; });
433
+ return { ...g, hooks: hs };
434
+ }).filter((g) => g.hooks.length);
435
+ if (groups.length) hooks[ev] = groups; else delete hooks[ev];
436
+ }
437
+ if (!Object.keys(hooks).length) delete d.hooks;
438
+ return { d, removed };
439
+ }
440
+ const sortKeys = (v) => Array.isArray(v) ? v.map(sortKeys) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])) : v;
441
+ function uninstall(o) {
442
+ const st = readState(o);
443
+ const pr = st.prior || {};
444
+ let rc = 0;
445
+ if (!st.version) log("no install record at " + statePath(o) + "; removing only what is ours by name");
446
+ // 1. global core.hooksPath back to its recorded value (only when it still points at a dir we wrote)
447
+ const cur = globalHooksPath();
448
+ const oursHp = (p) => !!p && [HOOKS_DIR(), join(HARNESS_DIR(), ".githooks"), st.kihDir && join(st.kihDir, ".githooks")]
449
+ .filter(Boolean).some((x) => resolve(p.replace(/^~(?=\/|$)/, homedir())) === resolve(x));
450
+ if (cur !== (pr.hooksPath ?? null) && (cur === null || oursHp(cur))) {
451
+ const r = pr.hooksPath ? gitGlobal("core.hooksPath", pr.hooksPath) : gitGlobal("--unset", "core.hooksPath");
452
+ log("hooksPath: " + (cur || "unset") + " -> " + (pr.hooksPath || "unset") + (r.status === 0 ? "" : " (git rc " + r.status + ")"));
453
+ } else log("hooksPath: " + (cur || "unset") + (cur === (pr.hooksPath ?? null) ? " (as before install)" : " (set by you, kept)"));
454
+ // 2. settings.json: our arms out, yours kept
455
+ const sp = SETTINGS();
456
+ if (existsSync(sp)) {
457
+ try {
458
+ const raw = readFileSync(sp, "utf8");
459
+ const { d, removed } = stripOurHooks(JSON.parse(raw), HARNESS_DIR());
460
+ // A hook of yours that an older harness --wire dropped comes back from the pre-install snapshot; when the result is
461
+ // then the same JSON as before install, the snapshot's own bytes are written (formatting kept).
462
+ let preRaw = "", pre = null, restored = 0;
463
+ try { preRaw = readFileSync(PRE_SETTINGS(), "utf8"); pre = JSON.parse(preRaw); } catch {}
464
+ if (pre && pre.hooks) {
465
+ d.hooks = d.hooks || {};
466
+ for (const [ev, groups] of Object.entries(pre.hooks)) for (const g of groups || []) {
467
+ const have = JSON.stringify(d.hooks[ev] || []);
468
+ if (!have.includes(JSON.stringify(g))) { (d.hooks[ev] = d.hooks[ev] || []).push(g); restored++; }
469
+ }
470
+ }
471
+ if (removed || restored) {
472
+ copyFileSync(sp, sp + ".bak-" + stamp());
473
+ if (pr.settingsExisted === false && !Object.keys(d).length) { rmSync(sp); log("settings: removed " + sp + " (it did not exist before install)"); }
474
+ else {
475
+ const same = pre && JSON.stringify(sortKeys(pre)) === JSON.stringify(sortKeys(d));
476
+ writeFileSync(sp, same ? preRaw : JSON.stringify(d, null, 2) + "\n");
477
+ log("settings: removed " + removed + " harness hook entr" + (removed === 1 ? "y" : "ies") + (restored ? ", restored " + restored + " of yours" : "") +
478
+ " in " + sp + (same ? " (now byte-identical to before install)" : "; your other entries stay"));
479
+ }
480
+ } else log("settings: no harness hook in " + sp);
481
+ } catch (e) { rc = 1; log("settings: " + sp + " is not plain JSON, left untouched (" + e.message + ")"); }
482
+ }
483
+ // 3. the shims install created
484
+ for (const p of st.shims || []) {
485
+ if (isSymlink(p) || existsSync(p)) { rmSync(p, { force: true }); log("shim: removed " + p); }
486
+ }
487
+ // 4. the harness dirs we created, the plugin entry and our config folder
488
+ if (st.harness && st.harness.mode === "web" && pr.harnessDirExisted !== true && existsSync(HARNESS_DIR())) { rmSync(HARNESS_DIR(), { recursive: true, force: true }); log("harness: removed " + HARNESS_DIR()); }
489
+ if (pr.hooksDirExisted !== true && existsSync(HOOKS_DIR())) { rmSync(HOOKS_DIR(), { recursive: true, force: true }); log("push-gate: removed " + HOOKS_DIR()); }
490
+ const cfgFile = join(configDir(o), "opencode.json");
491
+ try {
492
+ const c = JSON.parse(readFileSync(cfgFile, "utf8"));
493
+ const keep = (c.plugin || []).filter((p) => !String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel"));
494
+ if (keep.length !== (c.plugin || []).length) {
495
+ copyFileSync(cfgFile, cfgFile + ".bak-" + stamp());
496
+ if (keep.length) c.plugin = keep; else delete c.plugin;
497
+ writeFileSync(cfgFile, JSON.stringify(c, null, 2) + "\n");
498
+ log("config: plugin entry removed from " + cfgFile);
499
+ }
500
+ } catch {}
501
+ rmSync(STATE_HOME(), { recursive: true, force: true });
502
+ rmSync(join(configDir(o), "wkappbot-code"), { recursive: true, force: true });
503
+ log("uninstall done. Kept: opencode-ai, the wkappbot core (" + ((st.bin && st.bin.dest) || binDest(o)) + ") and its skills, and this CLI (npm rm -g wkappbot-code)");
504
+ return rc;
505
+ }
506
+
362
507
  // ---- doctor ---------------------------------------------------------------------------------------
363
508
  // The GitHub login a skill push would use: gh when present, else "none". Push is the owner's alone.
364
509
  export function pushLogin() {
@@ -367,6 +512,15 @@ export function pushLogin() {
367
512
  return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : "none";
368
513
  }
369
514
 
515
+ // The verdict road a pre call takes in harness dir k: "full" (pwsh hub, lite first for OpenCode), "lite", or "" (none).
516
+ export function harnessMode(k, ps = pwshPath()) {
517
+ if (!hasHook(k)) return "";
518
+ const lite = existsSync(join(k, "tools", "wkharness-lite.sh"));
519
+ const user = existsSync(join(k, USER_MARK));
520
+ if (ps && !user) return "full";
521
+ return lite ? "lite" : "";
522
+ }
523
+
370
524
  function doctor(o) {
371
525
  const cfg = configDir(o);
372
526
  const st = readState(o);
@@ -375,7 +529,7 @@ function doctor(o) {
375
529
  const v = opencodeVersion();
376
530
  add("opencode", v === OPENCODE_VERSION, v ? v + (v === OPENCODE_VERSION ? "" : " (pinned " + OPENCODE_VERSION + ")") : "not on PATH", false);
377
531
  const pdest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
378
- add("plugin", existsSync(pdest), pdest, true);
532
+ add("plugin", existsSync(pdest) && existsSync(join(cfg, "wkappbot-code", "wk-hook.js")), pdest + " + wk-hook.js", true);
379
533
  let entry = null;
380
534
  try { entry = (JSON.parse(readFileSync(join(cfg, "opencode.json"), "utf8")).plugin || []).find((p) => String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel")); } catch {}
381
535
  add("config", !!entry, join(cfg, "opencode.json"), true);
@@ -390,19 +544,15 @@ function doctor(o) {
390
544
  core = r.status === 0 ? (r.stdout || "").trim().split("\n")[0] : "";
391
545
  add("bin", !!core, core ? dest + " " + core + (st.bin && st.bin.link ? " (PATH link " + st.bin.link + ")" : "") : dest + " does not run", !!(st.bin && !st.bin.skipped));
392
546
  }
393
- // kih-hook: the harness the plugin calls
547
+ // kih-hook: the harness the plugin calls, and the verdict road it takes. No pwsh and no lite = NO verdict at all:
548
+ // that is a FAIL whatever the install mode, since the plugin then only refuses its own risky classes.
394
549
  const k = (entry && Array.isArray(entry) && entry[1] && entry[1].kihDir) || kihDir(o);
395
550
  const hk = hasHook(k);
551
+ const ps = pwshPath();
552
+ const mode = harnessMode(k, ps);
396
553
  if (st.harness && st.harness.skipped && !hk) add("kih-hook", false, "skipped (--no-harness)", false, true);
397
- else add("kih-hook", hk, k ? k + (st.harness && st.harness.mode ? " (" + st.harness.mode + ")" : "") : "not found", !!(st.harness && st.harness.mode === "web"));
398
- // guards: which guard set the plugin can run. The full set needs PowerShell; without it the plugin falls back to
399
- // the bash lite harness (intent lock, study lock), so this row WARNs instead of passing silently (QA 2026-11-06).
400
- if (hk) {
401
- const psDirs = String(process.env.PATH || "").split(IS_WIN ? ";" : ":");
402
- const ps = ["pwsh", "pwsh.exe", "powershell.exe", "powershell"].find((n) => psDirs.some((d) => d && existsSync(join(d, n))));
403
- const lite = existsSync(join(k, "tools", "wkharness-lite.sh"));
404
- add("guards", !!ps, ps ? "full guard set (" + ps + ")" : lite ? "no PowerShell on PATH: lite guards only (intent lock, study lock); install pwsh 7 for the full set" : "no PowerShell and no lite harness: every tool call is let through", !ps && !lite);
405
- }
554
+ else if (hk && !mode) add("kih-hook", false, k + ": pwsh is missing and the lite harness (tools/wkharness-lite.sh) is not there -- no hook verdict; reinstall the harness", true);
555
+ else add("kih-hook", hk, k ? k + (st.harness && st.harness.mode ? " (" + st.harness.mode + ")" : "") + (mode ? " verdict: " + (mode === "full" ? "full pwsh hub (" + ps + ") after lite" : "lite sh harness" + (ps ? " (user install)" : " (no pwsh)")) : "") : "not found", !!(st.harness && st.harness.mode === "web"));
406
556
  // skills: the HQ holds on-load and the core prints it
407
557
  const hq = (st.skills && st.skills.hq) || hqDir(o, dest);
408
558
  if (st.skills && st.skills.skipped) add("skills", false, "skipped (--no-skills)", false, true);
@@ -420,7 +570,7 @@ function doctor(o) {
420
570
  add("skills", n > 0 && printed, n + " skills in " + hq + (printed ? "; wkappbot skill read on-load prints" : why), !!(st.skills && !st.skills.skipped));
421
571
  }
422
572
  // push-gate: the global core.hooksPath runs the push-owner gate (directly, or through the global dispatcher).
423
- const ghp = (() => { try { return execFileSync("git", ["config", "--global", "--get", "core.hooksPath"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } })();
573
+ const ghp = globalHooksPath() || "";
424
574
  let gated = false;
425
575
  try { gated = /push-owner-gate|wk-global-hooks\.sh/.test(readFileSync(join(ghp.replace(/^~(?=\/|$)/, homedir()), "pre-push"), "utf8")); } catch {}
426
576
  add("push-gate", gated, "global core.hooksPath " + (ghp || "unset") + (gated ? " runs the push-owner gate" : " does not run the push-owner gate"), false);
@@ -452,12 +602,13 @@ switch (o.cmd) {
452
602
  case "--version": case "-v": case "version":
453
603
  console.log("wkappbot-code " + PKG.version + " (opencode-ai " + OPENCODE_VERSION + ")"); process.exit(0);
454
604
  case "install": process.exit(install(o));
605
+ case "uninstall": process.exit(uninstall(o));
455
606
  case "doctor": process.exit(doctor(o));
456
607
  case "update": process.exit(upd());
457
- case "config": installPlugin(o, kihDir(o), kihCheckout(o) ? "kih checkout" : "web harness"); process.exit(0);
608
+ case "config": installPlugin(o, kihDir(o), kihCheckout(o) ? "harness checkout" : "web harness"); process.exit(0);
458
609
  default:
459
- console.log("usage: wkappbot-code --version | install [--no-npm] [--no-bin] [--no-harness] [--no-skills] [--web-harness]\n" +
610
+ console.log("usage: wkappbot-code --version | install [--no-npm] [--no-bin] [--no-harness] [--no-skills] [--web-harness] [--dev-seat]\n" +
460
611
  " [--config-dir DIR] [--kih-dir DIR] [--bin-dest FILE] [--hq-dir DIR] [--update] [--auto-update] | doctor [--config-dir DIR]\n" +
461
- " | update [install opts] | config [--yes] [--project] [--set key=value] [--config-dir DIR]");
612
+ " | uninstall [--config-dir DIR] | update [install opts] | config [--yes] [--project] [--set key=value] [--config-dir DIR]");
462
613
  process.exit(o.cmd === "help" || o.cmd === "--help" ? 0 : 64);
463
614
  }
package/lib/config.js CHANGED
@@ -97,9 +97,13 @@ export function buildConfig(user, it, template) {
97
97
  const ours = [it.plugin ? it.plugin.value : "", { kihDir: it.harness ? it.harness.value : "" }];
98
98
  const keep = (Array.isArray(user.plugin) ? user.plugin : []).filter((p) => !String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel"));
99
99
  out.plugin = [...keep, ours];
100
- if (!out.provider) out.provider = template.provider;
101
- else if (template.provider) for (const [k, v] of Object.entries(template.provider)) if (!(k in out.provider)) out.provider[k] = v;
102
- if (!out.permission) out.permission = template.permission;
100
+ // 0.3.1 (owner: minimum items only): the shipped template carries no provider and no permission, so nothing is
101
+ // added for them; a template that does carry one still only fills what the user has not set.
102
+ if (template.provider) {
103
+ if (!out.provider) out.provider = template.provider;
104
+ else for (const [k, v] of Object.entries(template.provider)) if (!(k in out.provider)) out.provider[k] = v;
105
+ }
106
+ if (template.permission && !out.permission) out.permission = template.permission;
103
107
  if (it.model && (it.model.forced || !user.model)) out.model = it.model.value;
104
108
  return out;
105
109
  }
package/lib/wk-hook.js ADDED
@@ -0,0 +1,278 @@
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
+ env: { ...process.env, ...(W ? { HOME: sp(homedir()) } : {}), ...(kih ? { CLAUDE_PROJECT_DIR: sp(kih) } : {}) },
113
+ stdio: ["pipe", "pipe", "pipe"],
114
+ });
115
+ } catch (e) {
116
+ return finish({ code: 0, stdout: "", stderr: String(e), skipped: true });
117
+ }
118
+ if (opts.timeoutMs) {
119
+ timer = setTimeout(() => {
120
+ try { child.kill("SIGKILL"); } catch {}
121
+ finish({ code: 0, stdout: out, stderr: err, timedOut: true });
122
+ }, opts.timeoutMs);
123
+ }
124
+ child.stdout.on("data", (d) => (out += d));
125
+ child.stderr.on("data", (d) => (err += d));
126
+ child.on("error", (e) => finish({ code: 0, stdout: out, stderr: err + String(e), skipped: true }));
127
+ child.on("close", (code) => finish({ code: code ?? 0, stdout: out, stderr: err }));
128
+ child.stdin.on("error", () => {});
129
+ child.stdin.end(body);
130
+ });
131
+ }
132
+
133
+ // Claude contract: exit 2 refuses; so does a stdout JSON deny/block verdict.
134
+ export function isRefusal(r) {
135
+ if (r.timedOut || r.skipped) return false;
136
+ if (r.code === 2) return true;
137
+ try {
138
+ const j = JSON.parse(r.stdout);
139
+ return (j.hookSpecificOutput && j.hookSpecificOutput.permissionDecision === "deny") || j.decision === "block";
140
+ } catch { return false; }
141
+ }
142
+
143
+ // 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
144
+ // the budget, no hook at all) is NO verdict.
145
+ export function hasVerdict(r) {
146
+ return !r.timedOut && !r.skipped && (r.code === 0 || r.code === 2);
147
+ }
148
+
149
+ // A stdout deny JSON names its own reason; stderr then may hold only warn lines (wkharness-run.sh wk_pre_emit).
150
+ export function refusalReason(r) {
151
+ try {
152
+ const j = JSON.parse(r.stdout);
153
+ const why = (j.hookSpecificOutput && j.hookSpecificOutput.permissionDecisionReason) || j.reason;
154
+ if (why) return why;
155
+ } catch {}
156
+ return (r.stderr || "").trim() || (r.stdout || "").trim() || "refused by the kih harness";
157
+ }
158
+
159
+ // ---- fail-closed risk classes ----------------------------------------------------------------------------
160
+ // The SAME classes tools/wkharness-lite.sh refuses by itself (wk_lite_risk there). With NO verdict from the harness,
161
+ // a call in one of these classes is REFUSED instead of allowed: a public package must never let rm -rf HOME through
162
+ // because pwsh happened to be missing. Returns the class label, or "".
163
+ const HOME_T = /^(~|\$HOME|\$\{HOME\}|\/root|\/home\/[^/]+|\/Users\/[^/]+)\/?(\*|\.\*)?$/;
164
+ export function riskClass(tool, input, cwd) {
165
+ const t = claudeToolName(tool);
166
+ const i = input || {};
167
+ if (t === "Bash") {
168
+ const c = String(i.command || "");
169
+ for (const seg of c.split(/&&|\|\||[;|\n]/)) {
170
+ const w = seg.trim().split(/\s+/).map((x) => x.replace(/^['"]|['"]$/g, ""));
171
+ const gi = w.indexOf("git");
172
+ if (gi >= 0) {
173
+ const g = w.slice(gi + 1);
174
+ const sub = g.find((x) => !x.startsWith("-") && !/^[\w.]+=/.test(x) && g[g.indexOf(x) - 1] !== "-C" && g[g.indexOf(x) - 1] !== "-c");
175
+ if (g.includes("--no-verify") || (sub === "commit" && g.some((x) => /^-[A-Za-z]*n[A-Za-z]*$/.test(x)))) return "git --no-verify";
176
+ 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";
177
+ 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";
178
+ }
179
+ const ri = w.indexOf("rm");
180
+ if (ri >= 0) {
181
+ const a = w.slice(ri + 1);
182
+ if (a.some((x) => /^-[A-Za-z]*[rR][A-Za-z]*$|^--recursive$/.test(x))) {
183
+ const tg = a.filter((x) => !x.startsWith("-"));
184
+ const proj = cwd ? String(cwd).replace(/\/+$/, "") : "";
185
+ if (tg.some((x) => HOME_T.test(x) || x === "/" || x === "/*")) return "rm -r of HOME or /";
186
+ if (tg.some((x) => [".", "./", "./*", "*", "..", "../"].includes(x) || (proj && x.replace(/\/+$/, "") === proj))) return "rm -r of the project source";
187
+ if (tg.some((x) => /(^|\/)\.git(\/|$)/.test(x))) return "write under .git";
188
+ }
189
+ }
190
+ 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";
191
+ }
192
+ return "";
193
+ }
194
+ if (["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(t)) {
195
+ const f = String(i.file_path || i.notebook_path || "");
196
+ if (/(^|[\\/])\.git[\\/]/.test(f)) return "write under .git";
197
+ }
198
+ return "";
199
+ }
200
+
201
+ // ---- read-only fast path ---------------------------------------------------------------------------------
202
+ // These calls never start pwsh (5.3 s measured for echo hello): read tools, and ONE plain read-only shell command
203
+ // with no chain, pipe, redirect or substitution. A path that looks like a secret is never fast (the full harness
204
+ // keeps its say on it).
205
+ 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;
206
+ 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|$)/;
207
+ export function isFastRead(tool, input) {
208
+ const t = claudeToolName(tool);
209
+ const i = input || {};
210
+ if (t === "Read" || t === "Glob" || t === "Grep" || t === "LS") return !SECRET_RE.test(" " + String(i.file_path || i.path || "") + " " + String(i.pattern || ""));
211
+ if (t !== "Bash") return false;
212
+ const c = String(i.command || "");
213
+ if (!c.trim() || c.length > 400 || /[;&|<>`$\n\r\\(){}]/.test(c)) return false;
214
+ if (!RO_CMD.test(c) || SECRET_RE.test(c)) return false;
215
+ if (/^\s*git\b/.test(c) && /\s(--output\S*|--ext-diff|--exec\S*|-o)(\s|$)/.test(c)) return false;
216
+ return true;
217
+ }
218
+
219
+ // PER-CALL INJECTION (owner 2026-11-06: "so we must force our settings onto every model call OpenCode makes").
220
+ // experimental.chat.system.transform appends RULES to the system array of EVERY model request, so a model that never
221
+ // saw kih (Qwen, a fresh provider) still gets the reflex body. Short, fixed, no secrets. RULES_MARK makes the append
222
+ // idempotent when OpenCode hands the same array back. Spec: agent-harness-landscape-2026 step PER-CALL INJECTION.
223
+ export const RULES_MARK = "[wisdom-kernel rules]";
224
+ export const RULES = [
225
+ RULES_MARK + " You run under the Wisdom Kernel harness (wkappbot-code). On every turn:",
226
+ "1. One command per shell call; never chain a write with anything else.",
227
+ "2. A refusal names a skill: run wkskill read <id> (or wkappbot skill read <id>) and apply its remedy; never route around it.",
228
+ "3. Never use git --no-verify or git push --force, never change core.hooksPath, never rm -r a home directory, / or the project source.",
229
+ "4. Never edit or delete files under .git.",
230
+ "5. After a new user prompt, run wkskill search <three or more words> before the first write; read-only tools stay open.",
231
+ ].join("\n");
232
+
233
+ // The top step lines of the on-load skill, fetched ONCE per process (one OpenCode server = its sessions) in the
234
+ // background; the transform waits at most ONLOAD_WAIT_MS for it and otherwise sends the fixed rules alone.
235
+ const ONLOAD_WAIT_MS = Number(process.env.WK_APPBOT_ONLOAD_WAIT_MS || 1500);
236
+ let _onload = null;
237
+ export function onLoadLines(kihDir) {
238
+ if (_onload) return _onload;
239
+ _onload = new Promise((res) => {
240
+ const k = kihDir || resolveKihDir();
241
+ const cands = [process.env.WK_APPBOT_SKILL_CMD, k && join(k, "bin", "wkskill"), "wkskill"].filter(Boolean);
242
+ let out = "", done = false;
243
+ const finish = () => {
244
+ if (done) return; done = true;
245
+ // no secrets reach the model: a secret-file name (SECRET_RE), a key=value or key: value pair, or a URL drops the line
246
+ const lines = out.split("\n").map((l) => l.trim()).filter((l) => /^\d+\.\s/.test(l) && !SECRET_RE.test(l) &&
247
+ !/(token|secret|passw\w*|api[_-]?key|auth)\s*[=:]/i.test(l) && !/https?:\/\//.test(l));
248
+ res(lines.slice(0, 3).map((l) => l.slice(0, 240)));
249
+ };
250
+ try {
251
+ const c = spawn(cands.find((p) => p === "wkskill" || existsSync(p)), ["read", "on-load"], { stdio: ["ignore", "pipe", "ignore"] });
252
+ c.stdout.on("data", (d) => { out += d; if (out.length > 65536) c.kill("SIGKILL"); });
253
+ c.on("error", finish); c.on("close", finish);
254
+ setTimeout(() => { try { c.kill("SIGKILL"); } catch {} finish(); }, 8000).unref();
255
+ } catch { finish(); }
256
+ });
257
+ return _onload;
258
+ }
259
+ export async function rulesBlock(kihDir) {
260
+ let top = [];
261
+ try { top = await Promise.race([onLoadLines(kihDir), new Promise((r) => setTimeout(() => r([]), ONLOAD_WAIT_MS))]); } catch {}
262
+ return top.length ? RULES + "\nFrom the on-load skill:\n" + top.join("\n") : RULES;
263
+ }
264
+ export function injectRules(system, block) {
265
+ if (!Array.isArray(system) || system.some((s) => String(s).includes(RULES_MARK))) return false;
266
+ system.push(block);
267
+ return true;
268
+ }
269
+
270
+ // One line per model-call hook into ~/.wkappbot-code/plugin.log (chat.params is logged, never changed). Capped at 1 MB.
271
+ export function hookLog(hook, fields) {
272
+ try {
273
+ const f = process.env.WK_APPBOT_LOG || join(homedir(), ".wkappbot-code", "plugin.log");
274
+ mkdirSync(dirname(f), { recursive: true });
275
+ try { if (statSync(f).size > 1048576) writeFileSync(f, ""); } catch {}
276
+ appendFileSync(f, new Date().toISOString() + " " + hook + " " + JSON.stringify(fields || {}) + "\n");
277
+ } catch {}
278
+ }
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,6 +1,6 @@
1
1
  {
2
2
  "name": "wkappbot-code",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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
6
  "bin": {
@@ -8,9 +8,10 @@
8
8
  "wk-appbot-code": "bin/wkappbot-code.js"
9
9
  },
10
10
  "files": [
11
- "bin/",
12
- "lib/",
13
- "plugin/",
11
+ "bin/*.js",
12
+ "lib/*.js",
13
+ "lib/*.sh",
14
+ "plugin/*.js",
14
15
  "opencode.json",
15
16
  "install.sh",
16
17
  "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;