geneseed 4.6.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 +36 -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 -37
  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 -39
  34. package/js/hosts/installs.mjs +20 -0
  35. package/js/hosts/loss.mjs +522 -0
  36. package/js/hosts/settings.mjs +79 -75
  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,55 +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); `worktree remove -f`/`--force` (both
107
- * flag-then-path and path-then-flag order); `reflog expire`; `gc --prune` (matched bare, since
108
- * a `--prune` with no `=now` still eventually prunes); and `push *--delete*` / `push * :*` (a
109
- * literal space before the colon, so `push origin HEAD:main`, an ordinary refspec, does not
110
- * 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.
111
104
  *
112
- * `git restore` is DELIBERATELY NOT HERE. Its rule is "ask unless `--staged` appears ALONE" —
113
- * `--staged --worktree` together still discards the working tree — and a glob has no negation,
114
- * so the closest glob (`git restore*`) would ask on the harmless `--staged`-alone case too. The
115
- * regex (`DESTRUCTIVE_GIT_RE`) gets that exactly right; `stash drop`/`clear` have the SAME
116
- * `restore` anchoring concern in the regex (a bare word / ordinary English, matched there only
117
- * in verb position) but no false-positive risk as a glob, since OpenCode matches a glob against
118
- * the FULL sub-command text (`opencode-verdict.md` CR-6) — `git stash drop*` cannot match a
119
- * DIFFERENT sub-command like `git stash push -m "clear old state"`, so they ARE glob keys here.
120
- * Guard-only acts (currently just `restore`) still warn via the OpenCode guard plugin's
121
- * `SHELL_WARN_RE` — see `adapters/opencode/plugins/geneseed-guard.js`.
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.
110
+ *
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.
122
118
  */
123
- const LAW_IV_BASH = ['git push --force*', 'git push -f*', 'git push *--force*', 'git push * -f*',
124
- 'git push *+*', 'git reset --hard*', 'git reset * --hard*', 'git clean -f*', 'git clean * -f*',
125
- 'git branch -D*', 'git checkout -- *',
126
- 'git clean --force*', 'git clean * --force*',
127
- 'git branch *--delete*--force*', 'git branch *--force*--delete*',
128
- 'git checkout -f*', 'git checkout * -f*', 'git checkout --force*', 'git checkout * --force*',
129
- 'git switch -f*', 'git switch * -f*', 'git switch --force*', 'git switch * --force*',
130
- 'git switch --discard-changes*', 'git switch * --discard-changes*',
131
- 'git worktree remove -f*', 'git worktree remove --force*',
132
- 'git worktree remove * -f*', 'git worktree remove * --force*',
133
- 'git reflog expire*', 'git gc --prune*', 'git gc * --prune*',
134
- 'git stash drop*', 'git stash clear*',
135
- '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];
136
128
 
137
129
  /**
138
130
  * User decision (2026-10-10): a loop runs on its own `loop/*` branch, never `main`/`master`, so
@@ -147,12 +139,13 @@ const LAW_IV_BASH = ['git push --force*', 'git push -f*', 'git push *--force*',
147
139
  *
148
140
  * ORDER IS LOAD-BEARING. `PUSH_CHAIN` (which `defaultPermission` and `OWNED_BASH` both read)
149
141
  * puts these AFTER `git push*`'s ask (so a loop
150
- * push is not swallowed by the blanket consent ask) and BEFORE `LAW_IV_BASH` (so `findLast`
151
- * — OpenCode's own `permission.bash` evaluator — lets a force push, a `+refspec`, or a
152
- * `--delete`/bare-`:` push still ask even when the target is `loop/*`; those LAW_IV_BASH keys
153
- * match the same command and come later, so they win). Never move this block to before
154
- * `git push*` or after `LAW_IV_BASH` — either moves the `findLast` boundary the ordering above
155
- * 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.
156
149
  */
157
150
  const LOOP_PUSH_ALLOW = ['git push * HEAD:loop/*', 'git push * HEAD:refs/heads/loop/*'];
158
151
 
@@ -208,9 +201,9 @@ const LOOP_PUSH_ALLOW = ['git push * HEAD:loop/*', 'git push * HEAD:refs/heads/l
208
201
  * as a legitimate bare remote) — indistinguishable from a real remote name by token count alone,
209
202
  * and not a practical gap: a leading `-`/`--` token is parsed by git as an OPTION, never as the
210
203
  * remote, so this exact shape either errors (no remote given) or reads the refspec text itself
211
- * as the remote and fails to resolve it — it does not reach a working push to `main`. Still
212
- * caught by `LAW_IV_BASH`'s existing anywhere-in-the-command globs regardless of position
213
- * (`'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.)
214
207
  */
215
208
  const LOOP_PUSH_ASK_EXTRA = ['git push * * HEAD:loop/*', 'git push * * HEAD:refs/heads/loop/*'];
216
209
  const LOOP_PUSH_ALLOW_FLAG = [
@@ -232,13 +225,18 @@ const PUSH_CHAIN = [
232
225
  [LOOP_PUSH_ASK_FLAG_EXTRA, 'ask'], [LOOP_PUSH_ASK_TRAILING, 'ask'], [LAW_IV_BASH, 'ask'],
233
226
  ];
234
227
 
235
- function defaultPermission(doctrines = null, excluded = []) {
236
- 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 = {};
237
232
  if (consentRuleOn(doctrines, excluded)) {
238
233
  bash['git commit*'] = 'ask';
239
234
  bash['git push*'] = 'ask';
240
235
  }
241
- 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
+ }
242
240
  return { bash };
243
241
  }
244
242
 
@@ -249,7 +247,7 @@ function defaultPermission(doctrines = null, excluded = []) {
249
247
  * has to be able to recognise a key that is Geneseed's business at all — to add it back when
250
248
  * the pack returns, and to name it when this build no longer wants it but will not remove it.
251
249
  */
252
- const OWNED_BASH = ['rm -rf *', 'git commit*', 'git push*',
250
+ const OWNED_BASH = ['git commit*', 'git push*',
253
251
  ...PUSH_CHAIN.flatMap(([keys]) => keys)];
254
252
 
255
253
  /**
@@ -506,7 +504,8 @@ export function loadJsonObject(p, { strict = false } = {}) {
506
504
  * `'add'`, which is what the boolean callers this replaced meant.
507
505
  */
508
506
  function warnCommentedJsonc(target, agentPath, permission,
509
- includeLsp = false, prefix = 'geneseed', doctrines = null, excluded = [], skillNames = []) {
507
+ includeLsp = false, prefix = 'geneseed', doctrines = null, excluded = [], skillNames = [],
508
+ deletionGate = true) {
510
509
  process.stdout.write(`[${prefix}] ${path.basename(target)} has comments — not rewriting `
511
510
  + 'it (your edits are kept). Add this to its "instructions" array by hand:\n');
512
511
  process.stdout.write(`[${prefix}] ${jsonDumps(agentPath)}\n`);
@@ -515,7 +514,7 @@ function warnCommentedJsonc(target, agentPath, permission,
515
514
  ? `[${prefix}] and its "permission".bash is missing gates this build wires — `
516
515
  + 'reconcile it by hand against:\n'
517
516
  : `[${prefix}] and, for Geneseed's default ask-gates, a "permission" key:\n`);
518
- for (const line of jsonDumpsIndent(defaultPermission(doctrines, excluded)).split('\n')) {
517
+ for (const line of jsonDumpsIndent(defaultPermission(doctrines, excluded, deletionGate)).split('\n')) {
519
518
  process.stdout.write(`[${prefix}] ${line}\n`);
520
519
  }
521
520
  if (skillNames.length) {
@@ -566,7 +565,7 @@ export function atomicWriteJson(p, config) {
566
565
  * every path that wrote nothing — unreadable, not an object, commented `.jsonc`, write
567
566
  * failure — so the caller keeps its previous record rather than claiming denies never written. */
568
567
  export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [], skillNames = [],
569
- staleSkillNames = [], result = {}) {
568
+ staleSkillNames = [], result = {}, deletionGate = true) {
570
569
  const target = opencodeTarget(p);
571
570
  let config = { $schema: OPENCODE_SCHEMA, instructions: [] };
572
571
  const { state, data, hadComments, error } = loadJsonObject(target);
@@ -601,7 +600,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
601
600
  let permIsObject = true;
602
601
  const added = [];
603
602
  if (addPerm) {
604
- config.permission = defaultPermission(doctrines, excluded);
603
+ config.permission = defaultPermission(doctrines, excluded, deletionGate);
605
604
  if (skillNames.length) {
606
605
  config.permission.skill = defaultSkillPermission(skillNames);
607
606
  added.push(...skillNames);
@@ -610,7 +609,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
610
609
  const perm = get(config, 'permission');
611
610
  permIsObject = isDict(perm);
612
611
  [permChanged, stranded, opaque] = reconcileOpencodePermission(perm,
613
- defaultPermission(doctrines, excluded));
612
+ defaultPermission(doctrines, excluded, deletionGate));
614
613
  // Independent of the `bash` shape: a `permission.bash` the build cannot wire into must
615
614
  // not also block the UNRELATED `permission.skill` key from being reconciled.
616
615
  if (permIsObject) {
@@ -630,7 +629,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
630
629
  // skill reconcile above never ran either — the unwired skill denies are folded into THIS
631
630
  // one WARN rather than going unmentioned (a dedicated `skillOpaque` needs `perm` to be a
632
631
  // dict to even look at `perm.skill`, which this case by definition is not).
633
- const wanted = defaultPermission(doctrines, excluded).bash;
632
+ const wanted = defaultPermission(doctrines, excluded, deletionGate).bash;
634
633
  const names = [...OWNED_BASH.filter((k) => has(wanted, k)),
635
634
  ...(!permIsObject ? skillNames : [])];
636
635
  process.stderr.write(`[geneseed] WARN: "permission" in ${path.basename(target)} `
@@ -650,9 +649,9 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
650
649
  if (stranded.length) {
651
650
  process.stderr.write(`[geneseed] WARN: ${path.basename(target)} keeps `
652
651
  + `${stranded.map((k) => jsonDumps(k)).join(', ')} under "permission".bash — the `
653
- + 'doctrine pack behind those gates is off in this build, but Geneseed cannot tell its '
654
- + 'own entry from one you wrote, so it removes neither. Delete them by hand if you want '
655
- + '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');
656
655
  }
657
656
  const addLsp = !has(config, 'lsp');
658
657
  if (addLsp) config.lsp = true;
@@ -673,7 +672,7 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
673
672
  // block to paste, split by which of the two situations the reader is actually in. It is
674
673
  // stdout, not stderr, because on this path the paste-in IS the output.
675
674
  warnCommentedJsonc(target, agentPath, addPerm ? 'add' : (permChanged && 'reconcile'),
676
- addLsp, 'geneseed', doctrines, excluded, skillNames);
675
+ addLsp, 'geneseed', doctrines, excluded, skillNames, deletionGate);
677
676
  return target;
678
677
  }
679
678
  try {
@@ -726,7 +725,8 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
726
725
  * belongs in — the user's call — not how work is run, so it is not the process pack's to
727
726
  * remove. `default` here means unknown, and unknown keeps the gate (see `processPackOn`).
728
727
  */
729
- export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [], host = 'claude') {
728
+ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [], host = 'claude',
729
+ deletionGate = true) {
730
730
  const run = hookPrefix(hookOpts);
731
731
  const mem = `--memory "${path.join(cfg, 'memory')}"`;
732
732
  // --root carries the install's own dir so a GLOBAL hook can stand down when a live, wired
@@ -757,14 +757,17 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
757
757
  // else. Unverified live: no Bob install on the authoring machine. Revisit a matcher only
758
758
  // once a live Bob session can confirm both the current tool list and the regex dialect.
759
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' : '';
760
763
  return {
761
764
  SessionStart: [{ hooks: [{ type: 'command', command: `${run} context --root "${cfg}"${b} || exit 0` }] }],
762
765
  // One group, both gates: process 5's consent is a stderr line here (Bob has no ask
763
766
  // tier), so the pack toggle that drops Claude's git-gate group has nothing to drop —
764
- // `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
765
768
  // write under a project's own `.geneseed/protected-checks.txt` — the project opted in —
766
769
  // and for a recursive scan rooted at a whole filesystem (ops 2, the root-scan check).
767
- PreToolUse: [{ hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"${b}` }] }],
770
+ PreToolUse: [{ hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"${b}${dg}` }] }],
768
771
  // Bob's Stop payload never carries `transcript_path` (I2) — `learn` reads `--host bob`
769
772
  // and returns immediately rather than wasting a model call on the bare envelope. KEPT
770
773
  // rather than DROPPED: the stand-down costs nothing (no transcript read, no spawn), and
@@ -781,7 +784,8 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
781
784
  // `--host` on `git-gate` but not on `learn`, which then exits 1 under `|| exit 0` and skips.
782
785
  const h = host === 'openclaude' ? ' --host openclaude' : '';
783
786
  const context = `${run} context --root "${cfg}"${h} || exit 0`;
784
- 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' : '');
785
789
  const ruleGate = `${run} rule-gate --root "${cfg}"`;
786
790
  const learn = `${run} learn ${mem}${h} || exit 0`;
787
791
  // Claude Code only (hooks.md, v2.1.295+; Task 1): a `command` hook that can't start, times
@@ -920,14 +924,14 @@ function readSettingsForMerge(p, consequence) {
920
924
  * global that never stood down for a project install.
921
925
  */
922
926
  export function mergeClaudeSettings(p, priorHooks = null, hookOpts = {},
923
- doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p)) {
927
+ doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p), deletionGate = true) {
924
928
  const prior = (priorHooks || []).filter(isDict);
925
929
  const loaded = readSettingsForMerge(p, 'Hooks were not wired.');
926
930
  if (!loaded) return [p, prior];
927
931
  const [config, hadComments] = loaded;
928
932
  let hooks = get(config, 'hooks');
929
933
  if (!isDict(hooks)) hooks = {};
930
- const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host);
934
+ const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host, deletionGate);
931
935
  const canonFlat = [];
932
936
  for (const [event, groups] of Object.entries(canonical)) {
933
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.6.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
 
@@ -69,24 +69,24 @@ governs the goal).
69
69
 
70
70
  ### {{LAW:deletion-is-deliberate}} Deletion Is Deliberate
71
71
  <!-- LEAN:begin -->
72
- Deletion of what version control cannot restore, and any irreversible or
73
- outward-facing act — publishing, force-push, sending data to a third party —
74
- requires explicit confirmation bound to that specific act, or a durable
75
- authorization the user gave that already covers it; a yes to one act is never
76
- stretched to the next. Classify every action as Create, Read, Update, or Delete
77
- before acting, and tier it by reversibility: a read-only or easily-reversible
78
- action — an edit, or a delete of what is committed and pushed to a branch —
79
- runs freely; an irreversible, financial, externally-visible, or
80
- privilege-changing one needs that confirmation. Uncommitted, unpushed, or
81
- untracked work is not recoverable.
72
+ Real data loss and any irreversible, financial, privilege-changing or
73
+ outward-facing act (publishing, sending data to a third party) need explicit
74
+ confirmation bound to that act, or a durable authorization covering it; one
75
+ yes never stretches to the next. Real loss: deleting what version control
76
+ cannot restore (untracked, modified, unpushed or unmerged work), discarding
77
+ uncommitted changes, rewriting, deleting or force-moving main, master or the
78
+ default branch, or erasing reflog/gc data. Classify every action as Create,
79
+ Read, Update, or Delete and tier by reversibility: what git restores — an
80
+ edit, removing tracked clean files, force-pushing your own branch, removing a
81
+ worktree — runs freely.
82
82
  <!-- LEAN:else -->
83
- Deleting what version control cannot restore, and any irreversible or
84
- outward-facing act — publishing, force-push, sending data to a third party —
85
- needs explicit confirmation bound to that act, or a durable authorization that
86
- covers it; one yes never stretches to the next act. A read-only or
87
- easily-reversed action — deleting work pushed to a branch — runs freely; an
88
- irreversible, financial, externally-visible, or privilege-changing one asks.
89
- Deleting unpushed work asks.
83
+ Real data loss — deleting untracked, modified, unpushed, unmerged work,
84
+ discarding uncommitted work, deleting or force-moving main, master or the
85
+ default branch, pruning reflog/gc — and any irreversible, financial,
86
+ privilege-changing or outward-facing act need explicit confirmation bound to
87
+ that act, or a durable authorization covering it; one yes never stretches to
88
+ the next. What git restores runs freely: tracked clean files, a worktree,
89
+ force-pushing your own branch.
90
90
  <!-- LEAN:end -->
91
91
 
92
92
  ### {{LAW:surface-failures}} Surface Failures