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 +31 -34
- package/bin/wkappbot-code.js +195 -44
- package/lib/config.js +7 -3
- package/lib/wk-hook.js +278 -0
- package/opencode.json +1 -26
- package/package.json +5 -4
- package/plugin/wisdom-kernel.js +65 -187
package/README.md
CHANGED
|
@@ -1,74 +1,71 @@
|
|
|
1
1
|
# wkappbot-code -- Wisdom Kernel (WK)
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
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
|
|
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 |
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
+
## Install
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
|
-
npm i -g
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
48
|
-
| harness | when no
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
-
|
|
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
|
|
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.
|
package/bin/wkappbot-code.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
90
|
-
//
|
|
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
|
-
|
|
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
|
|
99
|
-
// (~/.wk-harness). Empty = plugin
|
|
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
|
|
149
|
-
// A
|
|
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:
|
|
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:
|
|
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: {
|
|
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
|
-
|
|
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
|
|
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 || "
|
|
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
|
|
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" : "
|
|
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
|
|
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",
|
|
398
|
-
|
|
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 = (
|
|
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) ? "
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
if (
|
|
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
|
+
"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
|
-
"
|
|
11
|
+
"bin/*.js",
|
|
12
|
+
"lib/*.js",
|
|
13
|
+
"lib/*.sh",
|
|
14
|
+
"plugin/*.js",
|
|
14
15
|
"opencode.json",
|
|
15
16
|
"install.sh",
|
|
16
17
|
"README.md"
|
package/plugin/wisdom-kernel.js
CHANGED
|
@@ -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
|
|
4
|
-
// tool.execute.before
|
|
5
|
-
// tool.execute.after
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
35
|
+
const inp = input || {};
|
|
36
|
+
const args = output && output.args;
|
|
37
|
+
let p, r;
|
|
180
38
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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 {
|
|
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
|
|
192
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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;
|