geneseed 4.8.0 → 4.9.0

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 (67) hide show
  1. package/CHANGELOG.md +13 -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 +17 -15
  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 +7 -6
  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 +3 -1
  25. package/js/hosts/installs.mjs +20 -11
  26. package/js/hosts/settings.mjs +17 -8
  27. package/js/inspect/diff.mjs +4 -4
  28. package/js/inspect/status.mjs +4 -3
  29. package/js/inspect/validate.mjs +9 -4
  30. package/js/maintain/uninstall.mjs +7 -5
  31. package/js/maintain/update.mjs +1 -1
  32. package/js/web/actions.mjs +20 -16
  33. package/js/web/api.mjs +4 -3
  34. package/package.json +1 -1
  35. package/src/AGENT.md.tmpl +1 -1
  36. package/web/dist/assets/{Activity-B-XbKlzu.js → Activity-DZ3O_DsJ.js} +1 -1
  37. package/web/dist/assets/{ActivityDetail-8oq0fycD.js → ActivityDetail-BQSWwoJ-.js} +1 -1
  38. package/web/dist/assets/Dashboard-COLskkOJ.js +2 -0
  39. package/web/dist/assets/Docs-DUxizutI.js +5 -0
  40. package/web/dist/assets/FilterInput-zLFXoSt6.js +1 -0
  41. package/web/dist/assets/GreenhouseView-BSzVX_l7.js +2 -0
  42. package/web/dist/assets/{Installs-DrqPaLDM.js → Installs-Ck4YyEzs.js} +2 -2
  43. package/web/dist/assets/JournalView-BbbACsYK.js +1 -0
  44. package/web/dist/assets/Laws-BZ_M86r3.js +3 -0
  45. package/web/dist/assets/{Library-BCaqPf61.js → Library-D_4LbUg2.js} +1 -1
  46. package/web/dist/assets/{Loops-Bx3HnlUA.js → Loops-Bzb2Y96W.js} +2 -2
  47. package/web/dist/assets/{Markdown-BK38dn4x.js → Markdown-iHvBrpRN.js} +1 -1
  48. package/web/dist/assets/{OperatorHudView-ChQPM8JE.js → OperatorHudView-D-nzB6_r.js} +1 -1
  49. package/web/dist/assets/{Personal-DELm61DF.js → Personal-C66284wd.js} +1 -1
  50. package/web/dist/assets/Tabs-DkPfjG3H.js +1 -0
  51. package/web/dist/assets/{activity-D4w9t8fE.js → activity-DvPxoS4Y.js} +1 -1
  52. package/web/dist/assets/drawable-DlrXDOJ7.js +1 -0
  53. package/web/dist/assets/{index-CHpydQJg.css → index-BCJz4_8C.css} +1 -1
  54. package/web/dist/assets/{index-r2jRgf6l.js → index-DJorAjeH.js} +2 -2
  55. package/web/dist/assets/{react-Ci4wQD4a.js → react-BOF6yL40.js} +1 -1
  56. package/web/dist/assets/{sections-GalMS0Zv.js → sections-BET3c1WT.js} +1 -1
  57. package/web/dist/assets/{usePoll-BcywUo55.js → usePoll-NSTzLOF4.js} +1 -1
  58. package/web/dist/index.html +4 -4
  59. package/web/src/pages/Laws.jsx +52 -25
  60. package/web/dist/assets/Dashboard-C4EMz5mf.js +0 -2
  61. package/web/dist/assets/Docs-mq4XDQK_.js +0 -5
  62. package/web/dist/assets/FilterInput-Ikvi-pS9.js +0 -1
  63. package/web/dist/assets/GreenhouseView-D1WOtq3n.js +0 -2
  64. package/web/dist/assets/JournalView-BYpASU-L.js +0 -1
  65. package/web/dist/assets/Laws-A3PFwiPD.js +0 -3
  66. package/web/dist/assets/Tabs-DcMq5IvC.js +0 -1
  67. package/web/dist/assets/drawable-BI2_tF3o.js +0 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,19 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [4.9.0] — 2026-10-18
12
+
13
+ Minor: on/off switches for the Sealed Secrets and done gates, and the console calls the laws Dictates.
14
+
15
+ ### Added
16
+ - **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.
17
+
18
+ ### Upgrade notes
19
+ - 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.
20
+
21
+ ### Changed
22
+ - **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).
23
+
11
24
  ## [4.8.0] — 2026-10-18
12
25
 
13
26
  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.0**.
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
 
@@ -1168,7 +1170,7 @@ export const GeneseedGuard = async (ctx) => {
1168
1170
  // blocks the whole call (see `pickPath` for why this is not a single path).
1169
1171
  const paths = pickPath(args, cwd)
1170
1172
  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 }
1173
+ 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
1174
  if (SECRET_WARN_RE.some((re) => re.test(p))) log(`WARN: writing ${p} — keep secrets out of tracked files (Sealed Secrets)`)
1173
1175
  const sensor = protectedCheck(p)
1174
1176
  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 +1187,7 @@ export const GeneseedGuard = async (ctx) => {
1185
1187
  // FILE, not pooled — a patch touching `.env` AND an ordinary file must still catch a
1186
1188
  // secret in the ordinary file; only the hunk whose OWN path is `.env`-shaped is
1187
1189
  // exempt, same as the path WARN tier above.
1188
- for (const { path: cp, content } of pickAddedContentByPath(args, cwd)) {
1190
+ for (const { path: cp, content } of SECRETS_GATE_OFF ? [] : pickAddedContentByPath(args, cwd)) {
1189
1191
  if (SECRET_WARN_RE.some((re) => re.test(cp))) continue
1190
1192
  if (SECRET_CONTENT_RE.test(content)) {
1191
1193
  await deny(`${cp} would carry a credential-shaped string (Sealed Secrets) — secrets `
@@ -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
@@ -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.0"
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,
@@ -647,19 +647,25 @@ const PROJECT_EMITS = {
647
647
  * mode readers return `null` for "undetectable" too, which an explicit `posture: null` would
648
648
  * carry straight past the parameter default.
649
649
  */
650
- function axesCfg({ posture, mode, trust, doctrines, excludeRules, excludeSkills, deletionGate = null }) {
650
+ function axesCfg({
651
+ posture, mode, trust, doctrines, excludeRules, excludeSkills,
652
+ deletionGate = null, secretsGate = null, doneGate = null,
653
+ }) {
651
654
  return makeCfg({
652
655
  ...(posture ? { posture } : {}), ...(mode ? { mode } : {}), ...(trust ? { trust } : {}),
653
656
  ...(doctrines ? { doctrines } : {}), ...(excludeRules ? { excludeRules } : {}),
654
657
  ...(excludeSkills ? { excludeSkills } : {}),
655
658
  // Only an explicit `false` disarms; `null` (no opinion) keeps the gate.
656
659
  ...(deletionGate === false ? { noDeletionGate: true } : {}),
660
+ ...(secretsGate === false ? { noSecretsGate: true } : {}),
661
+ ...(doneGate === false ? { noDoneGate: true } : {}),
657
662
  });
658
663
  }
659
664
 
660
665
  export function emitProjectInto(host, {
661
666
  theme, out, root, footprint = 'full', posture = null, mode = null, trust = null,
662
667
  doctrines = null, excludeRules = null, excludeSkills = null, deletionGate = null,
668
+ secretsGate = null, doneGate = null,
663
669
  }) {
664
670
  const emit = PROJECT_EMITS[host];
665
671
  // The axes ride the same rail into every `*Into`, and for the same reason: a drift reader
@@ -667,7 +673,8 @@ export function emitProjectInto(host, {
667
673
  // edited on every narrowed install, and `validate --doctrines craft` that ignored its flag
668
674
  // checked an all-packs render nobody asked for.
669
675
  return withPlatformNewlines(() => emit(
670
- axesCfg({ posture, mode, trust, doctrines, excludeRules, excludeSkills, deletionGate }),
676
+ axesCfg({
677
+ posture, mode, trust, doctrines, excludeRules, excludeSkills, deletionGate, secretsGate, doneGate }),
671
678
  { theme, footprint, root }, out,
672
679
  ));
673
680
  }
@@ -682,16 +689,16 @@ export function emitProjectInto(host, {
682
689
  */
683
690
  export function buildInto({
684
691
  theme, out, footprint = 'lean', posture = null, mode = null, trust = null, doctrines = null,
685
- excludeRules = null, excludeSkills = null, deletionGate = null,
692
+ excludeRules = null, excludeSkills = null, deletionGate = null, secretsGate = null, doneGate = null,
686
693
  }) {
687
694
  return withPlatformNewlines(() => build(axesCfg({
688
- posture, mode, trust, doctrines, excludeRules, excludeSkills, deletionGate }),
695
+ posture, mode, trust, doctrines, excludeRules, excludeSkills, deletionGate, secretsGate, doneGate }),
689
696
  theme, out, { footprint, nativeCatalog: false }));
690
697
  }
691
698
 
692
699
  export function emitGlobalInto(host, {
693
700
  theme, out, cfgDir, footprint, posture = null, mode = null, doctrines = null, trust = null,
694
- excludeRules = null, excludeSkills = null, deletionGate = null,
701
+ excludeRules = null, excludeSkills = null, deletionGate = null, secretsGate = null, doneGate = null,
695
702
  }) {
696
703
  // `build.HOSTS.get(host, build.HOSTS["opencode"])` — an unknown host falls back rather than
697
704
  // raising, because the host comes from a marker file a user can edit.
@@ -707,7 +714,8 @@ export function emitGlobalInto(host, {
707
714
  // edited on every install that chose a register, narrowed its packs or excluded a rule —
708
715
  // the same scar the footprint left in `diffCollect`, one axis over each time.
709
716
  return withPlatformNewlines(() => emit(
710
- axesCfg({ posture, mode, trust, doctrines, excludeRules, excludeSkills, deletionGate }),
717
+ axesCfg({
718
+ posture, mode, trust, doctrines, excludeRules, excludeSkills, deletionGate, secretsGate, doneGate }),
711
719
  { theme, footprint, root: null, cfgDir }, out,
712
720
  ));
713
721
  }
@@ -182,7 +182,7 @@ export function emitClaudeRender(cfg, job) {
182
182
  // WIRE — see `claudeWire` below. One child per emit, so the two halves are two
183
183
  // functions rather than two spawns; the seam between them is a call, not a process.
184
184
  const managed = claudeWire(job, claudeMdText, agentText !== null, cfg.doctrines,
185
- cfg.excludeRules, cfg.deletionGate);
185
+ cfg.excludeRules, cfg.deletionGate, { secretsGate: cfg.secretsGate, doneGate: cfg.doneGate });
186
186
 
187
187
  return {
188
188
  owned,
@@ -216,7 +216,7 @@ export function emitClaudeRender(cfg, job) {
216
216
  * always a string, so `''` is the only falsy value either language sees.
217
217
  */
218
218
  function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRules = [],
219
- deletionGate = true) {
219
+ deletionGate = true, gates = {}) {
220
220
  phaseLog('WIRE');
221
221
  const { cfgDir, claudeMd, scope, host, oldManaged, preambleExclude, hookOpts } = job;
222
222
  const old = oldManaged && typeof oldManaged === 'object' && !Array.isArray(oldManaged)
@@ -280,7 +280,7 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRu
280
280
  // DECIDES the install's packs, so the marker on disk is still the previous build's.
281
281
  const [, managedHooks] = mergeClaudeSettings(
282
282
  settingsPath, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
283
- doctrines, excludeRules, host, cfgDir, deletionGate,
283
+ doctrines, excludeRules, host, cfgDir, deletionGate, gates,
284
284
  );
285
285
  managed.settings_hooks = managedHooks;
286
286
 
@@ -35,12 +35,12 @@ import { existsSync, mkdirSync, statSync } from 'node:fs';
35
35
  import path from 'node:path';
36
36
 
37
37
  import { main as driverMain } from './driver.mjs';
38
- import { makeCfg, PACK_ORDER } from './source.mjs';
38
+ import { GATE_SWITCHES, makeCfg, PACK_ORDER } from './source.mjs';
39
39
  import { DEFAULT_PRESET } from '../loop/score.mjs';
40
40
  import { opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
41
41
  import {
42
42
  EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
43
- deletionGateOfDir, excludedRulesOfDir, excludedSkillsOfDir, footprintOfDir, installState, installTargets,
43
+ excludedRulesOfDir, excludedSkillsOfDir, footprintOfDir, gatesOfDir, installState, installTargets,
44
44
  legacyProcessCarrierOfDir, modeOfDir, postureOfDir, readMaybe, themeOfDir, trustOfDir,
45
45
  } from '../hosts/installs.mjs';
46
46
  import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from '../hosts/opencode.mjs';
@@ -102,8 +102,9 @@ const BUILD_FORWARD = [
102
102
  export function cmdBuild(args) {
103
103
  const argv = [];
104
104
  for (const [key, flag] of BUILD_FORWARD) if (args[key] != null) argv.push(flag, args[key]);
105
- // The one bare switch `build` forwards: it takes no value, so it is not a BUILD_FORWARD row.
106
- if (args.noDeletionGate) argv.push('--no-deletion-gate');
105
+ // The bare switches `build` forwards (the gate switches): they take no value, so they are
106
+ // not BUILD_FORWARD rows.
107
+ for (const g of GATE_SWITCHES) if (args[g.arg]) argv.push(g.flag);
107
108
  return driverMain(argv);
108
109
  }
109
110
 
@@ -140,7 +141,8 @@ export function cmdBuild(args) {
140
141
  */
141
142
  export function setupBuildArgs(theme, emit, out = null, root = null, footprint = 'lean',
142
143
  posture = 'peer', mode = 'direct', doctrines = null, allPacks = PACK_ORDER,
143
- excludeRules = null, trust = DEFAULT_PRESET, excludeSkills = null, deletionGate = true) {
144
+ excludeRules = null, trust = DEFAULT_PRESET, excludeSkills = null, deletionGate = true,
145
+ { secretsGate = true, doneGate = true } = {}) {
144
146
  const argv = ['--theme', theme, '--emit', emit];
145
147
  if (!emit.endsWith('-global')) {
146
148
  if (out) argv.push('--out', out);
@@ -168,9 +170,11 @@ export function setupBuildArgs(theme, emit, out = null, root = null, footprint =
168
170
  // feeds it), so the flag's absence and `none` are the same answer, and every argv that
169
171
  // excludes nothing stays the bytes it was before the flag existed.
170
172
  if (excludeSkills?.length) argv.push('--exclude-skills', excludeSkills.join(','));
171
- // Law IV's gate elides when armed: the driver's default IS armed (no config key feeds it), so
172
- // only an explicit `false` speaks, and every armed argv stays the bytes it was.
173
- if (deletionGate === false) argv.push('--no-deletion-gate');
173
+ // The gate switches elide when armed: the driver's default IS armed (no config key feeds
174
+ // them), so only an explicit `false` speaks, and every armed argv stays the bytes it was.
175
+ // Law order, so a Law IV-only install still ends on `--no-deletion-gate` as it did.
176
+ const gates = { secretsGate, doneGate, deletionGate };
177
+ for (const g of GATE_SWITCHES) if (gates[g.key] === false) argv.push(g.flag);
174
178
  return argv;
175
179
  }
176
180
 
@@ -229,12 +233,12 @@ export function installProfile(host, scope, root, notify = null) {
229
233
  const excludeRules = excludedRulesOfDir(root, host);
230
234
  // Skills left out stay left out: an upgrade that forgot them would put them all back.
231
235
  const excludeSkills = excludedSkillsOfDir(root, host, notify);
232
- // Law IV's gate switch: a rebuild that forgot it would silently re-arm (or, read wrong, disarm)
236
+ // The gate switches: a rebuild that forgot one would silently re-arm (or, read wrong, disarm)
233
237
  // the install. A missing marker reads as armed.
234
- const deletionGate = deletionGateOfDir(root, host);
238
+ const gates = gatesOfDir(root, host);
235
239
  const out = scope === 'global' ? null : root;
236
240
  const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
237
- PACK_ORDER, excludeRules, trust, excludeSkills, deletionGate);
241
+ PACK_ORDER, excludeRules, trust, excludeSkills, gates.deletionGate, gates);
238
242
  // A GLOBAL ROW NAMES ITS OWN DIR. `root` came from the host's config-dir resolver, which
239
243
  // honours env overrides (`OPENCODE_CONFIG_DIR`, `CLAUDE_CONFIG_DIR`…); without the flag the
240
244
  // same argv run from a shell lacking that variable re-emits into the DEFAULT dir — a second
@@ -243,7 +247,7 @@ export function installProfile(host, scope, root, notify = null) {
243
247
  return {
244
248
  host, scope, root, state: installState(root, host, scope),
245
249
  emit, theme, footprint, posture, mode, trust, doctrines, excludeRules, excludeSkills,
246
- deletionGate, argv,
250
+ ...gates, argv,
247
251
  };
248
252
  }
249
253
 
@@ -34,7 +34,7 @@ import { readText } from '../lib/fs.mjs';
34
34
  import { parseJson } from '../lib/json.mjs';
35
35
  import { normcase, comparePaths } from '../lib/paths.mjs';
36
36
  // `js/build/source.mjs` imports nothing but node builtins, so this direction cannot cycle.
37
- import { PACK_ORDER, readPackText, resolveRuleIds, ruleCanon } from './source.mjs';
37
+ import { GATE_SWITCHES, PACK_ORDER, readPackText, resolveRuleIds, ruleCanon } from './source.mjs';
38
38
  import { DEFAULT_PRESET } from '../loop/score.mjs';
39
39
 
40
40
  /** Document STRUCTURE is theme-INDEPENDENT — mirrors `_build_render.STRUCTURE`. */
@@ -444,10 +444,12 @@ export function effectiveTheme(cfg, themeName, { footprint = 'full', lawsPrefix
444
444
  const skipped = [...(cfg.excludeSkills ?? [])].sort();
445
445
  // It fills a template line of its own, so its value carries the blank line around it.
446
446
  theme.EXCLUDED_SKILLS_LINE = skipped.length ? `\nExcluded skills: ${skipped.join(', ')}\n` : '';
447
- // Law IV's gate switch, under the same contract: written only when OFF, so a default build
448
- // stays byte-identical and a missing line reads back as armed (`deletionGateOfDir`). The
449
- // OpenCode guard plugin reads this line at load, so its wording is a parsed contract.
450
- theme.DELETION_GATE_LINE = cfg.deletionGate === false ? '\nDeletion gate: off' : '';
447
+ // The gate switches (`GATE_SWITCHES`: Laws I, III, IV), under the same contract: a line is
448
+ // written only when its gate is OFF, so a default build stays byte-identical and a missing
449
+ // line reads back as armed (`gatesOfDir`). The OpenCode plugins read these lines at load, so
450
+ // their wording is a parsed contract.
451
+ theme.GATE_LINES = GATE_SWITCHES.filter((g) => cfg[g.key] === false)
452
+ .map((g) => `\n${g.marker}: off`).join('');
451
453
  return theme;
452
454
  }
453
455
 
@@ -399,7 +399,8 @@ export function resolveSkillNames(names, {
399
399
  */
400
400
  export function makeCfg({
401
401
  posture = 'peer', mode = 'direct', trust = DEFAULT_PRESET, doctrines = PACK_ORDER,
402
- excludeRules = [], excludeSkills = [], noDeletionGate = false,
402
+ excludeRules = [], excludeSkills = [], noDeletionGate = false, noSecretsGate = false,
403
+ noDoneGate = false,
403
404
  } = {}) {
404
405
  return {
405
406
  root: ROOT,
@@ -430,5 +431,22 @@ export function makeCfg({
430
431
  // Law IV's deletion gate (`--no-deletion-gate`). Armed unless switched off in so many words:
431
432
  // only a literal `true` disarms, so every caller that never heard of the switch keeps it.
432
433
  deletionGate: noDeletionGate !== true,
434
+ // Law I's secret scan (`--no-secrets-gate`) and Law III's done gate (`--no-done-gate`), the
435
+ // same contract (user decision 2026-10-18; see GATE_SWITCHES).
436
+ secretsGate: noSecretsGate !== true,
437
+ doneGate: noDoneGate !== true,
433
438
  };
434
439
  }
440
+
441
+ /**
442
+ * The laws whose hook gate an install can switch off, in law order: the cfg/profile key, the
443
+ * build flag, the carrier marker (`<marker>: off`, written only when off) and the law. ONE
444
+ * table, so the flag, the marker, the rebuild argv, the read-back and `status` cannot drift
445
+ * apart. Every gate is ARMED by default and the law's text binds either way. The deletion row
446
+ * is #319's, unchanged: existing installs carry its flag and marker.
447
+ */
448
+ export const GATE_SWITCHES = [
449
+ { key: 'secretsGate', flag: '--no-secrets-gate', arg: 'noSecretsGate', marker: 'Secrets gate', law: 'I' },
450
+ { key: 'doneGate', flag: '--no-done-gate', arg: 'noDoneGate', marker: 'Done gate', law: 'III' },
451
+ { key: 'deletionGate', flag: '--no-deletion-gate', arg: 'noDeletionGate', marker: 'Deletion gate', law: 'IV' },
452
+ ];