wkappbot-code 0.3.33 → 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,7 +37,7 @@ 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";
@@ -45,6 +45,7 @@ import { quietCheck, runUpdate, updateRow } from "../lib/update.js"; import { rm
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"));
@@ -481,7 +482,8 @@ function install(o) {
481
482
  // the compressed allow list (lib/allow-compact.js): five harness-own prefixes, never the wide rows learned on the owner's seats
482
483
  st.allows = (o.harness && o.allows) ? addAllows(SETTINGS(), prev.allows || {}, log) : (prev.allows || { skipped: true });
483
484
  // the standard global rules for a fresh machine (lib/starter-md.js): only where the user has no ~/.claude/CLAUDE.md
484
- 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.
485
487
  // A user's own absolute global core.hooksPath is kept (the script WARNs). Skipped with --no-harness.
486
488
  st.pushGate = { skipped: true };
487
489
  if (o.harness && kih && existsSync(join(kih, "tools", "wk-global-hooks.sh"))) {
@@ -576,7 +578,7 @@ function uninstall(o) {
576
578
  }
577
579
  // 3b. the ~/.local/bin/wkappbot link: removed ONLY when it points at the core this install placed (the core file stays)
578
580
  const core = (st.bin && st.bin.dest) || binDest(o);
579
- 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)
580
582
  let linkRemoved = "";
581
583
  for (const p of new Set(shimCandidates().concat([join(homedir(), ".local", "bin", "wkappbot")]))) {
582
584
  if (!isSymlink(p) || (st.shims || []).includes(p)) continue;
@@ -660,7 +662,7 @@ function doctor(o) {
660
662
  const v = opencodeVersion();
661
663
  add("opencode", v === OPENCODE_VERSION, v ? v + (v === OPENCODE_VERSION ? "" : " (pinned " + OPENCODE_VERSION + ")") : "not on PATH", false);
662
664
  const pdest = join(cfg, "wkappbot-code", "wisdom-kernel.js");
663
- 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);
664
666
  let entry = null;
665
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 {}
666
668
  add("config", !!entry, join(cfg, "opencode.json"), true);
@@ -746,7 +748,7 @@ if (process.argv.length <= 2 && o.cmd === "help") { const rc = launchOpenCode();
746
748
  switch (o.cmd) {
747
749
  case "--version": case "-v": case "version":
748
750
  console.log("wkappbot-code " + PKG.version + " (opencode-ai " + OPENCODE_VERSION + ")"); process.exit(0);
749
- 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); }
750
752
  case "uninstall": process.exit(uninstall(o));
751
753
  case "doctor": process.exitCode = doctor(o); break;
752
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/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/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.33",
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