geneseed 4.9.0 → 4.9.2

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 (68) hide show
  1. package/CHANGELOG.md +40 -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 +139 -27
  5. package/adapters/opencode/plugins/geneseed-learn.js +7 -1
  6. package/docs/concepts/hooks.md +1 -1
  7. package/docs/web-ui.md +3 -2
  8. package/harness.config.json +1 -1
  9. package/js/README.md +30 -9
  10. package/js/build/args.mjs +9 -8
  11. package/js/build/driver.mjs +21 -21
  12. package/js/build/emit-claude.mjs +3 -3
  13. package/js/build/generate.mjs +4 -5
  14. package/js/build/source.mjs +18 -22
  15. package/js/hosts/gitref.mjs +5 -3
  16. package/js/hosts/hooks-done.mjs +21 -9
  17. package/js/hosts/hooks-learn.mjs +23 -10
  18. package/js/hosts/hooks-prims.mjs +25 -0
  19. package/js/hosts/hooks.mjs +17 -8
  20. package/js/hosts/installs.mjs +5 -4
  21. package/js/hosts/loss.mjs +124 -18
  22. package/js/hosts/native.mjs +3 -1
  23. package/js/hosts/owned.mjs +34 -6
  24. package/js/hosts/settings.mjs +5 -4
  25. package/js/inspect/cost.mjs +37 -5
  26. package/js/inspect/diff.mjs +132 -61
  27. package/js/inspect/doctor.mjs +4 -3
  28. package/js/inspect/validate.mjs +2 -2
  29. package/js/lib/fs.mjs +73 -2
  30. package/js/maintain/setup.mjs +31 -7
  31. package/js/maintain/uninstall.mjs +1 -1
  32. package/js/maintain/update.mjs +1 -1
  33. package/js/web/actions.mjs +38 -4
  34. package/js/web/api.mjs +21 -13
  35. package/js/web/handler.mjs +5 -17
  36. package/js/web/jobs.mjs +5 -3
  37. package/js/web/routes.mjs +3 -1
  38. package/js/web/server.mjs +3 -1
  39. package/js/web/state.mjs +35 -8
  40. package/package.json +1 -1
  41. package/web/dist/assets/{Activity-DZ3O_DsJ.js → Activity-D7Igwuoo.js} +1 -1
  42. package/web/dist/assets/{ActivityDetail-BQSWwoJ-.js → ActivityDetail-53rp05hx.js} +1 -1
  43. package/web/dist/assets/Dashboard-BoEH5qyY.js +2 -0
  44. package/web/dist/assets/{Docs-DUxizutI.js → Docs-BM56RCT8.js} +3 -3
  45. package/web/dist/assets/GreenhouseView-BTMvGVco.js +2 -0
  46. package/web/dist/assets/Installs-Dh1tB1NK.js +2 -0
  47. package/web/dist/assets/JournalView-F4hlWTVB.js +1 -0
  48. package/web/dist/assets/Laws-iEheocNI.js +3 -0
  49. package/web/dist/assets/Library-d4_uOGmC.js +1 -0
  50. package/web/dist/assets/{Loops-Bzb2Y96W.js → Loops-BbYeWl70.js} +2 -2
  51. package/web/dist/assets/{OperatorHudView-D-nzB6_r.js → OperatorHudView-Dki9Q6W3.js} +1 -1
  52. package/web/dist/assets/Personal-B_YQJjA_.js +1 -0
  53. package/web/dist/assets/{Tabs-DkPfjG3H.js → Tabs-CAl6oFwG.js} +1 -1
  54. package/web/dist/assets/api-CiXlkbe0.js +1 -0
  55. package/web/dist/assets/drawable-BdTDG5oI.js +1 -0
  56. package/web/dist/assets/index-BZgEGRbL.js +2 -0
  57. package/web/dist/index.html +2 -2
  58. package/web/src/pages/Laws.jsx +60 -353
  59. package/web/dist/assets/Dashboard-COLskkOJ.js +0 -2
  60. package/web/dist/assets/GreenhouseView-BSzVX_l7.js +0 -2
  61. package/web/dist/assets/Installs-Ck4YyEzs.js +0 -2
  62. package/web/dist/assets/JournalView-BbbACsYK.js +0 -1
  63. package/web/dist/assets/Laws-BZ_M86r3.js +0 -3
  64. package/web/dist/assets/Library-D_4LbUg2.js +0 -1
  65. package/web/dist/assets/Personal-C66284wd.js +0 -1
  66. package/web/dist/assets/api-Bp5eW_6m.js +0 -1
  67. package/web/dist/assets/drawable-DlrXDOJ7.js +0 -1
  68. package/web/dist/assets/index-DJorAjeH.js +0 -2
package/CHANGELOG.md CHANGED
@@ -8,6 +8,46 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [4.9.2] — 2026-10-18
12
+
13
+ Patch: the review pass — faster emits, console and Stop; Build and setup keep your gate switches; protected checks follow links both ways.
14
+
15
+ ### Upgrade notes
16
+ - Run `geneseed rebuild-all` once after updating so installed hooks and OpenCode plugins pick up the changes.
17
+
18
+ ### Changed
19
+ - **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.
20
+ - **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.
21
+ - **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).
22
+ - **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.
23
+
24
+ ### Fixed
25
+ - **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.
26
+ - **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.
27
+ - **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`.
28
+ - **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.
29
+
30
+ ## [4.9.1] — 2026-10-18
31
+
32
+ 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.
33
+
34
+ ### Upgrade notes
35
+ - Run `geneseed rebuild-all` once after updating so installed hooks and OpenCode plugins pick up the fix.
36
+
37
+ ### Fixed
38
+ - **Deletion Is Deliberate and the root-scan check no longer fail open when shell structure hides the program word.** `true & git reset --hard`, `(git reset --hard)`, `if …; then git reset --hard; fi`, `{ rm x; }`, `echo $(rm x)`, `` echo `rm x` ``, `sudo -u root rm x`, `env -u FOO rm x`, `nice -n 5 rm x`, `timeout 5 rm x`, `true & find / …` and `(find / …)` passed where the plain form asks. The `reset --hard` forms had regressed in 4.7.0. Several changes close it, on Claude Code, OpenClaude and Bob (`git-gate`/`tool-gate`) and in the OpenCode guard:
39
+ - A lone `&` now separates commands. `2>&1`, `<&3`, `&>` and `|&` still do not.
40
+ - Leading `(`, `{`, `!`, `then`, `do` and `else` are stripped before the program word, and so are closing `)`/`}`.
41
+ - A command substitution is judged as a command of its own.
42
+ - Wrappers drop their value-taking flags. `timeout` is now a wrapper.
43
+ - A removal that names no path asks: `… | Remove-Item`, `ri -Force`.
44
+ - `find -delete` asks, and `unlink` counts as a removal.
45
+ - Behind all of these, a removal word written unquoted after a program the gate does not read asks ("could not verify"). That covers a wrapper it does not know (`setsid rm x`), a wrapper flag of unknown arity, `ForEach-Object { Remove-Item $_ }` and `case … rm …`. So does `ssh host rm x`. The exceptions are programs whose `rm` is their own subcommand (npm, pnpm, yarn, bun, cargo, brew, docker, podman) and programs that never run their arguments (echo, printf, the grep family, `which`, `type`, `man`, `Get-Command`, `Get-Help`).
46
+ - Proven-safe forms still pass: a clean tree, a tracked clean file, a quoted pattern, and a `$(cat <<'EOF' … EOF)` commit message.
47
+
48
+ ### Changed
49
+ - **The console stops re-running the doctor when nothing it checks has changed.** A memory delete no longer drops the cached verdict (~3.6 s saved on the next overview); a finished job, an install toggle and a view switch still do. The Doctor page, the Greenhouse and Operator HUD lenses now read the cached run (groups and themes included) instead of running every check on each visit; the page's *Re-run checks* button asks for a fresh run (`GET /api/doctor?fresh=1`). The server warms the cache right after it starts listening, so an overview requested a few seconds later answers in ~0.15 s rather than ~3.8 s.
50
+
11
51
  ## [4.9.0] — 2026-10-18
12
52
 
13
53
  Minor: on/off switches for the Sealed Secrets and done gates, and the console calls the laws Dictates.
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.0**.
23
+ > string. Current label: **4.9.2**.
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
@@ -234,8 +240,9 @@ const SHELL_BLOCK_RE = [
234
240
  // the Bob behaviour — and `GENESEED_GUARD=warn` downgrades it to a log line.
235
241
  //
236
242
  // 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.
243
+ // gitCommonDirOf, currentBranch, protectedBranches, stashExists) — tests/plugins/guard.test.mjs
244
+ // compares each with its gitref.mjs original, quotes, semicolons and layout aside; the block
245
+ // between the twin markers is js/hosts/loss.mjs verbatim, and the same file diffs it.
239
246
  function gitRootOf(cwd) {
240
247
  try {
241
248
  let dir = path.resolve(cwd)
@@ -297,16 +304,31 @@ function stashExists(root) {
297
304
  * a superset of what the parse acts on.
298
305
  */
299
306
  const LOSS_CANDIDATE_RE =
300
- /\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'])(?:rm|rmdir|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"']|$)/im;
307
+ /\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'`])(?:rm|rmdir|unlink|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"'`]|$)|(?:^|\s)-delete(?=\s|$)/im;
301
308
 
302
309
  class Unverified extends Error {}
303
310
  const unverified = (why) => { throw new Unverified(why); };
304
311
 
305
- const RM_PROGS = new Set(['rm', 'rmdir', 'del', 'erase', 'remove-item', 'ri', 'rd']);
312
+ const RM_PROGS = new Set(['rm', 'rmdir', 'unlink', 'del', 'erase', 'remove-item', 'ri', 'rd']);
306
313
  // cmd.exe's own removal verbs take `/s` `/q` switches, which are not paths.
307
314
  const CMD_SWITCH_PROGS = new Set(['rmdir', 'del', 'erase', 'rd']);
308
315
  const CD_PROGS = new Set(['cd', 'pushd', 'chdir', 'set-location', 'sl']);
309
- const WRAPPERS = new Set(['sudo', 'doas', 'command', 'builtin', '\\builtin', 'time', 'nice', 'nohup', 'exec', 'env']);
316
+ // A wrapper runs the command after its own flags: `<short flags taking a value>|<short flags taking
317
+ // none>`. A flag in neither list (`env -S` runs a string, `env -C`/`sudo -D` change directory,
318
+ // `command -v` only looks up) is not guessed at — the segment asks if it names a removal.
319
+ const WRAPPERS = new Map([['sudo', 'ughpCUrtT|AbEHiknPSs'], ['doas', 'uC|ns'], ['command', '|p'],
320
+ ['builtin', '|'], ['\\builtin', '|'], ['time', 'fo|pav'], ['nice', 'n|'], ['nohup', '|'],
321
+ ['exec', 'a|cl'], ['env', 'u|i0v'], ['timeout', 'sk|v'], ['ionice', 'cn|t'], ['stdbuf', 'ioe|']]);
322
+ const WRAPPER_LONG = new Set(['--preserve-status', '--foreground', '--verbose', '--login', '--preserve-env',
323
+ '--non-interactive', '--ignore-environment', '--null']);
324
+ // Shell grammar that may stand before the program word (`(cd x && rm y)`, `if …; then rm y; fi`).
325
+ const STRUCTURE = new Set(['(', '((', '{', '!', 'if', 'then', 'elif', 'else', 'do', 'while', 'until']);
326
+ // The only programs this reader does not parse that may still name a removal word and pass: their
327
+ // `rm`/`del` is their own subcommand (a package, a container), or they never run their arguments
328
+ // (a substitution in one is judged on its own). Any other program over a removal word asks.
329
+ const INERT = new Set(['npm', 'pnpm', 'yarn', 'bun', 'cargo', 'brew', 'docker', 'podman', 'echo', 'printf',
330
+ 'grep', 'egrep', 'fgrep', 'rg', 'findstr', 'select-string', 'which', 'where', 'type', 'man', 'get-command',
331
+ 'get-help']);
310
332
  // Programs that run ANOTHER command from their arguments or stdin: what they would remove is
311
333
  // not on this command line in a form the parse can read, so a removal word inside one asks.
312
334
  const INDIRECT = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd',
@@ -671,6 +693,8 @@ function rmAct(prog, args, dir) {
671
693
  if (opts && CMD_SWITCH_PROGS.has(prog) && /^\/[a-zA-Z?]$/.test(a)) continue;
672
694
  targets.push(a);
673
695
  }
696
+ // `… | Remove-Item`, `ri -Force`: the paths come from the pipe, which this reader cannot see.
697
+ if (!targets.length) unverified(`\`${prog}\` names no path, so what it removes comes from elsewhere`);
674
698
  const groups = new Map();
675
699
  for (const t of targets) {
676
700
  const shown = t.replace(OPAQUE, '');
@@ -712,6 +736,69 @@ function rmAct(prog, args, dir) {
712
736
  /** The program word, stripped of its path and `.exe`, lowercased (PowerShell and cmd are case-blind). */
713
737
  const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
714
738
 
739
+ /**
740
+ * A segment's RAW tokens with the shell grammar around its command dropped: leading `(`, `{`,
741
+ * `!`, `then`, `do`… (a `(` glued to the first word too) and a closing `)`/`}`. Without it
742
+ * `(git reset --hard)` has `(git` for its program and `--hard)` for its flag. `rootScan`
743
+ * shares it, so `(find / …)` is a scan.
744
+ */
745
+ function stripStructure(raw) {
746
+ const t = raw.slice();
747
+ while (t.length) {
748
+ if (STRUCTURE.has(t[0])) t.shift();
749
+ else if (/^\(+./.test(t[0])) t[0] = t[0].replace(/^\(+/, '');
750
+ else break;
751
+ }
752
+ while (t.length && (t[t.length - 1] === '}' || /^\)+$/.test(t[t.length - 1]))) t.pop();
753
+ if (t.length) t[t.length - 1] = t[t.length - 1].replace(/\)+$/, '');
754
+ return t;
755
+ }
756
+
757
+ /**
758
+ * The bodies of the command substitutions in `text` (`$(…)`, backticks, `<(…)`, `>(…)`) —
759
+ * single quotes hide them, double quotes do not. An unclosed one runs to the end of the text:
760
+ * the splitter already cut an unquoted `$(a; b)` at its `;`.
761
+ */
762
+ function substitutions(text) {
763
+ const out = [];
764
+ let sq = false; let dq = false;
765
+ for (let i = 0; i < text.length; i++) {
766
+ const c = text[i];
767
+ if (sq) { if (c === "'") sq = false; continue; }
768
+ if (c === "'" && !dq) { sq = true; continue; }
769
+ if (c === '"') { dq = !dq; continue; }
770
+ if (c === '`') {
771
+ const end = text.indexOf('`', i + 1);
772
+ out.push(text.slice(i + 1, end < 0 ? text.length : end));
773
+ i = end < 0 ? text.length : end;
774
+ } else if ('$<>'.includes(c) && text[i + 1] === '(') {
775
+ let depth = 1; let j = i + 2;
776
+ for (; j < text.length && depth; j++) depth += text[j] === '(' ? 1 : text[j] === ')' ? -1 : 0;
777
+ out.push(text.slice(i + 2, depth ? text.length : j - 1));
778
+ i = j - 1;
779
+ }
780
+ }
781
+ return out;
782
+ }
783
+
784
+ /** `w` past wrapper `name`'s own flags (and `timeout`'s duration), or null when a flag is unknown. */
785
+ function pastWrapper(name, w) {
786
+ const [takes, plain] = WRAPPERS.get(name).split('|');
787
+ let i = 0;
788
+ for (; i < w.length && w[i].length > 1 && w[i].startsWith('-'); i++) {
789
+ const a = w[i];
790
+ if (a === '--') { i += 1; break; }
791
+ if (a.startsWith('--')) { if (a.includes('=') || WRAPPER_LONG.has(a)) continue; return null; }
792
+ if (name === 'nice' && /^-\d+$/.test(a)) continue;
793
+ for (let j = 1; j < a.length; j++) {
794
+ if (takes.includes(a[j])) { if (j === a.length - 1) i += 1; break; } // the value: the rest, or the next word
795
+ if (!plain.includes(a[j])) return null;
796
+ }
797
+ }
798
+ if (name === 'timeout' && /^\d/.test(w[i] || '')) i += 1;
799
+ return w.slice(i);
800
+ }
801
+
715
802
  /**
716
803
  * The verdict for a whole command, given its segments as RAW token arrays (quotes still on —
717
804
  * `js/hosts/hooks.mjs` cuts and tokenises with the same lexer `rootScan` uses, so a quoted
@@ -722,19 +809,33 @@ const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '')
722
809
  * each host's ceiling for a crashed gate (docs/adr/0007-gates-fail-closed-per-host-ceiling.md).
723
810
  * `always: false` skips the two acts that lose data in every state (`reflog expire`, `gc --prune`)
724
811
  * — the OpenCode guard passes it, because a static `permission.bash` glob already asks for those.
812
+ * `split` cuts a command substitution's body into segments the same way (both callers pass their
813
+ * splitter); without it, a substitution that names a removal asks.
814
+ * @param {string[][]} segments
815
+ * @param {string} cwd
816
+ * @param {{ always?: boolean, split?: (text: string) => string[][] }} [options]
725
817
  */
726
- function lossReason(segments, cwd, { always = true } = {}) {
727
- let dir = cwd;
728
- try {
729
- for (const raw of segments) {
818
+ function lossReason(segments, cwd, { always = true, split } = {}) {
819
+ /** The first loss among `segs`, run from `start`; a substitution body is judged as a command. */
820
+ const judge = (segs, start, depth = 0) => {
821
+ let dir = start;
822
+ for (const raw of segs.map(stripStructure)) {
823
+ const text = raw.join(' ');
824
+ const named = LOSS_CANDIDATE_RE.test(text);
825
+ for (const body of named ? substitutions(text) : []) {
826
+ if (!LOSS_CANDIDATE_RE.test(body)) continue;
827
+ // Bounded: `$($($(…` nested past any real command would rescan the text once per level.
828
+ if (!split || depth >= 4) unverified('a command substitution names a removal');
829
+ const v = judge(split(body), dir, depth + 1);
830
+ if (v) return v;
831
+ }
730
832
  let w = words(raw);
731
- while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
833
+ while (w && w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
732
834
  const wrapper = progOf(w[0]);
733
- w = w.slice(1);
734
- // A wrapper's own flags (`sudo -u x`, `env -i`) are not the program.
735
- if (wrapper !== 'command' && wrapper !== 'exec') while (w.length && w[0].startsWith('-')) w = w.slice(1);
835
+ w = /^\w+=/.test(w[0]) ? w.slice(1) : pastWrapper(wrapper, w.slice(1));
836
+ if (!w && named) unverified(`\`${wrapper}\` takes a flag the gate does not know`);
736
837
  }
737
- if (!w.length) continue;
838
+ if (!w || !w.length) continue;
738
839
  const prog = progOf(w[0]);
739
840
  const args = w.slice(1);
740
841
  if (CD_PROGS.has(prog)) {
@@ -747,10 +848,18 @@ function lossReason(segments, cwd, { always = true } = {}) {
747
848
  else if (RM_PROGS.has(prog)) verdict = rmAct(prog, args, dir);
748
849
  else if (INDIRECT.has(prog) && LOSS_CANDIDATE_RE.test(args.join(' '))) {
749
850
  unverified(`\`${prog}\` runs a command the gate cannot read`);
851
+ } else if (named && !INERT.has(prog)
852
+ && LOSS_CANDIDATE_RE.test(raw.map((t) => t.replace(/"[^"]*"?|'[^']*'?/g, ' ')).join(' '))) {
853
+ // A program this reader does not know (a wrapper, a loop, shell syntax) over an unquoted
854
+ // removal word: it may run it, so it is not proven safe. A quoted word is data.
855
+ unverified(`\`${w[0].replace(OPAQUE, '')}\` is not a program the gate reads, and a removal follows it`);
750
856
  }
751
857
  if (verdict) return verdict;
752
858
  }
753
859
  return null;
860
+ };
861
+ try {
862
+ return judge(segments, cwd);
754
863
  } catch (e) {
755
864
  if (!(e instanceof Unverified)) throw e;
756
865
  return `the gate could not verify what this command would lose (${e.message})`;
@@ -776,9 +885,9 @@ const FS_ROOT_RE = /^(?:\/|\/[a-z]\/?|\/mnt\/[a-z]\/?|[a-z]:[\\/]?)\*?$/i
776
885
  const SCAN_WORD_RE = /\b(?:find|du|tree|rg|fd|where\.exe|grep|egrep|ls|dir|get-childitem|gci)\b/i
777
886
  const SCAN_TOKEN_RE = /(?:[^\s"']+|"[^"]*"?|'[^']*'?)+/g
778
887
  const SCAN_WRAPPERS = new Set(["sudo", "command", "\\builtin", "time", "nice"])
779
- // A comment (`#` starting a word), a quoted run (its `;`/`|` is data), a separator, or a heredoc
780
- // opener (never the `<<<` here-string).
781
- const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g
888
+ // A comment (`#` starting a word), a quoted run (its `;`/`|` is data), a separator (a lone `&`
889
+ // too, never the one in `2>&1`/`<&3`/`&>`/`|&`), or a heredoc opener (never the `<<<` here-string).
890
+ const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<![&<>|])&(?![&>])|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g
782
891
  // Cut at separators OUTSIDE quotes and drop every heredoc body (to its TAG line; `<<-` allows
783
892
  // tabs): a commit message that names a root scan is data, not a command to block.
784
893
  function scanSegments(command) {
@@ -812,7 +921,8 @@ function rootScan(command) {
812
921
  if (!SCAN_WORD_RE.test(command)) return null
813
922
  for (const seg of scanSegments(command)) {
814
923
  // Quotes kept until the root test, so a quoted "a:" is told from a bare C:.
815
- let toks = (seg.match(SCAN_TOKEN_RE) || []).filter((t) => !/^\w+=/.test(t))
924
+ // `(find / …)`, `then find / …`: the grammar around the command is not its program.
925
+ let toks = stripStructure(seg.match(SCAN_TOKEN_RE) || []).filter((t) => !/^\w+=/.test(t))
816
926
  while (toks.length && SCAN_WRAPPERS.has(unquote(toks[0]))) toks = toks.slice(1)
817
927
  if (!toks.length) continue
818
928
  // The program word without its path (either slash) or an `.exe` suffix.
@@ -1075,19 +1185,20 @@ function ruleStoreTarget(p) {
1075
1185
  // must not edit in `.geneseed/protected-checks.txt` at its git root, one repo-relative path per
1076
1186
  // line; the list protects itself. Found from the WRITE TARGET up to the first `.git`, re-read on
1077
1187
  // every write (a short file, read only on a write call). No ask tier here, so a hit is a deny.
1188
+ // The target is read twice — as written, and with links followed — and a hit on either is a hit:
1189
+ // a link from outside into a protected folder and a protected folder that links out are both
1190
+ // caught, so following links never loosens the gate.
1078
1191
  const SENSOR_LIST = ".geneseed/protected-checks.txt"
1079
1192
  // The done gate's list (#289, js/hosts/hooks-done.mjs) protects itself the same way, with or
1080
1193
  // without a protected-checks list: emptying it would disarm the gate. Twin of js/hosts/hooks.mjs.
1081
1194
  const DONE_LIST = ".geneseed/done-checks.txt"
1082
1195
  function protectedCheck(p) {
1196
+ return protectedIn(path.resolve(p)) ?? protectedIn(resolvePath(p))
1197
+ }
1198
+ function protectedIn(target) {
1083
1199
  try {
1084
- const target = path.resolve(p)
1085
- let root = path.dirname(target)
1086
- while (!existsSync(path.join(root, ".git"))) {
1087
- const up = path.dirname(root)
1088
- if (up === root) return null
1089
- root = up
1090
- }
1200
+ const root = gitRootOf(path.dirname(target))
1201
+ if (!root) return null
1091
1202
  const fold = (s) => nocase(s.replace(/\\/g, "/"))
1092
1203
  const rel = fold(path.relative(root, target))
1093
1204
  if (rel === fold(DONE_LIST)) return DONE_LIST
@@ -1218,7 +1329,8 @@ export const GeneseedGuard = async (ctx) => {
1218
1329
  if (c && !DELETION_GATE_OFF && LOSS_CANDIDATE_RE.test(c)) {
1219
1330
  // OpenCode's bash tool may run in `workdir`, resolved against the session directory.
1220
1331
  const wd = typeof args.workdir === "string" && args.workdir ? path.resolve(cwd, args.workdir) : cwd
1221
- const loss = lossReason(scanSegments(c).map((seg) => seg.match(SCAN_TOKEN_RE) || []), wd, { always: false })
1332
+ const cut = (t) => scanSegments(t).map((seg) => seg.match(SCAN_TOKEN_RE) || [])
1333
+ const loss = lossReason(cut(c), wd, { always: false, split: cut })
1222
1334
  if (loss) { await deny(`${loss} (Deletion Is Deliberate) — confirm with the user first`, "law-4"); return }
1223
1335
  }
1224
1336
  }
@@ -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
@@ -48,7 +48,7 @@ Geneseed writes its hooks into the host's settings file. Every hook calls one pr
48
48
  - 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
49
  - Ordinary edits pass untouched.
50
50
 
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)).
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 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
52
 
53
53
  **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
54
 
package/docs/web-ui.md CHANGED
@@ -165,8 +165,9 @@ streams the output of background jobs and keeps their history across reloads.
165
165
  added / missing files with per-file expansion and coloured unified-diff lines. Select
166
166
  files to **export an `improvements.md`** back-port, or **restore** them to source.
167
167
  - **Doctor** (`#/doctor`) — runs the same health engine as the `doctor` command (every
168
- theme, parity, links, authoring gates, drift), grouping any problems per check. Re-run
169
- on demand.
168
+ theme, parity, links, authoring gates, drift), grouping any problems per check. It shows
169
+ the server's last run — warmed at start, redone after a job, an install toggle or a view
170
+ switch — and **Re-run checks** runs every check again on demand.
170
171
 
171
172
  ### 🎨 Setup
172
173
 
@@ -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.0"
5
+ "version": "4.9.2"
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,