geneseed 4.4.0 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +2 -2
  4. package/adapters/opencode/plugins/geneseed-context.js +96 -0
  5. package/adapters/opencode/plugins/geneseed-guard.js +9 -2
  6. package/docs/concepts/enforcement-table.md +9 -3
  7. package/docs/concepts/hooks.md +3 -0
  8. package/docs/concepts/skills.md +1 -0
  9. package/docs/extending.md +1 -1
  10. package/docs/reference/plugin-context.md +2 -0
  11. package/docs/reference/troubleshoot-doctor.md +31 -0
  12. package/docs/understand/enforced-vs-asked.md +1 -0
  13. package/harness.config.json +1 -1
  14. package/js/README.md +3 -1
  15. package/js/cli-table.json +15 -0
  16. package/js/hosts/hooks-done.mjs +169 -0
  17. package/js/hosts/hooks-learn.mjs +5 -0
  18. package/js/hosts/hooks.mjs +11 -2
  19. package/js/hosts/installs.mjs +2 -1
  20. package/js/hosts/shim.mjs +20 -7
  21. package/js/inspect/checks-constitution.mjs +2 -2
  22. package/js/inspect/checks-probe.mjs +295 -0
  23. package/js/inspect/cost.mjs +1 -1
  24. package/js/inspect/doctor.mjs +27 -1
  25. package/js/inspect/inventory.mjs +1 -1
  26. package/package.json +1 -1
  27. package/registry.json +7 -0
  28. package/src/AGENT.md.tmpl +1 -0
  29. package/src/skills/deck.md +56 -0
  30. package/src/skills/frontend-design.md +1 -0
  31. package/src/skills/rule.md +11 -2
  32. package/themes/_TEMPLATE.json +1 -0
  33. package/themes/biker.json +1 -0
  34. package/themes/commentator.json +1 -0
  35. package/themes/cyberpunk.json +1 -0
  36. package/themes/gamer.json +1 -0
  37. package/themes/imperial.json +1 -0
  38. package/themes/joker.json +1 -0
  39. package/themes/marvin.json +1 -0
  40. package/themes/mean.json +1 -0
  41. package/themes/military.json +1 -0
  42. package/themes/neutral.json +1 -0
  43. package/themes/pirate.json +1 -0
  44. package/themes/sports.json +1 -0
  45. package/themes/verstappen.json +1 -0
  46. package/themes/wizard.json +1 -0
package/CHANGELOG.md CHANGED
@@ -8,6 +8,34 @@ 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
+
29
+ ## [4.4.1] — 2026-10-16
30
+
31
+ A presentation skill.
32
+
33
+ ### Upgrade notes
34
+ - Run `geneseed rebuild-all` once after updating so installs list the new skill.
35
+
36
+ ### Added
37
+ - **deck skill.** Builds slides or a presentation as one self-contained offline HTML file: a short interview with defaults, the outline approved before generation, arrow-key, swipe and `#N` navigation, speaker notes on N, one slide per printed page; `/slides` and `/presentation` answer too. A `.pptx` request stays with the host's pptx skill.
38
+
11
39
  ## [4.4.0] — 2026-10-16
12
40
 
13
41
  Minor: the memory store scales — a two-level index, merge and archive at creation, and a memory-curator agent.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![Node >= 22.3](https://img.shields.io/badge/node-%3E%3D22.3-5fa04e)](package.json)
12
12
  [![zero dependencies](https://img.shields.io/badge/deps-zero-success)](package.json)
13
13
  [![Themes](https://img.shields.io/badge/themes-14-9cf)](themes/)
14
- [![Skills](https://img.shields.io/badge/skills-46-blueviolet)](src/skills/)
14
+ [![Skills](https://img.shields.io/badge/skills-47-blueviolet)](src/skills/)
15
15
  [![Agents](https://img.shields.io/badge/agents-20-orange)](src/agents/)
16
16
  [![Laws and doctrines](https://img.shields.io/badge/laws-9%20%2B%2029%20doctrines-critical)](src/doctrines/)
17
17
  [![Plugins](https://img.shields.io/badge/plugins-7-teal)](adapters/opencode/plugins/)
@@ -179,7 +179,7 @@ The harness ships as a small set of layers, and the web console's rail is the sa
179
179
  | **🛡️ Rules** (`laws/`) | 9 universal laws — always in force, never toggleable: sealed-secrets, one-intent-one-act, verify-before-asserting, deletion-is-deliberate, surface-failures, data-not-orders, least-privilege, cure-the-cause, echo-the-intent. Each rule has a permanent id; its number is only its position, and the harness cites rules by name |
180
180
  | **📐 Doctrines** (`doctrines/`) | practice packs, chosen per install at build time: **craft** (how code is written), **rigor** (how work is proven), **ops** (how the machine is operated), **process** (how a task is run), **comms** (how answers are presented). A doctrine rule may tighten a Rule, never repeal one, and the user's own `user-rules.md` outranks it. Pick with `geneseed-build --doctrines craft,rigor` (or `none`), a single rule with `--exclude-rules "process 7"`, in the setup wizard, or through the switches on the console's Constitution page; all five pack files ship on disk either way, so a citation into a pack you left out still resolves |
181
181
  | **🤖 Agents** (20) | capability specialists: `reviewer`, `tester`, `architect`, `docs`, `security`, `performance`, `explorer`, `researcher`, `developer`, `memory-curator` — plus a debate **council** the `council` skill convenes: `advocate`, `skeptic`, `pragmatist`, `steward`, `visionary`, `user-advocate`, `framer`, `empiricist`, `operator`, `historian` |
182
- | **🛠 Skills** (46) | repeatable workflows: brainstorm · plan · **codebase-design** · **wayfinder** · **develop** · **worktree** · debug · **prototype** · refactor · **ponytail** · **forge-mcp** · **bruno** · geneseed-code-review · **fresh-eyes** · **security-audit** · **dependencies** · **review-response** · commit · **ship** · **release** · **git-history** · document-project · **frontend-design** · **prose** · **ingest** · **research** · **teach** · **quiz** · **learn-mode** · handoff · roast-me · **council** · parallel-agents · **workflow** · **wiki** · **geneseed** · **rule** · **profile** · **skill-forge** · **pipeline** · **loop** · **brick-forge** · **daydream** · **react-view-transitions** · **token-report** · **explain-changes** |
182
+ | **🛠 Skills** (47) | repeatable workflows: brainstorm · plan · **codebase-design** · **wayfinder** · **develop** · **worktree** · debug · **prototype** · refactor · **ponytail** · **forge-mcp** · **bruno** · geneseed-code-review · **fresh-eyes** · **security-audit** · **dependencies** · **review-response** · commit · **ship** · **release** · **git-history** · document-project · **frontend-design** · **deck** · **prose** · **ingest** · **research** · **teach** · **quiz** · **learn-mode** · handoff · roast-me · **council** · parallel-agents · **workflow** · **wiki** · **geneseed** · **rule** · **profile** · **skill-forge** · **pipeline** · **loop** · **brick-forge** · **daydream** · **react-view-transitions** · **token-report** · **explain-changes** |
183
183
  | **🔌 Plugins** (OpenCode) | `geneseed-context` injects project docs *and your machine wiki* every session (and across compaction); `geneseed-learn` distils memory at session end; `geneseed-guard` enforces the safety Laws and protected wiki folders at the tool boundary; `geneseed-workflow` registers the `workflow` tool that runs saved orchestration scripts; `geneseed-notify` sends a native OS notification when a long run finishes; `geneseed-ponytail` holds a minimal-code mode (`/ponytail lite\|full\|ultra\|off`), opt-in, injecting the laziest-that-works ruleset every turn so it doesn't drift; `geneseed-activity` streams what each session is doing to the web console's Activity view |
184
184
  | **🧠 Memory** (`memory/`) | one-fact-per-file durable knowledge, indexed by `MEMORY.md` (git-ignored, personal) |
185
185
  | **📓 Notebook** (`notebook/`) | the agent's sovereign space — any medium (code, tools, data, notes), self-ruled via a seed-once charter, always git-ignored; only its `.gitignore` is build-asserted |
package/SHIPPED.md CHANGED
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
20
20
  > **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
21
21
  > human-readable label only. The canonical identity of an installed harness is the
22
22
  > **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
23
- > string. Current label: **4.4.0**.
23
+ > string. Current label: **4.5.0**.
24
24
 
25
25
  ## Capabilities
26
26
 
@@ -29,7 +29,7 @@ Every row below is present in the tree today; "Spec" names the design record.
29
29
  | Capability | What it is | Spec |
30
30
  | --- | --- | --- |
31
31
  | **Themed generator** | One neutral `src/` → 14 themed bundles via `geneseed-build` (zero dependencies); structure stays theme-independent | [DESIGN.md](DESIGN.md) |
32
- | **Laws / Agents / Skills / Memory / Notebook** | 9 laws, 20 agents, 46 skills, the memory convention, and the agent's sovereign notebook | `2026-06-11-notebook-sovereign-space` · `2026-06-10-notebook-agent-own-space` |
32
+ | **Laws / Agents / Skills / Memory / Notebook** | 9 laws, 20 agents, 47 skills, the memory convention, and the agent's sovereign notebook | `2026-06-11-notebook-sovereign-space` · `2026-06-10-notebook-agent-own-space` |
33
33
  | **Three-tier constitution** | The governance surface splits into an always-on **Ontology** (Telos, Evidence, Decisions, Conduct — the Pact absorbed into Telos), the nine always-on **Invariants**, and five toggleable **Doctrine** packs (craft, rigor, ops, process, comms) addressed `<pack> <n>`. Every rule carries a permanent id; its number is its position, computed at render time, and a citation renders as the rule's principle name. Packs are chosen at build time with `--doctrines` or the setup wizard; the whole catalogue ships on disk either way, so a citation into an inactive pack still resolves. Precedence, in order: Ontology + Invariants, then the user's `user-rules.md`, then the active Doctrines, then `PROFILE.md`. `doctor`'s `constitutionProblems` gates rule ids (declared, unique, never a retired one), the per-rule Principle column, every `{{LAW:<id>}}` / `{{DOCTRINE:<id>}}` citation in `src/`, and the rule that an always-on tier may never cite a toggleable one | `2026-08-19-three-tier-constitution-design` |
34
34
  | **`doctor`** | One check: unresolved tokens, dead/non-hermetic links, theme-key parity, authoring gates, rendered-bundle drift | `2026-06-07-theme-aware-doctor-diff` |
35
35
  | **`diff` + improvements export** | Deployed-vs-source drift report; export an `improvements.md` back-port | `2026-06-12-improvements-export` |
@@ -877,6 +877,76 @@ async function alreadyInjected(client, sid) {
877
877
  return false
878
878
  }
879
879
 
880
+ // ---- The done gate (#289): the OpenCode twin of js/hosts/hooks-done.mjs ----------------
881
+ // OpenCode has no turn-end event a plugin can block on, so its ceiling is a reminder: on the
882
+ // request AFTER a turn that edited files without the project's declared checks passing
883
+ // (`.geneseed/done-checks.txt`), one invisible user message says which checks are owed. Only V1
884
+ // (`messages.transform`, whose tool parts carry `state.status` and the shell's
885
+ // `metadata.exit`); OpenCode 2's message shape for tool calls is unverified, so V2 is not
886
+ // judged and says so once. Fails open on every shape it does not recognise (ADR 0007).
887
+ // `runsUnpiped` is a hand-mirrored twin; tests/plugins/context_done.test.mjs runs both on one table.
888
+ const DONE_LIST = ".geneseed/done-checks.txt"
889
+ const DONE_EDIT_TOOLS = new Set(["edit", "write", "patch", "apply_patch", "multiedit"])
890
+ const DONE_SHELL_TOOLS = new Set(["bash", "shell"])
891
+
892
+ async function readDoneChecks(root) {
893
+ try {
894
+ const text = await fs.readFile(path.join(root, DONE_LIST), "utf8")
895
+ return text.split(/\r?\n/).map((l) => l.trim().replace(/\s+/g, " ")).filter((l) => l && !l.startsWith("#"))
896
+ } catch {
897
+ return []
898
+ }
899
+ }
900
+
901
+ function runsUnpiped(command, line) {
902
+ const cmd = String(command).replace(/[ \t]+/g, " ")
903
+ for (let i = cmd.indexOf(line); i !== -1; i = cmd.indexOf(line, i + 1)) {
904
+ const rest = cmd.slice(i + line.length)
905
+ let piped = false
906
+ for (let j = 0; j < rest.length; j += 1) {
907
+ const two = rest.slice(j, j + 2)
908
+ if (two === "&&" || two === "||" || rest[j] === ";" || rest[j] === "\n") break
909
+ if (rest[j] === "|") { piped = true; break }
910
+ }
911
+ if (!piped) return true
912
+ }
913
+ return false
914
+ }
915
+
916
+ // The checks still owed, judged over the previous turn and the current one: a reminder lasts
917
+ // one turn, not the whole session. Our own injected messages (`geneseed-*` ids) open no turn.
918
+ function doneOwed(messages, checks, root) {
919
+ const prompts = []
920
+ messages.forEach((m, i) => {
921
+ if (m?.info?.role === "user" && !String(m.info.id ?? "").startsWith("geneseed-")) prompts.push(i)
922
+ })
923
+ const from = prompts.length >= 2 ? prompts[prompts.length - 2] : 0
924
+ const tools = []
925
+ for (const m of messages.slice(from)) {
926
+ for (const p of m?.parts ?? []) if (p?.type === "tool" && p.state) tools.push(p)
927
+ }
928
+ let lastEdit = -1
929
+ tools.forEach((p, i) => {
930
+ if (!DONE_EDIT_TOOLS.has(String(p.tool).toLowerCase()) || p.state.status === "error") return
931
+ const fp = p.state.input?.filePath ?? p.state.input?.file_path
932
+ if (typeof fp === "string" && fp) {
933
+ const rel = path.relative(root, path.resolve(root, fp))
934
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return
935
+ }
936
+ lastEdit = i
937
+ })
938
+ if (lastEdit === -1) return []
939
+ const passed = new Set()
940
+ for (const p of tools.slice(lastEdit + 1)) {
941
+ const command = DONE_SHELL_TOOLS.has(String(p.tool).toLowerCase()) && p.state.input?.command
942
+ if (typeof command !== "string" || p.state.status !== "completed") continue
943
+ const exit = p.state.metadata?.exit
944
+ if (typeof exit === "number" && exit !== 0) continue
945
+ for (const check of checks) if (runsUnpiped(command, check)) passed.add(check)
946
+ }
947
+ return checks.filter((c) => !passed.has(c))
948
+ }
949
+
880
950
  export const GeneseedContext = async (ctx) => {
881
951
  const { client } = ctx
882
952
  const done = new Set()
@@ -1002,6 +1072,27 @@ export const GeneseedContext = async (ctx) => {
1002
1072
  // detects that and takes over with the visible delivery.
1003
1073
  "experimental.chat.messages.transform": async (_input, output) => {
1004
1074
  transformSeen = true // the hook exists on this build
1075
+ // The done gate's reminder: FIRST, and independent of the context block:
1076
+ // INJECT_OFF and the visible delivery leave it on, and no early return below can skip it.
1077
+ // Its own function, so its early returns end the reminder, never the context injection.
1078
+ await (async () => {
1079
+ if (!output || !Array.isArray(output.messages) || !output.messages.length) return
1080
+ const sid = output.messages[0]?.info?.sessionID
1081
+ if (await sovereignBypass(root) || await skipSession(sid)) return
1082
+ const checks = await readDoneChecks(root)
1083
+ if (!checks.length) return
1084
+ const owed = doneOwed(output.messages, checks, root)
1085
+ if (!owed.length) return
1086
+ const stamp = Date.now()
1087
+ output.messages.unshift({
1088
+ info: { id: `geneseed-done-${stamp}`, role: "user", sessionID: sid, time: { created: stamp } },
1089
+ parts: [{ id: `geneseed-done-part-${stamp}`, type: "text", text:
1090
+ "Geneseed (Verify Before Asserting) — files were edited, and these checks from "
1091
+ + `${DONE_LIST} have not passed since the last edit: ${owed.join("; ")}. Run them unpiped `
1092
+ + "before reporting the work done, or say plainly that they were not run and why." }],
1093
+ })
1094
+ log("transform: done-gate reminder injected")
1095
+ })().catch((err) => log(`done-gate error: ${err?.message ?? err}`))
1005
1096
  if (INJECT_OFF || !TRANSFORM || fallbackVisible) return
1006
1097
  if (await sovereignBypass(root)) return
1007
1098
  try {
@@ -1072,6 +1163,11 @@ async function setupV2(ctx) {
1072
1163
  }
1073
1164
  }
1074
1165
 
1166
+ // The done gate is not judged on OpenCode 2 (see `doneOwed`): said once, where its log goes.
1167
+ if ((await readDoneChecks(root).catch(() => [])).length) {
1168
+ console.error(`[geneseed-context] done gate: ${DONE_LIST} found, but OpenCode 2 turns are not judged.`)
1169
+ }
1170
+
1075
1171
  if (!VISIBLE) {
1076
1172
  await ctx.session.hook("context", async (event) => {
1077
1173
  if (INJECT_OFF) return
@@ -128,6 +128,9 @@ function log(msg) { console.error(`[geneseed-guard] ${msg}`) }
128
128
  // at the newest MAX_LEDGER_LINES like the hook's; never throws, never decides.
129
129
  const MAX_LEDGER_LINES = 1000
130
130
  async function ledger(rule) {
131
+ // `geneseed doctor --probe` blocks on purpose; its blocks are not the user's (the twin of
132
+ // the same line in js/hosts/hooks.mjs). The record only — never the decision.
133
+ if (process.env.GENESEED_GATE_PROBE) return
131
134
  try {
132
135
  const bases = []
133
136
  if (process.env.GENESEED_HARNESS) bases.push(process.env.GENESEED_HARNESS)
@@ -545,6 +548,9 @@ function ruleStoreTarget(p) {
545
548
  // line; the list protects itself. Found from the WRITE TARGET up to the first `.git`, re-read on
546
549
  // every write (a short file, read only on a write call). No ask tier here, so a hit is a deny.
547
550
  const SENSOR_LIST = ".geneseed/protected-checks.txt"
551
+ // The done gate's list (#289, js/hosts/hooks-done.mjs) protects itself the same way, with or
552
+ // without a protected-checks list: emptying it would disarm the gate. Twin of js/hosts/hooks.mjs.
553
+ const DONE_LIST = ".geneseed/done-checks.txt"
548
554
  function protectedCheck(p) {
549
555
  try {
550
556
  const target = path.resolve(p)
@@ -554,9 +560,10 @@ function protectedCheck(p) {
554
560
  if (up === root) return null
555
561
  root = up
556
562
  }
557
- const text = readFileSync(path.join(root, SENSOR_LIST), "utf8")
558
563
  const fold = (s) => nocase(s.replace(/\\/g, "/"))
559
564
  const rel = fold(path.relative(root, target))
565
+ if (rel === fold(DONE_LIST)) return DONE_LIST
566
+ const text = readFileSync(path.join(root, SENSOR_LIST), "utf8")
560
567
  const entries = [SENSOR_LIST, ...text.split(/\r?\n/).map((l) => l.trim().replace(/[\\/]+$/, ""))
561
568
  .filter((l) => l && !l.startsWith("#"))]
562
569
  return entries.find((e) => rel === fold(e) || rel.startsWith(fold(e) + "/")) ?? null
@@ -635,7 +642,7 @@ export const GeneseedGuard = async (ctx) => {
635
642
  if (SECRET_RE.some((re) => re.test(p))) { await deny(`write to secret/key file ${p} (Sealed Secrets)`, "law-1"); return }
636
643
  if (SECRET_WARN_RE.some((re) => re.test(p))) log(`WARN: writing ${p} — keep secrets out of tracked files (Sealed Secrets)`)
637
644
  const sensor = protectedCheck(p)
638
- if (sensor) { await deny(`${p} is a protected check (${sensor}, listed in ${SENSOR_LIST}) — a check the agent can edit is not a check (External Gate)`, "rigor-5"); return }
645
+ if (sensor) { await deny(`${p} is a protected check (${sensor === DONE_LIST ? "the done gate list itself" : `${sensor}, listed in ${SENSOR_LIST}`}) — a check the agent can edit is not a check (External Gate)`, "rigor-5"); return }
639
646
  const store = ruleStoreTarget(p)
640
647
  if (store && !RULE_STORE_BUMPED.has(p)) {
641
648
  RULE_STORE_BUMPED.add(p)
@@ -13,8 +13,8 @@ few rules are enforced; the rest are asked on every host, through the root rules
13
13
 
14
14
  Derived from the code, not from this project's own planning notes: `js/hosts/hooks.mjs`'s
15
15
  `BLOCK_RULES` and gate functions, `HOOK_PINNED` in `js/inspect/checks-constitution.mjs`,
16
- `LAW_IV_BASH` and `CONSENT_RULE` in `js/hosts/settings.mjs`, and
17
- `adapters/opencode/plugins/geneseed-guard.js`. `HOOK_PINNED` is what keeps this table from
16
+ `LAW_IV_BASH` and `CONSENT_RULE` in `js/hosts/settings.mjs`, `js/hosts/hooks-done.mjs`, and
17
+ `adapters/opencode/plugins/geneseed-guard.js` and `geneseed-context.js`. `HOOK_PINNED` is what keeps this table from
18
18
  silently guarding the wrong rule after a renumber. See [ADR 0005](../adr/0005-stable-rule-ids.md).
19
19
 
20
20
  ### The enforced rules
@@ -26,6 +26,7 @@ silently guarding the wrong rule after a renumber. See [ADR 0005](../adr/0005-st
26
26
  | `persist-insight` | process | ask: rule-gate (a write to `user-rules.md` or the memory store) | same as Claude Code | ask once: guard plugin (refuses the *first* write to that path; a re-issued write goes through) | log only (warning, call goes through) |
27
27
  | `consent-before-push` | process | ask: git-gate (every `git commit`/`git push`). Togglable: build without the process pack, or drop this rule alone with `--exclude-rules` (see [Exclusions](exclusions.md)). Only this question drops; the `deletion-is-deliberate` gate stays. | same as Claude Code | ask: permission prompt (`git commit*`/`git push*`), wired only when the process pack is on; a `loop/*` push is statically allowed | log only |
28
28
  | `external-gate` | rigor | ask: rule-gate (a write under a project's `.geneseed/protected-checks.txt` list) | same as Claude Code | block: guard plugin (same list) | block (exit 2) |
29
+ | `verify-before-asserting` | law | block at Stop, once per turn: the done gate in `learn` (a turn that edited files, while a check in the project's `.geneseed/done-checks.txt` has not passed since the last edit). Opt-in per project | same as Claude Code (**unverified live**: a frozen fork, and no check runs a model turn) | remind: context plugin, on the next request (OpenCode 1 only; OpenCode 2 is not judged, and logs so) | not judged: Stop carries no transcript (log line) |
29
30
  | `commands-must-return` | ops | ask: git-gate (a recursive scan rooted at a filesystem root: `find /`, `du`, `grep -r`, `rg`, `Get-ChildItem -Recurse` at `/`, `/c`, `C:\`) | same as Claude Code | block: guard plugin (same root-scan pattern) | block (exit 2) |
30
31
 
31
32
  Bob has no "ask" tier (see [ADR 0007](../adr/0007-gates-fail-closed-per-host-ceiling.md)), so a
@@ -37,12 +38,17 @@ mechanisms: its own permission prompt (`opencode.json`, static globs) and the gu
37
38
  See [ADR 0008](../adr/0008-host-parity-ceiling-of-the-weakest-host.md) for why a gate that only
38
39
  one host can run still ships for every host it can.
39
40
 
41
+ A gate that is registered but broken looks exactly like a working one. `geneseed doctor --probe`
42
+ runs the installed Claude Code, OpenClaude and Bob gates through each host's own shell, and calls
43
+ the OpenCode guard plugin in-process, on one call each must refuse and one it must let through
44
+ ([Doctor findings](../reference/troubleshoot-doctor.md)).
45
+
40
46
  ### Everything else: asked only, on every host
41
47
 
42
48
  Every other rule carries no gate on any host. A human reads them because the model was told to
43
49
  follow them, not because a tool call was ever inspected.
44
50
 
45
- **Laws** — `one-intent-one-act` · `verify-before-asserting` · `surface-failures` ·
51
+ **Laws** — `one-intent-one-act` · `verify-before-asserting` (in a project without a done list) · `surface-failures` ·
46
52
  `data-not-orders` · `least-privilege` · `cure-the-cause` · `echo-the-intent`.
47
53
 
48
54
  **Doctrine: process** — `plan-before-acting` · `context-economy` · `read-the-docs-first` ·
@@ -37,8 +37,11 @@ Geneseed writes its hooks into the host's settings file. Every hook calls one pr
37
37
  - Content that looks like a credential (a cloud access key, a GitHub or Anthropic token, a Slack token, a private-key block) going into any file other than `.env*` gets a prompt citing *Sealed Secrets*.
38
38
  - A write to `user-rules.md`, `MEMORY.md` or a file in the install's `memory/` gets a prompt citing *Persist Insight*: whether something is a standing rule or a fact to remember is your call, settled through the `rule` [skill](skills.md).
39
39
  - A write to a check the project protects gets a prompt citing *External Gate* (on Bob, it is blocked). A project opts in with `.geneseed/protected-checks.txt` at its git root: one repo-relative file or folder per line, `#` for comments — the checks and tests the agent is judged by. A check the agent can edit is not a check. The list protects itself. Only file-write tools are seen: a shell `sed -i` or redirect is not, so run the same checks in CI as the boundary that holds.
40
+ - A write to `.geneseed/done-checks.txt`, the done gate's own list (below), gets the same *External Gate* prompt: emptying it would disarm the gate.
40
41
  - Ordinary edits pass untouched.
41
42
 
43
+ **The done gate.** Runs when a turn ends, inside `learn` and before it calls any model. A project opts in with `.geneseed/done-checks.txt` at its git root: one command per line, `#` for comments — the checks that prove the work, for example `npm run lint` and `node --test`. If the turn edited a file under that root and a listed check has not passed since the last edit, the turn does not end: the agent is told which checks are owed, citing *Verify Before Asserting*. A check counts when it ran as a shell command, unpiped (`npm test | tail` exits with `tail`'s code, so it proves nothing), and its result was not an error. The gate never runs a check itself. It blocks at most once per turn; the second stop ends the turn and leaves a line in the gate ledger. Only file-write tools count as edits: a shell `sed -i` or redirect is not seen. Without the file, nothing changes. On OpenCode the gate is a reminder on the next request (OpenCode 1 only); on Bob, whose Stop carries no transcript, the turn is not judged and a log line says so.
44
+
42
45
  **learn.** Takes the tail of the transcript and asks a model to distil what is worth keeping into one-fact-per-file entries under `memory/`. It is shown the facts already stored, so it updates one with the merged text rather than writing a twin (the old version is kept in `memory/archive/`), and moves one the session proved obsolete into `memory/archive/`. It needs `GENESEED_LLM` set to a model command, for example:
43
46
 
44
47
  ```bash
@@ -30,6 +30,7 @@ A **skill** is a repeatable workflow written as a markdown playbook: when it app
30
30
  | answer a question from the open web, every claim cross-checked | `research` |
31
31
  | have several viewpoints debate a decision | `council` |
32
32
  | get an adversarial critique of your own ask, plan or idea | `roast-me` |
33
+ | present a subject as slides in one offline HTML file | `deck` |
33
34
  | see what a change does before you agree to commit it | `explain-changes` |
34
35
  | stage the right paths and write a focused commit | `commit` |
35
36
  | open a pull request, or finish and merge a branch | `ship` |
package/docs/extending.md CHANGED
@@ -125,7 +125,7 @@ Notes that matter:
125
125
 
126
126
  ### The gates CI does not run
127
127
 
128
- - **The mutation matrix itself.** `tests/mutate.mjs` — 33 rows, each a one-string edit to a
128
+ - **The mutation matrix itself.** `tests/mutate.mjs` — 35 rows, each a one-string edit to a
129
129
  product file with a written argument for which gate should catch it. The full run is **in no
130
130
  workflow** and should not be: it mutates the product tree 33 times and runs a gate per edit.
131
131
  Run `node tests/mutate.mjs --all` (or `--only M7`) by hand when you change something a gate is
@@ -17,6 +17,8 @@ One of the [OpenCode plugins](opencode-plugins.md). Its switches are also listed
17
17
 
18
18
  **When it runs.** On every request: the context rides invisibly in the request's message list, so it also survives compaction. If your OpenCode build lacks that (experimental) hook, the plugin notices and falls back to one visible message at session start, re-pushed when the session compacts.
19
19
 
20
+ **The done gate's reminder.** In a project with `.geneseed/done-checks.txt` (see [Hooks](../concepts/hooks.md)), a request that follows a turn which edited files without every listed check passing since gets one more invisible message naming the checks owed. OpenCode has no turn-end event a plugin can block on, so this is a reminder, not a block. OpenCode 1 only: on OpenCode 2 the turn is not judged, and the plugin logs that once. `GENESEED_CONTEXT_INJECT=off` does not turn it off; removing the file does.
21
+
20
22
  **What you see.** Normally nothing — the context is there, but not shown. With the visible fallback, a `PROJECT CONTEXT` block at the top of the session.
21
23
 
22
24
  **Switches.**
@@ -35,6 +35,37 @@ If the drift is your own edits, export them first — `geneseed diff` (or the co
35
35
 
36
36
  **Confirm.** `geneseed doctor` no longer reports the bundle.
37
37
 
38
+ ## `[probe] … registered but not refusing` / `refuses everything` / `does not run`
39
+
40
+ > `[probe] ~/.claude: git-gate under Git Bash registered but not refusing — …`
41
+
42
+ These come only from `geneseed doctor --probe`. It runs each installed gate exactly as the host's
43
+ settings spell it, through the host's own shell, on `git reset --hard HEAD` (which must be refused)
44
+ and `git status` (which must go through). An OpenCode install's guard plugin has no command to
45
+ run: the probe imports it and calls its hook through both plugin APIs (V1 and V2), with `rm -rf /`
46
+ as the call it must refuse. `GENESEED_GUARD=off` or `warn` in your terminal makes that a note,
47
+ not a failure.
48
+
49
+ **Cause.**
50
+ - **Not refusing:** the gate launches but lets a destructive call through. Usually something in
51
+ the install tells it to stand down: an exclusion covering the temp folder, or a command that
52
+ lost its `--host bob`.
53
+ - **Refuses everything:** the gate asks on every call. This is typically the hooks emitted for
54
+ one shell and run under another.
55
+ - **Does not run:** the shim or its runner is gone, or the shell is missing.
56
+
57
+ **Fix.**
58
+
59
+ ```bash
60
+ geneseed rebuild-all
61
+ ```
62
+
63
+ **Confirm.** `geneseed doctor --probe` prints `[probe] N gate(s) refused a destructive call and let
64
+ a safe one through`.
65
+
66
+ The probe runs under *this terminal's* environment (PATH, HOME), not the host app's. A host started
67
+ with a different environment can still break a gate that passes here.
68
+
38
69
  ## `registry.json unreadable` / `registry.json is not valid JSON`
39
70
 
40
71
  > `[authoring] registry.json unreadable: …`
@@ -27,6 +27,7 @@ On Claude Code and OpenClaude the hooks never block silently. They make the host
27
27
  - **A scan of a whole filesystem**: a recursive `find`, `du`, `grep -r`, `rg` or `Get-ChildItem -Recurse` rooted at `/`, `/c` or `C:\`, which in Git Bash walks every drive for hours. Wired in every build.
28
28
  - **Credentials**: a write or edit whose content looks like a real key (AWS, GitHub, Anthropic, Slack, or a private-key block), anywhere except a `.env` file.
29
29
  - **Your rules and memory**: any write by the agent to `user-rules.md` or to the memory store. Whether something is a standing rule or a fact to remember is your call.
30
+ - **Ending a turn without the proof**: only in a project that lists its checks in `.geneseed/done-checks.txt`. A turn that edited files cannot end until every listed check has passed since the last edit. The agent is stopped once, then the turn may end.
30
31
 
31
32
  If a gate crashes, it asks instead of letting the call through. If it cannot start at all (a deleted or broken shim) or times out, Claude Code blocks the call instead: see [Troubleshoot hooks](../reference/troubleshoot-hooks.md) to recover. Other shell commands, `rm -rf` included, go through Claude Code's normal permission flow.
32
33
 
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "4.4.0"
5
+ "version": "4.5.0"
6
6
  }
package/js/README.md CHANGED
@@ -71,6 +71,7 @@ user co-owns, and the hook verbs those emitted configs then run.
71
71
  | `hosts.mjs` | The four host config dirs (opencode/claude/bob/openclaude), their `family` (`CLAUDE_STYLE`), plus `resolvePath`/`expanduser` |
72
72
  | `hooks.mjs` | The gate verbs, run on every tool call: `git-gate`, `rule-gate`, `tool-gate` (the two gates fused, for Bob), plus `ask`/the gate ledger/`guardGate`. `--host` picks the verdict dialect |
73
73
  | `hooks-context.mjs` | The `context` verb — SessionStart: session files plus the repo's context docs |
74
+ | `hooks-done.mjs` | The done gate (#289): on Stop, inside `learn` and before its spawn, blocks a turn that edited files until `.geneseed/done-checks.txt`'s checks passed after the last edit |
74
75
  | `hooks-learn.mjs` | The `learn` verb — Stop/SubagentStop/PreCompact distiller; the hook path's ONE spawn (`$GENESEED_LLM`) |
75
76
  | `hooks-prims.mjs` | The Python path/text primitives the hook verbs share (`splitLines`, `readStdin`, a never-throwing `isFile`) |
76
77
  | `usage.mjs` | The usage counter `tool-gate` runs: one `{day, kind, name}` line per Skill or subagent call into `notebook/usage.jsonl`. Hook path: builtins only, silent, never throws |
@@ -101,7 +102,8 @@ Everything that inspects an already-built or already-deployed harness and report
101
102
  | `doctor.mjs` | The `geneseed doctor` **driver** only: picks themes, runs the checks, prints the verdict |
102
103
  | `checks-build.mjs` | Gates over what the build PRODUCED: links, tokens, theme parity, OpenCode colours, bundle drift |
103
104
  | `checks-repo.mjs` | Gates over the repo's own records: `registry.json`, committed secrets, the hook shim, vendored pins |
104
- | `checks-authoring.mjs` | `authoringProblems`, the aggregate self-consistency gate: spec purpose lines, the learn-prompt literal, `node --check` over the plugins (the doctor's one spawn), then the groups below |
105
+ | `checks-authoring.mjs` | `authoringProblems`, the aggregate self-consistency gate: spec purpose lines, the learn-prompt literal, `node --check` over the plugins (the doctor's one default spawn), then the groups below |
106
+ | `checks-probe.mjs` | `doctor --probe` (opt-in): runs each live install's own gate command through the host's shell on a must-refuse and a must-pass payload — the doctor's second spawn; the OpenCode guard plugin is imported and called in-process instead, through both plugin APIs |
105
107
  | `checks-constitution.mjs` | The constitution: rule declarations and citations, LEAN blocks, `HOOK_PINNED`, theme vocabulary, the console's `LAW_META`/`DOCTRINE_META` |
106
108
  | `checks-counts.mjs` | The hand-written counts: AGENT.md tables, README badges and prose, `SHIPPED.md`, held against `src/` |
107
109
  | `scan.mjs` | The shared walk/sort/strip/test primitives every `checks-*` uses. Owns no judgement itself |
package/js/cli-table.json CHANGED
@@ -473,6 +473,21 @@
473
473
  "is_flag": true,
474
474
  "type": null,
475
475
  "hidden": false
476
+ },
477
+ {
478
+ "names": [
479
+ "--probe"
480
+ ],
481
+ "dest": "probe",
482
+ "metavar": null,
483
+ "help": "also run each installed gate on a destructive and a safe payload: it must refuse the first and let the second through. Claude Code, OpenClaude and Bob gates run as their settings spell them, through the host's shell; the OpenCode guard plugin is imported and called in-process (runs under this terminal's environment, not the host app's)",
484
+ "choices": null,
485
+ "default": false,
486
+ "required": false,
487
+ "nargs": "0",
488
+ "is_flag": true,
489
+ "type": null,
490
+ "hidden": false
476
491
  }
477
492
  ],
478
493
  "mutex": []
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The done gate — Verify Before Asserting's boundary half, at the moment a turn ends (#289).
3
+ *
4
+ * Every other gate sits before an act. This one sits at Stop: a turn that edited files under a
5
+ * project which lists its proof in `.geneseed/done-checks.txt` may not end until every listed
6
+ * check has run AND passed after the last edit. The gate never runs a check itself — the hook
7
+ * path starts no process of its own (ADR 0002) — it reads the turn's own tool calls in the transcript.
8
+ *
9
+ * WHY IT RIDES `learn` AND NOT A VERB OR A FLAG OF ITS OWN (ADR 0010). The machine-wide shim is
10
+ * last-writer-wins: an older `geneseed-hook` would refuse a new verb or flag, and the Stop
11
+ * command's `|| exit 0` would then skip learning too, silently. Arming on a project FILE keeps
12
+ * every emitted settings file byte-identical, so a stale shim only means "no done gate".
13
+ *
14
+ * WHY STDOUT JSON AND NOT EXIT 2. The emitted Stop command ends in `|| exit 0`, which swallows
15
+ * a 2. `{"decision":"block"}` on exit 0 is the one signal that survives the existing wiring.
16
+ * Allowing prints NOTHING — on Stop a stray stdout byte is read as a verdict.
17
+ *
18
+ * WHAT COUNTS (shortcut: write tools only; a shell `sed -i` or redirect is not an edit here —
19
+ * seeing those would need `git status`, a spawn. Same limit as `protected-checks.txt`):
20
+ * - an edit is a Write/Edit/NotebookEdit/MultiEdit whose target is under the project root and
21
+ * whose result is not an error (a denied write changed nothing);
22
+ * - a pass is a Bash/PowerShell call whose command contains the declared line UNPIPED (a pipe
23
+ * takes the exit code of its last stage: `npm test | tail` exits 0 on a red suite) and whose
24
+ * result is not `is_error` — every non-zero exit, denial and timeout carries it (checked on
25
+ * 14 000+ real Claude Code results, 2026-10-17).
26
+ *
27
+ * At most one block per turn: `stop_hook_active` is true when this Stop follows a block, and the
28
+ * gate then lets the turn end and leaves a ledger line, so a gate that cries wolf gets found.
29
+ */
30
+ import { existsSync } from 'node:fs';
31
+ import path from 'node:path';
32
+ import { readText, printOut as out, printErr as err } from '../lib/fs.mjs';
33
+ import { within } from '../lib/paths.mjs';
34
+ import { gitRootOf } from './gitref.mjs';
35
+ import { DONE_LIST, recordGate } from './hooks.mjs';
36
+ import { splitLines } from './hooks-prims.mjs';
37
+
38
+ const EDIT_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'MultiEdit']);
39
+ const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
40
+
41
+ /** The declared checks, one per line, `#` comments; null when the project has no list. */
42
+ export function readDoneChecks(root) {
43
+ const file = path.join(root, DONE_LIST);
44
+ if (!existsSync(file)) return null;
45
+ try {
46
+ return splitLines(readText(file)).map((l) => l.trim().replace(/\s+/g, ' '))
47
+ .filter((l) => l && !l.startsWith('#'));
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * True when `command` runs `line` with no pipe after it in the same segment. A segment ends at
55
+ * `&&`, `||`, `;` or a newline; a single `|` before that end is a pipe. Whitespace is collapsed
56
+ * on both sides. shortcut: quotes are not parsed — a `|` inside a quoted argument after the
57
+ * check reads as a pipe, which only ever refuses a pass, never grants one.
58
+ */
59
+ export function runsUnpiped(command, line) {
60
+ const cmd = String(command).replace(/[ \t]+/g, ' ');
61
+ for (let i = cmd.indexOf(line); i !== -1; i = cmd.indexOf(line, i + 1)) {
62
+ const rest = cmd.slice(i + line.length);
63
+ let piped = false;
64
+ for (let j = 0; j < rest.length; j += 1) {
65
+ const two = rest.slice(j, j + 2);
66
+ if (two === '&&' || two === '||' || rest[j] === ';' || rest[j] === '\n') break;
67
+ if (rest[j] === '|') { piped = true; break; }
68
+ }
69
+ if (!piped) return true;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ /** A user line that opens a turn: the user typed it, it is not a tool result or a meta note. */
75
+ function isPrompt(obj) {
76
+ if (obj.type !== 'user' || obj.isMeta || obj.isSidechain) return false;
77
+ const c = obj.message && obj.message.content;
78
+ if (typeof c === 'string') return true;
79
+ return Array.isArray(c) && c.length > 0 && !c.some((b) => b && b.type === 'tool_result');
80
+ }
81
+
82
+ /**
83
+ * The decision, over transcript TEXT: the checks that have not passed since the turn's last
84
+ * edit, or [] when the turn may end (no edit, or every check passed after it). Exported for the
85
+ * unit tier — a transcript is text no CLI cell can vary cheaply.
86
+ */
87
+ export function doneDecide(transcript, checks, root, cwd = root) {
88
+ const entries = [];
89
+ for (const line of splitLines(transcript)) {
90
+ if (!line.trim()) continue;
91
+ try { entries.push(JSON.parse(line)); } catch { /* a torn last line is not evidence */ }
92
+ }
93
+ let start = 0;
94
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
95
+ if (isPrompt(entries[i])) { start = i + 1; break; }
96
+ }
97
+ const calls = []; // tool_use blocks, in order
98
+ const results = new Map(); // tool_use_id -> is_error
99
+ for (const obj of entries.slice(start)) {
100
+ if (obj.isSidechain) continue;
101
+ const content = obj.message && obj.message.content;
102
+ if (!Array.isArray(content)) continue;
103
+ for (const b of content) {
104
+ if (!b || typeof b !== 'object') continue;
105
+ if (b.type === 'tool_use') calls.push(b);
106
+ else if (b.type === 'tool_result') results.set(b.tool_use_id, b.is_error === true);
107
+ }
108
+ }
109
+ let lastEdit = -1;
110
+ calls.forEach((c, i) => {
111
+ if (!EDIT_TOOLS.has(c.name) || results.get(c.id) === true) return;
112
+ const p = c.input && (c.input.file_path || c.input.notebook_path || c.input.path);
113
+ if (typeof p === 'string' && p && within(path.resolve(cwd, p), root)) lastEdit = i;
114
+ });
115
+ if (lastEdit === -1) return [];
116
+ const passed = new Set();
117
+ calls.slice(lastEdit + 1).forEach((c) => {
118
+ const command = SHELL_TOOLS.has(c.name) && c.input && c.input.command;
119
+ // A call with no result yet (the host has not flushed it) is not a pass.
120
+ if (typeof command !== 'string' || results.get(c.id) !== false) return;
121
+ for (const check of checks) if (runsUnpiped(command, check)) passed.add(check);
122
+ });
123
+ return checks.filter((c) => !passed.has(c));
124
+ }
125
+
126
+ /**
127
+ * The Stop-side wrapper `cmdLearn` calls first. Returns true when it printed a block (the turn
128
+ * goes on, so `learn` must not distil yet); false in every other case, with nothing on stdout.
129
+ */
130
+ export function doneGate(meta, args) {
131
+ try {
132
+ const cwd = typeof meta.cwd === 'string' && meta.cwd ? meta.cwd : process.cwd();
133
+ const root = gitRootOf(cwd);
134
+ const checks = root ? readDoneChecks(root) : null;
135
+ if (!checks || !checks.length) return false;
136
+ // Bob's Stop payload carries no transcript (learn's I2): nothing to judge, and it says so.
137
+ if (args.host === 'bob' || typeof meta.transcript_path !== 'string') {
138
+ err(`[done-gate] no transcript${args.host === 'bob' ? ' (bob)' : ''} — turn not judged.\n`);
139
+ return false;
140
+ }
141
+ let transcript;
142
+ try {
143
+ transcript = readText(meta.transcript_path);
144
+ } catch {
145
+ // No ask tier on Stop: blocking on an unreadable transcript would trap the user.
146
+ err('[done-gate] transcript unreadable — turn not judged.\n');
147
+ return false;
148
+ }
149
+ const missing = doneDecide(transcript, checks, root, cwd);
150
+ if (!missing.length) return false;
151
+ const install = args.memory ? path.dirname(args.memory) : null;
152
+ if (meta.stop_hook_active === true) {
153
+ recordGate(install, 'done-gate-cap', 'law-3');
154
+ err(`[done-gate] cap reached — turn ends with unproven checks: ${missing.join('; ')}\n`);
155
+ return false;
156
+ }
157
+ recordGate(install, 'done-gate', 'law-3');
158
+ out(`${JSON.stringify({
159
+ decision: 'block',
160
+ reason: 'Geneseed (Verify Before Asserting) — this turn edited files, and these '
161
+ + `checks from ${DONE_LIST} have not passed since the last edit: ${missing.join('; ')}. `
162
+ + 'Run them unpiped, or say plainly that they were not run and why.',
163
+ })}\n`);
164
+ return true;
165
+ } catch (e) {
166
+ err(`[done-gate] skipped: ${e && e.message}\n`);
167
+ return false;
168
+ }
169
+ }
@@ -13,6 +13,7 @@ import {
13
13
  globalHookStandingDown, hookProjectDir, resolveMemoryDir, sovereignBypass,
14
14
  } from './hosts.mjs';
15
15
  import { readStdin, splitLines, splitWords } from './hooks-prims.mjs';
16
+ import { doneGate } from './hooks-done.mjs';
16
17
  import {
17
18
  existingSlugs, readFacts, writeMemories, consolidateMemory, appendAgentLesson,
18
19
  } from './memory-files.mjs';
@@ -309,6 +310,10 @@ export function cmdLearn(args) {
309
310
  const hookEvent = typeof meta.hook_event_name === 'string' ? meta.hook_event_name
310
311
  : (typeof meta.event === 'string' ? meta.event : null);
311
312
 
313
+ // The done gate (#289) judges the turn BEFORE anything here spawns, and only on Stop. A block
314
+ // means the turn goes on, so there is nothing to distil yet: the next Stop will.
315
+ if (hookEvent === 'Stop' && !args.file && doneGate(meta, args)) return 0;
316
+
312
317
  // I2 (Bob): its Stop payload never carries `transcript_path`, so there is never a transcript
313
318
  // to distil — and the bare envelope itself is not notes text (that would waste a model call
314
319
  // on `{"event":"Stop","session_id":"s"}`). Scoped to `--host bob`: every other host's Stop/