geneseed 4.7.0 → 4.8.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 (73) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -1
  3. package/SHIPPED.md +1 -1
  4. package/adapters/claude-code/README.md +3 -1
  5. package/adapters/opencode/HOW-OPENCODE-LOADS.md +1 -1
  6. package/adapters/opencode/README.md +5 -4
  7. package/adapters/opencode/plugins/geneseed-guard.js +565 -36
  8. package/bin/geneseed-hook.mjs +9 -3
  9. package/docs/concepts/enforcement-table.md +1 -1
  10. package/docs/concepts/exclusions.md +20 -1
  11. package/docs/concepts/hooks.md +11 -3
  12. package/docs/concepts/host-matrix.md +3 -3
  13. package/docs/reference/plugin-guard.md +3 -3
  14. package/docs/reference/troubleshoot-doctor.md +4 -2
  15. package/docs/understand/a-day.md +6 -6
  16. package/docs/understand/design-choices-gates.md +2 -2
  17. package/docs/understand/enforced-vs-asked.md +4 -4
  18. package/docs/understand/harness.md +1 -1
  19. package/docs/understand/machine-claude.md +1 -1
  20. package/docs/understand/machine-opencode.md +2 -2
  21. package/docs/web-ui.md +3 -1
  22. package/harness.config.json +1 -1
  23. package/js/README.md +2 -1
  24. package/js/build/args.mjs +4 -1
  25. package/js/build/driver.mjs +11 -7
  26. package/js/build/emit-claude.mjs +4 -3
  27. package/js/build/emit-opencode.mjs +2 -1
  28. package/js/build/generate.mjs +13 -4
  29. package/js/build/render.mjs +4 -0
  30. package/js/build/source.mjs +4 -1
  31. package/js/cli-table.json +45 -0
  32. package/js/hosts/gitref.mjs +47 -1
  33. package/js/hosts/hooks.mjs +19 -42
  34. package/js/hosts/installs.mjs +20 -0
  35. package/js/hosts/loss.mjs +522 -0
  36. package/js/hosts/settings.mjs +78 -77
  37. package/js/inspect/checks-probe.mjs +18 -6
  38. package/js/inspect/diff.mjs +4 -1
  39. package/js/inspect/status.mjs +3 -1
  40. package/js/inspect/validate.mjs +6 -4
  41. package/js/maintain/uninstall.mjs +5 -2
  42. package/js/maintain/update.mjs +1 -1
  43. package/js/web/actions.mjs +20 -3
  44. package/js/web/api.mjs +4 -2
  45. package/package.json +1 -1
  46. package/src/AGENT.md.tmpl +1 -1
  47. package/src/laws/universal.md +17 -17
  48. package/src/skills/worktree.md +1 -1
  49. package/web/dist/assets/{Activity-Dj0izJwj.js → Activity-B-XbKlzu.js} +1 -1
  50. package/web/dist/assets/{ActivityDetail-wjq5W02K.js → ActivityDetail-8oq0fycD.js} +1 -1
  51. package/web/dist/assets/{Dashboard-UMbgHbzF.js → Dashboard-C4EMz5mf.js} +2 -2
  52. package/web/dist/assets/{Docs-D3ABE_ds.js → Docs-mq4XDQK_.js} +1 -1
  53. package/web/dist/assets/FilterInput-Ikvi-pS9.js +1 -0
  54. package/web/dist/assets/{GreenhouseView-C238LWBD.js → GreenhouseView-D1WOtq3n.js} +1 -1
  55. package/web/dist/assets/{Installs-XSm0a_zQ.js → Installs-DrqPaLDM.js} +1 -1
  56. package/web/dist/assets/{JournalView-BUj4Ff-6.js → JournalView-BYpASU-L.js} +1 -1
  57. package/web/dist/assets/Laws-A3PFwiPD.js +3 -0
  58. package/web/dist/assets/{Library-CkaPVUYU.js → Library-BCaqPf61.js} +1 -1
  59. package/web/dist/assets/{Loops-CexwOARr.js → Loops-Bx3HnlUA.js} +1 -1
  60. package/web/dist/assets/{Markdown-DrXLM6TA.js → Markdown-BK38dn4x.js} +1 -1
  61. package/web/dist/assets/{OperatorHudView-D5qTQat0.js → OperatorHudView-ChQPM8JE.js} +1 -1
  62. package/web/dist/assets/{Personal-B_Vx9a7a.js → Personal-DELm61DF.js} +1 -1
  63. package/web/dist/assets/{Tabs-YZbGqWPW.js → Tabs-DcMq5IvC.js} +1 -1
  64. package/web/dist/assets/{activity-dcg5vF9j.js → activity-D4w9t8fE.js} +1 -1
  65. package/web/dist/assets/{drawable-BoQnwVpr.js → drawable-BI2_tF3o.js} +1 -1
  66. package/web/dist/assets/{index-BoG1M8Xy.css → index-CHpydQJg.css} +1 -1
  67. package/web/dist/assets/{index-BmbWeoTM.js → index-r2jRgf6l.js} +2 -2
  68. package/web/dist/assets/{react-DRhoX0kS.js → react-Ci4wQD4a.js} +2 -2
  69. package/web/dist/assets/{usePoll-BmR631BB.js → usePoll-BcywUo55.js} +1 -1
  70. package/web/dist/index.html +3 -3
  71. package/web/src/pages/Laws.jsx +53 -5
  72. package/web/dist/assets/FilterInput-BSfpStOZ.js +0 -1
  73. package/web/dist/assets/Laws-yGBJFKgY.js +0 -3
@@ -84,58 +84,47 @@ const consentRuleOn = (d, excluded = []) =>
84
84
  * doctrine section — and a boundary that keeps asking for a rule the install did not adopt is
85
85
  * the one disagreement the tiering is not allowed to produce. (It does NOT follow that the
86
86
  * string is gone from the bundle; see `claudeHookGroups` for what D5 ships anyway and why.)
87
- * `rm -rf *`, the force pushes and the history-discarding git verbs (`reset --hard`,
88
- * `clean -f`, `branch -D`, `checkout --`) stay in EVERY build: they are Law IV's territory —
89
- * an always-on invariant — not the process pack's. They mirror `DESTRUCTIVE_GIT_RE` in
90
- * js/hosts/hooks.mjs, so Claude's git-gate and OpenCode's permission ask about the same acts;
91
- * before, OpenCode only logged the last four. A glob is not a regex, so the mirror is the
92
- * closest glob for each act rather than an exact copy: the `* ` forms catch a flag that comes
93
- * after other words (`push origin -f`, `reset -q --hard`, `clean -d -f`) and `git push *+*`
94
- * a `+refspec` force push. The regex stays the stricter reader — it also sees a flag cluster
95
- * with `f` not first (`clean -d -xf`), which a glob cannot say without matching `--exclude=f`.
87
+ * The Law IV keys stay in EVERY build: they are Law IV's territory — an always-on invariant —
88
+ * not the process pack's.
96
89
  *
97
- * B5's (claude-code.md / claude-verdict.md) long/modern spellings, added here appended (never
98
- * reorder: OpenCode's permission engine evaluates key order with `findLast`, so moving an
99
- * existing key can change which VALUE wins on a file that already disagrees with one — adding
100
- * only is always safe, since every key here maps to the same `'ask'`). `clean --force`, the
101
- * long flag; `branch --delete --force`, the long form of `-D` (both orders, since a glob has
102
- * no "either order" — an un-forced `--delete` already refuses on an unmerged branch exactly
103
- * like `-d`, so it is deliberately NOT a key here; `-D --force`/`--delete -f`/`-d --force`/
104
- * `-df` are the same act again and are a known ceiling this does not chase); `checkout
105
- * -f`/`--force` and `switch -f`/`--force`/`--discard-changes` (git's own `switch -h` lists
106
- * `-f, --force` and `--discard-changes` as two separate options); `reflog expire`; `gc
107
- * --prune` (matched bare, since a `--prune` with no `=now` still eventually prunes); and `push
108
- * *--delete*` / `push * :*` (a literal space before the colon, so `push origin HEAD:main`, an
109
- * ordinary refspec, does not match).
90
+ * LAW IV HERE IS ONLY WHAT LOSES DATA IN EVERY STATE (user decision, 2026-10-17). The gate asks
91
+ * only when a command would really lose data, and whether it would — a dirty tree, an untracked
92
+ * file, an unpushed branch, a protected target, a stash — is STATE, which a glob cannot read. So
93
+ * the state-aware decisions moved into the OpenCode guard plugin (`lossReason`, the twin of
94
+ * js/hosts/loss.mjs, which BLOCKS a real loss because `tool.execute.before` has no ask tier), and
95
+ * this list keeps what loses data whatever the state: the protected-branch forms below, and the
96
+ * two acts that delete the recovery net, `reflog expire` and `gc --prune` (matched bare and with a word before it, since a `--prune` with no `=now`
97
+ * still eventually prunes). Every force-push, `reset --hard`, `clean -f`, `branch -D`,
98
+ * `checkout`/`switch` force, `stash drop`/`clear` and remote-delete glob this list used to carry
99
+ * was REMOVED, the way PR #313 removed `git worktree remove -f`: removal from this list also
100
+ * removes the keys from `OWNED_BASH`, so an `opencode.json` written before keeps them — neither
101
+ * taken back nor reported as stranded, since Geneseed no longer claims them. They still ask
102
+ * there (fail-safe, one prompt); the user deletes them by hand (the CHANGELOG upgrade note says
103
+ * which). The process-5 `git push*` ask still covers every push while that rule is on.
110
104
  *
111
- * `git worktree remove -f`/`--force` was a key here and was REMOVED (user decision, 2026-10-17:
112
- * worktree removal is routine housekeeping, not a Law IV deletion). Removal from this list also
113
- * removes the four globs from `OWNED_BASH`, so an `opencode.json` written before keeps them —
114
- * neither taken back nor reported as stranded, since Geneseed no longer claims them. Its user
115
- * deletes them by hand (the CHANGELOG upgrade note says so).
105
+ * `rm -rf *` WENT TOO (user ruling, 2026-10-17): a blanket ask on every recursive removal is the
106
+ * glob-cannot-read-state problem again — the guard's `lossReason` lets an `rm` of tracked,
107
+ * unmodified files through and blocks one git cannot restore, and its `SHELL_BLOCK_RE` still
108
+ * blocks `rm -rf /`, `~` and the drive-root forms outright. Same stranded-key handling as above:
109
+ * an existing `opencode.json` keeps its `rm -rf *` key, asking, until the user deletes it.
116
110
  *
117
- * `git restore` is DELIBERATELY NOT HERE. Its rule is "ask unless `--staged` appears ALONE" —
118
- * `--staged --worktree` together still discards the working tree — and a glob has no negation,
119
- * so the closest glob (`git restore*`) would ask on the harmless `--staged`-alone case too. The
120
- * regex (`DESTRUCTIVE_GIT_RE`) gets that exactly right; `stash drop`/`clear` have the SAME
121
- * `restore` anchoring concern in the regex (a bare word / ordinary English, matched there only
122
- * in verb position) but no false-positive risk as a glob, since OpenCode matches a glob against
123
- * the FULL sub-command text (`opencode-verdict.md` CR-6) — `git stash drop*` cannot match a
124
- * DIFFERENT sub-command like `git stash push -m "clear old state"`, so they ARE glob keys here.
125
- * Guard-only acts (currently just `restore`) still warn via the OpenCode guard plugin's
126
- * `SHELL_WARN_RE` — see `adapters/opencode/plugins/geneseed-guard.js`.
111
+ * PROTECTED BRANCHES BY NAME are the one state-free loss a glob CAN say: a force push to, or a
112
+ * delete of, `main`/`master` loses work whatever the tree holds. So `PROTECTED_GLOBS` asks for
113
+ * those forms even when the guard plugin is off or in `warn` mode (a remote's OTHER default
114
+ * branch needs `refs/remotes/<r>/HEAD`, which only the guard reads). Every key ENDS in the
115
+ * branch name — never in a literal `" *"`, which upstream's `Wildcard.match` makes optional —
116
+ * so `mainline` or `HEAD:loop/x` never match, and none matches a push that is not forced or a
117
+ * delete. They come last, so `findLast` lets them win over the loop chain's allows.
127
118
  */
128
- const LAW_IV_BASH = ['git push --force*', 'git push -f*', 'git push *--force*', 'git push * -f*',
129
- 'git push *+*', 'git reset --hard*', 'git reset * --hard*', 'git clean -f*', 'git clean * -f*',
130
- 'git branch -D*', 'git checkout -- *',
131
- 'git clean --force*', 'git clean * --force*',
132
- 'git branch *--delete*--force*', 'git branch *--force*--delete*',
133
- 'git checkout -f*', 'git checkout * -f*', 'git checkout --force*', 'git checkout * --force*',
134
- 'git switch -f*', 'git switch * -f*', 'git switch --force*', 'git switch * --force*',
135
- 'git switch --discard-changes*', 'git switch * --discard-changes*',
136
- 'git reflog expire*', 'git gc --prune*', 'git gc * --prune*',
137
- 'git stash drop*', 'git stash clear*',
138
- 'git push *--delete*', 'git push * :*'];
119
+ const PROTECTED_GLOBS = ['main', 'master'].flatMap((b) => [
120
+ `git push --force* ${b}`, `git push * --force* ${b}`, `git push -f* ${b}`, `git push * -f* ${b}`,
121
+ `git push --force* *:${b}`, `git push * --force* *:${b}`, `git push -f* *:${b}`, `git push * -f* *:${b}`,
122
+ `git push * +${b}`, `git push * +*:${b}`, `git push * +*:refs/heads/${b}`,
123
+ `git push * :${b}`, `git push * :refs/heads/${b}`, `git push *--delete* ${b}`,
124
+ `git branch -D ${b}`, `git branch * -D ${b}`,
125
+ `git branch *--delete*--force* ${b}`, `git branch *--force*--delete* ${b}`,
126
+ ]);
127
+ const LAW_IV_BASH = ['git reflog expire*', 'git gc --prune*', 'git gc * --prune*', ...PROTECTED_GLOBS];
139
128
 
140
129
  /**
141
130
  * User decision (2026-10-10): a loop runs on its own `loop/*` branch, never `main`/`master`, so
@@ -150,12 +139,13 @@ const LAW_IV_BASH = ['git push --force*', 'git push -f*', 'git push *--force*',
150
139
  *
151
140
  * ORDER IS LOAD-BEARING. `PUSH_CHAIN` (which `defaultPermission` and `OWNED_BASH` both read)
152
141
  * puts these AFTER `git push*`'s ask (so a loop
153
- * push is not swallowed by the blanket consent ask) and BEFORE `LAW_IV_BASH` (so `findLast`
154
- * — OpenCode's own `permission.bash` evaluator — lets a force push, a `+refspec`, or a
155
- * `--delete`/bare-`:` push still ask even when the target is `loop/*`; those LAW_IV_BASH keys
156
- * match the same command and come later, so they win). Never move this block to before
157
- * `git push*` or after `LAW_IV_BASH` — either moves the `findLast` boundary the ordering above
158
- * depends on.
142
+ * push is not swallowed by the blanket consent ask) and BEFORE `LAW_IV_BASH`. Never move this
143
+ * block to before `git push*` or after `LAW_IV_BASH` — either moves the `findLast` boundary the
144
+ * ordering above depends on. Since `LAW_IV_BASH` keeps no push glob (2026-10-17), a forced or
145
+ * `+refspec` push to `loop/*` is no longer re-asked by Law IV: `loop/*` is never a protected
146
+ * branch, so it loses nothing Law IV guards. The tiers below still ask for every 2+-token prefix
147
+ * (`-f origin` included), and `git push*` still asks for a `+HEAD:loop/x` or `:loop/x` form,
148
+ * which no allow key matches.
159
149
  */
160
150
  const LOOP_PUSH_ALLOW = ['git push * HEAD:loop/*', 'git push * HEAD:refs/heads/loop/*'];
161
151
 
@@ -211,9 +201,9 @@ const LOOP_PUSH_ALLOW = ['git push * HEAD:loop/*', 'git push * HEAD:refs/heads/l
211
201
  * as a legitimate bare remote) — indistinguishable from a real remote name by token count alone,
212
202
  * and not a practical gap: a leading `-`/`--` token is parsed by git as an OPTION, never as the
213
203
  * remote, so this exact shape either errors (no remote given) or reads the refspec text itself
214
- * as the remote and fails to resolve it — it does not reach a working push to `main`. Still
215
- * caught by `LAW_IV_BASH`'s existing anywhere-in-the-command globs regardless of position
216
- * (`'git push *--force*'`, `'git push * -f*'`, et al.), independently of this chain.
204
+ * as the remote and fails to resolve it — it does not reach a working push to `main`. (Before
205
+ * 2026-10-17 `LAW_IV_BASH`'s force-push globs re-asked it too; they are gone, and the OpenCode
206
+ * guard's `lossReason` now judges a forced push by its real target.)
217
207
  */
218
208
  const LOOP_PUSH_ASK_EXTRA = ['git push * * HEAD:loop/*', 'git push * * HEAD:refs/heads/loop/*'];
219
209
  const LOOP_PUSH_ALLOW_FLAG = [
@@ -235,13 +225,18 @@ const PUSH_CHAIN = [
235
225
  [LOOP_PUSH_ASK_FLAG_EXTRA, 'ask'], [LOOP_PUSH_ASK_TRAILING, 'ask'], [LAW_IV_BASH, 'ask'],
236
226
  ];
237
227
 
238
- function defaultPermission(doctrines = null, excluded = []) {
239
- const bash = { 'rm -rf *': 'ask' };
228
+ // `deletionGate === false` (`--no-deletion-gate`) drops the `LAW_IV_BASH` tier and nothing else;
229
+ // the keys stay OWNED, so an existing file reports them as stranded.
230
+ function defaultPermission(doctrines = null, excluded = [], deletionGate = true) {
231
+ const bash = {};
240
232
  if (consentRuleOn(doctrines, excluded)) {
241
233
  bash['git commit*'] = 'ask';
242
234
  bash['git push*'] = 'ask';
243
235
  }
244
- for (const [keys, verdict] of PUSH_CHAIN) for (const k of keys) bash[k] = verdict;
236
+ for (const [keys, verdict] of PUSH_CHAIN) {
237
+ if (keys === LAW_IV_BASH && deletionGate === false) continue;
238
+ for (const k of keys) bash[k] = verdict;
239
+ }
245
240
  return { bash };
246
241
  }
247
242
 
@@ -252,7 +247,7 @@ function defaultPermission(doctrines = null, excluded = []) {
252
247
  * has to be able to recognise a key that is Geneseed's business at all — to add it back when
253
248
  * the pack returns, and to name it when this build no longer wants it but will not remove it.
254
249
  */
255
- const OWNED_BASH = ['rm -rf *', 'git commit*', 'git push*',
250
+ const OWNED_BASH = ['git commit*', 'git push*',
256
251
  ...PUSH_CHAIN.flatMap(([keys]) => keys)];
257
252
 
258
253
  /**
@@ -509,7 +504,8 @@ export function loadJsonObject(p, { strict = false } = {}) {
509
504
  * `'add'`, which is what the boolean callers this replaced meant.
510
505
  */
511
506
  function warnCommentedJsonc(target, agentPath, permission,
512
- includeLsp = false, prefix = 'geneseed', doctrines = null, excluded = [], skillNames = []) {
507
+ includeLsp = false, prefix = 'geneseed', doctrines = null, excluded = [], skillNames = [],
508
+ deletionGate = true) {
513
509
  process.stdout.write(`[${prefix}] ${path.basename(target)} has comments — not rewriting `
514
510
  + 'it (your edits are kept). Add this to its "instructions" array by hand:\n');
515
511
  process.stdout.write(`[${prefix}] ${jsonDumps(agentPath)}\n`);
@@ -518,7 +514,7 @@ function warnCommentedJsonc(target, agentPath, permission,
518
514
  ? `[${prefix}] and its "permission".bash is missing gates this build wires — `
519
515
  + 'reconcile it by hand against:\n'
520
516
  : `[${prefix}] and, for Geneseed's default ask-gates, a "permission" key:\n`);
521
- for (const line of jsonDumpsIndent(defaultPermission(doctrines, excluded)).split('\n')) {
517
+ for (const line of jsonDumpsIndent(defaultPermission(doctrines, excluded, deletionGate)).split('\n')) {
522
518
  process.stdout.write(`[${prefix}] ${line}\n`);
523
519
  }
524
520
  if (skillNames.length) {
@@ -569,7 +565,7 @@ export function atomicWriteJson(p, config) {
569
565
  * every path that wrote nothing — unreadable, not an object, commented `.jsonc`, write
570
566
  * failure — so the caller keeps its previous record rather than claiming denies never written. */
571
567
  export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [], skillNames = [],
572
- staleSkillNames = [], result = {}) {
568
+ staleSkillNames = [], result = {}, deletionGate = true) {
573
569
  const target = opencodeTarget(p);
574
570
  let config = { $schema: OPENCODE_SCHEMA, instructions: [] };
575
571
  const { state, data, hadComments, error } = loadJsonObject(target);
@@ -604,7 +600,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
604
600
  let permIsObject = true;
605
601
  const added = [];
606
602
  if (addPerm) {
607
- config.permission = defaultPermission(doctrines, excluded);
603
+ config.permission = defaultPermission(doctrines, excluded, deletionGate);
608
604
  if (skillNames.length) {
609
605
  config.permission.skill = defaultSkillPermission(skillNames);
610
606
  added.push(...skillNames);
@@ -613,7 +609,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
613
609
  const perm = get(config, 'permission');
614
610
  permIsObject = isDict(perm);
615
611
  [permChanged, stranded, opaque] = reconcileOpencodePermission(perm,
616
- defaultPermission(doctrines, excluded));
612
+ defaultPermission(doctrines, excluded, deletionGate));
617
613
  // Independent of the `bash` shape: a `permission.bash` the build cannot wire into must
618
614
  // not also block the UNRELATED `permission.skill` key from being reconciled.
619
615
  if (permIsObject) {
@@ -633,7 +629,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
633
629
  // skill reconcile above never ran either — the unwired skill denies are folded into THIS
634
630
  // one WARN rather than going unmentioned (a dedicated `skillOpaque` needs `perm` to be a
635
631
  // dict to even look at `perm.skill`, which this case by definition is not).
636
- const wanted = defaultPermission(doctrines, excluded).bash;
632
+ const wanted = defaultPermission(doctrines, excluded, deletionGate).bash;
637
633
  const names = [...OWNED_BASH.filter((k) => has(wanted, k)),
638
634
  ...(!permIsObject ? skillNames : [])];
639
635
  process.stderr.write(`[geneseed] WARN: "permission" in ${path.basename(target)} `
@@ -653,9 +649,9 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
653
649
  if (stranded.length) {
654
650
  process.stderr.write(`[geneseed] WARN: ${path.basename(target)} keeps `
655
651
  + `${stranded.map((k) => jsonDumps(k)).join(', ')} under "permission".bash — the `
656
- + 'doctrine pack behind those gates is off in this build, but Geneseed cannot tell its '
657
- + 'own entry from one you wrote, so it removes neither. Delete them by hand if you want '
658
- + 'them gone.\n');
652
+ + 'rule behind those gates (a doctrine, or the deletion gate) is off in this build, but '
653
+ + 'Geneseed cannot tell its own entry from one you wrote, so it removes neither. Delete '
654
+ + 'them by hand if you want them gone.\n');
659
655
  }
660
656
  const addLsp = !has(config, 'lsp');
661
657
  if (addLsp) config.lsp = true;
@@ -676,7 +672,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
676
672
  // block to paste, split by which of the two situations the reader is actually in. It is
677
673
  // stdout, not stderr, because on this path the paste-in IS the output.
678
674
  warnCommentedJsonc(target, agentPath, addPerm ? 'add' : (permChanged && 'reconcile'),
679
- addLsp, 'geneseed', doctrines, excluded, skillNames);
675
+ addLsp, 'geneseed', doctrines, excluded, skillNames, deletionGate);
680
676
  return target;
681
677
  }
682
678
  try {
@@ -729,7 +725,8 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
729
725
  * belongs in — the user's call — not how work is run, so it is not the process pack's to
730
726
  * remove. `default` here means unknown, and unknown keeps the gate (see `processPackOn`).
731
727
  */
732
- export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [], host = 'claude') {
728
+ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [], host = 'claude',
729
+ deletionGate = true) {
733
730
  const run = hookPrefix(hookOpts);
734
731
  const mem = `--memory "${path.join(cfg, 'memory')}"`;
735
732
  // --root carries the install's own dir so a GLOBAL hook can stand down when a live, wired
@@ -760,14 +757,17 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
760
757
  // else. Unverified live: no Bob install on the authoring machine. Revisit a matcher only
761
758
  // once a live Bob session can confirm both the current tool list and the regex dialect.
762
759
  const b = ' --host bob';
760
+ // `--no-deletion-gate` only when the install switched Law IV's gate off, so an armed
761
+ // install's command stays byte-identical (and an older shim never sees the flag).
762
+ const dg = deletionGate === false ? ' --no-deletion-gate' : '';
763
763
  return {
764
764
  SessionStart: [{ hooks: [{ type: 'command', command: `${run} context --root "${cfg}"${b} || exit 0` }] }],
765
765
  // One group, both gates: process 5's consent is a stderr line here (Bob has no ask
766
766
  // tier), so the pack toggle that drops Claude's git-gate group has nothing to drop —
767
- // `tool-gate` only ever EXITS 2 for Laws I and IV, which every build carries, and for a
767
+ // `tool-gate` only ever EXITS 2 for Laws I and IV (IV unless built `--no-deletion-gate`), and for a
768
768
  // write under a project's own `.geneseed/protected-checks.txt` — the project opted in —
769
769
  // and for a recursive scan rooted at a whole filesystem (ops 2, the root-scan check).
770
- PreToolUse: [{ hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"${b}` }] }],
770
+ PreToolUse: [{ hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"${b}${dg}` }] }],
771
771
  // Bob's Stop payload never carries `transcript_path` (I2) — `learn` reads `--host bob`
772
772
  // and returns immediately rather than wasting a model call on the bare envelope. KEPT
773
773
  // rather than DROPPED: the stand-down costs nothing (no transcript read, no spawn), and
@@ -784,7 +784,8 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
784
784
  // `--host` on `git-gate` but not on `learn`, which then exits 1 under `|| exit 0` and skips.
785
785
  const h = host === 'openclaude' ? ' --host openclaude' : '';
786
786
  const context = `${run} context --root "${cfg}"${h} || exit 0`;
787
- const gate = `${run} git-gate --root "${cfg}"${h}${consentRuleOn(doctrines, excluded) ? '' : ' --no-consent'}`;
787
+ const gate = `${run} git-gate --root "${cfg}"${h}${consentRuleOn(doctrines, excluded) ? '' : ' --no-consent'}`
788
+ + (deletionGate === false ? ' --no-deletion-gate' : '');
788
789
  const ruleGate = `${run} rule-gate --root "${cfg}"`;
789
790
  const learn = `${run} learn ${mem}${h} || exit 0`;
790
791
  // Claude Code only (hooks.md, v2.1.295+; Task 1): a `command` hook that can't start, times
@@ -923,14 +924,14 @@ function readSettingsForMerge(p, consequence) {
923
924
  * global that never stood down for a project install.
924
925
  */
925
926
  export function mergeClaudeSettings(p, priorHooks = null, hookOpts = {},
926
- doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p)) {
927
+ doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p), deletionGate = true) {
927
928
  const prior = (priorHooks || []).filter(isDict);
928
929
  const loaded = readSettingsForMerge(p, 'Hooks were not wired.');
929
930
  if (!loaded) return [p, prior];
930
931
  const [config, hadComments] = loaded;
931
932
  let hooks = get(config, 'hooks');
932
933
  if (!isDict(hooks)) hooks = {};
933
- const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host);
934
+ const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host, deletionGate);
934
935
  const canonFlat = [];
935
936
  for (const [event, groups] of Object.entries(canonical)) {
936
937
  for (const g of groups) canonFlat.push({ event, group: g });
@@ -6,7 +6,9 @@
6
6
  * shim's paths and `hookShellProblems` infers shell drift — none of them observes a verdict. So
7
7
  * this runs each live install's own gate command, AS ITS SETTINGS SPELL IT, through the shell
8
8
  * the host would use, on two canned payloads: one Law IV call that every build must refuse
9
- * (`git reset --hard HEAD`, also in Bob's `BLOCK_RULES`) and one that must pass (`git status`).
9
+ * (`git reflog expire --expire=now --all`, Law IV in Bob's `BLOCK_RULES`) and one that must pass (`git status`).
10
+ * A gate wired `--no-deletion-gate` passes the Law IV call by design, so `badCommandFor` swaps in a
11
+ * root-wide scan (ops 2) it must still refuse — a disarmed install is not a gate that stopped working.
10
12
  * Both directions are judged, because "allows everything" and "refuses everything" (the
11
13
  * PowerShell-fallback case `hookShellProblems` can only infer) are both broken gates.
12
14
  *
@@ -44,7 +46,16 @@ import { spawnSync } from 'node:child_process';
44
46
  */
45
47
  const PROBED_VERB = { claude: 'git-gate', openclaude: 'git-gate', bob: 'tool-gate' };
46
48
 
47
- export const BAD_COMMAND = 'git reset --hard HEAD';
49
+ // A Law IV act that loses data in EVERY state: since the gate asks only on real loss
50
+ // (2026-10-17), a state-dependent act such as `reset --hard` would pass on a clean tree.
51
+ export const BAD_COMMAND = 'git reflog expire --expire=now --all';
52
+ // An install built `--no-deletion-gate` passes BAD_COMMAND by design, so its gate is probed on
53
+ // what it must still refuse: a recursive scan of a whole filesystem (ops 2, in Bob's
54
+ // `BLOCK_RULES` too).
55
+ export const DISARMED_BAD_COMMAND = 'find / -name geneseed-probe';
56
+ /** The must-refuse payload for one gate command, as its own flags say it is wired. */
57
+ export const badCommandFor = (command) => (/ --no-deletion-gate(?:\s|$)/.test(command)
58
+ ? DISARMED_BAD_COMMAND : BAD_COMMAND);
48
59
  export const GOOD_COMMAND = 'git status';
49
60
 
50
61
  const PROBE_TIMEOUT_MS = 20_000;
@@ -95,7 +106,7 @@ export function refused(host, r) {
95
106
  * The verdict for one gate, from its two runs — null when the gate is sound, or the problem.
96
107
  * Pure, so the whole verdict table is pinned without spawning.
97
108
  */
98
- export function probeVerdict(host, bad, good) {
109
+ export function probeVerdict(host, bad, good, badCommand = BAD_COMMAND) {
99
110
  for (const r of [bad, good]) {
100
111
  if (r.status !== 0 && r.status !== 2) {
101
112
  const why = r.error ? r.error : `exit ${r.status}`;
@@ -105,7 +116,7 @@ export function probeVerdict(host, bad, good) {
105
116
  : ' — the host lets every call through while this lasts'}`;
106
117
  }
107
118
  }
108
- if (!refused(host, bad)) return `registered but not refusing — \`${BAD_COMMAND}\` went through`;
119
+ if (!refused(host, bad)) return `registered but not refusing — \`${badCommand}\` went through`;
109
120
  if (refused(host, good)) return `refuses everything — \`${GOOD_COMMAND}\` was refused too`;
110
121
  return null;
111
122
  }
@@ -159,9 +170,10 @@ export function gateProbe(targets = installTargets(), { platform = process.platf
159
170
  }
160
171
  const verdict = withTempDir((tmp) => {
161
172
  const runEnv = { ...env, GENESEED_GATE_PROBE: '1', CLAUDE_PROJECT_DIR: tmp };
162
- const bad = runOnce(shell, probePayload(BAD_COMMAND, tmp), tmp, runEnv);
173
+ const badCommand = badCommandFor(handler.command);
174
+ const bad = runOnce(shell, probePayload(badCommand, tmp), tmp, runEnv);
163
175
  const good = runOnce(shell, probePayload(GOOD_COMMAND, tmp), tmp, runEnv);
164
- return probeVerdict(host, bad, good);
176
+ return probeVerdict(host, bad, good, badCommand);
165
177
  }, 'geneseed-probe-');
166
178
  if (verdict) problems.push(`${label} under ${shell.label} ${verdict}`);
167
179
  else probed.push(`${path.normalize(cfg)} (${verb}, ${shell.label})`);
@@ -44,7 +44,8 @@ import {
44
44
  GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
45
45
  } from '../hosts/hosts.mjs';
46
46
  import {
47
- EMIT_HOST_SCOPE, defaultTheme, doctrinesForBuild, excludedRulesOfDir, excludedSkillsOfDir,
47
+ EMIT_HOST_SCOPE, defaultTheme, deletionGateOfDir, doctrinesForBuild, excludedRulesOfDir,
48
+ excludedSkillsOfDir,
48
49
  footprintOfDir, modeOfDir, postureOfDir,
49
50
  trustOfDir,
50
51
  readJsonMaybe, readMaybe, themeOfDir,
@@ -183,6 +184,8 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
183
184
  excludeRules: excludedRulesOfDir(dir),
184
185
  // The excluded skills, or every skill the install left out reads as deleted.
185
186
  excludeSkills: excludedSkillsOfDir(dir),
187
+ // Law IV's gate switch, or a disarmed install reads its `Deletion gate: off` line as drift.
188
+ deletionGate: deletionGateOfDir(dir),
186
189
  }));
187
190
  // The one place a render depends on WHERE it is installed: a global Bob emit writes each
188
191
  // folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
@@ -421,7 +421,9 @@ export function statusLines(d, color = false) {
421
421
  const packs = p.doctrines.length ? p.doctrines.join(',') : 'no packs';
422
422
  const excl = p.excludeRules?.length ? `excluding ${p.excludeRules.join(',')}` : 'no rules excluded';
423
423
  rows.push([`${p.host}:${p.scope}`, [p.state, p.theme, p.footprint, `${p.posture}/${p.mode}/${p.trust}`,
424
- packs, excl, ...(p.excludeSkills?.length ? [`skills off: ${p.excludeSkills.join(',')}`] : [])]
424
+ packs, excl, ...(p.excludeSkills?.length ? [`skills off: ${p.excludeSkills.join(',')}`] : []),
425
+ // Elided when armed. Law IV's text still binds; only its tool-boundary gate is off.
426
+ ...(p.deletionGate === false ? ['deletion gate: off (Law IV text still binds)'] : [])]
425
427
  .join(` ${DOT} `) + where]);
426
428
  }
427
429
 
@@ -10,7 +10,7 @@ import path from 'node:path';
10
10
  import { buildInto, emitGlobalInto, emitProjectInto } from '../build/driver.mjs';
11
11
  import { resolvePath } from '../hosts/hosts.mjs';
12
12
  import {
13
- doctrinesOfDir, excludedRulesOfDir, modeOfDir, postureOfDir,
13
+ deletionGateOfDir, doctrinesOfDir, excludedRulesOfDir, modeOfDir, postureOfDir,
14
14
  } from '../hosts/installs.mjs';
15
15
  import { validateIsVendored } from '../hosts/native.mjs';
16
16
  import { printOut, printErr } from '../lib/fs.mjs';
@@ -33,7 +33,7 @@ export const VALIDATE_USAGE = [
33
33
  'usage: geneseed validate [-h] [--theme THEME] [--emit EMIT] [--out OUT] [--root ROOT]',
34
34
  ' [--footprint {lean,full}] [--posture POSTURE] [--mode MODE]',
35
35
  ' [--trust TRUST] [--doctrines DOCTRINES] [--exclude-rules RULES]',
36
- ' [--exclude-skills SKILLS] [-v]',
36
+ ' [--exclude-skills SKILLS] [--no-deletion-gate] [-v]',
37
37
  '',
38
38
  "Render and emit into a throwaway sandbox, run the doctor's checks, and write nothing real.",
39
39
  "Takes the generator's render flags (see geneseed-build --help) except --sync-themes and",
@@ -128,7 +128,7 @@ export function cmdValidate(args) {
128
128
  const opts = {
129
129
  theme: args.theme, footprint: args.footprint, posture: args.posture, mode: args.mode,
130
130
  trust: args.trust, doctrines: args.doctrines, excludeRules: args.excludeRules,
131
- excludeSkills: args.excludeSkills,
131
+ excludeSkills: args.excludeSkills, deletionGate: args.noDeletionGate ? false : null,
132
132
  };
133
133
  let scanDirs;
134
134
  try {
@@ -172,7 +172,9 @@ export function cmdValidate(args) {
172
172
  const excluded = excludedRulesOfDir(at);
173
173
  printOut(`[validate-only] rendered at posture=${postureOfDir(at) ?? '?'} `
174
174
  + `mode=${modeOfDir(at) ?? '?'} packs=${packs ? packs.join(',') || 'none' : '?'} `
175
- + `excluded=${excluded.map((id) => id.replace('.', ' ')).join(', ') || 'none'}\n`);
175
+ + `excluded=${excluded.map((id) => id.replace('.', ' ')).join(', ') || 'none'}`
176
+ // Elided when armed, so an armed dry run prints the line it always did.
177
+ + `${deletionGateOfDir(at) ? '' : ' deletion-gate=off'}\n`);
176
178
  printOut(`[validate-only] would write ${written.length} file(s) under ${args.out}`
177
179
  + (args.root ? ` (root ${args.root})` : '')
178
180
  + ' — nothing was actually written (sandboxed).\n');
@@ -51,7 +51,7 @@ import { hookRunnerEntry } from '../hosts/shim.mjs';
51
51
  import { confirm } from '../lib/prompt.mjs';
52
52
  import {
53
53
  claudeCfg, claudeReadManifest, doctrinesOfDir, EMIT_HOST_SCOPE, emitHostScopeOf,
54
- excludedRulesOfDir, installKind, installState,
54
+ deletionGateOfDir, excludedRulesOfDir, installKind, installState,
55
55
  registeredTargets,
56
56
  } from '../hosts/installs.mjs';
57
57
  import {
@@ -1173,8 +1173,11 @@ function remergeClaudeHooks(cfg, root = cfg, host = 'claude') {
1173
1173
  const excluded = excludedRulesOfDir(root, host).length ? excludedRulesOfDir(root, host)
1174
1174
  : excludedRulesOfDir(cfg, host);
1175
1175
  // `host` reaches the group builder: a Bob reactivate must re-wire Gemini-named groups.
1176
+ // Law IV's gate switch, the same two-carrier read: off only when a carrier says so, so a
1177
+ // reactivate never disarms an install that did not ask for it.
1178
+ const deletionGate = deletionGateOfDir(root, host) && deletionGateOfDir(cfg, host);
1176
1179
  const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed),
1177
- managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host, cfg);
1180
+ managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host, cfg, deletionGate);
1178
1181
  // `and data` — a manifest that did not parse is not one to write back.
1179
1182
  if (!deepEquals(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
1180
1183
  managed.settings_hooks = claims;
@@ -632,7 +632,7 @@ export async function installAxes(dir, { installProfile, setupBuildArgs }, notif
632
632
  if (legacyProcessCarrierOfDir(String(dir), 'opencode')) return null;
633
633
  const p = installProfile('opencode', 'global', String(dir), notify);
634
634
  return setupBuildArgs('', 'files', null, null, p.footprint, p.posture, p.mode, p.doctrines,
635
- undefined, p.excludeRules, p.trust, p.excludeSkills).slice(4);
635
+ undefined, p.excludeRules, p.trust, p.excludeSkills, p.deletionGate).slice(4);
636
636
  }
637
637
 
638
638
  /**
@@ -46,7 +46,8 @@ import {
46
46
  opencodeConfigDir, resolvePath,
47
47
  } from '../hosts/hosts.mjs';
48
48
  import {
49
- EMIT_HOST_SCOPE, doctrinesForBuild, excludedRulesOfDir, excludedSkillsOfDir, footprintOfDir,
49
+ EMIT_HOST_SCOPE, deletionGateOfDir, doctrinesForBuild, excludedRulesOfDir, excludedSkillsOfDir,
50
+ footprintOfDir,
50
51
  installState, installTargets, legacyProcessCarrierOfDir, modeOfDir, postureOfDir, trustOfDir,
51
52
  } from '../hosts/installs.mjs';
52
53
  import {
@@ -129,6 +130,16 @@ function bodyExcludeRules(body) {
129
130
  (n) => n.trim().replace(/[ \t]+/, '.'));
130
131
  }
131
132
 
133
+ /**
134
+ * Law IV's gate switch across the trust boundary: only a real JSON boolean counts. Anything
135
+ * else — a string `"false"`, a number, absence — is `null`, and the caller keeps the install's
136
+ * own answer, so a malformed body can never disarm the gate by accident.
137
+ */
138
+ function bodyDeletionGate(body) {
139
+ const v = bget(body, 'deletionGate');
140
+ return typeof v === 'boolean' ? v : null;
141
+ }
142
+
132
143
  /**
133
144
  * The five scalar axes of a build request — theme, footprint, posture, mode, trust — each
134
145
  * taken from the body only when it names a value this checkout ships, else from `dflt`. The
@@ -490,6 +501,8 @@ export function apiRestore(state, files) {
490
501
  // re-states a rule whose hook gate the install deliberately left unwired.
491
502
  excludeRules: excludedRulesOfDir(target),
492
503
  excludeSkills: excludedSkillsOfDir(target),
504
+ // Law IV's gate switch: a restored AGENT.md must keep the install's `Deletion gate:` line.
505
+ deletionGate: deletionGateOfDir(target),
493
506
  }));
494
507
  for (const raw of (isTruthy(files) ? files : [])) {
495
508
  const rel = stripWhitespace(formatValue(raw).replace(/\\/g, '/')).replace(/^\/+/, '');
@@ -572,9 +585,12 @@ export function apiInstallCmd(state, body) {
572
585
  // the silence a pre-2.3 carrier gives on packs. Unspecified therefore keeps the install's
573
586
  // own answer, and a pre-marker install answers the empty list — which is what it has.
574
587
  const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root, host);
588
+ // Law IV's gate switch (the Constitution page's toggle): a strict boolean in the body, else
589
+ // the install's own answer — so a pack Apply, which sends none, keeps the switch as it is.
590
+ const deletionGate = bodyDeletionGate(body) ?? deletionGateOfDir(root, host);
575
591
  const out = scope === 'global' ? null : String(root);
576
592
  const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines,
577
- PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host));
593
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), deletionGate);
578
594
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
579
595
  }
580
596
 
@@ -673,8 +689,9 @@ export function apiDeployCmd(state, body) {
673
689
  // and `PreToolUse::Bash` went with them. `doctrinesForBuild` resolves unknown to ALL packs.
674
690
  const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root, host);
675
691
  const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root, host);
692
+ const deletionGate = bodyDeletionGate(body) ?? deletionGateOfDir(root, host);
676
693
  // project-scope emit name == host name (opencode / claude / bob / openclaude)
677
694
  const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines,
678
- PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host));
695
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, host), deletionGate);
679
696
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
680
697
  }
package/js/web/api.mjs CHANGED
@@ -20,7 +20,7 @@ import { doctorCollect } from '../inspect/doctor.mjs';
20
20
  import { excludesSnapshot } from '../inspect/excludes.mjs';
21
21
  import { CLAUDE_STYLE, HOSTS, VERSION_MARKER, resolvePath } from '../hosts/hosts.mjs';
22
22
  import {
23
- footprintOfDir, installState, installTargets, modeOfDir, trustOfDir,
23
+ deletionGateOfDir, footprintOfDir, installState, installTargets, modeOfDir, trustOfDir,
24
24
  postureOfDir, readJsonMaybe, themeOfDir,
25
25
  } from '../hosts/installs.mjs';
26
26
  import { PRESETS } from '../loop/score.mjs';
@@ -247,7 +247,9 @@ export function apiOverview(state) {
247
247
  for (const [host, scope, root] of installTargets()) {
248
248
  try {
249
249
  if (samePath(viewCfg(host, scope, root), state.target)) {
250
- install = { host, scope, path: root, footprint: footprintOfDir(root) };
250
+ // `deletionGate`: Law IV's gate switch, for the Constitution page's toggle on row IV.
251
+ install = { host, scope, path: root, footprint: footprintOfDir(root),
252
+ deletionGate: deletionGateOfDir(root, host) };
251
253
  break;
252
254
  }
253
255
  } catch { /* an unreadable candidate just isn't a match */ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
3
3
  "name": "geneseed",
4
- "version": "4.7.0",
4
+ "version": "4.8.0",
5
5
  "description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
6
6
  "keywords": [
7
7
  "ai",
package/src/AGENT.md.tmpl CHANGED
@@ -144,7 +144,7 @@ Inactive packs still ship under `{{DIR_DOCTRINES}}/`, so a citation into one res
144
144
 
145
145
  {{DOCTRINES_BODY}}
146
146
 
147
- Active packs: {{DOCTRINES_LIST}}{{EXCLUDED_RULES_LINE}}
147
+ Active packs: {{DOCTRINES_LIST}}{{EXCLUDED_RULES_LINE}}{{DELETION_GATE_LINE}}
148
148
 
149
149
  ---
150
150