geneseed 4.8.0 → 4.9.1

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 (75) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/claude-code/README.md +3 -1
  4. package/adapters/opencode/plugins/geneseed-context.js +17 -1
  5. package/adapters/opencode/plugins/geneseed-guard.js +138 -32
  6. package/bin/geneseed-hook.mjs +9 -4
  7. package/docs/concepts/enforcement-table.md +2 -2
  8. package/docs/concepts/exclusions.md +18 -1
  9. package/docs/concepts/hooks.md +2 -2
  10. package/docs/reference/plugin-context.md +1 -1
  11. package/docs/reference/plugin-guard.md +1 -1
  12. package/docs/reference/troubleshoot-doctor.md +2 -1
  13. package/docs/web-ui.md +10 -8
  14. package/harness.config.json +1 -1
  15. package/js/build/args.mjs +3 -0
  16. package/js/build/driver.mjs +14 -6
  17. package/js/build/emit-claude.mjs +3 -3
  18. package/js/build/generate.mjs +16 -12
  19. package/js/build/render.mjs +7 -5
  20. package/js/build/source.mjs +19 -1
  21. package/js/cli-table.json +75 -0
  22. package/js/hosts/hooks-done.mjs +4 -0
  23. package/js/hosts/hooks-learn.mjs +3 -1
  24. package/js/hosts/hooks.mjs +14 -8
  25. package/js/hosts/installs.mjs +20 -11
  26. package/js/hosts/loss.mjs +124 -18
  27. package/js/hosts/settings.mjs +17 -8
  28. package/js/inspect/diff.mjs +4 -4
  29. package/js/inspect/doctor.mjs +4 -3
  30. package/js/inspect/status.mjs +4 -3
  31. package/js/inspect/validate.mjs +9 -4
  32. package/js/maintain/uninstall.mjs +7 -5
  33. package/js/maintain/update.mjs +1 -1
  34. package/js/web/actions.mjs +22 -17
  35. package/js/web/api.mjs +13 -11
  36. package/js/web/handler.mjs +1 -1
  37. package/js/web/routes.mjs +3 -1
  38. package/js/web/server.mjs +3 -1
  39. package/js/web/state.mjs +32 -8
  40. package/package.json +1 -1
  41. package/src/AGENT.md.tmpl +1 -1
  42. package/web/dist/assets/{Activity-B-XbKlzu.js → Activity-GQGAZHIE.js} +1 -1
  43. package/web/dist/assets/{ActivityDetail-8oq0fycD.js → ActivityDetail-DktWyxim.js} +1 -1
  44. package/web/dist/assets/Dashboard-CmSn6JfK.js +2 -0
  45. package/web/dist/assets/Docs-CsWYLnN1.js +5 -0
  46. package/web/dist/assets/FilterInput-zLFXoSt6.js +1 -0
  47. package/web/dist/assets/GreenhouseView-Dt_cIxAY.js +2 -0
  48. package/web/dist/assets/{Installs-DrqPaLDM.js → Installs-CWuUU4I3.js} +2 -2
  49. package/web/dist/assets/JournalView-ClbZj_YF.js +1 -0
  50. package/web/dist/assets/Laws-R5zZCmya.js +3 -0
  51. package/web/dist/assets/{Library-BCaqPf61.js → Library-u36V7oSD.js} +1 -1
  52. package/web/dist/assets/{Loops-Bx3HnlUA.js → Loops-BUBOeM0C.js} +2 -2
  53. package/web/dist/assets/{Markdown-BK38dn4x.js → Markdown-iHvBrpRN.js} +1 -1
  54. package/web/dist/assets/{OperatorHudView-ChQPM8JE.js → OperatorHudView-BJuCYsjf.js} +1 -1
  55. package/web/dist/assets/{Personal-DELm61DF.js → Personal-DWQuSAUz.js} +1 -1
  56. package/web/dist/assets/Tabs-GPQlocTw.js +1 -0
  57. package/web/dist/assets/{activity-D4w9t8fE.js → activity-DvPxoS4Y.js} +1 -1
  58. package/web/dist/assets/api-CiXlkbe0.js +1 -0
  59. package/web/dist/assets/drawable-CE-arUnF.js +1 -0
  60. package/web/dist/assets/{index-r2jRgf6l.js → index-3lnPT_1J.js} +2 -2
  61. package/web/dist/assets/{index-CHpydQJg.css → index-BCJz4_8C.css} +1 -1
  62. package/web/dist/assets/{react-Ci4wQD4a.js → react-BOF6yL40.js} +1 -1
  63. package/web/dist/assets/{sections-GalMS0Zv.js → sections-BET3c1WT.js} +1 -1
  64. package/web/dist/assets/{usePoll-BcywUo55.js → usePoll-NSTzLOF4.js} +1 -1
  65. package/web/dist/index.html +5 -5
  66. package/web/src/pages/Laws.jsx +52 -25
  67. package/web/dist/assets/Dashboard-C4EMz5mf.js +0 -2
  68. package/web/dist/assets/Docs-mq4XDQK_.js +0 -5
  69. package/web/dist/assets/FilterInput-Ikvi-pS9.js +0 -1
  70. package/web/dist/assets/GreenhouseView-D1WOtq3n.js +0 -2
  71. package/web/dist/assets/JournalView-BYpASU-L.js +0 -1
  72. package/web/dist/assets/Laws-A3PFwiPD.js +0 -3
  73. package/web/dist/assets/Tabs-DcMq5IvC.js +0 -1
  74. package/web/dist/assets/api-Bp5eW_6m.js +0 -1
  75. package/web/dist/assets/drawable-BI2_tF3o.js +0 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,40 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [4.9.1] — 2026-10-18
12
+
13
+ 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.
14
+
15
+ ### Upgrade notes
16
+ - Run `geneseed rebuild-all` once after updating so installed hooks and OpenCode plugins pick up the fix.
17
+
18
+ ### Fixed
19
+ - **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:
20
+ - A lone `&` now separates commands. `2>&1`, `<&3`, `&>` and `|&` still do not.
21
+ - Leading `(`, `{`, `!`, `then`, `do` and `else` are stripped before the program word, and so are closing `)`/`}`.
22
+ - A command substitution is judged as a command of its own.
23
+ - Wrappers drop their value-taking flags. `timeout` is now a wrapper.
24
+ - A removal that names no path asks: `… | Remove-Item`, `ri -Force`.
25
+ - `find -delete` asks, and `unlink` counts as a removal.
26
+ - 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`).
27
+ - Proven-safe forms still pass: a clean tree, a tracked clean file, a quoted pattern, and a `$(cat <<'EOF' … EOF)` commit message.
28
+
29
+ ### Changed
30
+ - **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.
31
+
32
+ ## [4.9.0] — 2026-10-18
33
+
34
+ Minor: on/off switches for the Sealed Secrets and done gates, and the console calls the laws Dictates.
35
+
36
+ ### Added
37
+ - **On/off switches for the Sealed Secrets and Verify Before Asserting gates**, beside Law IV's. `geneseed build --no-secrets-gate` stands down the credential scan (`rule-gate` on Claude Code and OpenClaude, `tool-gate` on Bob, the OpenCode guard's key-file block and content scan); `--no-done-gate` stands down the done check in `learn` on Stop (and OpenCode's reminder), while learning still runs. Both armed by default; the Rule's text still binds. The install records `Secrets gate: off` / `Done gate: off` beside its pack list; rebuilds, upgrades, `status`, `diff`, `validate` and the console keep them. The console's Constitution page now has a toggle column on every Rule: a switch on Rules I, III and IV, *no gate* on the others.
38
+
39
+ ### Upgrade notes
40
+ - The secrets and done gate switches: run `geneseed rebuild-all` before switching either off. While the machine's hook shim points at an older checkout, a disarmed install's `rule-gate`/`tool-gate` refuses the new flag and lets every write through (External Gate and Persist Insight too, and on Bob every check), and its `learn` refuses `--no-done-gate`, so that install stops learning as well. OpenCode reads both switches when it starts: restart it after flipping one.
41
+
42
+ ### Changed
43
+ - **The console calls the laws Dictates**, the name AGENT.md gives them, instead of Invariants: the Laws tab and band, the dashboard KPI tile, the constitution map, Greenhouse and the Operator HUD. Routes and internal keys are unchanged (#321).
44
+
11
45
  ## [4.8.0] — 2026-10-18
12
46
 
13
47
  Minor: Deletion Is Deliberate asks only on real data loss, and can be switched off per install.
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.8.0**.
23
+ > string. Current label: **4.9.1**.
24
24
 
25
25
  ## Capabilities
26
26
 
@@ -20,7 +20,9 @@ absolute-path hooks instead — `geneseed setup` does that for you.) It:
20
20
  wired with `--no-consent`: commits and pushes pass without asking, while the
21
21
  Law IV refusals (asked only on real data loss: a force-push to `main`, a `reset --hard`
22
22
  over uncommitted work, an `rm` git cannot undo) stay in every build unless the install is
23
- built `--no-deletion-gate`, which appends that flag and stands the real-loss check down. The hook inspects
23
+ built `--no-deletion-gate`, which appends that flag and stands the real-loss check down
24
+ (`--no-secrets-gate` does the same for `rule-gate`'s credential scan, `--no-done-gate` for
25
+ `learn`'s done check). The hook inspects
24
26
  command and, when it runs a `git commit` or `git push` (bare, flagged, `-C <path>`,
25
27
  or chained like `git add . && git commit … && git push`), returns
26
28
  `permissionDecision: "ask"` so Claude Code **prompts on every such call**. Crucially
@@ -895,6 +895,21 @@ const DONE_LIST = ".geneseed/done-checks.txt"
895
895
  const DONE_EDIT_TOOLS = new Set(["edit", "write", "patch", "apply_patch", "multiedit"])
896
896
  const DONE_SHELL_TOOLS = new Set(["bash", "shell"])
897
897
 
898
+ // Law III's gate switch (`--no-done-gate`, user decision 2026-10-18): the build writes
899
+ // `Done gate: off` into the install's AGENT.md only when the gate is off. Read ONCE, at the first
900
+ // use, from the harness dir (a switch flip takes an OpenCode restart); anything unreadable reads
901
+ // as ARMED. Off silences the reminder and the V2 notice, nothing else.
902
+ let doneGateOffOnce = null
903
+ function doneGateOff() {
904
+ doneGateOffOnce ??= (async () => {
905
+ const dir = await harnessDir()
906
+ if (!dir) return false
907
+ const text = await fs.readFile(path.join(dir, "AGENT.md"), "utf8")
908
+ return /^Done gate:[ \t]*off[ \t]*\r?$/m.test(text)
909
+ })().catch(() => false)
910
+ return doneGateOffOnce
911
+ }
912
+
898
913
  async function readDoneChecks(root) {
899
914
  try {
900
915
  const text = await fs.readFile(path.join(root, DONE_LIST), "utf8")
@@ -1140,6 +1155,7 @@ export const GeneseedContext = async (ctx) => {
1140
1155
  // INJECT_OFF and the visible delivery leave it on, and no early return below can skip it.
1141
1156
  // Its own function, so its early returns end the reminder, never the context injection.
1142
1157
  await (async () => {
1158
+ if (await doneGateOff()) return
1143
1159
  const checks = await readDoneChecks(root)
1144
1160
  if (!checks.length) return
1145
1161
  const owed = doneOwed(output.messages, checks, root)
@@ -1211,7 +1227,7 @@ async function setupV2(ctx) {
1211
1227
  })
1212
1228
 
1213
1229
  // The done gate is not judged on OpenCode 2 (see `doneOwed`): said once, where its log goes.
1214
- if ((await readDoneChecks(root).catch(() => [])).length) {
1230
+ if (!(await doneGateOff()) && (await readDoneChecks(root).catch(() => [])).length) {
1215
1231
  console.error(`[geneseed-context] done gate: ${DONE_LIST} found, but OpenCode 2 turns are not judged.`)
1216
1232
  }
1217
1233
 
@@ -124,26 +124,28 @@ const MODE = (process.env.GENESEED_GUARD || "on").toLowerCase()
124
124
  const OFF = ["off", "0", "false", "no"].includes(MODE)
125
125
  const WARN_ONLY = MODE === "warn"
126
126
 
127
- // Law IV's gate switch (`--no-deletion-gate`, user decision 2026-10-17). The build writes
128
- // `Deletion gate: off` into the install's AGENT.md only when the gate is off; this reads it ONCE
129
- // at load, from the same harness dir the context plugin resolves ($GENESEED_HARNESS, else
130
- // <plugin dir>/.. for a global install, else <plugin dir>/../.. for a project one), so flipping
131
- // the switch takes an OpenCode restart. Anything unreadable or unexpected reads as ARMED. It
132
- // stands down only the real-loss check below — catastrophic commands, root scans, secrets and
133
- // protected checks still block.
134
- function deletionGateOff() {
127
+ // The gate switches (`--no-deletion-gate` 2026-10-17, `--no-secrets-gate` 2026-10-18, user
128
+ // decisions). The build writes `Deletion gate: off` / `Secrets gate: off` into the install's
129
+ // AGENT.md only when that gate is off; this reads the carrier ONCE at load, from the same
130
+ // harness dir the context plugin resolves ($GENESEED_HARNESS, else <plugin dir>/.. for a global
131
+ // install, else <plugin dir>/../.. for a project one), so flipping a switch takes an OpenCode
132
+ // restart. Anything unreadable or unexpected reads as ARMED. Each stands down only its own check
133
+ // below (the real-loss check; the Sealed Secrets path block and content scan) — catastrophic
134
+ // commands, root scans and protected checks still block.
135
+ function carrierText() {
135
136
  try {
136
137
  const dirs = [path.resolve(PLUGIN_DIR, ".."), path.resolve(PLUGIN_DIR, "..", "..")]
137
138
  if (process.env.GENESEED_HARNESS) dirs.unshift(process.env.GENESEED_HARNESS)
138
139
  for (const d of dirs) {
139
- let text
140
- try { text = readFileSync(path.join(d, "AGENT.md"), "utf8") } catch { continue }
141
- return /^Deletion gate:[ \t]*off[ \t]*\r?$/m.test(text)
140
+ try { return readFileSync(path.join(d, "AGENT.md"), "utf8") } catch { continue }
142
141
  }
143
142
  } catch { /* fall through: armed */ }
144
- return false
143
+ return ""
145
144
  }
146
- const DELETION_GATE_OFF = deletionGateOff()
145
+ const CARRIER_TEXT = carrierText()
146
+ const gateOff = (marker) => new RegExp(`^${marker}:[ \\t]*off[ \\t]*\\r?$`, "m").test(CARRIER_TEXT)
147
+ const DELETION_GATE_OFF = gateOff("Deletion gate")
148
+ const SECRETS_GATE_OFF = gateOff("Secrets gate")
147
149
 
148
150
  function log(msg) { console.error(`[geneseed-guard] ${msg}`) }
149
151
 
@@ -295,16 +297,31 @@ function stashExists(root) {
295
297
  * a superset of what the parse acts on.
296
298
  */
297
299
  const LOSS_CANDIDATE_RE =
298
- /\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;
300
+ /\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;
299
301
 
300
302
  class Unverified extends Error {}
301
303
  const unverified = (why) => { throw new Unverified(why); };
302
304
 
303
- const RM_PROGS = new Set(['rm', 'rmdir', 'del', 'erase', 'remove-item', 'ri', 'rd']);
305
+ const RM_PROGS = new Set(['rm', 'rmdir', 'unlink', 'del', 'erase', 'remove-item', 'ri', 'rd']);
304
306
  // cmd.exe's own removal verbs take `/s` `/q` switches, which are not paths.
305
307
  const CMD_SWITCH_PROGS = new Set(['rmdir', 'del', 'erase', 'rd']);
306
308
  const CD_PROGS = new Set(['cd', 'pushd', 'chdir', 'set-location', 'sl']);
307
- const WRAPPERS = new Set(['sudo', 'doas', 'command', 'builtin', '\\builtin', 'time', 'nice', 'nohup', 'exec', 'env']);
309
+ // A wrapper runs the command after its own flags: `<short flags taking a value>|<short flags taking
310
+ // none>`. A flag in neither list (`env -S` runs a string, `env -C`/`sudo -D` change directory,
311
+ // `command -v` only looks up) is not guessed at — the segment asks if it names a removal.
312
+ const WRAPPERS = new Map([['sudo', 'ughpCUrtT|AbEHiknPSs'], ['doas', 'uC|ns'], ['command', '|p'],
313
+ ['builtin', '|'], ['\\builtin', '|'], ['time', 'fo|pav'], ['nice', 'n|'], ['nohup', '|'],
314
+ ['exec', 'a|cl'], ['env', 'u|i0v'], ['timeout', 'sk|v'], ['ionice', 'cn|t'], ['stdbuf', 'ioe|']]);
315
+ const WRAPPER_LONG = new Set(['--preserve-status', '--foreground', '--verbose', '--login', '--preserve-env',
316
+ '--non-interactive', '--ignore-environment', '--null']);
317
+ // Shell grammar that may stand before the program word (`(cd x && rm y)`, `if …; then rm y; fi`).
318
+ const STRUCTURE = new Set(['(', '((', '{', '!', 'if', 'then', 'elif', 'else', 'do', 'while', 'until']);
319
+ // The only programs this reader does not parse that may still name a removal word and pass: their
320
+ // `rm`/`del` is their own subcommand (a package, a container), or they never run their arguments
321
+ // (a substitution in one is judged on its own). Any other program over a removal word asks.
322
+ const INERT = new Set(['npm', 'pnpm', 'yarn', 'bun', 'cargo', 'brew', 'docker', 'podman', 'echo', 'printf',
323
+ 'grep', 'egrep', 'fgrep', 'rg', 'findstr', 'select-string', 'which', 'where', 'type', 'man', 'get-command',
324
+ 'get-help']);
308
325
  // Programs that run ANOTHER command from their arguments or stdin: what they would remove is
309
326
  // not on this command line in a form the parse can read, so a removal word inside one asks.
310
327
  const INDIRECT = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd',
@@ -669,6 +686,8 @@ function rmAct(prog, args, dir) {
669
686
  if (opts && CMD_SWITCH_PROGS.has(prog) && /^\/[a-zA-Z?]$/.test(a)) continue;
670
687
  targets.push(a);
671
688
  }
689
+ // `… | Remove-Item`, `ri -Force`: the paths come from the pipe, which this reader cannot see.
690
+ if (!targets.length) unverified(`\`${prog}\` names no path, so what it removes comes from elsewhere`);
672
691
  const groups = new Map();
673
692
  for (const t of targets) {
674
693
  const shown = t.replace(OPAQUE, '');
@@ -710,6 +729,69 @@ function rmAct(prog, args, dir) {
710
729
  /** The program word, stripped of its path and `.exe`, lowercased (PowerShell and cmd are case-blind). */
711
730
  const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
712
731
 
732
+ /**
733
+ * A segment's RAW tokens with the shell grammar around its command dropped: leading `(`, `{`,
734
+ * `!`, `then`, `do`… (a `(` glued to the first word too) and a closing `)`/`}`. Without it
735
+ * `(git reset --hard)` has `(git` for its program and `--hard)` for its flag. `rootScan`
736
+ * shares it, so `(find / …)` is a scan.
737
+ */
738
+ function stripStructure(raw) {
739
+ const t = raw.slice();
740
+ while (t.length) {
741
+ if (STRUCTURE.has(t[0])) t.shift();
742
+ else if (/^\(+./.test(t[0])) t[0] = t[0].replace(/^\(+/, '');
743
+ else break;
744
+ }
745
+ while (t.length && (t[t.length - 1] === '}' || /^\)+$/.test(t[t.length - 1]))) t.pop();
746
+ if (t.length) t[t.length - 1] = t[t.length - 1].replace(/\)+$/, '');
747
+ return t;
748
+ }
749
+
750
+ /**
751
+ * The bodies of the command substitutions in `text` (`$(…)`, backticks, `<(…)`, `>(…)`) —
752
+ * single quotes hide them, double quotes do not. An unclosed one runs to the end of the text:
753
+ * the splitter already cut an unquoted `$(a; b)` at its `;`.
754
+ */
755
+ function substitutions(text) {
756
+ const out = [];
757
+ let sq = false; let dq = false;
758
+ for (let i = 0; i < text.length; i++) {
759
+ const c = text[i];
760
+ if (sq) { if (c === "'") sq = false; continue; }
761
+ if (c === "'" && !dq) { sq = true; continue; }
762
+ if (c === '"') { dq = !dq; continue; }
763
+ if (c === '`') {
764
+ const end = text.indexOf('`', i + 1);
765
+ out.push(text.slice(i + 1, end < 0 ? text.length : end));
766
+ i = end < 0 ? text.length : end;
767
+ } else if ('$<>'.includes(c) && text[i + 1] === '(') {
768
+ let depth = 1; let j = i + 2;
769
+ for (; j < text.length && depth; j++) depth += text[j] === '(' ? 1 : text[j] === ')' ? -1 : 0;
770
+ out.push(text.slice(i + 2, depth ? text.length : j - 1));
771
+ i = j - 1;
772
+ }
773
+ }
774
+ return out;
775
+ }
776
+
777
+ /** `w` past wrapper `name`'s own flags (and `timeout`'s duration), or null when a flag is unknown. */
778
+ function pastWrapper(name, w) {
779
+ const [takes, plain] = WRAPPERS.get(name).split('|');
780
+ let i = 0;
781
+ for (; i < w.length && w[i].length > 1 && w[i].startsWith('-'); i++) {
782
+ const a = w[i];
783
+ if (a === '--') { i += 1; break; }
784
+ if (a.startsWith('--')) { if (a.includes('=') || WRAPPER_LONG.has(a)) continue; return null; }
785
+ if (name === 'nice' && /^-\d+$/.test(a)) continue;
786
+ for (let j = 1; j < a.length; j++) {
787
+ if (takes.includes(a[j])) { if (j === a.length - 1) i += 1; break; } // the value: the rest, or the next word
788
+ if (!plain.includes(a[j])) return null;
789
+ }
790
+ }
791
+ if (name === 'timeout' && /^\d/.test(w[i] || '')) i += 1;
792
+ return w.slice(i);
793
+ }
794
+
713
795
  /**
714
796
  * The verdict for a whole command, given its segments as RAW token arrays (quotes still on —
715
797
  * `js/hosts/hooks.mjs` cuts and tokenises with the same lexer `rootScan` uses, so a quoted
@@ -720,19 +802,33 @@ const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '')
720
802
  * each host's ceiling for a crashed gate (docs/adr/0007-gates-fail-closed-per-host-ceiling.md).
721
803
  * `always: false` skips the two acts that lose data in every state (`reflog expire`, `gc --prune`)
722
804
  * — the OpenCode guard passes it, because a static `permission.bash` glob already asks for those.
805
+ * `split` cuts a command substitution's body into segments the same way (both callers pass their
806
+ * splitter); without it, a substitution that names a removal asks.
807
+ * @param {string[][]} segments
808
+ * @param {string} cwd
809
+ * @param {{ always?: boolean, split?: (text: string) => string[][] }} [options]
723
810
  */
724
- function lossReason(segments, cwd, { always = true } = {}) {
725
- let dir = cwd;
726
- try {
727
- for (const raw of segments) {
811
+ function lossReason(segments, cwd, { always = true, split } = {}) {
812
+ /** The first loss among `segs`, run from `start`; a substitution body is judged as a command. */
813
+ const judge = (segs, start, depth = 0) => {
814
+ let dir = start;
815
+ for (const raw of segs.map(stripStructure)) {
816
+ const text = raw.join(' ');
817
+ const named = LOSS_CANDIDATE_RE.test(text);
818
+ for (const body of named ? substitutions(text) : []) {
819
+ if (!LOSS_CANDIDATE_RE.test(body)) continue;
820
+ // Bounded: `$($($(…` nested past any real command would rescan the text once per level.
821
+ if (!split || depth >= 4) unverified('a command substitution names a removal');
822
+ const v = judge(split(body), dir, depth + 1);
823
+ if (v) return v;
824
+ }
728
825
  let w = words(raw);
729
- while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
826
+ while (w && w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
730
827
  const wrapper = progOf(w[0]);
731
- w = w.slice(1);
732
- // A wrapper's own flags (`sudo -u x`, `env -i`) are not the program.
733
- if (wrapper !== 'command' && wrapper !== 'exec') while (w.length && w[0].startsWith('-')) w = w.slice(1);
828
+ w = /^\w+=/.test(w[0]) ? w.slice(1) : pastWrapper(wrapper, w.slice(1));
829
+ if (!w && named) unverified(`\`${wrapper}\` takes a flag the gate does not know`);
734
830
  }
735
- if (!w.length) continue;
831
+ if (!w || !w.length) continue;
736
832
  const prog = progOf(w[0]);
737
833
  const args = w.slice(1);
738
834
  if (CD_PROGS.has(prog)) {
@@ -745,10 +841,18 @@ function lossReason(segments, cwd, { always = true } = {}) {
745
841
  else if (RM_PROGS.has(prog)) verdict = rmAct(prog, args, dir);
746
842
  else if (INDIRECT.has(prog) && LOSS_CANDIDATE_RE.test(args.join(' '))) {
747
843
  unverified(`\`${prog}\` runs a command the gate cannot read`);
844
+ } else if (named && !INERT.has(prog)
845
+ && LOSS_CANDIDATE_RE.test(raw.map((t) => t.replace(/"[^"]*"?|'[^']*'?/g, ' ')).join(' '))) {
846
+ // A program this reader does not know (a wrapper, a loop, shell syntax) over an unquoted
847
+ // removal word: it may run it, so it is not proven safe. A quoted word is data.
848
+ unverified(`\`${w[0].replace(OPAQUE, '')}\` is not a program the gate reads, and a removal follows it`);
748
849
  }
749
850
  if (verdict) return verdict;
750
851
  }
751
852
  return null;
853
+ };
854
+ try {
855
+ return judge(segments, cwd);
752
856
  } catch (e) {
753
857
  if (!(e instanceof Unverified)) throw e;
754
858
  return `the gate could not verify what this command would lose (${e.message})`;
@@ -774,9 +878,9 @@ const FS_ROOT_RE = /^(?:\/|\/[a-z]\/?|\/mnt\/[a-z]\/?|[a-z]:[\\/]?)\*?$/i
774
878
  const SCAN_WORD_RE = /\b(?:find|du|tree|rg|fd|where\.exe|grep|egrep|ls|dir|get-childitem|gci)\b/i
775
879
  const SCAN_TOKEN_RE = /(?:[^\s"']+|"[^"]*"?|'[^']*'?)+/g
776
880
  const SCAN_WRAPPERS = new Set(["sudo", "command", "\\builtin", "time", "nice"])
777
- // A comment (`#` starting a word), a quoted run (its `;`/`|` is data), a separator, or a heredoc
778
- // opener (never the `<<<` here-string).
779
- const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g
881
+ // A comment (`#` starting a word), a quoted run (its `;`/`|` is data), a separator (a lone `&`
882
+ // too, never the one in `2>&1`/`<&3`/`&>`/`|&`), or a heredoc opener (never the `<<<` here-string).
883
+ const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<![&<>|])&(?![&>])|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g
780
884
  // Cut at separators OUTSIDE quotes and drop every heredoc body (to its TAG line; `<<-` allows
781
885
  // tabs): a commit message that names a root scan is data, not a command to block.
782
886
  function scanSegments(command) {
@@ -810,7 +914,8 @@ function rootScan(command) {
810
914
  if (!SCAN_WORD_RE.test(command)) return null
811
915
  for (const seg of scanSegments(command)) {
812
916
  // Quotes kept until the root test, so a quoted "a:" is told from a bare C:.
813
- let toks = (seg.match(SCAN_TOKEN_RE) || []).filter((t) => !/^\w+=/.test(t))
917
+ // `(find / …)`, `then find / …`: the grammar around the command is not its program.
918
+ let toks = stripStructure(seg.match(SCAN_TOKEN_RE) || []).filter((t) => !/^\w+=/.test(t))
814
919
  while (toks.length && SCAN_WRAPPERS.has(unquote(toks[0]))) toks = toks.slice(1)
815
920
  if (!toks.length) continue
816
921
  // The program word without its path (either slash) or an `.exe` suffix.
@@ -1168,7 +1273,7 @@ export const GeneseedGuard = async (ctx) => {
1168
1273
  // blocks the whole call (see `pickPath` for why this is not a single path).
1169
1274
  const paths = pickPath(args, cwd)
1170
1275
  for (const p of paths) {
1171
- if (SECRET_RE.some((re) => re.test(p))) { await deny(`write to secret/key file ${p} (Sealed Secrets)`, "law-1"); return }
1276
+ if (!SECRETS_GATE_OFF && SECRET_RE.some((re) => re.test(p))) { await deny(`write to secret/key file ${p} (Sealed Secrets)`, "law-1"); return }
1172
1277
  if (SECRET_WARN_RE.some((re) => re.test(p))) log(`WARN: writing ${p} — keep secrets out of tracked files (Sealed Secrets)`)
1173
1278
  const sensor = protectedCheck(p)
1174
1279
  if (sensor) { await deny(`${p} is a protected check (${sensor === DONE_LIST ? "the done gate list itself" : `${sensor}, listed in ${SENSOR_LIST}`}) — a check the agent can edit is not a check (External Gate)`, "rigor-5"); return }
@@ -1185,7 +1290,7 @@ export const GeneseedGuard = async (ctx) => {
1185
1290
  // FILE, not pooled — a patch touching `.env` AND an ordinary file must still catch a
1186
1291
  // secret in the ordinary file; only the hunk whose OWN path is `.env`-shaped is
1187
1292
  // exempt, same as the path WARN tier above.
1188
- for (const { path: cp, content } of pickAddedContentByPath(args, cwd)) {
1293
+ for (const { path: cp, content } of SECRETS_GATE_OFF ? [] : pickAddedContentByPath(args, cwd)) {
1189
1294
  if (SECRET_WARN_RE.some((re) => re.test(cp))) continue
1190
1295
  if (SECRET_CONTENT_RE.test(content)) {
1191
1296
  await deny(`${cp} would carry a credential-shaped string (Sealed Secrets) — secrets `
@@ -1216,7 +1321,8 @@ export const GeneseedGuard = async (ctx) => {
1216
1321
  if (c && !DELETION_GATE_OFF && LOSS_CANDIDATE_RE.test(c)) {
1217
1322
  // OpenCode's bash tool may run in `workdir`, resolved against the session directory.
1218
1323
  const wd = typeof args.workdir === "string" && args.workdir ? path.resolve(cwd, args.workdir) : cwd
1219
- const loss = lossReason(scanSegments(c).map((seg) => seg.match(SCAN_TOKEN_RE) || []), wd, { always: false })
1324
+ const cut = (t) => scanSegments(t).map((seg) => seg.match(SCAN_TOKEN_RE) || [])
1325
+ const loss = lossReason(cut(c), wd, { always: false, split: cut })
1220
1326
  if (loss) { await deny(`${loss} (Deletion Is Deliberate) — confirm with the user first`, "law-4"); return }
1221
1327
  }
1222
1328
  }
@@ -75,16 +75,21 @@ const VERBS = {
75
75
  flags: HOSTED,
76
76
  switches: { '--no-consent': 'noConsent', '--no-deletion-gate': 'noDeletionGate' },
77
77
  },
78
- 'rule-gate': { load: gates, fn: 'cmdRuleGate', flags: HOSTED },
78
+ 'rule-gate': {
79
+ load: gates, fn: 'cmdRuleGate', flags: HOSTED, switches: { '--no-secrets-gate': 'noSecretsGate' },
80
+ },
79
81
  'tool-gate': {
80
- load: gates, fn: 'cmdToolGate', flags: HOSTED, switches: { '--no-deletion-gate': 'noDeletionGate' },
82
+ load: gates,
83
+ fn: 'cmdToolGate',
84
+ flags: HOSTED,
85
+ switches: { '--no-deletion-gate': 'noDeletionGate', '--no-secrets-gate': 'noSecretsGate' },
81
86
  },
82
87
  learn: {
83
88
  load: () => import('../js/hosts/hooks-learn.mjs'),
84
89
  fn: 'cmdLearn',
85
90
  // `--host` only keys the global stand-down's marker (js/hosts/hosts.mjs); learn has no dialect.
86
91
  flags: { '--memory': 'memory', '--host': 'host' },
87
- switches: { '--consolidate': 'consolidate' },
92
+ switches: { '--consolidate': 'consolidate', '--no-done-gate': 'noDoneGate' },
88
93
  positional: 'file',
89
94
  },
90
95
  };
@@ -104,7 +109,7 @@ function die(code, msg) {
104
109
  function parse(spec, argv) {
105
110
  const args = {
106
111
  root: null, host: null, memory: null, consolidate: false, noConsent: false,
107
- noDeletionGate: false, file: null,
112
+ noDeletionGate: false, noSecretsGate: false, noDoneGate: false, file: null,
108
113
  };
109
114
  for (let i = 0; i < argv.length; i += 1) {
110
115
  const tok = argv[i];
@@ -21,12 +21,12 @@ silently guarding the wrong rule after a renumber. See [ADR 0005](../adr/0005-st
21
21
 
22
22
  | id | Pack | Claude Code | OpenClaude | OpenCode | Bob |
23
23
  |---|---|---|---|---|---|
24
- | `sealed-secrets` | law | ask: rule-gate (credential-shaped content outside `.env*`) | same as Claude Code | block: guard plugin (key/credential file path, or vendor-prefixed secret content); warns only on `.env*` | block (exit 2) |
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
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) |
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) |
29
- | `verify-before-asserting` | law | block at Stop, once per turn: the done gate in `learn` (a turn that edited files, while a check in the project's `.geneseed/done-checks.txt` has not passed since the last edit). Opt-in per project | same as Claude Code (**unverified live**: a frozen fork, and no check runs a model turn) | remind: context plugin, on the next request (OpenCode 1 only; OpenCode 2 is not judged, and logs so) | not judged: Stop carries no transcript (log line) |
29
+ | `verify-before-asserting` | law | block at Stop, once per turn: the done gate in `learn` (a turn that edited files, while a check in the project's `.geneseed/done-checks.txt` has not passed since the last edit). Opt-in per project; switchable per install with `--no-done-gate` (see [Exclusions](exclusions.md)) | same as Claude Code (**unverified live**: a frozen fork, and no check runs a model turn) | remind: context plugin, on the next request (OpenCode 1 only; OpenCode 2 is not judged, and logs so) | not judged: Stop carries no transcript (log line) |
30
30
  | `commands-must-return` | ops | ask: git-gate (a recursive scan rooted at a filesystem root: `find /`, `du`, `grep -r`, `rg`, `Get-ChildItem -Recurse` at `/`, `/c`, `C:\`) | same as Claude Code | block: guard plugin (same root-scan pattern) | block (exit 2) |
31
31
 
32
32
  Bob has no "ask" tier (see [ADR 0007](../adr/0007-gates-fail-closed-per-host-ceiling.md)), so a
@@ -7,7 +7,7 @@ section: "Configuration"
7
7
  description: "Three ways to switch things off: one rule, one skill, or every hook in a folder."
8
8
  link: {"hash": "#/harness", "label": "Manage in Harness →"}
9
9
  ---
10
- Geneseed has three ways to switch something off, and they work at different scales (plus one on/off switch for Law IV's gate, below):
10
+ Geneseed has three ways to switch something off, and they work at different scales (plus an on/off switch for the hook gates of Laws I, III and IV, below):
11
11
 
12
12
  | | Sovereign repo | Per-rule exclusion | Per-skill exclusion |
13
13
  | --- | --- | --- | --- |
@@ -88,3 +88,20 @@ Limits:
88
88
  - **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
89
  - **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.
90
90
  - **An older hook shim silences the whole gate.** The flag is new. While the machine's shared hook shim still points at an older checkout, that checkout refuses the flag and the gate verb lets every call through on a disarmed install — on Claude Code and OpenClaude the root-scan and consent checks too, on Bob also *Sealed Secrets* and *External Gate*. Update the checkout the shim points at (`geneseed rebuild-all`) before switching the gate off.
91
+
92
+ ### Law I secrets gate and Law III done gate — on or off
93
+
94
+ The same switch, for two more gates. Both are armed by default, and the Rule's text in the carrier still binds the agent with either off.
95
+
96
+ ```bash
97
+ geneseed build --emit claude-global --no-secrets-gate # Sealed Secrets
98
+ geneseed build --emit claude-global --no-done-gate # Verify Before Asserting
99
+ ```
100
+
101
+ The console's Constitution page has a switch on Rule I's and Rule III's rows too (*Enforced by* reads **Gate off** while one is off); the other Rules show *no gate*, because only their text enforces them. The install records `Secrets gate: off` / `Done gate: off` beside `Deletion gate:`, written only when off; rebuilds, upgrades and `geneseed status` carry them like Law IV's.
102
+
103
+ What stops, per host:
104
+ - **`--no-secrets-gate`**: the credential scan of `rule-gate` (Claude Code, OpenClaude) and of `tool-gate` (Bob, which then no longer exits 2 for it), and on OpenCode the guard plugin's *Sealed Secrets* block — both the key/credential file path block and the content scan. *External Gate*, *Persist Insight*, the deletion gate and the root-scan check stay.
105
+ - **`--no-done-gate`**: the done check `learn` runs on Stop (Claude Code, OpenClaude; on Bob, the "turn not judged" log line), and on OpenCode the context plugin's reminder (OpenCode 1) and its one-line notice (OpenCode 2). Learning itself still runs, and `.geneseed/done-checks.txt` stays protected by *External Gate*.
106
+
107
+ Limits: OpenCode reads both switches from the install's `AGENT.md` when it starts, so restart it after flipping one. A global install beside a project install: `rule-gate` and Bob's `tool-gate` do not stand down for a project install on any host, and neither does the OpenCode guard, so an armed global install still asks about (or blocks) a credential write inside a project whose own install has the secrets gate off. `learn` does stand down on Claude Code and OpenClaude, so there the project's done gate switch decides. And the older-shim limit above is sharper for the done gate: its flag rides on `learn`, so while the shim points at an older checkout an install with the done gate off **loses learning too**, not only the gate. Run `geneseed rebuild-all` before switching either gate off.
@@ -42,13 +42,13 @@ Geneseed writes its hooks into the host's settings file. Every hook calls one pr
42
42
  - Any other command passes untouched.
43
43
 
44
44
  **rule-gate.** Runs before every file write.
45
- - Content that looks like a credential (a cloud access key, a GitHub or Anthropic token, a Slack token, a private-key block) going into any file other than `.env*` gets a prompt citing *Sealed Secrets*.
45
+ - Content that looks like a credential (a cloud access key, a GitHub or Anthropic token, a Slack token, a private-key block) going into any file other than `.env*` gets a prompt citing *Sealed Secrets*. An install built `--no-secrets-gate` wires the hook with that flag and this check alone stands down ([Exclusions](exclusions.md)).
46
46
  - A write to `user-rules.md`, `MEMORY.md` or a file in the install's `memory/` gets a prompt citing *Persist Insight*: whether something is a standing rule or a fact to remember is your call, settled through the `rule` [skill](skills.md).
47
47
  - A write to a check the project protects gets a prompt citing *External Gate* (on Bob, it is blocked). A project opts in with `.geneseed/protected-checks.txt` at its git root: one repo-relative file or folder per line, `#` for comments — the checks and tests the agent is judged by. A check the agent can edit is not a check. The list protects itself. Only file-write tools are seen: a shell `sed -i` or redirect is not, so run the same checks in CI as the boundary that holds.
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.
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
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
 
@@ -17,7 +17,7 @@ One of the [OpenCode plugins](opencode-plugins.md). Its switches are also listed
17
17
 
18
18
  **When it runs.** On every request: the context rides invisibly in the request's message list, so it also survives compaction. If your OpenCode build lacks that (experimental) hook, the plugin notices and falls back to one visible message at session start, re-pushed when the session compacts.
19
19
 
20
- **The done gate's reminder.** In a project with `.geneseed/done-checks.txt` (see [Hooks](../concepts/hooks.md)), a request that follows a turn which edited files without every listed check passing since gets one more invisible message naming the checks owed. OpenCode has no turn-end event a plugin can block on, so this is a reminder, not a block. OpenCode 1 only: on OpenCode 2 the turn is not judged, and the plugin logs that once. `GENESEED_CONTEXT_INJECT=off` does not turn it off; removing the file does.
20
+ **The done gate's reminder.** In a project with `.geneseed/done-checks.txt` (see [Hooks](../concepts/hooks.md)), a request that follows a turn which edited files without every listed check passing since gets one more invisible message naming the checks owed. OpenCode has no turn-end event a plugin can block on, so this is a reminder, not a block. OpenCode 1 only: on OpenCode 2 the turn is not judged, and the plugin logs that once. `GENESEED_CONTEXT_INJECT=off` does not turn it off; removing the file does, and so does an install built `--no-done-gate` (`Done gate: off` in its `AGENT.md`, read once when OpenCode starts), which also silences the OpenCode 2 log line.
21
21
 
22
22
  **What you see.** Normally nothing — the context is there, but not shown. With the visible fallback, a `PROJECT CONTEXT` block at the top of the session.
23
23
 
@@ -10,7 +10,7 @@ One of the [OpenCode plugins](opencode-plugins.md). [Environment variables](envi
10
10
 
11
11
  **What it does.** Enforces the safety rules at the tool boundary — before a tool call runs, not after. High-confidence patterns only, so legitimate work is not caught.
12
12
 
13
- - **Blocks** writes to private-key and credential files (*Sealed Secrets*), catastrophic shell commands such as deleting the filesystem root and every command that would really lose data — a protected branch deleted or force-pushed, an unpushed unmerged branch deleted, a protected branch force-moved off commits nothing else holds, uncommitted work discarded, a stash dropped, files git cannot restore removed — judged against the repository's state, the same decisions the Claude/Bob hook makes (*Deletion Is Deliberate*; `reflog expire` and `gc --prune` are left to the `permission.bash` ask; an install built `--no-deletion-gate` carries `Deletion gate: off` in its `AGENT.md`, which the plugin reads once at load and then stops blocking real loss — catastrophic commands still block), a recursive scan rooted at a whole filesystem such as `find /` (*Commands Must Return* — search a specific directory instead), and any change — write, move, rename, delete — under a wiki folder declared `protected` in `geneseed-wiki.jsonc`, and writes to a check listed in the project's `.geneseed/protected-checks.txt` (*External Gate* — see [Hooks](../concepts/hooks.md)).
13
+ - **Blocks** writes to private-key and credential files and writes whose content carries a credential (*Sealed Secrets*; an install built `--no-secrets-gate` carries `Secrets gate: off` in its `AGENT.md`, read once at load, and both stop blocking), catastrophic shell commands such as deleting the filesystem root and every command that would really lose data — a protected branch deleted or force-pushed, an unpushed unmerged branch deleted, a protected branch force-moved off commits nothing else holds, uncommitted work discarded, a stash dropped, files git cannot restore removed — judged against the repository's state, the same decisions the Claude/Bob hook makes (*Deletion Is Deliberate*; `reflog expire` and `gc --prune` are left to the `permission.bash` ask; an install built `--no-deletion-gate` carries `Deletion gate: off` in its `AGENT.md`, which the plugin reads once at load and then stops blocking real loss — catastrophic commands still block), a recursive scan rooted at a whole filesystem such as `find /` (*Commands Must Return* — search a specific directory instead), and any change — write, move, rename, delete — under a wiki folder declared `protected` in `geneseed-wiki.jsonc`, and writes to a check listed in the project's `.geneseed/protected-checks.txt` (*External Gate* — see [Hooks](../concepts/hooks.md)).
14
14
  - **Warns** (logged, allowed) on `.env` writes.
15
15
  - **Speed-bumps** the *first* write to `user-rules.md` or a memory file: it is refused once, naming *Persist Insight* — whether something is a standing rule or a fact to remember is your call, settled through the `rule` skill. The re-issued write goes through.
16
16
 
@@ -43,7 +43,8 @@ These come only from `geneseed doctor --probe`. It runs each installed gate exac
43
43
  settings spell it, through the host's own shell, on `git reflog expire --expire=now --all` (which must be refused)
44
44
  and `git status` (which must go through). A gate wired with `--no-deletion-gate` lets the first one
45
45
  through by design, so it is probed on `find / -name geneseed-probe` instead — a root-wide scan it
46
- must still refuse. An OpenCode install's guard plugin has no command to
46
+ must still refuse. `--no-secrets-gate` and `--no-done-gate` change nothing here: neither judges a
47
+ shell command. An OpenCode install's guard plugin has no command to
47
48
  run: the probe imports it and calls its hook through both plugin APIs (V1 and V2), with `rm -rf /`
48
49
  as the call it must refuse. `GENESEED_GUARD=off` or `warn` in your terminal makes that a note,
49
50
  not a failure.
package/docs/web-ui.md CHANGED
@@ -77,8 +77,8 @@ streams the output of background jobs and keeps their history across reloads.
77
77
  **Operator** (a searchable table of every deployed capability). Headlines reflect the
78
78
  active theme's voice.
79
79
 
80
- The KPI band reads **Agents · Skills · Invariants · Local edits**. That third tile is
81
- labelled *Invariants* and not *Constitution* on purpose: its value is the invariant count
80
+ The KPI band reads **Agents · Skills · Dictates · Local edits**. That third tile is
81
+ labelled *Dictates* (the name AGENT.md gives the laws) and not *Constitution* on purpose: its value is the law count
82
82
  alone, and calling it the constitution would under-report the ontology and the doctrine
83
83
  packs. The two Status *layouts* that have room for it add the missing half: Greenhouse
84
84
  puts an active-packs fraction under the tile (`3/4 packs · N rules`), and the Operator HUD
@@ -95,12 +95,13 @@ streams the output of background jobs and keeps their history across reloads.
95
95
  content the page last read — if an agent session edited the file meanwhile, the save
96
96
  409s and the page reloads instead of clobbering it.
97
97
  - **Constitution** (`#/laws`) — the whole governance surface on one page, in three bands:
98
- the **Ethos** (four prose sections), the **Invariants** (the nine numbered Rules, with
98
+ the **Ethos** (four prose sections), the **Dictates** (the nine numbered Rules, with
99
99
  a class facet — only non-empty classes render, and two of the six have had no invariant
100
100
  since the split), and the **Doctrines** (one group per pack, each marked active or not for
101
- this install). Rule IV carries one switch of its own, the install's deletion gate
102
- (`--no-deletion-gate`; *Enforced by* reads *Gate off* while it is off) — flipping it asks,
103
- then rebuilds the install once. Every row expands to its full body, lazily fetched. The rail entry is
101
+ this install). Rules I, III and IV each carry a switch for the install's hook gate — the
102
+ secrets gate, the done gate and the deletion gate (`--no-secrets-gate`, `--no-done-gate`,
103
+ `--no-deletion-gate`; *Enforced by* reads *Gate off* while one is off) — flipping one asks,
104
+ then rebuilds the install once; the other Rules read *no gate*. Every row expands to its full body, lazily fetched. The rail entry is
104
105
  labelled *Constitution* while the route, the id and the badge key stay `laws`; deep links
105
106
  are `#/item/law/ont:telos`, `#/item/law/IV` and `#/item/law/process.5` — three address
106
107
  shapes that cannot collide, because a slug carries a colon no numeral has and a doctrine
@@ -164,8 +165,9 @@ streams the output of background jobs and keeps their history across reloads.
164
165
  added / missing files with per-file expansion and coloured unified-diff lines. Select
165
166
  files to **export an `improvements.md`** back-port, or **restore** them to source.
166
167
  - **Doctor** (`#/doctor`) — runs the same health engine as the `doctor` command (every
167
- theme, parity, links, authoring gates, drift), grouping any problems per check. Re-run
168
- 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.
169
171
 
170
172
  ### 🎨 Setup
171
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.8.0"
5
+ "version": "4.9.1"
6
6
  }
package/js/build/args.mjs CHANGED
@@ -104,6 +104,8 @@ const FLAGS = {
104
104
  // Law IV's gate switch. No `harness.config.json` key feeds it, deliberately: a rebuild elides
105
105
  // the flag when armed, so a config default could disarm an armed install on its next upgrade.
106
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',
107
109
  '-v': 'verbose', '--verbose': 'verbose',
108
110
  };
109
111
 
@@ -312,6 +314,7 @@ export function parseArgs(argv, defaults) {
312
314
  excludeSkills: defaults.excludeSkills ?? [],
313
315
  out: 'Harness', emit: 'files', footprint: 'lean', root: null,
314
316
  syncThemes: false, validateOnly: false, verbose: false, noDeletionGate: false,
317
+ noSecretsGate: false, noDoneGate: false,
315
318
  };
316
319
  const { tokens } = nodeParseArgs({
317
320
  args: argv, options: PARSE_OPTIONS, allowPositionals: true, strict: false, tokens: true,