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
@@ -0,0 +1,522 @@
1
+ /**
2
+ * js/hosts/loss.mjs — Deletion Is Deliberate, calibrated to REAL loss.
3
+ *
4
+ * WHY THIS EXISTS. The git gate used to ask on every destructive-LOOKING form: `reset --hard`
5
+ * on a clean tree, `branch -D` of a branch already on its remote, a force push to a feature
6
+ * branch. A gate that asks when nothing can be lost teaches the user to click through it, and
7
+ * then it protects nothing (user decision, 2026-10-17). So the boundary now asks only when the
8
+ * command would destroy something nothing else holds:
9
+ *
10
+ * - a protected branch (`main`, `master`, a remote's default) deleted or force-pushed, local
11
+ * or remote;
12
+ * - `branch -D x` while x carries a commit no remote ref and no HEAD reaches;
13
+ * - uncommitted work discarded (`reset --hard`, `checkout -f`/`-- <paths>`, `switch -f`/
14
+ * `--discard-changes`, `restore` beyond `--staged` alone, `clean -f`) — and only when the
15
+ * tree actually holds such work (for `clean`, what `git clean -n` says it would remove);
16
+ * - only where a protected branch is concerned (user ruling, 2026-10-17): `checkout <paths>`
17
+ * without `--` and `git rm -f` over changed files while a protected branch is checked out, and
18
+ * `branch -f`/`checkout -B`/`switch -C` moving a protected branch off commits nothing else
19
+ * holds — the same forms on a feature branch pass;
20
+ * - `stash drop`/`stash clear` while a stash exists; `reflog expire` and `gc --prune` always;
21
+ * - a file removal (`rm`, `rmdir`, `del`, `erase`, `Remove-Item`/`ri`/`rd`) of anything git
22
+ * cannot bring back: untracked, ignored or modified files, a repository's own `.git`, or
23
+ * any path outside a git repository.
24
+ *
25
+ * FAIL CLOSED. Anything this reader cannot settle — a glob or a `$variable` in a path it must
26
+ * name, a `cd` it cannot follow, an interpreter (`sh -c`, `xargs`, `eval`) carrying a removal,
27
+ * a git that fails, times out or is missing — asks, and the reason says it could not verify.
28
+ * "Not proven safe" is an ask; only "proven safe" passes.
29
+ *
30
+ * THE ONE SPAWN IN THE GATE PATH, and why it is allowed. Whether a work tree is dirty, which
31
+ * files are untracked, and whether a branch's commits are reachable elsewhere are not readable
32
+ * off disk at any sane cost (the index is a binary format with stat caches; reachability walks
33
+ * compressed objects and packs). So `git` itself answers, through `git()` below — `shell:false`,
34
+ * a timeout, no optional locks (`git status` must never take `index.lock` under the user), no
35
+ * fsmonitor, no terminal prompt. It is reached ONLY after `LOSS_CANDIDATE_RE` matched a
36
+ * destructive-looking form and the parse below found one in program position: an ordinary
37
+ * command never gets here (`js/hosts/hooks.mjs` tests the regex first), so the ~14 ms hook
38
+ * budget is untouched for everything else. Refs, the remote default branch and the stash are
39
+ * still read off disk (`./gitref.mjs`), which costs nothing. One ordinary form does pay: a plain
40
+ * `git checkout <x>` while a protected branch is checked out runs `git rev-parse` to tell a
41
+ * branch switch from a path discard (the branch is read off disk first, so a feature branch
42
+ * never pays it).
43
+ *
44
+ * TWIN: `adapters/opencode/plugins/geneseed-guard.js` carries the same decisions (the plugin is
45
+ * copied whole into an OpenCode install and cannot import this). Keep the two in step;
46
+ * `tests/plugins/guard.test.mjs` drives both through one table of real repositories.
47
+ */
48
+ import { spawnSync } from 'node:child_process';
49
+ import { lstatSync } from 'node:fs';
50
+ import path from 'node:path';
51
+ import { currentBranch, gitRootOf, protectedBranches, stashExists } from './gitref.mjs';
52
+
53
+ // ---- loss twin: BEGIN — adapters/opencode/plugins/geneseed-guard.js carries everything down to
54
+ // the END marker verbatim, with `export ` dropped (tests/plugins/guard.test.mjs diffs the two).
55
+
56
+ /**
57
+ * The cheap prefilter: a command that matches neither alternative never reaches the parser or
58
+ * a spawn. A git destructive VERB anywhere after `git` on the same line, or a removal program
59
+ * as a word at a command boundary. Deliberately loose — the parse decides; this only has to be
60
+ * a superset of what the parse acts on.
61
+ */
62
+ export const LOSS_CANDIDATE_RE =
63
+ /\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'])(?:rm|rmdir|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"']|$)/im;
64
+
65
+ class Unverified extends Error {}
66
+ const unverified = (why) => { throw new Unverified(why); };
67
+
68
+ const RM_PROGS = new Set(['rm', 'rmdir', 'del', 'erase', 'remove-item', 'ri', 'rd']);
69
+ // cmd.exe's own removal verbs take `/s` `/q` switches, which are not paths.
70
+ const CMD_SWITCH_PROGS = new Set(['rmdir', 'del', 'erase', 'rd']);
71
+ const CD_PROGS = new Set(['cd', 'pushd', 'chdir', 'set-location', 'sl']);
72
+ const WRAPPERS = new Set(['sudo', 'doas', 'command', 'builtin', '\\builtin', 'time', 'nice', 'nohup', 'exec', 'env']);
73
+ // Programs that run ANOTHER command from their arguments or stdin: what they would remove is
74
+ // not on this command line in a form the parse can read, so a removal word inside one asks.
75
+ const INDIRECT = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd',
76
+ 'eval', 'xargs', 'find', 'parallel', 'watch', 'invoke-expression', 'iex']);
77
+
78
+ const OPAQUE = '\u0000';
79
+ const GIT_TIMEOUT_MS = 5000;
80
+
81
+ /**
82
+ * `git -C <dir> …` — THE spawn. Returns `{ status, stdout, stderr }`; throws `Unverified` when
83
+ * git could not run to completion (missing, timed out, output too large), so the caller asks.
84
+ */
85
+ function git(dir, args) {
86
+ /** @type {Record<string, string | undefined>} */
87
+ const env = { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' };
88
+ delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE;
89
+ const r = spawnSync('git', ['-C', dir, '-c', 'core.fsmonitor=false', ...args], {
90
+ encoding: 'utf8', shell: false, windowsHide: true, timeout: GIT_TIMEOUT_MS,
91
+ maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'], env,
92
+ });
93
+ if (r.error || r.status === null) {
94
+ unverified(`git ${args[0]} did not finish (${r.error ? /** @type {any} */ (r.error).code || r.error.message : 'killed'})`);
95
+ }
96
+ return r;
97
+ }
98
+
99
+ const firstLine = (s) => String(s || '').trim().split(/\r?\n/)[0] || 'no output';
100
+
101
+ /** A raw token with its quotes removed, or `OPAQUE`-marked when the shell would rewrite it. */
102
+ function word(raw) {
103
+ const opaque = /[`$]|^~|[{}]/.test(raw);
104
+ const s = raw.replace(/"([^"]*)"?|'([^']*)'?/g, (_, a, b) => a ?? b ?? '');
105
+ return opaque ? `${OPAQUE}${s}` : s;
106
+ }
107
+
108
+ /** The segment's words, redirections dropped (`2>/dev/null`, `> out.log`) — they are not operands. */
109
+ function words(rawTokens) {
110
+ const out = [];
111
+ for (let i = 0; i < rawTokens.length; i++) {
112
+ const t = rawTokens[i];
113
+ if (/^(?:\d*|&)(?:>>?|<)&?$/.test(t)) { i += 1; continue; } // a bare operator: drop its target too
114
+ if (/^(?:\d*|&)(?:>>?|<)/.test(t)) continue;
115
+ out.push(word(t));
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /** `p` resolved against `dir`. On Windows a Git Bash `/c/…` path maps to `C:/…`; any other POSIX root cannot. */
121
+ function resolveIn(dir, p) {
122
+ if (p.includes(OPAQUE)) unverified(`\`${p.replace(OPAQUE, '')}\` expands in the shell`);
123
+ let q = p;
124
+ if (process.platform === 'win32') {
125
+ const m = /^\/([a-zA-Z])(\/.*)?$/.exec(q);
126
+ if (m) q = `${m[1]}:${m[2] || '/'}`;
127
+ else if (/^\/(?!\/)/.test(q)) unverified(`${p} is a POSIX path this host cannot map`);
128
+ }
129
+ if (!dir && !path.isAbsolute(q)) unverified('the working directory is unknown after a `cd` the gate cannot follow');
130
+ return path.resolve(dir || '', q);
131
+ }
132
+
133
+ function repoRoot(dir) {
134
+ if (!dir) unverified('the working directory is unknown after a `cd` the gate cannot follow');
135
+ return gitRootOf(dir) || unverified(`${dir} is not inside a git repository`);
136
+ }
137
+
138
+ /** Ask if `git status` shows a tracked change (staged or not) under `paths` (all when empty). */
139
+ function dirty(dir, paths, act) {
140
+ for (const p of paths) if (p.includes(OPAQUE)) unverified(`\`${p.replace(OPAQUE, '')}\` expands in the shell`);
141
+ repoRoot(dir);
142
+ const r = git(dir, ['status', '--porcelain', '-z', '--untracked-files=no', '--', ...paths]);
143
+ if (r.status !== 0) unverified(`git status failed: ${firstLine(r.stderr)}`);
144
+ const first = r.stdout.split('\0').find(Boolean);
145
+ return first ? `${act} would discard uncommitted changes (${first.slice(3)})` : null;
146
+ }
147
+
148
+ const isCluster = (a) => /^-[a-zA-Z]+$/.test(a);
149
+ const hasShort = (a, c) => isCluster(a) && a.includes(c);
150
+
151
+ /** `git push` — asks only when a protected branch would be force-pushed or deleted. */
152
+ function pushAct(rest, dir) {
153
+ let force = false; let del = false; let all = false; let mirror = false; let prune = false;
154
+ const pos = [];
155
+ for (let i = 0; i < rest.length; i++) {
156
+ const a = rest[i];
157
+ if (a === '--') { pos.push(...rest.slice(i + 1)); break; }
158
+ if (['-o', '--push-option', '--repo', '--receive-pack', '--exec'].includes(a)) { i += 1; continue; }
159
+ if (a.startsWith('--')) {
160
+ if (/^--force(?:-with-lease)?(?:=|$)/.test(a)) force = true;
161
+ else if (a === '--delete') del = true;
162
+ else if (a === '--all' || a === '--branches') all = true;
163
+ else if (a === '--mirror') mirror = true;
164
+ else if (a === '--prune') prune = true;
165
+ continue;
166
+ }
167
+ if (isCluster(a)) { if (a.includes('f')) force = true; if (a.includes('d')) del = true; continue; }
168
+ pos.push(a);
169
+ }
170
+ const specs = pos.slice(1);
171
+ const forcedSpec = (s) => force || del || s.startsWith('+') || s.startsWith(':');
172
+ if (!force && !del && !mirror && !prune && !specs.some(forcedSpec)) return null;
173
+ if (mirror) return '`git push --mirror` force-pushes and deletes every remote branch to match this repository';
174
+ if (prune) return '`git push --prune` deletes every remote branch with no local counterpart';
175
+ if (all && force) return 'a forced `git push --all` force-pushes every branch, protected ones included';
176
+ const root = repoRoot(dir);
177
+ const protectedSet = protectedBranches(root);
178
+ const verb = del ? 'delete' : 'force-push';
179
+ let targets;
180
+ if (specs.length) {
181
+ targets = [];
182
+ for (const spec of specs) {
183
+ if (spec.includes(OPAQUE)) unverified(`the refspec \`${spec.replace(OPAQUE, '')}\` expands in the shell`);
184
+ if (!forcedSpec(spec)) continue;
185
+ const s = spec.replace(/^\+/, '');
186
+ const colon = s.indexOf(':');
187
+ if (!del && colon === 0) {
188
+ const dst = s.slice(1).replace(/^refs\/heads\//, '');
189
+ if (protectedSet.has(dst)) return `\`git push ${spec}\` would delete the protected branch ${dst}`;
190
+ continue;
191
+ }
192
+ targets.push(del || colon < 0 ? s : s.slice(colon + 1));
193
+ }
194
+ } else {
195
+ targets = defaultPushTargets(dir);
196
+ }
197
+ for (let t of targets) {
198
+ if (t === 'HEAD' || t === '@') t = currentBranch(dir) || unverified('HEAD is detached, so the pushed branch is unknown');
199
+ t = t.replace(/^refs\/heads\//, '');
200
+ if (t.startsWith('refs/')) continue; // a tag or another namespace, not a branch
201
+ if (/[*?]/.test(t)) unverified(`the refspec target \`${t}\` is a pattern`);
202
+ if (protectedSet.has(t)) return `\`git push\` would ${verb} the protected branch ${t}`;
203
+ }
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * What a refspec-less forced push targets: the current branch (`simple`/`current`) and its
209
+ * upstream (`upstream`). `matching` pushes every branch and a `remote.*.push` rewrites the
210
+ * target — neither is something this read can bound, so both ask.
211
+ */
212
+ function defaultPushTargets(dir) {
213
+ const cur = currentBranch(dir) || unverified('HEAD is detached, so the pushed branch is unknown');
214
+ const esc = cur.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
215
+ const r = git(dir, ['config', '--get-regexp', `^(push\\.default|remote\\..*\\.push|branch\\.${esc}\\.merge)$`]);
216
+ if (r.status !== 0 && r.status !== 1) unverified(`git config failed: ${firstLine(r.stderr)}`);
217
+ const targets = [cur];
218
+ for (const line of r.stdout.split(/\r?\n/).filter(Boolean)) {
219
+ const [key, value = ''] = line.split(/\s+/, 2);
220
+ if (/^remote\..*\.push$/i.test(key)) unverified(`${key} rewrites what a bare push targets`);
221
+ if (/^push\.default$/i.test(key) && value === 'matching') unverified('push.default=matching pushes every branch');
222
+ if (/\.merge$/i.test(key)) targets.push(value);
223
+ }
224
+ return targets;
225
+ }
226
+
227
+ /**
228
+ * The checked-out branch when it is protected, null when HEAD is detached or unreadable, and
229
+ * undefined when it is a known unprotected branch — read off disk, so a feature branch passes the
230
+ * protected-branch-only forms below without running git (user ruling, 2026-10-17).
231
+ */
232
+ function protectedHead(dir) {
233
+ const cur = currentBranch(dir);
234
+ return cur && !protectedBranches(repoRoot(dir)).has(cur) ? undefined : cur;
235
+ }
236
+
237
+ /** `lost` on the protected branch `cur`; an unknown HEAD cannot be cleared as unprotected, so it asks. */
238
+ function onProtected(cur, lost) {
239
+ if (!lost) return null;
240
+ if (!cur) unverified('HEAD is detached or unreadable, so the gate cannot tell whether a protected branch is checked out');
241
+ return `${lost} on the protected branch ${cur}`;
242
+ }
243
+
244
+ /**
245
+ * `branch -f`, `checkout -B`, `switch -C` moving `name` to `start` (HEAD when absent): stops when
246
+ * `name` is protected and its old tip holds a commit neither `start` nor any remote ref reaches.
247
+ * A branch that does not exist yet is created, which loses nothing.
248
+ */
249
+ function forceMove(dir, name, start, act) {
250
+ if (name === undefined) return null; // git refuses the command on its own
251
+ if (name.includes(OPAQUE)) unverified(`the branch name \`${name.replace(OPAQUE, '')}\` expands in the shell`);
252
+ if (!protectedBranches(repoRoot(dir)).has(name)) return null;
253
+ const tip = git(dir, ['rev-parse', '-q', '--verify', `refs/heads/${name}`]);
254
+ if (tip.status === 1) return null;
255
+ if (tip.status !== 0) unverified(`cannot read the branch ${name}: ${firstLine(tip.stderr)}`);
256
+ const to = start ?? 'HEAD';
257
+ if (to.includes(OPAQUE)) unverified(`the start point \`${to.replace(OPAQUE, '')}\` expands in the shell`);
258
+ const r = git(dir, ['rev-list', '-n1', `refs/heads/${name}`, '--not', to, '--remotes']);
259
+ if (r.status !== 0) unverified(`cannot compare ${name} with ${to}: ${firstLine(r.stderr)}`);
260
+ return r.stdout.trim() ? `${act} ${name} would strand commits no remote and not ${to} holds` : null;
261
+ }
262
+
263
+ /**
264
+ * `git checkout` with no `--` and no force: `-B <protected>` force-moves it; otherwise, on a
265
+ * protected branch, positionals naming files (the first one is not a commit, or follows one)
266
+ * discard their changes. A lone commit-ish is a branch switch, which refuses to overwrite work.
267
+ */
268
+ function checkoutAct(rest, dir) {
269
+ const pos = [];
270
+ let reset;
271
+ for (let i = 0; i < rest.length; i++) {
272
+ const a = rest[i];
273
+ if (a === '-b' || a === '--orphan') return null; // a new branch: additive
274
+ if (a === '-B') { reset = rest[i + 1]; i += 1; continue; }
275
+ if (a.startsWith('--pathspec-from-file')) unverified('checkout reads its paths from a file');
276
+ if (!a.startsWith('-')) pos.push(a);
277
+ }
278
+ if (reset !== undefined) return forceMove(dir, reset, pos[0], '`git checkout -B`');
279
+ if (!pos.length) return null;
280
+ const cur = protectedHead(dir);
281
+ if (cur === undefined) return null;
282
+ let paths = pos;
283
+ if (!pos[0].includes(OPAQUE) && git(dir, ['rev-parse', '-q', '--verify', `${pos[0]}^{commit}`]).status === 0) {
284
+ if (pos.length === 1) return null;
285
+ paths = pos.slice(1);
286
+ }
287
+ return onProtected(cur, dirty(dir, paths, '`git checkout <paths>`'));
288
+ }
289
+
290
+ /** `git branch` — a forced delete asks for a protected branch or one whose commits live nowhere else. */
291
+ function branchAct(rest, dir) {
292
+ const opts = rest.filter((a) => a.startsWith('-'));
293
+ const del = opts.some((a) => a === '--delete' || a === '-D' || hasShort(a, 'd') || hasShort(a, 'D'));
294
+ const force = opts.some((a) => a === '--force' || a === '-D' || hasShort(a, 'f') || hasShort(a, 'D'));
295
+ if (!force) return null; // an un-forced delete refuses an unmerged branch on its own
296
+ const names = rest.filter((a) => !a.startsWith('-'));
297
+ if (!del) {
298
+ // `branch -f <name> [<start>]`; a forced rename or copy (-M, -C) is another form.
299
+ if (opts.some((a) => a === '--move' || a === '--copy' || [...'mMcC'].some((c) => hasShort(a, c)))) return null;
300
+ return forceMove(dir, names[0], names[1], '`git branch -f`');
301
+ }
302
+ if (opts.some((a) => a === '-r' || a === '--remotes')) return null; // a remote-tracking ref; `fetch` restores it
303
+ const root = repoRoot(dir);
304
+ const protectedSet = protectedBranches(root);
305
+ for (const n of names) {
306
+ if (n.includes(OPAQUE)) unverified(`the branch name \`${n.replace(OPAQUE, '')}\` expands in the shell`);
307
+ if (protectedSet.has(n)) return `\`git branch -D ${n}\` deletes the protected branch ${n}`;
308
+ // Empty output: every commit of `n` is reachable from a remote ref or from HEAD.
309
+ const r = git(dir, ['rev-list', '-n1', `refs/heads/${n}`, '--not', '--remotes', 'HEAD']);
310
+ if (r.status !== 0) unverified(`cannot read the branch ${n}: ${firstLine(r.stderr)}`);
311
+ if (r.stdout.trim()) return `\`git branch -D ${n}\` deletes commits no remote and no HEAD holds`;
312
+ }
313
+ return null;
314
+ }
315
+
316
+ /** `git clean` — forced, it asks when `git clean -n` with the same selection lists anything. */
317
+ function cleanAct(rest, dir) {
318
+ const forced = rest.some((a) => a === '--force' || hasShort(a, 'f'));
319
+ if (!forced) return null;
320
+ const dry = [];
321
+ for (const a of rest) {
322
+ if (a.includes(OPAQUE)) unverified(`\`${a.replace(OPAQUE, '')}\` expands in the shell`);
323
+ if (a === '--force' || a === '--interactive') continue;
324
+ if (isCluster(a)) {
325
+ const kept = a.replace(/[fin]/g, '');
326
+ if (kept !== '-') dry.push(kept);
327
+ continue;
328
+ }
329
+ dry.push(a);
330
+ }
331
+ repoRoot(dir);
332
+ const r = git(dir, ['clean', '-n', ...dry]);
333
+ if (r.status !== 0) unverified(`git clean -n failed: ${firstLine(r.stderr)}`);
334
+ const first = firstLine(r.stdout);
335
+ return r.stdout.trim() ? `\`git clean\` would delete untracked files (${first.replace(/^Would remove /, '')})` : null;
336
+ }
337
+
338
+ /** One `git …` segment: the verdict for its verb, or null. */
339
+ function gitAct(args, dir, always) {
340
+ let d = dir;
341
+ let i = 0;
342
+ let redirected = false;
343
+ for (; i < args.length; i++) {
344
+ const a = args[i];
345
+ // A `-C` the gate cannot resolve leaves the directory unknown; only a destructive verb asks for it.
346
+ if (a === '-C') { try { d = resolveIn(d, args[i + 1] ?? OPAQUE); } catch { d = null; } i += 1; continue; }
347
+ if (a === '-c') { i += 1; continue; }
348
+ if (/^--(?:git-dir|work-tree)(?:=|$)/.test(a)) { redirected = true; if (!a.includes('=')) i += 1; continue; }
349
+ if (a.startsWith('-')) continue;
350
+ break;
351
+ }
352
+ const verb = args[i];
353
+ const rest = args.slice(i + 1);
354
+ const act = () => {
355
+ switch (verb) {
356
+ case 'reset':
357
+ return rest.includes('--hard') ? dirty(d, [], '`git reset --hard`') : null;
358
+ case 'checkout': {
359
+ const forced = rest.some((a) => a === '--force' || hasShort(a, 'f')) && dirty(d, [], '`git checkout --force`');
360
+ if (forced) return forced;
361
+ const dd = rest.indexOf('--');
362
+ if (dd < 0) return checkoutAct(rest, d);
363
+ const paths = rest.slice(dd + 1);
364
+ return paths.length ? dirty(d, paths, '`git checkout --`') : null;
365
+ }
366
+ case 'switch': {
367
+ const c = rest.findIndex((a) => a === '-C' || a === '--force-create');
368
+ const pos = c < 0 ? [] : rest.slice(c + 1).filter((a) => !a.startsWith('-'));
369
+ return (c >= 0 && forceMove(d, pos[0], pos[1], '`git switch -C`'))
370
+ || (rest.some((a) => a === '--force' || a === '--discard-changes' || hasShort(a, 'f'))
371
+ ? dirty(d, [], '`git switch --discard-changes`') : null);
372
+ }
373
+ case 'rm': {
374
+ // Only a forced removal discards changes: git refuses a modified file without -f, and
375
+ // `--cached` or `-n` keep the file. Protected branch only (user ruling, 2026-10-17).
376
+ if (!rest.some((a) => a === '--force' || hasShort(a, 'f'))) return null;
377
+ if (rest.some((a) => a === '--cached' || a === '--dry-run' || hasShort(a, 'n'))) return null;
378
+ if (rest.some((a) => a.startsWith('--pathspec-from-file'))) unverified('git rm reads its paths from a file');
379
+ const paths = rest.filter((a) => !a.startsWith('-'));
380
+ const cur = paths.length ? protectedHead(d) : undefined;
381
+ return cur === undefined ? null : onProtected(cur, dirty(d, paths, '`git rm -f`'));
382
+ }
383
+ case 'restore': {
384
+ const staged = rest.some((a) => a === '--staged' || hasShort(a, 'S'));
385
+ const worktree = rest.some((a) => a === '--worktree' || hasShort(a, 'W'));
386
+ if (staged && !worktree) return null; // unstaging only: the change stays in the tree
387
+ if (rest.some((a) => a.startsWith('--pathspec-from-file'))) unverified('restore reads its paths from a file');
388
+ const paths = [];
389
+ for (let j = 0; j < rest.length; j++) {
390
+ const a = rest[j];
391
+ if (a === '-s' || a === '--source') { j += 1; continue; }
392
+ if (a === '--') continue;
393
+ if (!a.startsWith('-')) paths.push(a);
394
+ }
395
+ return paths.length ? dirty(d, paths, '`git restore`') : null;
396
+ }
397
+ case 'clean':
398
+ return cleanAct(rest, d);
399
+ case 'stash': {
400
+ const sub = rest.find((a) => !a.startsWith('-'));
401
+ if (sub !== 'drop' && sub !== 'clear') return null;
402
+ return stashExists(repoRoot(d)) ? `\`git stash ${sub}\` deletes stashed work` : null;
403
+ }
404
+ case 'reflog':
405
+ return always && rest.includes('expire') ? '`git reflog expire` deletes the reflog recovery depends on' : null;
406
+ case 'gc':
407
+ return always && rest.some((a) => a.startsWith('--prune'))
408
+ ? '`git gc --prune` deletes the unreachable commits recovery depends on' : null;
409
+ case 'branch':
410
+ return branchAct(rest, d);
411
+ case 'push':
412
+ return pushAct(rest, d);
413
+ default:
414
+ return null;
415
+ }
416
+ };
417
+ const verdict = act();
418
+ if (verdict && redirected) return `${verdict} (and --git-dir/--work-tree points it at a repository the gate did not read)`;
419
+ return verdict;
420
+ }
421
+
422
+ /** One removal segment: asks unless every operand is tracked AND unmodified in a git work tree. */
423
+ function rmAct(prog, args, dir) {
424
+ const targets = [];
425
+ let opts = true;
426
+ for (let i = 0; i < args.length; i++) {
427
+ const a = args[i];
428
+ if (opts && a === '--') { opts = false; continue; }
429
+ if (opts && /^-(?:path|literalpath)$/i.test(a)) { targets.push(args[i + 1] ?? OPAQUE); i += 1; continue; }
430
+ const bound = opts && /^-(?:path|literalpath)[:=](.+)$/i.exec(a);
431
+ if (bound) { targets.push(bound[1]); continue; }
432
+ if (opts && /^-(?:filter|include|exclude)(?:[:=]|$)/i.test(a)) unverified(`\`${prog} ${a}\` selects files the gate cannot list`);
433
+ if (opts && a.length > 1 && a.startsWith('-')) continue;
434
+ if (opts && CMD_SWITCH_PROGS.has(prog) && /^\/[a-zA-Z?]$/.test(a)) continue;
435
+ targets.push(a);
436
+ }
437
+ const groups = new Map();
438
+ for (const t of targets) {
439
+ const shown = t.replace(OPAQUE, '');
440
+ if (t.includes(OPAQUE) || /[*?[\],]/.test(t)) unverified(`\`${shown}\` expands in the shell, so the gate cannot list what it names`);
441
+ const abs = resolveIn(dir, t);
442
+ if (abs.split(/[\\/]+/).includes('.git')) return `\`${prog} ${shown}\` deletes git's own data`;
443
+ // Nothing there, nothing lost: `rm -f build.log` on a clean tree is routine cleanup.
444
+ try { lstatSync(abs); } catch (e) {
445
+ if (e && e.code === 'ENOENT') continue;
446
+ unverified(`cannot read ${shown}: ${e && e.code}`);
447
+ }
448
+ const root = gitRootOf(abs);
449
+ if (!root) return `\`${prog} ${shown}\` deletes a path outside any git repository, which nothing could restore`;
450
+ if (path.resolve(root) === path.resolve(abs)) return `\`${prog} ${shown}\` deletes a whole repository or worktree`;
451
+ const rel = path.relative(root, abs).split(path.sep).join('/');
452
+ if (!groups.has(root)) groups.set(root, []);
453
+ groups.get(root).push({ shown, rel });
454
+ }
455
+ const fold = (s) => (process.platform === 'win32' ? s.toLowerCase() : s);
456
+ for (const [root, list] of groups) {
457
+ const r = git(root, ['ls-files', '-z', '-t', '--cached', '--others', '--modified', '--directory',
458
+ '--', ...list.map((x) => `:(literal)${x.rel}`)]);
459
+ if (r.status !== 0) unverified(`git ls-files failed: ${firstLine(r.stderr)}`);
460
+ const entries = r.stdout.split('\0').filter(Boolean).map((e) => ({ tag: e[0], p: fold(e.slice(2)) }));
461
+ for (const { shown, rel } of list) {
462
+ const key = fold(rel);
463
+ const mine = entries.filter((e) => e.p === key || e.p.startsWith(`${key}/`) || e.p === `${key}/`);
464
+ if (!mine.length) return `\`${prog} ${shown}\` deletes something git does not track, which nothing could restore`;
465
+ const bad = mine.find((e) => e.tag !== 'H');
466
+ if (bad) {
467
+ const what = bad.tag === '?' ? 'untracked or ignored' : 'modified or otherwise not restorable';
468
+ return `\`${prog} ${shown}\` deletes ${what} files (${bad.p}), which git cannot restore`;
469
+ }
470
+ }
471
+ }
472
+ return null;
473
+ }
474
+
475
+ /** The program word, stripped of its path and `.exe`, lowercased (PowerShell and cmd are case-blind). */
476
+ const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
477
+
478
+ /**
479
+ * The verdict for a whole command, given its segments as RAW token arrays (quotes still on —
480
+ * `js/hosts/hooks.mjs` cuts and tokenises with the same lexer `rootScan` uses, so a quoted
481
+ * `;` or a heredoc body is never a segment). Returns the reason it would lose data, or null
482
+ * when it provably would not. What it cannot settle — a glob, a git that fails — is a "could not
483
+ * verify" reason, never a throw. A BUG still throws, on purpose: `guardGate` turns it into a
484
+ * `gate-error` (an ask on Claude, a warning on Bob) and the OpenCode guard logs and allows it —
485
+ * each host's ceiling for a crashed gate (docs/adr/0007-gates-fail-closed-per-host-ceiling.md).
486
+ * `always: false` skips the two acts that lose data in every state (`reflog expire`, `gc --prune`)
487
+ * — the OpenCode guard passes it, because a static `permission.bash` glob already asks for those.
488
+ */
489
+ export function lossReason(segments, cwd, { always = true } = {}) {
490
+ let dir = cwd;
491
+ try {
492
+ for (const raw of segments) {
493
+ let w = words(raw);
494
+ while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
495
+ const wrapper = progOf(w[0]);
496
+ w = w.slice(1);
497
+ // A wrapper's own flags (`sudo -u x`, `env -i`) are not the program.
498
+ if (wrapper !== 'command' && wrapper !== 'exec') while (w.length && w[0].startsWith('-')) w = w.slice(1);
499
+ }
500
+ if (!w.length) continue;
501
+ const prog = progOf(w[0]);
502
+ const args = w.slice(1);
503
+ if (CD_PROGS.has(prog)) {
504
+ const target = args.find((a) => !a.startsWith('-') && !/^\/d$/i.test(a));
505
+ try { dir = target && target !== '-' ? resolveIn(dir, target) : null; } catch { dir = null; }
506
+ continue;
507
+ }
508
+ let verdict = null;
509
+ if (prog === 'git') verdict = gitAct(args, dir, always);
510
+ else if (RM_PROGS.has(prog)) verdict = rmAct(prog, args, dir);
511
+ else if (INDIRECT.has(prog) && LOSS_CANDIDATE_RE.test(args.join(' '))) {
512
+ unverified(`\`${prog}\` runs a command the gate cannot read`);
513
+ }
514
+ if (verdict) return verdict;
515
+ }
516
+ return null;
517
+ } catch (e) {
518
+ if (!(e instanceof Unverified)) throw e;
519
+ return `the gate could not verify what this command would lose (${e.message})`;
520
+ }
521
+ }
522
+ // ---- loss twin: END