geneseed 4.4.1 → 4.5.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 CHANGED
@@ -8,6 +8,24 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [4.5.0] — 2026-10-17
12
+
13
+ Minor: gates that are proven, not just present — a done gate at turn end, `doctor --probe`, and Bob's refusals restored on Windows.
14
+
15
+ ### Upgrade notes
16
+ - Run `geneseed rebuild-all` once after updating: it rewrites the Windows hook shim (Bob's gates block again) and re-emits the rule skill.
17
+ - The done gate is off until a project lists its checks in `.geneseed/done-checks.txt`.
18
+
19
+ ### Added
20
+ - **Done gate (#289).** A project that lists its checks in `.geneseed/done-checks.txt` gets *Verify Before Asserting* enforced when a turn ends: on Claude Code and OpenClaude, a turn that edited files cannot end until every listed check has passed, unpiped, after the last edit (at most one block per turn). OpenCode 1 gets a reminder on the next request; Bob and OpenCode 2 are not judged and log so. The gate lives inside `learn`, before its model call, so the emitted settings are unchanged. The list protects itself through *External Gate*.
21
+ - **`geneseed doctor --probe`.** This is opt-in. For each live Claude Code, OpenClaude and Bob install, it runs the installed gate command exactly as the host's settings spell it, through the host's own shell. It sends `git reset --hard HEAD`, which the gate must refuse, and `git status`, which it must let through. A gate that allows both, refuses both, or does not run is a doctor failure. The probe runs under your terminal's environment, not the host app's. Its asks are kept out of the gate ledger (`GENESEED_GATE_PROBE`). The OpenCode guard plugin is probed in-process: the installed file is imported and its hook called through both plugin APIs, with `rm -rf /` as the call it must refuse, and `GENESEED_GUARD=off|warn` reported as a note. The PowerShell-fallback warning now suggests running the probe (#290).
22
+
23
+ ### Changed
24
+ - **A stated rule keeps the user's own words.** The rule skill now writes `source: user <date> "<their sentence>"` for a rule the user stated, so a stale rule can be told from a changed mind. Its review flow gains two cases: a rule the user's later words contradict, and a rule only checked by reading that was broken twice (#288).
25
+
26
+ ### Fixed
27
+ - **Bob gates now block on Windows.** The hook shim (`~/.geneseed/bin/geneseed-hook.cmd`) ended with a bare `exit /b`, which returns 0 under `cmd /c` whatever the gate exited. As a result, Bob's exit-2 refusals (Laws I and IV, protected checks, root scans) went through. The shim now ends with `exit /b %ERRORLEVEL%`. Claude Code and OpenClaude were not affected, because their gates answer through stdout and exit 0. Run `geneseed rebuild-all` once to rewrite the shim (#290).
28
+
11
29
  ## [4.4.1] — 2026-10-16
12
30
 
13
31
  A presentation skill.
package/SHIPPED.md CHANGED
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
20
20
  > **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
21
21
  > human-readable label only. The canonical identity of an installed harness is the
22
22
  > **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
23
- > string. Current label: **4.4.1**.
23
+ > string. Current label: **4.5.0**.
24
24
 
25
25
  ## Capabilities
26
26
 
@@ -877,6 +877,76 @@ async function alreadyInjected(client, sid) {
877
877
  return false
878
878
  }
879
879
 
880
+ // ---- The done gate (#289): the OpenCode twin of js/hosts/hooks-done.mjs ----------------
881
+ // OpenCode has no turn-end event a plugin can block on, so its ceiling is a reminder: on the
882
+ // request AFTER a turn that edited files without the project's declared checks passing
883
+ // (`.geneseed/done-checks.txt`), one invisible user message says which checks are owed. Only V1
884
+ // (`messages.transform`, whose tool parts carry `state.status` and the shell's
885
+ // `metadata.exit`); OpenCode 2's message shape for tool calls is unverified, so V2 is not
886
+ // judged and says so once. Fails open on every shape it does not recognise (ADR 0007).
887
+ // `runsUnpiped` is a hand-mirrored twin; tests/plugins/context_done.test.mjs runs both on one table.
888
+ const DONE_LIST = ".geneseed/done-checks.txt"
889
+ const DONE_EDIT_TOOLS = new Set(["edit", "write", "patch", "apply_patch", "multiedit"])
890
+ const DONE_SHELL_TOOLS = new Set(["bash", "shell"])
891
+
892
+ async function readDoneChecks(root) {
893
+ try {
894
+ const text = await fs.readFile(path.join(root, DONE_LIST), "utf8")
895
+ return text.split(/\r?\n/).map((l) => l.trim().replace(/\s+/g, " ")).filter((l) => l && !l.startsWith("#"))
896
+ } catch {
897
+ return []
898
+ }
899
+ }
900
+
901
+ function runsUnpiped(command, line) {
902
+ const cmd = String(command).replace(/[ \t]+/g, " ")
903
+ for (let i = cmd.indexOf(line); i !== -1; i = cmd.indexOf(line, i + 1)) {
904
+ const rest = cmd.slice(i + line.length)
905
+ let piped = false
906
+ for (let j = 0; j < rest.length; j += 1) {
907
+ const two = rest.slice(j, j + 2)
908
+ if (two === "&&" || two === "||" || rest[j] === ";" || rest[j] === "\n") break
909
+ if (rest[j] === "|") { piped = true; break }
910
+ }
911
+ if (!piped) return true
912
+ }
913
+ return false
914
+ }
915
+
916
+ // The checks still owed, judged over the previous turn and the current one: a reminder lasts
917
+ // one turn, not the whole session. Our own injected messages (`geneseed-*` ids) open no turn.
918
+ function doneOwed(messages, checks, root) {
919
+ const prompts = []
920
+ messages.forEach((m, i) => {
921
+ if (m?.info?.role === "user" && !String(m.info.id ?? "").startsWith("geneseed-")) prompts.push(i)
922
+ })
923
+ const from = prompts.length >= 2 ? prompts[prompts.length - 2] : 0
924
+ const tools = []
925
+ for (const m of messages.slice(from)) {
926
+ for (const p of m?.parts ?? []) if (p?.type === "tool" && p.state) tools.push(p)
927
+ }
928
+ let lastEdit = -1
929
+ tools.forEach((p, i) => {
930
+ if (!DONE_EDIT_TOOLS.has(String(p.tool).toLowerCase()) || p.state.status === "error") return
931
+ const fp = p.state.input?.filePath ?? p.state.input?.file_path
932
+ if (typeof fp === "string" && fp) {
933
+ const rel = path.relative(root, path.resolve(root, fp))
934
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return
935
+ }
936
+ lastEdit = i
937
+ })
938
+ if (lastEdit === -1) return []
939
+ const passed = new Set()
940
+ for (const p of tools.slice(lastEdit + 1)) {
941
+ const command = DONE_SHELL_TOOLS.has(String(p.tool).toLowerCase()) && p.state.input?.command
942
+ if (typeof command !== "string" || p.state.status !== "completed") continue
943
+ const exit = p.state.metadata?.exit
944
+ if (typeof exit === "number" && exit !== 0) continue
945
+ for (const check of checks) if (runsUnpiped(command, check)) passed.add(check)
946
+ }
947
+ return checks.filter((c) => !passed.has(c))
948
+ }
949
+
880
950
  export const GeneseedContext = async (ctx) => {
881
951
  const { client } = ctx
882
952
  const done = new Set()
@@ -1002,6 +1072,27 @@ export const GeneseedContext = async (ctx) => {
1002
1072
  // detects that and takes over with the visible delivery.
1003
1073
  "experimental.chat.messages.transform": async (_input, output) => {
1004
1074
  transformSeen = true // the hook exists on this build
1075
+ // The done gate's reminder: FIRST, and independent of the context block:
1076
+ // INJECT_OFF and the visible delivery leave it on, and no early return below can skip it.
1077
+ // Its own function, so its early returns end the reminder, never the context injection.
1078
+ await (async () => {
1079
+ if (!output || !Array.isArray(output.messages) || !output.messages.length) return
1080
+ const sid = output.messages[0]?.info?.sessionID
1081
+ if (await sovereignBypass(root) || await skipSession(sid)) return
1082
+ const checks = await readDoneChecks(root)
1083
+ if (!checks.length) return
1084
+ const owed = doneOwed(output.messages, checks, root)
1085
+ if (!owed.length) return
1086
+ const stamp = Date.now()
1087
+ output.messages.unshift({
1088
+ info: { id: `geneseed-done-${stamp}`, role: "user", sessionID: sid, time: { created: stamp } },
1089
+ parts: [{ id: `geneseed-done-part-${stamp}`, type: "text", text:
1090
+ "Geneseed (Verify Before Asserting) — files were edited, and these checks from "
1091
+ + `${DONE_LIST} have not passed since the last edit: ${owed.join("; ")}. Run them unpiped `
1092
+ + "before reporting the work done, or say plainly that they were not run and why." }],
1093
+ })
1094
+ log("transform: done-gate reminder injected")
1095
+ })().catch((err) => log(`done-gate error: ${err?.message ?? err}`))
1005
1096
  if (INJECT_OFF || !TRANSFORM || fallbackVisible) return
1006
1097
  if (await sovereignBypass(root)) return
1007
1098
  try {
@@ -1072,6 +1163,11 @@ async function setupV2(ctx) {
1072
1163
  }
1073
1164
  }
1074
1165
 
1166
+ // The done gate is not judged on OpenCode 2 (see `doneOwed`): said once, where its log goes.
1167
+ if ((await readDoneChecks(root).catch(() => [])).length) {
1168
+ console.error(`[geneseed-context] done gate: ${DONE_LIST} found, but OpenCode 2 turns are not judged.`)
1169
+ }
1170
+
1075
1171
  if (!VISIBLE) {
1076
1172
  await ctx.session.hook("context", async (event) => {
1077
1173
  if (INJECT_OFF) return
@@ -128,6 +128,9 @@ function log(msg) { console.error(`[geneseed-guard] ${msg}`) }
128
128
  // at the newest MAX_LEDGER_LINES like the hook's; never throws, never decides.
129
129
  const MAX_LEDGER_LINES = 1000
130
130
  async function ledger(rule) {
131
+ // `geneseed doctor --probe` blocks on purpose; its blocks are not the user's (the twin of
132
+ // the same line in js/hosts/hooks.mjs). The record only — never the decision.
133
+ if (process.env.GENESEED_GATE_PROBE) return
131
134
  try {
132
135
  const bases = []
133
136
  if (process.env.GENESEED_HARNESS) bases.push(process.env.GENESEED_HARNESS)
@@ -545,6 +548,9 @@ function ruleStoreTarget(p) {
545
548
  // line; the list protects itself. Found from the WRITE TARGET up to the first `.git`, re-read on
546
549
  // every write (a short file, read only on a write call). No ask tier here, so a hit is a deny.
547
550
  const SENSOR_LIST = ".geneseed/protected-checks.txt"
551
+ // The done gate's list (#289, js/hosts/hooks-done.mjs) protects itself the same way, with or
552
+ // without a protected-checks list: emptying it would disarm the gate. Twin of js/hosts/hooks.mjs.
553
+ const DONE_LIST = ".geneseed/done-checks.txt"
548
554
  function protectedCheck(p) {
549
555
  try {
550
556
  const target = path.resolve(p)
@@ -554,9 +560,10 @@ function protectedCheck(p) {
554
560
  if (up === root) return null
555
561
  root = up
556
562
  }
557
- const text = readFileSync(path.join(root, SENSOR_LIST), "utf8")
558
563
  const fold = (s) => nocase(s.replace(/\\/g, "/"))
559
564
  const rel = fold(path.relative(root, target))
565
+ if (rel === fold(DONE_LIST)) return DONE_LIST
566
+ const text = readFileSync(path.join(root, SENSOR_LIST), "utf8")
560
567
  const entries = [SENSOR_LIST, ...text.split(/\r?\n/).map((l) => l.trim().replace(/[\\/]+$/, ""))
561
568
  .filter((l) => l && !l.startsWith("#"))]
562
569
  return entries.find((e) => rel === fold(e) || rel.startsWith(fold(e) + "/")) ?? null
@@ -635,7 +642,7 @@ export const GeneseedGuard = async (ctx) => {
635
642
  if (SECRET_RE.some((re) => re.test(p))) { await deny(`write to secret/key file ${p} (Sealed Secrets)`, "law-1"); return }
636
643
  if (SECRET_WARN_RE.some((re) => re.test(p))) log(`WARN: writing ${p} — keep secrets out of tracked files (Sealed Secrets)`)
637
644
  const sensor = protectedCheck(p)
638
- 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 }
645
+ if (sensor) { await deny(`${p} is a protected check (${sensor === DONE_LIST ? "the done gate list itself" : `${sensor}, listed in ${SENSOR_LIST}`}) — a check the agent can edit is not a check (External Gate)`, "rigor-5"); return }
639
646
  const store = ruleStoreTarget(p)
640
647
  if (store && !RULE_STORE_BUMPED.has(p)) {
641
648
  RULE_STORE_BUMPED.add(p)
@@ -13,8 +13,8 @@ few rules are enforced; the rest are asked on every host, through the root rules
13
13
 
14
14
  Derived from the code, not from this project's own planning notes: `js/hosts/hooks.mjs`'s
15
15
  `BLOCK_RULES` and gate functions, `HOOK_PINNED` in `js/inspect/checks-constitution.mjs`,
16
- `LAW_IV_BASH` and `CONSENT_RULE` in `js/hosts/settings.mjs`, and
17
- `adapters/opencode/plugins/geneseed-guard.js`. `HOOK_PINNED` is what keeps this table from
16
+ `LAW_IV_BASH` and `CONSENT_RULE` in `js/hosts/settings.mjs`, `js/hosts/hooks-done.mjs`, and
17
+ `adapters/opencode/plugins/geneseed-guard.js` and `geneseed-context.js`. `HOOK_PINNED` is what keeps this table from
18
18
  silently guarding the wrong rule after a renumber. See [ADR 0005](../adr/0005-stable-rule-ids.md).
19
19
 
20
20
  ### The enforced rules
@@ -26,6 +26,7 @@ silently guarding the wrong rule after a renumber. See [ADR 0005](../adr/0005-st
26
26
  | `persist-insight` | process | ask: rule-gate (a write to `user-rules.md` or the memory store) | same as Claude Code | ask once: guard plugin (refuses the *first* write to that path; a re-issued write goes through) | log only (warning, call goes through) |
27
27
  | `consent-before-push` | process | ask: git-gate (every `git commit`/`git push`). Togglable: build without the process pack, or drop this rule alone with `--exclude-rules` (see [Exclusions](exclusions.md)). Only this question drops; the `deletion-is-deliberate` gate stays. | same as Claude Code | ask: permission prompt (`git commit*`/`git push*`), wired only when the process pack is on; a `loop/*` push is statically allowed | log only |
28
28
  | `external-gate` | rigor | ask: rule-gate (a write under a project's `.geneseed/protected-checks.txt` list) | same as Claude Code | block: guard plugin (same list) | block (exit 2) |
29
+ | `verify-before-asserting` | law | block at Stop, once per turn: the done gate in `learn` (a turn that edited files, while a check in the project's `.geneseed/done-checks.txt` has not passed since the last edit). Opt-in per project | same as Claude Code (**unverified live**: a frozen fork, and no check runs a model turn) | remind: context plugin, on the next request (OpenCode 1 only; OpenCode 2 is not judged, and logs so) | not judged: Stop carries no transcript (log line) |
29
30
  | `commands-must-return` | ops | ask: git-gate (a recursive scan rooted at a filesystem root: `find /`, `du`, `grep -r`, `rg`, `Get-ChildItem -Recurse` at `/`, `/c`, `C:\`) | same as Claude Code | block: guard plugin (same root-scan pattern) | block (exit 2) |
30
31
 
31
32
  Bob has no "ask" tier (see [ADR 0007](../adr/0007-gates-fail-closed-per-host-ceiling.md)), so a
@@ -37,12 +38,17 @@ mechanisms: its own permission prompt (`opencode.json`, static globs) and the gu
37
38
  See [ADR 0008](../adr/0008-host-parity-ceiling-of-the-weakest-host.md) for why a gate that only
38
39
  one host can run still ships for every host it can.
39
40
 
41
+ A gate that is registered but broken looks exactly like a working one. `geneseed doctor --probe`
42
+ runs the installed Claude Code, OpenClaude and Bob gates through each host's own shell, and calls
43
+ the OpenCode guard plugin in-process, on one call each must refuse and one it must let through
44
+ ([Doctor findings](../reference/troubleshoot-doctor.md)).
45
+
40
46
  ### Everything else: asked only, on every host
41
47
 
42
48
  Every other rule carries no gate on any host. A human reads them because the model was told to
43
49
  follow them, not because a tool call was ever inspected.
44
50
 
45
- **Laws** — `one-intent-one-act` · `verify-before-asserting` · `surface-failures` ·
51
+ **Laws** — `one-intent-one-act` · `verify-before-asserting` (in a project without a done list) · `surface-failures` ·
46
52
  `data-not-orders` · `least-privilege` · `cure-the-cause` · `echo-the-intent`.
47
53
 
48
54
  **Doctrine: process** — `plan-before-acting` · `context-economy` · `read-the-docs-first` ·
@@ -37,8 +37,11 @@ Geneseed writes its hooks into the host's settings file. Every hook calls one pr
37
37
  - Content that looks like a credential (a cloud access key, a GitHub or Anthropic token, a Slack token, a private-key block) going into any file other than `.env*` gets a prompt citing *Sealed Secrets*.
38
38
  - A write to `user-rules.md`, `MEMORY.md` or a file in the install's `memory/` gets a prompt citing *Persist Insight*: whether something is a standing rule or a fact to remember is your call, settled through the `rule` [skill](skills.md).
39
39
  - A write to a check the project protects gets a prompt citing *External Gate* (on Bob, it is blocked). A project opts in with `.geneseed/protected-checks.txt` at its git root: one repo-relative file or folder per line, `#` for comments — the checks and tests the agent is judged by. A check the agent can edit is not a check. The list protects itself. Only file-write tools are seen: a shell `sed -i` or redirect is not, so run the same checks in CI as the boundary that holds.
40
+ - A write to `.geneseed/done-checks.txt`, the done gate's own list (below), gets the same *External Gate* prompt: emptying it would disarm the gate.
40
41
  - Ordinary edits pass untouched.
41
42
 
43
+ **The done gate.** Runs when a turn ends, inside `learn` and before it calls any model. A project opts in with `.geneseed/done-checks.txt` at its git root: one command per line, `#` for comments — the checks that prove the work, for example `npm run lint` and `node --test`. If the turn edited a file under that root and a listed check has not passed since the last edit, the turn does not end: the agent is told which checks are owed, citing *Verify Before Asserting*. A check counts when it ran as a shell command, unpiped (`npm test | tail` exits with `tail`'s code, so it proves nothing), and its result was not an error. The gate never runs a check itself. It blocks at most once per turn; the second stop ends the turn and leaves a line in the gate ledger. Only file-write tools count as edits: a shell `sed -i` or redirect is not seen. Without the file, nothing changes. On OpenCode the gate is a reminder on the next request (OpenCode 1 only); on Bob, whose Stop carries no transcript, the turn is not judged and a log line says so.
44
+
42
45
  **learn.** Takes the tail of the transcript and asks a model to distil what is worth keeping into one-fact-per-file entries under `memory/`. It is shown the facts already stored, so it updates one with the merged text rather than writing a twin (the old version is kept in `memory/archive/`), and moves one the session proved obsolete into `memory/archive/`. It needs `GENESEED_LLM` set to a model command, for example:
43
46
 
44
47
  ```bash
package/docs/extending.md CHANGED
@@ -125,7 +125,7 @@ Notes that matter:
125
125
 
126
126
  ### The gates CI does not run
127
127
 
128
- - **The mutation matrix itself.** `tests/mutate.mjs` — 33 rows, each a one-string edit to a
128
+ - **The mutation matrix itself.** `tests/mutate.mjs` — 35 rows, each a one-string edit to a
129
129
  product file with a written argument for which gate should catch it. The full run is **in no
130
130
  workflow** and should not be: it mutates the product tree 33 times and runs a gate per edit.
131
131
  Run `node tests/mutate.mjs --all` (or `--only M7`) by hand when you change something a gate is
@@ -17,6 +17,8 @@ One of the [OpenCode plugins](opencode-plugins.md). Its switches are also listed
17
17
 
18
18
  **When it runs.** On every request: the context rides invisibly in the request's message list, so it also survives compaction. If your OpenCode build lacks that (experimental) hook, the plugin notices and falls back to one visible message at session start, re-pushed when the session compacts.
19
19
 
20
+ **The done gate's reminder.** In a project with `.geneseed/done-checks.txt` (see [Hooks](../concepts/hooks.md)), a request that follows a turn which edited files without every listed check passing since gets one more invisible message naming the checks owed. OpenCode has no turn-end event a plugin can block on, so this is a reminder, not a block. OpenCode 1 only: on OpenCode 2 the turn is not judged, and the plugin logs that once. `GENESEED_CONTEXT_INJECT=off` does not turn it off; removing the file does.
21
+
20
22
  **What you see.** Normally nothing — the context is there, but not shown. With the visible fallback, a `PROJECT CONTEXT` block at the top of the session.
21
23
 
22
24
  **Switches.**
@@ -35,6 +35,37 @@ If the drift is your own edits, export them first — `geneseed diff` (or the co
35
35
 
36
36
  **Confirm.** `geneseed doctor` no longer reports the bundle.
37
37
 
38
+ ## `[probe] … registered but not refusing` / `refuses everything` / `does not run`
39
+
40
+ > `[probe] ~/.claude: git-gate under Git Bash registered but not refusing — …`
41
+
42
+ These come only from `geneseed doctor --probe`. It runs each installed gate exactly as the host's
43
+ settings spell it, through the host's own shell, on `git reset --hard HEAD` (which must be refused)
44
+ and `git status` (which must go through). An OpenCode install's guard plugin has no command to
45
+ run: the probe imports it and calls its hook through both plugin APIs (V1 and V2), with `rm -rf /`
46
+ as the call it must refuse. `GENESEED_GUARD=off` or `warn` in your terminal makes that a note,
47
+ not a failure.
48
+
49
+ **Cause.**
50
+ - **Not refusing:** the gate launches but lets a destructive call through. Usually something in
51
+ the install tells it to stand down: an exclusion covering the temp folder, or a command that
52
+ lost its `--host bob`.
53
+ - **Refuses everything:** the gate asks on every call. This is typically the hooks emitted for
54
+ one shell and run under another.
55
+ - **Does not run:** the shim or its runner is gone, or the shell is missing.
56
+
57
+ **Fix.**
58
+
59
+ ```bash
60
+ geneseed rebuild-all
61
+ ```
62
+
63
+ **Confirm.** `geneseed doctor --probe` prints `[probe] N gate(s) refused a destructive call and let
64
+ a safe one through`.
65
+
66
+ The probe runs under *this terminal's* environment (PATH, HOME), not the host app's. A host started
67
+ with a different environment can still break a gate that passes here.
68
+
38
69
  ## `registry.json unreadable` / `registry.json is not valid JSON`
39
70
 
40
71
  > `[authoring] registry.json unreadable: …`
@@ -27,6 +27,7 @@ On Claude Code and OpenClaude the hooks never block silently. They make the host
27
27
  - **A scan of a whole filesystem**: a recursive `find`, `du`, `grep -r`, `rg` or `Get-ChildItem -Recurse` rooted at `/`, `/c` or `C:\`, which in Git Bash walks every drive for hours. Wired in every build.
28
28
  - **Credentials**: a write or edit whose content looks like a real key (AWS, GitHub, Anthropic, Slack, or a private-key block), anywhere except a `.env` file.
29
29
  - **Your rules and memory**: any write by the agent to `user-rules.md` or to the memory store. Whether something is a standing rule or a fact to remember is your call.
30
+ - **Ending a turn without the proof**: only in a project that lists its checks in `.geneseed/done-checks.txt`. A turn that edited files cannot end until every listed check has passed since the last edit. The agent is stopped once, then the turn may end.
30
31
 
31
32
  If a gate crashes, it asks instead of letting the call through. If it cannot start at all (a deleted or broken shim) or times out, Claude Code blocks the call instead: see [Troubleshoot hooks](../reference/troubleshoot-hooks.md) to recover. Other shell commands, `rm -rf` included, go through Claude Code's normal permission flow.
32
33
 
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "4.4.1"
5
+ "version": "4.5.0"
6
6
  }
package/js/README.md CHANGED
@@ -71,6 +71,7 @@ user co-owns, and the hook verbs those emitted configs then run.
71
71
  | `hosts.mjs` | The four host config dirs (opencode/claude/bob/openclaude), their `family` (`CLAUDE_STYLE`), plus `resolvePath`/`expanduser` |
72
72
  | `hooks.mjs` | The gate verbs, run on every tool call: `git-gate`, `rule-gate`, `tool-gate` (the two gates fused, for Bob), plus `ask`/the gate ledger/`guardGate`. `--host` picks the verdict dialect |
73
73
  | `hooks-context.mjs` | The `context` verb — SessionStart: session files plus the repo's context docs |
74
+ | `hooks-done.mjs` | The done gate (#289): on Stop, inside `learn` and before its spawn, blocks a turn that edited files until `.geneseed/done-checks.txt`'s checks passed after the last edit |
74
75
  | `hooks-learn.mjs` | The `learn` verb — Stop/SubagentStop/PreCompact distiller; the hook path's ONE spawn (`$GENESEED_LLM`) |
75
76
  | `hooks-prims.mjs` | The Python path/text primitives the hook verbs share (`splitLines`, `readStdin`, a never-throwing `isFile`) |
76
77
  | `usage.mjs` | The usage counter `tool-gate` runs: one `{day, kind, name}` line per Skill or subagent call into `notebook/usage.jsonl`. Hook path: builtins only, silent, never throws |
@@ -101,7 +102,8 @@ Everything that inspects an already-built or already-deployed harness and report
101
102
  | `doctor.mjs` | The `geneseed doctor` **driver** only: picks themes, runs the checks, prints the verdict |
102
103
  | `checks-build.mjs` | Gates over what the build PRODUCED: links, tokens, theme parity, OpenCode colours, bundle drift |
103
104
  | `checks-repo.mjs` | Gates over the repo's own records: `registry.json`, committed secrets, the hook shim, vendored pins |
104
- | `checks-authoring.mjs` | `authoringProblems`, the aggregate self-consistency gate: spec purpose lines, the learn-prompt literal, `node --check` over the plugins (the doctor's one spawn), then the groups below |
105
+ | `checks-authoring.mjs` | `authoringProblems`, the aggregate self-consistency gate: spec purpose lines, the learn-prompt literal, `node --check` over the plugins (the doctor's one default spawn), then the groups below |
106
+ | `checks-probe.mjs` | `doctor --probe` (opt-in): runs each live install's own gate command through the host's shell on a must-refuse and a must-pass payload — the doctor's second spawn; the OpenCode guard plugin is imported and called in-process instead, through both plugin APIs |
105
107
  | `checks-constitution.mjs` | The constitution: rule declarations and citations, LEAN blocks, `HOOK_PINNED`, theme vocabulary, the console's `LAW_META`/`DOCTRINE_META` |
106
108
  | `checks-counts.mjs` | The hand-written counts: AGENT.md tables, README badges and prose, `SHIPPED.md`, held against `src/` |
107
109
  | `scan.mjs` | The shared walk/sort/strip/test primitives every `checks-*` uses. Owns no judgement itself |
package/js/cli-table.json CHANGED
@@ -473,6 +473,21 @@
473
473
  "is_flag": true,
474
474
  "type": null,
475
475
  "hidden": false
476
+ },
477
+ {
478
+ "names": [
479
+ "--probe"
480
+ ],
481
+ "dest": "probe",
482
+ "metavar": null,
483
+ "help": "also run each installed gate on a destructive and a safe payload: it must refuse the first and let the second through. Claude Code, OpenClaude and Bob gates run as their settings spell them, through the host's shell; the OpenCode guard plugin is imported and called in-process (runs under this terminal's environment, not the host app's)",
484
+ "choices": null,
485
+ "default": false,
486
+ "required": false,
487
+ "nargs": "0",
488
+ "is_flag": true,
489
+ "type": null,
490
+ "hidden": false
476
491
  }
477
492
  ],
478
493
  "mutex": []
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The done gate — Verify Before Asserting's boundary half, at the moment a turn ends (#289).
3
+ *
4
+ * Every other gate sits before an act. This one sits at Stop: a turn that edited files under a
5
+ * project which lists its proof in `.geneseed/done-checks.txt` may not end until every listed
6
+ * check has run AND passed after the last edit. The gate never runs a check itself — the hook
7
+ * path starts no process of its own (ADR 0002) — it reads the turn's own tool calls in the transcript.
8
+ *
9
+ * WHY IT RIDES `learn` AND NOT A VERB OR A FLAG OF ITS OWN (ADR 0010). The machine-wide shim is
10
+ * last-writer-wins: an older `geneseed-hook` would refuse a new verb or flag, and the Stop
11
+ * command's `|| exit 0` would then skip learning too, silently. Arming on a project FILE keeps
12
+ * every emitted settings file byte-identical, so a stale shim only means "no done gate".
13
+ *
14
+ * WHY STDOUT JSON AND NOT EXIT 2. The emitted Stop command ends in `|| exit 0`, which swallows
15
+ * a 2. `{"decision":"block"}` on exit 0 is the one signal that survives the existing wiring.
16
+ * Allowing prints NOTHING — on Stop a stray stdout byte is read as a verdict.
17
+ *
18
+ * WHAT COUNTS (shortcut: write tools only; a shell `sed -i` or redirect is not an edit here —
19
+ * seeing those would need `git status`, a spawn. Same limit as `protected-checks.txt`):
20
+ * - an edit is a Write/Edit/NotebookEdit/MultiEdit whose target is under the project root and
21
+ * whose result is not an error (a denied write changed nothing);
22
+ * - a pass is a Bash/PowerShell call whose command contains the declared line UNPIPED (a pipe
23
+ * takes the exit code of its last stage: `npm test | tail` exits 0 on a red suite) and whose
24
+ * result is not `is_error` — every non-zero exit, denial and timeout carries it (checked on
25
+ * 14 000+ real Claude Code results, 2026-10-17).
26
+ *
27
+ * At most one block per turn: `stop_hook_active` is true when this Stop follows a block, and the
28
+ * gate then lets the turn end and leaves a ledger line, so a gate that cries wolf gets found.
29
+ */
30
+ import { existsSync } from 'node:fs';
31
+ import path from 'node:path';
32
+ import { readText, printOut as out, printErr as err } from '../lib/fs.mjs';
33
+ import { within } from '../lib/paths.mjs';
34
+ import { gitRootOf } from './gitref.mjs';
35
+ import { DONE_LIST, recordGate } from './hooks.mjs';
36
+ import { splitLines } from './hooks-prims.mjs';
37
+
38
+ const EDIT_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'MultiEdit']);
39
+ const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
40
+
41
+ /** The declared checks, one per line, `#` comments; null when the project has no list. */
42
+ export function readDoneChecks(root) {
43
+ const file = path.join(root, DONE_LIST);
44
+ if (!existsSync(file)) return null;
45
+ try {
46
+ return splitLines(readText(file)).map((l) => l.trim().replace(/\s+/g, ' '))
47
+ .filter((l) => l && !l.startsWith('#'));
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * True when `command` runs `line` with no pipe after it in the same segment. A segment ends at
55
+ * `&&`, `||`, `;` or a newline; a single `|` before that end is a pipe. Whitespace is collapsed
56
+ * on both sides. shortcut: quotes are not parsed — a `|` inside a quoted argument after the
57
+ * check reads as a pipe, which only ever refuses a pass, never grants one.
58
+ */
59
+ export function runsUnpiped(command, line) {
60
+ const cmd = String(command).replace(/[ \t]+/g, ' ');
61
+ for (let i = cmd.indexOf(line); i !== -1; i = cmd.indexOf(line, i + 1)) {
62
+ const rest = cmd.slice(i + line.length);
63
+ let piped = false;
64
+ for (let j = 0; j < rest.length; j += 1) {
65
+ const two = rest.slice(j, j + 2);
66
+ if (two === '&&' || two === '||' || rest[j] === ';' || rest[j] === '\n') break;
67
+ if (rest[j] === '|') { piped = true; break; }
68
+ }
69
+ if (!piped) return true;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ /** A user line that opens a turn: the user typed it, it is not a tool result or a meta note. */
75
+ function isPrompt(obj) {
76
+ if (obj.type !== 'user' || obj.isMeta || obj.isSidechain) return false;
77
+ const c = obj.message && obj.message.content;
78
+ if (typeof c === 'string') return true;
79
+ return Array.isArray(c) && c.length > 0 && !c.some((b) => b && b.type === 'tool_result');
80
+ }
81
+
82
+ /**
83
+ * The decision, over transcript TEXT: the checks that have not passed since the turn's last
84
+ * edit, or [] when the turn may end (no edit, or every check passed after it). Exported for the
85
+ * unit tier — a transcript is text no CLI cell can vary cheaply.
86
+ */
87
+ export function doneDecide(transcript, checks, root, cwd = root) {
88
+ const entries = [];
89
+ for (const line of splitLines(transcript)) {
90
+ if (!line.trim()) continue;
91
+ try { entries.push(JSON.parse(line)); } catch { /* a torn last line is not evidence */ }
92
+ }
93
+ let start = 0;
94
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
95
+ if (isPrompt(entries[i])) { start = i + 1; break; }
96
+ }
97
+ const calls = []; // tool_use blocks, in order
98
+ const results = new Map(); // tool_use_id -> is_error
99
+ for (const obj of entries.slice(start)) {
100
+ if (obj.isSidechain) continue;
101
+ const content = obj.message && obj.message.content;
102
+ if (!Array.isArray(content)) continue;
103
+ for (const b of content) {
104
+ if (!b || typeof b !== 'object') continue;
105
+ if (b.type === 'tool_use') calls.push(b);
106
+ else if (b.type === 'tool_result') results.set(b.tool_use_id, b.is_error === true);
107
+ }
108
+ }
109
+ let lastEdit = -1;
110
+ calls.forEach((c, i) => {
111
+ if (!EDIT_TOOLS.has(c.name) || results.get(c.id) === true) return;
112
+ const p = c.input && (c.input.file_path || c.input.notebook_path || c.input.path);
113
+ if (typeof p === 'string' && p && within(path.resolve(cwd, p), root)) lastEdit = i;
114
+ });
115
+ if (lastEdit === -1) return [];
116
+ const passed = new Set();
117
+ calls.slice(lastEdit + 1).forEach((c) => {
118
+ const command = SHELL_TOOLS.has(c.name) && c.input && c.input.command;
119
+ // A call with no result yet (the host has not flushed it) is not a pass.
120
+ if (typeof command !== 'string' || results.get(c.id) !== false) return;
121
+ for (const check of checks) if (runsUnpiped(command, check)) passed.add(check);
122
+ });
123
+ return checks.filter((c) => !passed.has(c));
124
+ }
125
+
126
+ /**
127
+ * The Stop-side wrapper `cmdLearn` calls first. Returns true when it printed a block (the turn
128
+ * goes on, so `learn` must not distil yet); false in every other case, with nothing on stdout.
129
+ */
130
+ export function doneGate(meta, args) {
131
+ try {
132
+ const cwd = typeof meta.cwd === 'string' && meta.cwd ? meta.cwd : process.cwd();
133
+ const root = gitRootOf(cwd);
134
+ const checks = root ? readDoneChecks(root) : null;
135
+ if (!checks || !checks.length) return false;
136
+ // Bob's Stop payload carries no transcript (learn's I2): nothing to judge, and it says so.
137
+ if (args.host === 'bob' || typeof meta.transcript_path !== 'string') {
138
+ err(`[done-gate] no transcript${args.host === 'bob' ? ' (bob)' : ''} — turn not judged.\n`);
139
+ return false;
140
+ }
141
+ let transcript;
142
+ try {
143
+ transcript = readText(meta.transcript_path);
144
+ } catch {
145
+ // No ask tier on Stop: blocking on an unreadable transcript would trap the user.
146
+ err('[done-gate] transcript unreadable — turn not judged.\n');
147
+ return false;
148
+ }
149
+ const missing = doneDecide(transcript, checks, root, cwd);
150
+ if (!missing.length) return false;
151
+ const install = args.memory ? path.dirname(args.memory) : null;
152
+ if (meta.stop_hook_active === true) {
153
+ recordGate(install, 'done-gate-cap', 'law-3');
154
+ err(`[done-gate] cap reached — turn ends with unproven checks: ${missing.join('; ')}\n`);
155
+ return false;
156
+ }
157
+ recordGate(install, 'done-gate', 'law-3');
158
+ out(`${JSON.stringify({
159
+ decision: 'block',
160
+ reason: 'Geneseed (Verify Before Asserting) — this turn edited files, and these '
161
+ + `checks from ${DONE_LIST} have not passed since the last edit: ${missing.join('; ')}. `
162
+ + 'Run them unpiped, or say plainly that they were not run and why.',
163
+ })}\n`);
164
+ return true;
165
+ } catch (e) {
166
+ err(`[done-gate] skipped: ${e && e.message}\n`);
167
+ return false;
168
+ }
169
+ }
@@ -13,6 +13,7 @@ import {
13
13
  globalHookStandingDown, hookProjectDir, resolveMemoryDir, sovereignBypass,
14
14
  } from './hosts.mjs';
15
15
  import { readStdin, splitLines, splitWords } from './hooks-prims.mjs';
16
+ import { doneGate } from './hooks-done.mjs';
16
17
  import {
17
18
  existingSlugs, readFacts, writeMemories, consolidateMemory, appendAgentLesson,
18
19
  } from './memory-files.mjs';
@@ -309,6 +310,10 @@ export function cmdLearn(args) {
309
310
  const hookEvent = typeof meta.hook_event_name === 'string' ? meta.hook_event_name
310
311
  : (typeof meta.event === 'string' ? meta.event : null);
311
312
 
313
+ // The done gate (#289) judges the turn BEFORE anything here spawns, and only on Stop. A block
314
+ // means the turn goes on, so there is nothing to distil yet: the next Stop will.
315
+ if (hookEvent === 'Stop' && !args.file && doneGate(meta, args)) return 0;
316
+
312
317
  // I2 (Bob): its Stop payload never carries `transcript_path`, so there is never a transcript
313
318
  // to distil — and the bare envelope itself is not notes text (that would waste a model call
314
319
  // on `{"event":"Stop","session_id":"s"}`). Scoped to `--host bob`: every other host's Stop/
@@ -255,8 +255,12 @@ const BLOCK_RULES = new Set(['law-1', 'law-4', 'rigor-5', 'ops-2']);
255
255
  * information a trimmed file loses. The re-read happens on the ask path only.
256
256
  */
257
257
  const MAX_LEDGER_LINES = 1000;
258
+ // Exported for the done gate (`./hooks-done.mjs`), which records from Stop, not from `ask`.
259
+ export function recordGate(root, verb, rule) { ledger(root, verb, rule); }
258
260
  function ledger(root, verb, rule) {
259
- if (!root) return;
261
+ // `doctor --probe` asks on purpose; its asks are not the user's. Read here, on the rare ask
262
+ // path only: the variable silences the RECORD, never the decision.
263
+ if (!root || process.env.GENESEED_GATE_PROBE) return;
260
264
  try {
261
265
  const file = path.join(root, GATE_LEDGER);
262
266
  appendFileSync(file, `${JSON.stringify({
@@ -533,11 +537,15 @@ const DOTENV_RE = /(^|[\\/])\.env(\.[^\\/]*)?$/;
533
537
  * not seen here; CI running the checks is the boundary that holds against that.
534
538
  */
535
539
  export const SENSOR_LIST = '.geneseed/protected-checks.txt';
540
+ // The done gate's list (`./hooks-done.mjs`, #289) protects itself too, with or without a
541
+ // protected-checks list: emptying it would disarm the gate it feeds.
542
+ export const DONE_LIST = '.geneseed/done-checks.txt';
536
543
  function protectedCheck(p) {
537
544
  try {
538
545
  const target = resolvePath(p);
539
546
  const root = gitRootOf(path.dirname(target));
540
547
  if (!root) return null;
548
+ if (normcase(path.relative(root, target)) === normcase(DONE_LIST)) return DONE_LIST;
541
549
  const text = readFileSync(path.join(root, SENSOR_LIST), 'utf8');
542
550
  // `normcase` folds case AND slashes on Windows, so both sides go through it, the trailing
543
551
  // separator of the prefix included.
@@ -579,7 +587,8 @@ function ruleDecide(args, payload) {
579
587
  const sensor = protectedCheck(p);
580
588
  if (sensor) {
581
589
  return ask(args, 'rule-gate', 'rigor-5', `Geneseed (External Gate) — ${p} is a protected `
582
- + `check (${sensor}, listed in ${SENSOR_LIST}). A check the agent can edit is not a `
590
+ + `check (${sensor === DONE_LIST ? 'the done gate list itself'
591
+ : `${sensor}, listed in ${SENSOR_LIST}`}). A check the agent can edit is not a `
583
592
  + 'check: fix the code, not the check, unless the user asks for this edit.');
584
593
  }
585
594
  const target = ruleGateTarget(p, args.root);
@@ -763,7 +763,8 @@ export function hookShellProblems(targets = installTargets(), platform = process
763
763
  out.push(was === 'bash'
764
764
  ? `[hooks] ${cfg}: hooks were emitted for Git Bash, which is no longer found, so Claude `
765
765
  + 'Code runs them under PowerShell where context/learn fail open (no effect) and '
766
- + 'git-gate/rule-gate now BLOCK every call instead of gating it - run: geneseed rebuild-all'
766
+ + 'git-gate/rule-gate now BLOCK every call instead of gating it - run: geneseed rebuild-all, '
767
+ + 'then confirm with: geneseed doctor --probe'
767
768
  : `[note] ${cfg}: hooks were emitted for PowerShell, but Git Bash is now found - they work, `
768
769
  + 'and pay PowerShell startup on every call; geneseed rebuild-all switches them back');
769
770
  }
package/js/hosts/shim.mjs CHANGED
@@ -118,14 +118,18 @@ export function shimDead() {
118
118
  */
119
119
  export function hookShimBody(runner, entry, platform = process.platform) {
120
120
  if (platform === 'win32') {
121
- // Bare `exit /b` propagates the LIVE errorlevel; `%ERRORLEVEL%` would expand at parse
122
- // time and return a stale one. Never plain `exit` — that kills the parent cmd.exe, so
121
+ // `exit /b %ERRORLEVEL%`, on its OWN line. A bare `exit /b` was here, on the claim that it
122
+ // propagates the live errorlevel, and that is false under `cmd /d /s /c` — Bob's hook
123
+ // shell, and Node's `shell: true`: the batch returned 0 after the gate exited 2, so every
124
+ // Bob block on Windows went through (#290; measured, see hook_form.test.mjs). `%ERRORLEVEL%`
125
+ // expands when ITS line is parsed, which is after the runner line has finished, so the value
126
+ // is the runner's, not a stale one. Never plain `exit` — that kills the parent cmd.exe, so
123
127
  // the emitted `|| exit 0` would never get to evaluate.
124
128
  return '@echo off\r\n'
125
129
  + 'rem Generated by Geneseed - do not edit. Rewritten on every emit.\r\n'
126
130
  + 'setlocal\r\n'
127
131
  + `"${runner}" "${entry}" %*\r\n`
128
- + 'exit /b\r\n';
132
+ + 'exit /b %ERRORLEVEL%\r\n';
129
133
  }
130
134
  return '#!/bin/sh\n'
131
135
  + '# Generated by Geneseed - do not edit. Rewritten on every emit.\n'
@@ -281,18 +285,27 @@ const GIT_BASH_NAMES = new Set(['bash.exe', 'sh.exe', 'bash', 'sh']);
281
285
  */
282
286
  export function claudeHookShell(env = process.env, platform = process.platform) {
283
287
  if (platform !== 'win32') return 'bash';
288
+ return claudeGitBashPath(env) ? 'bash' : 'powershell';
289
+ }
290
+
291
+ /**
292
+ * The Git Bash `claudeHookShell` finds on Windows, as a path — or null. One rule, two readers:
293
+ * the emit only needs to know WHETHER, `doctor --probe` (`js/inspect/checks-probe.mjs`) needs
294
+ * the file itself, to run a gate through the same shell Claude would.
295
+ */
296
+ export function claudeGitBashPath(env = process.env) {
284
297
  const read = (name) => Object.entries(env).find(([k]) => k.toUpperCase() === name)?.[1];
285
298
  const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
286
299
  const forced = read('CLAUDE_CODE_GIT_BASH_PATH');
287
300
  if (forced && GIT_BASH_NAMES.has(path.basename(forced).toLowerCase()) && isFile(forced)) {
288
- return 'bash';
301
+ return forced;
289
302
  }
290
303
  for (const raw of (read('PATH') || '').split(';')) {
291
304
  const dir = raw.replace(/"/g, '');
292
- if (dir && isFile(path.join(dir, 'git.exe'))
293
- && isFile(path.join(dir, '..', 'bin', 'bash.exe'))) return 'bash';
305
+ const bash = dir ? path.join(dir, '..', 'bin', 'bash.exe') : '';
306
+ if (dir && isFile(path.join(dir, 'git.exe')) && isFile(bash)) return path.resolve(bash);
294
307
  }
295
- return 'powershell';
308
+ return null;
296
309
  }
297
310
 
298
311
  /**
@@ -170,12 +170,12 @@ const DOCTRINE_TOKEN_RE = /\{\{DOCTRINE(?:\}\}|:)/;
170
170
  * The rules the hooks enforce at the tool boundary, and the address each is bound to there. The
171
171
  * hook path cannot afford to read the canon on every tool call, so its gate-ledger keys
172
172
  * (`law-1`, `law-4`, `process-1`, `process-5`, `rigor-5`, `ops-2` in `js/hosts/hooks.mjs` and the OpenCode guard
173
- * plugin), `CONSENT_RULE` in `js/hosts/settings.mjs` and the console's `HOOK_GATED` stay
173
+ * plugin; `law-3` in the done gate, `js/hosts/hooks-done.mjs`), `CONSENT_RULE` in `js/hosts/settings.mjs` and the console's `HOOK_GATED` stay
174
174
  * number-keyed — and this pin is what turns a renumber that moves one of these rules into a
175
175
  * doctor failure instead of a gate silently guarding its neighbour.
176
176
  */
177
177
  export const HOOK_PINNED = {
178
- 'sealed-secrets': 'I', 'deletion-is-deliberate': 'IV',
178
+ 'sealed-secrets': 'I', 'verify-before-asserting': 'III', 'deletion-is-deliberate': 'IV',
179
179
  'persist-insight': 'process 1', 'consent-before-push': 'process 5', 'external-gate': 'rigor 5',
180
180
  'commands-must-return': 'ops 2',
181
181
  };
@@ -0,0 +1,295 @@
1
+ /**
2
+ * `geneseed doctor --probe` — prove each installed gate still REFUSES, not just that it exists.
3
+ *
4
+ * A gate that is registered, launches and then allows everything looks exactly like a working
5
+ * one: `onFailure: "block"` (ADR 0007) covers a gate that never starts, `shimProblems` covers the
6
+ * shim's paths and `hookShellProblems` infers shell drift — none of them observes a verdict. So
7
+ * this runs each live install's own gate command, AS ITS SETTINGS SPELL IT, through the shell
8
+ * the host would use, on two canned payloads: one Law IV call that every build must refuse
9
+ * (`git reset --hard HEAD`, also in Bob's `BLOCK_RULES`) and one that must pass (`git status`).
10
+ * Both directions are judged, because "allows everything" and "refuses everything" (the
11
+ * PowerShell-fallback case `hookShellProblems` can only infer) are both broken gates.
12
+ *
13
+ * WHY THIS SPAWNS, and why it is opt-in. ADR 0002 allows a CLI spawn only as a declared row of
14
+ * `ALLOWED_SPAWNS` (`tests/unit/hook_cli.test.mjs`); this is the row that runs INSTALLED code
15
+ * rather than the checkout's, which is why it waits for `--probe` instead of riding every
16
+ * `doctor`. It runs only a command that `liveRecordedHooks` returns — recorded in our own
17
+ * manifest AND live in the settings — and that still carries `geneseed-hook`, a gate verb and
18
+ * `--root`; anything else is reported and never run. Nothing the payloads name is executed:
19
+ * the gate only reads them, from an empty temp dir.
20
+ *
21
+ * WHAT A PASS DOES NOT CLAIM. The probe runs under doctor's environment — this terminal's PATH
22
+ * and HOME — not the host app's. A host launched with a different environment (an empty HOME, a
23
+ * PATH without the shell's tools) can still break a gate that passes here.
24
+ *
25
+ * `GENESEED_GATE_PROBE=1` keeps the probe's asks out of the gate ledger (`ledger()` in
26
+ * `js/hosts/hooks.mjs`), so `status` never counts a doctor run as a refusal.
27
+ */
28
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
29
+ import os from 'node:os';
30
+ import path from 'node:path';
31
+ import { pathToFileURL } from 'node:url';
32
+ import { liveRecordedHooks } from '../hosts/hosts.mjs';
33
+ import { claudeCfg, installState, installTargets } from '../hosts/installs.mjs';
34
+ import { SHIM_MARK, claudeGitBashPath } from '../hosts/shim.mjs';
35
+ import { which } from '../lib/paths.mjs';
36
+ import { NO_WINDOW } from '../lib/proc.mjs';
37
+ import { NOTE, withTempDir } from './scan.mjs';
38
+ import { spawnSync } from 'node:child_process';
39
+
40
+ /**
41
+ * The gate that refuses Law IV on each host (`claudeHookGroups`, `js/hosts/settings.mjs`). On
42
+ * Claude and OpenClaude it is `git-gate`; `tool-gate` there only counts Skill/Agent calls. Bob
43
+ * runs everything through `tool-gate --host bob`. OpenCode's guard is an in-process plugin with
44
+ * no command: `guardProbe` below imports it instead.
45
+ */
46
+ export const PROBED_VERB = { claude: 'git-gate', openclaude: 'git-gate', bob: 'tool-gate' };
47
+
48
+ export const BAD_COMMAND = 'git reset --hard HEAD';
49
+ export const GOOD_COMMAND = 'git status';
50
+
51
+ const PROBE_TIMEOUT_MS = 20_000;
52
+
53
+ /** A PreToolUse payload in Claude's shape — the shape every probed host's gate reads. */
54
+ export function probePayload(command, cwd) {
55
+ return JSON.stringify({
56
+ session_id: 'geneseed-probe', hook_event_name: 'PreToolUse', tool_name: 'Bash',
57
+ tool_input: { command }, cwd,
58
+ });
59
+ }
60
+
61
+ /**
62
+ * The shell a host runs this handler under, as `{ exe, argv, label, verbatim }` — or null when
63
+ * that shell is not on this machine. Mirrors the emit's own rule: an explicit
64
+ * `shell: "powershell"` wins; Bob runs `cmd /c` on Windows; Claude/OpenClaude run Git Bash
65
+ * there (`claudeGitBashPath`, the rule `claudeHookShell` uses); `sh -c` everywhere else.
66
+ */
67
+ export function probeShell(host, handler, platform = process.platform, env = process.env) {
68
+ const cmd = handler.command;
69
+ if (handler.shell === 'powershell') {
70
+ const ps = which('powershell') || which('pwsh');
71
+ return ps ? { exe: ps, argv: ['-NoProfile', '-NonInteractive', '-Command', cmd], label: 'PowerShell' }
72
+ : null;
73
+ }
74
+ if (platform !== 'win32') return { exe: which('sh') || '/bin/sh', argv: ['-c', cmd], label: 'sh' };
75
+ if (host === 'bob') {
76
+ // `/s /c "<cmd>"` with verbatim arguments: cmd strips the outer quotes and keeps the
77
+ // command's own quoting. Node's default quoting would turn its `"` into `\"`, which cmd
78
+ // does not unescape.
79
+ const cmdExe = env.ComSpec || env.COMSPEC || which('cmd');
80
+ return cmdExe ? { exe: cmdExe, argv: ['/d', '/s', '/c', `"${cmd}"`], label: 'cmd', verbatim: true }
81
+ : null;
82
+ }
83
+ const bash = claudeGitBashPath(env);
84
+ return bash ? { exe: bash, argv: ['-c', cmd], label: 'Git Bash' } : null;
85
+ }
86
+
87
+ const ASK_RE = /"permissionDecision":\s*"(?:ask|deny)"/;
88
+
89
+ /** Did the gate refuse? Exit 2 on Bob; on Claude-shaped hosts exit 2 or an ask/deny verdict. */
90
+ export function refused(host, r) {
91
+ if (host === 'bob') return r.status === 2;
92
+ return r.status === 2 || (r.status === 0 && ASK_RE.test(r.stdout || ''));
93
+ }
94
+
95
+ /**
96
+ * The verdict for one gate, from its two runs — null when the gate is sound, or the problem.
97
+ * Pure, so the whole verdict table is pinned without spawning.
98
+ */
99
+ export function probeVerdict(host, bad, good) {
100
+ for (const r of [bad, good]) {
101
+ if (r.status !== 0 && r.status !== 2) {
102
+ const why = r.error ? r.error : `exit ${r.status}`;
103
+ const tail = (r.stderr || '').trim().split(/\r?\n/).pop();
104
+ return `does not run (${why}${tail ? `: ${tail}` : ''})${host === 'claude'
105
+ ? ' — Claude blocks every matching call while this lasts (onFailure: block)'
106
+ : ' — the host lets every call through while this lasts'}`;
107
+ }
108
+ }
109
+ if (!refused(host, bad)) return `registered but not refusing — \`${BAD_COMMAND}\` went through`;
110
+ if (refused(host, good)) return `refuses everything — \`${GOOD_COMMAND}\` was refused too`;
111
+ return null;
112
+ }
113
+
114
+ /** Is this recorded command one of ours for `verb`? A gate verb, `--root`, and our shim. */
115
+ export function isOurGate(command, verb) {
116
+ return command.includes(` ${verb} --root`) && command.includes(SHIM_MARK);
117
+ }
118
+
119
+ /** One spawn of one handler on one payload. THE ONE SPAWN of this module. */
120
+ function runOnce(shell, payload, cwd, env) {
121
+ const r = spawnSync(shell.exe, shell.argv, {
122
+ input: payload, cwd, env, encoding: 'utf-8', timeout: PROBE_TIMEOUT_MS,
123
+ windowsVerbatimArguments: Boolean(shell.verbatim), ...NO_WINDOW,
124
+ });
125
+ const e = /** @type {NodeJS.ErrnoException | undefined} */ (r.error);
126
+ const error = e ? (e.code === 'ETIMEDOUT' ? 'timed out' : e.message) : null;
127
+ return { status: r.status, stdout: r.stdout, stderr: r.stderr, error };
128
+ }
129
+
130
+ /**
131
+ * Probe every live Claude-shaped install. Returns `{ problems, probed }`: `problems` in
132
+ * doctor's line format, `probed` the labels of the gates that ran and behaved.
133
+ * `targets`, `platform` and `env` are parameters so a test hands it a sandboxed install.
134
+ */
135
+ export function gateProbe(targets = installTargets(), { platform = process.platform, env = process.env } = {}) {
136
+ const problems = [];
137
+ const probed = [];
138
+ for (const [host, scope, root] of targets) {
139
+ const verb = PROBED_VERB[host];
140
+ if (!verb || installState(root, host, scope) !== 'active') continue;
141
+ const cfg = claudeCfg(root, scope, host);
142
+ const gates = liveRecordedHooks(cfg)
143
+ .filter(([event, h]) => event === 'PreToolUse' && h.command.includes(` ${verb} --root`));
144
+ if (!gates.length) {
145
+ problems.push(`[probe] ${cfg}: no live ${verb} hook in its settings — nothing refuses `
146
+ + 'a destructive call at the boundary — run: geneseed rebuild-all');
147
+ continue;
148
+ }
149
+ for (const [, handler] of gates) {
150
+ const label = `[probe] ${cfg}: ${verb}`;
151
+ if (!isOurGate(handler.command, verb)) {
152
+ problems.push(`${label} not probed — its command does not run ${SHIM_MARK}, and doctor `
153
+ + 'runs nothing else from a settings file');
154
+ continue;
155
+ }
156
+ const shell = probeShell(host, handler, platform, env);
157
+ if (!shell) {
158
+ problems.push(`${label} does not run — the shell ${host} would use is not on this machine`);
159
+ continue;
160
+ }
161
+ const verdict = withTempDir((tmp) => {
162
+ const runEnv = { ...env, GENESEED_GATE_PROBE: '1', CLAUDE_PROJECT_DIR: tmp };
163
+ const bad = runOnce(shell, probePayload(BAD_COMMAND, tmp), tmp, runEnv);
164
+ const good = runOnce(shell, probePayload(GOOD_COMMAND, tmp), tmp, runEnv);
165
+ return probeVerdict(host, bad, good);
166
+ }, 'geneseed-probe-');
167
+ if (verdict) problems.push(`${label} under ${shell.label} ${verdict}`);
168
+ else probed.push(`${path.normalize(cfg)} (${verb}, ${shell.label})`);
169
+ }
170
+ }
171
+ return { problems, probed };
172
+ }
173
+
174
+ // ---- OpenCode: the guard plugin, probed in-process ------------------------------------------
175
+
176
+ export const GUARD_PLUGIN = 'geneseed-guard.js';
177
+ /** The guard blocks catastrophic shell (`SHELL_BLOCK_RE`); destructive git is the permission
178
+ * prompt's job there, and only WARNS in the guard — so its must-refuse payload is this one. */
179
+ export const GUARD_BAD_COMMAND = 'rm -rf /';
180
+
181
+ /** The guard plugin of an OpenCode install: `<global>/plugins/` or `<repo>/.opencode/plugins/`
182
+ * (`emitOpencodeInto` copies the plugins to `<dir>/plugins`). */
183
+ export function guardPluginPath(root, scope) {
184
+ return path.join(scope === 'global' ? root : path.join(root, '.opencode'), 'plugins', GUARD_PLUGIN);
185
+ }
186
+
187
+ /** Run `fn` with stderr swallowed and `GENESEED_GATE_PROBE` set, both restored after. */
188
+ async function quietProbe(fn) {
189
+ const write = process.stderr.write;
190
+ const saved = process.env.GENESEED_GATE_PROBE;
191
+ process.stderr.write = () => true;
192
+ process.env.GENESEED_GATE_PROBE = '1';
193
+ try { return await fn(); } finally {
194
+ process.stderr.write = write;
195
+ if (saved === undefined) delete process.env.GENESEED_GATE_PROBE;
196
+ else process.env.GENESEED_GATE_PROBE = saved;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Both plugin APIs, each on both payloads: `{ v1: [badRefused, goodRefused], v2: [...] }`.
202
+ * V1 (`server`) refuses by THROWING `[geneseed-guard] …`; V2 (`setup`) by RENAMING the call
203
+ * (`setupV2`'s docblock). An unexpected throw is not a refusal — it is rethrown, and the caller
204
+ * reports the guard as broken.
205
+ */
206
+ export async function runGuard(mod, cwd) {
207
+ const v1 = async (command) => {
208
+ const hooks = await mod.server({ worktree: cwd, directory: cwd });
209
+ try {
210
+ await hooks['tool.execute.before']({ tool: 'bash' }, { args: { command } });
211
+ return false;
212
+ } catch (e) {
213
+ if (String(e?.message || '').startsWith('[geneseed-guard]')) return true;
214
+ throw e;
215
+ }
216
+ };
217
+ const v2 = async (command) => {
218
+ /** @type {((event: { tool: string }) => Promise<void>) | null} */
219
+ let hook = null;
220
+ await mod.setup({
221
+ location: { project: { directory: cwd }, directory: cwd },
222
+ tool: { hook: (name, fn) => { if (name === 'execute.before') hook = fn; } },
223
+ });
224
+ if (!hook) throw new Error('setup registered no execute.before hook');
225
+ const event = { tool: 'bash', input: { command } };
226
+ await hook(event);
227
+ return event.tool !== 'bash';
228
+ };
229
+ return {
230
+ v1: [await v1(GUARD_BAD_COMMAND), await v1(GOOD_COMMAND)],
231
+ v2: [await v2(GUARD_BAD_COMMAND), await v2(GOOD_COMMAND)],
232
+ };
233
+ }
234
+
235
+ /** The verdict for one API's pair — null when sound. */
236
+ export function guardVerdict([badRefused, goodRefused]) {
237
+ if (!badRefused) return `registered but not refusing — \`${GUARD_BAD_COMMAND}\` went through`;
238
+ if (goodRefused) return `refuses everything — \`${GOOD_COMMAND}\` was refused too`;
239
+ return null;
240
+ }
241
+
242
+ /**
243
+ * Probe every live OpenCode install's guard plugin by importing it into this process and
244
+ * calling its hooks — no spawn. Proves the installed FILE and its logic under Node, not under
245
+ * OpenCode's runtime (the weekly host canary proves the plugin loads there). Returns
246
+ * `{ problems, probed }` like `gateProbe`; a guard this environment switched off
247
+ * (`GENESEED_GUARD=off|warn`) is a NOTE, not a failure — that switch is the user's.
248
+ *
249
+ * Only a file named `geneseed-guard.js` in an install `installState` recognises is imported,
250
+ * and only one whose default export is `{ id: 'geneseed-guard', server, setup }`.
251
+ */
252
+ export async function guardProbe(targets = installTargets(), env = process.env) {
253
+ const problems = [];
254
+ const probed = [];
255
+ for (const [host, scope, root] of targets) {
256
+ if (host !== 'opencode' || installState(root, host, scope) !== 'active') continue;
257
+ const file = guardPluginPath(root, scope);
258
+ const label = `[probe] ${file}`;
259
+ if (!existsSync(file)) {
260
+ problems.push(`${label}: no guard plugin — nothing refuses a catastrophic command at the `
261
+ + 'boundary — run: geneseed rebuild-all');
262
+ continue;
263
+ }
264
+ const mode = String(env.GENESEED_GUARD || 'on').toLowerCase();
265
+ if (['off', '0', 'false', 'no', 'warn'].includes(mode)) {
266
+ problems.push(`${NOTE}${label}: not probed — GENESEED_GUARD=${mode} in this environment `
267
+ + 'switches the guard off');
268
+ continue;
269
+ }
270
+ let verdicts;
271
+ try {
272
+ const mod = (await import(pathToFileURL(file).href)).default;
273
+ if (mod?.id !== 'geneseed-guard' || typeof mod.server !== 'function' || typeof mod.setup !== 'function') {
274
+ problems.push(`${label}: not probed — it does not export the Geneseed guard `
275
+ + '({ id, server, setup }) — run: geneseed rebuild-all');
276
+ continue;
277
+ }
278
+ verdicts = await withTempDirAsync((tmp) => quietProbe(() => runGuard(mod, tmp)));
279
+ } catch (e) {
280
+ problems.push(`${label}: does not run (${e?.message || e})`);
281
+ continue;
282
+ }
283
+ const bad = [['V1', guardVerdict(verdicts.v1)], ['V2', guardVerdict(verdicts.v2)]]
284
+ .filter(([, v]) => v);
285
+ for (const [api, v] of bad) problems.push(`${label}: guard (OpenCode ${api} hook) ${v}`);
286
+ if (!bad.length) probed.push(`${path.normalize(file)} (guard, in-process)`);
287
+ }
288
+ return { problems, probed };
289
+ }
290
+
291
+ /** `withTempDir` for an async body: the dir outlives the promise, then goes. */
292
+ async function withTempDirAsync(fn) {
293
+ const tmp = mkdtempSync(path.join(os.tmpdir(), 'geneseed-probe-'));
294
+ try { return await fn(tmp); } finally { rmSync(tmp, { recursive: true, force: true }); }
295
+ }
@@ -39,6 +39,7 @@ import {
39
39
  import { validateIsVendored } from '../hosts/native.mjs';
40
40
  import { printOut } from '../lib/fs.mjs';
41
41
  import { authoringProblems, loopProblems } from './checks-authoring.mjs';
42
+ import { gateProbe, guardProbe } from './checks-probe.mjs';
42
43
  import {
43
44
  checkBuild, colorThemeProblems, renderedProblems, themeParityProblems,
44
45
  } from './checks-build.mjs';
@@ -188,6 +189,7 @@ export function claudeBobEmitProblems(themeName) {
188
189
  */
189
190
  export function doctorCollect({
190
191
  theme = null, allThemes = false, bundle = null, noBundle = false, groups = null,
192
+ probe = false, probed = null, extraProbeProblems = [],
191
193
  } = {}) {
192
194
  const available = themeFiles().map(stemOf);
193
195
  if (!available.length) return [[], ['[doctor] no themes found']];
@@ -208,6 +210,11 @@ export function doctorCollect({
208
210
  // placed after them could only ever observe the freshly repaired file and would be dead
209
211
  // code that always reports clean. Reported in its usual slot further down.
210
212
  const shimProbs = shimProblems();
213
+ // `--probe` (js/inspect/checks-probe.mjs) runs the installed gates THROUGH the shim, so it is
214
+ // sampled here for the same reason: after the emits it could only meet a repaired shim.
215
+ // `probed` collects the gates that behaved, for `cmdDoctor`'s summary line.
216
+ const probeRun = probe ? gateProbe() : null;
217
+ if (probeRun && probed) probed.push(...probeRun.probed);
211
218
  let problems = [];
212
219
  withTempDir((tmp) => {
213
220
  for (const themeName of themes) {
@@ -237,6 +244,9 @@ export function doctorCollect({
237
244
  // misbehave under PowerShell (Task 15) — context/learn fail open, and since Task 1 the two
238
245
  // gates fail CLOSED instead (`onFailure: "block"`). The reverse direction is a note.
239
246
  problems = problems.concat(ran('hook_shell', 'Hook shell', hookShellProblems()));
247
+ if (probeRun) {
248
+ problems = problems.concat(ran('probe', 'Gate probe', [...probeRun.problems, ...extraProbeProblems]));
249
+ }
240
250
  // A machine check like the shim's: OPENCODE_CONFIG_DIR adds a dir, it does not replace one.
241
251
  const shadow = opencodeShadowedInstall();
242
252
  problems = problems.concat(ran('opencode_dirs', 'OpenCode config dirs', shadow
@@ -281,9 +291,19 @@ function captureBoth(fn) {
281
291
  * maintainer sweep. The cross-theme parity check runs in every mode.
282
292
  */
283
293
  export function cmdDoctor(args) {
294
+ // `--probe` alone is asynchronous: the OpenCode guard is probed by importing the installed
295
+ // plugin (`guardProbe`), and `import()` has no synchronous form. Every other path stays
296
+ // synchronous, because `validate` and `setup` call this and read its number directly.
297
+ if (args.probe) return guardProbe().then((guard) => doctorReport(args, guard));
298
+ return doctorReport(args, null);
299
+ }
300
+
301
+ function doctorReport(args, guard) {
302
+ const probed = guard ? [...guard.probed] : [];
284
303
  const [themes, collected] = doctorCollect({
285
304
  theme: args.theme, allThemes: Boolean(args.all), bundle: args.bundle,
286
- noBundle: Boolean(args.noBundle),
305
+ noBundle: Boolean(args.noBundle), probe: Boolean(args.probe), probed,
306
+ extraProbeProblems: guard ? guard.problems : [],
287
307
  });
288
308
  if (!themes.length) {
289
309
  printOut(`${collected.length ? collected[0] : '[doctor] no themes found'}\n`);
@@ -303,6 +323,12 @@ export function cmdDoctor(args) {
303
323
  const note = scoped
304
324
  ? ` (scoped to installed theme '${themes[0]}'; run with --all to sweep every theme)` : '';
305
325
  for (const n of notes) printOut(`${n}\n`);
326
+ // The probe's line on success, and the limit it cannot see past, said every time.
327
+ if (args.probe) {
328
+ printOut(`[probe] ${probed.length} gate(s) refused a destructive call and let a safe one `
329
+ + `through${probed.length ? `: ${probed.join(', ')}` : ''} — under this terminal's `
330
+ + 'environment, not the host app\'s\n');
331
+ }
306
332
  if (problems.length) {
307
333
  printOut(`[doctor] ${problems.length} problem(s) across ${themes.length} theme(s):\n`);
308
334
  for (const p of problems) printOut(` - ${p}\n`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
3
3
  "name": "geneseed",
4
- "version": "4.4.1",
4
+ "version": "4.5.0",
5
5
  "description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
6
6
  "keywords": [
7
7
  "ai",
@@ -73,7 +73,11 @@ re-derived. Then take exactly one branch.
73
73
  rebuild would have seeded it.
74
74
  6. **Draft in the file's own format**: the next free `## R<n> — Title` heading, an
75
75
  optional `(scope: user|project | source: …)` metadata line, then the rule in one
76
- short paragraph, plain and testable. For a promotion from {{MEMORY}}, set
76
+ short paragraph, plain and testable. For a rule the user stated, `source:` keeps
77
+ their own words and the day they said them — `source: user 2026-10-16 "never push
78
+ red"` — one sentence at most: it is what later tells a stale rule from a changed
79
+ mind. The metadata line splits on `|`, so write any `|` inside the quote as `/` and
80
+ say so when you show the draft. For a promotion from {{MEMORY}}, set
77
81
  `source:` to the memory's name and add `trial until:` about a month out — a
78
82
  promoted rule starts on probation.
79
83
  7. Show the exact text to the user and wait for explicit consent ({{LAW:deletion-is-deliberate}}) before
@@ -90,7 +94,12 @@ the user picks "rule" out of impatience and the set inflates.
90
94
  **Review flow** (on request, or when a `trial until:` date has passed): for each rule
91
95
  due, ask whether it actually fired since adoption — graduate it (drop the trial
92
96
  marker), demote it back to a {{MEMORY}} fact, or delete it. Same consent gate as
93
- adoption.
97
+ adoption. Two more cases are due at any time:
98
+ - **Contradicted.** The user's later words contradict a rule: the rule is the suspect.
99
+ Quote both — its `source:` and the new words — and ask which stands.
100
+ - **Broken while only read.** A rule nothing checks but reading it, broken twice
101
+ anyway, is step 1's mechanical case: propose the hook or check rather than
102
+ rewording it.
94
103
 
95
104
  **Consolidation flow** → read [rule/consolidate.md](rule/consolidate.md) and follow it.
96
105