geneseed 4.9.1 → 4.9.3

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +7 -1
  4. package/adapters/opencode/plugins/geneseed-guard.js +123 -12
  5. package/adapters/opencode/plugins/geneseed-learn.js +7 -1
  6. package/docs/concepts/enforcement-table.md +1 -1
  7. package/docs/concepts/exclusions.md +1 -2
  8. package/docs/concepts/hooks.md +3 -2
  9. package/docs/guides/upgrade.md +1 -1
  10. package/harness.config.json +1 -1
  11. package/js/README.md +30 -9
  12. package/js/build/args.mjs +9 -8
  13. package/js/build/driver.mjs +21 -21
  14. package/js/build/emit-claude.mjs +3 -3
  15. package/js/build/generate.mjs +4 -5
  16. package/js/build/source.mjs +18 -22
  17. package/js/hosts/gitref.mjs +5 -3
  18. package/js/hosts/hooks-done.mjs +21 -9
  19. package/js/hosts/hooks-learn.mjs +23 -10
  20. package/js/hosts/hooks-prims.mjs +25 -0
  21. package/js/hosts/hooks.mjs +19 -4
  22. package/js/hosts/installs.mjs +5 -4
  23. package/js/hosts/loss.mjs +98 -0
  24. package/js/hosts/native.mjs +3 -1
  25. package/js/hosts/owned.mjs +34 -6
  26. package/js/hosts/settings.mjs +6 -5
  27. package/js/inspect/checks-probe.mjs +1 -1
  28. package/js/inspect/cost.mjs +37 -5
  29. package/js/inspect/diff.mjs +154 -64
  30. package/js/inspect/validate.mjs +2 -2
  31. package/js/lib/fs.mjs +73 -2
  32. package/js/maintain/setup.mjs +31 -7
  33. package/js/maintain/uninstall.mjs +1 -1
  34. package/js/maintain/update.mjs +1 -1
  35. package/js/web/actions.mjs +36 -3
  36. package/js/web/api.mjs +17 -6
  37. package/js/web/handler.mjs +4 -16
  38. package/js/web/jobs.mjs +5 -3
  39. package/js/web/state.mjs +3 -0
  40. package/package.json +1 -1
  41. package/web/dist/assets/{Activity-GQGAZHIE.js → Activity-C8sZVIUU.js} +1 -1
  42. package/web/dist/assets/{ActivityDetail-DktWyxim.js → ActivityDetail-Cc6vWUS4.js} +1 -1
  43. package/web/dist/assets/Dashboard-C5ATa3ow.js +2 -0
  44. package/web/dist/assets/{Docs-CsWYLnN1.js → Docs-tDW7i7mo.js} +3 -3
  45. package/web/dist/assets/GreenhouseView-zk2Uzc3A.js +2 -0
  46. package/web/dist/assets/Installs-CB_rvbBP.js +3 -0
  47. package/web/dist/assets/JournalView-Bfj27XHC.js +1 -0
  48. package/web/dist/assets/Laws-BA3CzUf5.js +3 -0
  49. package/web/dist/assets/Library-BWwiy9Vb.js +1 -0
  50. package/web/dist/assets/{Loops-BUBOeM0C.js → Loops-DaPk-ckL.js} +2 -2
  51. package/web/dist/assets/{OperatorHudView-BJuCYsjf.js → OperatorHudView-CyiGhiHk.js} +1 -1
  52. package/web/dist/assets/Personal-Bwd27CmT.js +1 -0
  53. package/web/dist/assets/{Tabs-GPQlocTw.js → Tabs-BOD7Kaz1.js} +1 -1
  54. package/web/dist/assets/drawable-CyLe1gtW.js +1 -0
  55. package/web/dist/assets/index-DQXG_mqo.js +2 -0
  56. package/web/dist/index.html +1 -1
  57. package/web/src/pages/Laws.jsx +60 -353
  58. package/web/dist/assets/Dashboard-CmSn6JfK.js +0 -2
  59. package/web/dist/assets/GreenhouseView-Dt_cIxAY.js +0 -2
  60. package/web/dist/assets/Installs-CWuUU4I3.js +0 -2
  61. package/web/dist/assets/JournalView-ClbZj_YF.js +0 -1
  62. package/web/dist/assets/Laws-R5zZCmya.js +0 -3
  63. package/web/dist/assets/Library-u36V7oSD.js +0 -1
  64. package/web/dist/assets/Personal-DWQuSAUz.js +0 -1
  65. package/web/dist/assets/drawable-CE-arUnF.js +0 -1
  66. package/web/dist/assets/index-3lnPT_1J.js +0 -2
package/CHANGELOG.md CHANGED
@@ -8,6 +8,36 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [4.9.3] — 2026-10-18
12
+
13
+ Patch: rm -rf / and other catastrophic commands are caught on every host even with the deletion gate off; a skill you replaced with a link reads as yours, not as an edit.
14
+
15
+ ### Upgrade notes
16
+ - Run `geneseed rebuild-all` once after updating so installed hooks and OpenCode plugins pick up the changes.
17
+
18
+ ### Fixed
19
+ - **`rm -rf /` asks on Claude Code, OpenClaude and Bob even with the deletion gate off.** These hosts had no catastrophic-command check of their own: a recursive removal of `/`, a drive root or the home directory was caught only by Law IV's real-loss check, so `--no-deletion-gate` let it through unasked. `git-gate` (and Bob's `tool-gate`) now run the catastrophic class before that switch and whatever it says: a recursive removal of `/`, `C:\`, `/c` or `~`/`$HOME` (`rm`, `rd`/`del /s`, `Remove-Item -Recurse`), `mkfs` on a device, `dd` over a raw disk, `format C:` and a fork bomb ask on Claude Code and OpenClaude and exit 2 on Bob, as `law-4`. It judges the program after the shell structure is stripped, so `sudo rm -rf /`, `true & rm -rf /`, `(rm -rf /)`, `rm -rf /;`, `bash -c 'rm -rf /'` and a heredoc or here-string fed to a shell (`bash <<'EOF'`) are caught, and a commit message or a heredoc fed to `cat` or `git commit -F -` that only names `rm -rf /` is not. The check is one definition shared with the OpenCode guard (the loss twin block, pinned byte for byte), which now also blocks `rm -rf C:\`, `rm -rf /;`, `rd /s/q C:\` and `bash -c 'rm -rf /'`; its own stricter text match stays on top (it still blocks a command that only quotes one, and any `Remove-Item -Recurse -Force`). Ordinary commands are unaffected.
20
+ - **A skill you replaced with a link to your own copy no longer reads as a hand edit forever.** The emit rightly refuses to write through a link, but `geneseed diff` then compared the rendered file with your linked one and reported it as "edited — review to back-port", which no rebuild could clear. A deployed path that is, or sits under, a link the emit refuses (a junction on Windows, a symlink on POSIX — the same test the emit uses) is now `linked`: `geneseed diff` lists it in its own group ("linked (yours, left alone)"), it is not drift, and it never goes into an improvements file. The console keeps it out of every drift figure: the Overview's "Hand edits" cell shows it as a muted "N linked" when it is all there is, and the Local edits page lists it apart, not selectable for Restore. A regular file you edited is still `edited`.
21
+
22
+ ## [4.9.2] — 2026-10-18
23
+
24
+ Patch: the review pass — faster emits, console and Stop; Build and setup keep your gate switches; protected checks follow links both ways.
25
+
26
+ ### Upgrade notes
27
+ - Run `geneseed rebuild-all` once after updating so installed hooks and OpenCode plugins pick up the changes.
28
+
29
+ ### Changed
30
+ - **A Stop no longer parses the whole transcript.** The done gate and `learn` each read the full session transcript on every Stop (real ones reach 50 MB), and an armed install with `GENESEED_LLM` set read it twice. Both now read backward from the end, in windows that start at 2 MB and grow fourfold: the done gate until the window holds the prompt that opened the turn (a turn longer than every window falls back to the whole file, so it is never judged on a truncated turn), `learn` until its notes hold the 16 000 characters it keeps, so the notes are the ones a full read gives. One Stop reads each window once, shared by both. On a generated 50 MB transcript (old and new interleaved, median of 10) the armed gate alone runs about 3× faster and gate + learn about 3× faster, and both now cost about the same as on a 1 KB transcript: a Stop no longer grows with the session.
31
+ - **The console stops re-rendering an install to measure its drift on every refresh.** The overview and the fleet table each rendered the selected install's expected tree afresh (~0.6 s per install per call); the render is now memoised per install, keyed on the source fingerprint and every axis the install was built with (theme, footprint, posture, mode, packs, trust, excluded rules and skills, gate switches), and re-done the moment any of them changes. The comparison against the deployed tree still runs on every call, so a hand edit shows at once. Measured on a two-install sandbox: overview ~0.75 s → ~0.2 s, fleet ~1.5 s → ~0.32 s after the first refresh. `geneseed diff` is unchanged.
32
+ - **Emits no longer read back what they just wrote to hash it.** The owned-file hashes in `.geneseed-manifest.json` are now computed from the text `writeText` recorded at write time (a copied binary, or a file rewritten since by any other writer, is still read), and the claim-on-create closure (`claimer`) resolves the config root once per emit instead of once per file. The per-file symlink and hard-link checks are unchanged, and manifests are byte-identical to before. A global re-emit runs about a quarter faster (measured on Windows: claude-global 560 → 424 ms, opencode-global 716 → 542 ms, median of 7).
33
+ - **The console reads user-rules.md once, under one key, and a rules or profile write reloads only its own file.** Personal's cards, the Overview's ledger and the Constitution page's "Your rules" tier share one cached `/api/rules` read (`web/src/hooks/useRules.js`), so a rule written on Personal shows on the other two at once. Each visit to one of those pages paints the cached copy and refetches it once, so a rule an agent session wrote shows without waiting for a build. On Personal, a rules write no longer refetches the profile and a profile save no longer refetches the rules. A memory fact promoted to a rule from the Library drops the cached copy, so the Rules tab it lands on shows the new rule. Install changes (build, toggle, switch, edits) still refresh the whole console.
34
+
35
+ ### Fixed
36
+ - **Protected checks follow links in both directions on every host.** Both the Claude/Bob hook and the OpenCode guard now read a write's target twice: as written, and with symlinks and junctions followed (the hook's `resolvePath`). A hit on either reading asks (Claude) or blocks (Bob, OpenCode). A write through a link from outside a repo into one of its protected folders is now caught on OpenCode, where before it passed; a protected folder inside a repo that links out of it is now caught by the hook, where before it passed. Ordinary repos are unchanged. The guard's git readers are now pinned to `js/hosts/gitref.mjs` by a test.
37
+ - **The console's always-on cost no longer counts skills the model never sees.** The skill part of each install's cost counted every `SKILL.md`, including user-only skills and aliases the host keeps out of the model's catalogue. It now skips them the way each host does: on their `disable-model-invocation: true` frontmatter for Claude Code and OpenClaude, and on the `permission.skill` deny in `opencode.json(c)` for OpenCode, which ignores that frontmatter key. Bob writes no `SKILL.md` for a user-only skill, so nothing changes there. Measured on a fresh claude-global lean emit, the skill part drops from 12,982 to 10,837 characters (opencode-global: 12,548 to 11,825). The root-file ceiling is unaffected.
38
+ - **The console's Build no longer re-arms switched-off gates or restores excluded rules and skills.** Rebuilding a deployed install from the console (the theme picker) assembled its own argv: no gate switches, `--exclude-rules none` and no excluded skills. It now takes the argv the install's own profile gives `rebuild-all` and `status`, with only the picked theme swapped in. A global install's Build also names its own `--config-dir`, and a project install's its own `--out`/`--root`.
39
+ - **Re-running `geneseed setup` no longer re-arms switched-off gates.** The wizard kept an install's excluded rules and skills but read none of its `Secrets gate:` / `Done gate:` / `Deletion gate:` lines, so holding Enter through a re-run switched every gate back on. It now carries them into the build like the exclusions, without asking about them.
40
+
11
41
  ## [4.9.1] — 2026-10-18
12
42
 
13
43
  Patch: Deletion Is Deliberate and the root-scan check no longer fail open behind shell structure; the console stops re-running the doctor on every change.
package/SHIPPED.md CHANGED
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
20
20
  > **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
21
21
  > human-readable label only. The canonical identity of an installed harness is the
22
22
  > **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
23
- > string. Current label: **4.9.1**.
23
+ > string. Current label: **4.9.3**.
24
24
 
25
25
  ## Capabilities
26
26
 
@@ -64,7 +64,9 @@ const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url))
64
64
  // Sovereign-repo bypass — twin of the harness's own `sovereignBypass()`.
65
65
  // <cfg>/excludes.json (user-owned, managed by `harness exclude`) lists folders where
66
66
  // this GLOBAL install goes dormant. Any error degrades to "not excluded".
67
- const norm = (p) => {
67
+ // Twin of js/hosts/hosts.mjs `resolvePath`: the absolute path with symlinks and junctions
68
+ // followed, the way the Claude/Bob hook resolves one. `norm` folds its case for matching.
69
+ function resolvePath(p) {
68
70
  // Expand a leading "~" / "~/…" to the home dir — a hand-edited excludes.json entry
69
71
  // like "~/vault" must resolve the same way here as it does for the Claude/Bob
70
72
  // hook guard.
@@ -83,6 +85,10 @@ const norm = (p) => {
83
85
  tail.unshift(path.basename(cur))
84
86
  cur = up
85
87
  }
88
+ return s
89
+ }
90
+ const norm = (p) => {
91
+ const s = resolvePath(p)
86
92
  return process.platform === "win32" ? s.toLowerCase() : s
87
93
  }
88
94
  // A linked git worktree of an excluded repo is excluded too, wherever it sits on disk — twin of
@@ -59,7 +59,9 @@ const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url))
59
59
  // Sovereign-repo bypass — twin of the harness's own `sovereignBypass()`.
60
60
  // <cfg>/excludes.json (user-owned, managed by `harness exclude`) lists folders where
61
61
  // this GLOBAL install goes dormant. Any error degrades to "not excluded".
62
- const norm = (p) => {
62
+ // Twin of js/hosts/hosts.mjs `resolvePath`: the absolute path with symlinks and junctions
63
+ // followed, the way the Claude/Bob hook resolves one. `norm` folds its case for matching.
64
+ function resolvePath(p) {
63
65
  // Expand a leading "~" / "~/…" to the home dir — a hand-edited excludes.json entry
64
66
  // like "~/vault" must resolve the same way here as it does for the Claude/Bob
65
67
  // hook guard.
@@ -78,6 +80,10 @@ const norm = (p) => {
78
80
  tail.unshift(path.basename(cur))
79
81
  cur = up
80
82
  }
83
+ return s
84
+ }
85
+ const norm = (p) => {
86
+ const s = resolvePath(p)
81
87
  return process.platform === "win32" ? s.toLowerCase() : s
82
88
  }
83
89
  // A linked git worktree of an excluded repo is excluded too, wherever it sits on disk — twin of
@@ -212,6 +218,11 @@ const SECRET_CONTENT_RE =
212
218
 
213
219
  // Catastrophic, effectively irreversible shell → BLOCK. Both Unix and Windows shells,
214
220
  // since OpenCode runs natively on Windows and the agent may emit cmd / PowerShell.
221
+ // The catastrophic class itself is `catastrophic()` in the loss twin block below — one definition,
222
+ // shared with the hook (js/hosts/loss.mjs), judged in program position and run whatever the
223
+ // deletion gate's switch says. This raw text match is OpenCode's own stricter layer on top of it:
224
+ // it also blocks a command that only quotes one (a heredoc fed to `cat` included) and any
225
+ // `Remove-Item -Recurse -Force`, whatever its target.
215
226
  const SHELL_BLOCK_RE = [
216
227
  // rm -rf /, ~, /*, ~/* — flags may be split (-r -f), long (--recursive), or end with
217
228
  // `--`; the lookahead insists one of them recurses. The target must be the root or
@@ -234,8 +245,9 @@ const SHELL_BLOCK_RE = [
234
245
  // the Bob behaviour — and `GENESEED_GUARD=warn` downgrades it to a log line.
235
246
  //
236
247
  // The fs-only readers below are the twins of js/hosts/gitref.mjs (gitRootOf, gitDirOf,
237
- // gitCommonDirOf, currentBranch, protectedBranches, stashExists); the block between the twin
238
- // markers is js/hosts/loss.mjs verbatim, and tests/plugins/guard.test.mjs diffs it.
248
+ // gitCommonDirOf, currentBranch, protectedBranches, stashExists) — tests/plugins/guard.test.mjs
249
+ // compares each with its gitref.mjs original, quotes, semicolons and layout aside; the block
250
+ // between the twin markers is js/hosts/loss.mjs verbatim, and the same file diffs it.
239
251
  function gitRootOf(cwd) {
240
252
  try {
241
253
  let dir = path.resolve(cwd)
@@ -858,6 +870,104 @@ function lossReason(segments, cwd, { always = true, split } = {}) {
858
870
  return `the gate could not verify what this command would lose (${e.message})`;
859
871
  }
860
872
  }
873
+
874
+ /**
875
+ * THE CATASTROPHIC CLASS — what no state can make safe: a recursive removal of a filesystem root,
876
+ * a drive root or the home directory, a device format or raw-disk overwrite, a fork bomb. Unlike
877
+ * `lossReason` it reads no state, and it stands whatever the deletion gate's switch says (user
878
+ * decision 2026-10-18): `--no-deletion-gate` silences the real-loss check, never this one. Judged
879
+ * in PROGRAM position, segment by segment after `stripStructure` and the wrappers, so `true & rm
880
+ * -rf /`, `sudo rm -rf /`, `(rm -rf /)` and `rm -rf /;` are caught and a commit message that only
881
+ * names `rm -rf /` is not. A shell or `eval` (`bash -c '…'`, `pwsh -Command …`) and a `$(…)` body
882
+ * are judged as commands of their own, and so is what a shell reads on stdin: a heredoc or
883
+ * here-string fed to `bash`/`sh`/`pwsh`/`cmd`… (`bash <<'EOF'` over `rm -rf /`). One fed to any
884
+ * other program (`cat <<EOF`, `git commit -F - <<EOF`) is data. The fork bomb spans `|`/`&`/`;`, so
885
+ * it is tested on the whole command. `split` cuts a command into raw-token segments, as for
886
+ * `lossReason`. Returns the offending text, or null.
887
+ */
888
+ const CATASTROPHIC_CANDIDATE_RE = /(?:^|[^\w-])(?:rm|rmdir|rd|del|erase|remove-item|ri|mkfs(?:\.\w+)?|dd|format)(?:\.exe)?(?![\w-])|:\(\)/i;
889
+ const FORK_BOMB_RE = /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
890
+ // `/`, a Git Bash or WSL drive (`/c`, `/mnt/c`), a Windows drive (`C:`, `C:\`), the home dir — globbed or not.
891
+ const ROOT_TARGET_RE = /^(?:\/|~[\\/]?|\$\{?HOME\}?[\\/]?|\/[a-z]\/?|\/mnt\/[a-z]\/?|[a-z]:[\\/]?)\*?$/i;
892
+ const RM_RECURSE_RE = /^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)$/;
893
+ // PowerShell's `-Recurse` (or any prefix of it) and cmd.exe's `/s`, alone or glued (`/s/q`).
894
+ const PS_CMD_RECURSE_RE = /^(?:-r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?|(?:\/[a-z]+)*\/s(?:\/[a-z]+)*)$/i;
895
+ const leadingFlags = (w) => { let i = 0; while (i < w.length && /^[-/][\w-]+$/.test(w[i])) i += 1; return w.slice(i); };
896
+ // The programs that run what they read on stdin as commands.
897
+ const STDIN_SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd']);
898
+ // The same lexer as the splitter's (a comment, a quoted run, a heredoc opener, a newline), so the
899
+ // openers it finds are the ones left in the segments' tokens, in the same order.
900
+ const HEREDOC_SCAN_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2|\n/g;
901
+ /** Every heredoc body in `command`, in opener order; an unterminated one runs to the end, as in bash. */
902
+ function heredocBodies(command) {
903
+ const out = [];
904
+ const tags = [];
905
+ const re = new RegExp(HEREDOC_SCAN_RE);
906
+ let m;
907
+ while ((m = re.exec(command))) {
908
+ if (m[3]) { tags.push([m[1], m[3]]); continue; }
909
+ if (m[0] !== '\n' || !tags.length) continue;
910
+ let start = re.lastIndex;
911
+ while (tags.length) {
912
+ const [dash, tag] = tags.shift();
913
+ const body = [];
914
+ while (start < command.length) {
915
+ const end = command.indexOf('\n', start);
916
+ const line = command.slice(start, end < 0 ? command.length : end).replace(/\r$/, '');
917
+ start = end < 0 ? command.length : end + 1;
918
+ if ((dash ? line.replace(/^\t+/, '') : line) === tag) break;
919
+ body.push(line);
920
+ }
921
+ out.push(body.join('\n'));
922
+ }
923
+ re.lastIndex = start;
924
+ }
925
+ return out;
926
+ }
927
+ function catastrophic(command, split, depth = 0) {
928
+ if (!CATASTROPHIC_CANDIDATE_RE.test(command)) return null;
929
+ if (FORK_BOMB_RE.test(command)) return command.trim();
930
+ const bodies = command.includes('<<') ? heredocBodies(command) : [];
931
+ let next = 0;
932
+ for (const raw of split(command).map(stripStructure)) {
933
+ const text = raw.join(' ');
934
+ // What this segment feeds on stdin: each heredoc opener takes the next body, a here-string its word.
935
+ const fed = [];
936
+ raw.forEach((t, i) => {
937
+ if (t.startsWith('<<<')) fed.push(word(t.length > 3 ? t.slice(3) : raw[i + 1] || '').replace(OPAQUE, ''));
938
+ else if (t.startsWith('<<')) fed.push(bodies[next++] ?? '');
939
+ });
940
+ for (const body of depth < 4 ? substitutions(text) : []) {
941
+ const v = catastrophic(body, split, depth + 1);
942
+ if (v) return v;
943
+ }
944
+ let w = words(raw).map((t) => t.replace(OPAQUE, ''));
945
+ while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
946
+ w = /^\w+=/.test(w[0]) ? w.slice(1) : pastWrapper(progOf(w[0]), w.slice(1)) || leadingFlags(w.slice(1));
947
+ }
948
+ if (!w.length) continue;
949
+ const prog = progOf(w[0]);
950
+ for (const body of STDIN_SHELLS.has(prog) && depth < 4 ? fed : []) {
951
+ const v = catastrophic(body, split, depth + 1);
952
+ if (v) return v;
953
+ }
954
+ const args = w.slice(1).map((a) => a.replace(/^-(?:literal)?path[=:]/i, ''));
955
+ // cmd.exe's `/s` `/q` are switches, not the Git Bash drives `/s` `/q`.
956
+ const root = () => args.some((a) => ROOT_TARGET_RE.test(a) && !(CMD_SWITCH_PROGS.has(prog) && /^\/[a-z]$/i.test(a)));
957
+ let hit = false;
958
+ if (prog === 'rm') hit = args.some((a) => RM_RECURSE_RE.test(a)) && root();
959
+ else if (RM_PROGS.has(prog)) hit = args.some((a) => PS_CMD_RECURSE_RE.test(a)) && root();
960
+ else if (/^mkfs(?:\.\w+)?$/.test(prog)) hit = args.some((a) => a.startsWith('/dev/'));
961
+ else if (prog === 'dd') hit = args.some((a) => /^of=\/dev\/(?:sd|nvme|hd|disk)/.test(a));
962
+ else if (prog === 'format') hit = args.some((a) => /^[a-z]:/i.test(a));
963
+ else if (INDIRECT.has(prog) && depth < 4) {
964
+ const v = catastrophic(leadingFlags(w.slice(1)).join(' '), split, depth + 1);
965
+ if (v) return v;
966
+ }
967
+ if (hit) return text.trim();
968
+ }
969
+ return null;
970
+ }
861
971
  // ---- loss twin: END
862
972
 
863
973
  // A recursive scan rooted at a whole filesystem (Commands Must Return) → BLOCK. Twin of
@@ -1178,19 +1288,20 @@ function ruleStoreTarget(p) {
1178
1288
  // must not edit in `.geneseed/protected-checks.txt` at its git root, one repo-relative path per
1179
1289
  // line; the list protects itself. Found from the WRITE TARGET up to the first `.git`, re-read on
1180
1290
  // every write (a short file, read only on a write call). No ask tier here, so a hit is a deny.
1291
+ // The target is read twice — as written, and with links followed — and a hit on either is a hit:
1292
+ // a link from outside into a protected folder and a protected folder that links out are both
1293
+ // caught, so following links never loosens the gate.
1181
1294
  const SENSOR_LIST = ".geneseed/protected-checks.txt"
1182
1295
  // The done gate's list (#289, js/hosts/hooks-done.mjs) protects itself the same way, with or
1183
1296
  // without a protected-checks list: emptying it would disarm the gate. Twin of js/hosts/hooks.mjs.
1184
1297
  const DONE_LIST = ".geneseed/done-checks.txt"
1185
1298
  function protectedCheck(p) {
1299
+ return protectedIn(path.resolve(p)) ?? protectedIn(resolvePath(p))
1300
+ }
1301
+ function protectedIn(target) {
1186
1302
  try {
1187
- const target = path.resolve(p)
1188
- let root = path.dirname(target)
1189
- while (!existsSync(path.join(root, ".git"))) {
1190
- const up = path.dirname(root)
1191
- if (up === root) return null
1192
- root = up
1193
- }
1303
+ const root = gitRootOf(path.dirname(target))
1304
+ if (!root) return null
1194
1305
  const fold = (s) => nocase(s.replace(/\\/g, "/"))
1195
1306
  const rel = fold(path.relative(root, target))
1196
1307
  if (rel === fold(DONE_LIST)) return DONE_LIST
@@ -1311,7 +1422,8 @@ export const GeneseedGuard = async (ctx) => {
1311
1422
  // classes, and the shell check must still run after the write check.
1312
1423
  if (hasAny(tool, SHELL_TOOLS)) {
1313
1424
  const c = pickCommand(args)
1314
- if (c && SHELL_BLOCK_RE.some((re) => re.test(c))) { await deny(`catastrophic command (Deletion Is Deliberate): ${c.slice(0, 80)}`, "law-4"); return }
1425
+ const cut = (t) => scanSegments(t).map((seg) => seg.match(SCAN_TOKEN_RE) || [])
1426
+ if (c && (SHELL_BLOCK_RE.some((re) => re.test(c)) || catastrophic(c, cut))) { await deny(`catastrophic command (Deletion Is Deliberate): ${c.slice(0, 80)}`, "law-4"); return }
1315
1427
  const scan = c && rootScan(c)
1316
1428
  if (scan) {
1317
1429
  await deny(`\`${scan.slice(0, 80)}\` recursively scans a whole filesystem root (Commands Must Return) — ` +
@@ -1321,7 +1433,6 @@ export const GeneseedGuard = async (ctx) => {
1321
1433
  if (c && !DELETION_GATE_OFF && LOSS_CANDIDATE_RE.test(c)) {
1322
1434
  // OpenCode's bash tool may run in `workdir`, resolved against the session directory.
1323
1435
  const wd = typeof args.workdir === "string" && args.workdir ? path.resolve(cwd, args.workdir) : cwd
1324
- const cut = (t) => scanSegments(t).map((seg) => seg.match(SCAN_TOKEN_RE) || [])
1325
1436
  const loss = lossReason(cut(c), wd, { always: false, split: cut })
1326
1437
  if (loss) { await deny(`${loss} (Deletion Is Deliberate) — confirm with the user first`, "law-4"); return }
1327
1438
  }
@@ -46,7 +46,9 @@ const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url))
46
46
  // Sovereign-repo bypass — twin of the harness's own `sovereignBypass()`.
47
47
  // <cfg>/excludes.json (user-owned, managed by `harness exclude`) lists folders where
48
48
  // this GLOBAL install goes dormant. Any error degrades to "not excluded".
49
- const norm = (p) => {
49
+ // Twin of js/hosts/hosts.mjs `resolvePath`: the absolute path with symlinks and junctions
50
+ // followed, the way the Claude/Bob hook resolves one. `norm` folds its case for matching.
51
+ function resolvePath(p) {
50
52
  // Expand a leading "~" / "~/…" to the home dir — a hand-edited excludes.json entry
51
53
  // like "~/vault" must resolve the same way here as it does for the Claude/Bob
52
54
  // hook guard.
@@ -65,6 +67,10 @@ const norm = (p) => {
65
67
  tail.unshift(path.basename(cur))
66
68
  cur = up
67
69
  }
70
+ return s
71
+ }
72
+ const norm = (p) => {
73
+ const s = resolvePath(p)
68
74
  return process.platform === "win32" ? s.toLowerCase() : s
69
75
  }
70
76
  // A linked git worktree of an excluded repo is excluded too, wherever it sits on disk — twin of
@@ -22,7 +22,7 @@ silently guarding the wrong rule after a renumber. See [ADR 0005](../adr/0005-st
22
22
  | id | Pack | Claude Code | OpenClaude | OpenCode | Bob |
23
23
  |---|---|---|---|---|---|
24
24
  | `sealed-secrets` | law | ask: rule-gate (credential-shaped content outside `.env*`). Switchable per install with `--no-secrets-gate` (see [Exclusions](exclusions.md)): this scan stands down on every host, the guard plugin's path block included | same as Claude Code | block: guard plugin (key/credential file path, or vendor-prefixed secret content); warns only on `.env*` | block (exit 2) |
25
- | `deletion-is-deliberate` | law | ask: git-gate, only on real data loss judged against the repository's state (the list in [Hooks](hooks.md): a protected branch deleted or force-pushed, an unpushed unmerged branch deleted, uncommitted work discarded, a stash dropped, the reflog pruned, files git cannot restore removed); asks when it cannot verify. Switchable per install with `--no-deletion-gate` (see [Exclusions](exclusions.md)): the real-loss check and the Law IV `permission.bash` keys stand down, everything else in this table stays | same as Claude Code | ask: permission prompt (`git reflog expire*`, `git gc --prune*`, a force push or delete of `main`/`master` by name) **+** block: guard plugin (catastrophic shell: `rm -rf /`, fork bomb, `format`, `dd` over a raw disk) **+** block: guard plugin (every other real-loss case, the same state-aware decisions as the hook) | block (exit 2) |
25
+ | `deletion-is-deliberate` | law | ask: git-gate, only on real data loss judged against the repository's state (the list in [Hooks](hooks.md): a protected branch deleted or force-pushed, an unpushed unmerged branch deleted, uncommitted work discarded, a stash dropped, the reflog pruned, files git cannot restore removed); asks when it cannot verify. Switchable per install with `--no-deletion-gate` (see [Exclusions](exclusions.md)): the real-loss check and the Law IV `permission.bash` keys stand down, everything else in this table stays **+** ask: git-gate, catastrophic shell (`rm -rf` of `/`, a drive root or `~`, `mkfs`, `dd` over a raw disk, `format`, fork bomb), whatever the state and even with the switch off | same as Claude Code | ask: permission prompt (`git reflog expire*`, `git gc --prune*`, a force push or delete of `main`/`master` by name) **+** block: guard plugin (catastrophic shell, the hook's check plus its own stricter text match: `rm -rf` of `/`, a drive root or `~`, fork bomb, `format`, `dd` over a raw disk) **+** block: guard plugin (every other real-loss case, the same state-aware decisions as the hook) | block (exit 2) |
26
26
  | `persist-insight` | process | ask: rule-gate (a write to `user-rules.md` or the memory store) | same as Claude Code | ask once: guard plugin (refuses the *first* write to that path; a re-issued write goes through) | log only (warning, call goes through) |
27
27
  | `consent-before-push` | process | ask: git-gate (every `git commit`/`git push`). Togglable: build without the process pack, or drop this rule alone with `--exclude-rules` (see [Exclusions](exclusions.md)). Only this question drops; the `deletion-is-deliberate` gate stays. | same as Claude Code | ask: permission prompt (`git commit*`/`git push*`), wired only when the process pack is on; a `loop/*` push is statically allowed | log only |
28
28
  | `external-gate` | rigor | ask: rule-gate (a write under a project's `.geneseed/protected-checks.txt` list) | same as Claude Code | block: guard plugin (same list) | block (exit 2) |
@@ -80,10 +80,9 @@ geneseed build --emit claude-global --no-deletion-gate
80
80
 
81
81
  The web console has the same switch on Rule IV's row of the Constitution page; the row's *Enforced by* reads **Gate off** while it is off. The install records it (`Deletion gate: off` under its pack list, written only when off) and every rebuild and upgrade keeps it; rebuild without the flag, or flip the switch back, to re-arm it. `geneseed status` shows `deletion gate: off` on the install's row.
82
82
 
83
- What stops asking, per host: the real-loss check of `git-gate` (Claude Code, OpenClaude) and `tool-gate` (Bob, which then no longer exits 2 for it), the OpenCode guard plugin's real-loss block, and the Law IV keys of OpenCode's `permission.bash` (`git reflog expire*`, `git gc --prune*`, the `main`/`master` force-push and delete forms). What stays: the Rule's text in the carrier, *Sealed Secrets*, the root-wide scan check (*Commands Must Return*), *Consent Before Push* (a forced push still asks as a push while that rule is on), *External Gate*, and the OpenCode guard's catastrophic-command block (`rm -rf /`, `format`, `dd` over a disk).
83
+ What stops asking, per host: the real-loss check of `git-gate` (Claude Code, OpenClaude) and `tool-gate` (Bob, which then no longer exits 2 for it), the OpenCode guard plugin's real-loss block, and the Law IV keys of OpenCode's `permission.bash` (`git reflog expire*`, `git gc --prune*`, the `main`/`master` force-push and delete forms). What stays: the Rule's text in the carrier, *Sealed Secrets*, the root-wide scan check (*Commands Must Return*), *Consent Before Push* (a forced push still asks as a push while that rule is on), *External Gate*, and the catastrophic-command check on every host (`rm -rf` of `/`, a drive root or the home directory, `format`, `dd` over a disk): a prompt on Claude Code and OpenClaude, a block on Bob and OpenCode.
84
84
 
85
85
  Limits:
86
- - **Claude Code, OpenClaude and Bob have no separate catastrophic-command check.** There, a removal outside a repository — `rm -rf /` included — is caught only by the real-loss check, so with the gate off it no longer asks.
87
86
  - **OpenCode reads the switch at start.** The guard plugin reads `Deletion gate:` from the install's `AGENT.md` once, when OpenCode loads it: restart OpenCode after flipping it.
88
87
  - **A global install beside a project install.** On Claude Code and OpenClaude the global `git-gate` stands down for a project install, so the project's switch decides. Bob's `tool-gate` and the OpenCode guard do not stand down: an armed global install still refuses real loss inside a project whose own install is off.
89
88
  - **An existing `opencode.json` keeps its Law IV keys.** Switching off does not remove `permission.bash` entries already written — Geneseed cannot tell its own from yours — so the build warns that it keeps them and they keep asking. Delete them by hand.
@@ -28,6 +28,7 @@ Geneseed writes its hooks into the host's settings file. Every hook calls one pr
28
28
  **context.** Before your first message, the agent receives your own harness files — `user-rules.md`, `PROFILE.md`, the memory index, the notebook index, your wiki declaration — skipping any still untouched since install, then the repo's docs, discovered by convention or listed in a `context.json`. Each file is capped at 16 KB and the whole injection at 48 KB; what does not fit is listed for the agent to read on demand. You see nothing, except that the agent already knows your project. See [Project context](../guides/project-context.md).
29
29
 
30
30
  **git-gate.** Runs before every shell command and looks for `git` in it, including chained one-liners and `git -C <path>` forms, and for a recursive scan of a whole filesystem.
31
+ - **A catastrophic command** gets a confirmation prompt citing *Deletion Is Deliberate* (on Bob, it is refused with exit 2) whatever the repository holds, even with the deletion gate switched off: a recursive removal of `/`, a drive root (`C:\`, `/c`) or the home directory (`~`, `$HOME`), `mkfs` on a device, `dd` over a raw disk, `format C:`, a fork bomb. It judges the program, not the text: a `sudo`, a background `&`, a subshell, `bash -c '…'` or a heredoc fed to a shell (`bash <<'EOF'`) hides nothing, and a heredoc fed to anything else (`cat`, `git commit -F -`) or a commit message that only names `rm -rf /` passes. The OpenCode guard runs the same code, plus its own stricter text match.
31
32
  - **Real data loss** gets a confirmation prompt citing *Deletion Is Deliberate* — and only real loss, judged against the repository's actual state:
32
33
  - deleting or force-pushing a protected branch (`main`, `master`, or a remote's default branch), locally or on the remote;
33
34
  - `branch -D` of a branch whose commits are on no remote branch and not merged into the current one;
@@ -36,7 +37,7 @@ Geneseed writes its hooks into the host's settings file. Every hook calls one pr
36
37
  - `stash drop`/`clear` while a stash exists; `reflog expire` and `gc --prune`, always;
37
38
  - removing files (`rm`, `rmdir`, `del`, `erase`, `Remove-Item`) that git cannot restore: untracked, ignored or modified files, a repository's `.git`, or anything outside a repository.
38
39
 
39
- A force push to a feature branch, the protected-branch-only forms above on a feature branch, a new branch (`checkout -b`, `switch -c`), `reset --hard` on a clean tree, `branch -D` of a pushed branch, or `rm` of a tracked, unmodified file passes. When the gate cannot tell — a glob or `$variable` it would have to expand, a `cd` it cannot follow, `sh -c`/`xargs`, git failing or missing — it asks, and says it could not verify. To read the state it runs `git` itself, only once a command already looked destructive; every other command pays nothing. That is a Rule, so it holds in every build — unless you switch its gate off for one install with `--no-deletion-gate` (or the switch on Rule IV in the console's Constitution page). The hook is then wired with `--no-deletion-gate` and this check stands down; the Rule's text still binds the agent, and the root-scan and consent checks below still run ([Exclusions](exclusions.md)).
40
+ A force push to a feature branch, the protected-branch-only forms above on a feature branch, a new branch (`checkout -b`, `switch -c`), `reset --hard` on a clean tree, `branch -D` of a pushed branch, or `rm` of a tracked, unmodified file passes. When the gate cannot tell — a glob or `$variable` it would have to expand, a `cd` it cannot follow, `sh -c`/`xargs`, git failing or missing — it asks, and says it could not verify. To read the state it runs `git` itself, only once a command already looked destructive; every other command pays nothing. That is a Rule, so it holds in every build — unless you switch its gate off for one install with `--no-deletion-gate` (or the switch on Rule IV in the console's Constitution page). The hook is then wired with `--no-deletion-gate` and this check stands down; the Rule's text still binds the agent, and the catastrophic-command check above and the root-scan and consent checks below still run ([Exclusions](exclusions.md)).
40
41
  - **Every `git commit` and `git push`** gets a prompt citing *Consent Before Push*. This part belongs to the **process** doctrine pack: build without it, or exclude that rule, and the hook stays wired with `--no-consent` — commits and pushes go through without asking, real data loss is still stopped ([Rules](rules.md)).
41
42
  - **A recursive scan of a whole filesystem** — `find /`, `du -sh /`, `grep -r foo /`, `rg x C:\`, `Get-ChildItem C:\ -Recurse` — gets a prompt citing *Commands Must Return* (on Bob, it is blocked). In Git Bash `/` mounts every drive and mounted share, so such a scan runs for hours, and `| head` does not stop it. The prompt tells the agent to search a specific directory instead. A deeper path (`find /c/Users/me/project`), `ls /` without `-R` (`ls -r` is reverse order, not recursion), a quoted argument (a search pattern such as `grep -rn "/" src`), a `#` comment, and the remote half of an `ssh` command pass. This check is wired in every build, whatever packs or rules you excluded: it rides inside `git-gate` with no flag of its own, because a new flag or verb would reach machines still running an older hook shim, which would refuse it — and Claude Code blocks a tool call whose gate fails.
42
43
  - Any other command passes untouched.
@@ -48,7 +49,7 @@ Geneseed writes its hooks into the host's settings file. Every hook calls one pr
48
49
  - A write to `.geneseed/done-checks.txt`, the done gate's own list (below), gets the same *External Gate* prompt: emptying it would disarm the gate.
49
50
  - Ordinary edits pass untouched.
50
51
 
51
- **The done gate.** Runs when a turn ends, inside `learn` and before it calls any model. A project opts in with `.geneseed/done-checks.txt` at its git root: one command per line, `#` for comments — the checks that prove the work, for example `npm run lint` and `node --test`. If the turn edited a file under that root and a listed check has not passed since the last edit, the turn does not end: the agent is told which checks are owed, citing *Verify Before Asserting*. A check counts when it ran as a shell command, unpiped (`npm test | tail` exits with `tail`'s code, so it proves nothing), and its result was not an error. The gate never runs a check itself. It blocks at most once per turn; the second stop ends the turn and leaves a line in the gate ledger. Only file-write tools count as edits: a shell `sed -i` or redirect is not seen. Without the file, nothing changes. On OpenCode the gate is a reminder on the next request (OpenCode 1 only); on Bob, whose Stop carries no transcript, the turn is not judged and a log line says so. An install built `--no-done-gate` wires `learn` with that flag: the check is skipped, learning still runs ([Exclusions](exclusions.md)).
52
+ **The done gate.** Runs when a turn ends, inside `learn` and before it calls any model. A project opts in with `.geneseed/done-checks.txt` at its git root: one command per line, `#` for comments — the checks that prove the work, for example `npm run lint` and `node --test`. If the turn edited a file under that root and a listed check has not passed since the last edit, the turn does not end: the agent is told which checks are owed, citing *Verify Before Asserting*. A check counts when it ran as a shell command, unpiped (`npm test | tail` exits with `tail`'s code, so it proves nothing), and its result was not an error. The gate never runs a check itself. It reads the transcript from its end, back only to the prompt that opened the turn, so its cost does not grow with the session. It blocks at most once per turn; the second stop ends the turn and leaves a line in the gate ledger. Only file-write tools count as edits: a shell `sed -i` or redirect is not seen. Without the file, nothing changes. On OpenCode the gate is a reminder on the next request (OpenCode 1 only); on Bob, whose Stop carries no transcript, the turn is not judged and a log line says so. An install built `--no-done-gate` wires `learn` with that flag: the check is skipped, learning still runs ([Exclusions](exclusions.md)).
52
53
 
53
54
  **learn.** Takes the tail of the transcript and asks a model to distil what is worth keeping into one-fact-per-file entries under `memory/`. It is shown the facts already stored, so it updates one with the merged text rather than writing a twin (the old version is kept in `memory/archive/`), and moves one the session proved obsolete into `memory/archive/`. It needs `GENESEED_LLM` set to a model command, for example:
54
55
 
@@ -58,7 +58,7 @@ geneseed diff --full # with line-level diffs
58
58
  geneseed diff --out improvements.md # write the improvements file
59
59
  ```
60
60
 
61
- `diff` compares the global OpenCode install by default. Pass `--target <config dir>` to compare another install. The improvements file is self-contained. To make the changes permanent, hand it to an agent in a Geneseed clone and ask it to fold them back into `src/`. Then they survive every future build. The web console's **Changes** page exports the same file.
61
+ `diff` compares the global OpenCode install by default. Pass `--target <config dir>` to compare another install. A file you replaced with a link to your own copy (a skill folder junctioned or symlinked elsewhere, say) is listed as `linked`: Geneseed never writes through a link, so it is yours and not drift, and it stays out of the improvements file. The improvements file is self-contained. To make the changes permanent, hand it to an agent in a Geneseed clone and ask it to fold them back into `src/`. Then they survive every future build. The web console's **Changes** page exports the same file.
62
62
 
63
63
  ## Moving from a clone to npm
64
64
 
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "4.9.1"
5
+ "version": "4.9.3"
6
6
  }
package/js/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `js/` — the module map
2
2
 
3
- Sixty-nine modules in eight folders. This page is the address book: **what each one owns**, and
3
+ Every module under `js/`, in eight folders. This page is the address book: **what each one owns**, and
4
4
  **which file to open first** for a given task. It is not documentation of behaviour — every module
5
5
  has a docblock for that, and the docblock is the thing to read before editing.
6
6
 
@@ -26,8 +26,28 @@ js/ui/ what it prints at a terminal
26
26
  js/web/ the `geneseed web` console
27
27
  ```
28
28
 
29
- Dependency direction is one-way: `lib/` ← everything; `build/` ← `hosts/`; `inspect/` and `web/`
30
- read what `build/` produced. Nothing in `lib/` imports anything above it.
29
+ **Dependency direction is only partly one-way, and no test or doctor arm enforces any of it.**
30
+ `lib/` is the one true leaf: it imports only Node builtins and itself, and every other folder
31
+ imports it. A few modules sit just above it and are read from almost everywhere: `build/source.mjs`
32
+ (the source paths, the rule canon, `aliasesOf`), `hosts/hosts.mjs` (the host config dirs) and
33
+ `loop/score.mjs` (builtins only, by its docblock). Above those, the folders are domains, not
34
+ layers. These pairs import each other (relative imports between folders, measured 2026-10):
35
+
36
+ | pair | edges A→B / B→A | the smaller direction |
37
+ |---|---|---|
38
+ | build ↔ hosts | 24 / 5 | hosts reads `build/source.mjs` (`installs`, `link`, `native`, `opencode`) and `build/version.mjs` (`opencode`) |
39
+ | build ↔ inspect | 2 / 23 | `build/driver.mjs` records deploy roots in `inspect/registry.mjs`; `build/catalog.mjs` reads `inspect/inventory.mjs` |
40
+ | build ↔ loop | 4 / 1 | `loop/catalog.mjs` reads `build/source.mjs` |
41
+ | build ↔ ui | 2 / 1 | `ui/cli.mjs` reads `ROOT` from `build/source.mjs` |
42
+ | build ↔ web | 1 / 9 | `build/generate.mjs` lazily imports `web/daemon.mjs` to restart a stale console after `rebuild-all` |
43
+ | hosts ↔ inspect | 1 / 36 | `hosts/installs.mjs` reads `inspect/registry.mjs`'s deploy roots |
44
+ | ui ↔ web | 1 / 1 | `ui/menu.mjs` starts the daemon; `web/docs.mjs` renders `ui/cli.mjs`'s CLI reference |
45
+ | maintain ↔ web | 1 / 6 | `maintain/update.mjs` lazily imports `web/daemon.mjs` for the same restart |
46
+
47
+ The only gates over the import graph are the transitive closure walks in
48
+ `tests/unit/hook_cli.test.mjs`. They pin which modules the generator and CLI entries may pull
49
+ `child_process` into, not which folder imports which. Adding a cross-folder import is therefore a
50
+ review question, not a test failure.
31
51
 
32
52
  ---
33
53
 
@@ -108,7 +128,7 @@ Everything that inspects an already-built or already-deployed harness and report
108
128
  | `checks-constitution.mjs` | The constitution: rule declarations and citations, LEAN blocks, `HOOK_PINNED`, theme vocabulary, the console's `LAW_META`/`DOCTRINE_META` |
109
129
  | `checks-counts.mjs` | The hand-written counts: AGENT.md tables, README badges and prose, `SHIPPED.md`, held against `src/` |
110
130
  | `scan.mjs` | The shared walk/sort/strip/test primitives every `checks-*` uses. Owns no judgement itself |
111
- | `validate.mjs` | The `--validate-only` verb: renders a sandbox, scans it for tokens and dead links, own exit contract |
131
+ | `validate.mjs` | The `validate` verb (the generator's old `--validate-only`): renders a sandbox, scans it for tokens and dead links, own exit contract |
112
132
  | `inventory.mjs` | The one catalog walk and its taxonomy tables, read by status, the TUI and the web console |
113
133
  | `status.mjs` | `geneseed status` and `version` — the panel data, counts, accent colour, version verdict |
114
134
  | `cost.mjs` | What one install injects every session, by part (root file, skill and agent lists, session files), in characters; owns `CARRIER_CEILING`, which the smoke test gates and the console draws |
@@ -182,15 +202,16 @@ Verbs that change an install that already exists, as opposed to rendering one.
182
202
  | module | owns |
183
203
  |---|---|
184
204
  | `update.mjs` | `upgrade`/`sync-self`/`bootstrap` — git pull, doctor gate, rebuild, each via a respawn |
185
- | `setup.mjs` | The install wizard, and the CLI's only synchronous stdin readers (`ask`/`confirm`/`askChoice`) |
205
+ | `setup.mjs` | The install wizard. Its prompts go through `js/lib/prompt.mjs`'s stdin readers |
186
206
  | `uninstall.mjs` | Removes or disables an install: manifest-driven reversal, plus stash deactivate/reactivate |
187
207
  | `migrate.mjs` | `geneseed migrate` — all-or-nothing re-emit moving installs onto the npm install shape |
188
208
  | `memory.mjs` | `geneseed memory list|rm` — CLI-side fact removal (archive by default, `--purge` deletes), twin of the web delete endpoint |
189
209
 
190
210
  **Before editing:** `update.mjs` is the one module that MUST spawn rather than import — the code on
191
211
  disk is replaced mid-run, so an in-process call would validate the *old* source and then render it.
192
- `setup.mjs` is the whole program's stdin layer, not just setup's. `memory.mjs` hard-unlinks user
193
- facts with no archive path.
212
+ The stdin readers are not here: they live in `js/lib/prompt.mjs`, shared by `setup.mjs`,
213
+ `uninstall.mjs` and `js/web/server.mjs`. `memory.mjs` deletes user facts: `rm` moves a fact into
214
+ `archive/` by default, but `--purge` unlinks it and nothing can bring it back.
194
215
 
195
216
  ## `js/ui/` — the terminal
196
217
 
@@ -243,9 +264,9 @@ and inline lists are held against a surface list written out in
243
264
  | a law (invariant) | `src/laws/universal.md` — `### {{LAW:<id>}} <Principle>`; cite by id, never by number | `docs/extending.md` §2c. Author both LEAN halves: the lean footprint ships nothing else |
244
265
  | to remove or retire a rule | `docs/extending.md` §2e | `RETIRED_RULE_IDS` in `js/build/source.mjs` — a retired id is never reused |
245
266
  | a skill or an agent | `src/skills/<name>.md` or `src/agents/<name>.md` | `docs/extending.md` §3 — eight steps. Write the FILE before the table row or the build aborts |
246
- | a doctor check | `js/inspect/checks-{build,repo,authoring}.mjs` | register with `ran(...)` in `doctor.mjs`, then a PLANTED FAULT in `tests/unit/harness.test.mjs` |
267
+ | a doctor check | `js/inspect/checks-{build,repo,authoring,constitution,counts}.mjs`; `checks-probe.mjs` for a check that runs a live install's gates | wire it in one of two ways. Give it its own `ran(...)` group in `doctor.mjs`, as the module map and the host tables have. Or append it to `authoringProblems`, which is how the constitution and count checks run. Then add a PLANTED FAULT in `tests/unit/harness.test.mjs` (`tests/unit/doctor_probe.test.mjs` for the probe) |
247
268
  | a web endpoint | GET → `js/web/api.mjs`; POST → `js/web/actions.mjs` | declare it in `js/web/routes.mjs`; the partition test probes the real handler |
248
- | a CLI verb | `js/cli-table.json` — the table IS the parser | the `VERBS` row in `bin/geneseed-cli.mjs`, the row count in `cli_table.test.mjs`, `NATIVE` in **two** test files |
269
+ | a CLI verb | `js/cli-table.json` — the table IS the parser | the `VERBS` row in `bin/geneseed-cli.mjs`, the row count in `cli_table.test.mjs`, and, for a Node-native verb with no recorded cell, `NATIVE` in `tests/unit/hook_cli.test.mjs` |
249
270
  | a hook verb | `js/hosts/hooks.mjs` (a gate) or its own `js/hosts/hooks-<verb>.mjs` | `claudeHookGroups` in `settings.mjs`, the `VERBS` row (with its `load`) in `bin/geneseed-hook.mjs`. Keep the import graph tiny |
250
271
  | a console doc page | `docs/<understand|guides|concepts|reference>/<id>.md` — the stem is the id | `docs/extending.md` §4c. Only `kind: "concept"` gets `{N_*}` substitution |
251
272
  | an OpenCode plugin | `adapters/opencode/plugins/geneseed-<name>.js` | `docs/extending.md` §4a — the `geneseed-` prefix is mechanically load-bearing |
package/js/build/args.mjs CHANGED
@@ -11,7 +11,7 @@ import { parseArgs as nodeParseArgs } from 'node:util';
11
11
  import { withPlatformNewlines } from '../lib/fs.mjs';
12
12
  import path from 'node:path';
13
13
  import {
14
- CONFIG, discoverNames, PACK_ORDER, knownRuleIds, knownSkillIds, linkedSkillIds, resolveSkillNames,
14
+ CONFIG, discoverNames, GATE_SWITCHES, PACK_ORDER, knownRuleIds, knownSkillIds, linkedSkillIds, resolveSkillNames,
15
15
  } from './source.mjs';
16
16
  // The loop engine's trust presets — the `--trust` choices. `score.mjs` imports nothing, which is
17
17
  // what lets it sit inside this module's `child_process` ban.
@@ -101,11 +101,10 @@ const VALUED = {
101
101
  };
102
102
  const FLAGS = {
103
103
  '--sync-themes': 'syncThemes', '--validate-only': 'validateOnly',
104
- // Law IV's gate switch. No `harness.config.json` key feeds it, deliberately: a rebuild elides
105
- // the flag when armed, so a config default could disarm an armed install on its next upgrade.
106
- '--no-deletion-gate': 'noDeletionGate',
107
- // Laws I and III, the same contract (`GATE_SWITCHES` in source.mjs).
108
- '--no-secrets-gate': 'noSecretsGate', '--no-done-gate': 'noDoneGate',
104
+ // The gate switches (`GATE_SWITCHES`, law order). No `harness.config.json` key feeds them,
105
+ // deliberately: a rebuild elides a flag when armed, so a config default could disarm an armed
106
+ // install on its next upgrade.
107
+ ...Object.fromEntries(GATE_SWITCHES.map((g) => [g.flag, g.arg])),
109
108
  '-v': 'verbose', '--verbose': 'verbose',
110
109
  };
111
110
 
@@ -306,6 +305,8 @@ function usage() {
306
305
  * this one) refuses the whole typed token, `-bogus`, once.
307
306
  */
308
307
  export function parseArgs(argv, defaults) {
308
+ // Typed open: the gate rows are spread in from `GATE_SWITCHES`, and `cfgDir` is set only when given.
309
+ /** @type {Record<string, any>} */
309
310
  const args = {
310
311
  theme: defaults.theme, posture: defaults.posture, mode: defaults.mode,
311
312
  trust: defaults.trust,
@@ -313,8 +314,8 @@ export function parseArgs(argv, defaults) {
313
314
  excludeRules: defaults.excludeRules,
314
315
  excludeSkills: defaults.excludeSkills ?? [],
315
316
  out: 'Harness', emit: 'files', footprint: 'lean', root: null,
316
- syncThemes: false, validateOnly: false, verbose: false, noDeletionGate: false,
317
- noSecretsGate: false, noDoneGate: false,
317
+ syncThemes: false, validateOnly: false, verbose: false,
318
+ ...Object.fromEntries(GATE_SWITCHES.map((g) => [g.arg, false])),
318
319
  };
319
320
  const { tokens } = nodeParseArgs({
320
321
  args: argv, options: PARSE_OPTIONS, allowPositionals: true, strict: false, tokens: true,