wkappbot-code 0.3.22 → 0.3.25

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
@@ -12,7 +12,7 @@ The plugin `plugin/wisdom-kernel.js` maps OpenCode plugin events onto the harnes
12
12
  |---|---|---|
13
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 |
15
+ | `chat.message` | `prompt` | UserPromptSubmit: the intent lock owes a skill search (`wkappbot skill search ...`) before real work |
16
16
  | `event` `session.created` | `session` | SessionStart |
17
17
  | `event` `session.idle` | `stop-archive` | archive only, fail-open |
18
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) |
@@ -57,7 +57,7 @@ The generated `opencode.json` holds the minimum: `$schema` and the plugin entry
57
57
 
58
58
  ## Uninstall
59
59
 
60
- `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.
60
+ `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 and `wk-verify` in `~/.local/bin`; no `wkskill` command exists, the skill command is `wkappbot skill`). `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.
61
61
 
62
62
  ## Model swap
63
63
 
@@ -7,7 +7,7 @@
7
7
  // CONTRACT: (a) ALWAYS exits 0 and returns within a moment -- the real work runs DETACHED and logs to
8
8
  // ~/.wkappbot-code/postinstall.log; (b) the worker runs `wkappbot-code install` (lays the web harness, wires
9
9
  // ~/.claude/settings.json through the lite harness -- PreToolUse, Stop, UserPromptSubmit, SessionStart -- the global git
10
- // hooks, the wkskill/wk-verify shims and the public skills, keeping every user setting), then `wkappbot-code doctor`,
10
+ // hooks, the wk-verify shim and the public skills, keeping every user setting), then `wkappbot-code doctor`,
11
11
  // then a short self-test (lib/setup-run.js: core --version, skill read on-load, one hook allow, one hook refuse), and
12
12
  // writes ~/.wkappbot-code/postinstall.done {version, installRc, doctor:{pass,warn,fail,skip}, selftest:{n,m}, ...};
13
13
  // postinstall.running exists while the worker lives; (c) the first `wkappbot-code` run afterwards prints ONE summary
@@ -40,7 +40,7 @@ import {
40
40
  import { homedir, tmpdir } from "node:os";
41
41
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
42
42
  import { fileURLToPath, pathToFileURL } from "node:url";
43
- import { quietCheck, runUpdate, updateRow } from "../lib/update.js"; import { rmTreeHard } from "../lib/rmtree.js"; import { plainFileUrl } from "../lib/fileurl.js";
43
+ import { quietCheck, runUpdate, updateRow } from "../lib/update.js"; import { rmTreeHard } from "../lib/rmtree.js"; import { plainFileUrl } from "../lib/fileurl.js"; import { removeStaleWkskill } from "../lib/stale-wkskill.js"; import { checkPath, installBothLinks, removeCreatedLinks } from "../lib/doctor-path.js";
44
44
  import { applySets, askEach, buildConfig, configRow, detect, lineDiff, printDetected } from "../lib/config.js";
45
45
  import { aiCliRows, installAiClis, removeAiClis } from "../lib/ai-clis.js";
46
46
  import { hooksRow, wireClaudeHooks } from "../lib/hook-wire.js";
@@ -400,7 +400,7 @@ function installPlugin(o, kih, kihSource) {
400
400
  // The machine state an uninstall must give back, recorded ONCE (a re-install never overwrites the first record, or
401
401
  // it would record our own dispatcher as the "prior" hooksPath). Shim paths are those install may create.
402
402
  function shimCandidates() {
403
- return [join(linkDir(), "wkappbot"), join(HARNESS_LINKS(), "wkskill"), join(HARNESS_LINKS(), "wk-verify"), join(HARNESS_LINKS(), "wkskill.cmd"), join(HARNESS_LINKS(), "wk-verify.cmd")]; // Windows (Git Bash) .cmd twins from harness-install.sh: recorded like the rest, so uninstall removes them, never a user file
403
+ return [join(linkDir(), "wkappbot"), join(HARNESS_LINKS(), "wk-verify"), join(HARNESS_LINKS(), "wk-verify.cmd")]; // Windows (Git Bash) .cmd twins from harness-install.sh: recorded like the rest, so uninstall removes them, never a user file
404
404
  }
405
405
  const PRE_SETTINGS = () => join(STATE_HOME(), "settings.pre-install.json");
406
406
  function recordPrior(prev) {
@@ -455,9 +455,8 @@ function install(o) {
455
455
  : ((prev.aiClis && (prev.aiClis.installed || []).length) ? prev.aiClis : { skipped: true }); // a skip still owns what an earlier install added
456
456
  const dest = binDest(o);
457
457
  st.bin = o.bin ? installBin(o) : { skipped: true, dest };
458
- // 0.3.13 (owner 2026-11-09: "check that a missing core is handled as a failure"): NO core is a failed part on every
459
- // road, an unreachable release host included. The hooks and the lite harness still install, so the risky classes are
460
- // refused, but the install exits 1 and the banner says so until a rerun fetches the core.
458
+ if (o.bin) st.bin.links = installBothLinks({ dest, home: homedir(), env: process.env, log }).created; // both bare names, recorded for uninstall
459
+ // 0.3.13: NO core is a failed part on every road (exit 1 until a rerun fetches it); the hooks and lite harness still install.
461
460
  if (o.bin && !st.bin.ok) { bad++; log("bin: FAILED -- no wkappbot core installed: " + st.bin.note); }
462
461
  st.harness = o.harness ? installHarness(o) : { skipped: true };
463
462
  if (o.harness && !st.harness.ok) { bad++; log("harness: FAILED -- " + st.harness.note); }
@@ -562,7 +561,7 @@ function uninstall(o) {
562
561
  }
563
562
  // 3b. the ~/.local/bin/wkappbot link: removed ONLY when it points at the core this install placed (the core file stays)
564
563
  const core = (st.bin && st.bin.dest) || binDest(o);
565
- removeStarterMd(st.starterMd, log);
564
+ removeStarterMd(st.starterMd, log); removeStaleWkskill(HARNESS_LINKS(), log); // an older harness install's counterfeit wkskill link (SDK e2e: local-bin=wkskill)
566
565
  let linkRemoved = "";
567
566
  for (const p of new Set(shimCandidates().concat([join(homedir(), ".local", "bin", "wkappbot")]))) {
568
567
  if (!isSymlink(p) || (st.shims || []).includes(p)) continue;
@@ -578,6 +577,7 @@ function uninstall(o) {
578
577
  if (body.includes(core)) { rmSync(cmdShim, { force: true }); linkRemoved = cmdShim; log("shim: removed " + cmdShim + " (it called the core this install placed)"); }
579
578
  else log("shim: kept " + cmdShim + " (it no longer calls " + core + ")");
580
579
  }
580
+ removeCreatedLinks(st.bin?.links || []).forEach((p) => log("links: removed " + p)); // only the links install recorded
581
581
  // Undo only our npm license marker; do not alter a marker owned by another installer.
582
582
  if (IS_WIN && st.bin?.ok) {
583
583
  const marker = join(dirname(core), ".wkappbot-code-install");
@@ -657,8 +657,8 @@ function doctor(o) {
657
657
  core = r.status === 0 ? (r.stdout || "").trim().split("\n")[0] : "";
658
658
  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));
659
659
  }
660
- // kih-hook: the harness the plugin calls, and the verdict road it takes. No pwsh and no lite = NO verdict at all:
661
- // that is a FAIL whatever the install mode, since the plugin then only refuses its own risky classes.
660
+ const pr = checkPath({ home: homedir(), env: process.env, platform: process.platform }); add("path", pr.ok, pr.text, true);
661
+ // kih-hook: the harness the plugin calls and its verdict road; no pwsh and no lite = NO verdict = FAIL whatever the install mode.
662
662
  const k = (entry && Array.isArray(entry) && entry[1] && entry[1].kihDir) || kihDir(o);
663
663
  const hk = hasHook(k);
664
664
  const ps = pwshPath();
@@ -9,7 +9,6 @@ import { dirname } from "node:path";
9
9
  import { readSettings } from "./hook-wire.js";
10
10
 
11
11
  export const COMPACT_ALLOWS = [
12
- "Bash(wkskill *)", // skill read / search / like (the user's own store)
13
12
  "Bash(wkappbot skill *)", // the same through the core
14
13
  "Bash(wkappbot file read *)", // read-only file view
15
14
  "Bash(wk-verify)", // live hook check, no arguments
@@ -0,0 +1,279 @@
1
+ // doctor-path.js -- the REQUIRED doctor row "path": are `wkappbot` AND `wkappbot-core` found by their BARE names
2
+ // in a FRESH shell? Proto for wkappbot-code/lib/doctor-path.js. ES module, node built-ins only.
3
+ // Linux/macOS: a login bash (bash -lc) with HOME as given and the OS persistent PATH.
4
+ // Windows: a new cmd.exe whose PATH is machine PATH + user PATH read from the registry.
5
+ // PASS only when each bare-name run prints "wkappbot v". Any other outcome is FAIL for that name.
6
+ import { execFileSync } from "node:child_process";
7
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
8
+ import { dirname, join, resolve } from "node:path";
9
+
10
+ const OK_MARK = "wkappbot v";
11
+ const BINS = ["wkappbot", "wkappbot-core"];
12
+
13
+ // One REG_SZ/REG_EXPAND_SZ Path value from the registry; "" when absent or reg is unavailable.
14
+ function winRegPath(key) {
15
+ try {
16
+ const out = execFileSync("reg", ["query", key, "/v", "Path"], {
17
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15000,
18
+ });
19
+ const m = out.match(/^\s*Path\s+REG_(?:EXPAND_)?SZ\s+(.*?)\s*$/m);
20
+ return m ? m[1] : "";
21
+ } catch {
22
+ return "";
23
+ }
24
+ }
25
+
26
+ // %VAR% expansion for registry values such as %USERPROFILE%\bin; unknown names are kept as written.
27
+ function expandVars(s, env) {
28
+ return s.replace(/%([^%]+)%/g, (all, name) => (env[name] !== undefined ? env[name] : all));
29
+ }
30
+
31
+ // The real POSIX road: a login bash, as a user's new terminal starts. /etc/profile may reset PATH.
32
+ export function loginShell(h, env, bin) {
33
+ try {
34
+ return execFileSync("bash", ["-lc", `command -v ${bin} && ${bin} --version`], {
35
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60000,
36
+ env: { HOME: h, PATH: env.PATH || "/usr/local/bin:/usr/bin:/bin" },
37
+ });
38
+ } catch (e) {
39
+ return typeof e.stdout === "string" ? e.stdout : "";
40
+ }
41
+ }
42
+
43
+ // checkPath({home, env, platform, readRegPath, runShell}) -> {ok, text}
44
+ // home HOME (or USERPROFILE on Windows) the fresh shell starts in
45
+ // env carries the OS persistent PATH (PATH key; default process.env)
46
+ // platform process.platform by default; "win32" selects the registry road
47
+ // readRegPath injectable registry reader (tests pass a stub)
48
+ // runShell injectable POSIX fresh-shell runner (default loginShell; the selftest injects a hermetic one)
49
+ export function checkPath({ home, env = process.env, platform = process.platform, readRegPath = winRegPath, runShell = loginShell } = {}) {
50
+ const h = home || env.HOME || env.USERPROFILE || "";
51
+ let winPath = null;
52
+ const fails = [];
53
+ for (const bin of BINS) {
54
+ let out = "";
55
+ if (platform === "win32") {
56
+ if (winPath === null) {
57
+ const machine = expandVars(readRegPath("HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment"), env);
58
+ const user = expandVars(readRegPath("HKCU\\Environment"), env);
59
+ winPath = [machine, user].filter(Boolean).join(";");
60
+ }
61
+ try {
62
+ out = execFileSync("cmd.exe", ["/d", "/s", "/c", `${bin} --version`], {
63
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60000,
64
+ env: { SystemRoot: env.SystemRoot || "C:\\Windows", USERPROFILE: h, HOME: h, PATH: winPath },
65
+ });
66
+ } catch (e) {
67
+ out = typeof e.stdout === "string" ? e.stdout : "";
68
+ }
69
+ } else {
70
+ out = runShell(h, env, bin);
71
+ }
72
+ if (!out.includes(OK_MARK)) fails.push(bin);
73
+ }
74
+ if (fails.length) return { ok: false, text: fails.map((b) => `path: FAIL ${b} not found by bare name in a fresh shell`).join("; ") };
75
+ return { ok: true, text: "path: PASS wkappbot and wkappbot-core found by bare name in a fresh shell" };
76
+ }
77
+
78
+ // ---- installer helper: create the missing links on the persistent PATH ----------------------------------------
79
+
80
+ const WIN_MACHINE_KEY = "HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment";
81
+ const WIN_USER_KEY = "HKCU\\Environment";
82
+
83
+ // The PERSISTENT PATH entries. win32: machine Path then user Path from the registry (%VAR% expanded), the same
84
+ // values a new cmd.exe is built from; the process PATH is never consulted there. POSIX: the PATH given in env.
85
+ function persistentEntries(env, platform, readRegPath) {
86
+ if (platform !== "win32") return (env.PATH || "").split(":");
87
+ const machine = expandVars(readRegPath(WIN_MACHINE_KEY), env);
88
+ const user = expandVars(readRegPath(WIN_USER_KEY), env);
89
+ return [machine, user].join(";").split(";");
90
+ }
91
+
92
+ const onPathList = (env, platform, dir, readRegPath = winRegPath) => {
93
+ const norm = (p) => p.replace(/[\\/]+$/, "").toLowerCase();
94
+ return persistentEntries(env, platform, readRegPath).some((p) => p && norm(p) === norm(dir));
95
+ };
96
+
97
+ function occupied(p) {
98
+ try { lstatSync(p); return true; } catch { return false; }
99
+ }
100
+
101
+ function defaultNpmPrefix(platform) {
102
+ try {
103
+ return execFileSync(platform === "win32" ? "npm.cmd" : "npm", ["prefix", "-g"], {
104
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 20000, shell: platform === "win32",
105
+ }).trim();
106
+ } catch {
107
+ return "";
108
+ }
109
+ }
110
+
111
+ // The folder the links go in: npm global bin first, then ~/.local/bin. Prefers a folder already on PATH.
112
+ // needsPath is true when neither is on the persistent PATH (then ensureLinks adds the chosen folder).
113
+ export function pickBinDir({ home, env = process.env, platform = process.platform, npmPrefix = defaultNpmPrefix(platform), readRegPath = winRegPath } = {}) {
114
+ const npmBin = npmPrefix ? (platform === "win32" ? npmPrefix : join(npmPrefix, "bin")) : "";
115
+ const local = join(home, ".local", "bin");
116
+ const candidates = [npmBin, local].filter(Boolean);
117
+ const hit = candidates.find((d) => onPathList(env, platform, d, readRegPath));
118
+ if (hit) return { dir: hit, needsPath: false };
119
+ return { dir: candidates[0] || local, needsPath: true };
120
+ }
121
+
122
+ const exportLine = (dir) => `export PATH="${dir}:$PATH"`;
123
+ const trimSlash = (p) => p.replace(/[\\/]+$/, "").toLowerCase();
124
+
125
+ // PowerShell does the user-Path write: [Environment]::SetEnvironmentVariable broadcasts the settings change.
126
+ // The folder arrives in an env var, never in the script text. Prints ADDED, or PRESENT when it is already there.
127
+ const WIN_ADD_SCRIPT = [
128
+ "$ErrorActionPreference = 'Stop'",
129
+ "$d = $env:WK_PATH_DIR",
130
+ "$cur = [Environment]::GetEnvironmentVariable('Path', 'User')",
131
+ "if ($null -eq $cur) { $cur = '' }",
132
+ "$want = $d.TrimEnd([char[]]@('\\', '/')).ToLowerInvariant()",
133
+ "$hit = @($cur.Split(';') | Where-Object { $_ -and ($_.TrimEnd([char[]]@('\\', '/')).ToLowerInvariant() -eq $want) })",
134
+ "if ($hit.Count -gt 0) { Write-Output 'PRESENT'; exit 0 }",
135
+ "$new = if ($cur.Trim() -eq '') { $d } else { $cur.TrimEnd(';') + ';' + $d }",
136
+ "[Environment]::SetEnvironmentVariable('Path', $new, 'User')",
137
+ "Write-Output 'ADDED'",
138
+ ].join("\n");
139
+
140
+ // Removes exactly the folder's entries from the user Path (untouched entries keep their order and text).
141
+ const WIN_REMOVE_SCRIPT = [
142
+ "$ErrorActionPreference = 'Stop'",
143
+ "$d = $env:WK_PATH_DIR",
144
+ "$cur = [Environment]::GetEnvironmentVariable('Path', 'User')",
145
+ "if ($null -eq $cur) { Write-Output 'ABSENT'; exit 0 }",
146
+ "$want = $d.TrimEnd([char[]]@('\\', '/')).ToLowerInvariant()",
147
+ "$keep = @($cur.Split(';') | Where-Object { -not ($_ -and ($_.TrimEnd([char[]]@('\\', '/')).ToLowerInvariant() -eq $want)) })",
148
+ "$new = $keep -join ';'",
149
+ "if ($new -eq $cur) { Write-Output 'ABSENT'; exit 0 }",
150
+ "[Environment]::SetEnvironmentVariable('Path', $new, 'User')",
151
+ "Write-Output 'REMOVED'",
152
+ ].join("\n");
153
+
154
+ function psRun(script, dir) {
155
+ return execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], {
156
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 30000,
157
+ env: { ...process.env, WK_PATH_DIR: dir },
158
+ }).trim();
159
+ }
160
+
161
+ // Windows: append the folder to the USER Path. PowerShell first; the reg add road only when PowerShell fails.
162
+ // Returns true only when this call wrote the entry (so uninstall removes only what install added).
163
+ function addUserPathWin(dir, readRegPath) {
164
+ try {
165
+ return psRun(WIN_ADD_SCRIPT, dir) === "ADDED";
166
+ } catch {
167
+ const cur = readRegPath(WIN_USER_KEY);
168
+ if (cur.split(";").some((p) => p && trimSlash(p) === trimSlash(dir))) return false;
169
+ const next = cur ? cur + ";" + dir : dir;
170
+ execFileSync("reg", ["add", WIN_USER_KEY, "/v", "Path", "/t", "REG_EXPAND_SZ", "/d", next, "/f"], { stdio: "ignore", timeout: 15000 });
171
+ return true;
172
+ }
173
+ }
174
+
175
+ // Default PATH addition: POSIX appends an export line to ~/.profile; Windows appends to the user Path value.
176
+ // Returns true only when this call wrote the PATH entry.
177
+ function defaultAddToPath({ dir, home, platform, readRegPath = winRegPath }) {
178
+ try {
179
+ if (platform === "win32") return addUserPathWin(dir, readRegPath);
180
+ const profile = join(home, ".profile");
181
+ const text = existsSync(profile) ? readFileSync(profile, "utf8") : "";
182
+ if (text.includes(dir)) return false;
183
+ appendFileSync(profile, `\n${exportLine(dir)}\n`);
184
+ return true;
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ // Uninstall side of defaultAddToPath: removes exactly the entry install wrote. Returns true when something was removed.
191
+ function defaultRemovePath({ dir, home, platform }) {
192
+ try {
193
+ if (platform === "win32") return psRun(WIN_REMOVE_SCRIPT, dir) === "REMOVED";
194
+ const profile = join(home, ".profile");
195
+ if (!existsSync(profile)) return false;
196
+ const lines = readFileSync(profile, "utf8").split("\n");
197
+ const keep = lines.filter((l) => l !== exportLine(dir));
198
+ if (keep.length === lines.length) return false;
199
+ writeFileSync(profile, keep.join("\n"));
200
+ return true;
201
+ } catch {
202
+ return false;
203
+ }
204
+ }
205
+
206
+ // ensureLinks({home, platform, binDir, env, targets, npmPrefix, addToPath}) -> {created, dir, pathAdded}
207
+ // targets REQUIRED {wkappbot: <core path>, "wkappbot-core": <core path>}; the installer passes the real files.
208
+ // Creates a link (POSIX symlink) or a .cmd shim (Windows) only where NOTHING exists yet: a user's own file is
209
+ // never overwritten and never listed in created. created is exactly what removeCreatedLinks may delete.
210
+ export function ensureLinks({ home, platform = process.platform, binDir, env = process.env, targets, npmPrefix, addToPath = defaultAddToPath, readRegPath = winRegPath } = {}) {
211
+ if (!targets || !targets.wkappbot || !targets["wkappbot-core"]) throw new Error("ensureLinks: targets {wkappbot, wkappbot-core} required");
212
+ let dir = binDir, needsPath = false;
213
+ if (dir) needsPath = !onPathList(env, platform, dir, readRegPath);
214
+ else ({ dir, needsPath } = pickBinDir({ home, env, platform, npmPrefix, readRegPath }));
215
+ mkdirSync(dir, { recursive: true });
216
+ const created = [];
217
+ for (const bin of BINS) {
218
+ const dest = join(dir, platform === "win32" ? bin + ".cmd" : bin);
219
+ if (occupied(dest)) continue;
220
+ if (platform === "win32") writeFileSync(dest, `@"${targets[bin]}" %*\r\n`);
221
+ else symlinkSync(targets[bin], dest);
222
+ created.push(dest);
223
+ }
224
+ // Windows runs the PATH logic even when both shims already existed: the doctor row reads the persistent PATH,
225
+ // so a missing entry is a FAIL whether or not this run wrote a shim. POSIX keeps its old rule (a link was written).
226
+ const pathWanted = needsPath && (created.length > 0 || platform === "win32");
227
+ const pathAdded = pathWanted ? !!addToPath({ dir, home, platform, readRegPath }) : false;
228
+ return { created, dir, pathAdded };
229
+ }
230
+
231
+ // removeCreatedLinks(created, opts) -> the entries actually removed. Deletes only the listed entries, nothing else.
232
+ // A "PATH:<folder>" entry is the PATH entry install added: it is removed by removePath (exactly that folder),
233
+ // never by unlink. opts: platform, home, and an injectable removePath({dir, home, platform}) -> boolean.
234
+ export function removeCreatedLinks(created = [], { platform = process.platform, home = process.env.HOME || process.env.USERPROFILE || "", removePath = defaultRemovePath } = {}) {
235
+ const removed = [];
236
+ for (const p of created) {
237
+ if (p.startsWith("PATH:")) {
238
+ try { if (removePath({ dir: p.slice(5), home, platform })) removed.push(p); } catch { /* not removed */ }
239
+ continue;
240
+ }
241
+ try { unlinkSync(p); removed.push(p); } catch { /* already gone */ }
242
+ }
243
+ return removed;
244
+ }
245
+
246
+ // installBothLinks({dest, home, platform, binDir, env, log}) -> {created, pathAdded, kept, dir}
247
+ // ALWAYS makes both bare names, wkappbot and wkappbot-core, in binDir (env WK_LINK_DIR, else ~/.local/bin).
248
+ // dest is the core file; Windows wkappbot-core.cmd points at wkappbot-core.exe beside it. A user's own file
249
+ // stays and is listed in kept (and logged), never in created. A path already holding THIS install's link (a
250
+ // re-install) is ours: it is recorded in created again, so uninstall removes it and the folder is left empty.
251
+ export function installBothLinks({ dest, home, platform = process.platform, binDir, env = process.env, log = () => {}, addToPath = defaultAddToPath, readRegPath = winRegPath } = {}) {
252
+ const dir = binDir || env.WK_LINK_DIR || join(home, ".local", "bin");
253
+ const coreExe = platform === "win32" ? join(dirname(dest), "wkappbot-core.exe") : dest;
254
+ const targets = { wkappbot: dest, "wkappbot-core": coreExe };
255
+ const pathOf = (bin) => join(dir, platform === "win32" ? bin + ".cmd" : bin);
256
+ const ours = BINS.filter((bin) => occupied(pathOf(bin)) && isOurLink(pathOf(bin), targets[bin], platform));
257
+ const kept = BINS.filter((bin) => occupied(pathOf(bin)) && !ours.includes(bin));
258
+ for (const bin of kept) log("links: kept " + join(dir, bin) + " (not ours)");
259
+ const r = ensureLinks({ home, platform, binDir: dir, env, targets, addToPath, readRegPath });
260
+ const created = BINS.map(pathOf).filter((p) => r.created.includes(p) || ours.some((bin) => pathOf(bin) === p));
261
+ if (r.pathAdded) created.push("PATH:" + dir); // recorded only when this install wrote the PATH entry
262
+ return { created, pathAdded: r.pathAdded, kept, dir };
263
+ }
264
+
265
+ // An occupied link path is OURS when a POSIX symlink resolves to the target this install would create, or a
266
+ // Windows .cmd shim names that target. Anything else (a user's own file or symlink) is not ours.
267
+ function isOurLink(p, target, platform) {
268
+ try {
269
+ if (platform === "win32") return readFileSync(p, "utf8").includes(`"${target}"`);
270
+ return lstatSync(p).isSymbolicLink() && resolve(dirname(p), readlinkSync(p)) === resolve(target);
271
+ } catch {
272
+ return false;
273
+ }
274
+ }
275
+
276
+ // removeRecorded(created): the uninstall side of installBothLinks; removes only what that install recorded.
277
+ export function removeRecorded(created) {
278
+ return removeCreatedLinks(Array.isArray(created) ? created : []);
279
+ }
package/lib/hook-wire.js CHANGED
@@ -20,7 +20,10 @@ import { pickPython, stubWarning } from "./python-pick.js";
20
20
  // for harness hooks and deleted or rewrote them. Now a harness hook is a wkharness*.sh/.ps1/.cmd file name standing at a
21
21
  // path or word start, a file under a .wk-harness folder, the kih .claude/hooks scripts, or the two named sync hooks.
22
22
  const HARNESS_HOOK = /(^|[\s"'\\/])wkharness(-[\w-]+)?\.(sh|ps1|cmd)\b|\.wk-harness[\\/]|[\\/]\.claude[\\/]hooks[\\/](user-prompt-submit|session-start)\.sh\b|(^|[\s"'\\/])(wk-global-md-sync|wk-session-pull|wkappbot-shim)(\.[a-z0-9]+)?\b/;
23
- const ARMS = [["PreToolUse", "pre"], ["Stop", "stop"], ["UserPromptSubmit", "prompt"], ["SessionStart", "session"]];
23
+ // The history arms (owner 2026-11-12) push the reflex history like the repo hooks: PostToolUse only for the cross-session
24
+ // tools (a matcher, so no spawn per tool call), PreCompact, SessionEnd and StopFailure (a rate limit).
25
+ const ARMS = [["PreToolUse", "pre"], ["Stop", "stop"], ["UserPromptSubmit", "prompt"], ["SessionStart", "session"],
26
+ ["PostToolUse", "post", "mcp__claude-code-remote__send_message|ReadNotifications"], ["PreCompact", "precompact"], ["SessionEnd", "sessionend"], ["StopFailure", "stopfail"]];
24
27
  const fwd = (p) => String(p || "").replace(/\\/g, "/").replace(/\/+$/, "");
25
28
 
26
29
  export function readSettings(path) {
@@ -80,10 +83,10 @@ export function wireSettings(path, lite) {
80
83
  }
81
84
  if (keep.length) out[ev] = keep;
82
85
  }
83
- for (const [ev, arm] of ARMS) {
86
+ for (const [ev, arm, matcher] of ARMS) {
84
87
  const c = '"' + lite + '" ' + arm;
85
88
  if (!(out[ev] || []).some((g) => (g.hooks || []).some((h) => h.command === c)))
86
- (out[ev] = out[ev] || []).push({ hooks: [{ type: "command", command: c, timeout: 10 }] });
89
+ (out[ev] = out[ev] || []).push({ ...(matcher ? { matcher } : {}), hooks: [{ type: "command", command: c, timeout: 10 }] });
87
90
  }
88
91
  d.hooks = out;
89
92
  mkdirSync(dirname(path), { recursive: true });
@@ -0,0 +1,23 @@
1
+ // stale-wkskill.js -- uninstall also removes the counterfeit wkskill command an OLDER harness install left in the link dir
2
+ // (owner 2026-11-11: the fake wkskill is deleted; spokesman 2026-11-12: SDK e2e "after: local-bin=wkskill", RESULT 20/21 on every leg).
3
+ // A link or .cmd twin is removed ONLY while it still points into a harness (its target or body names wkharness or wk-harness);
4
+ // a wkskill file or link of the user's own stays. Spec: wkappbot skill read harness-installer-owns-all-setup
5
+ import { lstatSync, readFileSync, readlinkSync, rmSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ const isHarness = (s) => /wkharness|wk-harness/.test(String(s || ""));
9
+
10
+ // -> the paths removed; log gets one line per candidate
11
+ export function removeStaleWkskill(dir, log = () => {}) {
12
+ const removed = [];
13
+ for (const n of ["wkskill", "wkskill.cmd"]) {
14
+ const p = join(dir, n);
15
+ let st;
16
+ try { st = lstatSync(p); } catch { continue; }
17
+ let body = "";
18
+ try { body = st.isSymbolicLink() ? readlinkSync(p) : readFileSync(p, "utf8"); } catch { /* unreadable: kept */ }
19
+ if (isHarness(body)) { rmSync(p, { force: true }); removed.push(p); log("shim: removed " + p + " (the counterfeit wkskill command an older harness install linked)"); }
20
+ else log("shim: kept " + p + " (not a harness link)");
21
+ }
22
+ return removed;
23
+ }
package/lib/wk-hook.js CHANGED
@@ -301,16 +301,16 @@ export function isFastRead(tool, input) {
301
301
  }
302
302
 
303
303
  // SHIM PATH (SDK ROOT C 2026-11-06, run 42289814533): OpenCode's bash tool runs /bin/zsh on macOS, whose default PATH
304
- // has no ~/.local/bin, so "zsh:1: command not found: wkskill" and a Mac user could never pay the intent lock. A bash call
305
- // that names wkskill or wk-verify gets the shim dir (WK_LINK_DIR, else ~/.local/bin, where harness install.sh links
304
+ // has no ~/.local/bin, so "zsh:1: command not found: wkappbot" and a Mac user could never pay the intent lock. A bash call
305
+ // that names wkappbot or wk-verify gets the shim dir (WK_LINK_DIR, else ~/.local/bin, where harness install.sh links
306
306
  // them) prepended to PATH for that one command; nothing else changes. Returns the command to run (unchanged when the
307
307
  // shim is absent or the command names neither shim).
308
308
  export function withShimPath(tool, args, linkDir) {
309
309
  if (claudeToolName(tool) !== "Bash" || !args || typeof args.command !== "string") return args && args.command;
310
310
  const c = args.command;
311
- if (!/(^|[\s;&|(`])(wkskill|wk-verify)(\s|$)/.test(c)) return c;
311
+ if (!/(^|[\s;&|(`])(wkappbot|wk-verify)(\s|$)/.test(c)) return c;
312
312
  const dir = linkDir || process.env.WK_LINK_DIR || join(homedir(), ".local", "bin");
313
- if (!existsSync(join(dir, "wkskill")) && !existsSync(join(dir, "wk-verify"))) return c;
313
+ if (!existsSync(join(dir, "wkappbot")) && !existsSync(join(dir, "wk-verify"))) return c;
314
314
  if (c.startsWith("PATH=")) return c;
315
315
  return "PATH=" + JSON.stringify(dir) + ':"$PATH"; export PATH; ' + c;
316
316
  }
@@ -323,10 +323,10 @@ export const RULES_MARK = "[wisdom-kernel rules]";
323
323
  export const RULES = [
324
324
  RULES_MARK + " You run under the Wisdom Kernel harness (wkappbot-code). On every turn:",
325
325
  "1. One command per shell call; never chain a write with anything else.",
326
- "2. A refusal names a skill: run wkskill read <id> (or wkappbot skill read <id>) and apply its remedy; never route around it.",
326
+ "2. A refusal names a skill: run wkappbot skill read <id> and apply its remedy; never route around it.",
327
327
  "3. Never use git --no-verify or git push --force, never change core.hooksPath, never rm -r a home directory, / or the project source.",
328
328
  "4. Never edit or delete files under .git.",
329
- "5. After a new user prompt, run wkskill search <three or more words> before the first write; read-only tools stay open.",
329
+ "5. After a new user prompt, run wkappbot skill search <three or more words> before the first write; read-only tools stay open.",
330
330
  ].join("\n");
331
331
 
332
332
  // The top step lines of the on-load skill, fetched ONCE per process (one OpenCode server = its sessions) in the
@@ -337,7 +337,7 @@ export function onLoadLines(kihDir) {
337
337
  if (_onload) return _onload;
338
338
  _onload = new Promise((res) => {
339
339
  const k = kihDir || resolveKihDir();
340
- const cands = [process.env.WK_APPBOT_SKILL_CMD, k && join(k, "bin", "wkskill"), "wkskill"].filter(Boolean);
340
+ const cands = [process.env.WK_APPBOT_SKILL_CMD, k && join(k, "bin", "wkappbot"), "wkappbot"].filter(Boolean);
341
341
  let out = "", done = false;
342
342
  const finish = () => {
343
343
  if (done) return; done = true;
@@ -347,9 +347,9 @@ export function onLoadLines(kihDir) {
347
347
  res(lines.slice(0, 3).map((l) => l.slice(0, 240)));
348
348
  };
349
349
  try {
350
- const target = cands.find((p) => p === "wkskill" || existsSync(p)), opt = { stdio: ["ignore", "pipe", "ignore"] };
351
- // Windows: a shell-less spawn cannot run a .cmd shim, the bare wkskill name (PATHEXT) or an extensionless sh script
352
- const c = process.platform !== "win32" ? spawn(target, ["read", "on-load"], opt) : /\.exe$/i.test(target) ? spawn(target, ["read", "on-load"], opt) : /\.(cmd|bat)$/i.test(target) || target === "wkskill" ? spawn(target, ["read", "on-load"], { ...opt, shell: true }) : spawn("sh", [target.replace(/\\/g, "/"), "read", "on-load"], opt);
350
+ const target = cands.find((p) => p === "wkappbot" || existsSync(p)), opt = { stdio: ["ignore", "pipe", "ignore"] };
351
+ // Windows: a shell-less spawn cannot run a .cmd shim, the bare wkappbot name (PATHEXT) or an extensionless sh script
352
+ const c = process.platform !== "win32" ? spawn(target, ["skill", "read", "on-load"], opt) : /\.exe$/i.test(target) ? spawn(target, ["skill", "read", "on-load"], opt) : /\.(cmd|bat)$/i.test(target) || target === "wkappbot" ? spawn(target, ["skill", "read", "on-load"], { ...opt, shell: true }) : spawn("sh", [target.replace(/\\/g, "/"), "skill", "read", "on-load"], opt);
353
353
  c.stdout.on("data", (d) => { out += d; if (out.length > 65536) c.kill("SIGKILL"); });
354
354
  c.on("error", finish); c.on("close", finish);
355
355
  setTimeout(() => { try { c.kill("SIGKILL"); } catch {} finish(); }, 8000).unref();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wkappbot-code",
3
- "version": "0.3.22",
3
+ "version": "0.3.25",
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
  "scripts": {
@@ -51,7 +51,7 @@ export const WisdomKernel = async (ctx = {}, options = {}) => {
51
51
  (tail ? ": " + tail : "") + ". Fix the harness (wkappbot-code doctor) and retry.");
52
52
  }
53
53
  }
54
- // macOS /bin/zsh has no ~/.local/bin on PATH: a wkskill call gets the shim dir for that one command (lib/wk-hook.js).
54
+ // macOS /bin/zsh has no ~/.local/bin on PATH: a wkappbot call gets the shim dir for that one command (lib/wk-hook.js).
55
55
  try { if (args && typeof args.command === "string") args.command = H.withShimPath(inp.tool, args); } catch {}
56
56
  },
57
57
  "tool.execute.after": async (input, output) => {
@@ -5,7 +5,7 @@ change it, install never touches it again, and `wkappbot-code uninstall` leaves
5
5
 
6
6
  ## How to work
7
7
  - Reply in the language the user writes in. Keep replies short and concrete; lead with the result.
8
- - Before real work, find the skill that governs it: `wkskill search <three or more words>`, then `wkskill read <id>`,
8
+ - Before real work, find the skill that governs it: `wkappbot skill search <three or more words>`, then `wkappbot skill read <id>`,
9
9
  and follow the road it gives. Search again for any term you are not sure of, instead of guessing.
10
10
  - Run, do not guess: prove a change by running its test or command, and report the real output, failures included.
11
11
  - One command per tool call. Change only what the task needs, and back up a config file before you edit it.