geneseed 3.16.0 → 3.17.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 (57) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +14 -6
  4. package/adapters/opencode/plugins/geneseed-guard.js +28 -0
  5. package/bin/geneseed.mjs +17 -0
  6. package/docs/concepts/hooks.md +1 -0
  7. package/docs/concepts/loop-notes.md +3 -0
  8. package/docs/concepts/loops.md +11 -0
  9. package/docs/guides/headless.md +1 -1
  10. package/docs/guides/project-context.md +2 -2
  11. package/docs/guides/read-non-markdown.md +1 -1
  12. package/docs/reference/plugin-context.md +2 -2
  13. package/docs/reference/plugin-guard.md +1 -1
  14. package/harness.config.json +1 -1
  15. package/js/hosts/hooks-context.mjs +29 -23
  16. package/js/hosts/hooks.mjs +34 -1
  17. package/js/hosts/settings.mjs +2 -1
  18. package/js/inspect/checks-constitution.mjs +2 -2
  19. package/js/loop/graph.mjs +2 -1
  20. package/js/loop/state.mjs +30 -1
  21. package/package.json +1 -1
  22. package/src/agents/docs.md +5 -0
  23. package/src/bricks/apply.md +3 -0
  24. package/src/bricks/docs-update.md +2 -0
  25. package/src/bricks/test.md +17 -7
  26. package/src/doctrines/process.md +9 -5
  27. package/src/loops/api-endpoint.json +1 -1
  28. package/src/loops/bugfix.json +1 -1
  29. package/src/loops/ci-repair.json +1 -1
  30. package/src/loops/deps-upgrade.json +1 -1
  31. package/src/loops/enforce-architecture-rule.json +1 -1
  32. package/src/loops/feature.json +1 -1
  33. package/src/loops/fix-flaky-tests.json +1 -1
  34. package/src/loops/legacy-refactor.json +1 -1
  35. package/src/loops/legacy-tests.json +1 -1
  36. package/src/loops/refactor.json +1 -1
  37. package/src/loops/remove-dead-code.json +1 -1
  38. package/src/loops/spec-first-feature.json +1 -1
  39. package/src/loops/tdd.json +1 -1
  40. package/src/loops/update-contract.json +1 -1
  41. package/src/skills/document-project.md +91 -22
  42. package/web/dist/assets/{Activity-kpWxd70F.js → Activity-DonKN1pi.js} +1 -1
  43. package/web/dist/assets/{ActivityDetail-Ccr942VL.js → ActivityDetail-e47a3cdE.js} +1 -1
  44. package/web/dist/assets/{Dashboard-D1lE7jcg.js → Dashboard-Cr7i0PsO.js} +2 -2
  45. package/web/dist/assets/{Docs-BHyaggFy.js → Docs-Lne3nuzD.js} +1 -1
  46. package/web/dist/assets/{GreenhouseView-Sc_UDMOE.js → GreenhouseView-4CbIuTpl.js} +1 -1
  47. package/web/dist/assets/{Installs-Gw-pJ3Zi.js → Installs-CIzbV1Ks.js} +1 -1
  48. package/web/dist/assets/{JournalView-_gxpsxXi.js → JournalView-BlBfuvzj.js} +1 -1
  49. package/web/dist/assets/{Laws-S9fq1r9T.js → Laws-B-pjcjHr.js} +1 -1
  50. package/web/dist/assets/{Library-MBxadRzn.js → Library-x8Nzg6yq.js} +1 -1
  51. package/web/dist/assets/{Loops-Da_JhlzM.js → Loops-DIOxW1xH.js} +1 -1
  52. package/web/dist/assets/{OperatorHudView--TkvDybH.js → OperatorHudView-D7S7z7CJ.js} +1 -1
  53. package/web/dist/assets/{Personal-CTXKHVbV.js → Personal-Ws3v35AB.js} +1 -1
  54. package/web/dist/assets/{Tabs-B0cdf8k6.js → Tabs-6Z1eziAq.js} +1 -1
  55. package/web/dist/assets/{drawable-BbWQxe82.js → drawable-ATNeYKbT.js} +1 -1
  56. package/web/dist/assets/{index-ujKG1ZNf.js → index-CDg5XUhG.js} +2 -2
  57. package/web/dist/index.html +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,29 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [3.17.0] — 2026-10-08
12
+
13
+ ### Added
14
+
15
+ - Docs in AsciiDoc. `document-project`, the docs agent and the `docs-update` brick detect a project's docs format: a `.md`-only tree stays Markdown, an `.adoc`-only tree stays AsciiDoc, a mix asks you, and a project with no docs defaults to AsciiDoc. The choice is recorded as `Docs format:` in `AGENTS.md`. AsciiDoc mode uses header attributes, `.adoc` names and `[source,mermaid]` diagrams that GitHub renders, and it finishes with an `asciidoctor --failure-level=WARN` lint (or an explicit SKIPPED). With no docs yet, `document-project` interviews you first. Agent-runtime files stay Markdown.
16
+ - C4 architecture diagrams in `document-project`. The audience you name sets the levels: L1 and L2 always, L3 only for containers you call complex, never L4. Diagrams are Mermaid `flowchart`s styled as C4 (typed nodes, `intent · protocol` edges, a title and a legend), redrawn only inside their own section. The `AGENTS.md` it writes sends a cross-container change to the L2 diagram first.
17
+ - The context hook and the OpenCode plugin pick up `.adoc` files wherever they pick up `.md`; `README.adoc` and `CONTRIBUTING.adoc` load eagerly.
18
+ - Protected checks. List the checks and tests your agent is judged by in `.geneseed/protected-checks.txt` at the repo root, one path per line, and a write to any of them asks you first on Claude Code, is blocked on Bob and refused on OpenCode, citing *External Gate*. A check the agent can edit is not a check. Nothing changes until a project adds the file. Shell edits are not seen, so keep running the checks in CI.
19
+
20
+ ### Changed
21
+
22
+ - Loops repair once, then stop for you. The test repair rings of every shipped template (`apply-test`, `fix-test`, `write-run`, `stabilise`) are now `max: 1`: one repair, then the attempt is discarded for one fresh try, then the loop stops. A second revision makes code worse, not better — 0.82 correct after one revision, 0.673 after two ([arXiv 2607.24604](https://arxiv.org/abs/2607.24604)). A ring's `max` now binds its own repair edge even when a wider ring shares its nodes; before, `review-fix` (max 3) quietly let `apply-test` retry four times.
23
+ - The `test` brick runs checks in order — build gate, then every focused check (none hides another), then the full suite only when all passed — and lists each one as `PASS`, `FAIL`, `ERROR` or `SKIPPED (reason)`. Each failure is four lines: purpose, property, a one-line diagnostic, the rerun command.
24
+ - `error` is a reserved loop outcome: a check that crashed or was never wired stops the loop for a human instead of feeding a repair. A brick may declare it without an edge.
25
+ - Each `test` report replaces the earlier `test` notes, so a repair never reads findings about code that has since changed.
26
+ - Doctrine *Bound the Loop* says the same outside the loop engine: one repair, then a fresh attempt or a hand-back; only a verdict on the final artifact counts.
27
+
28
+ ## [3.16.1] — 2026-10-07
29
+
30
+ ### Fixed
31
+
32
+ - `geneseed upgrade` from a checkout older than the August rename of `bin/geneseed.mjs` to `bin/build-driver.mjs` no longer fails with `Cannot find module '…/bin/geneseed.mjs'` and `[E-BUILD]` after a successful pull and doctor gate. The old updater (and an old web daemon's build jobs) spawn the old path from the code they started with; `bin/geneseed.mjs` is back as a forwarder to the driver. If you already hit it, run `geneseed upgrade` again — it is up to date now and rebuilds with the new code.
33
+
11
34
  ## [3.16.0] — 2026-10-05
12
35
 
13
36
  **Upgrading:** re-emit (`geneseed rebuild-all`) so the context hook stays under Claude Code's output cap. To drop skills you never use, rebuild an install with `--exclude-skills <name,…>` (see [Exclusions](docs/concepts/exclusions.md)).
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: **3.16.0**.
23
+ > string. Current label: **3.17.0**.
24
24
 
25
25
  ## Capabilities
26
26
 
@@ -156,8 +156,12 @@ function log(msg) { if (DEBUG) console.error(`[geneseed-context] ${msg}`) }
156
156
  // (OpenCode's own native roots) are deliberately absent: OpenCode already has them, and
157
157
  // injecting them again re-sent the whole harness on every request until 2026-09.
158
158
  const EAGER_ROOT = new Set([
159
- ".cursorrules", "README.md", "CONTRIBUTING.md", "user-rules.md", "PROFILE.md",
159
+ ".cursorrules", "README.md", "README.adoc", "CONTRIBUTING.md", "CONTRIBUTING.adoc",
160
+ "user-rules.md", "PROFILE.md",
160
161
  ])
162
+ // Project docs are Markdown or AsciiDoc (document-project writes AsciiDoc when a project has
163
+ // none yet). Same set as js/hosts/hooks-context.mjs DOC_EXT.
164
+ const isDoc = (name) => /\.(md|adoc)$/i.test(name)
161
165
  // Doc trees walked recursively; everything found is lazy (listed, not injected).
162
166
  const LAZY_DIRS = ["docs", "doc", "documentation", "architecture", "adr", "ADR"]
163
167
  // Never descend into these.
@@ -277,11 +281,13 @@ function globToRegExp(glob) {
277
281
  return new RegExp("^" + re + "$")
278
282
  }
279
283
 
280
- // First H1 / heading line of a markdown file, for the lazy listing.
284
+ // First heading line of a markdown or AsciiDoc file, for the lazy listing. An AsciiDoc
285
+ // title is "= Title"; the space is required so a setext "====" underline never matches.
281
286
  function firstHeading(text) {
282
287
  for (const line of text.split("\n")) {
283
288
  const s = line.trim()
284
289
  if (s.startsWith("#")) return s.replace(/^#+\s*/, "").trim()
290
+ if (/^=+\s/.test(s)) return s.replace(/^=+\s*/, "").trim()
285
291
  }
286
292
  return ""
287
293
  }
@@ -425,7 +431,7 @@ async function walkMd(dir, root, depth, acc) {
425
431
  if (e.isDirectory()) {
426
432
  if (EXCLUDE_DIRS.has(e.name)) continue
427
433
  await walkMd(full, root, depth + 1, acc)
428
- } else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
434
+ } else if (e.isFile() && isDoc(e.name)) {
429
435
  acc.push(full)
430
436
  }
431
437
  }
@@ -448,7 +454,7 @@ async function discover(root) {
448
454
  if (!e.isFile()) continue
449
455
  const abs = path.join(root, e.name)
450
456
  if (EAGER_ROOT.has(e.name)) add(eager, abs)
451
- else if (e.name.toLowerCase().endsWith(".md")) add(lazy, abs) // misc root .md
457
+ else if (isDoc(e.name)) add(lazy, abs) // misc root .md / .adoc
452
458
  }
453
459
 
454
460
  // 2. Doc trees (recursive) -> lazy.
@@ -469,8 +475,10 @@ async function discover(root) {
469
475
  try { pkgs = await fs.readdir(base, { withFileTypes: true }) } catch { continue }
470
476
  for (const p of pkgs) {
471
477
  if (!p.isDirectory() || EXCLUDE_DIRS.has(p.name)) continue
472
- const readme = path.join(base, p.name, "README.md")
473
- if (await isFile(readme) && !eager.has(readme)) add(lazy, readme)
478
+ for (const name of ["README.md", "README.adoc"]) {
479
+ const readme = path.join(base, p.name, name)
480
+ if (await isFile(readme) && !eager.has(readme)) add(lazy, readme)
481
+ }
474
482
  }
475
483
  }
476
484
 
@@ -288,6 +288,32 @@ function ruleStoreTarget(p) {
288
288
  return nocase(abs).startsWith(nocase(mem) + "/") ? "memory" : null
289
289
  }
290
290
 
291
+ // ---- protected checks (External Gate) ------------------------------------------
292
+ // Twin of js/hosts/hooks.mjs's `protectedCheck` — keep in step. A project lists the checks its agent
293
+ // must not edit in `.geneseed/protected-checks.txt` at its git root, one repo-relative path per
294
+ // line; the list protects itself. Found from the WRITE TARGET up to the first `.git`, re-read on
295
+ // every write (a short file, read only on a write call). No ask tier here, so a hit is a deny.
296
+ const SENSOR_LIST = ".geneseed/protected-checks.txt"
297
+ function protectedCheck(p) {
298
+ try {
299
+ const target = path.resolve(p)
300
+ let root = path.dirname(target)
301
+ while (!existsSync(path.join(root, ".git"))) {
302
+ const up = path.dirname(root)
303
+ if (up === root) return null
304
+ root = up
305
+ }
306
+ const text = readFileSync(path.join(root, SENSOR_LIST), "utf8")
307
+ const fold = (s) => nocase(s.replace(/\\/g, "/"))
308
+ const rel = fold(path.relative(root, target))
309
+ const entries = [SENSOR_LIST, ...text.split(/\r?\n/).map((l) => l.trim().replace(/[\\/]+$/, ""))
310
+ .filter((l) => l && !l.startsWith("#"))]
311
+ return entries.find((e) => rel === fold(e) || rel.startsWith(fold(e) + "/")) ?? null
312
+ } catch {
313
+ return null // no list (the common case) or an unreadable one: nothing is protected
314
+ }
315
+ }
316
+
291
317
  // Mutation-class tools for the wiki check — wider than WRITE_TOOLS because moving,
292
318
  // renaming, or deleting a protected note is as destructive as overwriting it. Same
293
319
  // substring stance: over-matching the class is harmless (see WRITE_TOOLS note).
@@ -437,6 +463,8 @@ export const GeneseedGuard = async (ctx) => {
437
463
  const p = pickPath(args)
438
464
  if (p && SECRET_RE.some((re) => re.test(p))) { await deny(`write to secret/key file ${p} (Sealed Secrets)`, "law-1"); return }
439
465
  if (p && SECRET_WARN_RE.some((re) => re.test(p))) log(`WARN: writing ${p} — keep secrets out of tracked files (Sealed Secrets)`)
466
+ const sensor = p && protectedCheck(p)
467
+ 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 }
440
468
  const store = p && ruleStoreTarget(p)
441
469
  if (store && !RULE_STORE_BUMPED.has(p)) {
442
470
  RULE_STORE_BUMPED.add(p)
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The generator's PRE-RENAME path, kept as a forwarder to `bin/build-driver.mjs`.
4
+ *
5
+ * WHY A RENAMED FILE STILL HAS TO EXIST. `geneseed upgrade` runs the updater it started with:
6
+ * its module graph is loaded before the `git pull`, so an updater older than the rename
7
+ * (5c970dc9, 2026-08) pulls the new tree and then spawns `bin/geneseed.mjs` to rebuild —
8
+ * a file the tree it just pulled no longer has. The doctor gate passes (it spawns
9
+ * `geneseed-cli.mjs`, which never moved), and the rebuild dies with MODULE_NOT_FOUND and
10
+ * `[E-BUILD]`. An old web daemon's build jobs spawn the same path. Nothing in this tree
11
+ * calls it, and it has no npm name on purpose: it is a landing pad for old processes, not a
12
+ * fourth entry point.
13
+ *
14
+ * Importing the driver entry runs it on this process's argv, which is the argv the old
15
+ * caller passed — so the forwarding is the import and nothing else.
16
+ */
17
+ import './build-driver.mjs';
@@ -36,6 +36,7 @@ Geneseed writes its hooks into the host's settings file. Every hook calls one pr
36
36
  **rule-gate.** Runs before every file write.
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
+ - 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.
39
40
  - Ordinary edits pass untouched.
40
41
 
41
42
  **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/`, skipping duplicates. It needs `GENESEED_LLM` set to a model command, for example:
@@ -21,6 +21,9 @@ brick's behalf. This applies to `plan` and `review` as much as to the newer bric
21
21
  brick whose finding the next brick needs writes it to a file in the OS temp directory and reports
22
22
  that file's path with the outcome.
23
23
 
24
+ One exception keeps the verdict fresh: each `test` report replaces every earlier `test` note, so a
25
+ repair reads only the findings for the code as it stands, never a trace from an older version.
26
+
24
27
  ## `description` and `rules`: what a template is for, and what every brick must do
25
28
 
26
29
  A template's JSON carries two prose fields, and they are never confused:
@@ -47,6 +47,17 @@ all gets exactly 1 — no re-entry — which is also why **every cycle inside an
47
47
  named inner loop** in the graph JSON: `geneseed loop check` (and `loop init`, which runs the same
48
48
  check) refuses a graph with an undeclared cycle inside an iteration, before a single node runs.
49
49
 
50
+ **A ring's `max` is also its own repair budget.** The edge that returns to a ring's first node
51
+ from inside it — `test` → `apply` — is charged to the smallest ring holding both ends, and that
52
+ ring stops at its `max` even when a wider ring raises the node's shared budget. The shipped test
53
+ rings (`apply-test`, `fix-test`, `write-run`, `stabilise`) are `max: 1`: one repair, then the
54
+ attempt is discarded for one fresh try, then the loop stops for you. A second revision makes the
55
+ result worse, not better — 0.82 correct after one, 0.673 after two (arXiv 2607.24604).
56
+
57
+ **`error` always stops.** A brick may declare the outcome `error` without an edge for it; the
58
+ engine sends it to `$stop`. The shipped `test` brick reports it when a check crashed or its
59
+ evidence was never wired — no verdict is not a failure to repair.
60
+
50
61
  ## Limits
51
62
 
52
63
  - **No parallel nodes.** The graph runs one node at a time; there is no fan-out.
@@ -11,7 +11,7 @@ Once the harness is installed for OpenCode (global or per repo), OpenCode can ru
11
11
 
12
12
  ```
13
13
  opencode run "review the staged diff and list any correctness bugs" # one shot, prints to stdout
14
- opencode run -m anthropic/claude-sonnet-4-5 "…" # pin a model for this run
14
+ opencode run -m anthropic/claude-sonnet-5 "…" # pin a model for this run
15
15
  cat issue.md | opencode run "triage this and propose a fix plan" # pipe input in
16
16
  ```
17
17
 
@@ -21,8 +21,8 @@ The context step sorts every doc it finds into one of two lists:
21
21
 
22
22
  With no configuration, the context step finds these files:
23
23
 
24
- - **Eager:** root `AGENTS.md`, `AGENT.md`, `CLAUDE.md` and `.cursorrules`, plus `README.md` and `CONTRIBUTING.md`. The root file your tool already loads by itself is skipped, so you never pay for it twice.
25
- - **Lazy:** `docs/`, `doc/`, `documentation/`, `architecture/`, `adr/` and `ADR/`, monorepo `packages/*/README.md` and `apps/*/README.md`, and any other root `*.md`.
24
+ - **Eager:** root `AGENTS.md`, `AGENT.md`, `CLAUDE.md` and `.cursorrules`, plus `README.md` and `CONTRIBUTING.md` (or their AsciiDoc `.adoc` twins). The root file your tool already loads by itself is skipped, so you never pay for it twice.
25
+ - **Lazy:** `docs/`, `doc/`, `documentation/`, `architecture/`, `adr/` and `ADR/`, monorepo `packages/*/README.md` and `apps/*/README.md`, and any other root `*.md`. AsciiDoc docs (`.adoc`) are found everywhere a `.md` is.
26
26
  - **Never scanned:** `node_modules`, `.git`, `dist`, `build`, `vendor`, `.next`, `target`, `.venv`, `__pycache__`, `.opencode` and `.harness`.
27
27
 
28
28
  On Claude Code, Bob and OpenClaude the whole injection stays under 9,000 characters, because Claude Code hands the model only a short preview of a hook output past about 10,000 and keeps the rest in a file. Your session files come first, then eager files whole; an eager file that does not fit is listed instead, to be read on demand. On OpenCode eager files share a 48 KB budget. On every host, three or more docs in one folder are listed as that folder with a count (`docs/guides/ — 30 docs`), not file by file.
@@ -6,7 +6,7 @@ kind: "concept"
6
6
  section: "Set up"
7
7
  description: "Let the agent read PDF, Word, PowerPoint, Excel and HTML through a converter."
8
8
  ---
9
- The agent's doc discovery only sees markdown. The `ingest` skill teaches the agent to convert a PDF, Word, PowerPoint, Excel or HTML file, or a URL, to markdown before reading it. Install one converter and the skill uses it:
9
+ The agent's doc discovery sees markdown (`.md`) and AsciiDoc (`.adoc`) only. The `ingest` skill teaches the agent to convert a PDF, Word, PowerPoint, Excel or HTML file, or a URL, to markdown before reading it. Install one converter and the skill uses it:
10
10
 
11
11
  - **MarkItDown**: the broadest. Use [its MCP server](mcp-markitdown.md) (preferred on a host with MCP: nothing to install per call, one cheap tool), or `pip install markitdown`.
12
12
  - **Pandoc**: excellent for Office and HTML, and a single binary.
@@ -10,8 +10,8 @@ One of the [OpenCode plugins](opencode-plugins.md). Its switches are also listed
10
10
 
11
11
  **What it does.** Puts your project's documentation (and your machine wiki) into the agent's context before your first turn, so the agent never has to be trusted to read it. It needs **no per-repo file**: it discovers docs by convention.
12
12
 
13
- - **Injected in full (eager):** root `README.md`, `CONTRIBUTING.md`, `.cursorrules`, `user-rules.md`, `PROFILE.md`. Budget-capped; an oversized file is listed instead, never silently truncated. (`AGENT.md`, `AGENTS.md` and `CLAUDE.md` are not injected — OpenCode already loads them.)
14
- - **Listed (lazy, path + first heading, read on demand):** `docs/`, `doc/`, `documentation/`, `architecture/`, `adr/`, and `packages/*/README.md` / `apps/*/README.md` in a monorepo. `node_modules`, `.git`, `dist`, `build`, `vendor` and similar are never scanned.
13
+ - **Injected in full (eager):** root `README.md`, `CONTRIBUTING.md` (or `README.adoc`, `CONTRIBUTING.adoc`), `.cursorrules`, `user-rules.md`, `PROFILE.md`. Budget-capped; an oversized file is listed instead, never silently truncated. (`AGENT.md`, `AGENTS.md` and `CLAUDE.md` are not injected — OpenCode already loads them.)
14
+ - **Listed (lazy, path + first heading, read on demand):** `docs/`, `doc/`, `documentation/`, `architecture/`, `adr/`, and `packages/*/README.md` / `apps/*/README.md` in a monorepo, AsciiDoc `.adoc` docs included. `node_modules`, `.git`, `dist`, `build`, `vendor` and similar are never scanned.
15
15
  - **Machine wiki:** the wikis declared in `wiki.jsonc` ride in the same block, on the same budgets. See [Wiki](../guides/wiki.md).
16
16
  - **Explicit manifest:** `$GENESEED_CONTEXT`, `.harness/context.json` or a root `context.json` takes over from discovery; `"extend": true` layers it on top instead. See [Project context](../guides/project-context.md).
17
17
 
@@ -10,7 +10,7 @@ One of the [OpenCode plugins](opencode-plugins.md). [Environment variables](envi
10
10
 
11
11
  **What it does.** Enforces the safety rules at the tool boundary — before a tool call runs, not after. High-confidence patterns only, so legitimate work is not caught.
12
12
 
13
- - **Blocks** writes to private-key and credential files (*Sealed Secrets*), catastrophic shell commands such as deleting the filesystem root (*Deletion Is Deliberate*), and any change — write, move, rename, delete — under a wiki folder declared `protected` in `wiki.jsonc`.
13
+ - **Blocks** writes to private-key and credential files (*Sealed Secrets*), catastrophic shell commands such as deleting the filesystem root (*Deletion Is Deliberate*), and any change — write, move, rename, delete — under a wiki folder declared `protected` in `wiki.jsonc`, and writes to a check listed in the project's `.geneseed/protected-checks.txt` (*External Gate* — see [Hooks](../concepts/hooks.md)).
14
14
  - **Warns** (logged, allowed) on `.env` writes and force-push.
15
15
  - **Speed-bumps** the *first* write to `user-rules.md` or a memory file: it is refused once, naming *Persist Insight* — whether something is a standing rule or a fact to remember is your call, settled through the `rule` skill. The re-issued write goes through.
16
16
 
@@ -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": "3.16.0"
5
+ "version": "3.17.0"
6
6
  }
@@ -32,7 +32,8 @@ const _ERRNO = {
32
32
  function asOsError(e, filename) {
33
33
  const hit = _ERRNO[e && e.code];
34
34
  if (!hit) return String((e && e.message) || e);
35
- // Python reprs the filename, so a Windows path comes out with its backslashes doubled.
35
+ // The `[Errno N] text: 'file'` shape this hook has always printed: the filename is quoted,
36
+ // so a Windows path comes out with its backslashes doubled.
36
37
  return `[Errno ${hit[0]}] ${hit[1]}: ${JSON.stringify(filename).replace(/^"|"$/g, "'")
37
38
  .replace(/\\"/g, '"')}`;
38
39
  }
@@ -57,7 +58,7 @@ function fnTranslate(pat) {
57
58
  if (pat[j] === '!') j += 1;
58
59
  if (pat[j] === ']') j += 1;
59
60
  while (j < pat.length && pat[j] !== ']') j += 1;
60
- // An unterminated `[` is a LITERAL bracket in Python's translate, not an error.
61
+ // An unterminated `[` is a LITERAL bracket (fnmatch semantics), not an error.
61
62
  if (j >= pat.length) { out += '\\['; continue; }
62
63
  let body = pat.slice(i + 1, j).replaceAll('\\', '\\\\');
63
64
  if (body.startsWith('!')) body = `^${body.slice(1)}`;
@@ -77,9 +78,14 @@ function fnTranslate(pat) {
77
78
  */
78
79
  let HOST = 'claude';
79
80
 
80
- // Kept in step with adapters/opencode/plugins/geneseed-context.js, same as the Python.
81
+ // Kept in step with adapters/opencode/plugins/geneseed-context.js.
81
82
  const EAGER_ROOT = ['AGENTS.md', 'AGENT.md', 'CLAUDE.md', '.cursorrules',
82
- 'README.md', 'CONTRIBUTING.md', 'user-rules.md', 'PROFILE.md'];
83
+ 'README.md', 'README.adoc', 'CONTRIBUTING.md', 'CONTRIBUTING.adoc', 'user-rules.md', 'PROFILE.md'];
84
+ // Project docs are Markdown or AsciiDoc: the document-project skill writes AsciiDoc by default
85
+ // when a project has no docs yet, so a loader blind to `.adoc` would hide every doc it wrote.
86
+ // Agent-runtime files (AGENTS.md, CLAUDE.md, specs) stay Markdown whatever the project uses.
87
+ const DOC_EXT = new Set(['.md', '.adoc']);
88
+ const isDoc = (name) => DOC_EXT.has(path.extname(name).toLowerCase());
83
89
  const LAZY_DIRS = ['docs', 'doc', 'documentation', 'architecture', 'adr', 'ADR'];
84
90
  const EXCLUDE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'vendor', '.next',
85
91
  'target', '.venv', '__pycache__', '.opencode', '.harness']);
@@ -182,9 +188,8 @@ export function globalHookStandingDown(hookRoot, cwd) {
182
188
 
183
189
  /** `_disp` — relative to the repo root when it sits under it, else verbatim. */
184
190
  function disp(pathStr, root) {
185
- // `os.path.relpath` raises ValueError across drives and the Python falls back to the
186
- // path as given; `path.relative` silently returns an absolute path instead, which would
187
- // print a DIFFERENT string rather than the same one.
191
+ // A path on another drive has no relative form, so it is printed as given. `path.relative`
192
+ // would silently return an absolute path instead — a different string, not the same one.
188
193
  if (path.parse(path.resolve(pathStr)).root.toLowerCase()
189
194
  !== path.parse(path.resolve(root)).root.toLowerCase()) {
190
195
  return pathStr;
@@ -193,18 +198,18 @@ function disp(pathStr, root) {
193
198
  }
194
199
 
195
200
 
196
- function rglobMd(dir, acc = []) {
201
+ function rglobDocs(dir, acc = []) {
197
202
  for (const name of listDir(dir)) {
198
203
  const full = path.join(dir, name);
199
- if (isDir(full)) rglobMd(full, acc);
200
- else if (isFile(full) && path.extname(name).toLowerCase() === '.md') acc.push(full);
204
+ if (isDir(full)) rglobDocs(full, acc);
205
+ else if (isFile(full) && isDoc(name)) acc.push(full);
201
206
  }
202
207
  return acc;
203
208
  }
204
209
 
205
210
  /**
206
211
  * `_discover_context` — the no-manifest path, mirroring the OpenCode context plugin.
207
- * Root entry docs are eager; other root markdown, the doc trees and monorepo package
212
+ * Root entry docs are eager; other root docs (.md or .adoc), the doc trees and monorepo package
208
213
  * READMEs are lazy.
209
214
  */
210
215
  export function discoverContext(root, host = HOST) {
@@ -217,12 +222,12 @@ export function discoverContext(root, host = HOST) {
217
222
  const name = path.basename(full);
218
223
  if (native.includes(name)) continue;
219
224
  if (EAGER_ROOT.includes(name)) eager.set(full, null);
220
- else if (path.extname(name).toLowerCase() === '.md') lazy.set(full, null);
225
+ else if (isDoc(name)) lazy.set(full, null);
221
226
  }
222
227
  for (const d of LAZY_DIRS) {
223
228
  const sub = path.join(root, d);
224
229
  if (!isDir(sub)) continue;
225
- for (const md of sortPaths(rglobMd(sub))) {
230
+ for (const md of sortPaths(rglobDocs(sub))) {
226
231
  const parts = path.relative(root, md).split(/[\\/]/);
227
232
  if (parts.some((part) => EXCLUDE_DIRS.has(part))) continue;
228
233
  if (!eager.has(md) && !lazy.has(md)) lazy.set(md, null);
@@ -233,8 +238,10 @@ export function discoverContext(root, host = HOST) {
233
238
  if (!isDir(base)) continue;
234
239
  for (const pkg of sortPaths(listDir(base).map((n) => path.join(base, n)))) {
235
240
  if (!isDir(pkg) || EXCLUDE_DIRS.has(path.basename(pkg))) continue;
236
- const readme = path.join(pkg, 'README.md');
237
- if (isFile(readme) && !eager.has(readme) && !lazy.has(readme)) lazy.set(readme, null);
241
+ for (const name of ['README.md', 'README.adoc']) {
242
+ const readme = path.join(pkg, name);
243
+ if (isFile(readme) && !eager.has(readme) && !lazy.has(readme)) lazy.set(readme, null);
244
+ }
238
245
  }
239
246
  }
240
247
  return [
@@ -281,19 +288,18 @@ export function resolveContextSets(root, hookRoot = null) {
281
288
  let extend;
282
289
  try {
283
290
  let data = JSON.parse(raw);
284
- // Valid JSON of the wrong SHAPE, guarded on both sides — see the Python original. JS
285
- // would not have thrown here (`[].context` is merely undefined), which is exactly why
286
- // the guard is written out rather than left to fall out of the language: without it,
287
- // the two CLIs disagree about a file a user really does hand-edit.
291
+ // Valid JSON of the wrong SHAPE (null, a number, an array) is an empty manifest, not a
292
+ // syntax error. The guard is written out rather than left to fall out of the language:
293
+ // without it, `null.context` throws into the catch below and a file a user really does
294
+ // hand-edit is reported as invalid JSON when it is not.
288
295
  data = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
289
296
  entries = data.context || [];
290
297
  if (!Array.isArray(entries)) entries = [];
291
298
  extend = Boolean(data.extend);
292
299
  } catch {
293
- // See the Python original for why the decoder's own message is not quoted: the two
294
- // engines disagree on the wording and on the offset, and V8 supplies no offset at all
295
- // for the commonest shapes. Reporting the file is what the user needs; reporting
296
- // WHICH decoder found it is what makes two implementations impossible.
300
+ // The decoder's own message is not quoted: its wording and offset are engine details,
301
+ // and V8 supplies no offset at all for the commonest shapes. Reporting the file is what
302
+ // the user needs.
297
303
  err(`[context] ${manifest} is not valid JSON — no context was loaded `
298
304
  + '(fix the syntax, then re-run).\n');
299
305
  return [[], [], String(manifest)];
@@ -201,7 +201,7 @@ const BOB_DENY_EXIT = 2;
201
201
  * logs. `gate-error` is a warning too: a crashed gate that blocked every tool call would be
202
202
  * a lockout, not a safeguard, and there is no prompt through which the user could clear it.
203
203
  */
204
- const BLOCK_RULES = new Set(['law-1', 'law-4']);
204
+ const BLOCK_RULES = new Set(['law-1', 'law-4', 'rigor-5']);
205
205
 
206
206
  /**
207
207
  * THE GATE LEDGER — one JSON line per ask, nothing per defer.
@@ -358,6 +358,33 @@ const SECRET_RE =
358
358
  /\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|sk-ant-[A-Za-z0-9_-]{20,}|xox[abprs]-[0-9A-Za-z-]{10,})\b|-----BEGIN [A-Z ]*PRIVATE KEY-----/;
359
359
  const DOTENV_RE = /(^|[\\/])\.env(\.[^\\/]*)?$/;
360
360
 
361
+ /**
362
+ * External Gate's boundary half: the checks an agent is judged by must sit outside what it can
363
+ * edit, or a failing check is one edit away from a passing one. A project opts in by listing
364
+ * them, one repo-relative path per line (`#` comments), in `.geneseed/protected-checks.txt` at
365
+ * its git root — found by walking up from the WRITE TARGET, not the cwd, so a session in one repo
366
+ * writing into another reads that other repo's list. The list protects itself. Returns the entry
367
+ * the path falls under, or null. ponytail: a write tool only — a shell `sed -i` or redirect is
368
+ * not seen here; CI running the checks is the boundary that holds against that.
369
+ */
370
+ export const SENSOR_LIST = '.geneseed/protected-checks.txt';
371
+ function protectedCheck(p) {
372
+ try {
373
+ const target = resolvePath(p);
374
+ const root = gitRootOf(path.dirname(target));
375
+ if (!root) return null;
376
+ const text = readFileSync(path.join(root, SENSOR_LIST), 'utf8');
377
+ // `normcase` folds case AND slashes on Windows, so both sides go through it, the trailing
378
+ // separator of the prefix included.
379
+ const rel = normcase(path.relative(root, target));
380
+ const entries = [SENSOR_LIST, ...text.split(/\r?\n/).map((l) => l.trim().replace(/[\\/]+$/, ''))
381
+ .filter((l) => l && !l.startsWith('#'))];
382
+ return entries.find((e) => rel === normcase(e) || rel.startsWith(normcase(`${e}/`))) ?? null;
383
+ } catch {
384
+ return null; // no list (the common case) or an unreadable one: nothing is protected
385
+ }
386
+ }
387
+
361
388
  function ruleGate(args) {
362
389
  if (sovereignBypass(args.root)) return 0;
363
390
  return ruleDecide(args, readPayload());
@@ -378,6 +405,12 @@ function ruleDecide(args, payload) {
378
405
  + 'credential-shaped string. Secrets live in .env or a secret manager, never in a '
379
406
  + 'tracked file.');
380
407
  }
408
+ const sensor = protectedCheck(p);
409
+ if (sensor) {
410
+ return ask(args, 'rule-gate', 'rigor-5', `Geneseed (External Gate) — ${p} is a protected `
411
+ + `check (${sensor}, listed in ${SENSOR_LIST}). A check the agent can edit is not a `
412
+ + 'check: fix the code, not the check, unless the user asks for this edit.');
413
+ }
381
414
  const target = ruleGateTarget(p, args.root);
382
415
  if (!target) return 0;
383
416
  return ask(args, 'rule-gate', 'process-1', `Geneseed (Persist Insight) \u2014 writing to `
@@ -513,7 +513,8 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
513
513
  SessionStart: [{ hooks: [{ type: 'command', command: `${run} context --root "${cfg}"${b} || exit 0` }] }],
514
514
  // One group, both gates: process 5's consent is a stderr line here (Bob has no ask
515
515
  // tier), so the pack toggle that drops Claude's git-gate group has nothing to drop —
516
- // `tool-gate` only ever EXITS 2 for Laws I and IV, which every build carries.
516
+ // `tool-gate` only ever EXITS 2 for Laws I and IV, which every build carries, and for a
517
+ // write under a project's own `.geneseed/protected-checks.txt` — the project opted in.
517
518
  PreToolUse: [{ hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"${b}` }] }],
518
519
  Stop: [{ hooks: [{ type: 'command', command: `${run} learn ${mem} || exit 0` }] }],
519
520
  };
@@ -169,14 +169,14 @@ const DOCTRINE_TOKEN_RE = /\{\{DOCTRINE(?:\}\}|:)/;
169
169
  /**
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
- * (`law-1`, `law-4`, `process-1`, `process-5` in `js/hosts/hooks.mjs` and the OpenCode guard
172
+ * (`law-1`, `law-4`, `process-1`, `process-5`, `rigor-5` in `js/hosts/hooks.mjs` and the OpenCode guard
173
173
  * plugin), `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
178
  'sealed-secrets': 'I', 'deletion-is-deliberate': 'IV',
179
- 'persist-insight': 'process 1', 'consent-before-push': 'process 5',
179
+ 'persist-insight': 'process 1', 'consent-before-push': 'process 5', 'external-gate': 'rigor 5',
180
180
  };
181
181
 
182
182
  /**
package/js/loop/graph.mjs CHANGED
@@ -83,7 +83,8 @@ export function checkGraph(graph, bricks) {
83
83
  if (!brick) continue;
84
84
  for (const o of brick.outcomes) {
85
85
  const count = edges.filter((e) => e.from === n && e.on === o).length;
86
- if (count === 0) problems.push(`node ${n}: no edge for outcome ${o}`);
86
+ // `error` is reserved: with no edge the engine routes it to $stop (`recordOutcome`).
87
+ if (count === 0 && o !== 'error') problems.push(`node ${n}: no edge for outcome ${o}`);
87
88
  else if (count > 1) problems.push(`node ${n}: ${count} edges for outcome ${o}`);
88
89
  }
89
90
  }
package/js/loop/state.mjs CHANGED
@@ -288,6 +288,22 @@ function allowedEntries(graph, v) {
288
288
  return { allowed: widest ? 1 + widest.max : 1, loop: widest };
289
289
  }
290
290
 
291
+ // The repair ring an edge closes: the smallest non-iteration loop whose FIRST node is the edge's
292
+ // target and which also holds its source (test -> apply is apply-test's repair, review -> apply
293
+ // review-fix's). Its own counter makes `max` the number of repairs that ring may spend, even when
294
+ // a wider ring sharing the node raises the node budget above it — `max: 1` is one repair, then
295
+ // `exhaust`'s fresh attempt, then a stop (arXiv 2607.24604: a second revision lowers accuracy).
296
+ // Its key lives in `counters`, so every reset of a unit resets it too.
297
+ function repairRing(graph, from, to) {
298
+ const iteration = iterationLoop(graph);
299
+ let ring = null;
300
+ for (const m of graph.loops) {
301
+ if (m === iteration || m.nodes[0] !== to || !m.nodes.includes(from)) continue;
302
+ if (!ring || m.nodes.length < ring.nodes.length) ring = m;
303
+ }
304
+ return ring;
305
+ }
306
+
291
307
  // Porcelain lines are `XY <path>`; strip the 2-char status + space, slash-normalise, drop
292
308
  // LOOP.md rows (the engine rewrites it on every action — it is never part of the scored work)
293
309
  // and a lingering LOOP.md.tmp (the atomic writer's own sibling — see `writeLoopFile`'s docblock).
@@ -348,10 +364,17 @@ export function recordOutcome(state, bricks, outcome, { card = null, porcelain =
348
364
  // this call closes the unit, re-splits, or stops the loop. `notes` is never reset mid-run
349
365
  // (only `exhaust`'s own push and `decideAwaiting` add to it otherwise), so it survives
350
366
  // `resetUnit`/`finishUnit` — which a card does not: `resetUnit` clears it.
367
+ // A fresh verdict: `test`'s latest report supersedes its earlier ones, so a repair never reads
368
+ // findings about code that has since changed (stale traces broke 34 of 135 correct attempts
369
+ // in arXiv 2607.24604, fresh ones 4). Every other brick's notes are a record and are kept.
370
+ if (from === 'test') state.notes = state.notes.filter((n) => !/^iteration \d+ \(test\): /.test(n));
351
371
  if (note) state.notes.push(`iteration ${state.iteration} (${from}): ${note}`);
352
372
  state.visited.push(from);
353
373
 
354
- const edge = state.graph.edges.find((e) => e.from === from && e.on === outcome);
374
+ // `error` is reserved: a check that produced no verdict stops for a human, never a repair. A
375
+ // brick may declare it without an edge (`checkGraph` allows that); the engine routes it here.
376
+ const edge = state.graph.edges.find((e) => e.from === from && e.on === outcome)
377
+ ?? (outcome === 'error' ? { from, on: 'error', to: '$stop' } : null);
355
378
  if (!edge) throw new Error(`no edge from ${from} on ${outcome}`);
356
379
  if (brick.gate === 'human' && (!brick.gateOn || brick.gateOn.includes(outcome))) {
357
380
  state.status = 'awaiting';
@@ -385,6 +408,12 @@ function transition(state, bricks, from, edge) {
385
408
  finishUnit(state, false);
386
409
  return state.status === 'stopped' ? { stopped: state.reason } : { node: state.node };
387
410
  }
411
+ const ring = repairRing(state.graph, from, edge.to);
412
+ if (ring) {
413
+ const key = `ring:${ring.name}`;
414
+ state.counters[key] = (state.counters[key] ?? 0) + 1;
415
+ if (state.counters[key] > ring.max) return exhaust(state, ring);
416
+ }
388
417
  state.counters[edge.to] = (state.counters[edge.to] ?? 0) + 1;
389
418
  const { allowed, loop } = allowedEntries(state.graph, edge.to);
390
419
  if (state.counters[edge.to] > allowed) return exhaust(state, loop ?? { name: edge.to, max: 0 });
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": "3.16.0",
4
+ "version": "3.17.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",
@@ -24,6 +24,11 @@
24
24
  that the code does not implement ({{LAW:verify-before-asserting}}).
25
25
  2. Write for the stated audience; lead with what the reader needs to do.
26
26
  3. Keep examples runnable; update any example that the change broke.
27
+ 4. Write each page in the format it is already in — Markdown or AsciiDoc; never convert one.
28
+ A new page follows the `Docs format:` line in the root `AGENTS.md`, else the format the
29
+ project's docs already use (only AsciiDoc when it has none yet). Agent-runtime files —
30
+ `AGENTS.md`, `CLAUDE.md`, specs, ADRs loops read — stay Markdown. In AsciiDoc, never add
31
+ an `include::` to a page agents read.
27
32
 
28
33
  ## Output contract
29
34
  - The doc files written/updated, a one-line note of what changed and why,
@@ -9,6 +9,9 @@ Implement the card's intent exactly, touching only the files in its `writeSet`.
9
9
  carries an `amend`, that supersedes the original intent — follow the amendment, not the plan it
10
10
  replaced.
11
11
 
12
+ When this is a repair after `test`, act on the latest `test` note only — it is the verdict on
13
+ the code as it stands. Fix every finding it lists in this one pass: there is no second repair.
14
+
12
15
  Make no unrelated edits: no drive-by rename, no incidental cleanup, no touching a file the card
13
16
  did not declare. If the change genuinely needs a file outside the write set, stop and report that
14
17
  rather than widening scope unasked — the diff is scored against the declared set, not your
@@ -10,6 +10,8 @@ flag, a moved file, a behaviour the README or docs still describe the old way.
10
10
 
11
11
  Update only those docs, only to match what the diff actually changed. Do not rewrite unrelated
12
12
  prose, reorganize a page, or fix an unrelated staleness you happen to notice while in there.
13
+ Keep each doc in the format it is already in (Markdown or AsciiDoc) — never convert one; a new
14
+ page follows the `Docs format:` line in the root `AGENTS.md`.
13
15
 
14
16
  Report `pass` once every doc the diff touched is back in sync, naming the files you changed — or
15
17
  that none needed it, if the diff left nothing stale.