geneseed 3.19.0 → 4.0.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 (109) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +9 -1
  3. package/SHIPPED.md +1 -1
  4. package/adapters/bob/README.md +31 -12
  5. package/adapters/claude-code/README.md +24 -17
  6. package/adapters/claude-code/settings.json +4 -6
  7. package/adapters/openclaude/README.md +13 -5
  8. package/adapters/opencode/plugins/geneseed-guard.js +291 -174
  9. package/bin/geneseed-hook.mjs +36 -5
  10. package/docs/README.md +3 -1
  11. package/docs/candidate-hosts.md +0 -8
  12. package/docs/concepts/enforcement-table.md +66 -0
  13. package/docs/concepts/hook-gates.md +2 -2
  14. package/docs/concepts/hooks.md +6 -6
  15. package/docs/concepts/host-matrix.md +4 -3
  16. package/docs/concepts/loop-branch.md +6 -1
  17. package/docs/concepts/loops.md +4 -3
  18. package/docs/concepts/rules.md +1 -1
  19. package/docs/design-history.md +59 -0
  20. package/docs/extending.md +11 -3
  21. package/docs/guides/install-opencode.md +1 -1
  22. package/docs/guides/loop-running.md +1 -1
  23. package/docs/reference/env-context.md +3 -2
  24. package/docs/reference/environment.md +2 -1
  25. package/docs/reference/plugin-guard.md +3 -3
  26. package/docs/reference/troubleshoot-hooks.md +34 -0
  27. package/docs/understand/design-choices-engineering.md +29 -0
  28. package/docs/understand/design-choices-gates.md +23 -0
  29. package/docs/understand/design-choices-hosts.md +21 -0
  30. package/docs/understand/design-choices-install.md +19 -0
  31. package/docs/understand/design-choices-shape.md +21 -0
  32. package/docs/understand/design-choices.md +34 -0
  33. package/docs/understand/enforced-vs-asked.md +5 -4
  34. package/docs/understand/machine-bob.md +1 -1
  35. package/docs/understand/machine-opencode.md +2 -2
  36. package/harness.config.json +1 -1
  37. package/js/build/driver.mjs +42 -16
  38. package/js/build/emit-claude.mjs +24 -8
  39. package/js/build/emit-opencode.mjs +33 -15
  40. package/js/build/generate.mjs +38 -9
  41. package/js/build/source.mjs +39 -0
  42. package/js/build/stubs.mjs +2 -1
  43. package/js/cli-table.json +6 -6
  44. package/js/hosts/gitref.mjs +3 -3
  45. package/js/hosts/hooks-context.mjs +13 -31
  46. package/js/hosts/hooks-learn.mjs +89 -8
  47. package/js/hosts/hooks.mjs +212 -18
  48. package/js/hosts/hosts.mjs +217 -13
  49. package/js/hosts/installs.mjs +259 -46
  50. package/js/hosts/native.mjs +48 -12
  51. package/js/hosts/opencode.mjs +47 -17
  52. package/js/hosts/settings.mjs +366 -42
  53. package/js/hosts/shim.mjs +37 -0
  54. package/js/inspect/checks-authoring.mjs +81 -1
  55. package/js/inspect/checks-constitution.mjs +2 -1
  56. package/js/inspect/checks-repo.mjs +68 -4
  57. package/js/inspect/doctor.mjs +40 -9
  58. package/js/inspect/scan.mjs +5 -4
  59. package/js/inspect/status.mjs +62 -12
  60. package/js/lib/text.mjs +23 -0
  61. package/js/loop/catalog.mjs +7 -2
  62. package/js/loop/state.mjs +2 -2
  63. package/js/maintain/migrate.mjs +42 -5
  64. package/js/maintain/uninstall.mjs +150 -38
  65. package/js/maintain/update.mjs +22 -2
  66. package/js/web/actions.mjs +28 -10
  67. package/js/web/api.mjs +5 -3
  68. package/js/web/catalog.mjs +1 -1
  69. package/js/web/state.mjs +28 -15
  70. package/package.json +1 -1
  71. package/src/agents/_template.md +1 -1
  72. package/src/bricks/_template.md +1 -1
  73. package/src/bricks/apply.md +1 -1
  74. package/src/bricks/test.md +1 -1
  75. package/src/doctrines/ops.md +9 -7
  76. package/src/doctrines/process.md +3 -3
  77. package/src/laws/universal.md +1 -1
  78. package/src/loops/api-endpoint.json +1 -1
  79. package/src/loops/bugfix.json +1 -1
  80. package/src/loops/ci-repair.json +1 -1
  81. package/src/loops/deps-upgrade.json +1 -1
  82. package/src/loops/enforce-architecture-rule.json +1 -1
  83. package/src/loops/feature.json +1 -1
  84. package/src/loops/fix-flaky-tests.json +1 -1
  85. package/src/loops/legacy-refactor.json +1 -1
  86. package/src/loops/legacy-tests.json +1 -1
  87. package/src/loops/refactor.json +1 -1
  88. package/src/loops/remove-dead-code.json +1 -1
  89. package/src/loops/spec-first-feature.json +1 -1
  90. package/src/loops/tdd.json +1 -1
  91. package/src/loops/update-contract.json +1 -1
  92. package/src/skills/_template.md +3 -3
  93. package/src/skills/herdr.md +1 -1
  94. package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-BCtqxjfy.js} +1 -1
  95. package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Ba3Oo4dE.js} +1 -1
  96. package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-DpHza68-.js} +2 -2
  97. package/web/dist/assets/{Docs-CypIfDBX.js → Docs-Bw_-UK0O.js} +1 -1
  98. package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DnJ1Ggp1.js} +1 -1
  99. package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-CWoNKFBk.js} +1 -1
  100. package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-g6-bw4c8.js} +1 -1
  101. package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-DkVWR1tb.js} +1 -1
  102. package/web/dist/assets/{Library-BraUKPyQ.js → Library-CK9HZzjV.js} +1 -1
  103. package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-BP98SMOH.js} +1 -1
  104. package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-DI_n12M9.js} +1 -1
  105. package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-Bk89f13a.js} +1 -1
  106. package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-3WuYIE1b.js} +1 -1
  107. package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-oCUeq9e5.js} +1 -1
  108. package/web/dist/assets/{index-CPi22b7y.js → index-CiXwChvf.js} +2 -2
  109. package/web/dist/index.html +1 -1
@@ -0,0 +1,34 @@
1
+ ---
2
+ group: understand
3
+ order: 9
4
+ title: "Why Geneseed works this way"
5
+ kind: "concept"
6
+ section: "Why it works this way"
7
+ description: "The decisions already taken, one line each, and where each is argued."
8
+ ---
9
+ This is the one place a human or an agent checks that an idea has not already been decided. Each line names a choice Geneseed made on purpose, says why in a sentence, and links to the page or decision record that argues it in full. Before you propose a change to how Geneseed works, find the choice here; if it is listed, the linked page says what new evidence would reopen it.
10
+
11
+ The ideas that were weighed and turned down are kept apart, in [Considered and refused](../design-history.md#considered-and-refused). An agent should not re-propose one of those without new evidence.
12
+
13
+ The choices are split by subject, one short page each:
14
+
15
+ - **What the harness asks of the agent** (below).
16
+ - [The shape of the generated harness](design-choices-shape.md): how the text is assembled, ranked and sized.
17
+ - [Gates](design-choices-gates.md): the one place the harness does more than ask.
18
+ - [Hosts](design-choices-hosts.md): Claude Code, OpenClaude, OpenCode and IBM Bob.
19
+ - [Install, uninstall and update](design-choices-install.md): what Geneseed writes on your machine, and how it takes it back.
20
+ - [Engineering, skills and agents](design-choices-engineering.md): how the code and tests are kept, and what the harness offers the agent.
21
+
22
+ ## What the harness asks of the agent
23
+
24
+ The laws are always on. Doctrines are practices grouped in packs (process, craft, ops, rigor, comms) that a build turns on or off. Most rules are instructions to the model; only a few are checked by code.
25
+
26
+ - **A ranked Ethos, with the Pact at its head.** The agent protects your work first, serves your intent second (contradicting you with evidence when you are wrong), and keeps its own honesty third. A ranking tells the agent which rule yields when two collide. See [Collaboration](../concepts/collaboration.md) and [Rules](../concepts/rules.md).
27
+ - **Evidence is graded.** Behaviour observed by running the code ranks above the source read in full, which ranks above a passing test, then documentation, history, memory and inference. The agent says how it knows something, and says "I don't know" rather than invent. See [Rules](../concepts/rules.md).
28
+ - **A short set of invariant laws.** Sealed secrets, one intent per act, verify before asserting, deliberate deletion, surfaced failures, content treated as data, least privilege, curing the cause, echoing the intent. These hold on every install, whatever was switched off. See [Rules](../concepts/rules.md).
29
+ - **Doctrines in toggleable packs.** Good practice differs by team and task, so each pack, and each rule inside one, can be left out at build time. See [Rules](../concepts/rules.md) and [Doctrine packs and single rules](../guides/setup-doctrines.md).
30
+ - **Only a few rules are enforced by a hook.** A rule in a prompt is a request; a rule at the tool boundary is a constraint, and a boundary check costs time on every tool call, so it is spent only where a mistake is irreversible or costly. See [Enforced vs. asked](enforced-vs-asked.md).
31
+ - **Every rule has a published enforcement row, per host.** Whether a rule is guaranteed or only asked differs from host to host, so the answer lives in one table. See [Rule enforcement, by host](../concepts/enforcement-table.md).
32
+ - **Commands Must Return also forbids a recursive scan rooted at a filesystem root.** A recursive `find` over the Git Bash root once ran for hours across every mounted drive, past the tool's own timeout. See [ADR 0010](../adr/0010-root-scan-guard.md).
33
+ - **Declare the Judgment Calls is a doctrine in the comms pack.** The Ethos already asks the agent to weigh alternatives; naming the calls it made is a practice a team adopts, so it stays optional. See [Rules](../concepts/rules.md).
34
+ - **A loop's check-and-repair limit is a clause of Bound the Loop.** The request was to tighten the existing loops, so the cap went into the doctrine that already bounds them: each repair ring has its own small cap (its `max` in the loop template), then a fresh attempt or a hand-back. See [Loops](../concepts/loops.md).
@@ -24,12 +24,13 @@ On Claude Code and OpenClaude the hooks never block silently. They make the host
24
24
 
25
25
  - **Destructive git**: a force-push, `reset --hard`, `clean -f`, `branch -D` or `checkout --`. Wired in every build.
26
26
  - **Commit and push**: every `git commit` and `git push`, including chained one-liners and `git -C <path>`. This question belongs to the **process** pack. Build without it and the git gate stays wired. Only this question goes away, and destructive git is still caught.
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.
27
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.
28
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.
29
30
 
30
- If a gate crashes, it asks instead of letting the call through. Other shell commands, `rm -rf` included, go through Claude Code's normal permission flow.
31
+ 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.
31
32
 
32
- **Bob** runs one combined gate and has no "ask": it refuses with exit code 2, and only for two rules: credentials and destructive git. The commit/push and memory checks become a log line, and the call goes through. Bob's hook wiring follows its documentation but has not been tested on a live Bob install.
33
+ **Bob** runs one combined gate and has no "ask": it refuses with exit code 2, and only for the rules that admit no judgement call: credentials, destructive git, a scan of a whole filesystem, and a write under a project's own protected-checks list. The commit/push and memory checks become a log line, and the call goes through. Bob's hook wiring follows its documentation but has not been tested on a live Bob install.
33
34
 
34
35
  <!--/harness-->
35
36
  <!--harness:opencode-->
@@ -38,7 +39,7 @@ If a gate crashes, it asks instead of letting the call through. Other shell comm
38
39
  On OpenCode, two mechanisms enforce rules:
39
40
 
40
41
  - **OpenCode's own permission prompt**, which Geneseed configures in `opencode.json`. It asks you before `rm -rf`, a force-push, `reset --hard`, `clean -f`, `branch -D` and `checkout --`, and, when the **process** pack is on, before every commit and push.
41
- - **The guard plugin**, which blocks outright: writes to key and credential files (`id_rsa`, `*.pem`, `.ssh/`, `.npmrc`…), catastrophic commands (`rm -rf /`, `rm -rf ~`, formatting or overwriting a disk), and any change inside a folder your wiki marks `protected`. It also refuses the first write to `user-rules.md` or memory once. Re-issue the write and it goes through.
42
+ - **The guard plugin**, which blocks outright: writes to key and credential files (`id_rsa`, `*.pem`, `.ssh/`, `.npmrc`…), catastrophic commands (`rm -rf /`, `rm -rf ~`, formatting or overwriting a disk), a recursive scan of a whole filesystem (`find /`, `du -sh /`, `Get-ChildItem C:\ -Recurse`), and any change inside a folder your wiki marks `protected`. It also refuses the first write to `user-rules.md` or memory once. Re-issue the write and it goes through.
42
43
 
43
44
  The guard only warns on `.env` edits. It checks credentials by **file path**, not by content, so a key pasted into an ordinary source file is not caught here. One caveat about the process pack: if your `opencode.json` already has a `git commit*` entry, a rebuild without the pack leaves that entry in place and reports it. Geneseed cannot tell its own entry from one you typed.
44
45
 
@@ -63,4 +64,4 @@ The patterns that do exist are deliberately narrow. A gate that fired on every h
63
64
 
64
65
  ---
65
66
 
66
- **Where next:** [Install it](../guides/install.md) · [The rules in detail](../concepts/rules.md) · [Glossary](../reference/glossary.md)
67
+ **Where next:** [Install it](../guides/install.md) · [The rules in detail](../concepts/rules.md) · [Every rule, mapped to its host](../concepts/enforcement-table.md) · [Glossary](../reference/glossary.md)
@@ -8,7 +8,7 @@ description: "Where an install lands on IBM Bob or as a plain AGENT.md bundle, a
8
8
  ---
9
9
  Bob and the plain bundle carry the same rules, agents and skills as the other hosts. What differs is where they land and how much of it is enforced. The **Kind** words are explained in [What lands on your machine](on-your-machine.md).
10
10
 
11
- **Bob.** A global install writes to `~/.bob/`, or `BOB_CONFIG_DIR`. Bob does not load a global `AGENTS.md`, so the rules go into `~/.bob/rules/geneseed.md`. A per-repo install writes `AGENTS.md` instead. Hooks live in `~/.bob/settings/settings.json`. Bob runs three of them: context at session start, one combined gate before each tool call, and learn at session end. Bob has no "ask" prompt, so the gate refuses outright, and only for credentials and destructive git. The commit/push and memory questions become log lines.
11
+ **Bob.** A global install writes to `~/.bob/`, or `BOB_CONFIG_DIR`. Bob does not load a global `AGENTS.md`, so the rules go into `~/.bob/rules/geneseed.md`. A per-repo install writes `AGENTS.md` instead. Hooks live in `~/.bob/settings/settings.json`. Bob runs three of them: context at session start, one combined gate before each tool call, and learn at session end. Bob has no "ask" prompt, so the gate refuses outright, and only for the checks that admit no judgement call: credentials, destructive git, and a write under a project's own protected-checks list. The commit/push and memory questions become log lines.
12
12
 
13
13
  **Plain bundle.** The `files` target writes `AGENT.md` and the same folders into `./Harness/` for any tool that reads `AGENT.md`. It installs no hooks and no plugins, so nothing in it is enforced.
14
14
 
@@ -14,9 +14,9 @@ What an OpenCode install writes, piece by piece. The **Kind** words are explaine
14
14
  | `AGENT.md` | The rules, the pointers to agents and skills, and how memory works | asked | ~7.2k tokens every session | Shrink it with fewer doctrine packs (`--doctrines`); remove it with `geneseed uninstall` |
15
15
  | `user-rules.md` | Your own standing rules, read every session. Seeded empty | asked | 0 until you write in it | Leave it empty |
16
16
  | `PROFILE.md` | Who you are and how you like answers pitched. Fill it with the `profile` skill | asked | 0 until you fill it | Leave the seeded text untouched |
17
- | `opencode.json` entries | `instructions` points OpenCode at `AGENT.md`. `permission.bash` makes OpenCode ask before `rm -rf`, a force-push, `reset --hard`, `clean -f`, `branch -D` or `checkout --`, and before every commit and push when the **process** pack is on | enforced | No tokens | Edit the `permission` block yourself. The commit/push questions follow the **process** pack |
17
+ | `opencode.json` entries | `instructions` points OpenCode at `AGENT.md`. `permission.bash` makes OpenCode ask before `rm -rf`, a force-push, `reset --hard`, `clean -f`, `branch -D` or `checkout --`, and before every commit and push when the **process** pack is on. `permission.skill` denies each user-only skill, so the model never starts it but `/name` still runs it; Geneseed takes back only the denies it wrote | enforced | No tokens | Edit the `permission` block yourself. The commit/push questions follow the **process** pack |
18
18
  | context plugin | Puts your repo's README, CONTRIBUTING and a listing of `docs/` in front of the agent, with your rules, profile, memory index and wiki | automatic | Your docs, capped at 16 KB a file and 48 KB a session | `GENESEED_CONTEXT_INJECT=off` |
19
- | guard plugin | Blocks writes to key and credential files (`id_rsa`, `.pem`, `.ssh/`…), catastrophic commands (`rm -rf /`, formatting a disk), and changes inside your wiki's protected folders. Refuses the first write to `user-rules.md` or memory once, so the choice is yours | enforced | No tokens | `GENESEED_GUARD=off`, or `GENESEED_GUARD=warn` to log instead of block |
19
+ | guard plugin | Blocks writes to key and credential files (`id_rsa`, `.pem`, `.ssh/`…), catastrophic commands (`rm -rf /`, formatting a disk), a recursive scan of a whole filesystem (`find /`), and changes inside your wiki's protected folders. Refuses the first write to `user-rules.md` or memory once, so the choice is yours | enforced | No tokens | `GENESEED_GUARD=off`, or `GENESEED_GUARD=warn` to log instead of block |
20
20
  | learn plugin | Once a session has been quiet for 60 seconds, distils durable facts into `memory/` with the model the session already uses | automatic | One model call per quiet session | `geneseed exclude add <folder>` silences it in that folder |
21
21
  | activity plugin | Writes one small file per session that the web console's Activity view shows | automatic | No tokens | `GENESEED_ACTIVITY=off` |
22
22
  | notify plugin | A desktop notification when a turn took longer than 30 seconds | automatic | No tokens | `GENESEED_NOTIFY=off` |
@@ -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.19.0"
5
+ "version": "4.0.0"
6
6
  }
@@ -47,7 +47,7 @@ import { relPosix } from '../lib/text.mjs';
47
47
  // find a global install, and a resolver that decides WHERE a driver writes is the last thing
48
48
  // that should exist twice. golden.py's 259 cells are what made the move safe to attempt.
49
49
  import {
50
- GLOBAL_MANIFEST, resolvePath, opencodeConfigDir, claudeConfigDir, bobConfigDir,
50
+ GLOBAL_MANIFEST, HOSTS, resolvePath, opencodeConfigDir, claudeConfigDir, bobConfigDir,
51
51
  openclaudeConfigDir, hostCatalogsNatively,
52
52
  } from '../hosts/hosts.mjs';
53
53
 
@@ -103,8 +103,9 @@ export function resolveOut(raw) {
103
103
  * `_build_global._preamble_exclude` — the `claudeMdExcludes` entry a PROJECT install writes
104
104
  * to suppress the GLOBAL preamble of the same host, or null for a host that gets none.
105
105
  *
106
- * `_PREAMBLE_CONFIG_DIR` has exactly one key, so only a `CLAUDE.md` carrier resolves to a
107
- * value: Bob's `AGENTS.md` gets null.
106
+ * Only a `CLAUDE.md` carrier resolves to a value — Bob's `AGENTS.md` gets null. The function
107
+ * branches on `host` to pick between Claude's and OpenClaude's own global config dir, so "one
108
+ * key" no longer describes what resolves the directory; the carrier-name rule still holds.
108
109
  *
109
110
  * Computed HERE and not in the render child, and that is the inverted boundary rule doing
110
111
  * real work rather than being restated. P3b's note on the Python original: it resolves
@@ -226,6 +227,15 @@ function readManifest(manifestPath) {
226
227
  return { existed, doc };
227
228
  }
228
229
 
230
+ /**
231
+ * An OpenCode manifest's `skill_denies` — the `permission.skill` denies Geneseed itself added
232
+ * (see `opencodeLayer`) — or `[]` when absent or malformed, which sweeps nothing.
233
+ */
234
+ function recordedDenies(doc) {
235
+ const d = doc && doc.skill_denies;
236
+ return Array.isArray(d) ? d.filter((n) => typeof n === 'string') : [];
237
+ }
238
+
229
239
  /** The "+ primary agent, N command(s)" tail both OpenCode summary lines share. */
230
240
  function extrasTail(stats) {
231
241
  const extras = [...(stats.primary ? ['primary agent'] : []),
@@ -271,6 +281,7 @@ function emitOpencode(cfg, args, out) {
271
281
  // rather than deleting them. The prune set is then empty by construction.
272
282
  const { existed: manifestExisted, doc } = readManifest(manifestPath);
273
283
  const oldOwned = (doc && doc.owned) || [];
284
+ const oldDenies = recordedDenies(doc);
274
285
 
275
286
  const agentPathRel = relUnder(out, root);
276
287
  const agentPath = agentPathRel ? `${agentPathRel}/AGENT.md` : 'AGENT.md';
@@ -282,8 +293,9 @@ function emitOpencode(cfg, args, out) {
282
293
  {
283
294
  theme: args.theme, out, root, footprint: args.footprint,
284
295
  nativeCatalog: hostCatalogsNatively('opencode'), oldOwned, manifestExisted, agentPath,
296
+ oldDenies,
285
297
  });
286
- const { owned, stats, cfgName } = rendered;
298
+ const { owned, stats, cfgName, skillDenies } = rendered;
287
299
 
288
300
  phaseLog('PRUNE');
289
301
  pruneOwned(oc, oldOwned, owned);
@@ -295,6 +307,7 @@ function emitOpencode(cfg, args, out) {
295
307
  + 'list is yours and is never touched.',
296
308
  owned: [...owned].sort(),
297
309
  scope: 'project',
310
+ ...(skillDenies.length ? { skill_denies: skillDenies } : {}),
298
311
  });
299
312
 
300
313
  const extra = extrasTail(stats);
@@ -318,7 +331,8 @@ function emitOpencode(cfg, args, out) {
318
331
  * sends a path relative to the project root;
319
332
  * - the manifest carries no `scope` and no `managed` claim set, because the one file this
320
333
  * emit wires is never unwired by any teardown — so there is nothing to record and no
321
- * VERIFY stage to re-read it.
334
+ * VERIFY stage to re-read it. It does carry `skill_denies`, as the per-repo one does: the
335
+ * `permission.skill` denies an uninstall takes back.
322
336
  */
323
337
  function emitOpencodeGlobal(cfg, args, out) {
324
338
  const cfgDir = args.cfgDir ?? opencodeConfigDir();
@@ -326,6 +340,7 @@ function emitOpencodeGlobal(cfg, args, out) {
326
340
 
327
341
  const { doc } = readManifest(manifestPath);
328
342
  const oldOwned = (doc && doc.owned) || [];
343
+ const oldDenies = recordedDenies(doc);
329
344
 
330
345
  const agentPath = path.join(cfgDir, 'AGENT.md').split(path.sep).join('/');
331
346
 
@@ -334,9 +349,9 @@ function emitOpencodeGlobal(cfg, args, out) {
334
349
  {
335
350
  theme: args.theme, cfgDir, out,
336
351
  footprint: args.footprint, nativeCatalog: hostCatalogsNatively('opencode'),
337
- oldOwned, agentPath,
352
+ oldOwned, agentPath, oldDenies,
338
353
  });
339
- const { owned, stats, memStatus, nbStatus, cfgName } = rendered;
354
+ const { owned, stats, memStatus, nbStatus, cfgName, skillDenies } = rendered;
340
355
 
341
356
  phaseLog('PRUNE');
342
357
  pruneOwned(cfgDir, oldOwned, owned);
@@ -347,6 +362,7 @@ function emitOpencodeGlobal(cfg, args, out) {
347
362
  + 'Do not edit; removed on re-emit. The memory and notebook '
348
363
  + 'stores are NOT listed — they are never deleted.',
349
364
  owned: [...owned].sort(),
365
+ ...(skillDenies.length ? { skill_denies: skillDenies } : {}),
350
366
  });
351
367
 
352
368
  const extra = extrasTail(stats);
@@ -377,9 +393,10 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
377
393
  && !Array.isArray(doc.managed)) ? doc.managed : {};
378
394
 
379
395
  // RENDER + WIRE in one child's worth of work. `preambleExclude` is null for every
380
- // carrier but `CLAUDE.md` — `_PREAMBLE_CONFIG_DIR` has exactly one key — so Bob's
381
- // `AGENTS.md` does not resolve to one. Spelling it as a call rather than a literal is what makes that a
382
- // measured `null` instead of an assumed one.
396
+ // carrier but `CLAUDE.md`, so Bob's `AGENTS.md` does not resolve to one; for a `CLAUDE.md`
397
+ // carrier it branches on `host` to pick Claude's or OpenClaude's own global config dir (see
398
+ // `preambleExclude`'s own docblock). Spelling it as a call rather than a literal is what
399
+ // makes that a measured `null` instead of an assumed one.
383
400
  const rendered = emitClaudeRender(cfg, {
384
401
  theme: args.theme, cfgDir, claudeMd, scope, host,
385
402
  out,
@@ -394,9 +411,10 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
394
411
 
395
412
  phaseLog('MANIFEST');
396
413
  writeManifestAtomic(manifestPath, {
397
- _comment: "Files owned by Geneseed's Claude emit. Do not edit; removed on "
414
+ _comment: "Files owned by Geneseed's Claude/Bob/OpenClaude emit. Do not edit; removed on "
398
415
  + 're-emit. The memory and notebook stores are NOT listed — never '
399
- + 'deleted. `managed` records the CLAUDE.md block + settings.json '
416
+ + 'deleted. `managed` records the CLAUDE.md/AGENTS.md block + settings file '
417
+ + '(settings.json, or settings.local.json at PROJECT scope on Claude/OpenClaude) '
400
418
  + 'hooks so uninstall removes exactly those.',
401
419
  owned: [...owned].sort(),
402
420
  managed,
@@ -474,8 +492,14 @@ const CLAUDE_SHAPED = {
474
492
  // Everything under `.openclaude/`, the preamble included. OpenClaude reads the root CLAUDE.md
475
493
  // only when the repo has no AGENTS.md, but `.openclaude/CLAUDE.md` always; keeping the root
476
494
  // untouched also lets a Claude Code install share the repo.
495
+ //
496
+ // `carrierInLayer` reads off `HOSTS`' own column of the same name (`js/hosts/hosts.mjs`)
497
+ // rather than restating the literal here — `installs.mjs`'s `carriersFor` reads the SAME
498
+ // column to know where the deployed carrier is, and two booleans naming one fact is exactly
499
+ // the kind of drift this repo's docblocks keep warning about.
477
500
  openclaude: {
478
- host: 'openclaude', layer: '.openclaude', carrier: 'CLAUDE.md', carrierInLayer: true,
501
+ host: 'openclaude', layer: '.openclaude', carrier: 'CLAUDE.md',
502
+ carrierInLayer: HOSTS.find((h) => h.host === 'openclaude').carrierInLayer,
479
503
  summary: (at, r) => `[geneseed] openclaude (folder) -> ${at}: .openclaude/ `
480
504
  + `(CLAUDE.md, ${r.nAgents} subagents, ${r.nSkills} skills, ${r.nHooks} hook group(s), `
481
505
  + `settings.local.json), ${r.memStatus}, ${r.nbStatus}.\n`,
@@ -746,9 +770,11 @@ function run(argv) {
746
770
  writeMarkers(markerDir, args.emit, args.footprint);
747
771
  // build() drops a .geneseed-theme in `out` for the emits that call it; the global emits
748
772
  // render into the config dir WITHOUT calling build(), so the theme is recorded here.
749
- // Deliberately not written for the claude/bob/openclaude PROJECT emits — they carry none,
750
- // and `_harness_setup._installed_defaults` detects those by an AGENT.md sigil scan
751
- // instead. Writing one for them would change the emitted tree.
773
+ // Deliberately not written for the claude/bob/openclaude PROJECT emits — they carry none.
774
+ // Claude and Bob fall back to a sigil scan of their ROOT carrier instead
775
+ // (`_harness_setup._installed_defaults`); OpenClaude has no root carrier to scan, so its
776
+ // project theme is read off `.openclaude/CLAUDE.md` (host-narrowed `firstCarrier`, host-compat
777
+ // B1). Writing one for them would change the emitted tree.
752
778
  if (args.emit.endsWith('-global')) {
753
779
  try {
754
780
  writeText(path.join(markerDir, '.geneseed-theme'), `${args.theme}\n`);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The Claude and Bob emits — one renderer, and the settings merge that wires it in.
2
+ * The Claude, Bob and OpenClaude emits — one renderer, and the settings merge that wires it in.
3
3
  *
4
4
  * Split out of `emit.mjs` beside `emit-opencode.mjs`, over the shared writers in
5
5
  * `emit-common.mjs`. `claudeWire` is the half that touches a file the USER also edits, and
@@ -7,8 +7,8 @@
7
7
  */
8
8
  import path from 'node:path';
9
9
  import { VERSION_MARKER } from '../hosts/hosts.mjs';
10
- import { loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
11
- import { ensureAgentOverridesStub } from '../hosts/opencode.mjs';
10
+ import { claimer, loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
11
+ import { ensureAgentOverridesStub, writeAliasCommands } from '../hosts/opencode.mjs';
12
12
  import {
13
13
  managedBlockRemove, managedBlockWrite, mergeClaudeSettings,
14
14
  unwireClaudeExcludes, unwireClaudeSettings, wireClaudeExcludes,
@@ -116,12 +116,27 @@ export function emitClaudeRender(cfg, job) {
116
116
  // Bob's and OpenClaude's agents/skills use the Claude dialect verbatim.
117
117
  // `manifestExisted` is deliberately not passed: the Python does not pass it either, so
118
118
  // the pre-manifest header line is unreachable from this emit on both sides.
119
+ // One shared `claim` (see `claimer`): the skills/agents writer and the Bob command writer
120
+ // below both push into the same `owned` list, so a pre-manifest-install warning still
121
+ // prints once across the whole emit rather than once per writer.
122
+ const claim = claimer(oldOwned, cfgDir, true);
119
123
  const { nAgents, nSkills, written } = writeNativeLayer(
120
124
  items, path.join(cfgDir, 'agents'), path.join(cfgDir, 'skills'),
121
125
  loadAgentOverrides(cfgDir),
122
- { host: 'claude', oldOwned, cfg: cfgDir, src: cfg.src, skillDirOf });
126
+ { host: 'claude', bobDialect: isBob, oldOwned, cfg: cfgDir, src: cfg.src, skillDirOf, claim });
123
127
  for (const p of written) owned.push(relPosix(cfgDir, p));
124
128
 
129
+ // Bob host-compat O2/R1: an alias, or a genuinely user-only skill (unverified
130
+ // `disable-model-invocation` support — see `bobDialect` above), gets `/<name>` through
131
+ // `.bob/commands/<name>.md` instead of a model-visible SKILL.md. The command layer
132
+ // OpenCode built for its own alias problem is exactly the right shape, reused as-is.
133
+ if (isBob) {
134
+ const commandDir = path.join(cfgDir, 'commands');
135
+ for (const p of writeAliasCommands(cfg, items, commandDir, claim, skillDirOf, true)) {
136
+ owned.push(relPosix(cfgDir, p));
137
+ }
138
+ }
139
+
125
140
  const memStatus = globalMemory(cfgDir, items, out, cfg.src);
126
141
  ensureMemoryIndex(path.join(cfgDir, 'memory'));
127
142
  const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
@@ -236,8 +251,8 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRu
236
251
  managedBlockRemove(victim);
237
252
  }
238
253
 
239
- // Hooks embed machine-absolute paths. At PROJECT scope for Claude they go into
240
- // settings.local.json — the personal, untracked file — never the team-shared
254
+ // Hooks embed machine-absolute paths. At PROJECT scope for Claude and OpenClaude they go
255
+ // into settings.local.json — the personal, untracked file — never the team-shared
241
256
  // settings.json, which would hand every teammate failing hooks pointing at this machine's
242
257
  // node and this machine's checkout. (Bob documents no local variant, so it keeps
243
258
  // settings.json.)
@@ -269,8 +284,9 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRu
269
284
  );
270
285
  managed.settings_hooks = managedHooks;
271
286
 
272
- // Project-bypasses-global (Claude only): a PROJECT install suppresses the GLOBAL
273
- // ~/.claude/CLAUDE.md while cwd is this repo, via Claude's native claudeMdExcludes.
287
+ // Project-bypasses-global (Claude and OpenClaude): a PROJECT install suppresses the GLOBAL
288
+ // ~/.claude/CLAUDE.md (or ~/.openclaude/CLAUDE.md) while cwd is this repo, via the shared
289
+ // claudeMdExcludes dialect.
274
290
  // Written only when this run actually emitted the project's own preamble (never
275
291
  // suppress with no replacement); GENESEED_STACK_GLOBAL=1 opts out, and a re-emit with
276
292
  // it set strips a prior exclude. Bob never gets one: its bypass is the same-named
@@ -13,7 +13,7 @@ import {
13
13
  copyPlugins, copyWorkflows, ensureAgentOverridesStub, writeColorThemes, writeCommandLayer,
14
14
  writeAliasCommands, writePonytailCommand, writePrimaryAgent, writeTheme,
15
15
  } from '../hosts/opencode.mjs';
16
- import { mergeOpencodeJson } from '../hosts/settings.mjs';
16
+ import { mergeOpencodeJson, opencodeSentinelWrite } from '../hosts/settings.mjs';
17
17
  import { isFile, readText, writeText } from '../lib/fs.mjs';
18
18
  import { relPosix } from '../lib/text.mjs';
19
19
  import { assertSourceComplete, build, phaseLog } from './bundle.mjs';
@@ -53,14 +53,14 @@ import { mkdirSync } from 'node:fs';
53
53
  */
54
54
  function opencodeLayer(cfg, items, themeName, theme, dir, owned, opts) {
55
55
  const {
56
- oldOwned, manifestExisted, agentPath, wireBase, overridesDir,
56
+ oldOwned, manifestExisted, agentPath, wireBase, overridesDir, oldDenies = [],
57
57
  } = opts;
58
58
  ensureAgentOverridesStub(cfg, overridesDir);
59
59
  const overrides = loadAgentOverrides(overridesDir);
60
60
  // One claim for every writer below whose path lands in `owned` — see `claimer`.
61
61
  const claim = claimer(oldOwned, dir, manifestExisted);
62
62
 
63
- const { nAgents, nSkills, written } = writeNativeLayer(
63
+ const { nAgents, nSkills, written, userOnlySkills } = writeNativeLayer(
64
64
  items, path.join(dir, 'agents'), path.join(dir, 'skills'), overrides,
65
65
  { host: 'opencode', theme, src: cfg.src, claim });
66
66
  for (const p of written) owned.push(relPosix(dir, p));
@@ -80,19 +80,32 @@ function opencodeLayer(cfg, items, themeName, theme, dir, owned, opts) {
80
80
  // Owned but not counted in `nCommands`, as an alias skill is not counted in `nSkills`.
81
81
  for (const p of writeAliasCommands(cfg, items, path.join(dir, 'command'), claim, skillDirOf)) owned.push(relPosix(dir, p));
82
82
 
83
- owned.push(relPosix(dir, writeTheme(path.join(dir, 'themes'), themeName, theme)));
84
- for (const p of writeColorThemes(cfg, path.join(dir, 'themes'))) owned.push(relPosix(dir, p));
83
+ const themeDest = writeTheme(path.join(dir, 'themes'), themeName, theme, claim);
84
+ if (themeDest) owned.push(relPosix(dir, themeDest));
85
+ for (const p of writeColorThemes(cfg, path.join(dir, 'themes'), claim)) owned.push(relPosix(dir, p));
85
86
 
86
- const nPlugins = copyPlugins(cfg, path.join(dir, 'plugins'), owned);
87
- const nWorkflows = copyWorkflows(cfg, path.join(dir, 'workflows'), owned);
87
+ const nPlugins = copyPlugins(cfg, path.join(dir, 'plugins'), owned, claim);
88
+ const nWorkflows = copyWorkflows(cfg, path.join(dir, 'workflows'), owned, claim);
88
89
 
89
- // WIRE — the one file of this layer the user co-owns.
90
+ // WIRE — the one file of this layer the user co-owns. `oldDenies` is the previous manifest's
91
+ // `skill_denies`: the `permission.skill` denies Geneseed itself ADDED (final review — owning
92
+ // `skills/<name>/SKILL.md` is not owning a deny the user put on that name). `staleSkillNames`
93
+ // is the part of it this build no longer wants denied — the skill lost its user-only marker,
94
+ // or was `--exclude-skills`'d. No record (a first emit, or an older manifest) sweeps nothing.
90
95
  phaseLog('WIRE');
96
+ const staleSkillNames = oldDenies.filter((n) => !userOnlySkills.includes(n));
97
+ const wired = {};
91
98
  const cfgName = path.basename(mergeOpencodeJson(path.join(wireBase, 'opencode.json'),
92
- agentPath, cfg.doctrines, cfg.excludeRules));
99
+ agentPath, cfg.doctrines, cfg.excludeRules, userOnlySkills, staleSkillNames, wired));
100
+ // The next record: Geneseed's earlier denies still wanted, plus the ones it added now. A merge
101
+ // that wrote nothing (`skillsAdded` unset) keeps the old record whole.
102
+ const skillDenies = wired.skillsAdded
103
+ ? [...new Set([...oldDenies.filter((n) => userOnlySkills.includes(n)), ...wired.skillsAdded])].sort()
104
+ : oldDenies;
93
105
 
94
106
  return {
95
107
  nAgents, nSkills, nPlugins, nWorkflows, primary, nCommands: commands.length, cfgName,
108
+ skillDenies,
96
109
  };
97
110
  }
98
111
 
@@ -116,6 +129,7 @@ export function emitOpencodeRender(cfg, job) {
116
129
  phaseLog('RENDER');
117
130
  const {
118
131
  theme: _theme, out, root, footprint, nativeCatalog, oldOwned, manifestExisted, agentPath,
132
+ oldDenies,
119
133
  } = job;
120
134
  const oc = path.join(root, '.opencode');
121
135
 
@@ -127,12 +141,12 @@ export function emitOpencodeRender(cfg, job) {
127
141
  if (isFile(agentMd)) writeText(agentMd, stripCapabilityLinks(readText(agentMd)));
128
142
 
129
143
  const owned = [];
130
- const { theme, items } = renderAll(cfg, _theme);
144
+ const { theme, items } = renderAll(cfg, _theme, { footprint, nativeCatalog });
131
145
 
132
146
  const {
133
- nAgents, nSkills, nPlugins, nWorkflows, primary, nCommands, cfgName,
147
+ nAgents, nSkills, nPlugins, nWorkflows, primary, nCommands, cfgName, skillDenies,
134
148
  } = opencodeLayer(cfg, items, _theme, theme, oc, owned, {
135
- oldOwned, manifestExisted, agentPath, wireBase: root, overridesDir: out,
149
+ oldOwned, manifestExisted, agentPath, wireBase: root, overridesDir: out, oldDenies,
136
150
  });
137
151
 
138
152
  return {
@@ -141,6 +155,7 @@ export function emitOpencodeRender(cfg, job) {
141
155
  nAgents, nSkills, nPlugins, nWorkflows, nCommands, primary: !!primary,
142
156
  },
143
157
  cfgName,
158
+ skillDenies,
144
159
  };
145
160
  }
146
161
 
@@ -179,7 +194,7 @@ export function emitOpencodeRender(cfg, job) {
179
194
  export function emitOpencodeGlobalRender(cfg, job) {
180
195
  phaseLog('RENDER');
181
196
  const {
182
- theme: themeName, cfgDir, out, footprint, nativeCatalog, oldOwned, agentPath,
197
+ theme: themeName, cfgDir, out, footprint, nativeCatalog, oldOwned, agentPath, oldDenies,
183
198
  } = job;
184
199
 
185
200
  // No `lawsPrefix`: the standalone laws dir sits beside AGENT.md in <cfg>, so the lean
@@ -198,13 +213,15 @@ export function emitOpencodeGlobalRender(cfg, job) {
198
213
  writeText(path.join(cfgDir, 'AGENT.md'), stripCapabilityLinks(agentText));
199
214
  owned.push('AGENT.md');
200
215
  }
216
+ // Co-owned, so never in `owned` — see `opencodeSentinelWrite`.
217
+ opencodeSentinelWrite(cfgDir);
201
218
 
202
219
  // `manifestExisted` is deliberately not passed — the Python does not pass it either, so
203
220
  // the pre-manifest header line is unreachable from this emit on both sides.
204
221
  const {
205
- nAgents, nSkills, nPlugins, nWorkflows, primary, nCommands, cfgName,
222
+ nAgents, nSkills, nPlugins, nWorkflows, primary, nCommands, cfgName, skillDenies,
206
223
  } = opencodeLayer(cfg, items, themeName, theme, cfgDir, owned, {
207
- oldOwned, agentPath, wireBase: cfgDir, overridesDir: cfgDir,
224
+ oldOwned, agentPath, wireBase: cfgDir, overridesDir: cfgDir, oldDenies,
208
225
  });
209
226
 
210
227
  const memStatus = globalMemory(cfgDir, items, out, cfg.src);
@@ -229,5 +246,6 @@ export function emitOpencodeGlobalRender(cfg, job) {
229
246
  memStatus,
230
247
  nbStatus,
231
248
  cfgName,
249
+ skillDenies,
232
250
  };
233
251
  }
@@ -40,8 +40,8 @@ import { DEFAULT_PRESET } from '../loop/score.mjs';
40
40
  import { opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
41
41
  import {
42
42
  EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
43
- excludedRulesOfDir, excludedSkillsOfDir, footprintOfDir, installState, installTargets, modeOfDir, postureOfDir,
44
- readMaybe, themeOfDir, trustOfDir,
43
+ excludedRulesOfDir, excludedSkillsOfDir, footprintOfDir, installState, installTargets,
44
+ legacyProcessCarrierOfDir, modeOfDir, postureOfDir, readMaybe, themeOfDir, trustOfDir,
45
45
  } from '../hosts/installs.mjs';
46
46
  import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from '../hosts/opencode.mjs';
47
47
  import { renderAll } from './render.mjs';
@@ -201,10 +201,13 @@ export function installProfile(host, scope, root, notify = null) {
201
201
  let marker = raw === null ? '' : raw.trim();
202
202
  if (marker && (EMIT_HOST_SCOPE.get(marker) ?? ['', ''])[0] !== host) marker = '';
203
203
  const emit = marker || DEFAULT_EMIT.get(`${host} ${scope}`) || 'opencode-global';
204
- const theme = themeOfDir(root) || defaultTheme();
204
+ // Every read-back below is narrowed to `host`'s OWN carrier (host-compat B1): in a repo
205
+ // shared with another Claude-style host, an un-narrowed scan answers with WHICHEVER host's
206
+ // carrier the shared `CARRIERS` order favours — the wrong carrier for every host but one.
207
+ const theme = themeOfDir(root, host) || defaultTheme();
205
208
  const footprint = footprintOfDir(root);
206
- const posture = postureOfDir(root) || defaultPosture();
207
- const mode = modeOfDir(root) || defaultMode();
209
+ const posture = postureOfDir(root, host) || defaultPosture();
210
+ const mode = modeOfDir(root, host) || defaultMode();
208
211
  // Read off the deployed loop skill; no config fallback — `DEFAULT_PRESET` is the engine's.
209
212
  const trust = trustOfDir(root, host) || DEFAULT_PRESET;
210
213
  // A rebuild that defaulted the pack selection would re-emit an install into a constitution
@@ -214,13 +217,13 @@ export function installProfile(host, scope, root, notify = null) {
214
217
  // `harness.config.json`: a pre-migration carrier has no marker, and resolving that silence
215
218
  // out of a machine-wide config narrows every such install on its first upgrade.
216
219
  // `doctrinesForBuild` resolves unknown to ALL packs — see its docblock.
217
- const doctrines = doctrinesForBuild(root);
220
+ const doctrines = doctrinesForBuild(root, host);
218
221
  // The per-rule axis is preserved for the same reason and with sharper teeth: an upgrade that
219
222
  // dropped it would quietly hand back every rule its owner switched off, and `process 5` is
220
223
  // one of them. `null` (no marker) stays `null` so the argv omits the flag, as it always did.
221
- const excludeRules = excludedRulesOfDir(root);
224
+ const excludeRules = excludedRulesOfDir(root, host);
222
225
  // Skills left out stay left out: an upgrade that forgot them would put them all back.
223
- const excludeSkills = excludedSkillsOfDir(root, notify);
226
+ const excludeSkills = excludedSkillsOfDir(root, notify, host);
224
227
  const out = scope === 'global' ? null : root;
225
228
  const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
226
229
  PACK_ORDER, excludeRules, trust, excludeSkills);
@@ -263,6 +266,15 @@ export function rebuildCommand(argv) {
263
266
  * defaulted any of them would silently re-emit an install into something its owner did not
264
267
  * choose — which is the same failure mode `_footprint_of_dir`'s WARN exists to prevent, at a
265
268
  * larger scale.
269
+ *
270
+ * A LEGACY CARRIER IS SKIPPED, NEVER RE-EMITTED. `installProfile` (through `doctrinesOfDir`/
271
+ * `excludedRulesOfDir`) no longer migrates a pre-comms carrier — it reads it as written. Calling
272
+ * `driverMain` on that read-back would WRITE a fresh carrier missing `comms` and still naming
273
+ * `process 7`/`process 8` nowhere, which deletes the one signal (`legacyProcessCarrierOfDir`)
274
+ * that could ever have caught it again: a permanent, silent loss, and `upgrade` reaches this
275
+ * same loop by shelling into `rebuild-all` (`js/maintain/update.mjs`'s `rebuildInstalls`), so
276
+ * fixing it once here covers both verbs. Checked BEFORE `installProfile`, so no argv is even
277
+ * built for it.
266
278
  */
267
279
  export async function cmdRebuildAll() {
268
280
  const rc = rebuildAll();
@@ -282,8 +294,16 @@ export function rebuildAll() {
282
294
  return 0;
283
295
  }
284
296
  const failures = [];
297
+ const skipped = [];
285
298
  for (const [host, scope, root] of targets) {
286
299
  const label = `${host}:${scope} (${root})`;
300
+ // Ask BEFORE `installProfile`: a legacy carrier must never reach `driverMain`, see above.
301
+ if (legacyProcessCarrierOfDir(root, host)) {
302
+ skipped.push(label);
303
+ printOut(`[rebuild-all] SKIPPED ${label}: pre-comms carrier (names 'process 8') — `
304
+ + 'run `geneseed setup` for it.\n');
305
+ continue;
306
+ }
287
307
  // The one replay that speaks when an excluded skill's name got older: status and the console
288
308
  // read the same profile on every refresh and stay silent.
289
309
  const p = installProfile(host, scope, root, (n) => printErr(`[rebuild-all] ${label}: ${n}\n`));
@@ -300,7 +320,16 @@ export function rebuildAll() {
300
320
  + `${failures.join(', ')}\n`);
301
321
  return 1;
302
322
  }
303
- printOut(`[rebuild-all] rebuilt ${targets.length} install(s).\n`);
323
+ const rebuilt = targets.length - skipped.length;
324
+ // Skipping is protective, not a failure — same convention as the install count itself
325
+ // already filtering to `active` targets without flagging the inactive ones. Exit 0 as long
326
+ // as nothing that WAS attempted failed; the per-install SKIPPED line above is the record.
327
+ if (skipped.length) {
328
+ printOut(`[rebuild-all] rebuilt ${rebuilt} install(s), skipped ${skipped.length}: `
329
+ + `${skipped.join(', ')}.\n`);
330
+ } else {
331
+ printOut(`[rebuild-all] rebuilt ${rebuilt} install(s).\n`);
332
+ }
304
333
  return 0;
305
334
  }
306
335
 
@@ -27,6 +27,7 @@
27
27
  import { readdirSync, readFileSync, statSync } from 'node:fs';
28
28
  import path from 'node:path';
29
29
  import { DEFAULT_PRESET } from '../loop/score.mjs';
30
+ import { blankCodeSpans } from '../lib/text.mjs';
30
31
 
31
32
  /**
32
33
  * One doctrine pack file used to be read three times per render — the survives gate and the
@@ -136,12 +137,50 @@ export function ruleCanon(src = SRC) {
136
137
  return canon;
137
138
  }
138
139
 
140
+ /**
141
+ * AUTHOR NOTES — `<!-- note: ... -->`, single or multi-line, is maintainer-only prose and must
142
+ * never reach an agent (user decision 2026-10-11). Stripped here because `resolveRuleIds` is the
143
+ * one function every text file threads through before a render sees it: `readSource` in
144
+ * `js/build/render.mjs` for `renderFile` (every file in `src/`, including the on-disk `laws/`,
145
+ * `ontology/` and `doctrines/` copies and everything an INCLUDE pulls in), `registerBody` there
146
+ * for a posture/mode, `readPackText` here for a doctrine pack body, and `parseBrick`
147
+ * (`js/loop/catalog.mjs`) for a brick body, which never goes through `renderFile` at all — the
148
+ * loop CLI hands it to the model raw, so it calls `stripAuthorNotes` itself. One function, every
149
+ * reader. `js/inspect/checks-authoring.mjs`'s `authorNoteProblems` is the companion gate: it
150
+ * scans `src/` directly for a comment that is neither this prefix nor a known functional marker,
151
+ * so a new author note without the prefix is caught instead of silently shipping.
152
+ *
153
+ * MATCHED AGAINST THE BLANKED TEXT, NOT THE RAW ONE. `blankCodeSpans` (`js/lib/text.mjs`, shared
154
+ * with the doctor arm so the two can never disagree about what counts as "shown as an example")
155
+ * replaces every fenced block and inline code span with same-length whitespace, so (a) a
156
+ * `note:` comment that is only a literal example — a skill teaching the syntax — never matches,
157
+ * and (b) a real comment whose prose quotes a literal marker inside backticks (the
158
+ * `src/skills/_template.md` landmine: `` `<!-- invocation: user -->` `` closing the OUTER note
159
+ * at its own inner `-->`) is blanked there too, so the lazy `[\s\S]*?` correctly runs to the
160
+ * REAL closing `-->` instead of stopping at the quoted one. Blanking preserves every index and
161
+ * every newline, so a match found in the blanked copy is removed from the SAME range of the
162
+ * original — never from the blanked copy itself, which would turn the code span's blanks into
163
+ * removed source.
164
+ */
165
+ const AUTHOR_NOTE_RE = /[ \t]*<!--[ \t]*note:[\s\S]*?-->[ \t]*\r?\n?/g;
166
+ export function stripAuthorNotes(text) {
167
+ const masked = blankCodeSpans(text);
168
+ const ranges = [...masked.matchAll(AUTHOR_NOTE_RE)]
169
+ .map((m) => [m.index, m.index + m[0].length]);
170
+ if (!ranges.length) return text;
171
+ let out = '';
172
+ let last = 0;
173
+ for (const [start, end] of ranges) { out += text.slice(last, start); last = end; }
174
+ return out + text.slice(last);
175
+ }
176
+
139
177
  /**
140
178
  * Resolve every declaration and citation in `text` against the canon under `src`. An unknown id
141
179
  * is left exactly as written — visible in the output, and named by the doctor — rather than
142
180
  * guessed at.
143
181
  */
144
182
  export function resolveRuleIds(text, src = SRC) {
183
+ text = stripAuthorNotes(text);
145
184
  if (!text.includes('{{LAW:') && !text.includes('{{DOCTRINE:')) return text;
146
185
  const { byId } = ruleCanon(src);
147
186
  const known = (kind, id) => { const r = byId.get(id); return r && r.kind === kind ? r : null; };
@@ -223,7 +223,8 @@ export function ensureContextStub(out) {
223
223
 
224
224
  /** The wiki manifest's name. `geneseed-` says who owns it in a shared `.claude/`/`.opencode/`. */
225
225
  export const WIKI_FILE = 'geneseed-wiki.jsonc';
226
- /** Its earlier names, newest first. Readers still fall back to them for one release. */
226
+ /** Its earlier names, newest first. Readers still fall back to them, with no end date:
227
+ * nothing yet tells a user an install missed the rename, so dropping the fallback would lose a wiki in silence. */
227
228
  const LEGACY_WIKI_FILES = ['wiki.jsonc', 'wiki.json'];
228
229
 
229
230
  /**