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.
Files changed (109) hide show
  1. package/CHANGELOG.md +41 -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/loops.md +4 -3
  18. package/docs/concepts/rules.md +1 -1
  19. package/docs/design-history.md +59 -0
  20. package/docs/extending.md +11 -3
  21. package/docs/guides/install-opencode.md +1 -1
  22. package/docs/guides/loop-running.md +1 -1
  23. package/docs/reference/env-context.md +3 -2
  24. package/docs/reference/environment.md +2 -1
  25. package/docs/reference/plugin-guard.md +3 -3
  26. package/docs/reference/troubleshoot-hooks.md +34 -0
  27. package/docs/understand/design-choices-engineering.md +29 -0
  28. package/docs/understand/design-choices-gates.md +23 -0
  29. package/docs/understand/design-choices-hosts.md +21 -0
  30. package/docs/understand/design-choices-install.md +19 -0
  31. package/docs/understand/design-choices-shape.md +21 -0
  32. package/docs/understand/design-choices.md +34 -0
  33. package/docs/understand/enforced-vs-asked.md +5 -4
  34. package/docs/understand/machine-bob.md +1 -1
  35. package/docs/understand/machine-opencode.md +2 -2
  36. package/harness.config.json +1 -1
  37. package/js/build/driver.mjs +42 -16
  38. package/js/build/emit-claude.mjs +24 -8
  39. package/js/build/emit-opencode.mjs +33 -15
  40. package/js/build/generate.mjs +38 -9
  41. package/js/build/source.mjs +39 -0
  42. package/js/build/stubs.mjs +2 -1
  43. package/js/cli-table.json +6 -6
  44. package/js/hosts/gitref.mjs +3 -3
  45. package/js/hosts/hooks-context.mjs +13 -31
  46. package/js/hosts/hooks-learn.mjs +89 -8
  47. package/js/hosts/hooks.mjs +212 -18
  48. package/js/hosts/hosts.mjs +217 -13
  49. package/js/hosts/installs.mjs +259 -46
  50. package/js/hosts/native.mjs +48 -12
  51. package/js/hosts/opencode.mjs +47 -17
  52. package/js/hosts/settings.mjs +366 -42
  53. package/js/hosts/shim.mjs +37 -0
  54. package/js/inspect/checks-authoring.mjs +81 -1
  55. package/js/inspect/checks-constitution.mjs +2 -1
  56. package/js/inspect/checks-repo.mjs +68 -4
  57. package/js/inspect/doctor.mjs +40 -9
  58. package/js/inspect/scan.mjs +5 -4
  59. package/js/inspect/status.mjs +62 -12
  60. package/js/lib/text.mjs +23 -0
  61. package/js/loop/catalog.mjs +7 -2
  62. package/js/loop/state.mjs +2 -2
  63. package/js/maintain/migrate.mjs +42 -5
  64. package/js/maintain/uninstall.mjs +150 -38
  65. package/js/maintain/update.mjs +22 -2
  66. package/js/web/actions.mjs +28 -10
  67. package/js/web/api.mjs +5 -3
  68. package/js/web/catalog.mjs +1 -1
  69. package/js/web/state.mjs +28 -15
  70. package/package.json +1 -1
  71. package/src/agents/_template.md +1 -1
  72. package/src/bricks/_template.md +1 -1
  73. package/src/bricks/apply.md +1 -1
  74. package/src/bricks/test.md +1 -1
  75. package/src/doctrines/ops.md +9 -7
  76. package/src/doctrines/process.md +3 -3
  77. package/src/laws/universal.md +1 -1
  78. package/src/loops/api-endpoint.json +1 -1
  79. package/src/loops/bugfix.json +1 -1
  80. package/src/loops/ci-repair.json +1 -1
  81. package/src/loops/deps-upgrade.json +1 -1
  82. package/src/loops/enforce-architecture-rule.json +1 -1
  83. package/src/loops/feature.json +1 -1
  84. package/src/loops/fix-flaky-tests.json +1 -1
  85. package/src/loops/legacy-refactor.json +1 -1
  86. package/src/loops/legacy-tests.json +1 -1
  87. package/src/loops/refactor.json +1 -1
  88. package/src/loops/remove-dead-code.json +1 -1
  89. package/src/loops/spec-first-feature.json +1 -1
  90. package/src/loops/tdd.json +1 -1
  91. package/src/loops/update-contract.json +1 -1
  92. package/src/skills/_template.md +3 -3
  93. package/src/skills/herdr.md +1 -1
  94. package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-BCtqxjfy.js} +1 -1
  95. package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Ba3Oo4dE.js} +1 -1
  96. package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-DpHza68-.js} +2 -2
  97. package/web/dist/assets/{Docs-CypIfDBX.js → Docs-Bw_-UK0O.js} +1 -1
  98. package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DnJ1Ggp1.js} +1 -1
  99. package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-CWoNKFBk.js} +1 -1
  100. package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-g6-bw4c8.js} +1 -1
  101. package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-DkVWR1tb.js} +1 -1
  102. package/web/dist/assets/{Library-BraUKPyQ.js → Library-CK9HZzjV.js} +1 -1
  103. package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-BP98SMOH.js} +1 -1
  104. package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-DI_n12M9.js} +1 -1
  105. package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-Bk89f13a.js} +1 -1
  106. package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-3WuYIE1b.js} +1 -1
  107. package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-oCUeq9e5.js} +1 -1
  108. package/web/dist/assets/{index-CPi22b7y.js → index-CiXwChvf.js} +2 -2
  109. package/web/dist/index.html +1 -1
@@ -4,6 +4,7 @@
4
4
  // "enforce by injection, don't just instruct" stance as the context plugin:
5
5
  // - Sealed Secrets: block writes to private-key / credential files.
6
6
  // - Deletion Is Deliberate: block catastrophic shell commands.
7
+ // - Commands Must Return: block a recursive scan rooted at a whole filesystem (find /).
7
8
  // - Persist Insight (rule vs memory): speed-bump the first write to user-rules.md
8
9
  // memory file — that choice belongs to the user, via the rule skill.
9
10
  // - Wiki (AGENT.md §8): block mutations under a declared wiki's `protected`
@@ -17,21 +18,30 @@
17
18
  // Install: dropped into the plugins dir by `build --emit opencode[-global]` (the *.js
18
19
  // glob), exactly like the context and learn plugins. Errors never break a tool call.
19
20
  //
20
- // `permission.ask` — the loop/* exemption (Consent Before Push). OpenCode's static
21
- // `permission.bash` globs (`git commit*`/`git push*` = "ask", written by
22
- // js/hosts/settings.mjs) cannot see which branch is checked out — they are a build-time
23
- // string match with no request-time context. This hook is the ONLY seam where Geneseed can
24
- // look at the live branch, and it only DOWNGRADES an ask to allow: only a fixed set of
25
- // commit/push forms (see `loopExempt` below, the twin of js/hosts/hooks.mjs's) on a `loop/*`
26
- // branch a loop was actually LAUNCHED on — not just named `loop/...`. It is a WHITELIST, not
27
- // a blacklist: a false ask costs one prompt, a false allow publishes. It never raises the
28
- // bar: anything not on the whitelist, anything off a launched loop branch, a status this hook
29
- // did not itself leave as the default "ask", or any non-bash permission leaves `output.status`
30
- // untouched and OpenCode's own ask (or another hook's deny) stands. A host version that never
31
- // fires `permission.ask` simply never reaches this hook — the static ask still fires, so the
32
- // failure mode is one extra prompt, never a silent allow.
21
+ // No `permission.ask` hook (OpenCode verdict I-2). Upstream declares one in its plugin
22
+ // types but never triggers it: `Permission.ask` evaluates the rules and publishes the
23
+ // `permission.asked` bus event with no plugin hook in between. The loop/* exemption this
24
+ // plugin once hung there (downgrade the static `git commit*`/`git push*` ask to allow on a
25
+ // launched loop branch, the twin of js/hosts/hooks.mjs's `loopExempt`) therefore never ran,
26
+ // and is gone. Lost behaviour, stated plainly: an OpenCode loop's COMMIT gets the static ask
27
+ // every time — that fails safe, one prompt, never a silent allow. The `permission.asked`
28
+ // event + SDK reply route was not taken: it races the TUI's own prompt and is unverified from
29
+ // inside a plugin.
30
+ //
31
+ // The PUSH half is recovered differently (user decision, 2026-10-10): pushing a `loop/*`
32
+ // branch is not pushing `main`/`master`, so it is allowed STATICALLY rather than dynamically —
33
+ // js/hosts/settings.mjs's `LOOP_PUSH_ALLOW` wires an unconditional `permission.bash` allow for
34
+ // the loop engine's own refspec shapes (`git push [-u] <remote> HEAD:loop/*` and its
35
+ // `refs/heads/` spelling), ordered so a force push, a `+refspec`, or a `--delete`/bare-`:`
36
+ // push to `loop/*` still asks (`LAW_IV_BASH` comes after it, and `findLast` wins on the last
37
+ // match). This plugin has no part in that — it is a `permission.bash` key, read before any
38
+ // `tool.execute.before` hook runs — so there is nothing to change here for the push half; this
39
+ // comment only keeps this file's account in step. The commit still has no static equivalent:
40
+ // a commit's content cannot be named by a glob the way a push's target branch can, so the
41
+ // upgrade path there is still a session-level `permission` ruleset set by the loop launcher
42
+ // when it creates the loop session (`session.create` accepts one).
33
43
 
34
- import { promises as fs, realpathSync, existsSync, statSync, lstatSync, readFileSync, openSync, readSync, closeSync } from "node:fs"
44
+ import { promises as fs, realpathSync, existsSync, readFileSync } from "node:fs"
35
45
  import * as path from "node:path"
36
46
  import { fileURLToPath } from "node:url"
37
47
  import { homedir } from "node:os"
@@ -123,9 +133,25 @@ const SECRET_RE = [
123
133
  /(^|\/)\.npmrc$/i,
124
134
  /(^|\/)\.pypirc$/i,
125
135
  ]
126
- // .env files are often edited legitimately → WARN, don't hard-block.
136
+ // .env files are often edited legitimately → WARN, don't hard-block. Considered aligning this
137
+ // with js/hosts/hooks.mjs's DOTENV_RE (`/(^|[\\/])\.env(\.[^\\/]*)?$/`) the way SECRET_CONTENT_RE
138
+ // is aligned with SECRET_RE below — but the two diverge beyond case/slash-style: DOTENV_RE's
139
+ // suffix is `*` (zero-or-more, any non-slash char — matches `.env.` and `.env!`), this one's is
140
+ // `+` over `[\w.-]` (one-or-more, word/dot/hyphen only). Widening to DOTENV_RE's shape would
141
+ // change which filenames warn instead of block on THIS host, not just fold case, so it is left
142
+ // as-is rather than silently changing guard behaviour for a parity task.
127
143
  const SECRET_WARN_RE = [/(^|\/)\.env(\.[\w.-]+)?$/i]
128
144
 
145
+ // Twin of js/hosts/hooks.mjs's SECRET_RE (Law I's boundary half) — scans WRITE CONTENT for a
146
+ // credential-shaped string, not the target path (SECRET_RE above is the path twin, file names
147
+ // only). High-precision vendor prefixes only, same rationale as the Node side: a generic
148
+ // "looks like entropy" scan fires on every hash and lockfile. Kept byte-identical by the parity
149
+ // test in tests/plugins/guard.test.mjs — simple enough to literally diff, unlike SHELL_WARN_RE's
150
+ // hand-mirrored git acts. `.env*` is exempt (SECRET_WARN_RE above already names it as where a
151
+ // secret may legitimately live) via the same path check used for the WARN tier.
152
+ const SECRET_CONTENT_RE =
153
+ /\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|sk-ant-[A-Za-z0-9_-]{20,}|xox[abprs]-[0-9A-Za-z-]{10,})\b|-----BEGIN [A-Z ]*PRIVATE KEY-----/
154
+
129
155
  // Catastrophic, effectively irreversible shell → BLOCK. Both Unix and Windows shells,
130
156
  // since OpenCode runs natively on Windows and the agent may emit cmd / PowerShell.
131
157
  const SHELL_BLOCK_RE = [
@@ -142,16 +168,217 @@ const SHELL_BLOCK_RE = [
142
168
  /\bremove-item\b[^\n]*-recurse\b[^\n]*-force\b/i, // PowerShell rm -rf
143
169
  /\bremove-item\b[^\n]*-force\b[^\n]*-recurse\b/i, // (either flag order)
144
170
  ]
145
- // History-rewriting / irreversible git ops → WARN.
146
- const SHELL_WARN_RE = [/\bgit\s+push\b[^\n]*(--force\b|-f\b)/, /\bgit\s+reset\s+--hard\b/]
171
+ // History-rewriting / irreversible git ops (Deletion Is Deliberate) → WARN, never BLOCK:
172
+ // `tool.execute.before` has no ask tier, so these are a speed bump rather than a wall. The
173
+ // same acts js/hosts/hooks.mjs's `DESTRUCTIVE_GIT_RE` asks about — mirrored by hand rather
174
+ // than shared, since this plugin is copied whole into an OpenCode install, outside this
175
+ // repo's module graph. `clean`
176
+ // and `--delete` need `--force` alongside them to count, EITHER order (`--force --delete` is
177
+ // the same act as `--delete --force`) — an un-forced `clean`/`branch --delete` already refuses
178
+ // or only removes what is reproducible. `-D --force`/`--delete -f`/`-d --force`/`-df` are the
179
+ // same act again and are a known ceiling this does not chase. `git switch -h` lists `-f,
180
+ // --force` and `--discard-changes` as two SEPARATE options, not one flag's long/short spelling,
181
+ // so both are listed. `restore` and `stash drop`/`clear` are anchored to the VERB position
182
+ // (`\bgit\s+restore\b`, not `\bgit\s+.*restore\b`) — every other row needs a flag that is
183
+ // vanishingly unlikely in a commit message or filename, but `restore` needs none and
184
+ // `drop`/`clear` are ordinary English, so a message like "restore working behavior" or "clear
185
+ // old state" must never warn. `restore` matches UNLESS `--staged` appears on the line, EXCEPT
186
+ // `--staged --worktree` together, which restores the working tree too and so discards exactly
187
+ // like plain `restore` (see the twin rationale and tests in js/hosts/hooks.mjs).
188
+ const SHELL_WARN_RE = [
189
+ /\bgit\s+push\b[^\n]*(--force\b|-f\b|\+\S|\s--delete\b|\s:\S)/, // forced, mirror, or delete push
190
+ /\bgit\s+reset\s+--hard\b/,
191
+ /\bgit\s+clean\b[^\n]*\s(-[a-zA-Z]*f|--force\b)/,
192
+ /\bgit\s+branch\b[^\n]*\s(-D\b|--delete\b[^\n]*--force\b|--force\b[^\n]*--delete\b)/,
193
+ /\bgit\s+checkout\s+--\s/,
194
+ /\bgit\s+checkout\b[^\n]*\s(-[a-zA-Z]*f\b|--force\b)/,
195
+ /\bgit\s+switch\b[^\n]*\s(-[a-zA-Z]*f\b|--force\b|--discard-changes\b)/,
196
+ /\bgit\s+restore\b(?:(?![^\n]*--staged\b)|(?=[^\n]*--staged\b)(?=[^\n]*--worktree\b))/,
197
+ /\bgit\s+stash\s+(drop|clear)\b/,
198
+ /\bgit\s+worktree\b[^\n]*\bremove\b[^\n]*(-[a-zA-Z]*f\b|--force\b)/,
199
+ /\bgit\s+reflog\b[^\n]*\bexpire\b/,
200
+ /\bgit\s+gc\b[^\n]*--prune\b/,
201
+ ]
202
+
203
+ // A recursive scan rooted at a whole filesystem (Commands Must Return) → BLOCK. Twin of
204
+ // js/hosts/hooks.mjs's `rootScan` — keep in step; tests/plugins/guard.test.mjs diffs the
205
+ // constants below against it. In Git Bash `/` mounts every drive and share: on 2026-10-10 a
206
+ // `find / … | head -5` ran 3.5 h (`| head` stops nothing). Exempt: a segment whose program is ssh, a quoted argument, any path
207
+ // deeper than a root, a scanner word that is not the program. Backslash is never an escape in
208
+ // the tokeniser, so `C:\ -Recurse` stays two tokens.
209
+ const SCAN_ALWAYS = new Set(["find", "du", "tree", "rg", "fd", "where.exe"])
210
+ const SCAN_GREP_R = new Set(["grep", "egrep"])
211
+ const SCAN_LS_R = new Set(["ls", "dir"])
212
+ const SCAN_PS_R = new Set(["get-childitem", "gci"])
213
+ // `-r` recurses for grep only; for ls it is reverse, so ls/dir need an uppercase `R` cluster.
214
+ const GREP_RECURSE_RE = /^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)$/
215
+ const LS_RECURSE_RE = /^(?:-[a-zA-Z]*R[a-zA-Z]*|--recursive)$/
216
+ const PS_RECURSE_RE = /^-r(?:e(?:c(?:u(?:r(?:s(?:e)?)?)?)?)?)?$/i
217
+ const FS_ROOT_RE = /^(?:\/|\/[a-z]\/?|\/mnt\/[a-z]\/?|[a-z]:[\\/]?)\*?$/i
218
+ const SCAN_WORD_RE = /\b(?:find|du|tree|rg|fd|where\.exe|grep|egrep|ls|dir|get-childitem|gci)\b/i
219
+ const SCAN_TOKEN_RE = /(?:[^\s"']+|"[^"]*"?|'[^']*'?)+/g
220
+ const SCAN_WRAPPERS = new Set(["sudo", "command", "\\builtin", "time", "nice"])
221
+ // A comment (`#` starting a word), a quoted run (its `;`/`|` is data), a separator, or a heredoc
222
+ // opener (never the `<<<` here-string).
223
+ const SCAN_SPLIT_RE = /(?<![^\s;|&(])#[^\n]*|"[^"]*"?|'[^']*'?|\|\||&&|[|;\n]|(?<!<)<<(?!<)(-?)[ \t]*(["']?)([A-Za-z_]\w*)\2/g
224
+ // Cut at separators OUTSIDE quotes and drop every heredoc body (to its TAG line; `<<-` allows
225
+ // tabs): a commit message that names a root scan is data, not a command to block.
226
+ function scanSegments(command) {
227
+ const segs = []
228
+ const tags = []
229
+ const re = new RegExp(SCAN_SPLIT_RE)
230
+ let start = 0
231
+ let m
232
+ while ((m = re.exec(command))) {
233
+ if (m[3]) { tags.push([m[1], m[3]]); continue }
234
+ if (m[0][0] === '"' || m[0][0] === "'") continue
235
+ segs.push(command.slice(start, m.index))
236
+ start = re.lastIndex
237
+ // A comment is cut OUT of its segment; the newline after it is the next separator.
238
+ if (m[0][0] === "#") continue
239
+ while (m[0] === "\n" && tags.length) {
240
+ const end = command.indexOf("\n", start)
241
+ const line = command.slice(start, end < 0 ? command.length : end).replace(/\r$/, "")
242
+ start = end < 0 ? command.length : end + 1
243
+ if ((tags[0][0] ? line.replace(/^\t+/, "") : line) === tags[0][1]) tags.shift()
244
+ if (end < 0) break
245
+ }
246
+ re.lastIndex = start
247
+ }
248
+ segs.push(command.slice(start))
249
+ return segs
250
+ }
251
+ function rootScan(command) {
252
+ // A segment whose program is ssh passes (its remote command is ssh's argument): ssh is in no
253
+ // scanner set below. Only that segment — `ssh h true; find /` still blocks.
254
+ if (!SCAN_WORD_RE.test(command)) return null
255
+ for (const seg of scanSegments(command)) {
256
+ // Quotes kept until the root test, so a quoted "a:" is told from a bare C:.
257
+ let toks = (seg.match(SCAN_TOKEN_RE) || []).filter((t) => !/^\w+=/.test(t))
258
+ while (toks.length && SCAN_WRAPPERS.has(unquote(toks[0]))) toks = toks.slice(1)
259
+ if (!toks.length) continue
260
+ // The program word without its path (either slash) or an `.exe` suffix.
261
+ const word = unquote(toks[0]).split(/[\\/]/).pop().toLowerCase()
262
+ const prog = SCAN_ALWAYS.has(word) ? word : word.replace(/\.exe$/, "")
263
+ const always = SCAN_ALWAYS.has(prog)
264
+ const grep = SCAN_GREP_R.has(prog)
265
+ const ls = SCAN_LS_R.has(prog)
266
+ const ps = SCAN_PS_R.has(prog)
267
+ if (!always && !grep && !ls && !ps) continue
268
+ // `-Path=x` and PowerShell's colon binding `-Path:C:\` read as the path itself.
269
+ const argv = toks.slice(1).map((t) => {
270
+ const m = /^-path[=:](.+)$/i.exec(t)
271
+ return m ? m[1] : t
272
+ })
273
+ if (!always && !argv.some((a) => (grep && GREP_RECURSE_RE.test(a))
274
+ || (ls && (LS_RECURSE_RE.test(a) || (a.length >= 3 && PS_RECURSE_RE.test(a))))
275
+ || (ps && PS_RECURSE_RE.test(a)))) continue
276
+ if (argv.some(rootArg)) return seg.trim()
277
+ }
278
+ return null
279
+ }
280
+ // For ls/dir a `-Recurse` abbreviation counts from `-re` up: bare `-r` there is POSIX reverse.
281
+ const unquote = (t) => t.replace(/^["']+|["']+$/g, "")
282
+ // A QUOTED token is data (a grep pattern, a path named in a search), never a root target.
283
+ const rootArg = (t) => !/^["']/.test(t) && FS_ROOT_RE.test(t)
284
+
285
+ // `apply_patch`'s only argument is `patchText` (OpenCode verdict I-1): every gpt-5*
286
+ // model gets ONLY this tool, never `edit`/`write`, so a path that lives inside the
287
+ // patch text — not in `filePath`/`path`/etc — must still hit every gate below, or
288
+ // Sealed Secrets, the protected-check gate, the rule store and the wiki gate all go
289
+ // dark for that model class. Markers match upstream's own parser
290
+ // (`packages/opencode/src/patch/index.ts` `parsePatchHeader`): `*** Add File:`,
291
+ // `*** Update File:` (optionally followed by `*** Move to:`, both the old and the new
292
+ // path count) and `*** Delete File:`. The `+`-prefixed body lines are the new file
293
+ // content, never a path, and are not scanned here — no gate in this file inspects
294
+ // write content, only the target path.
295
+ function parsePatchPaths(patchText) {
296
+ const out = []
297
+ const lines = String(patchText).split(/\r?\n/)
298
+ for (let i = 0; i < lines.length; i++) {
299
+ const line = lines[i]
300
+ let m = /^\*\*\* (?:Add|Delete) File:\s*(.+)$/.exec(line)
301
+ if (m) { out.push(m[1].trim()); continue }
302
+ m = /^\*\*\* Update File:\s*(.+)$/.exec(line)
303
+ if (m) {
304
+ out.push(m[1].trim())
305
+ const mv = /^\*\*\* Move to:\s*(.+)$/.exec(lines[i + 1] || "")
306
+ if (mv) { out.push(mv[1].trim()); i++ }
307
+ }
308
+ }
309
+ return out.filter(Boolean)
310
+ }
147
311
 
148
- function pickPath(args) {
312
+ // Returns every candidate path for this call, normalized (backslashes -> `/`) and,
313
+ // for an `apply_patch` call, resolved against `cwd` (the session's `ctx.directory` —
314
+ // the same root `apply_patch.ts:72` resolves against upstream) since a patch marker
315
+ // is worktree-relative. Non-patch tools keep the single-path shape every other gate
316
+ // already expects, just wrapped in an array.
317
+ function pickPath(args, cwd) {
318
+ if (args && typeof args.patchText === "string") {
319
+ return parsePatchPaths(args.patchText)
320
+ .map((p) => path.resolve(cwd || process.cwd(), p).replace(/\\/g, "/"))
321
+ }
149
322
  for (const k of ["filePath", "path", "file", "target", "filename"]) {
150
323
  // Normalize Windows backslashes to `/` so the secret-path patterns (which use `/`
151
324
  // as the segment separator) match `C:\Users\me\.ssh\id_rsa` the same as a POSIX path.
152
- if (args && typeof args[k] === "string") return args[k].replace(/\\/g, "/")
325
+ if (args && typeof args[k] === "string") return [args[k].replace(/\\/g, "/")]
153
326
  }
154
- return ""
327
+ return []
328
+ }
329
+ // Per-FILE added content for an `apply_patch` call: `parsePatchPaths` flattens every marker
330
+ // path into one list (right for the path-only gates, which must catch a secret/protected name
331
+ // on either side of a `Move to`), but the CONTENT scan must not pool every file's `+` lines
332
+ // into one string — a patch that touches a `.env` AND an ordinary file would then exempt the
333
+ // ordinary file's own secret too, because the exemption was computed over the pool, not the
334
+ // line's own file. One section per marker; a `Move to` target is where the `+` lines actually
335
+ // land, so content is attributed to it, not the pre-move path. `*** Delete File:` sections
336
+ // collect no `+` lines (nothing is being written).
337
+ function parsePatchSections(patchText) {
338
+ const out = []
339
+ let current = null
340
+ const flush = () => { if (current) out.push(current) }
341
+ const lines = String(patchText).split(/\r?\n/)
342
+ for (let i = 0; i < lines.length; i++) {
343
+ const line = lines[i]
344
+ let m = /^\*\*\* (?:Add|Delete) File:\s*(.+)$/.exec(line)
345
+ if (m) { flush(); current = { path: m[1].trim(), added: [] }; continue }
346
+ m = /^\*\*\* Update File:\s*(.+)$/.exec(line)
347
+ if (m) {
348
+ flush()
349
+ let p = m[1].trim()
350
+ const mv = /^\*\*\* Move to:\s*(.+)$/.exec(lines[i + 1] || "")
351
+ if (mv) { p = mv[1].trim(); i++ }
352
+ current = { path: p, added: [] }
353
+ continue
354
+ }
355
+ if (current && line.startsWith("+") && !line.startsWith("+++")) current.added.push(line.slice(1))
356
+ }
357
+ flush()
358
+ return out.filter((s) => s.path)
359
+ }
360
+
361
+ // The content a write/edit/apply_patch call would actually land on disk, per FILE — what the
362
+ // content secret scan runs against, paired with the path it belongs to so the `.env` exemption
363
+ // can be checked against that file alone. `write` carries `content`, `edit` carries `newString`
364
+ // (OpenCode tool Parameters, packages/opencode/src/tool/{write,edit}.ts) — both single-path, so
365
+ // one pair. For `apply_patch`, one pair per marker section; only the `+`-prefixed hunk lines are
366
+ // NEW content — a `-` line is text being REMOVED, so a secret there is leaving the file, not
367
+ // landing in it, and must not block the call.
368
+ function pickAddedContentByPath(args, cwd) {
369
+ if (args && typeof args.patchText === "string") {
370
+ return parsePatchSections(args.patchText)
371
+ .filter((s) => s.added.length)
372
+ .map((s) => ({
373
+ path: path.resolve(cwd || process.cwd(), s.path).replace(/\\/g, "/"),
374
+ content: s.added.join("\n"),
375
+ }))
376
+ }
377
+ const [p] = pickPath(args, cwd)
378
+ if (!p) return []
379
+ if (typeof args.content === "string") return [{ path: p, content: args.content }]
380
+ if (typeof args.newString === "string") return [{ path: p, content: args.newString }]
381
+ return []
155
382
  }
156
383
  function pickCommand(args) {
157
384
  for (const k of ["command", "cmd", "script"]) {
@@ -319,130 +546,8 @@ function protectedCheck(p) {
319
546
  // substring stance: over-matching the class is harmless (see WRITE_TOOLS note).
320
547
  const WIKI_MUTATE_TOOLS = [...WRITE_TOOLS, "delete", "remove", "rename", "move", "trash"]
321
548
 
322
- // ---- the loop/* exemption (Consent Before Push) --------------------------------
323
- // Twin of js/hosts/gitref.mjs (currentBranch/loopLaunched) and js/hosts/hooks.mjs's
324
- // git-gate (the rest) — keep in step. Node's native sync `fs` (not the `fs.promises`
325
- // import above): a worktree's `.git` is read the same way the Claude/Bob hook reads it, so
326
- // the two decide the exemption identically. This plugin cannot import from `js/` at all
327
- // (it is copied whole into an OpenCode install, outside this repo's module graph), so it
328
- // keeps its own standalone copy rather than the single shared owner the Node side has.
329
-
330
- const GIT_GATE_RE = /\bgit\b[^\n]*\b(?:commit|push)\b/
331
-
332
- // No newline, backtick, or `$(` substitution anywhere, no `<`/`>` redirect, and no single
333
- // `&` (a shell runs `a & b` as two commands exactly like `a && b`) — these can smuggle a
334
- // second command (or a shared-branch target) past every check below. `(?<!&)&(?!&)` matches
335
- // a lone `&` without matching either half of a doubled `&&`.
336
- const UNSAFE_CHARS_RE = /[\n`<>]|\$\(|(?<!&)&(?!&)/
337
-
338
- // The only three git shapes a loop's own automation ever needs — see js/hosts/hooks.mjs's
339
- // identically-named constants for the full rationale. The token class `[\w./:@^~=+,-]`
340
- // excludes quotes, braces, `!`, `*`, `$` and `&` — a brace/glob expansion is exactly how a
341
- // shell turns one whitelisted-looking token into several unknown ones, which is also why the
342
- // `-F`/`--file` path uses this same class rather than the looser `[^\s'"]+` it once did: that
343
- // loose class let `git commit -F {m,--amend}` brace-expand into an amend of the last commit.
344
- const ARG_RE = "[\\w./:@^~=+,-]+"
345
- const SEG_ADD_RE = new RegExp(`^git\\s+add(\\s+${ARG_RE})*$`)
346
- const SEG_COMMIT_RE = new RegExp(`^git\\s+commit(\\s+-q)?\\s+(-F\\s+|--file[=\\s])${ARG_RE}(\\s+-q)?$`)
347
- const SEG_READONLY_RE = new RegExp(`^git\\s+(status|diff|log|rev-parse|show)(\\s+${ARG_RE})*$`)
348
-
349
- /**
350
- * `git push`, exactly: `[-u|--set-upstream] <remote> HEAD:<branch>` or
351
- * `HEAD:refs/heads/<branch>` — the ONLY exempt form, same as js/hosts/hooks.mjs's
352
- * `pushSegmentOk`. An explicit refspec is immune to `push.default`/`remote.*.push`
353
- * redirection and refuses to coexist with a mirror/`+`/`:`-prefixed form; nothing else is.
354
- */
355
- function pushSegmentOk(seg, branch) {
356
- const m = /^git\s+push(?:\s+(.*))?$/.exec(seg)
357
- if (!m) return false
358
- const rest = (m[1] || "").trim()
359
- if (!rest) return false
360
- const tokens = rest.split(/\s+/)
361
- let i = 0
362
- if (tokens[i] === "-u" || tokens[i] === "--set-upstream") i += 1
363
- const remote = tokens[i]
364
- if (remote === undefined || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(remote)) return false
365
- i += 1
366
- const ref = tokens[i]
367
- i += 1
368
- if (ref === undefined || i !== tokens.length) return false
369
- return ref === `HEAD:${branch}` || ref === `HEAD:refs/heads/${branch}`
370
- }
371
-
372
- const LOOP_STATE_MARKER = "<!-- loop-state:begin -->"
373
-
374
- /**
375
- * Whether `<root>/LOOP.md` carries the engine's state marker — read at most 64 KB.
376
- * `lstatSync` (never `statSync`, which follows a link) must find an ordinary file: a FIFO
377
- * would block the read, a symlink could point anywhere outside the repo.
378
- */
379
- function loopLaunched(gitRoot) {
380
- let fd
381
- try {
382
- const p = path.join(gitRoot, "LOOP.md")
383
- if (!lstatSync(p).isFile()) return false
384
- fd = openSync(p, "r")
385
- const buf = Buffer.alloc(65536)
386
- const n = readSync(fd, buf, 0, buf.length, 0)
387
- return buf.toString("utf8", 0, n).includes(LOOP_STATE_MARKER)
388
- } catch {
389
- return false
390
- } finally {
391
- if (fd !== undefined) { try { closeSync(fd) } catch { /* already gone */ } }
392
- }
393
- }
394
-
395
- /**
396
- * The branch checked out in `cwd`, and the directory holding `.git` for it, read from
397
- * .git/HEAD without spawning git — a worktree's `.git` is a FILE naming its gitdir, so both
398
- * shapes are followed. `{ branch: null, root: null }` on anything unexpected (detached HEAD,
399
- * no repo, unreadable): the caller then leaves the ask alone.
400
- */
401
- function currentBranch(cwd) {
402
- try {
403
- let dir = path.resolve(cwd)
404
- for (;;) {
405
- const dotgit = path.join(dir, ".git")
406
- if (existsSync(dotgit)) {
407
- let gitdir = dotgit
408
- if (statSync(dotgit).isFile()) {
409
- const m = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotgit, "utf8"))
410
- if (!m) return { branch: null, root: null }
411
- gitdir = path.resolve(dir, m[1])
412
- }
413
- const ref = /^ref:\s*refs\/heads\/(.+?)\s*$/m.exec(readFileSync(path.join(gitdir, "HEAD"), "utf8"))
414
- return { branch: ref ? ref[1] : null, root: dir }
415
- }
416
- const up = path.dirname(dir)
417
- if (up === dir) return { branch: null, root: null }
418
- dir = up
419
- }
420
- } catch {
421
- return { branch: null, root: null }
422
- }
423
- }
424
-
425
- /**
426
- * Consent Before Push's loop/* exemption — the twin of js/hosts/hooks.mjs's `loopExempt`.
427
- * True only when the command carries none of the unsafe characters, every `&&`/`;`/`||`/`|`
428
- * segment is one of the three whitelisted git shapes (every push segment also passing
429
- * `pushSegmentOk`), and the branch checked out in `cwd` starts with `loop/` AND its git root
430
- * carries a launched LOOP.md.
431
- */
432
- function loopExempt(command, cwd) {
433
- if (typeof command !== "string" || UNSAFE_CHARS_RE.test(command)) return false
434
- const { branch, root } = currentBranch(cwd)
435
- if (!branch || !branch.startsWith("loop/")) return false
436
- if (!root || !loopLaunched(root)) return false
437
- const segments = command.split(/&&|\|\||[;|]/).map((s) => s.trim()).filter(Boolean)
438
- if (!segments.length) return false
439
- return segments.every((seg) => SEG_ADD_RE.test(seg) || SEG_COMMIT_RE.test(seg)
440
- || SEG_READONLY_RE.test(seg) || pushSegmentOk(seg, branch))
441
- }
442
-
443
549
  export const GeneseedGuard = async (ctx) => {
444
- // The session's working directory, the same resolution the context plugin uses — a
445
- // worktree session's `.git` is a FILE, which `currentBranch` already follows.
550
+ // The session's working directory, the same resolution the context plugin uses.
446
551
  const root = () => ctx?.worktree || ctx?.directory || process.cwd()
447
552
  return {
448
553
  "tool.execute.before": async (input, output) => {
@@ -459,24 +564,47 @@ export const GeneseedGuard = async (ctx) => {
459
564
  throw new Error(`[geneseed-guard] blocked: ${why} — set GENESEED_GUARD=off to allow`)
460
565
  }
461
566
  try {
567
+ // `apply_patch`'s own tool resolves every marker path against `instance.directory`
568
+ // (`apply_patch.ts:72` in the verdict's evidence), NOT the worktree root `root()`
569
+ // uses for the sovereign-repo bypass above — a worktree session started in a
570
+ // subdirectory of its git root must see the same (narrower) root the write
571
+ // actually lands under, or a protected-check/wiki/secret hit one directory up
572
+ // would be missed.
573
+ const cwd = ctx?.directory || process.cwd()
462
574
  if (hasAny(tool, WRITE_TOOLS)) {
463
- const p = pickPath(args)
464
- if (p && SECRET_RE.some((re) => re.test(p))) { await deny(`write to secret/key file ${p} (Sealed Secrets)`, "law-1"); return }
465
- if (p && SECRET_WARN_RE.some((re) => re.test(p))) log(`WARN: writing ${p} — keep secrets out of tracked files (Sealed Secrets)`)
466
- const sensor = p && protectedCheck(p)
467
- if (sensor) { await deny(`${p} is a protected check (${sensor}, listed in ${SENSOR_LIST}) — a check the agent can edit is not a check (External Gate)`, "rigor-5"); return }
468
- const store = p && ruleStoreTarget(p)
469
- if (store && !RULE_STORE_BUMPED.has(p)) {
470
- RULE_STORE_BUMPED.add(p)
471
- await deny(`writing to ${store} — a standing rule, or a fact to remember? That ` +
472
- `choice is the user's (Persist Insight). Settle it through the rule skill, ` +
473
- `then re-issue this write`, "process-1")
474
- return
575
+ // `apply_patch` (gpt-5*) can carry several marker paths in one call — every
576
+ // one of them is a write target, so every one runs every gate; the first hit
577
+ // blocks the whole call (see `pickPath` for why this is not a single path).
578
+ const paths = pickPath(args, cwd)
579
+ for (const p of paths) {
580
+ if (SECRET_RE.some((re) => re.test(p))) { await deny(`write to secret/key file ${p} (Sealed Secrets)`, "law-1"); return }
581
+ if (SECRET_WARN_RE.some((re) => re.test(p))) log(`WARN: writing ${p} — keep secrets out of tracked files (Sealed Secrets)`)
582
+ const sensor = protectedCheck(p)
583
+ if (sensor) { await deny(`${p} is a protected check (${sensor}, listed in ${SENSOR_LIST}) — a check the agent can edit is not a check (External Gate)`, "rigor-5"); return }
584
+ const store = ruleStoreTarget(p)
585
+ if (store && !RULE_STORE_BUMPED.has(p)) {
586
+ RULE_STORE_BUMPED.add(p)
587
+ await deny(`writing to ${store} — a standing rule, or a fact to remember? That ` +
588
+ `choice is the user's (Persist Insight). Settle it through the rule skill, ` +
589
+ `then re-issue this write`, "process-1")
590
+ return
591
+ }
592
+ }
593
+ // Content scan (Sealed Secrets, the twin of js/hosts/hooks.mjs's ruleDecide): per
594
+ // FILE, not pooled — a patch touching `.env` AND an ordinary file must still catch a
595
+ // secret in the ordinary file; only the hunk whose OWN path is `.env`-shaped is
596
+ // exempt, same as the path WARN tier above.
597
+ for (const { path: cp, content } of pickAddedContentByPath(args, cwd)) {
598
+ if (SECRET_WARN_RE.some((re) => re.test(cp))) continue
599
+ if (SECRET_CONTENT_RE.test(content)) {
600
+ await deny(`${cp} would carry a credential-shaped string (Sealed Secrets) — secrets `
601
+ + "live in .env or a secret manager, never in a tracked file", "law-1")
602
+ return
603
+ }
475
604
  }
476
605
  }
477
606
  if (hasAny(tool, WIKI_MUTATE_TOOLS)) {
478
- const p = pickPath(args)
479
- if (p) {
607
+ for (const p of pickPath(args, cwd)) {
480
608
  const abs = (path.isAbsolute(p) ? p : path.resolve(p)).replace(/\\/g, "/").toLowerCase()
481
609
  const hit = (await protectedPrefixes()).find(
482
610
  (x) => abs.startsWith(x.prefix) || abs === x.prefix.slice(0, -1))
@@ -488,6 +616,12 @@ export const GeneseedGuard = async (ctx) => {
488
616
  if (hasAny(tool, SHELL_TOOLS)) {
489
617
  const c = pickCommand(args)
490
618
  if (c && SHELL_BLOCK_RE.some((re) => re.test(c))) { await deny(`catastrophic command (Deletion Is Deliberate): ${c.slice(0, 80)}`, "law-4"); return }
619
+ const scan = c && rootScan(c)
620
+ if (scan) {
621
+ await deny(`\`${scan.slice(0, 80)}\` recursively scans a whole filesystem root (Commands Must Return) — ` +
622
+ "search a specific directory instead: the project, node_modules, ~/.npm, %APPDATA%", "ops-2")
623
+ return
624
+ }
491
625
  if (c && SHELL_WARN_RE.some((re) => re.test(c))) log(`WARN: irreversible op — confirm intent (Deletion Is Deliberate): ${c.slice(0, 80)}`)
492
626
  }
493
627
  } catch (err) {
@@ -496,23 +630,6 @@ export const GeneseedGuard = async (ctx) => {
496
630
  log(`inspect error (ignored): ${err?.message ?? err}`)
497
631
  }
498
632
  },
499
- "permission.ask": async (input, output) => {
500
- // Never throw from a permission hook — leave `output.status` untouched on any
501
- // error, exactly as a crashed check here must default to the static ask standing.
502
- try {
503
- if (OFF) return
504
- // Never loosen anything but the plain "ask" this hook itself would otherwise leave
505
- // alone — a deny (or any other status another hook already set) is never overwritten.
506
- if (output?.status && output.status !== "ask") return
507
- if (input?.type !== "bash") return
508
- // ONLY `input.metadata?.command` — no pattern/title fallback. A value this hook did
509
- // not itself read in full is a value it must not reason about.
510
- const command = input?.metadata?.command
511
- if (typeof command !== "string" || !command || !GIT_GATE_RE.test(command)) return
512
- if (await sovereignBypass(root())) return
513
- if (loopExempt(command, root())) output.status = "allow"
514
- } catch { /* leave output.status untouched */ }
515
- },
516
633
  }
517
634
  }
518
635
 
@@ -38,7 +38,12 @@
38
38
  * subparsers (25 invocable names — `update` is an alias of `upgrade`); a hook entry that
39
39
  * silently accepted `doctor` and did nothing would be the worst available failure, because
40
40
  * every Geneseed hook returns 0 and signals through stdout — so "did nothing" and "worked"
41
- * are the same observation.
41
+ * are the same observation. That refusal exits **1**, never 2: on Claude Code and Bob, exit 2
42
+ * is the one code that blocks a tool call (or, on `Stop`/`SubagentStop`, keeps the turn going
43
+ * without running the hook again), so a verb this build does not know yet — the forward case,
44
+ * when a newer Geneseed names a verb this stale checkout has never heard of, under the
45
+ * machine-wide last-writer-wins shim (see I3/B1 below) — must not read as a block. Exit 1 still
46
+ * shows a `<hook name> hook error` notice; it just does not stop anything (see I3/B1).
42
47
  */
43
48
  // FIRST, before anything it could fail to run: an old Node gets one sentence, not a stack trace.
44
49
  import '../js/lib/node-floor.mjs';
@@ -55,6 +60,13 @@ import { printErr } from '../js/lib/fs.mjs';
55
60
  // specifiers stay literal strings — `tests/unit/hook_cli.test.mjs`'s import walk reads them.
56
61
  const HOSTED = { '--root': 'root', '--host': 'host' };
57
62
  const gates = () => import('../js/hosts/hooks.mjs');
63
+ // Verbs PreToolUse/PostToolUse actually gate a tool call with. The machine-wide, last-writer-wins
64
+ // shim (this file's header) means a stale checkout can be handed a flag only a newer install
65
+ // knows (e.g. `--no-consent`); on Claude Code, exit 2 from one of THESE verbs blocks the tool
66
+ // outright, with no ask. `main` below answers an argv error on a gate verb with exit 0 instead —
67
+ // a gate's own decision still exits 0 and signals through stdout (`askDecision`), so this only
68
+ // changes what used to be a hard lockout into "the gate is silent this one call" (see I3/B1).
69
+ const GATE_VERBS = new Set(['git-gate', 'rule-gate', 'tool-gate']);
58
70
  const VERBS = {
59
71
  context: { load: () => import('../js/hosts/hooks-context.mjs'), fn: 'cmdContext', flags: HOSTED },
60
72
  'git-gate': {
@@ -65,7 +77,8 @@ const VERBS = {
65
77
  learn: {
66
78
  load: () => import('../js/hosts/hooks-learn.mjs'),
67
79
  fn: 'cmdLearn',
68
- flags: { '--memory': 'memory' },
80
+ // `--host` only keys the global stand-down's marker (js/hosts/hosts.mjs); learn has no dialect.
81
+ flags: { '--memory': 'memory', '--host': 'host' },
69
82
  switches: { '--consolidate': 'consolidate' },
70
83
  positional: 'file',
71
84
  },
@@ -118,7 +131,9 @@ async function main(argv) {
118
131
 
119
132
  const verb = argv[0];
120
133
  if (!verb || verb === '-h' || verb === '--help') {
121
- return die(2, `the following arguments are required: cmd (one of ${
134
+ // 1, not 2: see this file's header (I3/B1) — 2 is the one code that blocks a tool call on
135
+ // Claude Code and Bob, and a bare invocation is not a gate decision.
136
+ return die(1, `the following arguments are required: cmd (one of ${
122
137
  Object.keys(VERBS).join(', ')})`);
123
138
  }
124
139
  const spec = VERBS[verb];
@@ -130,7 +145,11 @@ async function main(argv) {
130
145
  // The COMMAND is not a table: `geneseed` is this package's `bin` entry for the CLI
131
146
  // (package.json), it answers every non-hook verb, and it survives the deletion of the
132
147
  // interpreter-plus-script invocation this line used to print.
133
- return die(2, `invalid choice: '${verb}'. This entry point carries only the HOOK `
148
+ //
149
+ // 1, not 2 (I3/B1): the forward case is the one that matters — settings emitted by a NEWER
150
+ // Geneseed name a verb this stale checkout, running from the machine-wide last-writer-wins
151
+ // shim, has never heard of. That must not read as a block.
152
+ return die(1, `invalid choice: '${verb}'. This entry point carries only the HOOK `
134
153
  + `verbs (${Object.keys(VERBS).join(', ')}); every other harness subcommand lives `
135
154
  + 'elsewhere — run `geneseed ' + verb + '`.');
136
155
  }
@@ -146,7 +165,19 @@ async function main(argv) {
146
165
  if (rc !== null) return rc;
147
166
  }
148
167
  const parsed = parse(spec, argv.slice(1));
149
- if (parsed.error) return die(2, parsed.error);
168
+ if (parsed.error) {
169
+ // A gate verb must never exit 2 on an argv error: that is indistinguishable, to Claude's
170
+ // PreToolUse, from the gate deliberately blocking (see I3/B1 above). Print the same message
171
+ // to stderr for a human debugging the shim, but return 0 with no stdout, exactly like a gate
172
+ // that chose not to act.
173
+ if (GATE_VERBS.has(verb)) { printErr(`geneseed-hook: error: ${parsed.error}\n`); return 0; }
174
+ // `context` and `learn` are not gate verbs, but their own argv errors are not a decision
175
+ // either, so still 1, not 2 (I3/B1): a `<hook name> hook error` notice, and nothing more.
176
+ // `learn` is the one that matters here — it runs on Stop/SubagentStop, where exit 2 ALSO has
177
+ // an effect (it sends Claude back to keep working instead of letting the turn end; docs
178
+ // `hooks.md`, "Stop, SubagentStop, TaskCompleted, and TeammateIdle").
179
+ return die(1, parsed.error);
180
+ }
150
181
  return (await spec.load())[spec.fn](parsed.args);
151
182
  }
152
183
 
package/docs/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [← Back to README](../README.md)
4
4
 
5
- **New to agent harnesses?** Read **Understand** first — a short track, about ten minutes, that every other page assumes. The same pages open the **Docs** tab of the web console (`geneseed web`), which groups each part into sections, adds an interactive map of your own install, and filters host-specific passages to the tool you use.
5
+ **New to agent harnesses?** Read **Understand** first: the track every other page assumes. Its last section, *Why it works this way*, is reference, to consult when a question comes up. The same pages open the **Docs** tab of the web console (`geneseed web`), which groups each part into sections, adds an interactive map of your own install, and filters host-specific passages to the tool you use.
6
6
 
7
7
  ## 1 · Understand
8
8
 
@@ -12,6 +12,8 @@
12
12
 
13
13
  **Day to day** — [A day with the harness](understand/a-day.md): the moments you will actually meet · [Enforced vs. asked](understand/enforced-vs-asked.md): what is guaranteed by code, and what the model is only asked to do.
14
14
 
15
+ **Why it works this way** — [Why Geneseed works this way](understand/design-choices.md): every decision already taken, one line each, and where it is argued · [The shape of the harness](understand/design-choices-shape.md) · [Gates](understand/design-choices-gates.md) · [Hosts](understand/design-choices-hosts.md) · [Install, uninstall, update](understand/design-choices-install.md) · [Engineering, skills and agents](understand/design-choices-engineering.md) · [Considered and refused](design-history.md#considered-and-refused).
16
+
15
17
  ## 2 · Guides
16
18
 
17
19
  | Section | Guide | When |