geneseed 3.19.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.
- package/CHANGELOG.md +41 -0
- package/README.md +9 -1
- package/SHIPPED.md +1 -1
- package/adapters/bob/README.md +31 -12
- package/adapters/claude-code/README.md +24 -17
- package/adapters/claude-code/settings.json +4 -6
- package/adapters/openclaude/README.md +13 -5
- package/adapters/opencode/plugins/geneseed-guard.js +291 -174
- package/bin/geneseed-hook.mjs +36 -5
- package/docs/README.md +3 -1
- package/docs/candidate-hosts.md +0 -8
- package/docs/concepts/enforcement-table.md +66 -0
- package/docs/concepts/hook-gates.md +2 -2
- package/docs/concepts/hooks.md +6 -6
- package/docs/concepts/host-matrix.md +4 -3
- package/docs/concepts/loop-branch.md +6 -1
- package/docs/concepts/loops.md +4 -3
- package/docs/concepts/rules.md +1 -1
- package/docs/design-history.md +59 -0
- package/docs/extending.md +11 -3
- package/docs/guides/install-opencode.md +1 -1
- package/docs/guides/loop-running.md +1 -1
- package/docs/reference/env-context.md +3 -2
- package/docs/reference/environment.md +2 -1
- package/docs/reference/plugin-guard.md +3 -3
- package/docs/reference/troubleshoot-hooks.md +34 -0
- package/docs/understand/design-choices-engineering.md +29 -0
- package/docs/understand/design-choices-gates.md +23 -0
- package/docs/understand/design-choices-hosts.md +21 -0
- package/docs/understand/design-choices-install.md +19 -0
- package/docs/understand/design-choices-shape.md +21 -0
- package/docs/understand/design-choices.md +34 -0
- package/docs/understand/enforced-vs-asked.md +5 -4
- package/docs/understand/machine-bob.md +1 -1
- package/docs/understand/machine-opencode.md +2 -2
- package/harness.config.json +1 -1
- package/js/build/driver.mjs +42 -16
- package/js/build/emit-claude.mjs +24 -8
- package/js/build/emit-opencode.mjs +33 -15
- package/js/build/generate.mjs +38 -9
- package/js/build/source.mjs +39 -0
- package/js/build/stubs.mjs +2 -1
- package/js/cli-table.json +6 -6
- package/js/hosts/gitref.mjs +3 -3
- package/js/hosts/hooks-context.mjs +13 -31
- package/js/hosts/hooks-learn.mjs +89 -8
- package/js/hosts/hooks.mjs +212 -18
- package/js/hosts/hosts.mjs +217 -13
- package/js/hosts/installs.mjs +259 -46
- package/js/hosts/native.mjs +48 -12
- package/js/hosts/opencode.mjs +47 -17
- package/js/hosts/settings.mjs +366 -42
- package/js/hosts/shim.mjs +37 -0
- package/js/inspect/checks-authoring.mjs +81 -1
- package/js/inspect/checks-constitution.mjs +2 -1
- package/js/inspect/checks-repo.mjs +68 -4
- package/js/inspect/doctor.mjs +40 -9
- package/js/inspect/scan.mjs +5 -4
- package/js/inspect/status.mjs +62 -12
- package/js/lib/text.mjs +23 -0
- package/js/loop/catalog.mjs +7 -2
- package/js/loop/state.mjs +2 -2
- package/js/maintain/migrate.mjs +42 -5
- package/js/maintain/uninstall.mjs +150 -38
- package/js/maintain/update.mjs +22 -2
- package/js/web/actions.mjs +28 -10
- package/js/web/api.mjs +5 -3
- package/js/web/catalog.mjs +1 -1
- package/js/web/state.mjs +28 -15
- package/package.json +1 -1
- package/src/agents/_template.md +1 -1
- package/src/bricks/_template.md +1 -1
- package/src/bricks/apply.md +1 -1
- package/src/bricks/test.md +1 -1
- package/src/doctrines/ops.md +9 -7
- package/src/doctrines/process.md +3 -3
- package/src/laws/universal.md +1 -1
- package/src/loops/api-endpoint.json +1 -1
- package/src/loops/bugfix.json +1 -1
- package/src/loops/ci-repair.json +1 -1
- package/src/loops/deps-upgrade.json +1 -1
- package/src/loops/enforce-architecture-rule.json +1 -1
- package/src/loops/feature.json +1 -1
- package/src/loops/fix-flaky-tests.json +1 -1
- package/src/loops/legacy-refactor.json +1 -1
- package/src/loops/legacy-tests.json +1 -1
- package/src/loops/refactor.json +1 -1
- package/src/loops/remove-dead-code.json +1 -1
- package/src/loops/spec-first-feature.json +1 -1
- package/src/loops/tdd.json +1 -1
- package/src/loops/update-contract.json +1 -1
- package/src/skills/_template.md +3 -3
- package/src/skills/herdr.md +1 -1
- package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-BCtqxjfy.js} +1 -1
- package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Ba3Oo4dE.js} +1 -1
- package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-DpHza68-.js} +2 -2
- package/web/dist/assets/{Docs-CypIfDBX.js → Docs-Bw_-UK0O.js} +1 -1
- package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DnJ1Ggp1.js} +1 -1
- package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-CWoNKFBk.js} +1 -1
- package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-g6-bw4c8.js} +1 -1
- package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-DkVWR1tb.js} +1 -1
- package/web/dist/assets/{Library-BraUKPyQ.js → Library-CK9HZzjV.js} +1 -1
- package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-BP98SMOH.js} +1 -1
- package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-DI_n12M9.js} +1 -1
- package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-Bk89f13a.js} +1 -1
- package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-3WuYIE1b.js} +1 -1
- package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-oCUeq9e5.js} +1 -1
- package/web/dist/assets/{index-CPi22b7y.js → index-CiXwChvf.js} +2 -2
- package/web/dist/index.html +1 -1
package/js/hosts/settings.mjs
CHANGED
|
@@ -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
|
|
16
|
-
* like — see the shim comment below), so a stray byte
|
|
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*',
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
+ `${
|
|
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)
|
|
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
|
|
498
|
-
// install of the same host sits
|
|
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).
|
|
508
|
-
//
|
|
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
|
|
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
|
|
524
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
535
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
],
|
|
545
|
-
// `|| exit 0` (not `|| true`):
|
|
546
|
-
//
|
|
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
|
|
555
|
-
// re-seeds context AFTER, this captures memory BEFORE. Same
|
|
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.
|