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.
- package/CHANGELOG.md +37 -0
- package/README.md +6 -6
- package/SHIPPED.md +1 -1
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +3 -2
- package/adapters/opencode/plugins/geneseed-context.js +195 -52
- package/adapters/opencode/plugins/geneseed-guard.js +42 -29
- package/adapters/opencode/plugins/geneseed-learn.js +1 -1
- package/docs/concepts/enforcement-table.md +9 -3
- package/docs/concepts/hooks.md +3 -0
- package/docs/extending.md +1 -1
- package/docs/guides/install-clone.md +1 -1
- package/docs/guides/install.md +2 -2
- package/docs/reference/plugin-context.md +2 -0
- package/docs/reference/troubleshoot-doctor.md +31 -0
- package/docs/reference/troubleshoot-install.md +5 -5
- package/docs/understand/enforced-vs-asked.md +1 -0
- package/harness.config.json +1 -1
- package/js/README.md +8 -5
- package/js/build/bundle.mjs +11 -2
- package/js/build/driver.mjs +1 -10
- package/js/build/emit-claude.mjs +7 -22
- package/js/build/emit-common.mjs +16 -9
- package/js/build/emit-opencode.mjs +5 -11
- package/js/build/generate.mjs +1 -1
- package/js/cli-table.json +15 -0
- package/js/hosts/hooks-context.mjs +1 -4
- package/js/hosts/hooks-done.mjs +186 -0
- package/js/hosts/hooks-learn.mjs +9 -4
- package/js/hosts/hooks.mjs +27 -8
- package/js/hosts/hosts.mjs +2 -0
- package/js/hosts/installs.mjs +47 -11
- package/js/hosts/native.mjs +5 -13
- package/js/hosts/opencode.mjs +37 -22
- package/js/hosts/owned.mjs +19 -5
- package/js/hosts/settings.mjs +17 -11
- package/js/hosts/shim.mjs +24 -10
- package/js/hosts/usage.mjs +4 -3
- package/js/inspect/checks-authoring.mjs +9 -7
- package/js/inspect/checks-constitution.mjs +2 -2
- package/js/inspect/checks-probe.mjs +292 -0
- package/js/inspect/checks-repo.mjs +13 -54
- package/js/inspect/cost.mjs +5 -6
- package/js/inspect/doctor.mjs +29 -3
- package/js/inspect/scan.mjs +9 -5
- package/js/inspect/scorecard-svg.mjs +3 -3
- package/js/inspect/scorecard-yaml.mjs +407 -0
- package/js/inspect/scorecard.mjs +7 -405
- package/js/inspect/status.mjs +14 -13
- package/js/lib/node-floor.mjs +4 -3
- package/js/maintain/migrate.mjs +2 -3
- package/js/maintain/uninstall.mjs +20 -26
- package/js/web/actions.mjs +2 -2
- package/js/web/api.mjs +36 -24
- package/js/web/gates-api.mjs +5 -8
- package/js/web/usage-api.mjs +14 -17
- package/package.json +2 -2
- package/src/skills/rule.md +11 -2
- package/web/dist/assets/{Activity-BZE6REef.js → Activity-Dj0izJwj.js} +1 -1
- package/web/dist/assets/{ActivityDetail-DCB8Cr2W.js → ActivityDetail-wjq5W02K.js} +1 -1
- package/web/dist/assets/Dashboard-UMbgHbzF.js +2 -0
- package/web/dist/assets/{Docs-D30CiqYy.js → Docs-D3ABE_ds.js} +3 -3
- package/web/dist/assets/{GreenhouseView-Djt-4oO5.js → GreenhouseView-C238LWBD.js} +1 -1
- package/web/dist/assets/{Installs-B3bw5ckh.js → Installs-XSm0a_zQ.js} +2 -2
- package/web/dist/assets/JournalView-BUj4Ff-6.js +1 -0
- package/web/dist/assets/{Laws-B9day2FV.js → Laws-yGBJFKgY.js} +1 -1
- package/web/dist/assets/{Library-BpIrLH-O.js → Library-CkaPVUYU.js} +1 -1
- package/web/dist/assets/{Loops-CxH14kyE.js → Loops-CexwOARr.js} +1 -1
- package/web/dist/assets/{OperatorHudView-otOz6Gha.js → OperatorHudView-D5qTQat0.js} +1 -1
- package/web/dist/assets/{Personal-abL6089y.js → Personal-B_Vx9a7a.js} +1 -1
- package/web/dist/assets/Tabs-YZbGqWPW.js +1 -0
- package/web/dist/assets/drawable-BoQnwVpr.js +1 -0
- package/web/dist/assets/index-BmbWeoTM.js +2 -0
- package/web/dist/assets/{index-CO-9wMJY.css → index-BoG1M8Xy.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/Dashboard-B4Drq5wp.js +0 -2
- package/web/dist/assets/JournalView-OWhx9YKg.js +0 -1
- package/web/dist/assets/Tabs-DjWN0i7A.js +0 -1
- package/web/dist/assets/drawable-C2ZsBZCv.js +0 -1
- 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
|
[](https://github.com/Arylmera/Geneseed/actions/workflows/ci.yml)
|
|
9
9
|
[](#ai-harness-scorecard)
|
|
10
10
|
[](LICENSE)
|
|
11
|
-
[](package.json)
|
|
12
12
|
[](package.json)
|
|
13
13
|
[](themes/)
|
|
14
14
|
[](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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 `
|
|
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) {
|
|
198
|
-
|
|
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
|
-
|
|
670
|
-
|
|
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
|
|
1043
|
+
let meta
|
|
897
1044
|
try {
|
|
898
1045
|
const info = await client.session.get({ path: { id: sid } })
|
|
899
|
-
|
|
900
|
-
title = meta?.title ?? ""
|
|
901
|
-
parent = meta?.parentID ?? meta?.parentId
|
|
1046
|
+
meta = info?.data ?? info
|
|
902
1047
|
} catch {}
|
|
903
|
-
if (
|
|
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,
|
|
932
|
-
// guards
|
|
933
|
-
//
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
|
1059
|
-
//
|
|
1060
|
-
const
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
|
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
|
|
318
|
-
//
|
|
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
|
-
|
|
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
|
|
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) {
|
|
429
|
-
|
|
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/
|
|
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.
|