geneseed 3.9.0 → 3.10.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 (76) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/QUICKSTART.md +1 -1
  3. package/README.md +27 -28
  4. package/SETUP.md +7 -42
  5. package/SHIPPED.md +2 -2
  6. package/adapters/bob/README.md +17 -1
  7. package/adapters/openclaude/README.md +1 -1
  8. package/adapters/opencode/plugins/geneseed-context.js +78 -8
  9. package/bin/geneseed-hook.mjs +1 -1
  10. package/docs/candidate-hosts.md +12 -5
  11. package/docs/token-footprint.md +26 -21
  12. package/docs/web/claude-hooks.md +1 -1
  13. package/docs/web/collaboration.md +1 -1
  14. package/docs/web/exclusions.md +0 -1
  15. package/docs/web/footprint.md +1 -1
  16. package/docs/web/harnesses.md +13 -13
  17. package/docs/web/install-quick.md +1 -1
  18. package/docs/web-ui.md +1 -1
  19. package/harness.config.json +1 -1
  20. package/js/README.md +4 -4
  21. package/js/build/driver.mjs +26 -95
  22. package/js/build/emit-claude.mjs +76 -81
  23. package/js/build/generate.mjs +0 -1
  24. package/js/build/render.mjs +4 -0
  25. package/js/build/stubs.mjs +18 -2
  26. package/js/cli-table.json +5 -5
  27. package/js/hosts/hooks.mjs +86 -54
  28. package/js/hosts/hosts.mjs +5 -21
  29. package/js/hosts/installs.mjs +4 -6
  30. package/js/hosts/mcp.mjs +3 -13
  31. package/js/hosts/native.mjs +27 -50
  32. package/js/hosts/opencode.mjs +1 -1
  33. package/js/hosts/settings.mjs +4 -78
  34. package/js/inspect/checks-authoring.mjs +4 -1
  35. package/js/inspect/checks-repo.mjs +45 -1
  36. package/js/inspect/diff.mjs +11 -3
  37. package/js/inspect/doctor.mjs +3 -3
  38. package/js/inspect/excludes.mjs +0 -4
  39. package/js/inspect/status.mjs +4 -4
  40. package/js/maintain/migrate.mjs +5 -5
  41. package/js/maintain/setup.mjs +3 -5
  42. package/js/maintain/uninstall.mjs +8 -42
  43. package/js/web/actions.mjs +4 -5
  44. package/js/web/api.mjs +4 -4
  45. package/package.json +1 -2
  46. package/src/AGENT.md.tmpl +69 -52
  47. package/src/laws/universal.md +3 -0
  48. package/src/ontology/universal.md +1 -1
  49. package/src/skills/daydream/SKILL.md +10 -3
  50. package/src/skills/daydream/VENDOR.md +10 -2
  51. package/src/skills/daydream/instructions.md +2 -2
  52. package/src/skills/explain-changes/SKILL.md +1 -1
  53. package/src/skills/explain-changes/VENDOR.md +1 -1
  54. package/src/skills/geneseed.md +1 -1
  55. package/src/skills/react-view-transitions/SKILL.md +1 -1
  56. package/src/skills/react-view-transitions/VENDOR.md +7 -2
  57. package/src/skills/token-report/SKILL.md +2 -3
  58. package/src/skills/token-report/VENDOR.md +2 -3
  59. package/src/skills/token-report/scripts/token_report.mjs +7 -121
  60. package/src/skills/workflow.md +1 -1
  61. package/web/dist/assets/{Activity-DAzV-qQj.js → Activity-BjVp522q.js} +1 -1
  62. package/web/dist/assets/{ActivityDetail-CwEwMtKl.js → ActivityDetail-8ep1bspW.js} +1 -1
  63. package/web/dist/assets/{Dashboard-C2ELBW-X.js → Dashboard-OkC_PCH6.js} +2 -2
  64. package/web/dist/assets/{Docs-iZrvQP8J.js → Docs-CKPWz00o.js} +1 -1
  65. package/web/dist/assets/{GreenhouseView-Bq1QmaGO.js → GreenhouseView-D0cbCJ5m.js} +1 -1
  66. package/web/dist/assets/{Installs-BiqR8xdO.js → Installs-BtGGdoTt.js} +1 -1
  67. package/web/dist/assets/{JournalView-6wilG5Zd.js → JournalView-Ckl4dmzR.js} +1 -1
  68. package/web/dist/assets/{Laws-D8YXD_XE.js → Laws-CLpdRRk8.js} +1 -1
  69. package/web/dist/assets/{Library-DwuTZ2-L.js → Library-wPPuKQrI.js} +1 -1
  70. package/web/dist/assets/{OperatorHudView-B-HVBqfY.js → OperatorHudView-CiAkAkQL.js} +1 -1
  71. package/web/dist/assets/{Personal-BbUTmZQR.js → Personal-D8CnDo9v.js} +1 -1
  72. package/web/dist/assets/{Tabs-_pjUFIEI.js → Tabs-B4HNrrCs.js} +1 -1
  73. package/web/dist/assets/{drawable-CoIulEH2.js → drawable-BiwaOo6x.js} +1 -1
  74. package/web/dist/assets/{index-BMwSp8BP.js → index-C1yil5j1.js} +2 -2
  75. package/web/dist/index.html +1 -1
  76. package/adapters/copilot/README.md +0 -105
@@ -4,7 +4,7 @@ order: 11
4
4
  title: "The collaboration layer"
5
5
  kind: "concept"
6
6
  ---
7
- Beside the [constitution](#/docs/rules), a handful of mechanisms shape *how* the agent works with you — the two registers, the mutual contract, how memory binds, and who you are. All of them are plain content in `AGENT.md` and its neighbours, so they ride to every host (OpenCode, Claude, Bob, Copilot) unchanged.
7
+ Beside the [constitution](#/docs/rules), a handful of mechanisms shape *how* the agent works with you — the two registers, the mutual contract, how memory binds, and who you are. All of them are plain content in `AGENT.md` and its neighbours, so they ride to every host (OpenCode, Claude, Bob, OpenClaude) unchanged.
8
8
 
9
9
  ### Postures — the register
10
10
 
@@ -17,7 +17,6 @@ Exclusion is all-or-nothing per folder — it doesn't trim which Rules apply or
17
17
  - **OpenClaude** — the same `claudeMdExcludes`, in the repo's `.openclaude/settings.local.json`.
18
18
  - **Bob** — a shadow `rules/geneseed.md` stub that shadows the global one (never overwrites a hand-written stub already there).
19
19
  - **OpenCode** — the hook entry's sovereign-bypass guard and its twin in the plugins short-circuit at session start.
20
- - **Copilot — documented limitation.** GitHub Copilot has no native per-repo suppression hook: the global `copilot-instructions.md` still loads even inside an excluded folder.
21
20
 
22
21
  ### Manage it
23
22
 
@@ -26,7 +26,7 @@ Neither setting hides text from the agent; they hide it from *every turn*.
26
26
 
27
27
  ### How to set it
28
28
 
29
- It's set-and-forget — stored in the `.geneseed-footprint` marker, preserved across every rebuild, identical on every host (OpenCode, Claude Code, Bob, Copilot, OpenClaude). Changing it re-emits the install.
29
+ It's set-and-forget — stored in the `.geneseed-footprint` marker, preserved across every rebuild, identical on every host (OpenCode, Claude Code, Bob, OpenClaude). Changing it re-emits the install.
30
30
 
31
31
  - **Settings** — the **Footprint** toggle flips the current install (full ⇄ lean) and rebuilds it in place.
32
32
  - **Harness tab** — on an active install, a per-harness dropdown sets it for that install independently, then **Apply**. On a host that is not installed yet, it is step 2 of **Install…**.
@@ -4,26 +4,26 @@ order: 4
4
4
  title: "Supported harnesses"
5
5
  kind: "concept"
6
6
  ---
7
- One source, **six emit targets** — each with a per-repo and a global (`-global`) variant, plus a portable `files` bundle any `AGENT.md`-aware tool can read. **OpenCode** runs its own engine (JS plugins, colour themes, LSP); **Claude Code**, **Bob**, **Copilot**, and **OpenClaude** share one Claude-shaped engine that diverges only by host dialect.
7
+ One source, **five emit targets** — each with a per-repo and a global (`-global`) variant, plus a portable `files` bundle any `AGENT.md`-aware tool can read. **OpenCode** runs its own engine (JS plugins, colour themes, LSP); **Claude Code**, **Bob**, and **OpenClaude** share one Claude-shaped engine that diverges only by host dialect.
8
8
 
9
- The harness *content* — the constitution (Ethos, Rules, Doctrines), Agents, Skills, Memory, and the preamble voice — is **identical on every host**: one build renders the same text for all six targets. What differs is how much of it the host can *automate* (via plugins or hooks) versus carry as preamble discipline.
9
+ The harness *content* — the constitution (Ethos, Rules, Doctrines), Agents, Skills, Memory, and the preamble voice — is **identical on every host**: one build renders the same text for all five targets. What differs is how much of it the host can *automate* (via plugins or hooks) versus carry as preamble discipline.
10
10
 
11
- | Capability | OpenCode | Claude | Bob | Copilot | OpenClaude |
12
- | --- | :---: | :---: | :---: | :---: | :---: |
13
- | Agents · Skills · Memory | ✅ | ✅ | ✅ | ✅ | ✅ |
14
- | Context injection | ⚙️ plugin | 🪝 hook | 🪝 hook¹ | 📄 preamble | 🪝 hook² |
15
- | Memory write-back (learn) | ⚙️ plugin | 🪝 hook | 🪝 hook¹ | 📄 preamble | 🪝 hook² |
16
- | Git-gate consent (Consent Before Push) | ⚙️ plugin | 🪝 hook | 🪝 hook¹ | 📄 preamble | 🪝 hook² |
17
- | Sovereign-repo excludes | ⚙️ plugin | ✅ | ✅ | ➖ | ✅ |
18
- | MCP server wiring | ✅ | ✅ | ✅ | ✅ | ✅ |
19
- | Colour themes | ✅ | ➖ | ➖ | ➖ | ➖ |
20
- | LSP · workflows · primary-agent · `/`-commands | ✅ | ➖ | ➖ | ➖ | ➖ |
11
+ | Capability | OpenCode | Claude | Bob | OpenClaude |
12
+ | --- | :---: | :---: | :---: | :---: |
13
+ | Agents · Skills · Memory | ✅ | ✅ | ✅ | ✅ |
14
+ | Context injection | ⚙️ plugin | 🪝 hook | 🪝 hook¹ | 🪝 hook² |
15
+ | Memory write-back (learn) | ⚙️ plugin | 🪝 hook | 🪝 hook¹ | 🪝 hook² |
16
+ | Git-gate consent (Consent Before Push) | ⚙️ plugin | 🪝 hook | 🪝 hook¹ | 🪝 hook² |
17
+ | Sovereign-repo excludes | ⚙️ plugin | ✅ | ✅ | ✅ |
18
+ | MCP server wiring | ✅ | ✅ | ✅ | ✅ |
19
+ | Colour themes | ✅ | ➖ | ➖ | ➖ |
20
+ | LSP · workflows · primary-agent · `/`-commands | ✅ | ➖ | ➖ | ➖ |
21
21
 
22
22
  ✅ native · ⚙️ OpenCode plugin · 🪝 `settings.json` hook · 📄 preamble prose only · ➖ no host mechanism (harness discipline still applies). ¹ Bob honours Claude-dialect hooks best-effort — inert if unsupported, harness still holds via the preamble. ² OpenClaude is a Claude Code fork: Claude's hooks verbatim, under `~/.openclaude` or a repo's `.openclaude/`.
23
23
 
24
24
  **The git-gate row is the one capability that is not always present**, on any host. It implements **Consent Before Push** (`process 5`), so it is wired only on an install that built the **process** [doctrine pack](#/docs/setup-choices) in — drop that pack and the boundary drops with it, because a gate that keeps asking for a rule the install never adopted is a gate arguing with its own harness. One asymmetry: on OpenCode that holds for a *fresh* install, but a pack-off rebuild of an install whose `opencode.json` already carries a `git commit*` entry leaves it in place and reports it — Geneseed cannot tell its own entry from one you typed, so it removes neither ([doctrine packs](#/docs/setup-choices)). The invariant-territory refusals in the same neighbourhood (`rm -rf`, a force-push) are the Rules' territory, not the pack's, and stay in every build.
25
25
 
26
- **No host drops an Agent, Skill, or the memory convention.** The asymmetry is otherwise entirely in *automation mechanism*: OpenCode's plugins and Claude/Bob/OpenClaude's hooks enforce a few of these rules for you at the tool boundary, where **Copilot** (no hook mechanism) enforces them through preamble discipline. The OpenCode-only extras (themes, LSP, workflow runner, primary-agent) have no analogue on a Claude-shaped host.
26
+ **No host drops an Agent, Skill, or the memory convention.** The asymmetry is otherwise entirely in *automation mechanism*: OpenCode's plugins and Claude/Bob/OpenClaude's hooks enforce a few of these rules for you at the tool boundary; what no host can automate rides the preamble. The OpenCode-only extras (themes, LSP, workflow runner, primary-agent) have no analogue on a Claude-shaped host.
27
27
 
28
28
  ---
29
29
 
@@ -18,7 +18,7 @@ To keep the command around: `npm install -g geneseed`, then plain `geneseed …`
18
18
 
19
19
  ### 2. Open your agent
20
20
 
21
- Open OpenCode (or Claude Code, Bob, Copilot, OpenClaude, or any `AGENT.md`-aware tool) in any repo. The first reply opens with the readiness sigil (`✅` neutral / `🧬` imperial / your theme's equivalent) and your project's docs are already in context.
21
+ Open OpenCode (or Claude Code, Bob, OpenClaude, or any `AGENT.md`-aware tool) in any repo. The first reply opens with the readiness sigil (`✅` neutral / `🧬` imperial / your theme's equivalent) and your project's docs are already in context.
22
22
 
23
23
  ### Prefer a clone?
24
24
 
package/docs/web-ui.md CHANGED
@@ -120,7 +120,7 @@ streams the output of background jobs and keeps their history across reloads.
120
120
  - **Docs** (`#/docs`) — rendered documentation: markdown pages, concept pages, a CLI
121
121
  reference (generated from the harness argument parser), and a glossary, grouped into
122
122
  Get started / Core concepts / How-to / MCP servers / Plugins / Reference / Deeper. Filtered
123
- for one host family (OpenCode, or Claude Code — which Bob, Copilot and OpenClaude share); with no
123
+ for one host family (OpenCode, or Claude Code — which Bob and OpenClaude share); with no
124
124
  choice stored it follows the deployed install.
125
125
 
126
126
  ### 🩺 Care
@@ -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.9.0"
5
+ "version": "3.10.0"
6
6
  }
package/js/README.md CHANGED
@@ -40,12 +40,12 @@ as a per-host install at project or global scope.
40
40
  | `source.mjs` | Where the SOURCE is: `ROOT`/`SRC`/`THEMES`/`CONFIG`, `PACK_ORDER`, the rule canon (stable ids → positional numbers, citations → names), the cfg object |
41
41
  | `render.mjs` | The pure text pipeline: theme load, `INCLUDE` inlining, CATALOG blocks, `{{TOKENS}}`, dest paths |
42
42
  | `bundle.mjs` | `build` — render a theme into a bundle dir, plus the source-completeness refusal. Sits UNDER both emits |
43
- | `emit-claude.mjs` | The Claude/Bob/Copilot/OpenClaude emit — the render half plus the settings + CLAUDE.md managed-block wire |
43
+ | `emit-claude.mjs` | The Claude/Bob/OpenClaude emit — the render half plus the settings + CLAUDE.md managed-block wire |
44
44
  | `emit-opencode.mjs` | The OpenCode emits — a project's `.opencode/` and the global config dir. Deliberate twin of the above |
45
45
  | `emit-common.mjs` | Constants, tree walkers, and the three writers BOTH global emits share. Decides no host |
46
46
  | `stubs.mjs` | Write-once seed files: context, wiki, rules, excludes, profile, `.gitignore`, memory/notebook indexes |
47
47
  | `version.mjs` | The release marker: fingerprint the sources, read/compare/write an install's version, warn on downgrade |
48
- | `driver.mjs` | The generator driver: `--emit`/`--theme`/`--footprint` flag parsing, the eleven emit targets, per-host emit orchestration, the prune and the manifest. `bin/build-driver.mjs` is only its entry |
48
+ | `driver.mjs` | The generator driver: `--emit`/`--theme`/`--footprint` flag parsing, the nine emit targets, per-host emit orchestration, the prune and the manifest. `bin/build-driver.mjs` is only its entry |
49
49
  | `generate.mjs` | CLI verbs `build`, `prompt`, `theme`, `rebuild-all` — the thin face over `driver.mjs` |
50
50
  | `catalog.mjs` | `geneseed catalog` — prints the shipped roster. Classifies nothing itself |
51
51
  | `themes.mjs` | Maintainer-only `--sync-themes`: inserts `_TEMPLATE.json`'s missing keys into the committed themes |
@@ -66,8 +66,8 @@ user co-owns, and the hook verbs those emitted configs then run.
66
66
 
67
67
  | module | owns |
68
68
  |---|---|
69
- | `hosts.mjs` | The five host config dirs (opencode/claude/bob/copilot/openclaude), plus `resolvePath`/`expanduser` |
70
- | `hooks.mjs` | The verbs the emitted hooks run every session: `context`, `git-gate`, `rule-gate`, `tool-gate` (the two gates fused, for Copilot), `learn`. `--host` picks the verdict dialect |
69
+ | `hosts.mjs` | The four host config dirs (opencode/claude/bob/openclaude), plus `resolvePath`/`expanduser` |
70
+ | `hooks.mjs` | The verbs the emitted hooks run every session: `context`, `git-gate`, `rule-gate`, `tool-gate` (the two gates fused, for Bob), `learn`. `--host` picks the verdict dialect |
71
71
  | `settings.mjs` | Merges into user-owned `settings.json`/`opencode.json`; owns the hook shim and the managed blocks |
72
72
  | `native.mjs` | Capability specs → host-native subagents and skills. The impure half of the emit |
73
73
  | `opencode.mjs` | OpenCode-only extras: colour themes, primary agent, `/commands`, plugins, overrides stub |
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The generator driver — flag parsing, the eleven emit targets, and the per-host emit orchestration.
2
+ * The generator driver — flag parsing, the nine emit targets, and the per-host emit orchestration.
3
3
  * `bin/build-driver.mjs` (`geneseed-build`) is only its entry; this module is what the CLI verbs
4
4
  * (`build`, `setup`, `migrate`, `doctor`, `diff`, `validate`, the web console) import. It lived in
5
5
  * `bin/` until 2026-09, which made nine `js/` modules import upward from a binary.
@@ -51,7 +51,7 @@ import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
51
51
  // that should exist twice. golden.py's 259 cells are what made the move safe to attempt.
52
52
  import {
53
53
  GLOBAL_MANIFEST, resolvePath, opencodeConfigDir, claudeConfigDir, bobConfigDir,
54
- copilotConfigDir, openclaudeConfigDir, hostCatalogsNatively,
54
+ openclaudeConfigDir, hostCatalogsNatively,
55
55
  } from '../hosts/hosts.mjs';
56
56
 
57
57
  // P5d moved these out of this file for the reason P5c moved the host resolvers: `harness
@@ -71,9 +71,9 @@ import { registryRecord, registryRoots } from '../inspect/registry.mjs';
71
71
  // maintainer pair that needs nothing this driver is banned from having.
72
72
  import { syncThemes } from './themes.mjs';
73
73
 
74
- /** The eleven `--emit` choices: build.py:337-338's nine, then OpenClaude's pair. */
74
+ /** The nine `--emit` choices: a plain bundle, then a project and a global emit per host. */
75
75
  const EMITS = ['files', 'opencode', 'opencode-global', 'claude', 'claude-global',
76
- 'bob', 'bob-global', 'copilot', 'copilot-global', 'openclaude', 'openclaude-global'];
76
+ 'bob', 'bob-global', 'openclaude', 'openclaude-global'];
77
77
 
78
78
  /** `_build_emit.PRIMARY_AGENT_SRC`. */
79
79
  const PRIMARY_AGENT_SRC = path.join(ROOT, 'adapters', 'opencode', 'agents', 'orchestrator.md');
@@ -497,7 +497,7 @@ export function hookRunnerEntry() {
497
497
  * to suppress the GLOBAL preamble of the same host, or null for a host that gets none.
498
498
  *
499
499
  * `_PREAMBLE_CONFIG_DIR` has exactly one key, so only a `CLAUDE.md` carrier resolves to a
500
- * value: Bob's and Copilot's `AGENTS.md`, and Copilot's `copilot-instructions.md`, get null.
500
+ * value: Bob's `AGENTS.md` gets null.
501
501
  *
502
502
  * Computed HERE and not in the render child, and that is the inverted boundary rule doing
503
503
  * real work rather than being restated. P3b's note on the Python original: it resolves
@@ -519,10 +519,8 @@ function preambleExclude(claudeMd, host) {
519
519
  /**
520
520
  * `_build_global._warn_bob_global_over_project` — informational, never auto-removes.
521
521
  *
522
- * Same shape as `warnCopilotGlobalOverProject` and a DIFFERENT message, because the two
523
- * hosts differ in what they can promise: Copilot simply stacks both preambles, while Bob's
524
- * workspace rules may or may not shadow the global copy depending on precedence nothing can
525
- * verify at emit time — so this text hedges where Copilot's asserts.
522
+ * The text hedges on purpose: Bob's workspace rules may or may not shadow the global copy,
523
+ * depending on a precedence nothing can verify at emit time.
526
524
  */
527
525
  function warnBobGlobalOverProject() {
528
526
  const survivors = projectSurvivors('bob');
@@ -646,27 +644,6 @@ function projectSurvivors(emitName) {
646
644
  return out;
647
645
  }
648
646
 
649
- /**
650
- * `_build_global._warn_copilot_global_over_project` — purely informational.
651
- *
652
- * Copilot documents no exclude or shadow mechanism, so a global preamble and a project
653
- * one simply stack. UNREACHABLE from every golden cell: each emits into a fresh sandbox
654
- * whose registry is empty, so `projectSurvivors` is always `[]` and this never prints.
655
- * Reaching it needs a project emit registered BEFORE a global one in the same sandbox.
656
- */
657
- function warnCopilotGlobalOverProject() {
658
- const survivors = projectSurvivors('copilot');
659
- if (!survivors.length) return;
660
- process.stderr.write(
661
- `[geneseed] WARN: ${survivors.length} project Copilot install(s) already exist — `
662
- + 'emitting GLOBAL now means BOTH preambles load together in those repos '
663
- + '(doubled context): Copilot stacks ~/.copilot/copilot-instructions.md on top '
664
- + "of a repo's own AGENTS.md. Review and remove what you don't want:\n");
665
- for (const root of survivors) {
666
- process.stderr.write(` - ${root} -> geneseed uninstall --target "${root}"\n`);
667
- }
668
- }
669
-
670
647
  /**
671
648
  * `_build_emit.emit_opencode` — the driver body, which is what this phase actually ports.
672
649
  *
@@ -776,22 +753,14 @@ function emitOpencodeGlobal(cfg, args, out) {
776
753
  }
777
754
 
778
755
  /**
779
- * `_build_global._emit_claude_core` — the shared engine behind six emits.
780
- *
781
- * Only the two Copilot ones run through it so far, and that is a deliberate split rather
782
- * than an arbitrary stopping point: `js/build/emit-claude.mjs's wire` gates the entire settings/hook path
783
- * behind `if (!isCopilot)`, so a Copilot emit never reaches `hookPrefix` and therefore never
784
- * needs `hookOpts` — the pair of machine-absolute values (`sys.executable` and the harness
785
- * entry point) that a Node driver has no way to produce. Copilot crossing first isolates
786
- * this driver body from that question entirely.
756
+ * `_build_global._emit_claude_core` — the shared engine behind six emits (Claude, Bob and
757
+ * OpenClaude, each per-repo and global).
787
758
  *
788
- * `hookOpts` is OMITTED for a host that does not wire hooks, rather than passed as `{}` or
789
- * `null`. If a future change ever routed a Copilot emit into the hook path, `hookPrefix()`
790
- * would receive `undefined`, hit its own default and throw the error it was built to throw
791
- * — where `null` would raise an unrelated TypeError and `{}` would bake the literal string
792
- * `"undefined"` into the shim and silently kill every hook in the install. Fail loud, on
793
- * purpose. The four hook-writing hosts pass a real pair from `hookRunnerEntry()`, which
794
- * cannot fail to produce one — `process.execPath` is the node already running.
759
+ * Every caller passes a real `hookOpts` pair from `hookRunnerEntry()`, which cannot fail to
760
+ * produce one — `process.execPath` is the node already running. Were it ever omitted,
761
+ * `hookPrefix()` would receive `undefined`, hit its own default and throw the error it was
762
+ * built to throw — where `null` would raise an unrelated TypeError and `{}` would bake the
763
+ * literal string `"undefined"` into the shim and silently kill every hook in the install.
795
764
  */
796
765
  function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpts }) {
797
766
  const manifestPath = path.join(cfgDir, GLOBAL_MANIFEST);
@@ -799,12 +768,10 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
799
768
  const oldOwned = (doc && doc.owned) || [];
800
769
  const oldManaged = (doc && doc.managed && typeof doc.managed === 'object'
801
770
  && !Array.isArray(doc.managed)) ? doc.managed : {};
802
- const isCopilot = host === 'copilot';
803
771
 
804
- // RENDER + WIRE in one child's worth of work. `preambleExclude` is null for every host
805
- // but Claude — `_PREAMBLE_CONFIG_DIR` has exactly one key, `CLAUDE.md` — so neither
806
- // Copilot carrier filename (copilot-instructions.md, AGENTS.md) resolves to one, and
807
- // neither does Bob's. Spelling it as a call rather than a literal is what makes that a
772
+ // RENDER + WIRE in one child's worth of work. `preambleExclude` is null for every
773
+ // carrier but `CLAUDE.md` — `_PREAMBLE_CONFIG_DIR` has exactly one key — so Bob's
774
+ // `AGENTS.md` does not resolve to one. Spelling it as a call rather than a literal is what makes that a
808
775
  // measured `null` instead of an assumed one.
809
776
  const rendered = emitClaudeRender(cfg, {
810
777
  theme: args.theme, cfgDir, claudeMd, scope, host,
@@ -832,8 +799,7 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
832
799
  // VERIFY — re-read the settings file just written and match it against the claims just
833
800
  // recorded, so a merge that silently did not stick (a commented file, a mid-flight
834
801
  // external edit, a bug in the merge) is loud now instead of surfacing as hooks that
835
- // quietly never fire. Copilot's settings have their own shape and their own check; a
836
- // Copilot PROJECT emit records no hooks and so checks nothing.
802
+ // quietly never fire.
837
803
  //
838
804
  // On the Python driver this stage runs in Python AFTER a Node child did the wiring, which
839
805
  // makes it a live cross-implementation check on every build. Here both halves are Node, so
@@ -845,10 +811,8 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
845
811
  // deleting this call is byte-identical in all 259 cells. `test_verify_reports_an_orphaned
846
812
  // _geneseed_hook` plants the fault that makes it speak.
847
813
  phaseLog('VERIFY');
848
- if (!isCopilot || managed.settings_hooks) {
849
- settingsIntegrityCheck(
850
- path.join(cfgDir, managed.settings_file || 'settings.json'), managed, 'present');
851
- }
814
+ settingsIntegrityCheck(
815
+ path.join(cfgDir, managed.settings_file || 'settings.json'), managed, 'present');
852
816
  return {
853
817
  nAgents: stats.nAgents,
854
818
  nSkills: stats.nSkills,
@@ -858,38 +822,6 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
858
822
  };
859
823
  }
860
824
 
861
- /** `_build_global.emit_copilot` — per-repo: AGENTS.md at the repo root + a `.github/` layer. */
862
- function emitCopilot(cfg, args, out) {
863
- const root = args.root ? resolveOut(args.root) : out;
864
- const cfgDir = path.join(root, '.github');
865
- const r = emitClaudeCore(cfg, args, {
866
- cfgDir, claudeMd: path.join(root, 'AGENTS.md'), scope: 'project', host: 'copilot', out,
867
- });
868
- process.stdout.write(`[geneseed] copilot (folder) -> ${root}: AGENTS.md + .github/ `
869
- + `(${r.nAgents} agents (.agent.md), ${r.nSkills} skills), ${r.memStatus}, `
870
- + `${r.nbStatus}. No hooks at project scope (Copilot reads hooks from `
871
- + '~/.copilot/settings.json only — the global emit wires them).\n');
872
- return cfgDir;
873
- }
874
-
875
- /** `_build_global.emit_copilot_global` — into Copilot's personal config dir (~/.copilot). */
876
- function emitCopilotGlobal(cfg, args, out) {
877
- const cfgDir = args.cfgDir ?? copilotConfigDir();
878
- warnCopilotGlobalOverProject();
879
- // The hook runner pair, as for Claude and Bob: the global emit is the one Copilot scope
880
- // that wires hooks (`~/.copilot/settings.json`), and the shim it bakes is machine-wide.
881
- const hookOpts = hookRunnerEntry();
882
- const r = emitClaudeCore(cfg, args, {
883
- cfgDir, claudeMd: path.join(cfgDir, 'copilot-instructions.md'),
884
- scope: 'global', host: 'copilot', out, hookOpts,
885
- });
886
- process.stdout.write(`[geneseed] copilot-global -> ${cfgDir}: ${r.nAgents} agents (.agent.md), `
887
- + `${r.nSkills} skills, copilot-instructions.md, ${r.memStatus}, ${r.nbStatus}, `
888
- + `${r.nHooks} hooks (settings.json: sessionStart context, preToolUse gate, agentStop + `
889
- + 'preCompact learn); MCP servers go in mcp-config.json.\n');
890
- return cfgDir;
891
- }
892
-
893
825
  /**
894
826
  * `_build_global.emit_claude_global` — into Claude Code's global config dir (~/.claude).
895
827
  *
@@ -1018,7 +950,7 @@ function writeMarkers(markerDir, emit, footprint) {
1018
950
  mkdirSync(markerDir, { recursive: true });
1019
951
  writeText(path.join(markerDir, '.geneseed-emit'), `${emit}\n`);
1020
952
  // Written for EVERY emit (build.py:443, unconditional), unlike the theme marker:
1021
- // the claude/bob/copilot PROJECT installs never call build(), so this is their only
953
+ // the claude/bob/openclaude PROJECT installs never call build(), so this is their only
1022
954
  // footprint record. Read by harness._footprint_of_dir, which defaults to 'full'.
1023
955
  writeText(path.join(markerDir, '.geneseed-footprint'), `${footprint}\n`);
1024
956
  } catch { /* best-effort, exactly as build.py:444-445 — a marker hiccup never fails a build */ }
@@ -1049,14 +981,13 @@ const GLOBAL_EMITS = {
1049
981
  opencode: emitOpencodeGlobal,
1050
982
  claude: emitClaudeGlobal,
1051
983
  bob: emitBobGlobal,
1052
- copilot: emitCopilotGlobal,
1053
984
  openclaude: emitOpenclaudeGlobal,
1054
985
  };
1055
986
 
1056
987
  /**
1057
988
  * The PER-REPO half of the same idea, and `doctor` is its only caller.
1058
989
  *
1059
- * `_claude_bob_emit_problems` renders `emit_claude`, `emit_bob` and `emit_copilot` into
990
+ * `_claude_bob_emit_problems` renders `emit_claude`, `emit_bob` and `emit_openclaude` into
1060
991
  * throwaway sandboxes and scans each — the three emits that write CLAUDE.md/AGENTS.md
1061
992
  * straight into a repo, and the ones outside doctor's sweep when the skill-table dead links
1062
993
  * shipped.
@@ -1072,7 +1003,7 @@ const GLOBAL_EMITS = {
1072
1003
  * three-positional call leaves in place, inherited here rather than re-decided.
1073
1004
  */
1074
1005
  const PROJECT_EMITS = {
1075
- claude: emitClaude, bob: emitBob, copilot: emitCopilot, openclaude: emitOpenclaude,
1006
+ claude: emitClaude, bob: emitBob, openclaude: emitOpenclaude,
1076
1007
  // P2. `opencode` joins the three for `cmdValidate`, which has to be able to render EVERY
1077
1008
  // `--emit` choice into its sandbox and not only the three doctor already scans. Its call
1078
1009
  // shape is `emitClaude`'s exactly — `(cfg, args, out)` — so the row is the whole change.
@@ -1210,7 +1141,7 @@ function run(argv) {
1210
1141
  markerDir = GLOBAL_EMITS[args.emit.slice(0, -'-global'.length)](cfg, args, out);
1211
1142
  } else if (args.emit !== 'files') {
1212
1143
  // The PROJECT emits keep their markers in `out`, not in the host config dir the emit
1213
- // wrote to (build.py:435-436) — `.github/` is the layer, `out` is the install.
1144
+ // wrote to (build.py:435-436) — `.claude/` is the layer, `out` is the install.
1214
1145
  PROJECT_EMITS[args.emit](cfg, args, out);
1215
1146
  } else {
1216
1147
  // The ninth choice, a plain bundle: `nativeCatalog: false` is build.py:421's
@@ -1223,7 +1154,7 @@ function run(argv) {
1223
1154
  writeMarkers(markerDir, args.emit, args.footprint);
1224
1155
  // build() drops a .geneseed-theme in `out` for the emits that call it; the global emits
1225
1156
  // render into the config dir WITHOUT calling build(), so the theme is recorded here.
1226
- // Deliberately not written for the claude/bob/copilot PROJECT emits — they carry none,
1157
+ // Deliberately not written for the claude/bob/openclaude PROJECT emits — they carry none,
1227
1158
  // and `_harness_setup._installed_defaults` detects those by an AGENT.md sigil scan
1228
1159
  // instead. Writing one for them would change the emitted tree.
1229
1160
  if (args.emit.endsWith('-global')) {
@@ -1234,7 +1165,7 @@ function run(argv) {
1234
1165
  // An ALLOW-LIST, not "everything that is not global": a plain `--emit files` dev build —
1235
1166
  // the default — must never pollute the registry, and only the four per-repo host emits
1236
1167
  // are ones `_EMIT_HOST_SCOPE` can map back to a row. Records `out`, where the marker is.
1237
- if (['opencode', 'claude', 'bob', 'copilot', 'openclaude'].includes(args.emit)) {
1168
+ if (['opencode', 'claude', 'bob', 'openclaude'].includes(args.emit)) {
1238
1169
  registryRecord(markerDir);
1239
1170
  }
1240
1171
  return 0;
@@ -11,7 +11,7 @@ import { loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
11
11
  import { ensureAgentOverridesStub } from '../hosts/opencode.mjs';
12
12
  import {
13
13
  managedBlockRemove, managedBlockWrite, mergeClaudeSettings,
14
- unwireClaudeExcludes, unwireClaudeSettings, unwireCopilotSettings, wireClaudeExcludes,
14
+ unwireClaudeExcludes, unwireClaudeSettings, wireClaudeExcludes,
15
15
  } from '../hosts/settings.mjs';
16
16
  import { writeText } from '../lib/fs.mjs';
17
17
  import { isTruthy } from '../lib/json.mjs';
@@ -21,7 +21,8 @@ import {
21
21
  } from './emit-common.mjs';
22
22
  import { renderAll, renderFile } from './render.mjs';
23
23
  import {
24
- BOB_RULES_STUB, ensureExcludesStub, ensureMemoryIndex, ensureNotebookIndex, ensureProfileStub,
24
+ BOB_RULES_STUB, ensureContextStub, ensureExcludesStub, ensureMemoryIndex, ensureNotebookIndex,
25
+ ensureProfileStub,
25
26
  ensureRulesStub, ensureWikiStub,
26
27
  } from './stubs.mjs';
27
28
  import { writeVersion } from './version.mjs';
@@ -36,7 +37,7 @@ import { existsSync, mkdirSync } from 'node:fs';
36
37
  * `claudeWire` below for the CLAUDE.md managed-block merge and the settings file. PRUNE,
37
38
  * MANIFEST and VERIFY are the driver's, in `bin/build-driver.mjs`.
38
39
  *
39
- * Six emits route through here — claude, bob and copilot at both scopes — so this one
40
+ * Six emits route through here — claude, bob and openclaude at both scopes — so this one
40
41
  * job is what puts two thirds of the matrix on the seam.
41
42
  *
42
43
  * `claudeMdText` is the payload's one item that is not a file this process wrote. WIRE
@@ -76,9 +77,11 @@ export function emitClaudeRender(cfg, job) {
76
77
  // `themed_rel` and `lstripNewlines` are, so the two verdicts stay distinguishable.
77
78
  if (!prefix) return tmplItem.text;
78
79
  const ptheme = { ...theme };
79
- for (const tok of ['DIR_LAWS', 'DIR_AGENTS', 'DIR_SKILLS', 'DIR_MEMORY', 'DIR_NOTEBOOK']) {
80
+ for (const tok of ['DIR_LAWS', 'DIR_AGENTS', 'DIR_SKILLS', 'DIR_MEMORY', 'DIR_NOTEBOOK',
81
+ 'DIR_DOCTRINES']) {
80
82
  ptheme[tok] = prefix + (ptheme[tok] ?? tok.split('_').slice(1).join('_').toLowerCase());
81
83
  }
84
+ ptheme.AT_HARNESS = prefix;
82
85
  // Same catalogue decision as the renderAll above — a re-render that forgot it would
83
86
  // quietly put the stripped tables back.
84
87
  return renderFile(cfg, tmplItem.src, ptheme, footprint, '', new Set(), nativeCatalog);
@@ -87,7 +90,6 @@ export function emitClaudeRender(cfg, job) {
87
90
  const owned = [];
88
91
  const agentText = items.find((i) => i.rel === 'AGENT.md' && i.text !== null)?.text ?? null;
89
92
  const isBob = host === 'bob';
90
- const isCopilot = host === 'copilot';
91
93
 
92
94
  // Bob's always-injected channel is the rules folder. At GLOBAL scope rules/geneseed.md
93
95
  // IS the preamble (a global ~/.bob/AGENTS.md is not auto-loaded), and its pointers need
@@ -102,19 +104,30 @@ export function emitClaudeRender(cfg, job) {
102
104
  }
103
105
 
104
106
  ensureAgentOverridesStub(cfg, cfgDir);
105
- // Bob's agents/skills use the Claude dialect verbatim; Copilot has its own frontmatter.
107
+ // Bob gives the model a skill's text but not its directory, so a folder skill's
108
+ // `<this-skill-directory>` is resolved here. Per repo it is relative to the workspace root,
109
+ // where Bob runs commands, so a committed `.bob/` stays valid on a teammate's checkout;
110
+ // globally there is no workspace to be relative to, so it is absolute.
111
+ const skillDirOf = !isBob ? null
112
+ : scope === 'project' ? (name) => relPosix(path.dirname(cfgDir), path.join(cfgDir, 'skills', name))
113
+ : (name) => path.join(cfgDir, 'skills', name);
114
+ // Bob's and OpenClaude's agents/skills use the Claude dialect verbatim.
106
115
  // `manifestExisted` is deliberately not passed: the Python does not pass it either, so
107
116
  // the pre-manifest header line is unreachable from this emit on both sides.
108
117
  const { nAgents, nSkills, written } = writeNativeLayer(
109
118
  items, path.join(cfgDir, 'agents'), path.join(cfgDir, 'skills'),
110
119
  loadAgentOverrides(cfgDir),
111
- { host: isCopilot ? 'copilot' : 'claude', oldOwned, cfg: cfgDir, src: cfg.src });
120
+ { host: 'claude', oldOwned, cfg: cfgDir, src: cfg.src, skillDirOf });
112
121
  for (const p of written) owned.push(relPosix(cfgDir, p));
113
122
 
114
123
  const memStatus = globalMemory(cfgDir, items, out, cfg.src);
115
124
  ensureMemoryIndex(path.join(cfgDir, 'memory'));
116
125
  const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
117
126
  ensureNotebookIndex(path.join(cfgDir, 'notebook'));
127
+ // context.json too: the root names it as the project's own manifest, and until 2026-10 this
128
+ // emit never seeded one — the bundle and OpenCode did — so §9 pointed at a file that was not
129
+ // there. The context hook reads it from this dir (`resolveContextSets`' last candidate).
130
+ ensureContextStub(cfgDir);
118
131
  ensureWikiStub(cfgDir);
119
132
  ensureRulesStub(cfgDir);
120
133
  ensureProfileStub(cfgDir);
@@ -128,7 +141,7 @@ export function emitClaudeRender(cfg, job) {
128
141
  const gi = path.join(cfgDir, '.gitignore');
129
142
  const giLines = (host === 'claude' || host === 'openclaude' ? ['settings.local.json']
130
143
  : host === 'bob' ? ['settings.json'] : [])
131
- .concat(['wiki.jsonc', 'agent-overrides.json']);
144
+ .concat(['context.json', 'wiki.jsonc', 'agent-overrides.json']);
132
145
  if (!existsSync(gi)) {
133
146
  writeText(gi, `${giLines.join('\n')}\n`);
134
147
  owned.push('.gitignore');
@@ -194,7 +207,6 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRu
194
207
  const old = oldManaged && typeof oldManaged === 'object' && !Array.isArray(oldManaged)
195
208
  ? oldManaged : {};
196
209
  const isBob = host === 'bob';
197
- const isCopilot = host === 'copilot';
198
210
  const managed = {};
199
211
 
200
212
  // CLAUDE.md — Claude auto-loads it by location; merge as a delimited block so any user
@@ -228,80 +240,63 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRu
228
240
  // settings.local.json — the personal, untracked file — never the team-shared
229
241
  // settings.json, which would hand every teammate failing hooks pointing at this machine's
230
242
  // node and this machine's checkout. (Bob documents no local variant, so it keeps
231
- // settings.json.) Copilot's settings are a different shape entirely (one command per
232
- // event, no matcher groups) and take the `else` branch below; its PROJECT scope wires
233
- // nothing, because the CLI documents hooks in `~/.copilot/settings.json` only and a
234
- // machine-absolute hook committed into `.github/` would fail on every teammate's machine.
235
- if (!isCopilot) {
236
- // Bob GLOBAL: its hooks doc puts the global file at `~/.bob/settings/settings.json`
237
- // (nested), where an older emit wrote `~/.bob/settings.json`; the `oldSf !==
238
- // settingsName` branch below unwires the old file, so a re-emit migrates. Bob's project
239
- // file stays `.bob/settings.json`.
240
- const settingsName = scope === 'project' && !isBob ? 'settings.local.json'
241
- : isBob && scope === 'global' ? path.join('settings', 'settings.json') : 'settings.json';
242
- const settingsPath = path.join(cfgDir, settingsName);
243
- mkdirSync(path.dirname(settingsPath), { recursive: true });
244
- managed.settings_file = settingsName;
245
- // Migration: an older install wired hooks/excludes into a different file — unwire the
246
- // recorded claims there, or they linger (and run) forever.
247
- const oldSf = get(old, 'settings_file') || 'settings.json';
248
- if (oldSf !== settingsName) {
249
- unwireClaudeSettings(path.join(cfgDir, oldSf), get(old, 'settings_hooks') || []);
250
- unwireClaudeExcludes(path.join(cfgDir, oldSf), get(old, 'settings_excludes') || []);
251
- }
252
- // The merge prunes recorded groups that are no longer canonical and returns the
253
- // COMPLETE current claim set — store it as-is; unioning with prior would resurrect the
254
- // stale claims.
255
- // `doctrines` decides whether the git-gate group is part of the canonical claim set. It
256
- // travels from `cfg` rather than being re-read off the deployment: this is the emit that
257
- // DECIDES the install's packs, so the marker on disk is still the previous build's.
258
- const [, managedHooks] = mergeClaudeSettings(
259
- settingsPath, scope, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
260
- doctrines, excludeRules, host, cfgDir,
261
- );
262
- managed.settings_hooks = managedHooks;
243
+ // settings.json.)
244
+ // Bob GLOBAL: its hooks doc puts the global file at `~/.bob/settings/settings.json`
245
+ // (nested), where an older emit wrote `~/.bob/settings.json`; the `oldSf !==
246
+ // settingsName` branch below unwires the old file, so a re-emit migrates. Bob's project
247
+ // file stays `.bob/settings.json`.
248
+ const settingsName = scope === 'project' && !isBob ? 'settings.local.json'
249
+ : isBob && scope === 'global' ? path.join('settings', 'settings.json') : 'settings.json';
250
+ const settingsPath = path.join(cfgDir, settingsName);
251
+ mkdirSync(path.dirname(settingsPath), { recursive: true });
252
+ managed.settings_file = settingsName;
253
+ // Migration: an older install wired hooks/excludes into a different file — unwire the
254
+ // recorded claims there, or they linger (and run) forever.
255
+ const oldSf = get(old, 'settings_file') || 'settings.json';
256
+ if (oldSf !== settingsName) {
257
+ unwireClaudeSettings(path.join(cfgDir, oldSf), get(old, 'settings_hooks') || []);
258
+ unwireClaudeExcludes(path.join(cfgDir, oldSf), get(old, 'settings_excludes') || []);
259
+ }
260
+ // The merge prunes recorded groups that are no longer canonical and returns the
261
+ // COMPLETE current claim set — store it as-is; unioning with prior would resurrect the
262
+ // stale claims.
263
+ // `doctrines` decides whether the git-gate group is part of the canonical claim set. It
264
+ // travels from `cfg` rather than being re-read off the deployment: this is the emit that
265
+ // DECIDES the install's packs, so the marker on disk is still the previous build's.
266
+ const [, managedHooks] = mergeClaudeSettings(
267
+ settingsPath, scope, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
268
+ doctrines, excludeRules, host, cfgDir,
269
+ );
270
+ managed.settings_hooks = managedHooks;
263
271
 
264
- // Project-bypasses-global (Claude only): a PROJECT install suppresses the GLOBAL
265
- // ~/.claude/CLAUDE.md while cwd is this repo, via Claude's native claudeMdExcludes.
266
- // Written only when this run actually emitted the project's own preamble (never
267
- // suppress with no replacement); GENESEED_STACK_GLOBAL=1 opts out, and a re-emit with
268
- // it set strips a prior exclude. Bob never gets one: its bypass is the same-named
269
- // workspace rules file.
270
- const priorRaw = get(old, 'settings_excludes');
271
- const priorExcl = Array.isArray(priorRaw) ? priorRaw : [];
272
- if (scope === 'project' && hasAgentText && preambleExclude) {
273
- const wantExcl = [preambleExclude];
274
- if (process.env.GENESEED_STACK_GLOBAL) {
275
- unwireClaudeExcludes(settingsPath, wantExcl);
276
- managed.settings_excludes = [];
277
- } else {
278
- const addedExcl = wireClaudeExcludes(settingsPath, wantExcl);
279
- // Claim only what Geneseed itself wired (prior + newly added) — folding `wantExcl`
280
- // in unconditionally would claim a user's own pre-existing exclude, and uninstall
281
- // would then strip it. `sorted(set(a) | set(b))` — Python sorts strings by code
282
- // point and so does the default `Array.sort`, which is why no comparator is passed.
283
- managed.settings_excludes = [...new Set([...priorExcl, ...addedExcl])].sort();
284
- }
285
- } else if (priorExcl.length && isBob) {
286
- // Self-heal older Bob installs: earlier versions wrote the global AGENTS.md into
287
- // claudeMdExcludes here. The key is Claude-only and its Bob semantics are unknown, so
288
- // a re-emit removes it instead of carrying it forward.
289
- unwireClaudeExcludes(settingsPath, priorExcl);
290
- } else if (priorExcl.length) {
291
- managed.settings_excludes = priorExcl;
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.
274
+ // Written only when this run actually emitted the project's own preamble (never
275
+ // suppress with no replacement); GENESEED_STACK_GLOBAL=1 opts out, and a re-emit with
276
+ // it set strips a prior exclude. Bob never gets one: its bypass is the same-named
277
+ // workspace rules file.
278
+ const priorRaw = get(old, 'settings_excludes');
279
+ const priorExcl = Array.isArray(priorRaw) ? priorRaw : [];
280
+ if (scope === 'project' && hasAgentText && preambleExclude) {
281
+ const wantExcl = [preambleExclude];
282
+ if (process.env.GENESEED_STACK_GLOBAL) {
283
+ unwireClaudeExcludes(settingsPath, wantExcl);
284
+ managed.settings_excludes = [];
285
+ } else {
286
+ const addedExcl = wireClaudeExcludes(settingsPath, wantExcl);
287
+ // Claim only what Geneseed itself wired (prior + newly added) — folding `wantExcl`
288
+ // in unconditionally would claim a user's own pre-existing exclude, and uninstall
289
+ // would then strip it. `sorted(set(a) | set(b))` — Python sorts strings by code
290
+ // point and so does the default `Array.sort`, which is why no comparator is passed.
291
+ managed.settings_excludes = [...new Set([...priorExcl, ...addedExcl])].sort();
292
292
  }
293
- } else if (scope === 'global') {
294
- // Copilot's hooks are event → array now, so the Claude merge (append/prune by deep
295
- // equality) is the Copilot merge too, recorded under `settings_hooks`. An install from
296
- // before that recorded single-slot `copilot_hooks` (the dead `toolCall` event): unwire
297
- // those claims first, or they linger in the file forever.
298
- const settingsPath = path.join(cfgDir, 'settings.json');
299
- managed.settings_file = 'settings.json';
300
- const legacy = get(old, 'copilot_hooks');
301
- if (Array.isArray(legacy) && legacy.length) unwireCopilotSettings(settingsPath, legacy);
302
- const [, managedHooks] = mergeClaudeSettings(settingsPath, scope,
303
- Array.isArray(legacy) ? null : get(old, 'settings_hooks'), hookOpts, null, [], host, cfgDir);
304
- managed.settings_hooks = managedHooks;
293
+ } else if (priorExcl.length && isBob) {
294
+ // Self-heal older Bob installs: earlier versions wrote the global AGENTS.md into
295
+ // claudeMdExcludes here. The key is Claude-only and its Bob semantics are unknown, so
296
+ // a re-emit removes it instead of carrying it forward.
297
+ unwireClaudeExcludes(settingsPath, priorExcl);
298
+ } else if (priorExcl.length) {
299
+ managed.settings_excludes = priorExcl;
305
300
  }
306
301
  return managed;
307
302
  }