geneseed 4.5.0 → 4.7.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 (70) hide show
  1. package/CHANGELOG.md +30 -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 +108 -61
  6. package/adapters/opencode/plugins/geneseed-guard.js +33 -28
  7. package/adapters/opencode/plugins/geneseed-learn.js +1 -1
  8. package/docs/guides/install-clone.md +1 -1
  9. package/docs/guides/install.md +2 -2
  10. package/docs/reference/troubleshoot-install.md +5 -5
  11. package/harness.config.json +1 -1
  12. package/js/README.md +5 -4
  13. package/js/build/bundle.mjs +11 -2
  14. package/js/build/driver.mjs +1 -10
  15. package/js/build/emit-claude.mjs +7 -22
  16. package/js/build/emit-common.mjs +16 -9
  17. package/js/build/emit-opencode.mjs +5 -11
  18. package/js/build/generate.mjs +1 -1
  19. package/js/hosts/hooks-context.mjs +1 -4
  20. package/js/hosts/hooks-done.mjs +23 -6
  21. package/js/hosts/hooks-learn.mjs +5 -5
  22. package/js/hosts/hooks.mjs +28 -15
  23. package/js/hosts/hosts.mjs +2 -0
  24. package/js/hosts/installs.mjs +45 -10
  25. package/js/hosts/native.mjs +5 -13
  26. package/js/hosts/opencode.mjs +37 -22
  27. package/js/hosts/owned.mjs +19 -5
  28. package/js/hosts/settings.mjs +27 -18
  29. package/js/hosts/shim.mjs +4 -3
  30. package/js/hosts/usage.mjs +4 -3
  31. package/js/inspect/checks-authoring.mjs +9 -7
  32. package/js/inspect/checks-probe.mjs +14 -17
  33. package/js/inspect/checks-repo.mjs +13 -54
  34. package/js/inspect/cost.mjs +5 -6
  35. package/js/inspect/doctor.mjs +2 -2
  36. package/js/inspect/scan.mjs +9 -5
  37. package/js/inspect/scorecard-svg.mjs +3 -3
  38. package/js/inspect/scorecard-yaml.mjs +407 -0
  39. package/js/inspect/scorecard.mjs +7 -405
  40. package/js/inspect/status.mjs +14 -13
  41. package/js/lib/node-floor.mjs +4 -3
  42. package/js/maintain/migrate.mjs +2 -3
  43. package/js/maintain/uninstall.mjs +20 -26
  44. package/js/web/actions.mjs +2 -2
  45. package/js/web/api.mjs +36 -24
  46. package/js/web/gates-api.mjs +5 -8
  47. package/js/web/usage-api.mjs +14 -17
  48. package/package.json +2 -2
  49. package/web/dist/assets/{Activity-BZE6REef.js → Activity-Dj0izJwj.js} +1 -1
  50. package/web/dist/assets/{ActivityDetail-DCB8Cr2W.js → ActivityDetail-wjq5W02K.js} +1 -1
  51. package/web/dist/assets/Dashboard-UMbgHbzF.js +2 -0
  52. package/web/dist/assets/{Docs-D30CiqYy.js → Docs-D3ABE_ds.js} +3 -3
  53. package/web/dist/assets/{GreenhouseView-Djt-4oO5.js → GreenhouseView-C238LWBD.js} +1 -1
  54. package/web/dist/assets/{Installs-B3bw5ckh.js → Installs-XSm0a_zQ.js} +2 -2
  55. package/web/dist/assets/JournalView-BUj4Ff-6.js +1 -0
  56. package/web/dist/assets/{Laws-B9day2FV.js → Laws-yGBJFKgY.js} +1 -1
  57. package/web/dist/assets/{Library-BpIrLH-O.js → Library-CkaPVUYU.js} +1 -1
  58. package/web/dist/assets/{Loops-CxH14kyE.js → Loops-CexwOARr.js} +1 -1
  59. package/web/dist/assets/{OperatorHudView-otOz6Gha.js → OperatorHudView-D5qTQat0.js} +1 -1
  60. package/web/dist/assets/{Personal-abL6089y.js → Personal-B_Vx9a7a.js} +1 -1
  61. package/web/dist/assets/Tabs-YZbGqWPW.js +1 -0
  62. package/web/dist/assets/drawable-BoQnwVpr.js +1 -0
  63. package/web/dist/assets/index-BmbWeoTM.js +2 -0
  64. package/web/dist/assets/{index-CO-9wMJY.css → index-BoG1M8Xy.css} +1 -1
  65. package/web/dist/index.html +2 -2
  66. package/web/dist/assets/Dashboard-B4Drq5wp.js +0 -2
  67. package/web/dist/assets/JournalView-OWhx9YKg.js +0 -1
  68. package/web/dist/assets/Tabs-DjWN0i7A.js +0 -1
  69. package/web/dist/assets/drawable-C2ZsBZCv.js +0 -1
  70. package/web/dist/assets/index-CdpLpPQN.js +0 -2
package/CHANGELOG.md CHANGED
@@ -8,6 +8,36 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [4.7.0] — 2026-10-17
12
+
13
+ Minor: the Deletion Is Deliberate gate stops asking on worktree removal.
14
+
15
+ ### Upgrade notes
16
+ - Run `geneseed rebuild-all` once after updating so installed hooks and OpenCode plugins pick up the change.
17
+ - OpenCode: an `opencode.json` written by an earlier version keeps the four `git worktree remove … -f`/`--force` keys under `permission.bash`. Geneseed no longer claims them, so it neither removes nor reports them; delete them by hand to stop the prompt.
18
+
19
+ ### Changed
20
+ - **`git worktree remove --force` no longer asks.** Removing a worktree is routine agent housekeeping, so the *Deletion Is Deliberate* gate now lets `worktree remove -f`/`--force` through like a plain `worktree remove`, on every host: the Claude Code/OpenClaude/Bob git-gate, OpenCode's permission globs and the guard plugin's warning. Every other destructive git form is gated as before.
21
+
22
+ ## [4.6.0] — 2026-10-17
23
+
24
+ Minor: a refactor and cleanup pass over everything since 3.15.0, and the nine gate and counter bugs it turned up.
25
+
26
+ ### Upgrade notes
27
+ - **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).
28
+ - Run `geneseed rebuild-all` once after updating so the installed hooks and OpenCode plugins pick up the fixes below.
29
+
30
+ ### Fixed
31
+ - **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).
32
+ - **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).
33
+ - **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).
34
+ - **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).
35
+ - **The console shows *Verify Before Asserting* as hook-gated**, matching the doctor, since the done gate enforces it (#306).
36
+ - The dead-shim warning names the release (4.0.0) instead of an internal plan step (#302).
37
+
38
+ ### Changed
39
+ - 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.
40
+
11
41
  ## [4.5.0] — 2026-10-17
12
42
 
13
43
  Minor: gates that are proven, not just present — a done gate at turn end, `doctor --probe`, and Bob's refusals restored on Windows.
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.5.0**.
23
+ > string. Current label: **4.7.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
  }
@@ -901,6 +907,18 @@ async function readDoneChecks(root) {
901
907
  function runsUnpiped(command, line) {
902
908
  const cmd = String(command).replace(/[ \t]+/g, " ")
903
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
904
922
  const rest = cmd.slice(i + line.length)
905
923
  let piped = false
906
924
  for (let j = 0; j < rest.length; j += 1) {
@@ -913,6 +931,33 @@ function runsUnpiped(command, line) {
913
931
  return false
914
932
  }
915
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
+
916
961
  // The checks still owed, judged over the previous turn and the current one: a reminder lasts
917
962
  // one turn, not the whole session. Our own injected messages (`geneseed-*` ids) open no turn.
918
963
  function doneOwed(messages, checks, root) {
@@ -925,14 +970,21 @@ function doneOwed(messages, checks, root) {
925
970
  for (const m of messages.slice(from)) {
926
971
  for (const p of m?.parts ?? []) if (p?.type === "tool" && p.state) tools.push(p)
927
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
+ }
928
978
  let lastEdit = -1
929
979
  tools.forEach((p, i) => {
930
980
  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
- }
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
936
988
  lastEdit = i
937
989
  })
938
990
  if (lastEdit === -1) return []
@@ -947,6 +999,31 @@ function doneOwed(messages, checks, root) {
947
999
  return checks.filter((c) => !passed.has(c))
948
1000
  }
949
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
+
950
1027
  export const GeneseedContext = async (ctx) => {
951
1028
  const { client } = ctx
952
1029
  const done = new Set()
@@ -963,15 +1040,12 @@ export const GeneseedContext = async (ctx) => {
963
1040
  // context). The sid is only marked done AFTER the guards pass — marking it
964
1041
  // earlier would permanently skip a session when a transient session.get
965
1042
  // failure let it through the title check.
966
- let title = "", parent
1043
+ let meta
967
1044
  try {
968
1045
  const info = await client.session.get({ path: { id: sid } })
969
- const meta = info?.data ?? info
970
- title = meta?.title ?? ""
971
- parent = meta?.parentID ?? meta?.parentId
1046
+ meta = info?.data ?? info
972
1047
  } catch {}
973
- if (parent) { log("skipped: child/subagent session"); return }
974
- if (title.startsWith("geneseed-")) { log("skipped: geneseed-* session"); return }
1048
+ if (isSkippedSession(meta)) { log("skipped: child/subagent or geneseed-* session"); return }
975
1049
 
976
1050
  if (await alreadyInjected(client, sid)) { log("skipped: already injected (marker present)"); return }
977
1051
  done.add(sid)
@@ -998,28 +1072,13 @@ export const GeneseedContext = async (ctx) => {
998
1072
  }
999
1073
  }
1000
1074
 
1001
- // Session-scope filter for the INVISIBLE delivery, mirroring injectVisible's
1002
- // guards: child/subagent sessions inherit the parent's context, and geneseed-*
1003
- // sessions (learn distils, geneseed-wf:* children) must stay clean — without this
1004
- // the transform prepends the full context block to every request of every session
1005
- // in-process. Cached per sid (the transform runs on EVERY request); a transient
1006
- // session.get failure is NOT cached so the next request re-checks.
1007
- const sessionSkip = new Map()
1008
- async function skipSession(sid) {
1009
- if (!sid) return false
1010
- const hit = sessionSkip.get(sid)
1011
- if (hit !== undefined) return hit
1012
- try {
1013
- const info = await client.session.get({ path: { id: sid } })
1014
- const meta = info?.data ?? info
1015
- const skip = Boolean(meta?.parentID ?? meta?.parentId) ||
1016
- String(meta?.title ?? "").startsWith("geneseed-")
1017
- sessionSkip.set(sid, skip)
1018
- return skip
1019
- } catch {
1020
- return false
1021
- }
1022
- }
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
+ })
1023
1082
 
1024
1083
  return {
1025
1084
  event: async ({ event }) => {
@@ -1072,13 +1131,15 @@ export const GeneseedContext = async (ctx) => {
1072
1131
  // detects that and takes over with the visible delivery.
1073
1132
  "experimental.chat.messages.transform": async (_input, output) => {
1074
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
1075
1139
  // The done gate's reminder: FIRST, and independent of the context block:
1076
1140
  // INJECT_OFF and the visible delivery leave it on, and no early return below can skip it.
1077
1141
  // Its own function, so its early returns end the reminder, never the context injection.
1078
1142
  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
1143
  const checks = await readDoneChecks(root)
1083
1144
  if (!checks.length) return
1084
1145
  const owed = doneOwed(output.messages, checks, root)
@@ -1094,11 +1155,7 @@ export const GeneseedContext = async (ctx) => {
1094
1155
  log("transform: done-gate reminder injected")
1095
1156
  })().catch((err) => log(`done-gate error: ${err?.message ?? err}`))
1096
1157
  if (INJECT_OFF || !TRANSFORM || fallbackVisible) return
1097
- if (await sovereignBypass(root)) return
1098
1158
  try {
1099
- if (!output || !Array.isArray(output.messages) || !output.messages.length) return
1100
- const sid = output.messages[0]?.info?.sessionID
1101
- if (await skipSession(sid)) return
1102
1159
  // Already present this request? (idempotent across plugin copies / retries.)
1103
1160
  const present = output.messages.some((m) =>
1104
1161
  (m?.parts ?? []).some((p) => typeof p?.text === "string" && p.text.includes(MARKER)))
@@ -1146,22 +1203,12 @@ const v2Model = (ref) => (ref?.providerID && ref?.id ? `${ref.providerID}/${ref.
1146
1203
  async function setupV2(ctx) {
1147
1204
  const root = repoRoot({ worktree: ctx?.location?.project?.directory, directory: ctx?.location?.directory })
1148
1205
  const blockCache = { text: null, at: 0 }
1149
- // Same scope rule as V1's skipSession: child/subagent sessions inherit the parent's
1150
- // context, geneseed-* sessions stay clean. A failed lookup is not cached.
1151
- const sessionSkip = new Map()
1152
- async function skipSession(sid) {
1153
- if (!sid) return false
1154
- const hit = sessionSkip.get(sid)
1155
- if (hit !== undefined) return hit
1156
- try {
1157
- const info = await ctx.session.get({ sessionID: sid })
1158
- const skip = Boolean(info?.parentID) || String(info?.title ?? "").startsWith("geneseed-")
1159
- sessionSkip.set(sid, skip)
1160
- return skip
1161
- } catch {
1162
- return false
1163
- }
1164
- }
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
+ })
1165
1212
 
1166
1213
  // The done gate is not judged on OpenCode 2 (see `doneOwed`): said once, where its log goes.
1167
1214
  if ((await readDoneChecks(root).catch(() => [])).length) {
@@ -1205,7 +1252,7 @@ async function setupV2(ctx) {
1205
1252
  if (ev?.type !== "session.created" || INJECT_OFF) continue
1206
1253
  const d = ev.data ?? {}
1207
1254
  try {
1208
- if (d.parentID || String(d.title ?? "").startsWith("geneseed-")) continue
1255
+ if (isSkippedSession({ parentID: d.parentID, title: d.title })) continue
1209
1256
  if (await sovereignBypass(root)) continue
1210
1257
  const { block } = await resolveBlock(root)
1211
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).
@@ -222,7 +228,6 @@ const SHELL_WARN_RE = [
222
228
  /\bgit\s+switch\b[^\n]*\s(-[a-zA-Z]*f\b|--force\b|--discard-changes\b)/,
223
229
  /\bgit\s+restore\b(?:(?![^\n]*--staged\b)|(?=[^\n]*--staged\b)(?=[^\n]*--worktree\b))/,
224
230
  /\bgit\s+stash\s+(drop|clear)\b/,
225
- /\bgit\s+worktree\b[^\n]*\bremove\b[^\n]*(-[a-zA-Z]*f\b|--force\b)/,
226
231
  /\bgit\s+reflog\b[^\n]*\bexpire\b/,
227
232
  /\bgit\s+gc\b[^\n]*--prune\b/,
228
233
  ]
@@ -317,23 +322,10 @@ const rootArg = (t) => !/^["']/.test(t) && FS_ROOT_RE.test(t)
317
322
  // (`packages/opencode/src/patch/index.ts` `parsePatchHeader`): `*** Add File:`,
318
323
  // `*** Update File:` (optionally followed by `*** Move to:`, both the old and the new
319
324
  // path count) and `*** Delete File:`. The `+`-prefixed body lines are the new file
320
- // content, never a path, and are not scanned here — no gate in this file inspects
321
- // write content, only the target path.
325
+ // content, never a path: `parsePatchSections` below collects them for the content secret
326
+ // scan, and this list carries only the target paths, both sides of a move included.
322
327
  function parsePatchPaths(patchText) {
323
- const out = []
324
- const lines = String(patchText).split(/\r?\n/)
325
- for (let i = 0; i < lines.length; i++) {
326
- const line = lines[i]
327
- let m = /^\*\*\* (?:Add|Delete) File:\s*(.+)$/.exec(line)
328
- if (m) { out.push(m[1].trim()); continue }
329
- m = /^\*\*\* Update File:\s*(.+)$/.exec(line)
330
- if (m) {
331
- out.push(m[1].trim())
332
- const mv = /^\*\*\* Move to:\s*(.+)$/.exec(lines[i + 1] || "")
333
- if (mv) { out.push(mv[1].trim()); i++ }
334
- }
335
- }
336
- return out.filter(Boolean)
328
+ return patchSections(patchText).flatMap((s) => [s.from, s.path]).filter(Boolean)
337
329
  }
338
330
 
339
331
  // Returns every candidate path for this call, normalized (backslashes -> `/`) and,
@@ -362,6 +354,12 @@ function pickPath(args, cwd) {
362
354
  // land, so content is attributed to it, not the pre-move path. `*** Delete File:` sections
363
355
  // collect no `+` lines (nothing is being written).
364
356
  function parsePatchSections(patchText) {
357
+ return patchSections(patchText).filter((s) => s.path)
358
+ }
359
+ // The one marker parser both lists derive from. `from` is the pre-move path of an
360
+ // `Update File` followed by `Move to` (empty otherwise); `path` is where the file lands.
361
+ // Unfiltered: an empty marker path is dropped by each caller on its own terms.
362
+ function patchSections(patchText) {
365
363
  const out = []
366
364
  let current = null
367
365
  const flush = () => { if (current) out.push(current) }
@@ -369,20 +367,20 @@ function parsePatchSections(patchText) {
369
367
  for (let i = 0; i < lines.length; i++) {
370
368
  const line = lines[i]
371
369
  let m = /^\*\*\* (?:Add|Delete) File:\s*(.+)$/.exec(line)
372
- if (m) { flush(); current = { path: m[1].trim(), added: [] }; continue }
370
+ if (m) { flush(); current = { from: "", path: m[1].trim(), added: [] }; continue }
373
371
  m = /^\*\*\* Update File:\s*(.+)$/.exec(line)
374
372
  if (m) {
375
373
  flush()
376
- let p = m[1].trim()
374
+ let from = "", p = m[1].trim()
377
375
  const mv = /^\*\*\* Move to:\s*(.+)$/.exec(lines[i + 1] || "")
378
- if (mv) { p = mv[1].trim(); i++ }
379
- current = { path: p, added: [] }
376
+ if (mv) { from = p; p = mv[1].trim(); i++ }
377
+ current = { from, path: p, added: [] }
380
378
  continue
381
379
  }
382
380
  if (current && line.startsWith("+") && !line.startsWith("+++")) current.added.push(line.slice(1))
383
381
  }
384
382
  flush()
385
- return out.filter((s) => s.path)
383
+ return out
386
384
  }
387
385
 
388
386
  // The content a write/edit/apply_patch call would actually land on disk, per FILE — what the
@@ -428,8 +426,12 @@ const hasAny = (name, parts) => parts.some((s) => name.includes(s))
428
426
  // geneseed-wiki.jsonc (the machine-level knowledge-base manifest) may list `protected` folders
429
427
  // per wiki. Mutating anything under one is denied. Same resolution chain as the
430
428
  // context plugin: $GENESEED_WIKI -> $GENESEED_HARNESS/geneseed-wiki.jsonc -> beside the install.
431
- async function isFile(p) { try { return (await fs.stat(p)).isFile() } catch { return false } }
432
- async function isDir(p) { try { return (await fs.stat(p)).isDirectory() } catch { return false } }
429
+ async function isFile(p) {
430
+ try { return (await fs.stat(p)).isFile() } catch { return false }
431
+ }
432
+ async function isDir(p) {
433
+ try { return (await fs.stat(p)).isDirectory() } catch { return false }
434
+ }
433
435
 
434
436
  // The wiki manifest is JSONC (the seeded stub carries a commented example): strip // and
435
437
  // /* */ comments plus trailing commas before parsing — string-aware, so quoted
@@ -589,7 +591,7 @@ function usageOf(tool, args) {
589
591
  let kind = null
590
592
  let name = null
591
593
  if (!t.startsWith("mcp") && t.includes("skill")) { kind = "skill"; name = a.skill ?? a.name ?? a.skill_name }
592
- else if (t === "agent" || t === "task") { kind = "agent"; name = a.subagent_type ?? a.agent }
594
+ else if (t === "agent" || t === "task" || t === "subagent") { kind = "agent"; name = a.subagent_type ?? a.agent }
593
595
  if (!kind || typeof name !== "string") return null
594
596
  name = name.trim().replace(/^\/+/, "")
595
597
  return USAGE_NAME_RE.test(name) ? { kind, name } : null
@@ -612,9 +614,12 @@ export const GeneseedGuard = async (ctx) => {
612
614
  const root = () => ctx?.worktree || ctx?.directory || process.cwd()
613
615
  return {
614
616
  "tool.execute.before": async (input, output) => {
617
+ // Bypass before counting, the order Claude's toolGate keeps: an excluded repo is
618
+ // dormant, so nothing is written there. GENESEED_GUARD=off still counts — Claude's
619
+ // hook has no such switch; GENESEED_NO_USAGE / .usage-off stop the counter.
620
+ if (await sovereignBypass(root())) return
615
621
  await recordUse(input?.tool || input?.name, output?.args || input?.args)
616
622
  if (OFF) return
617
- if (await sovereignBypass(root())) return
618
623
  const tool = (input?.tool || input?.name || "").toLowerCase()
619
624
  const args = output?.args || input?.args || {}
620
625
  // `rule` is the ledger key, spelled as js/hosts/hooks.mjs spells it (`law-1`,
@@ -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.
@@ -6,7 +6,7 @@ kind: "concept"
6
6
  section: "Install"
7
7
  description: "Install from a git clone when npm is out of reach, or when you want to change the harness."
8
8
  ---
9
- Clone the repository when npm is out of reach (for example, a corporate network with no registry access), or when you want to change the harness rather than just use it. A clone needs **git** as well as Node 22.3 or newer. git is what updates the clone.
9
+ Clone the repository when npm is out of reach (for example, a corporate network with no registry access), or when you want to change the harness rather than just use it. A clone needs **git** as well as Node 22.7 or newer. git is what updates the clone.
10
10
 
11
11
  Each OS has a one-step installer at the root of the clone. It checks for Node and git. If one is missing, it names what to install and where to get it, then stops. **It never installs anything itself.** When the checks pass, it runs the setup wizard and offers to open the web console.
12
12
 
@@ -10,7 +10,7 @@ Geneseed builds a harness (the rules, agents, skills and hooks your AI coding to
10
10
 
11
11
  ## The 5-minute path
12
12
 
13
- The only prerequisite is **Node 22.3 or newer**.
13
+ The only prerequisite is **Node 22.7 or newer**.
14
14
 
15
15
  ```
16
16
  npx geneseed setup
@@ -30,7 +30,7 @@ Next: [Verify it works](verify.md). To see what the install just wrote, read [Wh
30
30
 
31
31
  ## Prerequisites
32
32
 
33
- - **Node 22.3 or newer.** This is the only hard requirement. Geneseed has zero runtime dependencies, and nothing it installs needs a second interpreter.
33
+ - **Node 22.7 or newer.** This is the only hard requirement. Geneseed has zero runtime dependencies, and nothing it installs needs a second interpreter.
34
34
  - **Your agent tool**: OpenCode (recommended), Claude Code, IBM Bob, OpenClaude, or any tool that reads a root instructions file.
35
35
  - **git**, for a clone only. A clone updates itself with `git pull`.
36
36
  - *Optional:* a document converter (MarkItDown, Pandoc or Docling) if you want the agent to read PDFs and Office files. See [Reading non-markdown docs](read-non-markdown.md).
@@ -12,7 +12,7 @@ Each entry gives the likely cause, the fix, and how to confirm it worked. First
12
12
 
13
13
  The one-step installer (`./install`, `install.cmd`, `install.command`) checks Node first and stops:
14
14
 
15
- > `xx Node v20.11.0 is too old — Geneseed needs 22.3 or newer.`
15
+ > `xx Node v20.11.0 is too old — Geneseed needs 22.7 or newer.`
16
16
 
17
17
  **Cause.** No Node on PATH, or an older one — Linux distribution packages are often behind.
18
18
 
@@ -24,15 +24,15 @@ The one-step installer (`./install`, `install.cmd`, `install.command`) checks No
24
24
  node --version
25
25
  ```
26
26
 
27
- prints 22.3 or newer, and the installer's first line reads `ok Node v…`.
27
+ prints 22.7 or newer, and the installer's first line reads `ok Node v…`.
28
28
 
29
- ## `geneseed needs Node 22.3 or newer`
29
+ ## `geneseed needs Node 22.7 or newer`
30
30
 
31
31
  Run without the installer — `npx geneseed`, a global npm install, or a hook — on an older Node, every Geneseed command stops with one sentence:
32
32
 
33
- > `geneseed needs Node 22.3 or newer; this is Node 20.18.1. Install a current Node from https://nodejs.org (or your package manager), then run the command again.`
33
+ > `geneseed needs Node 22.7 or newer; this is Node 20.18.1. Install a current Node from https://nodejs.org (or your package manager), then run the command again.`
34
34
 
35
- **Cause.** The `node` on PATH is older than 22.3. npm only *warns* about the engine requirement, so a global install succeeds anyway. A hook prints the same sentence and lets the tool call through rather than block it, so on Claude Code, Bob or OpenClaude the hooks are off until Node is upgraded.
35
+ **Cause.** The `node` on PATH is older than 22.7. npm only *warns* about the engine requirement, so a global install succeeds anyway. A hook prints the same sentence and lets the tool call through rather than block it, so on Claude Code, Bob or OpenClaude the hooks are off until Node is upgraded.
36
36
 
37
37
  **Fix.** Install the current Node.js LTS (see above), open a **new** terminal, and run the command again.
38
38
 
@@ -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.5.0"
5
+ "version": "4.7.0"
6
6
  }
package/js/README.md CHANGED
@@ -82,7 +82,7 @@ user co-owns, and the hook verbs those emitted configs then run.
82
82
  | `native.mjs` | Capability specs → host-native subagents and skills. The impure half of the emit |
83
83
  | `opencode.mjs` | OpenCode-only extras: colour themes, primary agent, `/commands`, plugins, overrides stub |
84
84
  | `installs.mjs` | Detects deployed installs and reads back their theme, footprint, posture, mode and loop trust preset |
85
- | `mcp.mjs` | MCP server presets, the per-host `mcpServers` config read/write, and toggle state |
85
+ | `mcp.mjs` | Reads each host's MCP config (`mcpServers`, or OpenCode's `mcp`) and where it lives — read-only since 2026-10: the user edits the file, Geneseed only shows it |
86
86
  | `link.mjs` | `geneseed link`/`unlink` — the PATH shim, and the only Windows USER-Path registry edit |
87
87
  | `owned.mjs` | Manifest rel -> path on disk decisions: `ownedWithin`, the containment boundary `uninstall.mjs` and the driver's prune both go through before acting on an `owned` list entry |
88
88
 
@@ -101,7 +101,7 @@ Everything that inspects an already-built or already-deployed harness and report
101
101
  |---|---|
102
102
  | `doctor.mjs` | The `geneseed doctor` **driver** only: picks themes, runs the checks, prints the verdict |
103
103
  | `checks-build.mjs` | Gates over what the build PRODUCED: links, tokens, theme parity, OpenCode colours, bundle drift |
104
- | `checks-repo.mjs` | Gates over the repo's own records: `registry.json`, committed secrets, the hook shim, vendored pins |
104
+ | `checks-repo.mjs` | Gates over the repo's own records: `registry.json`, committed secrets, the hook shim, vendored pins and folder skills, skill aliases and side files, this module map, the host tables, the scorecard ratchet |
105
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
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 |
107
107
  | `checks-constitution.mjs` | The constitution: rule declarations and citations, LEAN blocks, `HOOK_PINNED`, theme vocabulary, the console's `LAW_META`/`DOCTRINE_META` |
@@ -114,7 +114,8 @@ Everything that inspects an already-built or already-deployed harness and report
114
114
  | `diff.mjs` | `geneseed diff` — what a deployed install has that a fresh render of `src/` does not |
115
115
  | `excludes.mjs` | `geneseed exclude add|remove|list` — the WRITER for `excludes.json`; `hooks.mjs` only reads it |
116
116
  | `registry.mjs` | `installs.json` under XDG — the persistent deploy-root list `rebuild-all` re-emits into |
117
- | `scorecard.mjs` | `geneseed scorecard` — a port of the AI Harness Scorecard (upstream pinned in its docblock): the repo walk, the CI-YAML subset parser, `assessRepo`, the text/JSON renderers. Pure: no spawn, no network |
117
+ | `scorecard.mjs` | `geneseed scorecard` — a port of the AI Harness Scorecard (upstream pinned in its docblock): the repo walk, the CI-YAML parsing, `assessRepo`, the text/JSON renderers. Pure: no spawn, no network |
118
+ | `scorecard-yaml.mjs` | The YAML 1.1 subset the scorecard reads CI configs with, plus Python's `str()`/`bool()` over its values; `scorecard.mjs` re-exports it. Its ceiling is in its `ponytail:` note |
118
119
  | `scorecard-svg.mjs` | The scorecard as an SVG card for a README (`geneseed scorecard --svg`): grade plus one bar per pillar, deterministic so doctor can compare the committed card byte for byte |
119
120
  | `scorecard-checks.mjs` | The scorecard's 31 checks in five pillars, ported line for line — ids, points, partial scores, evidence and fixes as upstream writes them |
120
121
 
@@ -138,7 +139,7 @@ into files users have committed, so change the test that states it in the same c
138
139
  | `udiff.mjs` | `geneseed diff`'s own difflib clone — the alignment IS the printed hunks — plus `splitLines` |
139
140
  | `proc.mjs` | One constant: the `windowsHide` flag every spawn in `js/` must reuse |
140
141
  | `prompt.mjs` | The line-mode stdin readers — `ask`, `promptLine`, `confirm`, `askChoice` — read a byte at a time so they stop where Enter stops. Shared by `setup`, `uninstall` and `web` without dragging in the wizard |
141
- | `node-floor.mjs` | The first import of all three binaries: below Node 22.3, one sentence on stderr and exit 1 (0 in the hook) instead of a stack trace |
142
+ | `node-floor.mjs` | The first import of all three binaries: below Node 22.7, one sentence on stderr and exit 1 (0 in the hook) instead of a stack trace |
142
143
 
143
144
  **Before editing:** parse with `parseJson`, never `JSON.parse` — the latter collapses int `1` and
144
145
  float `1.0` into one double, and that value gets written into a user's file. Do not swap in a diff