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