geneseed 3.20.0 → 4.0.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 (88) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +9 -1
  3. package/SHIPPED.md +1 -1
  4. package/adapters/bob/README.md +31 -12
  5. package/adapters/claude-code/README.md +24 -17
  6. package/adapters/claude-code/settings.json +4 -6
  7. package/adapters/openclaude/README.md +13 -5
  8. package/adapters/opencode/plugins/geneseed-guard.js +291 -174
  9. package/bin/geneseed-hook.mjs +36 -5
  10. package/docs/README.md +3 -1
  11. package/docs/candidate-hosts.md +0 -8
  12. package/docs/concepts/enforcement-table.md +66 -0
  13. package/docs/concepts/hook-gates.md +2 -2
  14. package/docs/concepts/hooks.md +6 -6
  15. package/docs/concepts/host-matrix.md +4 -3
  16. package/docs/concepts/loop-branch.md +6 -1
  17. package/docs/concepts/rules.md +1 -1
  18. package/docs/design-history.md +59 -0
  19. package/docs/extending.md +11 -3
  20. package/docs/guides/install-opencode.md +1 -1
  21. package/docs/guides/loop-running.md +1 -1
  22. package/docs/reference/env-context.md +3 -2
  23. package/docs/reference/environment.md +2 -1
  24. package/docs/reference/plugin-guard.md +3 -3
  25. package/docs/reference/troubleshoot-hooks.md +34 -0
  26. package/docs/understand/design-choices-engineering.md +29 -0
  27. package/docs/understand/design-choices-gates.md +23 -0
  28. package/docs/understand/design-choices-hosts.md +21 -0
  29. package/docs/understand/design-choices-install.md +19 -0
  30. package/docs/understand/design-choices-shape.md +21 -0
  31. package/docs/understand/design-choices.md +34 -0
  32. package/docs/understand/enforced-vs-asked.md +5 -4
  33. package/docs/understand/machine-bob.md +1 -1
  34. package/docs/understand/machine-opencode.md +2 -2
  35. package/harness.config.json +1 -1
  36. package/js/build/driver.mjs +42 -16
  37. package/js/build/emit-claude.mjs +24 -8
  38. package/js/build/emit-opencode.mjs +33 -15
  39. package/js/build/generate.mjs +38 -9
  40. package/js/build/source.mjs +39 -0
  41. package/js/cli-table.json +6 -6
  42. package/js/hosts/gitref.mjs +3 -3
  43. package/js/hosts/hooks-context.mjs +11 -30
  44. package/js/hosts/hooks-learn.mjs +89 -8
  45. package/js/hosts/hooks.mjs +212 -18
  46. package/js/hosts/hosts.mjs +217 -13
  47. package/js/hosts/installs.mjs +259 -46
  48. package/js/hosts/native.mjs +48 -12
  49. package/js/hosts/opencode.mjs +47 -17
  50. package/js/hosts/settings.mjs +366 -42
  51. package/js/hosts/shim.mjs +37 -0
  52. package/js/inspect/checks-authoring.mjs +81 -1
  53. package/js/inspect/checks-constitution.mjs +2 -1
  54. package/js/inspect/checks-repo.mjs +68 -4
  55. package/js/inspect/doctor.mjs +40 -9
  56. package/js/inspect/scan.mjs +5 -4
  57. package/js/inspect/status.mjs +62 -12
  58. package/js/lib/text.mjs +23 -0
  59. package/js/loop/catalog.mjs +7 -2
  60. package/js/maintain/migrate.mjs +42 -5
  61. package/js/maintain/uninstall.mjs +150 -38
  62. package/js/maintain/update.mjs +22 -2
  63. package/js/web/actions.mjs +28 -10
  64. package/js/web/api.mjs +5 -3
  65. package/js/web/state.mjs +28 -15
  66. package/package.json +1 -1
  67. package/src/agents/_template.md +1 -1
  68. package/src/bricks/_template.md +1 -1
  69. package/src/doctrines/ops.md +9 -7
  70. package/src/laws/universal.md +1 -1
  71. package/src/skills/_template.md +3 -3
  72. package/src/skills/herdr.md +1 -1
  73. package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-BCtqxjfy.js} +1 -1
  74. package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Ba3Oo4dE.js} +1 -1
  75. package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-DpHza68-.js} +2 -2
  76. package/web/dist/assets/{Docs-CypIfDBX.js → Docs-Bw_-UK0O.js} +1 -1
  77. package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DnJ1Ggp1.js} +1 -1
  78. package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-CWoNKFBk.js} +1 -1
  79. package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-g6-bw4c8.js} +1 -1
  80. package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-DkVWR1tb.js} +1 -1
  81. package/web/dist/assets/{Library-BraUKPyQ.js → Library-CK9HZzjV.js} +1 -1
  82. package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-BP98SMOH.js} +1 -1
  83. package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-DI_n12M9.js} +1 -1
  84. package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-Bk89f13a.js} +1 -1
  85. package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-3WuYIE1b.js} +1 -1
  86. package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-oCUeq9e5.js} +1 -1
  87. package/web/dist/assets/{index-CPi22b7y.js → index-CiXwChvf.js} +2 -2
  88. package/web/dist/index.html +1 -1
@@ -12,9 +12,9 @@
12
12
  * this module.
13
13
  *
14
14
  * THE STDOUT RULE BINDS HARDEST HERE. The hooks this module writes signal their verdict as
15
- * a JSON object on stdout and return 0 on EVERY path (`|| exit 0` is not what it looks
16
- * like — see the shim comment below), so a stray byte printed on a hook path does not make
17
- * noise, it silently disables a gate. Everything printed here is a generator-time message;
15
+ * a JSON object on stdout and return 0 on EVERY path (`|| exit 0`, or `; exit 0` in the
16
+ * PowerShell form, is not what it looks like — see the shim comment below), so a stray byte
17
+ * printed on a hook path does not make noise, it silently disables a gate. Everything printed here is a generator-time message;
18
18
  * the split between them is asserted absolutely by `tests/unit/settings_jsonc.test.mjs` and
19
19
  * `tests/unit/settings_integrity.test.mjs`. The asymmetry (`_warn_commented_jsonc` prints to
20
20
  * STDOUT, every other message to stderr) is inherited from `_build_settings.py` and kept
@@ -41,7 +41,7 @@ import {
41
41
  jsonDumps, jsonDumpsCompact, jsonDumpsIndent, parseJson, deepEquals, formatRepr,
42
42
  indexOfDeepEqual, get, has, isDict,
43
43
  } from '../lib/json.mjs';
44
- import { SHIM_MARK, hookPrefix } from './shim.mjs';
44
+ import { SHIM_MARK, claudeHookShell, hookPrefix } from './shim.mjs';
45
45
  import { stripWhitespace, stripWhitespaceEnd } from '../lib/text.mjs';
46
46
 
47
47
  const OPENCODE_SCHEMA = 'https://opencode.ai/config.json';
@@ -93,10 +93,134 @@ const consentRuleOn = (d, excluded = []) =>
93
93
  * after other words (`push origin -f`, `reset -q --hard`, `clean -d -f`) and `git push *+*`
94
94
  * a `+refspec` force push. The regex stays the stricter reader — it also sees a flag cluster
95
95
  * with `f` not first (`clean -d -xf`), which a glob cannot say without matching `--exclude=f`.
96
+ *
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).
111
+ *
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`.
96
122
  */
97
123
  const LAW_IV_BASH = ['git push --force*', 'git push -f*', 'git push *--force*', 'git push * -f*',
98
124
  'git push *+*', 'git reset --hard*', 'git reset * --hard*', 'git clean -f*', 'git clean * -f*',
99
- 'git branch -D*', 'git checkout -- *'];
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 * :*'];
136
+
137
+ /**
138
+ * User decision (2026-10-10): a loop runs on its own `loop/*` branch, never `main`/`master`, so
139
+ * pushing it is fine — only the COMMIT still asks. OpenCode has no dynamic equivalent of
140
+ * `js/hosts/hooks.mjs`'s `loopExempt` (Task 6: its `permission.ask` plugin hook is declared but
141
+ * never called), so this is a STATIC allow for the exact refspec shapes the loop engine itself
142
+ * pushes — `pushSegmentOk`'s `[-u|--set-upstream] <remote> HEAD:<branch>` and
143
+ * `HEAD:refs/heads/<branch>` forms, with `<branch>` fixed to `loop/*` here since a static glob
144
+ * cannot read the checked-out branch the way the Node hook does. `git push * HEAD:loop/*` eats
145
+ * `-u origin`/`--set-upstream origin`/any remote through its leading `*`; the second key covers
146
+ * the `refs/heads/` spelling the same way.
147
+ *
148
+ * ORDER IS LOAD-BEARING. `defaultPermission` appends these AFTER `git push*`'s ask (so a loop
149
+ * push is not swallowed by the blanket consent ask) and BEFORE `LAW_IV_BASH` (so `findLast`
150
+ * — OpenCode's own `permission.bash` evaluator — lets a force push, a `+refspec`, or a
151
+ * `--delete`/bare-`:` push still ask even when the target is `loop/*`; those LAW_IV_BASH keys
152
+ * match the same command and come later, so they win). Never move this block to before
153
+ * `git push*` or after `LAW_IV_BASH` — either moves the `findLast` boundary the ordering above
154
+ * depends on.
155
+ */
156
+ const LOOP_PUSH_ALLOW = ['git push * HEAD:loop/*', 'git push * HEAD:refs/heads/loop/*'];
157
+
158
+ /**
159
+ * ⚠ SECOND FIX ROUND (control review). The first guard (`'git push *:* HEAD:loop/*'`) checked
160
+ * for a literal `:` ahead of the loop refspec, reasoning that a second refspec always carries
161
+ * one. Wrong: a bare branch name IS a valid refspec (`git push origin main` pushes local
162
+ * `main` to remote `main`) and carries no colon at all, so `git push origin main HEAD:loop/x`
163
+ * sailed past the colon check and pushed `main` with no prompt. A colon is not the invariant;
164
+ * TOKEN COUNT is — the loop engine's leading extras are exactly `-u <remote>` or
165
+ * `--set-upstream <remote>` (one flag, one remote: always precisely ONE or TWO tokens before
166
+ * the refspec), and every injected extra refspec, colon or not, adds a token beyond that. This
167
+ * replaces the colon guard with a token-count chain, each tier ordered (`findLast` wins) to
168
+ * override the one before it where it must:
169
+ *
170
+ * 1. `LOOP_PUSH_ALLOW` (above) — allow. Matches ANY text before the loop refspec, however many
171
+ * tokens.
172
+ * 2. `LOOP_PUSH_ASK_EXTRA` (`'git push * * HEAD:loop/*'`) — ask. Requires the text before the
173
+ * refspec to contain its own literal space, i.e. 2+ tokens — which a bare single remote
174
+ * (`origin`) never has (no space inside one word), so the documented bare-remote form keeps
175
+ * falling through to (1)'s allow; `origin main` (remote + an injected second refspec, with
176
+ * or without a colon), `-u origin` (flag + remote — ALSO 2 tokens, so this ALSO fires for the
177
+ * legitimate `-u`/`--set-upstream` form; that is corrected by tier 3, not avoided here) and
178
+ * any other 2+-token prefix — `--all origin`, `-f origin`, `--force-with-lease origin`,
179
+ * `--mirror origin` — all trip it.
180
+ * 3. `LOOP_PUSH_ALLOW_FLAG` (`'git push -u * HEAD:loop/*'` / `'git push --set-upstream * …'`) —
181
+ * allow, ordered AFTER (2) specifically to re-admit the one 2-token prefix that is legitimate:
182
+ * `-u <remote>` or `--set-upstream <remote>`. The literal `-u `/`--set-upstream ` prefix means
183
+ * only a command that STARTS with that flag can match; nothing with a different first token
184
+ * (including an injected refspec in that slot) does.
185
+ * 4. `LOOP_PUSH_ASK_FLAG_EXTRA` (`'git push -u * * HEAD:loop/*'` / `--set-upstream` twin) — ask,
186
+ * ordered AFTER (3) to re-close it: ANYTHING beyond `-u <remote>` before the refspec — a
187
+ * third token, e.g. `-u origin main HEAD:loop/x` — puts a literal space inside what (3)'s
188
+ * single wildcard would otherwise swallow whole, so this fires and wins back from (3).
189
+ * 5. `LOOP_PUSH_ASK_TRAILING` (`'git push * HEAD:loop/* ?*'`) — ask. Requires ONE more character,
190
+ * ANYWHERE, past the refspec, via `?` (`Wildcard.match`'s single-char wildcard) rather than a
191
+ * bare trailing `*`: a pattern ENDING in literal `" *"` hits upstream's own special case
192
+ * (`escaped.endsWith(' .*')` -> `'( .*)?'`), making that whole clause OPTIONAL and silently
193
+ * re-allowing the plain push this tier exists to still catch. Catches an injected refspec, or
194
+ * anything else, AFTER the loop one (`git push origin HEAD:loop/x HEAD:main`,
195
+ * `… HEAD:loop/x main`) — order relative to the others does not matter for this tier, since
196
+ * nothing else in this chain inspects text after the refspec.
197
+ *
198
+ * A second `loop/*` refspec after the first also trips tier 5 and asks — harmless, and accepted
199
+ * rather than chased: the engine never emits one, and telling it apart from an injected `main`
200
+ * needs no extra tier, since both are "something after the refspec".
201
+ *
202
+ * NO REMAINING CEILING for the realistic shapes: every 2+-token prefix — a flag, an injected
203
+ * refspec, colon or not — is caught by (2) unless it is exactly the one legitimate `-u`/
204
+ * `--set-upstream` + remote pair, which (4) still catches the moment anything rides along with
205
+ * it. The one shape this chain cannot see is a BARE SINGLE flag occupying the sole "remote"
206
+ * slot with nothing else before the refspec (`git push --all HEAD:loop/x`, one token, same shape
207
+ * as a legitimate bare remote) — indistinguishable from a real remote name by token count alone,
208
+ * and not a practical gap: a leading `-`/`--` token is parsed by git as an OPTION, never as the
209
+ * remote, so this exact shape either errors (no remote given) or reads the refspec text itself
210
+ * as the remote and fails to resolve it — it does not reach a working push to `main`. Still
211
+ * caught by `LAW_IV_BASH`'s existing anywhere-in-the-command globs regardless of position
212
+ * (`'git push *--force*'`, `'git push * -f*'`, et al.), independently of this chain.
213
+ */
214
+ const LOOP_PUSH_ASK_EXTRA = ['git push * * HEAD:loop/*', 'git push * * HEAD:refs/heads/loop/*'];
215
+ const LOOP_PUSH_ALLOW_FLAG = [
216
+ 'git push -u * HEAD:loop/*', 'git push --set-upstream * HEAD:loop/*',
217
+ 'git push -u * HEAD:refs/heads/loop/*', 'git push --set-upstream * HEAD:refs/heads/loop/*',
218
+ ];
219
+ const LOOP_PUSH_ASK_FLAG_EXTRA = [
220
+ 'git push -u * * HEAD:loop/*', 'git push --set-upstream * * HEAD:loop/*',
221
+ 'git push -u * * HEAD:refs/heads/loop/*', 'git push --set-upstream * * HEAD:refs/heads/loop/*',
222
+ ];
223
+ const LOOP_PUSH_ASK_TRAILING = ['git push * HEAD:loop/* ?*', 'git push * HEAD:refs/heads/loop/* ?*'];
100
224
 
101
225
  function defaultPermission(doctrines = null, excluded = []) {
102
226
  const bash = { 'rm -rf *': 'ask' };
@@ -104,6 +228,11 @@ function defaultPermission(doctrines = null, excluded = []) {
104
228
  bash['git commit*'] = 'ask';
105
229
  bash['git push*'] = 'ask';
106
230
  }
231
+ for (const k of LOOP_PUSH_ALLOW) bash[k] = 'allow';
232
+ for (const k of LOOP_PUSH_ASK_EXTRA) bash[k] = 'ask';
233
+ for (const k of LOOP_PUSH_ALLOW_FLAG) bash[k] = 'allow';
234
+ for (const k of LOOP_PUSH_ASK_FLAG_EXTRA) bash[k] = 'ask';
235
+ for (const k of LOOP_PUSH_ASK_TRAILING) bash[k] = 'ask';
107
236
  for (const k of LAW_IV_BASH) bash[k] = 'ask';
108
237
  return { bash };
109
238
  }
@@ -115,7 +244,9 @@ function defaultPermission(doctrines = null, excluded = []) {
115
244
  * has to be able to recognise a key that is Geneseed's business at all — to add it back when
116
245
  * the pack returns, and to name it when this build no longer wants it but will not remove it.
117
246
  */
118
- const OWNED_BASH = ['rm -rf *', 'git commit*', 'git push*', ...LAW_IV_BASH];
247
+ const OWNED_BASH = ['rm -rf *', 'git commit*', 'git push*',
248
+ ...LOOP_PUSH_ALLOW, ...LOOP_PUSH_ASK_EXTRA, ...LOOP_PUSH_ALLOW_FLAG, ...LOOP_PUSH_ASK_FLAG_EXTRA,
249
+ ...LOOP_PUSH_ASK_TRAILING, ...LAW_IV_BASH];
119
250
 
120
251
  /**
121
252
  * Bring an ALREADY-WRITTEN `permission` block back into line with the pack selection, and
@@ -189,6 +320,59 @@ function reconcileOpencodePermission(perm, want) {
189
320
  return [changed, stranded, null];
190
321
  }
191
322
 
323
+ /**
324
+ * `permission.skill` rows for OpenCode's O-1 fix (opencode-verdict.md): a skill marked
325
+ * `<!-- invocation: user -->` is still model-invocable there (Claude Code's
326
+ * `disable-model-invocation` has no OpenCode equivalent), so each one gets an explicit
327
+ * `"<name>": "deny"` — confirmed against upstream `permission.ts` (`skill: Schema.optional(Rule)`,
328
+ * `Rule = Action | Record<string,Action>`) and `Skill.available`'s `Permission.evaluate("skill",
329
+ * …)` filter. `/name` still runs it: `Command`'s skill loop reads `skill.all()`, never the
330
+ * permission-filtered list, so the command path is untouched by design.
331
+ */
332
+ function defaultSkillPermission(names) {
333
+ const skill = {};
334
+ for (const n of names) skill[n] = 'deny';
335
+ return skill;
336
+ }
337
+
338
+ /**
339
+ * Add any of `names` missing from an EXISTING `permission.skill` (each one pushed onto `added`),
340
+ * and remove any of `staleNames` that still read `"deny"` — the `skill` twin of
341
+ * `reconcileOpencodePermission`, but with a DIFFERENT removal rule. `reconcileOpencodePermission`
342
+ * never removes a bash key because nothing on disk records which `ask`/`allow` entry is
343
+ * Geneseed's; a skill deny has a record instead: the manifest's `skill_denies`, the names a
344
+ * previous emit itself ADDED (never a name whose deny was already there — that one is the
345
+ * user's, even on a Geneseed skill). The caller reads it into `staleNames` = "recorded by the
346
+ * PREVIOUS build, not wanted by THIS one" — a skill that lost its `<!-- invocation: user -->`
347
+ * marker, or was `--exclude-skills`'d. The value check on top still guards a user's own
348
+ * re-scoped `"allow"`/`"ask"` for a recorded name — the record licenses taking back Geneseed's
349
+ * OWN entry, not whatever is there now.
350
+ *
351
+ * A non-object `permission.skill` (a blanket `"skill": "allow"`/`"deny"`/`"ask"`) is the
352
+ * user's own policy and is left alone, reported as opaque — same contract as
353
+ * `reconcileOpencodePermission`'s `bash` shape guard. `staleNames` is silently skipped in that
354
+ * case too: there is no per-name object to delete from.
355
+ * @returns {[boolean, string | null]}
356
+ */
357
+ function reconcileOpencodeSkillPermission(perm, names, staleNames = [], added = []) {
358
+ if (!has(perm, 'skill')) {
359
+ if (!names.length) return [false, null];
360
+ perm.skill = defaultSkillPermission(names);
361
+ added.push(...names);
362
+ return [true, null];
363
+ }
364
+ const skill = get(perm, 'skill');
365
+ if (!isDict(skill)) return [false, names.length ? 'has a "skill" that is not an object' : null];
366
+ let changed = false;
367
+ for (const n of names) {
368
+ if (!has(skill, n)) { skill[n] = 'deny'; changed = true; added.push(n); }
369
+ }
370
+ for (const n of staleNames) {
371
+ if (skill[n] === 'deny') { delete skill[n]; changed = true; }
372
+ }
373
+ return [changed, null];
374
+ }
375
+
192
376
  /** `_build_settings._opencode_target`. Only caller of a `.json` -> `.jsonc` suffix swap. */
193
377
  export function opencodeTarget(jsonPath) {
194
378
  const ext = path.extname(jsonPath);
@@ -318,7 +502,7 @@ export function loadJsonObject(p, { strict = false } = {}) {
318
502
  * `'add'`, which is what the boolean callers this replaced meant.
319
503
  */
320
504
  function warnCommentedJsonc(target, agentPath, permission,
321
- includeLsp = false, prefix = 'geneseed', doctrines = null, excluded = []) {
505
+ includeLsp = false, prefix = 'geneseed', doctrines = null, excluded = [], skillNames = []) {
322
506
  process.stdout.write(`[${prefix}] ${path.basename(target)} has comments — not rewriting `
323
507
  + 'it (your edits are kept). Add this to its "instructions" array by hand:\n');
324
508
  process.stdout.write(`[${prefix}] ${jsonDumps(agentPath)}\n`);
@@ -330,6 +514,13 @@ function warnCommentedJsonc(target, agentPath, permission,
330
514
  for (const line of jsonDumpsIndent(defaultPermission(doctrines, excluded)).split('\n')) {
331
515
  process.stdout.write(`[${prefix}] ${line}\n`);
332
516
  }
517
+ if (skillNames.length) {
518
+ process.stdout.write(`[${prefix}] and, to hide its user-only skills from the model, a `
519
+ + '"permission"."skill" key:\n');
520
+ for (const line of jsonDumpsIndent(defaultSkillPermission(skillNames)).split('\n')) {
521
+ process.stdout.write(`[${prefix}] ${line}\n`);
522
+ }
523
+ }
333
524
  }
334
525
  if (includeLsp) {
335
526
  process.stdout.write(`[${prefix}] and, to enable code intelligence, a top-level `
@@ -364,8 +555,14 @@ export function atomicWriteJson(p, config) {
364
555
  * Zero runtime callers — re-measured this phase and still true. `rituals/_harness_mcp.py`
365
556
  * re-implements the inverse itself rather than inherit this one's permission/lsp side
366
557
  * effects, so the only crossings are the two emit-tree ones (`_build_emit.emit_opencode`,
367
- * `_build_global.emit_opencode_global`). */
368
- export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = []) {
558
+ * `_build_global.emit_opencode_global`).
559
+ *
560
+ * `result.skillsAdded` is set to the `permission.skill` denies this call added ONLY when the
561
+ * file on disk now matches what it computed (written, or nothing to change). Left unset on
562
+ * every path that wrote nothing — unreadable, not an object, commented `.jsonc`, write
563
+ * failure — so the caller keeps its previous record rather than claiming denies never written. */
564
+ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [], skillNames = [],
565
+ staleSkillNames = [], result = {}) {
369
566
  const target = opencodeTarget(p);
370
567
  let config = { $schema: OPENCODE_SCHEMA, instructions: [] };
371
568
  const { state, data, hadComments, error } = loadJsonObject(target);
@@ -396,10 +593,28 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [])
396
593
  let permChanged = addPerm;
397
594
  let stranded = [];
398
595
  let opaque = null;
399
- if (addPerm) config.permission = defaultPermission(doctrines, excluded);
400
- else {
401
- [permChanged, stranded, opaque] = reconcileOpencodePermission(get(config, 'permission'),
596
+ let skillOpaque = null;
597
+ let permIsObject = true;
598
+ const added = [];
599
+ if (addPerm) {
600
+ config.permission = defaultPermission(doctrines, excluded);
601
+ if (skillNames.length) {
602
+ config.permission.skill = defaultSkillPermission(skillNames);
603
+ added.push(...skillNames);
604
+ }
605
+ } else {
606
+ const perm = get(config, 'permission');
607
+ permIsObject = isDict(perm);
608
+ [permChanged, stranded, opaque] = reconcileOpencodePermission(perm,
402
609
  defaultPermission(doctrines, excluded));
610
+ // Independent of the `bash` shape: a `permission.bash` the build cannot wire into must
611
+ // not also block the UNRELATED `permission.skill` key from being reconciled.
612
+ if (permIsObject) {
613
+ let skillChanged;
614
+ [skillChanged, skillOpaque] = reconcileOpencodeSkillPermission(perm, skillNames, staleSkillNames,
615
+ added);
616
+ permChanged = permChanged || skillChanged;
617
+ }
403
618
  }
404
619
  // The shape said no. This is the direction the residue warning above is NOT — a rule stated
405
620
  // in AGENT.md with nothing behind it — and it used to pass in silence, because a blanket
@@ -407,12 +622,23 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [])
407
622
  // short-circuits before any of the three WARNs below. Reported, never rewritten: the blanket
408
623
  // policy is the user's answer and it wins (`generate.test.mjs` pins that it survives).
409
624
  if (opaque) {
625
+ // `permIsObject` is false only here: `permission` itself is not an object at all, so the
626
+ // skill reconcile above never ran either — the unwired skill denies are folded into THIS
627
+ // one WARN rather than going unmentioned (a dedicated `skillOpaque` needs `perm` to be a
628
+ // dict to even look at `perm.skill`, which this case by definition is not).
629
+ const names = [...OWNED_BASH.filter((k) => has(defaultPermission(doctrines, excluded).bash, k)),
630
+ ...(!permIsObject ? skillNames : [])];
410
631
  process.stderr.write(`[geneseed] WARN: "permission" in ${path.basename(target)} `
411
632
  + `${opaque}, so Geneseed cannot wire its own gates into it — including `
412
- + `${OWNED_BASH.filter((k) => has(defaultPermission(doctrines, excluded).bash, k))
413
- .map((k) => jsonDumps(k)).join(', ')}. Your policy is left exactly as written; the `
633
+ + `${names.map((k) => jsonDumps(k)).join(', ')}. Your policy is left exactly as written; the `
414
634
  + 'harness states those rules but nothing enforces them at this boundary.\n');
415
635
  }
636
+ if (skillOpaque) {
637
+ process.stderr.write(`[geneseed] WARN: "permission"."skill" in ${path.basename(target)} `
638
+ + `${skillOpaque}, so Geneseed cannot wire its own skill-visibility gates into it — `
639
+ + `including ${skillNames.map((k) => jsonDumps(k)).join(', ')}. Your policy is left `
640
+ + 'exactly as written; those skills stay in the model\'s catalogue.\n');
641
+ }
416
642
  // Told, not silently left: these are gates for a rule this build does not make binding, kept
417
643
  // because Geneseed cannot prove it was the one that wrote them. stderr, like the other two
418
644
  // WARNs here — the emit's stdout is byte-compared by the golden corpus.
@@ -425,7 +651,10 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [])
425
651
  }
426
652
  const addLsp = !has(config, 'lsp');
427
653
  if (addLsp) config.lsp = true;
428
- if (!addInstr && !permChanged && !addLsp) return target;
654
+ if (!addInstr && !permChanged && !addLsp) {
655
+ result.skillsAdded = added;
656
+ return target;
657
+ }
429
658
  if (path.extname(target) === '.jsonc' && hadComments) {
430
659
  // ⚠ THE TWO CASES GIVE OPPOSITE ADVICE, and telling a file that HAS a `permission` key to
431
660
  // add one is how the first cut of this got it wrong. `add` = there is no block, here is the
@@ -439,11 +668,12 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [])
439
668
  // block to paste, split by which of the two situations the reader is actually in. It is
440
669
  // stdout, not stderr, because on this path the paste-in IS the output.
441
670
  warnCommentedJsonc(target, agentPath, addPerm ? 'add' : (permChanged && 'reconcile'),
442
- addLsp, 'geneseed', doctrines, excluded);
671
+ addLsp, 'geneseed', doctrines, excluded, skillNames);
443
672
  return target;
444
673
  }
445
674
  try {
446
675
  atomicWriteJson(target, config);
676
+ result.skillsAdded = added;
447
677
  } catch (e) {
448
678
  if (!isOsError(e)) throw e;
449
679
  process.stderr.write(`[geneseed] WARN: could not write ${target} (${e.message}) — `
@@ -494,8 +724,11 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [])
494
724
  export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [], host = 'claude') {
495
725
  const run = hookPrefix(hookOpts);
496
726
  const mem = `--memory "${path.join(cfg, 'memory')}"`;
497
- // --root carries the install's own dir so a GLOBAL hook can stand down when a project
498
- // install of the same host sits at/above cwd (project-bypasses-global).
727
+ // --root carries the install's own dir so a GLOBAL hook can stand down when a live, wired
728
+ // project install of the same host sits in the session's project dir (no walk up —
729
+ // `globalHookStandingDown`, project-bypasses-global): `context` and
730
+ // `git-gate` read it, `learn` reads `--memory`'s parent. `rule-gate` and Bob's `tool-gate`
731
+ // do not stand down: both still run once per install (host-compat B4 fixed only the three).
499
732
  if (host === 'bob') {
500
733
  // BOB'S OWN HOOK CONTRACT, per its hooks doc and 2.0.2 changelog (docs/reviews/
501
734
  // bob-global-injection-2026-09.md): FIVE events — SessionStart, UserPromptSubmit,
@@ -504,46 +737,84 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
504
737
  // refuse a call is EXIT CODE 2. The groups this emit wrote before were Claude's whole
505
738
  // set: `SubagentStop`/`PreCompact` are not Bob events, and the gates answered with a
506
739
  // stdout JSON Bob never reads — permissive on every call. `--host bob` makes the gates
507
- // speak exit codes (js/hosts/hooks.mjs). No matcher on PreToolUse: Bob's tool names
508
- // are undocumented (its IDE and Shell differ), so `tool-gate` reads the payload's shape
740
+ // speak exit codes (js/hosts/hooks.mjs). DELIBERATELY no matcher on PreToolUse
741
+ // (host-compat O1, revisited): bob-docs.md §2's tool list is already stale against the
742
+ // changelog (`web_fetch`, the 2.1.0 Office/IBM-docs tools are undocumented there), so an
743
+ // allow-list matcher built from it fails OPEN on every tool Bob adds between one
744
+ // docs read and the next — the exact failure mode a gate must not have. A deny-list
745
+ // with a negative-lookahead was the other shape considered; it depends on an unverified
746
+ // regex DIALECT (bob-docs.md never states whether its "regex on the tool name" supports
747
+ // lookahead at all), and a dialect mismatch there would silence the WHOLE gate rather than
748
+ // merely skip spawning it. bob-verdict.md O1 itself rates the matcher's upside "low,
749
+ // performance only" — not worth either risk. So `tool-gate` reads the payload's shape
509
750
  // instead. Which tool payload fields Bob sends is equally undocumented; the gates read
510
751
  // Claude's (`command`, `file_path`/`path`, `content`/`new_string`) and defer on anything
511
- // else. Unverified live: no Bob install on the authoring machine.
752
+ // else. Unverified live: no Bob install on the authoring machine. Revisit a matcher only
753
+ // once a live Bob session can confirm both the current tool list and the regex dialect.
512
754
  const b = ' --host bob';
513
755
  return {
514
756
  SessionStart: [{ hooks: [{ type: 'command', command: `${run} context --root "${cfg}"${b} || exit 0` }] }],
515
757
  // One group, both gates: process 5's consent is a stderr line here (Bob has no ask
516
758
  // tier), so the pack toggle that drops Claude's git-gate group has nothing to drop —
517
759
  // `tool-gate` only ever EXITS 2 for Laws I and IV, which every build carries, and for a
518
- // write under a project's own `.geneseed/protected-checks.txt` — the project opted in.
760
+ // write under a project's own `.geneseed/protected-checks.txt` — the project opted in —
761
+ // and for a recursive scan rooted at a whole filesystem (ops 2, the root-scan check).
519
762
  PreToolUse: [{ hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"${b}` }] }],
520
- Stop: [{ hooks: [{ type: 'command', command: `${run} learn ${mem} || exit 0` }] }],
763
+ // Bob's Stop payload never carries `transcript_path` (I2) — `learn` reads `--host bob`
764
+ // and returns immediately rather than wasting a model call on the bare envelope. KEPT
765
+ // rather than DROPPED: the stand-down costs nothing (no transcript read, no spawn), and
766
+ // if Bob ever starts sending one, learning on Bob starts working with no emit change —
767
+ // dropping the group would need a second one re-added later for the same reason.
768
+ Stop: [{ hooks: [{ type: 'command', command: `${run} learn ${mem}${b} || exit 0` }] }],
521
769
  };
522
770
  }
523
- // OpenClaude takes Claude's whole group set and verdicts unchanged; only `context` needs
524
- // the host, to know which root file OpenClaude already loads by itself.
771
+ // OpenClaude takes Claude's whole group set and verdicts unchanged. `context` needs the host
772
+ // to know which root file OpenClaude already loads by itself; `git-gate` and `learn` need it
773
+ // to stand down under a relocated `$OPENCLAUDE_CONFIG_DIR` (the marker is keyed on `--host`,
774
+ // not the folder name — `globalHookStandingDown`). Claude's commands carry none: no `--host`
775
+ // means Claude, so they stay byte-identical. An OLDER shim (last-writer-wins) accepts
776
+ // `--host` on `git-gate` but not on `learn`, which then exits 1 under `|| exit 0` and skips.
525
777
  const h = host === 'openclaude' ? ' --host openclaude' : '';
526
778
  const context = `${run} context --root "${cfg}"${h} || exit 0`;
527
- const gate = `${run} git-gate --root "${cfg}"${consentRuleOn(doctrines, excluded) ? '' : ' --no-consent'}`;
779
+ const gate = `${run} git-gate --root "${cfg}"${h}${consentRuleOn(doctrines, excluded) ? '' : ' --no-consent'}`;
528
780
  const ruleGate = `${run} rule-gate --root "${cfg}"`;
529
- const learn = `${run} learn ${mem} || exit 0`;
530
- return {
781
+ const learn = `${run} learn ${mem}${h} || exit 0`;
782
+ // Claude Code only (hooks.md, v2.1.295+; Task 1): a `command` hook that can't start, times
783
+ // out, or exits anything but 0 or 2 — even one whose stdout happens to be valid JSON — now
784
+ // BLOCKS the tool call instead of sliding through as the non-blocking default. Only safe to
785
+ // turn on now that an argv error inside geneseed-hook.mjs itself exits 0 (earlier host-compat
786
+ // fix): before that, a gate typo would have turned every tool call into a hang-visible block.
787
+ // A normal run (exit 0 + a schema-valid verdict) is untouched either way — see hooks.md's
788
+ // failure table. OpenClaude is a 2.1.88-era fork and must never see a field that version
789
+ // predates; Bob's own hook doc names no such field (and takes the early return above).
790
+ const onFailureBlock = host === 'claude' ? { onFailure: 'block' } : {};
791
+ const groups = {
531
792
  PreToolUse: [
532
- { matcher: 'Bash', hooks: [{ type: 'command', command: gate }] },
793
+ // `Bash|PowerShell`, not `Bash` alone (Claude verdict I1): docs `hooks.md` says a hook
794
+ // that matches only `Bash` never fires on the PowerShell tool, which is Windows' default
795
+ // shell whenever Git Bash is absent — and on Windows without Git Bash, Bash is not even
796
+ // registered. `tool_input.command` is the same field on both tools, so `GIT_GATE_RE`
797
+ // needs no change.
798
+ { matcher: 'Bash|PowerShell', hooks: [{ type: 'command', command: gate, ...onFailureBlock }] },
533
799
  {
534
- matcher: 'Write|Edit|MultiEdit|NotebookEdit',
535
- hooks: [{ type: 'command', command: ruleGate }],
800
+ // No `MultiEdit`: it is not in Claude Code's tool table (tools-reference.md) — a dead
801
+ // matcher entry (Claude verdict R2).
802
+ matcher: 'Write|Edit|NotebookEdit',
803
+ hooks: [{ type: 'command', command: ruleGate, ...onFailureBlock }],
536
804
  },
537
805
  ],
538
- SessionStart: [
539
- { matcher: 'startup|clear', hooks: [{ type: 'command', command: context }] },
540
- // `compact` too: auto-compaction keeps the instruction file (the host re-reads it) but
541
- // summarises away the eagerly injected context, memory index and notebook TOC. Re-seed
542
- // them the way a resume does — the static AGENT.md is NOT re-printed.
543
- { matcher: 'resume|compact', hooks: [{ type: 'command', command: context }] },
544
- ],
545
- // `|| exit 0` (not `|| true`): hooks run under cmd.exe on native Windows, where `true`
546
- // is not a command and the swallow-failures intent would invert into a 9009 error.
806
+ // One matcher-less group, not a `startup|clear` / `resume|compact` split (Claude verdict
807
+ // I4): every SessionStart source — `startup`, `resume`, `clear`, `compact` and `fork` —
808
+ // runs the identical `context` command, so splitting them only risked missing one. The
809
+ // split DID: a `--fork-session` run sets `source: "fork"`, which neither half matched, so
810
+ // a forked session got no session files or project context. The static AGENT.md is NOT
811
+ // re-printed here either way.
812
+ SessionStart: [{ hooks: [{ type: 'command', command: context }] }],
813
+ // `|| exit 0` (not `|| true`): the hook shell is `sh -c` on POSIX and Git Bash on Windows
814
+ // (OpenClaude requires it), or PowerShell on Windows when Git Bash isn't installed
815
+ // (hooks.md, "Shell form") — never cmd.exe for Claude. Bob runs `cmd /c`, where `true` is
816
+ // not a command (a 9009 error), so `exit 0` is the spelling every shell-form host shares.
817
+ // Under PowerShell `||` is a parse error in 5.1: `powershellForm` below swaps the tail.
547
818
  Stop: [{ hooks: [{ type: 'command', command: learn }] }],
548
819
  // Same command as Stop: `learn` reads the payload's hook_event_name and routes a
549
820
  // SubagentStop to the per-agent lesson path.
@@ -551,10 +822,45 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
551
822
  // And once more BEFORE compaction. `learn` distils the tail of the transcript (the newest
552
823
  // MAX_NOTES_CHARS), so per-turn Stop is a sliding window over the session — and
553
824
  // compaction is the one moment that window is about to be summarised away for good.
554
- // The payload carries `transcript_path` like Stop's; the SessionStart `compact` matcher
555
- // re-seeds context AFTER, this captures memory BEFORE. Same `|| exit 0`: never block.
825
+ // The payload carries `transcript_path` like Stop's; the matcher-less SessionStart group
826
+ // (its `compact` source) re-seeds context AFTER, this captures memory BEFORE. Same
827
+ // `|| exit 0`: never block.
556
828
  PreCompact: [{ hooks: [{ type: 'command', command: learn }] }],
557
829
  };
830
+ // Claude Code only: OpenClaude always runs Git Bash on Windows (its `findGitBashPath` exits
831
+ // without one), and Bob takes the early return above.
832
+ if (host !== 'claude' || claudeHookShell(process.env, hookOpts?.platform) !== 'powershell') {
833
+ return groups;
834
+ }
835
+ return powershellForm(groups);
836
+ }
837
+
838
+ /**
839
+ * The PowerShell spelling of a hook group set, for a Claude install on Windows without Git Bash
840
+ * (`claudeHookShell`). Without it the bash form is a parse error there and every hook fails
841
+ * OPEN. Each handler gets an explicit `shell: "powershell"` (hooks.md: it wins over the
842
+ * default) and a leading `&` — PowerShell refuses a quoted command head followed by arguments —
843
+ * and the never-block tail `|| exit 0` becomes `; exit 0`, valid in pwsh 7 and Windows
844
+ * PowerShell 5.1 alike (both measured byte-identical to the bash form's output). The gates stay
845
+ * bare on the exit-code side (no verb exits 2), but since Task 1 they carry `onFailure: "block"`
846
+ * (Claude only — `claudeHookGroups`'s `onFailureBlock`), so a launch failure — e.g. the bash-form
847
+ * command run under a Git-Bash-less PowerShell, a parse error — now BLOCKS instead of sliding
848
+ * through. `context`/`learn` carry no such field and stay non-blocking on a launch failure,
849
+ * in either shell.
850
+ *
851
+ * The paths stay double-quoted, where PowerShell expands `$name` and reads a backtick as an
852
+ * escape — a Windows path may legally hold either, and a mangled `--root` silently changes
853
+ * which install a gate answers for. So both are backtick-escaped first; nothing else in the
854
+ * command (verbs, flags) can contain them.
855
+ */
856
+ function powershellForm(groups) {
857
+ const ps = (h) => {
858
+ const escaped = h.command.replace(/[`$]/g, '`$&');
859
+ const command = `& ${escaped.replace(/ \|\| exit 0$/, '; exit 0')}`;
860
+ return { ...h, command, shell: 'powershell' };
861
+ };
862
+ return Object.fromEntries(Object.entries(groups)
863
+ .map(([event, gs]) => [event, gs.map((g) => ({ ...g, hooks: g.hooks.map(ps) }))]));
558
864
  }
559
865
 
560
866
  /**
@@ -898,6 +1204,24 @@ export function managedBlockRemove(p, blockId = 'GENESEED', whole = false) {
898
1204
  else unlinkSync(p);
899
1205
  }
900
1206
 
1207
+ /**
1208
+ * The global OpenCode `AGENTS.md` sentinel. OpenCode loads `<cfg>/AGENTS.md` and, only when it
1209
+ * is ABSENT, `~/.claude/CLAUDE.md` — where a Claude-global install keeps the whole harness in
1210
+ * Claude dialect — so without an AGENTS.md the harness loads twice. The switch upstream is an
1211
+ * env var (`OPENCODE_DISABLE_CLAUDE_CODE_PROMPT`) no config file can set, so the file has to exist.
1212
+ * The carrier stays `AGENT.md`: this block only has to exist, and it is one line because it
1213
+ * lands in every system prompt. A user's own AGENTS.md already suppresses the fallback and is
1214
+ * left byte-for-byte alone; only an absent file or one carrying our block is written.
1215
+ */
1216
+ export const OPENCODE_SENTINEL = 'AGENTS.md';
1217
+
1218
+ export function opencodeSentinelWrite(cfgDir) {
1219
+ const p = path.join(cfgDir, OPENCODE_SENTINEL);
1220
+ if (existsSync(p) && managedBlockRead(p) === null) return;
1221
+ managedBlockWrite(p, 'Geneseed keeps this block so OpenCode does not also load '
1222
+ + '~/.claude/CLAUDE.md; the harness is AGENT.md. Put your own rules outside the block.');
1223
+ }
1224
+
901
1225
  /** `_build_settings._managed_block_read` — the block's inner content, or null. */
902
1226
  export function managedBlockRead(p, blockId = 'GENESEED') {
903
1227
  if (!existsSync(p)) return null;
package/js/hosts/shim.mjs CHANGED
@@ -258,6 +258,43 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
258
258
  return `"${runner}" "${entry}"`;
259
259
  }
260
260
 
261
+ /** The basenames env-vars.md accepts for `CLAUDE_CODE_GIT_BASH_PATH`. */
262
+ const GIT_BASH_NAMES = new Set(['bash.exe', 'sh.exe', 'bash', 'sh']);
263
+
264
+ /**
265
+ * Which shell Claude Code runs a shell-form hook under on this machine: 'bash' | 'powershell'.
266
+ *
267
+ * hooks.md (`shell` field; "Shell form"): Git Bash on Windows, or PowerShell when Git Bash isn't
268
+ * installed — never cmd.exe. Under PowerShell the bash-form `"<shim>.cmd" verb … || exit 0` is a
269
+ * parse error, so every hook fails OPEN; `claudeHookGroups` emits the PowerShell form instead.
270
+ *
271
+ * THE RULE, filesystem and env only (the generator never spawns):
272
+ * 1. `CLAUDE_CODE_GIT_BASH_PATH` naming an existing bash.exe/sh.exe/bash/sh (env-vars.md; an
273
+ * invalid value is ignored and Claude auto-detects as if unset);
274
+ * 2. any PATH entry holding `git.exe` with `<dir>\..\bin\bash.exe` beside it — the PATH
275
+ * auto-detect is undocumented, so this mirrors the one detector we can read, OpenClaude's
276
+ * `findGitBashPath` (`<git>/../../bin/bash.exe`).
277
+ * ponytail: no guessed default install paths. A missed Git Bash costs only PowerShell startup
278
+ * (~150 ms a hook — `shell: "powershell"` is explicit and wins), a false hit fails open; add a
279
+ * location when Claude documents one. `env` is a parameter so tests inject it; keys are matched
280
+ * case-insensitively because a copied Windows env keeps `Path`.
281
+ */
282
+ export function claudeHookShell(env = process.env, platform = process.platform) {
283
+ if (platform !== 'win32') return 'bash';
284
+ const read = (name) => Object.entries(env).find(([k]) => k.toUpperCase() === name)?.[1];
285
+ const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
286
+ const forced = read('CLAUDE_CODE_GIT_BASH_PATH');
287
+ if (forced && GIT_BASH_NAMES.has(path.basename(forced).toLowerCase()) && isFile(forced)) {
288
+ return 'bash';
289
+ }
290
+ for (const raw of (read('PATH') || '').split(';')) {
291
+ const dir = raw.replace(/"/g, '');
292
+ if (dir && isFile(path.join(dir, 'git.exe'))
293
+ && isFile(path.join(dir, '..', 'bin', 'bash.exe'))) return 'bash';
294
+ }
295
+ return 'powershell';
296
+ }
297
+
261
298
  /**
262
299
  * `_build_settings._hook_runner_entry()`'s two values — this driver's answer, which since
263
300
  * P5b is NOT Python's.