wkappbot-code 0.3.32 → 0.3.35

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.
@@ -52,11 +52,15 @@ async function runWorker() {
52
52
  const env = withPathFirst({ ...process.env, WKAPPBOT_CODE_NO_UPDATE_CHECK: "1", WKAPPBOT_CODE_SETUP_WORKER: "1" }, globalBinDir(process.env));
53
53
  stamp("worker start: node " + args.join(" "));
54
54
  const out = openSync(LOG, "a");
55
- const installRc = await new Promise((res) => {
55
+ const runInstall = () => new Promise((res) => {
56
56
  const c = spawn(process.execPath, args, { stdio: ["ignore", out, out], env });
57
57
  c.on("error", () => res(-1));
58
58
  c.on("close", (code) => res(code ?? -1));
59
59
  });
60
+ let installRc = await runInstall();
61
+ // a permanent file lock makes install exit 75 (lib/lockwait.js EXIT_LOCKED): retry three times, then say what to do and go on (the worker exits 0)
62
+ for (let i = 1; i <= 3 && installRc === 75; i++) { stamp("install rc 75 (a file is locked); retry " + i + " of 3"); installRc = await runInstall(); }
63
+ if (installRc === 75) stamp("install is still locked: close the program holding the file, then run wkappbot-code install again (run wkappbot-code install again)");
60
64
  stamp("install rc " + installRc + "; doctor");
61
65
  const dr = spawnSync(process.execPath, [CLI, "doctor"], { encoding: "utf8", env, timeout: 300000 });
62
66
  const dtext = (dr.stdout || "") + (dr.stderr || "");
package/bin/prepack.js CHANGED
@@ -12,6 +12,7 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFile
12
12
  import { tmpdir } from "node:os";
13
13
  import { dirname, join, resolve } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
+ import { PLUGIN_LIBS } from "../lib/plugin-files.js";
15
16
 
16
17
  const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17
18
  const KIH = resolve(PKG_DIR, "..");
@@ -20,6 +21,15 @@ const PKG = JSON.parse(readFileSync(join(PKG_DIR, "package.json"), "utf8"));
20
21
  const die = (m) => { console.error("[wkappbot-code prepack] " + m); process.exit(1); };
21
22
  const sh = (p) => process.platform === "win32" ? p.replace(/\\/g, "/") : p;
22
23
 
24
+ // Every relative import of a file install copies beside the plugin must be copied too (0.3.34: wk-hook.js imported ./rmguard.js, install copied wk-hook.js alone,
25
+ // ERR_MODULE_NOT_FOUND, every guard dead). Fail BEFORE a tarball exists. Spec: wkappbot skill read agent-harness-landscape-2026-howto
26
+ {
27
+ const flat = new Set(["wisdom-kernel.js", ...PLUGIN_LIBS]);
28
+ for (const [d, f] of [["plugin", "wisdom-kernel.js"], ...PLUGIN_LIBS.map((n) => ["lib", n])]) {
29
+ const src = readFileSync(join(PKG_DIR, d, f), "utf8");
30
+ for (const m of src.matchAll(/(?:\bfrom\s+|\bimport\s*\(\s*)["'](\.\/[^"']+)["']/g)) if (!flat.has(m[1].slice(2))) die(d + "/" + f + " imports " + m[1] + ", which install does not copy beside the plugin (add it to lib/plugin-files.js)");
31
+ }
32
+ }
23
33
  let stage = process.env.WKAPPBOT_CODE_HARNESS_STAGE || "";
24
34
  let tmp = "";
25
35
  const copy = join(KIH, "tools", "wk-harness-min-copy.sh");
@@ -37,14 +37,15 @@ import { createHash } from "node:crypto";
37
37
  import {
38
38
  copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync,
39
39
  realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, utimesSync, writeFileSync,
40
- } from "node:fs";
40
+ } from "../lib/fslocked.js";
41
41
  import { homedir, tmpdir } from "node:os";
42
42
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
43
43
  import { fileURLToPath, pathToFileURL } from "node:url";
44
- import { quietCheck, runUpdate, updateRow } from "../lib/update.js"; import { rmTreeHard, waitForArchive, holderReport } from "../lib/rmtree.js"; import { waitForHolders } from "../lib/holders.js"; import { plainFileUrl } from "../lib/fileurl.js"; import { removeStaleWkskill } from "../lib/stale-wkskill.js"; import { checkPath, installBothLinks, removeCreatedLinks } from "../lib/doctor-path.js";
44
+ import { quietCheck, runUpdate, updateRow } from "../lib/update.js"; import { rmTreeHard, waitForArchive, holderReport } from "../lib/rmtree.js"; import { waitForHolders, stopHolders } from "../lib/holders.js"; import { plainFileUrl } from "../lib/fileurl.js"; import { removeStaleWkskill } from "../lib/stale-wkskill.js"; import { checkPath, installBothLinks, removeCreatedLinks } from "../lib/doctor-path.js";
45
45
  import { applySets, askEach, buildConfig, configRow, detect, lineDiff, printDetected } from "../lib/config.js";
46
46
  import { aiCliRows, installAiClis, removeAiClis } from "../lib/ai-clis.js";
47
47
  import { providerCli, providerRow } from "../lib/providers.js";
48
+ import { EXIT_LOCKED, isLockError } from "../lib/lockwait.js"; import { PLUGIN_LIBS } from "../lib/plugin-files.js"; import { installLockMd, removeLockMd } from "../lib/lock-md.js";
48
49
  import { hooksRow, wireClaudeHooks } from "../lib/hook-wire.js";
49
50
  import { installStarterMd, removeStarterMd } from "../lib/starter-md.js";
50
51
  import { addAllows, removeAllows } from "../lib/allow-compact.js";
@@ -361,7 +362,7 @@ function installPlugin(o, kih, kihSource) {
361
362
  const dest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
362
363
  copyFileSync(join(PKG_DIR, "plugin", "wisdom-kernel.js"), dest);
363
364
  // The plugin file exports ONLY the plugin (OpenCode calls every named export as one); its helpers ride beside it.
364
- copyFileSync(join(PKG_DIR, "lib", "wk-hook.js"), join(cfg, "wkappbot-code", "wk-hook.js"));
365
+ for (const f of PLUGIN_LIBS) copyFileSync(join(PKG_DIR, "lib", f), join(cfg, "wkappbot-code", f));
365
366
  writeFileSync(join(cfg, "wkappbot-code", "package.json"), '{ "type": "module" }\n');
366
367
  const url = pathToFileURL(dest).href;
367
368
  const tmpl = JSON.parse(readFileSync(join(PKG_DIR, "opencode.json"), "utf8"));
@@ -437,6 +438,7 @@ function install(o) {
437
438
  // 0.3.13 (owner 2026-11-09: Git Bash is a required tool too): bash, sh, curl, tar and git first; on Windows a Git that
438
439
  // is not on PATH is used from its folder, and a missing one is installed with winget (lib/prereq.js)
439
440
  // 0.3.32 (2026-11-12, kih ROOT 3): the uninstall also waits for every process whose command line names the harness dir (lib/holders.js) and prints the holders as one line the SDK e2e cause filter keeps.
441
+ // 0.3.33 (2026-11-12, kih ROOT 3): the holder wait matches the Git Bash spelling of the harness dir (/d/a/...), and after 8 s the shell-class children still naming it (bash, sh, flock, setsid, nohup, python; never wkappbot, wkchat, node) are ended before the removal (lib/holders.js stopHolders).
440
442
  // 0.3.31 (2026-11-12, spokesman order): provider add on a clean machine creates the OpenCode config folder (lib/providers.js mkdirSync) instead of dying with ENOENT, and the opencode.json backup of provider add and remove goes under <config dir>/wkappbot-code/backups, which uninstall removes with the rest of its state, so no opencode.json.bak-<stamp> stays in the user config folder.
441
443
  // 0.3.30 (2026-11-12, spokesman order after gate runs 43246725941 and 43247534046): 0.3.29 left the EPERM on .claude/hooks/wkharness-run.sh in one of two win-x64-nopy runs. The detached shadow run (wkjs_shadow) and the skill-draft child of the hook script were still started inside the harness copy with the script fd open; both now start from cwd / with the fd closed (wk_detach_prep), like the archive child. A failing tree removal on Windows now prints lib/rmtree.js holderReport: every Win32_Process row naming the harness dir plus every shell, node and python process with pid, ppid and command line, so a run that still hits EPERM names the real holder.
442
444
  // 0.3.29 (2026-11-12, spokesman order after SDK gate run 43227049219): uninstall waits (bounded 15 s) for a RECORDED archive pid before it removes the harness copy (lib/rmtree.js waitForArchive; the detached archive child starts from cwd / with the script fd closed), so no EPERM on .claude/hooks/wkharness-run.sh; install records st.opencodeBackups and uninstall leaves no opencode.json.bak-<stamp> of its own.
@@ -480,7 +482,8 @@ function install(o) {
480
482
  // the compressed allow list (lib/allow-compact.js): five harness-own prefixes, never the wide rows learned on the owner's seats
481
483
  st.allows = (o.harness && o.allows) ? addAllows(SETTINGS(), prev.allows || {}, log) : (prev.allows || { skipped: true });
482
484
  // the standard global rules for a fresh machine (lib/starter-md.js): only where the user has no ~/.claude/CLAUDE.md
483
- st.starterMd = o.harness ? installStarterMd({ claudeDir: dirname(SETTINGS()), pkgDir: PKG_DIR, prev: prev.starterMd, log }) : { skipped: true }; // Global pre-push dispatcher (owner 2026-11-06): the push-owner gate runs in every repo, then the repo's own hooks.
485
+ st.starterMd = o.harness ? installStarterMd({ claudeDir: dirname(SETTINGS()), pkgDir: PKG_DIR, prev: prev.starterMd, log }) : { skipped: true };
486
+ st.lockMd = o.harness ? installLockMd({ claudeDir: dirname(SETTINGS()), pkgDir: PKG_DIR, prev: prev.lockMd, log }) : { skipped: true }; // the study-lock rules of the installed sources, in ~/.claude/REFLEX.md (lib/lock-md.js) // Global pre-push dispatcher (owner 2026-11-06): the push-owner gate runs in every repo, then the repo's own hooks.
484
487
  // A user's own absolute global core.hooksPath is kept (the script WARNs). Skipped with --no-harness.
485
488
  st.pushGate = { skipped: true };
486
489
  if (o.harness && kih && existsSync(join(kih, "tools", "wk-global-hooks.sh"))) {
@@ -575,7 +578,7 @@ function uninstall(o) {
575
578
  }
576
579
  // 3b. the ~/.local/bin/wkappbot link: removed ONLY when it points at the core this install placed (the core file stays)
577
580
  const core = (st.bin && st.bin.dest) || binDest(o);
578
- removeStarterMd(st.starterMd, log); removeStaleWkskill(HARNESS_LINKS(), log); // an older harness install's counterfeit wkskill link (SDK e2e: local-bin=wkskill)
581
+ removeStarterMd(st.starterMd, log); removeLockMd(st.lockMd, log); removeStaleWkskill(HARNESS_LINKS(), log); // an older harness install's counterfeit wkskill link (SDK e2e: local-bin=wkskill)
579
582
  let linkRemoved = "";
580
583
  for (const p of new Set(shimCandidates().concat([join(homedir(), ".local", "bin", "wkappbot")]))) {
581
584
  if (!isSymlink(p) || (st.shims || []).includes(p)) continue;
@@ -609,7 +612,7 @@ function uninstall(o) {
609
612
  };
610
613
  // (B) the detached per-turn archive child may still hold .claude/hooks/wkharness-run.sh (Windows EPERM): wait for its RECORDED pid first.
611
614
  if (existsSync(HARNESS_DIR())) { const w = waitForArchive(HARNESS_DIR()); if (w.waited) log("harness: waited " + w.waited + " ms for the archive child" + (w.alive.length ? " (pid " + w.alive.join(",") + " still running)" : "")); }
612
- if (st.harness && st.harness.mode === "web" && pr.harnessDirExisted !== true && existsSync(HARNESS_DIR())) { const h = waitForHolders(HARNESS_DIR()); if (h.waited) log("harness: waited " + h.waited + " ms for process(es) naming the harness dir" + (h.alive.length ? " (pid " + h.alive.join(",") + " still running)" : "")); }
615
+ if (st.harness && st.harness.mode === "web" && pr.harnessDirExisted !== true && existsSync(HARNESS_DIR())) { const h = waitForHolders(HARNESS_DIR()); if (h.waited) log("harness: waited " + h.waited + " ms for process(es) naming the harness dir" + (h.alive.length ? " (pid " + h.alive.join(",") + " still running)" : "")); if (h.alive.length) { const s = stopHolders(HARNESS_DIR()); log("harness: stopped " + s.stopped.length + " shell process(es) still naming the harness dir: " + s.stopped.map((r) => "pid=" + r.pid + " " + r.name + " " + String(r.cmd).slice(0, 200)).join(" ;; ") + (s.alive.length ? " (pid " + s.alive.join(",") + " still running)" : "")); } }
613
616
  if (st.harness && st.harness.mode === "web" && pr.harnessDirExisted !== true && existsSync(HARNESS_DIR())) rmTree(HARNESS_DIR(), "harness");
614
617
  if (pr.hooksDirExisted !== true && existsSync(HOOKS_DIR())) rmTree(HOOKS_DIR(), "push-gate");
615
618
  const cfgFile = join(configDir(o), "opencode.json");
@@ -659,7 +662,7 @@ function doctor(o) {
659
662
  const v = opencodeVersion();
660
663
  add("opencode", v === OPENCODE_VERSION, v ? v + (v === OPENCODE_VERSION ? "" : " (pinned " + OPENCODE_VERSION + ")") : "not on PATH", false);
661
664
  const pdest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
662
- add("plugin", existsSync(pdest) && existsSync(join(cfg, "wkappbot-code", "wk-hook.js")), pdest + " + wk-hook.js", true);
665
+ add("plugin", existsSync(pdest) && PLUGIN_LIBS.every((f) => existsSync(join(cfg, "wkappbot-code", f))), pdest + " + " + PLUGIN_LIBS.join(" + "), true);
663
666
  let entry = null;
664
667
  try { entry = (JSON.parse(readFileSync(join(cfg, "opencode.json"), "utf8")).plugin || []).find((p) => String(Array.isArray(p) ? p[0] : p).includes("wisdom-kernel")); } catch {}
665
668
  add("config", !!entry, join(cfg, "opencode.json"), true);
@@ -745,7 +748,7 @@ if (process.argv.length <= 2 && o.cmd === "help") { const rc = launchOpenCode();
745
748
  switch (o.cmd) {
746
749
  case "--version": case "-v": case "version":
747
750
  console.log("wkappbot-code " + PKG.version + " (opencode-ai " + OPENCODE_VERSION + ")"); process.exit(0);
748
- case "install": process.exit(install(o));
751
+ case "install": { let rc; try { rc = install(o); } catch (e) { if (e && (e.code === "ELOCKBUDGET" || isLockError(e))) { console.error(e.message + "\nwkappbot-code: a file is held by another program; close it and run wkappbot-code install again"); rc = EXIT_LOCKED; } else throw e; } process.exit(rc); }
749
752
  case "uninstall": process.exit(uninstall(o));
750
753
  case "doctor": process.exitCode = doctor(o); break;
751
754
  case "update": process.exit(upd());
@@ -4,7 +4,7 @@
4
4
  // are far too wide for a stranger's machine. A fresh install gets ONLY these five read-or-own-store prefixes. Every call
5
5
  // still meets the harness PreToolUse hook first, and a hook refusal beats an allow rule, so an entry here skips the
6
6
  // permission prompt, never the harness. Uninstall removes exactly the entries this install added.
7
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { existsSync, mkdirSync, writeFileSync } from "./fslocked.js";
8
8
  import { dirname } from "node:path";
9
9
  import { readSettings } from "./hook-wire.js";
10
10
 
@@ -13,6 +13,9 @@ export const COMPACT_ALLOWS = [
13
13
  "Bash(wkappbot file read *)", // read-only file view
14
14
  "Bash(wk-verify)", // live hook check, no arguments
15
15
  "Bash(wkappbot-code doctor)", // read-only health table
16
+ "Bash(wkappbot-code install)", // what postinstall and the user run to set up (owner 2026-11-13: refused or prompted every time)
17
+ "Bash(wkappbot-code install *)", // the same with flags
18
+ "Bash(node */wkappbot-code/bin/postinstall.js*)", // the postinstall entry npm runs
16
19
  ];
17
20
 
18
21
  // prev: the state recorded by the last install ({ added, madeAllow, madePerms }); a reinstall keeps owning what it added.
@@ -4,7 +4,7 @@
4
4
  // Windows: a new cmd.exe whose PATH is machine PATH + user PATH read from the registry.
5
5
  // PASS only when each bare-name run prints "wkappbot v". Any other outcome is FAIL for that name.
6
6
  import { execFileSync } from "node:child_process";
7
- import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from "./fslocked.js";
8
8
  import { dirname, join, resolve } from "node:path";
9
9
 
10
10
  const OK_MARK = "wkappbot v";
@@ -0,0 +1,18 @@
1
+ // fslocked.js -- node:fs for the install road: every name of node:fs, with the writers wrapped in lockRetry, so a file another
2
+ // process holds (EBUSY, EPERM, EACCES, ETXTBSY) is waited for, then reported as ELOCKBUDGET, instead of failing at once.
3
+ // The modules an install runs import from here instead of "node:fs" (owner 2026-11-13). Spec: wkappbot skill read harness-installer-owns-all-setup
4
+ import * as real from "node:fs";
5
+ import { lockRetry } from "./lockwait.js";
6
+
7
+ export * from "node:fs";
8
+
9
+ const wrap = (name, labelAt = 0) => (...a) => lockRetry(() => real[name](...a), { label: String(a[labelAt]) });
10
+ export const writeFileSync = wrap("writeFileSync");
11
+ export const appendFileSync = wrap("appendFileSync");
12
+ export const mkdirSync = wrap("mkdirSync");
13
+ export const copyFileSync = wrap("copyFileSync", 1);
14
+ export const renameSync = wrap("renameSync", 1);
15
+ export const symlinkSync = wrap("symlinkSync", 1);
16
+ export const unlinkSync = wrap("unlinkSync");
17
+ export const rmSync = wrap("rmSync");
18
+ export const chmodSync = wrap("chmodSync");
package/lib/holders.js CHANGED
@@ -1,32 +1,61 @@
1
- // holders.js -- wait for the processes that still hold the harness copy before an uninstall removes it (kih ROOT 3 2026-11-12, 0.3.32).
2
- // The 0.3.30 holder report named a live bash.exe at the failing Windows uninstall (SDK gate runs 43264303070 and 43269021533). lib/rmtree.js waitForArchive only waits
3
- // for the RECORDED archive pid, so any other detached hook child (the shadow run, the skill-draft child, a flock wrapper) still held .claude/hooks/wkharness-run.sh.
4
- // waitForHolders waits (bounded) for every process whose command line names the harness dir, never the uninstall's own pid or parent pid. exec and sleep are injectable.
1
+ // holders.js -- wait for, then stop, the processes that still hold the harness copy before an uninstall removes it (kih ROOT 3 2026-11-12).
2
+ // 0.3.32: the SDK win-x64-nopy gate (push run 43284028291, job 131412874028) still failed step 20 with EPERM on .claude/hooks/wkharness-run.sh while a 20 s holder wait ran,
3
+ // and the runner cleanup then killed 8 orphan bash processes. Two defects, both fixed in 0.3.33:
4
+ // 1. The command line of a Git Bash child names the harness dir in MSYS form (/d/a/_temp/home/.wk-harness/tools/wk-hippo-archive.sh), while the uninstall holds the Windows form
5
+ // (D:\a\_temp\home\.wk-harness). Matching only the Windows form never saw them (the live report listed bash.exe as a plain "shell" row, never HOLDS-DIR). dirForms() matches every form.
6
+ // 2. A detached archive child can wait up to 300 s on its flock, longer than any sane wait. After the bounded wait, stopHolders() ends the shell-class children of the harness
7
+ // (bash, sh, flock, setsid, nohup, python) whose command line names the dir; it never touches wkappbot, wkchat or wka11y processes, the uninstall's own pid or parent pid, or node.
8
+ // exec, sleep and kill are injectable so the selftest never queries or kills a real process.
5
9
  import { spawnSync } from "node:child_process";
6
10
 
7
11
  const sleepMs = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* no wait available */ } };
8
12
  const procExec = (cmd, args) => { const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 20000 }); return String(r.stdout || r.stderr || ""); };
13
+ const STOPPABLE = /^(?:bash|sh|flock|setsid|nohup|python3?)(?:\.exe)?$/i;
9
14
 
10
- export function listHolders(dir, opts = {}) {
15
+ // Every spelling of one directory in a process command line: Windows (c:/x), Git Bash (/c/x), Cygwin (/cygdrive/c/x), WSL (/mnt/c/x). All lower case, slashes forward.
16
+ export function dirForms(dir) {
17
+ const w = String(dir).replace(/\\/g, "/").toLowerCase().replace(/\/+$/, "");
18
+ const forms = [w], m = /^([a-z]):\/(.*)$/.exec(w);
19
+ if (m) for (const pre of ["/", "/cygdrive/", "/mnt/"]) forms.push(pre + m[1] + "/" + m[2]);
20
+ return forms;
21
+ }
22
+
23
+ // Rows { pid, name, cmd } of every process whose command line names the dir in any form, never this process or its parent.
24
+ export function listHolderRows(dir, opts = {}) {
11
25
  const exec = opts.exec || procExec, isWin = opts.win ?? (process.platform === "win32");
12
- const needle = String(dir).replace(/\\/g, "/").toLowerCase(), own = new Set([process.pid, process.ppid]);
26
+ const forms = dirForms(dir), own = new Set([process.pid, process.ppid]);
13
27
  let table = "";
14
28
  try {
15
29
  table = isWin ? exec("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "Get-CimInstance Win32_Process | ForEach-Object { \"$($_.ProcessId)`t$($_.ParentProcessId)`t$($_.Name)`t$([string]$_.CommandLine)\" }"])
16
30
  : exec("ps", ["-eo", "pid=,ppid=,comm=,args="]).split(/\r?\n/).map((l) => l.trim().replace(/\s+/g, "\t")).join("\n");
17
31
  } catch { return []; }
18
- const pids = [];
32
+ const rows = [];
19
33
  for (const line of String(table).split(/\r?\n/)) {
20
34
  const f = line.split("\t"), pid = parseInt(f[0], 10);
21
35
  if (f.length < 4 || !(pid > 0) || own.has(pid)) continue;
22
- if (f.slice(3).join(" ").replace(/\\/g, "/").toLowerCase().includes(needle)) pids.push(pid);
36
+ const cmd = f.slice(3).join(" "), hay = cmd.replace(/\\/g, "/").toLowerCase();
37
+ if (forms.some((x) => hay.includes(x))) rows.push({ pid, name: String(f[2]).trim(), cmd });
23
38
  }
24
- return pids;
39
+ return rows;
25
40
  }
26
41
 
42
+ export function listHolders(dir, opts = {}) { return listHolderRows(dir, opts).map((r) => r.pid); }
43
+
27
44
  export function waitForHolders(dir, opts = {}) {
28
- const maxMs = opts.maxMs ?? 20000, gap = opts.gap || 500, nap = opts.sleep || sleepMs;
45
+ const maxMs = opts.maxMs ?? 8000, gap = opts.gap || 500, nap = opts.sleep || sleepMs;
29
46
  let alive = listHolders(dir, opts), waited = 0;
30
47
  while (alive.length && waited < maxMs) { nap(gap); waited += gap; alive = listHolders(dir, opts); }
31
48
  return { waited, alive };
32
49
  }
50
+
51
+ // End the shell-class holders still alive, then report who remains. { stopped: [{pid,name,cmd}], alive: [pid...] }
52
+ export function stopHolders(dir, opts = {}) {
53
+ const kill = opts.kill || ((pid) => process.kill(pid, "SIGKILL")), nap = opts.sleep || sleepMs;
54
+ const stopped = [];
55
+ for (const r of listHolderRows(dir, opts)) {
56
+ if (!STOPPABLE.test(r.name)) continue;
57
+ try { kill(r.pid); stopped.push(r); } catch { /* already gone, or not ours to end */ }
58
+ }
59
+ if (stopped.length) nap(opts.settle ?? 1500);
60
+ return { stopped, alive: listHolders(dir, opts) };
61
+ }
package/lib/hook-wire.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // stays verbatim, and every lite arm is present once. The web installer calls it through the CLI form below when it has
11
11
  // node but no python3: node hook-wire.js wire SETTINGS LITE
12
12
  import { spawnSync } from "node:child_process";
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "./fslocked.js";
14
14
  import { dirname, join, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { pickPython, stubWarning } from "./python-pick.js";
package/lib/lock-md.js ADDED
@@ -0,0 +1,56 @@
1
+ // lock-md.js -- the study-lock rules for the installed harness sources, laid where the installed hooks read them (owner 2026-11-13
2
+ // via the spokesman: "the md files that protect the harness sources are not installed, not in the global md either").
3
+ // The JS study lock reads ~/.claude/CLAUDE.md and ~/.claude/REFLEX.md besides the repo's own; this install writes the rules of
4
+ // templates/harness-locks.md into ~/.claude/REFLEX.md between two markers. A REFLEX.md the user already has keeps every byte
5
+ // of its own text: the block is appended once and refreshed in place. Uninstall removes exactly the block, and the file when
6
+ // the install made it and nothing else is left. Spec: wkappbot skill read harness-installer-owns-all-setup
7
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "./fslocked.js";
8
+ import { dirname, join } from "node:path";
9
+
10
+ export const BEGIN = "<!-- wk-harness-locks:begin (managed by wkappbot-code install; edit outside this block) -->";
11
+ export const END = "<!-- wk-harness-locks:end -->";
12
+
13
+ const block = (tpl) => BEGIN + "\n" + tpl.replace(/\s+$/, "") + "\n" + END + "\n";
14
+ const cut = (text) => {
15
+ const a = text.indexOf(BEGIN), b = text.indexOf(END);
16
+ return a >= 0 && b > a ? { before: text.slice(0, a), after: text.slice(b + END.length).replace(/^\r?\n/, ""), has: true } : { before: text, after: "", has: false };
17
+ };
18
+
19
+ // o: { claudeDir, pkgDir, prev ({ path, created }), log } -> { path, created, action }
20
+ export function installLockMd(o) {
21
+ const log = o.log || (() => {});
22
+ const path = join(o.claudeDir, "REFLEX.md");
23
+ const tplPath = join(o.pkgDir, "templates", "harness-locks.md");
24
+ if (!existsSync(tplPath)) { log("lock-md: WARN " + tplPath + " is missing from this package"); return { path, action: "missing-template" }; }
25
+ const mine = block(readFileSync(tplPath, "utf8"));
26
+ if (!existsSync(path)) {
27
+ mkdirSync(dirname(path), { recursive: true });
28
+ writeFileSync(path, mine);
29
+ log("lock-md: wrote the harness study-lock rules to " + path);
30
+ return { path, created: true, action: "written" };
31
+ }
32
+ const cur = readFileSync(path, "utf8"), c = cut(cur);
33
+ const created = !!(o.prev && o.prev.created);
34
+ if (c.has) {
35
+ const next = c.before + mine + c.after;
36
+ if (next === cur) return { path, created, action: "current" };
37
+ writeFileSync(path, next);
38
+ log("lock-md: refreshed the harness study-lock block in " + path);
39
+ return { path, created, action: "refreshed" };
40
+ }
41
+ writeFileSync(path, cur + (cur === "" || cur.endsWith("\n") ? "" : "\n") + (cur === "" ? "" : "\n") + mine);
42
+ log("lock-md: appended the harness study-lock block to your " + path + " (your text is untouched)");
43
+ return { path, created: false, action: "appended" };
44
+ }
45
+
46
+ // Removes the block; a file this install made is deleted when nothing but the block was in it.
47
+ export function removeLockMd(rec, log = () => {}) {
48
+ if (!rec || !rec.path || !existsSync(rec.path)) return false;
49
+ const cur = readFileSync(rec.path, "utf8"), c = cut(cur);
50
+ if (!c.has) return false;
51
+ const rest = (c.before.replace(/\s+$/, "") + (c.before.trim() && c.after.trim() ? "\n\n" : "") + c.after.replace(/^\s+/, ""));
52
+ if (rec.created && !rest.trim()) { rmSync(rec.path, { force: true }); log("lock-md: removed " + rec.path + " (it held only the harness block)"); return true; }
53
+ writeFileSync(rec.path, c.before.trim() ? (c.before.replace(/\s+$/, "") + (c.after.trim() ? "\n\n" + c.after.replace(/^\s+/, "") : "\n")) : c.after.replace(/^\s+/, ""));
54
+ log("lock-md: removed the harness study-lock block from " + rec.path + " (your text kept)");
55
+ return true;
56
+ }
@@ -0,0 +1,36 @@
1
+ // lockwait.js -- run a file operation, and when another process holds the file (EBUSY, EPERM, EACCES, ETXTBSY) wait for it to
2
+ // leave instead of failing at once (owner 2026-11-13 via the spokesman: install and postinstall failed on a locked file every time).
3
+ // lockRetry(fn, { label, holderDir, budgetMs = 30000, firstMs = 250 })
4
+ // - the wait starts at 250 ms and doubles, through holders.js waitForHolders when holderDir names the harness folder
5
+ // - ONE untruncated "LOCKED <label>: <code> ..." line goes to the log, however many retries follow
6
+ // - past the budget the error has code ELOCKBUDGET (the message holds the whole label and the last OS code)
7
+ // - any other error is thrown at once
8
+ // lib/fslocked.js puts this under every file write an install makes; bin/wkappbot-code.js turns ELOCKBUDGET into exit 75.
9
+ // Spec: wkappbot skill read harness-installer-owns-all-setup
10
+ import { waitForHolders } from "./holders.js";
11
+
12
+ export const LOCK_CODES = new Set(["EBUSY", "EPERM", "EACCES", "ETXTBSY"]);
13
+ export const EXIT_LOCKED = 75;
14
+ export const isLockError = (e) => !!e && LOCK_CODES.has(e.code);
15
+ const sleepMs = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* no wait available */ } };
16
+ const envBudget = () => { const n = Number(process.env.WKAPPBOT_CODE_LOCK_BUDGET_MS); return Number.isFinite(n) && n >= 0 && process.env.WKAPPBOT_CODE_LOCK_BUDGET_MS !== undefined ? n : 30000; };
17
+
18
+ export function lockRetry(fn, opts = {}) {
19
+ const budget = opts.budgetMs ?? envBudget(), nap = opts.sleep || sleepMs, log = opts.log || ((s) => console.error(s));
20
+ const label = String(opts.label || "file"), wait = opts.waitForHolders || waitForHolders;
21
+ let delay = opts.firstMs ?? 250, waited = 0, said = false, last = null;
22
+ for (;;) {
23
+ try { return fn(); } catch (e) {
24
+ if (!isLockError(e)) throw e;
25
+ last = e;
26
+ if (waited >= budget) break;
27
+ if (!said) { log(`LOCKED ${label}: ${e.code} -- another process holds it; waiting up to ${Math.round(budget / 1000)} s`); said = true; }
28
+ const step = Math.min(delay, budget - waited);
29
+ if (opts.holderDir) { wait(opts.holderDir, { maxMs: step, gap: Math.min(50, step), sleep: nap }); } else nap(step);
30
+ waited += step; delay *= 2;
31
+ }
32
+ }
33
+ const err = new Error(`LOCKED ${label}: ${last.code} still held after ${waited} ms (budget ${budget} ms)`);
34
+ err.code = "ELOCKBUDGET"; err.cause = last; err.lockedLabel = label;
35
+ throw err;
36
+ }
@@ -0,0 +1,6 @@
1
+ // plugin-files.js -- the helper files that ride BESIDE the OpenCode plugin in <config>/wkappbot-code/ (install copies each of
2
+ // lib/<name> there). wisdom-kernel.js imports ./wk-hook.js, and wk-hook.js imports ./rmguard.js, so EVERY relative import of a
3
+ // copied file must be in this list: a missing one made the whole plugin fail to load (0.3.34, SDK run 43333160662:
4
+ // ERR_MODULE_NOT_FOUND, every guard dead). bin/prepack.js refuses to pack when an import is not listed, and
5
+ // selftest-pluginlayout.mjs imports the plugin from an installed layout. Spec: wkappbot skill read agent-harness-landscape-2026-howto
6
+ export const PLUGIN_LIBS = ["wk-hook.js", "rmguard.js"];
package/lib/providers.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // ("{env:VAR}"); a key or a URL typed where a name belongs is refused and never echoed, and no env var value is read
4
4
  // except by the doctor row, which only checks that a variable is set and prints its name.
5
5
  // Pure build/merge/remove functions first, then the CLI body. Spec: wkappbot skill read agent-harness-landscape-2026
6
- import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "./fslocked.js";
7
7
  import { dirname, join } from "node:path";
8
8
 
9
9
  export const NPM = "@ai-sdk/openai-compatible";
@@ -10,7 +10,7 @@
10
10
  // Spec: wkappbot skill read harness-installer-owns-all-setup
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { createHash } from "node:crypto";
13
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
13
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from "./fslocked.js";
14
14
  import { homedir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { pickPython, stubWarning } from "./python-pick.js";
package/lib/rmguard.js ADDED
@@ -0,0 +1,128 @@
1
+ // wkappbot-code/lib/rm-guard.js -- the rm rule every harness family shares (owner 2026-11-12: rm was not blocked; block
2
+ // wildcards and folders, make each file be named, check each file's protection state). JS home of the rule; the sh twin
3
+ // is tools/wkharness-lite.sh rk_eval/rk_rm, and both run the one case table tools/wkjs/rm-guard.cases.txt
4
+ // (tools/wkjs/rm-guard.selftest.js). Spec: wkappbot skill read harness-rm-guard-howto
5
+ // REFUSED: a recursive delete (rm -r, rmdir -r, find -delete, Remove-Item -Recurse, del /s); a glob character in an rm
6
+ // target; an expansion ($, backtick) or xargs rm; a named file that is git-tracked with uncommitted changes, on the
7
+ // harness protected list, or read-only/immutable. EXEMPT: scratch (repo .tmp/, /tmp, TMPDIR, node_modules/ dist/ build/
8
+ // .bak/ inside the repo). A clean tracked file may go (git restores it).
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import { execFileSync } from "node:child_process";
12
+
13
+ const REMEDY = "name each file explicitly, or move scratch work under .tmp/";
14
+ const SCRATCH_SEG = new Set([".tmp", "node_modules", "dist", "build", ".bak"]);
15
+ const PROTECTED_BASE = /^(CLAUDE\.md|REFLEX\.md|HANDOFF\.md|settings(\.local)?\.json|[^/]*\.skill\.json)$/i;
16
+ const PROTECTED_DIR = /(^|\/)(\.claude\/hooks|\.githooks)(\/|$)/i;
17
+
18
+ // Slash form, ".." and "." folded away; relative names resolve against cwd. Returns "" for an escape past the root.
19
+ function norm(p, cwd) {
20
+ let s = String(p).replace(/\\+/g, "/");
21
+ if (!/^([A-Za-z]:)?\//.test(s)) s = String(cwd || ".").replace(/\\+/g, "/").replace(/\/+$/, "") + "/" + s;
22
+ const m = /^([A-Za-z]:)?(\/)?/.exec(s);
23
+ const head = m[0];
24
+ const out = [];
25
+ for (const seg of s.slice(head.length).split("/")) {
26
+ if (seg === "" || seg === ".") continue;
27
+ if (seg === "..") { if (!out.length) return ""; out.pop(); continue; }
28
+ out.push(seg);
29
+ }
30
+ return head + out.join("/");
31
+ }
32
+
33
+ function tmpRoots(env) {
34
+ const r = ["/tmp", "/var/tmp"];
35
+ for (const t of [env && env.TMPDIR, process.env.TMPDIR, os.tmpdir()]) if (t) r.push(String(t).replace(/\\+/g, "/").replace(/\/+$/, ""));
36
+ return r.filter((x) => x && x !== "/");
37
+ }
38
+
39
+ // True when target sits under a scratch area. The scratch root itself is NOT scratch (rm -rf /tmp is refused).
40
+ export function isScratch(target, cwd, env) {
41
+ const t = String(target).replace(/^\$\{?TMPDIR\}?(?=\/|\\|$)/, (env && env.TMPDIR) || process.env.TMPDIR || os.tmpdir());
42
+ if (/[$`]/.test(t)) return false;
43
+ const n = norm(t, cwd);
44
+ if (!n) return false;
45
+ const nd = n.replace(/^[A-Za-z]:/, ""); // C:/r/build and an MSYS-style cwd /r name the same place
46
+ const lower = nd.toLowerCase();
47
+ for (const r of tmpRoots(env)) { const rl = r.toLowerCase(); if (lower.startsWith(rl + "/") && lower.length > rl.length + 1) return true; }
48
+ const c = norm(cwd || ".", "/").replace(/^[A-Za-z]:/, "");
49
+ if (c && lower.startsWith(c.toLowerCase() + "/")) {
50
+ const rel = nd.slice(c.length + 1).split("/");
51
+ if (rel.some((seg) => SCRATCH_SEG.has(seg))) return true;
52
+ }
53
+ return false;
54
+ }
55
+
56
+ function gitDirty(file, cwd) {
57
+ try {
58
+ const dir = fs.statSync(file).isDirectory() ? file : file.replace(/[\\/][^\\/]*$/, "") || ".";
59
+ const out = execFileSync("git", ["-C", dir, "status", "--porcelain", "--", file], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] });
60
+ return out.split("\n").some((l) => l.length > 3 && !l.startsWith("??") && !l.startsWith("!!"));
61
+ } catch { return false; }
62
+ }
63
+
64
+ function immutable(file) {
65
+ if (process.platform !== "linux") return false;
66
+ try { return /^\S*i\S*\s/.test(execFileSync("lsattr", ["-d", file], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] })); } catch { return false; }
67
+ }
68
+
69
+ // A named file target: why it must not be removed, or "".
70
+ export function fileProtection(target, cwd) {
71
+ const abs = norm(target, cwd);
72
+ if (!abs) return "";
73
+ if (PROTECTED_BASE.test(abs.replace(/^.*\//, "")) || PROTECTED_DIR.test(abs)) return "protected harness file";
74
+ let st; try { st = fs.lstatSync(abs); } catch { return ""; }
75
+ if (st.isDirectory()) return "";
76
+ if ((st.mode & 0o222) === 0) return "read-only file";
77
+ if (immutable(abs)) return "immutable file";
78
+ if (gitDirty(abs, cwd)) return "git-tracked with uncommitted changes";
79
+ return "";
80
+ }
81
+
82
+ const isRecFlag = (x) => /^-[A-Za-z]*[rR][A-Za-z]*$/.test(x) || x === "--recursive";
83
+ const PS_RM = /^(remove-item|ri|rm|del|erase|rd|rmdir|rmdir\.exe)$/i;
84
+
85
+ // One simple command's words (quotes already removed by shellSegments). Returns a refusal label or "".
86
+ export function rmRisk(words, cwd, env) {
87
+ const w = words || [];
88
+ if (!w.length) return "";
89
+ const low = w.map((x) => String(x).toLowerCase());
90
+ // find ... -delete / -exec rm: refused unless every search root is scratch
91
+ const fi = low.indexOf("find");
92
+ if (fi >= 0 && fi === low.findIndex((x) => /^(find|gfind)$/.test(x)) && (low.includes("-delete") || /-exec(dir)?/.test(low.join(" ")) && low.includes("rm"))) {
93
+ const roots = w.slice(fi + 1).filter((x, i, a) => !x.startsWith("-") && (i === 0 || !a[i - 1].startsWith("-") || /^-[HLP]$/.test(a[i - 1])));
94
+ if (!roots.length || !roots.every((r) => isScratch(r, cwd, env))) return `find -delete (recursive delete): ${REMEDY}`;
95
+ }
96
+ const xi = low.indexOf("xargs");
97
+ if (xi >= 0 && low.slice(xi + 1).some((x) => /^(rm|rmdir|unlink|shred)$/.test(x))) return `xargs rm (targets unknown): ${REMEDY}`;
98
+ // Remove-Item / del / rd with a recursive switch
99
+ const pi = w.findIndex((x, i) => PS_RM.test(x) && (i === 0 || /^(sudo|cmd|powershell|pwsh|\/c|-command|-c)$/i.test(w[i - 1])));
100
+ const ri = w.indexOf("rm");
101
+ if (ri > 0 && w[ri - 1] === "git") return ""; // git rm stages a removal; git restores it
102
+ const i = ri >= 0 && (ri === 0 || /^(sudo|doas|command|exec|env|time|nice|nohup)$/.test(w[ri - 1])) ? ri : pi;
103
+ if (i < 0 && !/^(unlink|shred)$/.test(low[0])) return "";
104
+ const a = w.slice(i < 0 ? 1 : i + 1);
105
+ const rec = a.some((x) => isRecFlag(x) || /^-recurse$/i.test(x) || /^\/s$/i.test(x));
106
+ const tg = [];
107
+ let dd = false;
108
+ for (const x of a) {
109
+ if (dd) { tg.push(x); continue; }
110
+ if (x === "--") { dd = true; continue; }
111
+ if (x.startsWith("--") || (/^-[A-Za-z]/.test(x) && !/[\\/.]/.test(x)) || /^\/[a-z]$/i.test(x)) continue;
112
+ if (/^-(path|literalpath|include|exclude|filter)$/i.test(x)) continue;
113
+ tg.push(x);
114
+ }
115
+ if (!tg.length) return "";
116
+ for (const t of tg) {
117
+ if (/[$`]/.test(t) && !/^\$\{?TMPDIR\}?[\\/]/.test(t)) return `rm with a shell expansion in the target: ${REMEDY}`;
118
+ }
119
+ const scratch = tg.map((t) => isScratch(t, cwd, env));
120
+ if (rec && !scratch.every(Boolean)) return `rm -r of a folder or tree (recursive delete): ${REMEDY}`;
121
+ if (tg.some((t, k) => /[*?[\]]/.test(t) && !scratch[k])) return `rm with a wildcard in the target: ${REMEDY}`;
122
+ for (let k = 0; k < tg.length; k++) {
123
+ if (scratch[k]) continue;
124
+ const why = fileProtection(tg[k], cwd);
125
+ if (why) return `rm of ${tg[k]} refused, ${why}: ${REMEDY}`;
126
+ }
127
+ return "";
128
+ }
package/lib/rmtree.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { existsSync, lstatSync, readdirSync, readFileSync, rmSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { spawnSync } from "node:child_process";
6
+ import { dirForms } from "./holders.js";
6
7
 
7
8
  const sleepMs = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* no wait available */ } };
8
9
 
@@ -16,7 +17,7 @@ const sleepMs = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffe
16
17
  export function holderReport(dir, opts = {}) {
17
18
  const exec = opts.exec || ((cmd, args) => { const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 20000 }); return String(r.stdout || r.stderr || ""); });
18
19
  const isWin = opts.win ?? (process.platform === "win32");
19
- const needle = String(dir).replace(/\\/g, "/").toLowerCase();
20
+ const needle = dirForms(dir);
20
21
  try {
21
22
  if (isWin) {
22
23
  const ps = "Get-CimInstance Win32_Process | ForEach-Object { $c = [string]$_.CommandLine; $p = [string]$_.ExecutablePath; \"$($_.ProcessId)`t$($_.ParentProcessId)`t$($_.Name)`t$c`t$p\" }";
@@ -32,7 +33,7 @@ function filterHolderRows(out, needle) {
32
33
  const f = line.split("\t");
33
34
  if (f.length < 3) continue;
34
35
  const hay = f.slice(3).join(" ").replace(/\\/g, "/").toLowerCase();
35
- const named = hay.includes(needle), shellish = HOLDER_NAMES.test(String(f[2]).trim());
36
+ const named = needle.some((x) => hay.includes(x)), shellish = HOLDER_NAMES.test(String(f[2]).trim());
36
37
  if (named || shellish) rows.push((named ? "HOLDS-DIR " : "shell ") + "pid=" + f[0] + " ppid=" + f[1] + " " + f[2] + " " + f.slice(3).join(" ").slice(0, 300));
37
38
  }
38
39
  return rows.length ? rows.slice(0, 60).join("\n") : "no process names the harness dir and no shell, node or python process is running";
package/lib/setup-run.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // A user's own settings.json entries are the installer's job to keep (wkappbot-code.js install); this module only reads.
10
10
  // Spec: wkappbot skill read agent-harness-landscape-2026-howto
11
11
  import { spawnSync } from "node:child_process";
12
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
12
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "./fslocked.js";
13
13
  import { homedir, tmpdir } from "node:os";
14
14
  import { delimiter, dirname, join } from "node:path";
15
15
 
@@ -2,7 +2,7 @@
2
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
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
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";
5
+ import { lstatSync, readFileSync, readlinkSync, rmSync } from "./fslocked.js";
6
6
  import { join } from "node:path";
7
7
 
8
8
  const isHarness = (s) => /wkharness|wk-harness/.test(String(s || ""));
package/lib/starter-md.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // NO global CLAUDE.md; refreshed only while it still equals what this install wrote (sha256 kept in install-state.json);
6
6
  // a file the user changed or wrote is never touched. Uninstall removes it only when it is still unchanged.
7
7
  import { createHash } from "node:crypto";
8
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "./fslocked.js";
9
9
  import { dirname, join } from "node:path";
10
10
 
11
11
  const sha = (s) => createHash("sha256").update(s).digest("hex");
package/lib/update.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // WKAPPBOT_CODE_NO_UPDATE_CHECK=1 turns the quiet check off. Spec: wkappbot skill read agent-harness-landscape-2026-howto
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { createHash } from "node:crypto";
8
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
8
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "./fslocked.js";
9
9
  import { homedir, tmpdir } from "node:os";
10
10
  import { dirname, join } from "node:path";
11
11
 
package/lib/wk-hook.js CHANGED
@@ -9,6 +9,7 @@ import { appendFileSync, existsSync, mkdirSync, statSync, writeFileSync } from "
9
9
  import { homedir } from "node:os";
10
10
  import { dirname, join, resolve } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
+ import { rmRisk } from "./rmguard.js";
12
13
 
13
14
  // OpenCode tool id -> Claude tool name the kih guards already know.
14
15
  export const TOOL_MAP = {
@@ -271,6 +272,8 @@ export function riskClass(tool, input, cwd) {
271
272
  if (tg.some((x) => GIT_SEG.test(x))) return "write under .git";
272
273
  }
273
274
  }
275
+ const rr = rmRisk(w, cwd, process.env); // owner 2026-11-12: recursive, wildcard and unprotected-file deletes (lib/rmguard.js)
276
+ if (rr) return rr;
274
277
  if (w.some((x) => GIT_SEG.test(x)) && (seg.redirect || /^(rm|mv|cp|tee|truncate|dd|ln|chmod|sed)$/.test(w[0] || ""))) return "write under .git";
275
278
  }
276
279
  return "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wkappbot-code",
3
- "version": "0.3.32",
3
+ "version": "0.3.35",
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": {
@@ -0,0 +1,6 @@
1
+ ## Study locks for the installed harness sources (wkappbot-code install, owner 2026-11-13)
2
+ ## An Edit or Write of an installed harness source is refused until the skills below are read, even after an emergency-edit grant.
3
+ ## harness:skill (?i)[/\\]\.wk-harness[/\\](?:tools|\.githooks|\.claude[/\\]hooks|wkappbot-code[/\\]lib)[/\\]
4
+ wkappbot skill read harness-installer-owns-all-setup
5
+ wkappbot skill read agent-harness-landscape-2026-howto
6
+ wkappbot skill read verify-tool-or-guard-synthetic-live-static-howto