geneseed 4.4.1 → 4.6.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 (79) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +6 -6
  3. package/SHIPPED.md +1 -1
  4. package/adapters/opencode/HOW-OPENCODE-LOADS.md +3 -2
  5. package/adapters/opencode/plugins/geneseed-context.js +195 -52
  6. package/adapters/opencode/plugins/geneseed-guard.js +42 -29
  7. package/adapters/opencode/plugins/geneseed-learn.js +1 -1
  8. package/docs/concepts/enforcement-table.md +9 -3
  9. package/docs/concepts/hooks.md +3 -0
  10. package/docs/extending.md +1 -1
  11. package/docs/guides/install-clone.md +1 -1
  12. package/docs/guides/install.md +2 -2
  13. package/docs/reference/plugin-context.md +2 -0
  14. package/docs/reference/troubleshoot-doctor.md +31 -0
  15. package/docs/reference/troubleshoot-install.md +5 -5
  16. package/docs/understand/enforced-vs-asked.md +1 -0
  17. package/harness.config.json +1 -1
  18. package/js/README.md +8 -5
  19. package/js/build/bundle.mjs +11 -2
  20. package/js/build/driver.mjs +1 -10
  21. package/js/build/emit-claude.mjs +7 -22
  22. package/js/build/emit-common.mjs +16 -9
  23. package/js/build/emit-opencode.mjs +5 -11
  24. package/js/build/generate.mjs +1 -1
  25. package/js/cli-table.json +15 -0
  26. package/js/hosts/hooks-context.mjs +1 -4
  27. package/js/hosts/hooks-done.mjs +186 -0
  28. package/js/hosts/hooks-learn.mjs +9 -4
  29. package/js/hosts/hooks.mjs +27 -8
  30. package/js/hosts/hosts.mjs +2 -0
  31. package/js/hosts/installs.mjs +47 -11
  32. package/js/hosts/native.mjs +5 -13
  33. package/js/hosts/opencode.mjs +37 -22
  34. package/js/hosts/owned.mjs +19 -5
  35. package/js/hosts/settings.mjs +17 -11
  36. package/js/hosts/shim.mjs +24 -10
  37. package/js/hosts/usage.mjs +4 -3
  38. package/js/inspect/checks-authoring.mjs +9 -7
  39. package/js/inspect/checks-constitution.mjs +2 -2
  40. package/js/inspect/checks-probe.mjs +292 -0
  41. package/js/inspect/checks-repo.mjs +13 -54
  42. package/js/inspect/cost.mjs +5 -6
  43. package/js/inspect/doctor.mjs +29 -3
  44. package/js/inspect/scan.mjs +9 -5
  45. package/js/inspect/scorecard-svg.mjs +3 -3
  46. package/js/inspect/scorecard-yaml.mjs +407 -0
  47. package/js/inspect/scorecard.mjs +7 -405
  48. package/js/inspect/status.mjs +14 -13
  49. package/js/lib/node-floor.mjs +4 -3
  50. package/js/maintain/migrate.mjs +2 -3
  51. package/js/maintain/uninstall.mjs +20 -26
  52. package/js/web/actions.mjs +2 -2
  53. package/js/web/api.mjs +36 -24
  54. package/js/web/gates-api.mjs +5 -8
  55. package/js/web/usage-api.mjs +14 -17
  56. package/package.json +2 -2
  57. package/src/skills/rule.md +11 -2
  58. package/web/dist/assets/{Activity-BZE6REef.js → Activity-Dj0izJwj.js} +1 -1
  59. package/web/dist/assets/{ActivityDetail-DCB8Cr2W.js → ActivityDetail-wjq5W02K.js} +1 -1
  60. package/web/dist/assets/Dashboard-UMbgHbzF.js +2 -0
  61. package/web/dist/assets/{Docs-D30CiqYy.js → Docs-D3ABE_ds.js} +3 -3
  62. package/web/dist/assets/{GreenhouseView-Djt-4oO5.js → GreenhouseView-C238LWBD.js} +1 -1
  63. package/web/dist/assets/{Installs-B3bw5ckh.js → Installs-XSm0a_zQ.js} +2 -2
  64. package/web/dist/assets/JournalView-BUj4Ff-6.js +1 -0
  65. package/web/dist/assets/{Laws-B9day2FV.js → Laws-yGBJFKgY.js} +1 -1
  66. package/web/dist/assets/{Library-BpIrLH-O.js → Library-CkaPVUYU.js} +1 -1
  67. package/web/dist/assets/{Loops-CxH14kyE.js → Loops-CexwOARr.js} +1 -1
  68. package/web/dist/assets/{OperatorHudView-otOz6Gha.js → OperatorHudView-D5qTQat0.js} +1 -1
  69. package/web/dist/assets/{Personal-abL6089y.js → Personal-B_Vx9a7a.js} +1 -1
  70. package/web/dist/assets/Tabs-YZbGqWPW.js +1 -0
  71. package/web/dist/assets/drawable-BoQnwVpr.js +1 -0
  72. package/web/dist/assets/index-BmbWeoTM.js +2 -0
  73. package/web/dist/assets/{index-CO-9wMJY.css → index-BoG1M8Xy.css} +1 -1
  74. package/web/dist/index.html +2 -2
  75. package/web/dist/assets/Dashboard-B4Drq5wp.js +0 -2
  76. package/web/dist/assets/JournalView-OWhx9YKg.js +0 -1
  77. package/web/dist/assets/Tabs-DjWN0i7A.js +0 -1
  78. package/web/dist/assets/drawable-C2ZsBZCv.js +0 -1
  79. package/web/dist/assets/index-CdpLpPQN.js +0 -2
package/CHANGELOG.md CHANGED
@@ -8,6 +8,43 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [4.6.0] — 2026-10-17
12
+
13
+ Minor: a refactor and cleanup pass over everything since 3.15.0, and the nine gate and counter bugs it turned up.
14
+
15
+ ### Upgrade notes
16
+ - **Node 22.7.0 or newer is now required** (was 22.3.0). `doctor --probe` imports the installed OpenCode guard plugin, an ES module under a package.json with no `type`; Node detects that syntax by default only from 22.7.0 (#303).
17
+ - Run `geneseed rebuild-all` once after updating so the installed hooks and OpenCode plugins pick up the fixes below.
18
+
19
+ ### Fixed
20
+ - **Bob no longer blocks removing a secret.** The credential scan read the text being replaced (`search`, and the SEARCH half of an `apply_diff`), so an agent deleting a leaked key was hard-refused. Only what is written is scanned now; planting a key still blocks (#304).
21
+ - **The done gate needs the whole command.** A declared check such as `npm run lint` was counted as run by `npm run lint:fix`, by `echo npm run lint`, or by a commit message naming it. The check must now open a command segment, end at a boundary and sit outside quotes, on Claude Code and OpenCode alike (#307).
22
+ - **The OpenCode done reminder counts only edits inside the project.** An `apply_patch` outside the repo armed it, and a folder named like `..cache/` was taken for outside (#310). The wiki `put` had the same over-strict path check (#312).
23
+ - **Usage counting.** The OpenCode guard no longer counts Skill and subagent calls in a repo listed in `excludes.json` (#308), and OpenCode 2's `subagent` tool is now counted (#311). The console's usage endpoint only reads the ledger and never rewrites it, so a concurrent append can no longer be lost (#305).
24
+ - **The console shows *Verify Before Asserting* as hook-gated**, matching the doctor, since the done gate enforces it (#306).
25
+ - The dead-shim warning names the release (4.0.0) instead of an internal plan step (#302).
26
+
27
+ ### Changed
28
+ - Behaviour-preserving refactors across inspect, the OpenCode plugins and emitters, hosts, the web layer, and maintain/build (#297–#301). The scorecard's YAML reader moved to `js/inspect/scorecard-yaml.mjs`. Unused exports and duplicated helpers were removed, and parity tests now pin more of the code the OpenCode plugins share.
29
+
30
+ ## [4.5.0] — 2026-10-17
31
+
32
+ Minor: gates that are proven, not just present — a done gate at turn end, `doctor --probe`, and Bob's refusals restored on Windows.
33
+
34
+ ### Upgrade notes
35
+ - Run `geneseed rebuild-all` once after updating: it rewrites the Windows hook shim (Bob's gates block again) and re-emits the rule skill.
36
+ - The done gate is off until a project lists its checks in `.geneseed/done-checks.txt`.
37
+
38
+ ### Added
39
+ - **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*.
40
+ - **`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).
41
+
42
+ ### Changed
43
+ - **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).
44
+
45
+ ### Fixed
46
+ - **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).
47
+
11
48
  ## [4.4.1] — 2026-10-16
12
49
 
13
50
  A presentation skill.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  [![CI](https://github.com/Arylmera/Geneseed/actions/workflows/ci.yml/badge.svg)](https://github.com/Arylmera/Geneseed/actions/workflows/ci.yml)
9
9
  [![AI Harness Scorecard](https://img.shields.io/badge/AI%20Harness%20Scorecard-A%2095.3%2F100-brightgreen)](#ai-harness-scorecard)
10
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
11
- [![Node >= 22.3](https://img.shields.io/badge/node-%3E%3D22.3-5fa04e)](package.json)
11
+ [![Node >= 22.7](https://img.shields.io/badge/node-%3E%3D22.7-5fa04e)](package.json)
12
12
  [![zero dependencies](https://img.shields.io/badge/deps-zero-success)](package.json)
13
13
  [![Themes](https://img.shields.io/badge/themes-14-9cf)](themes/)
14
14
  [![Skills](https://img.shields.io/badge/skills-47-blueviolet)](src/skills/)
@@ -75,7 +75,7 @@ geneseed-build --theme imperial # Warhammer 40k voice, identical structure
75
75
 
76
76
  ### ⚡ The short way — `npx`
77
77
 
78
- The harness is published on npm as **[`geneseed`](https://www.npmjs.com/package/geneseed)** — three commands (`geneseed`, `geneseed-hook`, `geneseed-build`), zero dependencies. One command, nothing cloned, nothing else to install. The only prerequisite is **Node ≥ 22.3**.
78
+ The harness is published on npm as **[`geneseed`](https://www.npmjs.com/package/geneseed)** — three commands (`geneseed`, `geneseed-hook`, `geneseed-build`), zero dependencies. One command, nothing cloned, nothing else to install. The only prerequisite is **Node ≥ 22.7**.
79
79
 
80
80
  ```bash
81
81
  npx geneseed setup # the guided wizard
@@ -102,9 +102,9 @@ Every other route (Claude Code, Bob, OpenClaude, plain `AGENT.md`, per-repo inst
102
102
 
103
103
  ### 🧬 The long way — a git checkout
104
104
 
105
- Cloning is the route when npm is out of reach (a corporate network with no registry access) or when you intend to *change* the harness rather than use it. It needs **git** and the same **Node ≥ 22.3** as everything else — there is nothing extra to install.
105
+ Cloning is the route when npm is out of reach (a corporate network with no registry access) or when you intend to *change* the harness rather than use it. It needs **git** and the same **Node ≥ 22.7** as everything else — there is nothing extra to install.
106
106
 
107
- **One step: run the installer at the root of the clone.** It checks that Node ≥ 22.3 and git are there and, when one is missing, says exactly what to install and where (on a managed PC: Software Center / Company Portal) — it never installs anything itself. Then it runs the setup wizard — which opens by listing the AI coding tools it can install into (OpenCode, Claude Code, IBM Bob, OpenClaude), each `ok` or with its install link, and asks before going on when none is there — and offers to open the web console.
107
+ **One step: run the installer at the root of the clone.** It checks that Node ≥ 22.7 and git are there and, when one is missing, says exactly what to install and where (on a managed PC: Software Center / Company Portal) — it never installs anything itself. Then it runs the setup wizard — which opens by listing the AI coding tools it can install into (OpenCode, Claude Code, IBM Bob, OpenClaude), each `ok` or with its install link, and asks before going on when none is there — and offers to open the web console.
108
108
 
109
109
  ```bash
110
110
  git clone https://github.com/Arylmera/Geneseed.git
@@ -124,11 +124,11 @@ cd Geneseed
124
124
 
125
125
  Afterwards, `./geneseed setup` (`.\geneseed.cmd setup`) re-runs the wizard and bare `./geneseed` (`.\geneseed.cmd`) opens the web console. PowerShell runs a `.cmd` directly, so that is the PowerShell spelling too.
126
126
 
127
- Both launchers are thin shims over the Node CLI: they need `node` (22.3+) on `PATH`, and nothing else. Set `GENESEED_NODE` to an absolute path if `node` is not on `PATH` — under a version manager that only patches interactive shells, say. The wizard is plain text prompts on every console, old or new.
127
+ Both launchers are thin shims over the Node CLI: they need `node` (22.7+) on `PATH`, and nothing else. Set `GENESEED_NODE` to an absolute path if `node` is not on `PATH` — under a version manager that only patches interactive shells, say. The wizard is plain text prompts on every console, old or new.
128
128
 
129
129
  ### 🟩 One runtime — Node, and nothing else
130
130
 
131
- Node ≥ 22.3 is the entire dependency list — every command, all five hook verbs, the web console and both generators run from it, and nothing any bundle ships needs a second interpreter.
131
+ Node ≥ 22.7 is the entire dependency list — every command, all five hook verbs, the web console and both generators run from it, and nothing any bundle ships needs a second interpreter.
132
132
 
133
133
  ### ✅ After installing
134
134
 
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.6.0**.
24
24
 
25
25
  ## Capabilities
26
26
 
@@ -94,7 +94,7 @@ changed for each plugin:
94
94
  | `@opencode-ai/plugin` `tool()` helper | `@opencode/plugin`; a plain JSON-schema `input` needs no helper |
95
95
  | `todo.updated`, session `summary` diffs | no V2 event. The activity cards show no todos or files on V2 |
96
96
  | tool part carries its name | `session.tool.called` has no name; it rides on `session.tool.input.started` |
97
- | tools `bash`, `apply_patch`, `task` | renamed `shell`, `patch`, `subagent` (the old ids are aliases). The guard's substring classes cover both spellings |
97
+ | tools `bash`, `apply_patch`, `task` | renamed `shell`, `patch`, `subagent` (the old ids are aliases). The guard's substring classes cover both spellings; its usage counter takes `subagent` (type under `agent`) beside `task`, since the hook sees the name before alias resolution |
98
98
 
99
99
  None of this is checked by a mock alone: `node tests/host_canary.mjs` starts the real V1 and V2
100
100
  and fails on the host's own `failed to load plugin` log line.
@@ -124,7 +124,8 @@ to native **skills**, not slash commands (same `SKILL.md` shape as Claude Code).
124
124
  | plugin on `session.created` | **context plugin (v2)** — auto-discovers & injects the repo's `eager` docs |
125
125
  | plugin on `experimental.session.compacting` | **context plugin** — re-pushes the `eager` docs so they survive compaction |
126
126
  | plugin on `session.idle` | **learn plugin** — distils memory into `memory/` (debounced to fire once, at session end) |
127
- | plugin on `tool.execute.before` | **guard plugin** — blocks secret-file writes and catastrophic shell (Laws I & IV; `GENESEED_GUARD=off\|warn`) |
127
+ | plugin on `experimental.chat.messages.transform` (V1) | **context plugin** — also adds the done gate's reminder when a check in `.geneseed/done-checks.txt` has not passed since the last edit (Verify Before Asserting); OpenCode 2 turns are not judged |
128
+ | plugin on `tool.execute.before` | **guard plugin** — blocks secret-file writes and credential-shaped write content (Sealed Secrets), writes to a protected check or the done list (External Gate), catastrophic shell (Deletion Is Deliberate), a recursive whole-filesystem scan (Commands Must Return), the first rule-store write (Persist Insight) and protected wiki folders; logs each block to the gate ledger and counts skill/subagent use (`GENESEED_GUARD=off\|warn`) |
128
129
  | plugin registering a custom tool | **workflow plugin** — the `workflow` tool runs saved orchestration scripts from `workflows/` |
129
130
  | `context.json` / `.harness/` (manifest) | *optional* override when discovery doesn't fit |
130
131
 
@@ -194,8 +194,12 @@ const EXCLUDE_DIRS = new Set([
194
194
  ".venv", "__pycache__", ".opencode", ".harness",
195
195
  ])
196
196
 
197
- async function isFile(p) { try { return (await fs.stat(p)).isFile() } catch { return false } }
198
- async function isDir(p) { try { return (await fs.stat(p)).isDirectory() } catch { return false } }
197
+ async function isFile(p) {
198
+ try { return (await fs.stat(p)).isFile() } catch { return false }
199
+ }
200
+ async function isDir(p) {
201
+ try { return (await fs.stat(p)).isDirectory() } catch { return false }
202
+ }
199
203
 
200
204
  // ---- session files (the harness's own) --------------------------------------
201
205
  // The files AGENT.md names for session start, relative to the HARNESS dir — the dir that
@@ -666,8 +670,10 @@ async function wikiSets() {
666
670
 
667
671
  const recs = new Map() // abs -> { rel, abs, load, desc }
668
672
  const put = (abs, load, desc) => {
669
- let rel = path.relative(w.path, abs).split(path.sep).join("/")
670
- if (rel.startsWith("..")) rel = abs.split(path.sep).join("/") // outside the root
673
+ const raw = path.relative(w.path, abs)
674
+ // outside the root: "..", "../…" or another drive — not a name like "..cache"
675
+ const outside = raw === ".." || raw.startsWith(".." + path.sep) || path.isAbsolute(raw)
676
+ const rel = (outside ? abs : raw).split(path.sep).join("/")
671
677
  const prev = recs.get(abs)
672
678
  recs.set(abs, { rel: `${name}/${rel}`, abs, load, desc: desc || prev?.desc || "" })
673
679
  }
@@ -877,6 +883,147 @@ async function alreadyInjected(client, sid) {
877
883
  return false
878
884
  }
879
885
 
886
+ // ---- The done gate (#289): the OpenCode twin of js/hosts/hooks-done.mjs ----------------
887
+ // OpenCode has no turn-end event a plugin can block on, so its ceiling is a reminder: on the
888
+ // request AFTER a turn that edited files without the project's declared checks passing
889
+ // (`.geneseed/done-checks.txt`), one invisible user message says which checks are owed. Only V1
890
+ // (`messages.transform`, whose tool parts carry `state.status` and the shell's
891
+ // `metadata.exit`); OpenCode 2's message shape for tool calls is unverified, so V2 is not
892
+ // judged and says so once. Fails open on every shape it does not recognise (ADR 0007).
893
+ // `runsUnpiped` is a hand-mirrored twin; tests/plugins/context_done.test.mjs runs both on one table.
894
+ const DONE_LIST = ".geneseed/done-checks.txt"
895
+ const DONE_EDIT_TOOLS = new Set(["edit", "write", "patch", "apply_patch", "multiedit"])
896
+ const DONE_SHELL_TOOLS = new Set(["bash", "shell"])
897
+
898
+ async function readDoneChecks(root) {
899
+ try {
900
+ const text = await fs.readFile(path.join(root, DONE_LIST), "utf8")
901
+ return text.split(/\r?\n/).map((l) => l.trim().replace(/\s+/g, " ")).filter((l) => l && !l.startsWith("#"))
902
+ } catch {
903
+ return []
904
+ }
905
+ }
906
+
907
+ function runsUnpiped(command, line) {
908
+ const cmd = String(command).replace(/[ \t]+/g, " ")
909
+ for (let i = cmd.indexOf(line); i !== -1; i = cmd.indexOf(line, i + 1)) {
910
+ const next = cmd[i + line.length]
911
+ if (next !== undefined && !/[\s;&|]/.test(next)) continue
912
+ const head = cmd.slice(0, i)
913
+ if (!/(^|&&|\|\||;|\n) ?$/.test(head)) continue
914
+ let quote = "", esc = false
915
+ for (const ch of head) {
916
+ if (esc) esc = false
917
+ else if (ch === "\\" && quote !== "'") esc = true
918
+ else if (quote) { if (ch === quote) quote = "" }
919
+ else if (ch === '"' || ch === "'") quote = ch
920
+ }
921
+ if (quote) continue
922
+ const rest = cmd.slice(i + line.length)
923
+ let piped = false
924
+ for (let j = 0; j < rest.length; j += 1) {
925
+ const two = rest.slice(j, j + 2)
926
+ if (two === "&&" || two === "||" || rest[j] === ";" || rest[j] === "\n") break
927
+ if (rest[j] === "|") { piped = true; break }
928
+ }
929
+ if (!piped) return true
930
+ }
931
+ return false
932
+ }
933
+
934
+ // An `apply_patch` call names its targets only in `patchText` markers. Copied from
935
+ // geneseed-guard.js (tests/plugins/helper_parity.test.mjs holds the copies equal); `from` is
936
+ // the pre-move path of an `Update File` + `Move to`, `path` where the file lands.
937
+ function patchSections(patchText) {
938
+ const out = []
939
+ let current = null
940
+ const flush = () => { if (current) out.push(current) }
941
+ const lines = String(patchText).split(/\r?\n/)
942
+ for (let i = 0; i < lines.length; i++) {
943
+ const line = lines[i]
944
+ let m = /^\*\*\* (?:Add|Delete) File:\s*(.+)$/.exec(line)
945
+ if (m) { flush(); current = { from: "", path: m[1].trim(), added: [] }; continue }
946
+ m = /^\*\*\* Update File:\s*(.+)$/.exec(line)
947
+ if (m) {
948
+ flush()
949
+ let from = "", p = m[1].trim()
950
+ const mv = /^\*\*\* Move to:\s*(.+)$/.exec(lines[i + 1] || "")
951
+ if (mv) { from = p; p = mv[1].trim(); i++ }
952
+ current = { from, path: p, added: [] }
953
+ continue
954
+ }
955
+ if (current && line.startsWith("+") && !line.startsWith("+++")) current.added.push(line.slice(1))
956
+ }
957
+ flush()
958
+ return out
959
+ }
960
+
961
+ // The checks still owed, judged over the previous turn and the current one: a reminder lasts
962
+ // one turn, not the whole session. Our own injected messages (`geneseed-*` ids) open no turn.
963
+ function doneOwed(messages, checks, root) {
964
+ const prompts = []
965
+ messages.forEach((m, i) => {
966
+ if (m?.info?.role === "user" && !String(m.info.id ?? "").startsWith("geneseed-")) prompts.push(i)
967
+ })
968
+ const from = prompts.length >= 2 ? prompts[prompts.length - 2] : 0
969
+ const tools = []
970
+ for (const m of messages.slice(from)) {
971
+ for (const p of m?.parts ?? []) if (p?.type === "tool" && p.state) tools.push(p)
972
+ }
973
+ // The Node hook's `within()`: `..cache/x` is inside the root, `../x` is not.
974
+ const inside = (fp) => {
975
+ const rel = path.relative(root, path.resolve(root, fp))
976
+ return !(rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel))
977
+ }
978
+ let lastEdit = -1
979
+ tools.forEach((p, i) => {
980
+ if (!DONE_EDIT_TOOLS.has(String(p.tool).toLowerCase()) || p.state.status === "error") return
981
+ const input = p.state.input ?? {}
982
+ const fp = input.filePath ?? input.file_path
983
+ const targets = typeof fp === "string" && fp ? [fp]
984
+ : typeof input.patchText === "string" ? patchSections(input.patchText).flatMap((s) => [s.from, s.path]).filter(Boolean)
985
+ : []
986
+ // No derivable target: counted — the reminder is fail-open, a missed one is the worse error.
987
+ if (targets.length && !targets.some(inside)) return
988
+ lastEdit = i
989
+ })
990
+ if (lastEdit === -1) return []
991
+ const passed = new Set()
992
+ for (const p of tools.slice(lastEdit + 1)) {
993
+ const command = DONE_SHELL_TOOLS.has(String(p.tool).toLowerCase()) && p.state.input?.command
994
+ if (typeof command !== "string" || p.state.status !== "completed") continue
995
+ const exit = p.state.metadata?.exit
996
+ if (typeof exit === "number" && exit !== 0) continue
997
+ for (const check of checks) if (runsUnpiped(command, check)) passed.add(check)
998
+ }
999
+ return checks.filter((c) => !passed.has(c))
1000
+ }
1001
+
1002
+ // The session-scope rule, once for both majors and both deliveries: a child/subagent session
1003
+ // inherits its parent's context, and a geneseed-* session (learn distils, geneseed-wf:*
1004
+ // children) must stay clean.
1005
+ const isSkippedSession = (meta) =>
1006
+ Boolean(meta?.parentID ?? meta?.parentId) || String(meta?.title ?? "").startsWith("geneseed-")
1007
+
1008
+ // The rule as a per-sid cached filter over `getMeta(sid)` (each major's own session.get shape).
1009
+ // Cached because the transform runs on EVERY request; a failed lookup is NOT cached, so the
1010
+ // next request re-checks.
1011
+ function sessionSkipper(getMeta) {
1012
+ const cache = new Map()
1013
+ return async (sid) => {
1014
+ if (!sid) return false
1015
+ const hit = cache.get(sid)
1016
+ if (hit !== undefined) return hit
1017
+ try {
1018
+ const skip = isSkippedSession(await getMeta(sid))
1019
+ cache.set(sid, skip)
1020
+ return skip
1021
+ } catch {
1022
+ return false
1023
+ }
1024
+ }
1025
+ }
1026
+
880
1027
  export const GeneseedContext = async (ctx) => {
881
1028
  const { client } = ctx
882
1029
  const done = new Set()
@@ -893,15 +1040,12 @@ export const GeneseedContext = async (ctx) => {
893
1040
  // context). The sid is only marked done AFTER the guards pass — marking it
894
1041
  // earlier would permanently skip a session when a transient session.get
895
1042
  // failure let it through the title check.
896
- let title = "", parent
1043
+ let meta
897
1044
  try {
898
1045
  const info = await client.session.get({ path: { id: sid } })
899
- const meta = info?.data ?? info
900
- title = meta?.title ?? ""
901
- parent = meta?.parentID ?? meta?.parentId
1046
+ meta = info?.data ?? info
902
1047
  } catch {}
903
- if (parent) { log("skipped: child/subagent session"); return }
904
- if (title.startsWith("geneseed-")) { log("skipped: geneseed-* session"); return }
1048
+ if (isSkippedSession(meta)) { log("skipped: child/subagent or geneseed-* session"); return }
905
1049
 
906
1050
  if (await alreadyInjected(client, sid)) { log("skipped: already injected (marker present)"); return }
907
1051
  done.add(sid)
@@ -928,28 +1072,13 @@ export const GeneseedContext = async (ctx) => {
928
1072
  }
929
1073
  }
930
1074
 
931
- // Session-scope filter for the INVISIBLE delivery, mirroring injectVisible's
932
- // guards: child/subagent sessions inherit the parent's context, and geneseed-*
933
- // sessions (learn distils, geneseed-wf:* children) must stay clean — without this
934
- // the transform prepends the full context block to every request of every session
935
- // in-process. Cached per sid (the transform runs on EVERY request); a transient
936
- // session.get failure is NOT cached so the next request re-checks.
937
- const sessionSkip = new Map()
938
- async function skipSession(sid) {
939
- if (!sid) return false
940
- const hit = sessionSkip.get(sid)
941
- if (hit !== undefined) return hit
942
- try {
943
- const info = await client.session.get({ path: { id: sid } })
944
- const meta = info?.data ?? info
945
- const skip = Boolean(meta?.parentID ?? meta?.parentId) ||
946
- String(meta?.title ?? "").startsWith("geneseed-")
947
- sessionSkip.set(sid, skip)
948
- return skip
949
- } catch {
950
- return false
951
- }
952
- }
1075
+ // Session-scope filter for the INVISIBLE delivery, the same rule as injectVisible's
1076
+ // guards — without it the transform prepends the full context block to every request of
1077
+ // every session in-process.
1078
+ const skipSession = sessionSkipper(async (sid) => {
1079
+ const info = await client.session.get({ path: { id: sid } })
1080
+ return info?.data ?? info
1081
+ })
953
1082
 
954
1083
  return {
955
1084
  event: async ({ event }) => {
@@ -1002,12 +1131,31 @@ export const GeneseedContext = async (ctx) => {
1002
1131
  // detects that and takes over with the visible delivery.
1003
1132
  "experimental.chat.messages.transform": async (_input, output) => {
1004
1133
  transformSeen = true // the hook exists on this build
1134
+ // One scope decision per request, shared by the done gate and the context block:
1135
+ // both end on an empty request, an excluded repo, or a skipped session.
1136
+ if (!output || !Array.isArray(output.messages) || !output.messages.length) return
1137
+ const sid = output.messages[0]?.info?.sessionID
1138
+ if (await sovereignBypass(root) || await skipSession(sid)) return
1139
+ // The done gate's reminder: FIRST, and independent of the context block:
1140
+ // INJECT_OFF and the visible delivery leave it on, and no early return below can skip it.
1141
+ // Its own function, so its early returns end the reminder, never the context injection.
1142
+ await (async () => {
1143
+ const checks = await readDoneChecks(root)
1144
+ if (!checks.length) return
1145
+ const owed = doneOwed(output.messages, checks, root)
1146
+ if (!owed.length) return
1147
+ const stamp = Date.now()
1148
+ output.messages.unshift({
1149
+ info: { id: `geneseed-done-${stamp}`, role: "user", sessionID: sid, time: { created: stamp } },
1150
+ parts: [{ id: `geneseed-done-part-${stamp}`, type: "text", text:
1151
+ "Geneseed (Verify Before Asserting) — files were edited, and these checks from "
1152
+ + `${DONE_LIST} have not passed since the last edit: ${owed.join("; ")}. Run them unpiped `
1153
+ + "before reporting the work done, or say plainly that they were not run and why." }],
1154
+ })
1155
+ log("transform: done-gate reminder injected")
1156
+ })().catch((err) => log(`done-gate error: ${err?.message ?? err}`))
1005
1157
  if (INJECT_OFF || !TRANSFORM || fallbackVisible) return
1006
- if (await sovereignBypass(root)) return
1007
1158
  try {
1008
- if (!output || !Array.isArray(output.messages) || !output.messages.length) return
1009
- const sid = output.messages[0]?.info?.sessionID
1010
- if (await skipSession(sid)) return
1011
1159
  // Already present this request? (idempotent across plugin copies / retries.)
1012
1160
  const present = output.messages.some((m) =>
1013
1161
  (m?.parts ?? []).some((p) => typeof p?.text === "string" && p.text.includes(MARKER)))
@@ -1055,21 +1203,16 @@ const v2Model = (ref) => (ref?.providerID && ref?.id ? `${ref.providerID}/${ref.
1055
1203
  async function setupV2(ctx) {
1056
1204
  const root = repoRoot({ worktree: ctx?.location?.project?.directory, directory: ctx?.location?.directory })
1057
1205
  const blockCache = { text: null, at: 0 }
1058
- // Same scope rule as V1's skipSession: child/subagent sessions inherit the parent's
1059
- // context, geneseed-* sessions stay clean. A failed lookup is not cached.
1060
- const sessionSkip = new Map()
1061
- async function skipSession(sid) {
1062
- if (!sid) return false
1063
- const hit = sessionSkip.get(sid)
1064
- if (hit !== undefined) return hit
1065
- try {
1066
- const info = await ctx.session.get({ sessionID: sid })
1067
- const skip = Boolean(info?.parentID) || String(info?.title ?? "").startsWith("geneseed-")
1068
- sessionSkip.set(sid, skip)
1069
- return skip
1070
- } catch {
1071
- return false
1072
- }
1206
+ // Same scope rule as V1's skipSession. V2's info names the parent `parentID` only, so only
1207
+ // that and the title are passed on.
1208
+ const skipSession = sessionSkipper(async (sid) => {
1209
+ const info = await ctx.session.get({ sessionID: sid })
1210
+ return { parentID: info?.parentID, title: info?.title }
1211
+ })
1212
+
1213
+ // The done gate is not judged on OpenCode 2 (see `doneOwed`): said once, where its log goes.
1214
+ if ((await readDoneChecks(root).catch(() => [])).length) {
1215
+ console.error(`[geneseed-context] done gate: ${DONE_LIST} found, but OpenCode 2 turns are not judged.`)
1073
1216
  }
1074
1217
 
1075
1218
  if (!VISIBLE) {
@@ -1109,7 +1252,7 @@ async function setupV2(ctx) {
1109
1252
  if (ev?.type !== "session.created" || INJECT_OFF) continue
1110
1253
  const d = ev.data ?? {}
1111
1254
  try {
1112
- if (d.parentID || String(d.title ?? "").startsWith("geneseed-")) continue
1255
+ if (isSkippedSession({ parentID: d.parentID, title: d.title })) continue
1113
1256
  if (await sovereignBypass(root)) continue
1114
1257
  const { block } = await resolveBlock(root)
1115
1258
  if (!block) continue
@@ -2,7 +2,10 @@
2
2
  //
3
3
  // Enforces the safety Laws at the tool boundary (`tool.execute.before`), the same
4
4
  // "enforce by injection, don't just instruct" stance as the context plugin:
5
- // - Sealed Secrets: block writes to private-key / credential files.
5
+ // - Sealed Secrets: block writes to private-key / credential files, and any write whose
6
+ // added content carries a credential-shaped string (`.env`-shaped targets exempt).
7
+ // - External Gate: block writes to a check listed in `.geneseed/protected-checks.txt`,
8
+ // and to the done gate's own list `.geneseed/done-checks.txt`.
6
9
  // - Deletion Is Deliberate: block catastrophic shell commands.
7
10
  // - Commands Must Return: block a recursive scan rooted at a whole filesystem (find /).
8
11
  // - Persist Insight (rule vs memory): speed-bump the first write to user-rules.md
@@ -10,7 +13,10 @@
10
13
  // - Wiki (AGENT.md §8): block mutations under a declared wiki's `protected`
11
14
  // folders — the user's knowledge base sets its own no-go zones in geneseed-wiki.jsonc.
12
15
  // High-confidence patterns only, so legitimate work is never caught. Borderline cases
13
- // (.env edits, force-push) are WARNED, not blocked.
16
+ // (.env edits, force-push) are WARNED, not blocked. Every block is recorded in the gate
17
+ // ledger (`<cfg>/notebook/gates.jsonl`) under the hook's rule key (`law-1`, `rigor-5`,
18
+ // `law-4`, `ops-2`, `process-1`, `wiki`); every skill or subagent call, blocked or not, is
19
+ // counted in `<cfg>/notebook/usage.jsonl` when that `notebook/` exists and `.usage-off` does not.
14
20
  //
15
21
  // GENESEED_GUARD=off disable entirely.
16
22
  // GENESEED_GUARD=warn downgrade every block to a warning (log, but allow).
@@ -128,6 +134,9 @@ function log(msg) { console.error(`[geneseed-guard] ${msg}`) }
128
134
  // at the newest MAX_LEDGER_LINES like the hook's; never throws, never decides.
129
135
  const MAX_LEDGER_LINES = 1000
130
136
  async function ledger(rule) {
137
+ // `geneseed doctor --probe` blocks on purpose; its blocks are not the user's (the twin of
138
+ // the same line in js/hosts/hooks.mjs). The record only — never the decision.
139
+ if (process.env.GENESEED_GATE_PROBE) return
131
140
  try {
132
141
  const bases = []
133
142
  if (process.env.GENESEED_HARNESS) bases.push(process.env.GENESEED_HARNESS)
@@ -314,23 +323,10 @@ const rootArg = (t) => !/^["']/.test(t) && FS_ROOT_RE.test(t)
314
323
  // (`packages/opencode/src/patch/index.ts` `parsePatchHeader`): `*** Add File:`,
315
324
  // `*** Update File:` (optionally followed by `*** Move to:`, both the old and the new
316
325
  // path count) and `*** Delete File:`. The `+`-prefixed body lines are the new file
317
- // content, never a path, and are not scanned here — no gate in this file inspects
318
- // write content, only the target path.
326
+ // content, never a path: `parsePatchSections` below collects them for the content secret
327
+ // scan, and this list carries only the target paths, both sides of a move included.
319
328
  function parsePatchPaths(patchText) {
320
- const out = []
321
- const lines = String(patchText).split(/\r?\n/)
322
- for (let i = 0; i < lines.length; i++) {
323
- const line = lines[i]
324
- let m = /^\*\*\* (?:Add|Delete) File:\s*(.+)$/.exec(line)
325
- if (m) { out.push(m[1].trim()); continue }
326
- m = /^\*\*\* Update File:\s*(.+)$/.exec(line)
327
- if (m) {
328
- out.push(m[1].trim())
329
- const mv = /^\*\*\* Move to:\s*(.+)$/.exec(lines[i + 1] || "")
330
- if (mv) { out.push(mv[1].trim()); i++ }
331
- }
332
- }
333
- return out.filter(Boolean)
329
+ return patchSections(patchText).flatMap((s) => [s.from, s.path]).filter(Boolean)
334
330
  }
335
331
 
336
332
  // Returns every candidate path for this call, normalized (backslashes -> `/`) and,
@@ -359,6 +355,12 @@ function pickPath(args, cwd) {
359
355
  // land, so content is attributed to it, not the pre-move path. `*** Delete File:` sections
360
356
  // collect no `+` lines (nothing is being written).
361
357
  function parsePatchSections(patchText) {
358
+ return patchSections(patchText).filter((s) => s.path)
359
+ }
360
+ // The one marker parser both lists derive from. `from` is the pre-move path of an
361
+ // `Update File` followed by `Move to` (empty otherwise); `path` is where the file lands.
362
+ // Unfiltered: an empty marker path is dropped by each caller on its own terms.
363
+ function patchSections(patchText) {
362
364
  const out = []
363
365
  let current = null
364
366
  const flush = () => { if (current) out.push(current) }
@@ -366,20 +368,20 @@ function parsePatchSections(patchText) {
366
368
  for (let i = 0; i < lines.length; i++) {
367
369
  const line = lines[i]
368
370
  let m = /^\*\*\* (?:Add|Delete) File:\s*(.+)$/.exec(line)
369
- if (m) { flush(); current = { path: m[1].trim(), added: [] }; continue }
371
+ if (m) { flush(); current = { from: "", path: m[1].trim(), added: [] }; continue }
370
372
  m = /^\*\*\* Update File:\s*(.+)$/.exec(line)
371
373
  if (m) {
372
374
  flush()
373
- let p = m[1].trim()
375
+ let from = "", p = m[1].trim()
374
376
  const mv = /^\*\*\* Move to:\s*(.+)$/.exec(lines[i + 1] || "")
375
- if (mv) { p = mv[1].trim(); i++ }
376
- current = { path: p, added: [] }
377
+ if (mv) { from = p; p = mv[1].trim(); i++ }
378
+ current = { from, path: p, added: [] }
377
379
  continue
378
380
  }
379
381
  if (current && line.startsWith("+") && !line.startsWith("+++")) current.added.push(line.slice(1))
380
382
  }
381
383
  flush()
382
- return out.filter((s) => s.path)
384
+ return out
383
385
  }
384
386
 
385
387
  // The content a write/edit/apply_patch call would actually land on disk, per FILE — what the
@@ -425,8 +427,12 @@ const hasAny = (name, parts) => parts.some((s) => name.includes(s))
425
427
  // geneseed-wiki.jsonc (the machine-level knowledge-base manifest) may list `protected` folders
426
428
  // per wiki. Mutating anything under one is denied. Same resolution chain as the
427
429
  // context plugin: $GENESEED_WIKI -> $GENESEED_HARNESS/geneseed-wiki.jsonc -> beside the install.
428
- async function isFile(p) { try { return (await fs.stat(p)).isFile() } catch { return false } }
429
- async function isDir(p) { try { return (await fs.stat(p)).isDirectory() } catch { return false } }
430
+ async function isFile(p) {
431
+ try { return (await fs.stat(p)).isFile() } catch { return false }
432
+ }
433
+ async function isDir(p) {
434
+ try { return (await fs.stat(p)).isDirectory() } catch { return false }
435
+ }
430
436
 
431
437
  // The wiki manifest is JSONC (the seeded stub carries a commented example): strip // and
432
438
  // /* */ comments plus trailing commas before parsing — string-aware, so quoted
@@ -545,6 +551,9 @@ function ruleStoreTarget(p) {
545
551
  // line; the list protects itself. Found from the WRITE TARGET up to the first `.git`, re-read on
546
552
  // every write (a short file, read only on a write call). No ask tier here, so a hit is a deny.
547
553
  const SENSOR_LIST = ".geneseed/protected-checks.txt"
554
+ // The done gate's list (#289, js/hosts/hooks-done.mjs) protects itself the same way, with or
555
+ // without a protected-checks list: emptying it would disarm the gate. Twin of js/hosts/hooks.mjs.
556
+ const DONE_LIST = ".geneseed/done-checks.txt"
548
557
  function protectedCheck(p) {
549
558
  try {
550
559
  const target = path.resolve(p)
@@ -554,9 +563,10 @@ function protectedCheck(p) {
554
563
  if (up === root) return null
555
564
  root = up
556
565
  }
557
- const text = readFileSync(path.join(root, SENSOR_LIST), "utf8")
558
566
  const fold = (s) => nocase(s.replace(/\\/g, "/"))
559
567
  const rel = fold(path.relative(root, target))
568
+ if (rel === fold(DONE_LIST)) return DONE_LIST
569
+ const text = readFileSync(path.join(root, SENSOR_LIST), "utf8")
560
570
  const entries = [SENSOR_LIST, ...text.split(/\r?\n/).map((l) => l.trim().replace(/[\\/]+$/, ""))
561
571
  .filter((l) => l && !l.startsWith("#"))]
562
572
  return entries.find((e) => rel === fold(e) || rel.startsWith(fold(e) + "/")) ?? null
@@ -582,7 +592,7 @@ function usageOf(tool, args) {
582
592
  let kind = null
583
593
  let name = null
584
594
  if (!t.startsWith("mcp") && t.includes("skill")) { kind = "skill"; name = a.skill ?? a.name ?? a.skill_name }
585
- else if (t === "agent" || t === "task") { kind = "agent"; name = a.subagent_type ?? a.agent }
595
+ else if (t === "agent" || t === "task" || t === "subagent") { kind = "agent"; name = a.subagent_type ?? a.agent }
586
596
  if (!kind || typeof name !== "string") return null
587
597
  name = name.trim().replace(/^\/+/, "")
588
598
  return USAGE_NAME_RE.test(name) ? { kind, name } : null
@@ -605,9 +615,12 @@ export const GeneseedGuard = async (ctx) => {
605
615
  const root = () => ctx?.worktree || ctx?.directory || process.cwd()
606
616
  return {
607
617
  "tool.execute.before": async (input, output) => {
618
+ // Bypass before counting, the order Claude's toolGate keeps: an excluded repo is
619
+ // dormant, so nothing is written there. GENESEED_GUARD=off still counts — Claude's
620
+ // hook has no such switch; GENESEED_NO_USAGE / .usage-off stop the counter.
621
+ if (await sovereignBypass(root())) return
608
622
  await recordUse(input?.tool || input?.name, output?.args || input?.args)
609
623
  if (OFF) return
610
- if (await sovereignBypass(root())) return
611
624
  const tool = (input?.tool || input?.name || "").toLowerCase()
612
625
  const args = output?.args || input?.args || {}
613
626
  // `rule` is the ledger key, spelled as js/hosts/hooks.mjs spells it (`law-1`,
@@ -635,7 +648,7 @@ export const GeneseedGuard = async (ctx) => {
635
648
  if (SECRET_RE.some((re) => re.test(p))) { await deny(`write to secret/key file ${p} (Sealed Secrets)`, "law-1"); return }
636
649
  if (SECRET_WARN_RE.some((re) => re.test(p))) log(`WARN: writing ${p} — keep secrets out of tracked files (Sealed Secrets)`)
637
650
  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 }
651
+ 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
652
  const store = ruleStoreTarget(p)
640
653
  if (store && !RULE_STORE_BUMPED.has(p)) {
641
654
  RULE_STORE_BUMPED.add(p)
@@ -304,7 +304,7 @@ function parseFrontmatter(chunk) {
304
304
 
305
305
  // A memory `name:` becomes a FILENAME and the model writes it, so it is a plain slug or
306
306
  // nothing — no separator, no `..`, no drive. The same rule as MEMORY_SLUG_RE in
307
- // js/hosts/hooks.mjs, inlined because this file ships standalone.
307
+ // js/hosts/memory-files.mjs, inlined because this file ships standalone.
308
308
  const MEMORY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/
309
309
 
310
310
  // The chunk without its frontmatter `action:` line, which is an instruction, not the fact.