geneseed 3.1.1 → 3.1.2

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 (98) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/DESIGN.md +15 -15
  3. package/README.md +1 -1
  4. package/SETUP.md +1 -1
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +39 -42
  7. package/bin/geneseed-cli.mjs +33 -32
  8. package/bin/geneseed-hook.mjs +7 -7
  9. package/docs/design-history.md +40 -40
  10. package/docs/extending.md +111 -83
  11. package/harness.config.json +1 -1
  12. package/js/README.md +203 -0
  13. package/js/build/bundle.mjs +231 -0
  14. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  15. package/js/build/emit-claude.mjs +287 -0
  16. package/js/build/emit-common.mjs +255 -0
  17. package/js/build/emit-opencode.mjs +214 -0
  18. package/js/{generate.mjs → build/generate.mjs} +27 -28
  19. package/js/{render.mjs → build/render.mjs} +13 -9
  20. package/js/{checkout.mjs → build/source.mjs} +9 -9
  21. package/js/build/stubs.mjs +256 -0
  22. package/js/{themes.mjs → build/themes.mjs} +24 -24
  23. package/js/build/version.mjs +155 -0
  24. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  25. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  26. package/js/{installs.mjs → hosts/installs.mjs} +18 -16
  27. package/js/{link.mjs → hosts/link.mjs} +29 -28
  28. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  29. package/js/{native.mjs → hosts/native.mjs} +24 -22
  30. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  31. package/js/{settings.mjs → hosts/settings.mjs} +63 -55
  32. package/js/inspect/checks-authoring.mjs +761 -0
  33. package/js/inspect/checks-build.mjs +231 -0
  34. package/js/inspect/checks-repo.mjs +308 -0
  35. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  36. package/js/inspect/doctor.mjs +286 -0
  37. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  38. package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
  39. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  40. package/js/inspect/scan.mjs +125 -0
  41. package/js/{status.mjs → inspect/status.mjs} +27 -25
  42. package/js/inspect/validate.mjs +199 -0
  43. package/js/lib/fs.mjs +37 -759
  44. package/js/lib/json.mjs +344 -0
  45. package/js/lib/paths.mjs +242 -0
  46. package/js/lib/proc.mjs +3 -3
  47. package/js/lib/text.mjs +181 -0
  48. package/js/lib/udiff.mjs +1 -1
  49. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  50. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  51. package/js/{setup.mjs → maintain/setup.mjs} +34 -32
  52. package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
  53. package/js/{update.mjs → maintain/update.mjs} +51 -51
  54. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  55. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  56. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  57. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  58. package/js/web/actions.mjs +84 -85
  59. package/js/web/activity.mjs +22 -21
  60. package/js/web/api.mjs +72 -50
  61. package/js/web/daemon.mjs +319 -0
  62. package/js/web/docs.mjs +24 -21
  63. package/js/web/graph.mjs +0 -0
  64. package/js/web/handler.mjs +442 -0
  65. package/js/web/jobs.mjs +21 -19
  66. package/js/web/routes.mjs +223 -0
  67. package/js/web/server.mjs +56 -987
  68. package/package.json +11 -3
  69. package/src/doctrines/README.md +1 -1
  70. package/themes/opencode/README.md +1 -1
  71. package/web/dist/assets/{About-DyK7yRy7.js → About-CPvIFZGh.js} +1 -1
  72. package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-z0uXdS7J.js} +1 -1
  73. package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-CEj5IJpF.js} +1 -1
  74. package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-BMR8fPuG.js} +1 -1
  75. package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-Bz5qE4r9.js} +1 -1
  76. package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-BV6a6t2l.js} +1 -1
  77. package/web/dist/assets/{Harnesses-DgRyOviS.js → Harnesses-C5Ag5PaQ.js} +1 -1
  78. package/web/dist/assets/{Laws-CWk3Mpri.js → Laws-BK07NKw9.js} +1 -1
  79. package/web/dist/assets/{Library-DXhTN9ju.js → Library-CiXN9sC3.js} +1 -1
  80. package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-DG6FjUz-.js} +1 -1
  81. package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-B2oSvdxH.js} +1 -1
  82. package/web/dist/assets/{Rules-eND7hJv9.js → Rules-DobVPd80.js} +1 -1
  83. package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DQPsvsn9.js} +1 -1
  84. package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-BhJ3HhdI.js} +1 -1
  85. package/web/dist/assets/{Themes-CoAWuIeM.js → Themes-BP38FBU9.js} +1 -1
  86. package/web/dist/assets/{activity-Dmb0FTAx.js → activity-D9mfv7pY.js} +1 -1
  87. package/web/dist/assets/index-BE226Gv6.css +1 -0
  88. package/web/dist/assets/{index-jrY4m9jP.js → index-BZ7cPLMI.js} +1 -1
  89. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  90. package/web/dist/assets/{index-Dg58ScBe.js → index-DGM5j2hB.js} +1 -1
  91. package/web/dist/index.html +2 -2
  92. package/web/src/pages/Laws.jsx +1 -1
  93. package/docs/declined.md +0 -200
  94. package/docs/limits.md +0 -329
  95. package/js/doctor.mjs +0 -1750
  96. package/js/emit.mjs +0 -1411
  97. package/web/dist/assets/index-B-mXquBZ.css +0 -1
  98. package/web/dist/assets/index-CH_Z725C.js +0 -12
package/js/emit.mjs DELETED
@@ -1,1411 +0,0 @@
1
- /**
2
- * The emit half, in Node — and the process boundary Python drives it across.
3
- *
4
- * `js/render.mjs` renders, `js/native.mjs` and `js/opencode.mjs` write the host-native
5
- * layers, `js/settings.mjs` edits the files the user co-owns; this module is the piece that
6
- * turns them into an install on disk and the CLI that a `build.py` emit spawns. It is a
7
- * translation of `_build_render.build` plus BOTH halves of `_build_emit.emit_opencode`,
8
- * `_build_global.emit_opencode_global` and `_build_global._emit_claude_core` — the last of
9
- * which is the shared engine behind six emits, so the four job kinds below cover all NINE.
10
- *
11
- * "ALL NINE" IS A MEASUREMENT HERE, NOT A CLAIM. It was a claim for two phases and it was
12
- * false: `emit_opencode_global` spawned Node zero times while four documents said it did,
13
- * because it was the one mode with no boundary cell able to contradict them.
14
- * `tests/test_seam_coverage.py` now counts `run_node` invocations per mode and pins the
15
- * table, and it refuses a mode that crosses without a cell in `tests/test_emit_boundary.py`
16
- * — so the sentence above cannot come back without the observation behind it.
17
- *
18
- * THE HANDOFF THIS MODULE WAS SHAPED BY. Python drove, one spawn per emit:
19
- *
20
- * the Python driver, one emit
21
- * +- spawn node js/emit.mjs <job.json>
22
- * | RENDER Node writes every file Geneseed owns wholesale
23
- * | WIRE Node merges the CLAUDE.md managed block, settings(.local).json
24
- * | and opencode.json — the files the USER co-owns
25
- * | Node returns { owned, stats, managed, stdout, stderr } as JSON on stdout
26
- * +- Python PRUNE old_owned - owned
27
- * +- Python MANIFEST owned + managed
28
- * +- Python VERIFY _settings_integrity_check
29
- *
30
- * WIRE joined the child at P3b and VERIFY deliberately did not. VERIFY runs after MANIFEST
31
- * and MANIFEST is Python, so with one spawn per emit there is no child left to run it in —
32
- * and that is worth having rather than merely tolerable: `_settings_integrity_check` never
33
- * writes, so every Claude-shaped emit now ends with PYTHON re-reading the settings file
34
- * NODE just wrote and checking it against the claims NODE just returned. Two
35
- * implementations of the wiring layer have to interoperate on a real file, on every build.
36
- *
37
- * `hookOpts` is the one job field this module cannot compute and must not default. It
38
- * carries the interpreter and entry point the hook shim bakes, and the child's own
39
- * `process.execPath` is *node* while the hooks it is wiring are still Python. Left
40
- * undefined, `hookShimBody` would write `"undefined" "undefined" %*` into the shim and every
41
- * hook in the install would be dead — so `hookPrefix` throws instead of defaulting.
42
- *
43
- * Python still drives because the runtime *is* Python and calls `build.emit_*` in-process
44
- * from doctor, web deploy, setup and rebuild-all; Node-as-driver would wrap each of those
45
- * in a subprocess. The seam is designed to collapse: the driver becomes `geneseed.mjs`, the
46
- * spawn becomes an import, and this job object becomes the function signature.
47
- *
48
- * STDOUT CARRIES THE PROTOCOL AND NOTHING ELSE, STRUCTURALLY.
49
- *
50
- * The generator prints progress to stdout, and so does this process's protocol — the same
51
- * stream. Rather than ask every future caller to remember which is which, `main` REPLACES
52
- * `process.stdout.write` and `process.stderr.write` with buffers for the whole run and
53
- * restores them only to emit the single JSON document. So:
54
- *
55
- * - a stray `console.log` anywhere in the render tree cannot corrupt the protocol; it
56
- * lands in `payload.stdout`, Python re-prints it, and the byte comparison against the
57
- * Python generator FAILS. Loud, not silent.
58
- * - Python re-emits both buffers through its own `print`, so the emitted bytes carry
59
- * Python's encoding behaviour (including its cp1252 failure modes) unchanged. The
60
- * alternative — letting Node inherit the streams — would have written UTF-8 where
61
- * Python writes the console's locale encoding, a divergence on exactly the machines
62
- * least able to report it.
63
- *
64
- * This matters beyond tidiness because the same modules reach hook paths at P3, where the
65
- * emitted git-gate and rule-gate signal their verdict as JSON on stdout and return 0 on
66
- * every path: one stray byte there turns a blocking gate into a silently permissive one.
67
- */
68
- import {
69
- existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync,
70
- } from 'node:fs';
71
- import { createHash } from 'node:crypto';
72
- import { pathToFileURL } from 'node:url';
73
- import path from 'node:path';
74
- import { destRel, renderAll, renderFile, SRC_DIR_TOKENS } from './render.mjs';
75
- import { writeNativeLayer, loadAgentOverrides } from './native.mjs';
76
- import {
77
- ensureAgentOverridesStub, writePrimaryAgent, writeCommandLayer, writePonytailCommand,
78
- writeTheme, writeColorThemes, copyPlugins, copyWorkflows, sourceReleaseVersion,
79
- } from './opencode.mjs';
80
- import {
81
- mergeOpencodeJson, mergeClaudeSettings, wireClaudeExcludes, unwireClaudeExcludes,
82
- unwireClaudeSettings, managedBlockWrite, managedBlockRemove,
83
- } from './settings.mjs';
84
- import { VERSION_MARKER } from './hosts.mjs';
85
- import {
86
- writeText, readText, copyFile, jsonDumpsIndent, parseJson, pyAscii, pyTruthy,
87
- } from './lib/fs.mjs';
88
- /** `_build_render.SRC_DIRS_MARKER`. */
89
- const SRC_DIRS_MARKER = '.geneseed-srcdirs.json';
90
- /**
91
- * `_build_render.OWNED_SRC_DIRS` — wiped and regenerated each run.
92
- *
93
- * `ontology` and `doctrines` are appended, not inserted: this array's iteration order is the
94
- * key order of the `.geneseed-srcdirs.json` marker it fills, and appending keeps the three
95
- * original keys where an existing install's marker already has them.
96
- *
97
- * They belong here for the same reason `laws` does — they are GENERATED, so an install must
98
- * not keep a copy the source no longer produces. Without them a build writes the dirs on
99
- * every run and never wipes them, which turns the doctrines toggle into a one-way switch:
100
- * disable a pack, rebuild, and its rendered file sits in the install forever, contradicting
101
- * the `Active packs:` line in the AGENT.md right beside it.
102
- */
103
- const OWNED_SRC_DIRS = ['laws', 'agents', 'skills', 'ontology', 'doctrines'];
104
-
105
- /**
106
- * The constitution dirs a LEAN emit re-ships in full text — see `shipLeanLaws`.
107
- *
108
- * Not all of `OWNED_SRC_DIRS`: `agents` and `skills` are already written as whole files by
109
- * the native layer, and only the three constitution dirs are inlined into AGENT.md in a
110
- * form (terse, or pack-filtered) that loses text the reader may still need.
111
- */
112
- const LEAN_FULL_TEXT_DIRS = ['laws', 'ontology', 'doctrines'];
113
-
114
- /**
115
- * `_build_core.CAPABILITY_LINK_RE` — the DEFAULT only.
116
- *
117
- * `cfg.capabilityLinkRe` overrides it and the driver always supplies one, for the same
118
- * reason `cfg.structure` exists: the constant is in `_OWNED` because a doctor test
119
- * redirects it to the pre-fix form and asserts the emit then renders dead links, and a
120
- * redirect that stops at the process boundary half-works silently. The literal stays as
121
- * the fallback for the parity harnesses, which drive this module in-process.
122
- */
123
- const CAPABILITY_LINK_RE =
124
- /\[([^\]]+)\]\((?:(?!https?:\/\/|\/)[A-Za-z0-9_.-]+\/)*(?:agents|skills)\/[A-Za-z0-9_-]+\.md\)/g;
125
-
126
- /** `_build_render._TMPL_SPEC_RE`. */
127
- const TMPL_SPEC_RE = /\{\{DIR_(AGENTS|SKILLS)\}\}\/([A-Za-z0-9_-]+)\.md/g;
128
-
129
- // ---------------------------------------------------------------------------
130
- // Seeded stubs. Written once into the bundle and NEVER overwritten — each holds
131
- // something the user owns. Duplicated from `_build_render.py` rather than read from a
132
- // shared file: the parity gate drives both sides, so drift surfaces as a failing cell
133
- // instead of as two copies rotting apart, and a shared file would be a third format to
134
- // keep in agreement. Backticks are escaped; a raw one would end the literal.
135
- // ---------------------------------------------------------------------------
136
-
137
- /** `_build_render.CONTEXT_STUB`. */
138
- const CONTEXT_STUB = {
139
- _comment: "Point the agent at this project's own documentation. Each entry: 'path' "
140
- + "(absolute, or relative to the repo root), 'load' ('eager' = read every "
141
- + "session for small always-relevant rules; 'lazy' = read only when the task "
142
- + "needs it), and 'description'. This file is host-specific — git-ignore it. "
143
- + 'The build creates it once, empty, and never overwrites it.',
144
- context: [],
145
- };
146
-
147
- /** `_build_render.WIKI_STUB`. */
148
- const WIKI_STUB = `\
149
- // Geneseed wiki.jsonc — declare your machine-wide knowledge base(s) here, typically
150
- // an Obsidian vault (AGENT.md: the Wiki section). Comments are allowed in this file
151
- // (JSONC). It is host-specific — never commit it. The build created it once, empty,
152
- // and will never overwrite it.
153
- //
154
- // Each wiki carries:
155
- // name a short label
156
- // path absolute root of the vault (use forward slashes, also on Windows)
157
- // description one line shown to the agent
158
- // entries notes OR folders to load: path relative to the vault root ("." =
159
- // the whole vault); load "eager" = read every session, "lazy" =
160
- // read on demand; a folder applies its mode to every note beneath
161
- // it, a file entry overrides its folder, "exclude" prunes
162
- // conventions the vault's authoring-rules note — read before the first write
163
- // inbox drop folder for notes the agent cannot confidently file
164
- // protected folders the agent must never write to (guard-enforced on OpenCode)
165
- //
166
- // Example — copy this object into the "wikis" array below and edit:
167
- // {
168
- // "name": "Brain",
169
- // "path": "C:/Users/me/Documents/Brain",
170
- // "description": "my machine-wide knowledge base",
171
- // "entries": [
172
- // { "path": "ARCHITECTURE.md", "load": "eager", "description": "the root map" },
173
- // { "path": ".", "load": "lazy" }
174
- // ],
175
- // "conventions": "STYLE.md",
176
- // "inbox": "Inbox/",
177
- // "protected": ["Journal/"]
178
- // }
179
- {
180
- "wikis": []
181
- }
182
- `;
183
-
184
- /** `_build_render.RULES_FILE`. */
185
- const RULES_FILE = 'user-rules.md';
186
-
187
- /** `_build_render.RULES_STUB`. */
188
- const RULES_STUB = `\
189
- # User rules
190
-
191
- Your own standing rules. The agent obeys every rule in this file exactly as it
192
- obeys the laws in AGENT.md §1 — always in force, in every task. A user rule may
193
- *tighten* a law, never repeal one: where they conflict, the law wins.
194
-
195
- Geneseed seeded this file once and will never overwrite it. The laws file is
196
- regenerated on every update — never edit that one; this file is where your own
197
- governance lives, and it survives updates, reinstalls, and theme switches.
198
- Unlike \`context.json\`, it is safe to commit: project rules are meant to travel
199
- with the repo and bind the whole team.
200
-
201
- Keep the set small — every rule here is loaded every session, and a bloated
202
- rule set dilutes the rules that matter. A durable fact belongs in memory, a
203
- pointer to documentation belongs in \`context.json\`; only a standing *behaviour*
204
- belongs here.
205
-
206
- Format — one rule per \`## R<n> — Title\` heading, an optional metadata line in
207
- parentheses, then the rule stated plainly:
208
-
209
- ## R1 — No emoji in commit subjects
210
- (scope: project | source: written by hand)
211
- Commit subjects are plain text; no emoji, no decorative unicode.
212
-
213
- \`trial until: YYYY-MM-DD\` in the metadata line marks a rule on probation —
214
- usually one promoted from a recurring memory. Review it by that date, then
215
- graduate it (remove the marker) or demote it back to memory.
216
- `;
217
-
218
- /** `_build_render.EXCLUDES_FILE`. */
219
- export const EXCLUDES_FILE = 'excludes.json';
220
-
221
- /**
222
- * `_build_render.EXCLUDES_STUB` — one long line, exactly as Python spells it.
223
- *
224
- * Exported since P5c because `js/excludes.mjs` reads it as the single source of the shape a
225
- * missing or corrupt `excludes.json` degrades to, exactly as `_harness_exclude._read_excludes`
226
- * reads `build.EXCLUDES_STUB` rather than spelling `{"excludes": []}` a second time.
227
- */
228
- export const EXCLUDES_STUB = `\
229
- {
230
- "_comment": "Folders where this global Geneseed install goes dormant (hooks silent, preamble suppressed). Managed by \`harness exclude add|remove|list\`; safe to edit by hand. Paths are absolute.",
231
- "excludes": []
232
- }
233
- `;
234
-
235
- /**
236
- * `_build_global._BOB_RULES_STUB` — the workspace shadow stub a PROJECT Bob emit ships.
237
- *
238
- * Exported since P5c: `harness exclude add` writes the SAME stub into an excluded repo, and
239
- * decides ownership on the next `remove` by comparing the file's content to it byte for byte
240
- * — so a second spelling here would orphan every stub the other writer created.
241
- */
242
- export const BOB_RULES_STUB = `\
243
- <!-- geneseed: workspace shadow stub -->
244
- This project's Geneseed instructions are the repo-root \`AGENTS.md\`, which Bob
245
- auto-loads. This file exists only to shadow the same-named global Geneseed rules
246
- file (\`~/.bob/rules/geneseed.md\`) so the global preamble does not stack on top of
247
- the project's own. Follow the root \`AGENTS.md\`.
248
- `;
249
-
250
- /** `_build_render.PROFILE_FILE`. */
251
- const PROFILE_FILE = 'PROFILE.md';
252
-
253
- /** `_build_render.PROFILE_STUB`. */
254
- const PROFILE_STUB = `\
255
- # Your profile
256
-
257
- Who you are and how you like to work — so the agent can meet you where you are
258
- instead of guessing. Every section is optional; delete what you don't want to
259
- share, add what you do. Geneseed seeded this file once and will never overwrite
260
- it, so it survives updates, reinstalls, and theme switches.
261
-
262
- This is *identity, not rules*. A standing rule the agent must obey belongs in
263
- \`user-rules.md\` (AGENT.md §1); this file only colours how the agent works — tone,
264
- depth, defaults. Where the two ever seem to conflict, the rule wins: precedence
265
- is laws, then user-rules, then this profile.
266
-
267
- ## Who I am
268
-
269
- Role, domains you know deeply, domains you're learning. What you're usually here
270
- to do.
271
-
272
- ## How I work
273
-
274
- Habits, tools, and environment worth knowing — your stack, your shell, the
275
- conventions you hold to, the things that reliably annoy you.
276
-
277
- ## Register preferences
278
-
279
- How you like answers pitched: terse or expansive, teach-me or just-do-it, how
280
- much pushback you want, which language(s) you think in.
281
- `;
282
-
283
- /** `_build_render.BUNDLE_GITIGNORE`. */
284
- const BUNDLE_GITIGNORE = `\
285
- # Generated by Geneseed. The rendered harness — AGENT.md, the laws, agents, and
286
- # skills — is safe to commit; track it if you want it versioned with your project.
287
- # Only the host-specific / personal files below are kept out of git.
288
-
289
- # Project-context manifest — may hold private paths; never commit.
290
- context.json
291
-
292
- # Knowledge-base manifest — holds private machine paths; never commit.
293
- # (wiki.json is the legacy name from earlier seeds.)
294
- wiki.jsonc
295
- wiki.json
296
-
297
- # Per-agent model/temperature overrides — host-specific; never commit.
298
- agent-overrides.json
299
-
300
- # Which theme + emit mode + footprint this host last built (local build state, must not travel).
301
- .geneseed-theme
302
- .geneseed-emit
303
- .geneseed-footprint
304
- .geneseed-srcdirs.json
305
-
306
- # memory/ keeps its own .gitignore so learned facts stay on this machine.
307
- # notebook/ keeps its own .gitignore so the agent's own files stay on this machine.
308
- `;
309
-
310
- // ---------------------------------------------------------------------------
311
- // Small filesystem predicates. Python spells these `Path.is_file()` / `is_dir()`, both
312
- // of which answer False for a missing path rather than raising; `statSync` throws.
313
- // ---------------------------------------------------------------------------
314
-
315
- function isFile(p) {
316
- try { return statSync(p).isFile(); } catch { return false; }
317
- }
318
-
319
- function isDir(p) {
320
- try { return statSync(p).isDirectory(); } catch { return false; }
321
- }
322
-
323
- /** `dict.get(key)` with Python's semantics — OWN properties only.
324
- *
325
- * The same hazard `js/settings.mjs` and `js/native.mjs` both spell out: the keys here come
326
- * out of a manifest the user can edit, and `old['constructor']` hands back
327
- * `Object.prototype`'s member where Python's `.get` returns None. */
328
- function get(obj, key) {
329
- return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
330
- }
331
-
332
- function isDict(v) {
333
- return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
334
- }
335
-
336
- /** `Path.rglob("*")` restricted to files, skipping `__pycache__` as Python's callers do. */
337
- function rglobFiles(root, out = []) {
338
- for (const name of readdirSync(root)) {
339
- if (name === '__pycache__') continue;
340
- const full = path.join(root, name);
341
- if (isDir(full)) rglobFiles(full, out);
342
- else out.push(full);
343
- }
344
- return out;
345
- }
346
-
347
- /**
348
- * `Path.rglob("*")` restricted to files, keeping EVERYTHING — including `__pycache__`.
349
- *
350
- * Not a duplicate of `rglobFiles` by accident: `source_fingerprint`'s Python filters
351
- * `__pycache__` and the legacy-store migration in `_global_memory` does not, so a single
352
- * walk would be wrong for one caller either way. A user's legacy bundle really can carry
353
- * one (the harness ships `.py` skill scripts), and copying it or not is a byte difference
354
- * the two runtimes would disagree on silently.
355
- */
356
- function rglobAllFiles(root, out = []) {
357
- for (const name of readdirSync(root)) {
358
- const full = path.join(root, name);
359
- if (isDir(full)) rglobAllFiles(full, out);
360
- else out.push(full);
361
- }
362
- return out;
363
- }
364
-
365
- /** `p.relative_to(base).as_posix()`. */
366
- function relPosix(base, p) {
367
- return path.relative(base, p).split(path.sep).join('/');
368
- }
369
-
370
- // ---------------------------------------------------------------------------
371
- // Version identity
372
- // ---------------------------------------------------------------------------
373
-
374
- /**
375
- * `_build_render.source_fingerprint`.
376
- *
377
- * Sorted by the ROOT-relative POSIX path as a plain string — NOT `comparePaths`. Python
378
- * sorts by an explicit `key=` here rather than by `Path` objects, so this one sort is
379
- * case-SENSITIVE on every platform while `sorted(SRC.rglob("*"))` in `render_all` is not.
380
- * Two sorts of the same files, two different orders, and only one of them is observable
381
- * in the output; reproducing the wrong one changes the fingerprint of every install.
382
- */
383
- export function sourceFingerprint(cfg) {
384
- const h = createHash('sha256');
385
- const files = [];
386
- for (const r of [cfg.src, cfg.themes, cfg.pluginSrc, cfg.workflowSrc]) {
387
- if (isDir(r)) files.push(...rglobFiles(r));
388
- }
389
- const keyed = files.map((p) => [relPosix(cfg.root, p), p]);
390
- keyed.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
391
- for (const [rel, p] of keyed) {
392
- h.update(Buffer.from(`${rel}\0`, 'utf8'));
393
- h.update(readFileSync(p));
394
- h.update(Buffer.from([0]));
395
- }
396
- return h.digest('hex').slice(0, 12);
397
- }
398
-
399
- /**
400
- * `_build_render.read_version` — the FINGERPRINT token, which is a different name and a
401
- * different value from `read_release_version` right below it. `harness version` and the
402
- * status panel both compare this one against `sourceFingerprint`.
403
- *
404
- * `txt.split()[0] if txt else None`: a marker that is empty or whitespace-only reads as no
405
- * version at all, so the caller's candidate walk carries on past it rather than stopping.
406
- */
407
- export function readVersion(dir) {
408
- let txt;
409
- try {
410
- txt = readText(path.join(dir, VERSION_MARKER)).trim();
411
- } catch {
412
- return null;
413
- }
414
- // `str.split()` with no argument splits on runs of whitespace AND drops the leading
415
- // empties; `String.split(/\s+/)` does not, so an indented marker would yield ''.
416
- const first = txt.split(/\s+/).filter(Boolean)[0];
417
- return first === undefined ? null : first;
418
- }
419
-
420
- /** `_build_render.read_release_version`. */
421
- function readReleaseVersion(out) {
422
- let txt;
423
- try {
424
- txt = readText(path.join(out, VERSION_MARKER)).trim();
425
- } catch {
426
- // Python catches OSError only. A marker that is not valid UTF-8 raises there and
427
- // degrades to U+FFFD here (Node's decoder substitutes rather than reporting) — the
428
- // same asymmetry already recorded for `agent-overrides.json`, and on no covered path.
429
- return null;
430
- }
431
- const m = /\[release ([^\]]+)\]/.exec(txt);
432
- return m ? m[1] : null;
433
- }
434
-
435
- /** `_build_core._parse_version_tuple`. */
436
- function parseVersionTuple(v) {
437
- const parts = v.trim().split('.');
438
- if (!parts.length) return null;
439
- const out = [];
440
- for (const p of parts) {
441
- // `str.isdigit()`, not `Number.isInteger(+p)`: '' and '1e2' and ' 1' are all
442
- // non-digit to Python, and `+p` accepts every one of them.
443
- if (!/^[0-9]+$/.test(p)) return null;
444
- out.push(Number(p));
445
- }
446
- return out;
447
- }
448
-
449
- /**
450
- * `_build_core.version_is_newer` — null when either side fails to parse.
451
- *
452
- * EXPORTED FOR ITS GATE, and the argument belongs here rather than in the test. Its only
453
- * caller is `warnIfDowngrade`, one line down, whose whole observable behaviour is "print a
454
- * warning or say nothing" — so through the emit face `false` and `null` are the SAME
455
- * observation, and six of the nine claims this function makes are invisible. That numeric
456
- * comparison beats lexical (`1.10.0` > `1.9.0`), that a short tuple is zero-padded on the
457
- * right (`1.2` == `1.2.0`), and that an unparseable side answers `null` rather than `false`
458
- * are each a decision with a wrong answer that no cell and no emit can distinguish.
459
- *
460
- * Nothing else changes: it is the same function, called the same way, from the same one
461
- * place. See `tests/unit/lifecycle.test.mjs`.
462
- */
463
- export function versionIsNewer(a, b) {
464
- const ta = parseVersionTuple(a);
465
- const tb = parseVersionTuple(b);
466
- if (ta === null || tb === null) return null;
467
- const width = Math.max(ta.length, tb.length);
468
- for (let i = 0; i < width; i++) {
469
- const x = ta[i] ?? 0;
470
- const y = tb[i] ?? 0;
471
- if (x !== y) return x > y;
472
- }
473
- return false;
474
- }
475
-
476
- /** `_build_render._warn_if_downgrade` — STDOUT, matching the Python. */
477
- function warnIfDowngrade(cfg, out) {
478
- const deployed = readReleaseVersion(out);
479
- if (deployed === null) return;
480
- const current = sourceReleaseVersion(cfg);
481
- if (versionIsNewer(deployed, current)) {
482
- process.stdout.write(`[geneseed] WARN: installing older Geneseed ${current} over newer `
483
- + `${deployed} at ${out} — did you forget git pull?\n`);
484
- }
485
- }
486
-
487
- /** `_build_render.write_version`. */
488
- function writeVersion(cfg, out) {
489
- warnIfDowngrade(cfg, out);
490
- const fp = sourceFingerprint(cfg);
491
- const release = sourceReleaseVersion(cfg);
492
- // `datetime.date.today().isoformat()` — the LOCAL date. `toISOString()` is UTC and
493
- // would stamp tomorrow's date for anyone east of Greenwich after their evening.
494
- //
495
- // No gate can catch this one, and it is recorded rather than left to be discovered:
496
- // both runtimes read their own clock at almost the same instant, so a UTC-vs-local
497
- // mutation produces identical bytes except during the hour the two dates disagree.
498
- // Mutating it away stays green here for a reason no cell can remove — freezing the
499
- // clock across a process boundary is not something the harness can do — which is
500
- // exactly why the choice is spelled out instead of relying on the comparison.
501
- const d = new Date();
502
- const today = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-`
503
- + `${String(d.getDate()).padStart(2, '0')}`;
504
- writeText(path.join(out, VERSION_MARKER),
505
- `${fp} (built ${today}) [release ${release}]\n`);
506
- return fp;
507
- }
508
-
509
- // ---------------------------------------------------------------------------
510
- // Source completeness
511
- // ---------------------------------------------------------------------------
512
-
513
- /** `_build_render._missing_referenced_specs` — scans the TEMPLATE, not the render. */
514
- function missingReferencedSpecs(cfg) {
515
- let tmpl;
516
- try {
517
- tmpl = readText(path.join(cfg.src, 'AGENT.md.tmpl'));
518
- } catch {
519
- return [];
520
- }
521
- const missing = new Set();
522
- for (const [, kind, name] of tmpl.matchAll(TMPL_SPEC_RE)) {
523
- const dir = kind.toLowerCase();
524
- if (name !== '_template' && !isFile(path.join(cfg.src, dir, `${name}.md`))) {
525
- missing.add(`${dir}/${name}.md`);
526
- }
527
- }
528
- return [...missing].sort();
529
- }
530
-
531
- /**
532
- * `_build_render.assert_source_complete`.
533
- *
534
- * Python raises `SystemExit(1)` after writing to stderr. Here the message goes to the
535
- * captured stderr buffer and the throw carries the exit code, which the driver turns
536
- * back into a `SystemExit(1)` on the Python side — so the caller sees the same stream
537
- * bytes and the same exit status whichever runtime rendered.
538
- */
539
- function assertSourceComplete(cfg, context = '') {
540
- const missing = missingReferencedSpecs(cfg);
541
- if (!missing.length) return;
542
- const where = context ? ` (${context})` : '';
543
- process.stderr.write(
544
- `[geneseed][E-INCOMPLETE] ✗ source is incomplete${where}: AGENT.md references `
545
- + `${missing.length} spec(s) with no file under src/:\n`
546
- + missing.map((m) => ` - ${m}\n`).join('')
547
- + '[geneseed] ✗ Refusing to emit — a partial source would write dead links '
548
- + 'and a global re-emit would delete the good copies in an existing install.\n'
549
- + '[geneseed] ✗ Re-sync the source (./geneseed update, or re-run the upgrade) '
550
- + 'and try again.\n');
551
- const e = new Error('source is incomplete');
552
- e.exitCode = 1;
553
- throw e;
554
- }
555
-
556
- // ---------------------------------------------------------------------------
557
- // Seeded stubs — write-once, never overwrite
558
- // ---------------------------------------------------------------------------
559
-
560
- /** `_build_render.ensure_context_stub`. */
561
- function ensureContextStub(out) {
562
- const dest = path.join(out, 'context.json');
563
- if (!existsSync(dest)) {
564
- writeText(dest, `${jsonDumpsIndent(CONTEXT_STUB, { ensureAscii: false })}\n`);
565
- }
566
- }
567
-
568
- /** `_build_render.ensure_wiki_stub` — a legacy `wiki.json` counts as present. */
569
- function ensureWikiStub(out) {
570
- const dest = path.join(out, 'wiki.jsonc');
571
- if (!existsSync(dest) && !existsSync(path.join(out, 'wiki.json'))) writeText(dest, WIKI_STUB);
572
- }
573
-
574
- /** `_build_render.ensure_rules_stub`. */
575
- function ensureRulesStub(out) {
576
- const dest = path.join(out, RULES_FILE);
577
- if (!existsSync(dest)) writeText(dest, RULES_STUB);
578
- }
579
-
580
- /** `_build_render.ensure_profile_stub`. */
581
- function ensureProfileStub(out) {
582
- const dest = path.join(out, PROFILE_FILE);
583
- if (!existsSync(dest)) writeText(dest, PROFILE_STUB);
584
- }
585
-
586
- /**
587
- * `_build_render.ensure_excludes_stub` — the sovereign-repo list, seeded once and NEVER
588
- * overwritten. Reachable only from the Claude-shaped emits, which is why it arrived with
589
- * `emitClaudeRender` rather than with the bundle stubs beside it.
590
- */
591
- function ensureExcludesStub(out) {
592
- const dest = path.join(out, EXCLUDES_FILE);
593
- if (!existsSync(dest)) writeText(dest, EXCLUDES_STUB);
594
- }
595
-
596
- /** `_build_render.ensure_bundle_gitignore`. */
597
- function ensureBundleGitignore(out) {
598
- const dest = path.join(out, '.gitignore');
599
- if (!existsSync(dest)) writeText(dest, BUNDLE_GITIGNORE);
600
- }
601
-
602
- /** `_build_render.ensure_memory_index` — only inside an EXISTING store dir. */
603
- function ensureMemoryIndex(memDir) {
604
- if (!isDir(memDir)) return;
605
- const idx = path.join(memDir, 'MEMORY.md');
606
- if (!existsSync(idx)) writeText(idx, '# Memory Index\n');
607
- }
608
-
609
- /** `_build_render.ensure_notebook_index`. */
610
- function ensureNotebookIndex(nbDir) {
611
- if (!isDir(nbDir)) return;
612
- const idx = path.join(nbDir, 'NOTEBOOK.md');
613
- if (!existsSync(idx)) writeText(idx, '# Notebook Index\n');
614
- }
615
-
616
- // ---------------------------------------------------------------------------
617
- // The owned-dir marker
618
- // ---------------------------------------------------------------------------
619
-
620
- /** `_build_render._read_prior_src_dirs` — `{}` on anything unreadable or not an object. */
621
- function readPriorSrcDirs(out) {
622
- let data;
623
- try {
624
- data = parseJson(readText(path.join(out, SRC_DIRS_MARKER)));
625
- } catch {
626
- return {};
627
- }
628
- return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
629
- }
630
-
631
- /** `_build_render._write_src_dirs_marker` — temp + replace. */
632
- function writeSrcDirsMarker(out, resolved) {
633
- const tmp = path.join(out, `${SRC_DIRS_MARKER}.tmp`);
634
- writeText(tmp, `${jsonDumpsIndent(resolved)}\n`);
635
- renameSync(tmp, path.join(out, SRC_DIRS_MARKER));
636
- }
637
-
638
- /**
639
- * The guard on the prune of a RENAMED owned dir — `build`'s one destructive path driven
640
- * by file content rather than by code.
641
- *
642
- * `.geneseed-srcdirs.json` is a plain file a user (or another tool) can edit, and the
643
- * name in it is joined onto `out` and handed to a recursive delete. `".."` would take out
644
- * the bundle's PARENT, an absolute path replaces `out` entirely under Python's
645
- * `Path.__truediv__`, and `"a/b"` reaches into nested content. Only a plain
646
- * single-segment name resolving directly under `out` is ever deleted.
647
- */
648
- function safePriorDirName(out, priorName) {
649
- return typeof priorName === 'string'
650
- && priorName !== '.' && priorName !== '..'
651
- && !path.isAbsolute(priorName)
652
- && path.basename(priorName) === priorName
653
- && path.dirname(path.resolve(out, priorName)) === path.resolve(out);
654
- }
655
-
656
- // ---------------------------------------------------------------------------
657
- // build
658
- // ---------------------------------------------------------------------------
659
-
660
- /**
661
- * `_build_render.build` — render the bundle into `out`.
662
- *
663
- * `out` arrives as the STRING Python already resolved, and is echoed into the progress
664
- * lines verbatim: Python interpolates `str(Path)`, which is backslash-separated on
665
- * Windows, and re-deriving it here would differ by separator in every message.
666
- */
667
- /**
668
- * THE PHASE BOUNDARY MARKER — T8, and the whole of what this port owes
669
- * `tests/test_emit_phase_order.py`.
670
- *
671
- * Every emit runs its stages in one order: RENDER* → WIRE* → PRUNE → MANIFEST → VERIFY. RENDER
672
- * writes files Geneseed owns wholesale; WIRE reconciles files the USER co-owns (the
673
- * CLAUDE.md/AGENTS.md managed block, settings(.local).json, opencode.json) by reading what is
674
- * already there and merging into it. WIRE must precede MANIFEST because wiring is what fills the
675
- * `managed` record the manifest writes, and every teardown path unwires exactly what was wired.
676
- *
677
- * WHY A RUNTIME LOG AND NOT A SOURCE WALK. The reference gated this with five `ast` walks over
678
- * its own generator, and could, because it keeps `_claude_render` / `_claude_wire` as two
679
- * dispatchers and therefore two statements at every call site — a shape its own docstring calls
680
- * "not incidental, it is what leaves this walker something to check". THIS PORT DOES NOT HAVE
681
- * THAT SHAPE: `claudeWire` is a real function but the two OpenCode emits inline their merge
682
- * (`emitOpencodeRender`, `emitOpencodeGlobalRender`), and PRUNE/MANIFEST/VERIFY are not in this
683
- * module at all — they are in `bin/geneseed.mjs`'s driver bodies. A walker would have to span two
684
- * files and would still be reading source rather than watching a run.
685
- *
686
- * So the order is made OBSERVABLE instead, which is strictly the better gate: it reports what
687
- * actually executed, in the order it executed, including the branch a given host took.
688
- *
689
- * ⚠ BEHIND AN ENV VAR THE THREE REPLAYERS DO NOT SET, and that is the entire safety argument.
690
- * `cellEnv` clears every `GENESEED_*` knob by prefix, so no recorded cell can turn this on and
691
- * the emit corpus cannot move by one byte. It writes to STDERR, never stdout: stdout is the
692
- * decision channel every Geneseed hook signals through, and a stray byte there turns a blocking
693
- * gate into a silently permissive one.
694
- *
695
- * Read at CALL time, not at import, so one process can drive several emits under it.
696
- */
697
- export function phaseLog(phase) {
698
- if (!process.env.GENESEED_PHASE_LOG) return;
699
- process.stderr.write(`[geneseed:phase] ${phase}\n`);
700
- }
701
-
702
- export function build(cfg, themeName, out, { footprint = 'full', nativeCatalog = false } = {}) {
703
- phaseLog('RENDER');
704
- const { theme, items } = renderAll(cfg, themeName, { footprint, nativeCatalog });
705
- assertSourceComplete(cfg, `theme '${themeName}'`);
706
- mkdirSync(out, { recursive: true });
707
-
708
- // Wipe the owned dirs ONLY inside an established bundle: a first render into an
709
- // arbitrary repo (`--out .`) must never delete an agents/ or skills/ the USER owns.
710
- const isBundle = isFile(path.join(out, '.geneseed-theme')) || isFile(path.join(out, VERSION_MARKER));
711
- const priorSrcDirs = isBundle ? readPriorSrcDirs(out) : {};
712
- const resolvedSrcDirs = {};
713
- for (const srcDir of OWNED_SRC_DIRS) {
714
- // `theme.get(token, srcDir)` — `??` agrees with it except for an explicit JSON null,
715
- // where Python would go on to join `None` onto a path and raise. No theme has one.
716
- const dirname = theme[SRC_DIR_TOKENS[srcDir]] ?? srcDir;
717
- resolvedSrcDirs[srcDir] = dirname;
718
- const managed = path.join(out, dirname);
719
-
720
- // `pyTruthy`, not `priorName &&`: the marker is user-editable JSON, and Python's
721
- // `if prior_name` is false for `[]`, `{}` and `0` where JS's is true for the first
722
- // two. Getting that wrong warns about a value Python skips in silence — a stderr
723
- // divergence reachable only from a hand-edited file, which is the one place nobody
724
- // would look for it.
725
- const priorName = priorSrcDirs[srcDir];
726
- if (isBundle && pyTruthy(priorName) && priorName !== dirname) {
727
- if (safePriorDirName(out, priorName)) {
728
- const stale = path.join(out, priorName);
729
- if (isDir(stale)) rmSync(stale, { recursive: true, force: true });
730
- } else {
731
- process.stderr.write('[geneseed] WARN: ignoring suspicious prior dir name '
732
- + `${pyAscii(priorName)} recorded in ${SRC_DIRS_MARKER} - not pruned.\n`);
733
- }
734
- }
735
-
736
- if (!isDir(managed)) continue;
737
- if (isBundle) {
738
- rmSync(managed, { recursive: true, force: true });
739
- } else {
740
- // STDOUT, and ASCII-only: this print crashed with UnicodeEncodeError on a cp1252
741
- // Windows console when it carried a warning-sign emoji.
742
- process.stdout.write(`[geneseed] WARN: ${managed} already exists and ${out} is not a `
743
- + 'Geneseed bundle — keeping it; rendered files merge into it.\n');
744
- }
745
- }
746
-
747
- const nbDirname = theme[SRC_DIR_TOKENS.notebook] ?? 'notebook';
748
- for (const { rel, text, src } of items) {
749
- const dest = path.join(out, rel);
750
- mkdirSync(path.dirname(dest), { recursive: true });
751
- // The notebook is the agent's sovereign space: seeded files are written once and
752
- // never re-emitted, so the agent may rewrite its own rules. Only `.gitignore` is
753
- // re-asserted — the one law it cannot lift.
754
- const parts = rel.split('/');
755
- if (parts[0] === nbDirname && path.basename(rel) !== '.gitignore' && existsSync(dest)) {
756
- continue;
757
- }
758
- if (text !== null) writeText(dest, text);
759
- else copyFile(src, dest);
760
- }
761
-
762
- writeText(path.join(out, '.geneseed-theme'), `${themeName}\n`);
763
- writeVersion(cfg, out);
764
- writeSrcDirsMarker(out, resolvedSrcDirs);
765
- ensureContextStub(out);
766
- ensureWikiStub(out);
767
- ensureRulesStub(out);
768
- ensureProfileStub(out);
769
- ensureBundleGitignore(out);
770
- ensureMemoryIndex(path.join(out, theme[SRC_DIR_TOKENS.memory] ?? 'memory'));
771
- ensureNotebookIndex(path.join(out, theme[SRC_DIR_TOKENS.notebook] ?? 'notebook'));
772
- process.stdout.write(`[geneseed] built theme '${themeName}' -> ${out} (${items.length} files)\n`);
773
- }
774
-
775
- // ---------------------------------------------------------------------------
776
- // emit_opencode — the RENDER stage only
777
- // ---------------------------------------------------------------------------
778
-
779
- /**
780
- * `_build_emit._strip_capability_links` — `re.sub(r"\1")`, so the pattern needs `g`.
781
- *
782
- * Exported for `js/doctor.mjs`: `_rendered_problems` applies the same de-linking before
783
- * comparing an OpenCode bundle's AGENT.md against a fresh render, because the emit puts
784
- * that difference back by design and a check that did not would report the file stale on
785
- * every run forever.
786
- */
787
- export function stripCapabilityLinks(cfg, text) {
788
- const re = cfg.capabilityLinkRe
789
- ? new RegExp(cfg.capabilityLinkRe, 'g')
790
- : CAPABILITY_LINK_RE;
791
- return text.replace(re, '$1');
792
- }
793
-
794
- /**
795
- * The RENDER half of `_build_emit.emit_opencode`: everything up to (not including) the
796
- * `_merge_opencode_json` call. WIRE, PRUNE and MANIFEST stay in Python.
797
- *
798
- * `oldOwned` and `manifestExisted` arrive in the job rather than being read here. The
799
- * manifest is Python's stage — it writes it, prunes against it, and now reads it too, so
800
- * there is one owner of the file instead of two processes racing to interpret it.
801
- *
802
- * `nativeCatalog` likewise arrives decided. It is `HOSTS['opencode']['native_catalog']`,
803
- * and the HOSTS registry stays Python this phase; passing the decision keeps the registry
804
- * in exactly one place rather than duplicating it for one boolean.
805
- */
806
- export function emitOpencodeRender(cfg, job) {
807
- phaseLog('RENDER');
808
- const {
809
- theme: _theme, out, root, footprint, nativeCatalog, oldOwned, manifestExisted, agentPath,
810
- } = job;
811
- const oc = path.join(root, '.opencode');
812
-
813
- build(cfg, _theme, out, { footprint, nativeCatalog });
814
-
815
- // OpenCode loads agents/skills natively, so strip AGENT.md's per-row spec links to
816
- // plain names (the portable build keeps them). A deliberate de-link, not a fix.
817
- const agentMd = path.join(out, 'AGENT.md');
818
- if (isFile(agentMd)) writeText(agentMd, stripCapabilityLinks(cfg, readText(agentMd)));
819
-
820
- const owned = [];
821
- const { theme, items } = renderAll(cfg, _theme);
822
-
823
- ensureAgentOverridesStub(cfg, out);
824
- const overrides = loadAgentOverrides(out);
825
-
826
- const { nAgents, nSkills, written } = writeNativeLayer(
827
- items, path.join(oc, 'agents'), path.join(oc, 'skills'), overrides,
828
- { host: 'opencode', oldOwned, cfg: oc, manifestExisted, theme, src: cfg.src });
829
- for (const p of written) owned.push(relPosix(oc, p));
830
-
831
- const primary = writePrimaryAgent(cfg, path.join(oc, 'agents'), overrides);
832
- if (primary) owned.push(relPosix(oc, primary));
833
-
834
- const commands = writeCommandLayer(cfg, items, path.join(oc, 'command'));
835
- commands.push(writePonytailCommand(path.join(oc, 'command'))); // always-on /ponytail
836
- for (const p of commands) owned.push(relPosix(oc, p));
837
-
838
- owned.push(relPosix(oc, writeTheme(path.join(oc, 'themes'), _theme, theme)));
839
- for (const p of writeColorThemes(cfg, path.join(oc, 'themes'))) owned.push(relPosix(oc, p));
840
-
841
- const nPlugins = copyPlugins(cfg, path.join(oc, 'plugins'), owned);
842
- const nWorkflows = copyWorkflows(cfg, path.join(oc, 'workflows'), owned);
843
-
844
- // WIRE — the one file of this emit the user co-owns. `agentPath` arrives decided
845
- // (`_rel_under` is the Python side by design), and only the BASENAME is consumed
846
- // downstream: `opencode.json`, or the `.jsonc` sibling when that is what is on disk.
847
- phaseLog('WIRE');
848
- const cfgName = path.basename(mergeOpencodeJson(path.join(root, 'opencode.json'), agentPath,
849
- cfg.doctrines, cfg.excludeRules));
850
-
851
- return {
852
- owned,
853
- stats: {
854
- nAgents, nSkills, nPlugins, nWorkflows, nCommands: commands.length, primary: !!primary,
855
- },
856
- cfgName,
857
- };
858
- }
859
-
860
- // ---------------------------------------------------------------------------
861
- // The global-store helpers, shared by the `opencode-global` and `claude` jobs
862
- // ---------------------------------------------------------------------------
863
-
864
- /**
865
- * `_build_global._global_memory` — ensure `<cfg>/memory` exists, without ever touching a
866
- * store that already holds something.
867
- *
868
- * The three outcomes are the returned status string, which the emit prints, so they are
869
- * compared through stdout as well as through the tree. Python's `theme` parameter is
870
- * dropped: the store dir is ALWAYS the classic English `memory/`, never themed (the
871
- * OpenCode config dir uses fixed names), so the argument was never read.
872
- *
873
- * The migration branch copies arbitrary USER files out of a legacy bundle, which is the
874
- * reason this is not a plain translation: everything it touches is the user's, and the
875
- * only thing keeping it safe is that it runs at all only when the destination is empty.
876
- */
877
- function globalMemory(cfgDir, items, legacy, srcRoot) {
878
- const memName = 'memory';
879
- const memDir = path.join(cfgDir, memName);
880
- if (isDir(memDir) && readdirSync(memDir).length) return `kept ${memName}/`;
881
- mkdirSync(memDir, { recursive: true });
882
- if (legacy) {
883
- // `dict.fromkeys([mem_name, "memory", "anamnesis"])` — de-duplicated, order kept.
884
- // `mem_name` is the literal 'memory' and the Python docstring beside it says the
885
- // store name is NEVER themed, so the first two entries always collapse and
886
- // `anamnesis` — an older themed install's name — is the only live alias. The dedupe
887
- // is what keeps the duplicate harmless rather than copying the same dir twice.
888
- for (const nm of [...new Set([memName, 'memory', 'anamnesis'])]) {
889
- const src = path.join(legacy, nm);
890
- if (isDir(src) && readdirSync(src).length) {
891
- for (const f of rglobAllFiles(src)) {
892
- const dest = path.join(memDir, path.relative(src, f));
893
- mkdirSync(path.dirname(dest), { recursive: true });
894
- copyFile(f, dest);
895
- }
896
- return `migrated ${nm}/ -> ${memName}/`;
897
- }
898
- }
899
- }
900
- for (const { text, src } of items) {
901
- const sp = relPosix(srcRoot, src).split('/');
902
- if (sp[0] === 'memory' && sp.length > 1) {
903
- // `destRel`, because this seeds from the SOURCE path and not from the item's `rel`
904
- // (which is themed, and `memory/` never is). Without it the store would be seeded
905
- // with the on-disk name `gitignore` — see `js/render.mjs`'s `destRel`.
906
- const dest = path.join(memDir, destRel(path.join(...sp.slice(1))));
907
- mkdirSync(path.dirname(dest), { recursive: true });
908
- if (text !== null) writeText(dest, text);
909
- else copyFile(src, dest);
910
- }
911
- }
912
- return `seeded ${memName}/`;
913
- }
914
-
915
- /** `_build_global._global_notebook` — the same shape, with no `anamnesis` alias. */
916
- function globalNotebook(cfgDir, items, legacy, srcRoot) {
917
- const nbName = 'notebook';
918
- const nbDir = path.join(cfgDir, nbName);
919
- if (isDir(nbDir) && readdirSync(nbDir).length) return `kept ${nbName}/`;
920
- mkdirSync(nbDir, { recursive: true });
921
- if (legacy) {
922
- const src = path.join(legacy, nbName);
923
- if (isDir(src) && readdirSync(src).length) {
924
- for (const f of rglobAllFiles(src)) {
925
- const dest = path.join(nbDir, path.relative(src, f));
926
- mkdirSync(path.dirname(dest), { recursive: true });
927
- copyFile(f, dest);
928
- }
929
- return `migrated ${nbName}/`;
930
- }
931
- }
932
- for (const { text, src } of items) {
933
- const sp = relPosix(srcRoot, src).split('/');
934
- if (sp[0] === nbName && sp.length > 1) {
935
- // `destRel` — same reason as `globalMemory` above.
936
- const dest = path.join(nbDir, destRel(path.join(...sp.slice(1))));
937
- mkdirSync(path.dirname(dest), { recursive: true });
938
- if (text !== null) writeText(dest, text);
939
- else copyFile(src, dest);
940
- }
941
- }
942
- return `seeded ${nbName}/`;
943
- }
944
-
945
- /**
946
- * `_build_global._ship_lean_laws`.
947
- *
948
- * Historically the one RENDER that ran AFTER a WIRE, which is why the phase-order
949
- * normalisation had to move it before the seam could be cut. Re-derived rather than
950
- * trusted: its inputs are `items` and `theme` (both from `render_all`) and its only
951
- * output besides the files is what it APPENDS to `owned` — which travels back in the
952
- * payload for the manifest and the prune. It reads nothing any wiring stage writes, so
953
- * its new position is not merely legal, it has no dependency on the old one.
954
- *
955
- * NO LONGER LEAN-ONLY, and the name is kept because the lean case is still the whole reason
956
- * it exists. `doctrines` ships at BOTH footprints, because the two footprints lose different
957
- * things: lean loses text (every rule is truncated to its first sentence, so the full text has
958
- * to be on disk), while FULL loses whole packs (only the active ones are inlined, so the
959
- * inactive ones have to be on disk). AGENT.md tells the agent it may read a rule in a pack
960
- * that is not active, and `src/doctrines/README.md` rests cross-pack citations on exactly
961
- * that — a claim the emit has to make true, not merely the `files` bundle.
962
- */
963
- function shipLeanLaws(items, theme, cfgDir, owned, footprint) {
964
- // Themed names, resolved through the same token table `build`'s wipe resolves, so the dir
965
- // a lean emit re-ships and the dir it prunes can never be two different spellings.
966
- const wanted = footprint === 'lean' ? LEAN_FULL_TEXT_DIRS : ['doctrines'];
967
- const dirs = new Set(wanted.map((d) => theme[SRC_DIR_TOKENS[d]] ?? d));
968
- for (const { rel, text } of items) {
969
- const parts = rel.split('/');
970
- // `parts.length` mirrors Python's `parts and parts[0]`, where `Path("").parts` really
971
- // is empty. `String.split` never returns an empty array, so this half can never be
972
- // false here — kept for the mirror, and its mutation stays green because there is
973
- // nothing to detect, not because the gate cannot see it.
974
- if (text !== null && parts.length && dirs.has(parts[0])) {
975
- const dest = path.join(cfgDir, ...parts);
976
- mkdirSync(path.dirname(dest), { recursive: true });
977
- writeText(dest, text);
978
- owned.push(rel);
979
- }
980
- }
981
- }
982
-
983
- // ---------------------------------------------------------------------------
984
- // emit_opencode_global — the ninth, and the last emit to cross
985
- // ---------------------------------------------------------------------------
986
-
987
- /**
988
- * BOTH halves of `_build_global.emit_opencode_global`, the "everything global, zero
989
- * per-repo" OpenCode deployment. PRUNE and MANIFEST stay in Python; there is no VERIFY,
990
- * because this emit writes no settings file.
991
- *
992
- * ASSEMBLY, NOT TRANSLATION. Every unit it needs was already here — 58 of the 65 functions
993
- * in its Python closure, counted with `ast` rather than by name-mapping, which is how the
994
- * scout that produced this list first reported `_posture_body` and `_mode_body` as missing
995
- * when both are `registerBody` in `js/render.mjs`. What is new is the assembly and the
996
- * three values that had to be decided on the Python side.
997
- *
998
- * `cfgDir` IS THE FIRST OF THEM AND THE REASON THE OTHERS WERE ASKED ABOUT.
999
- * `_build_core._opencode_config_dir` is an `_OWNED` name — the suite and `harness diff`
1000
- * both redirect it at a sandbox — so resolving it here would render 135 files into the
1001
- * developer's real `~/.config/opencode`. Python's entry point resolves it once, before
1002
- * anything else, and sends the answer. Fourth phase running (`STRUCTURE` at P2d,
1003
- * `capabilityLinkRe` at P2e, `_PREAMBLE_CONFIG_DIR` at P3b): send the decision, never the
1004
- * resolver.
1005
- *
1006
- * `out` IS NOT THE TARGET, and with the default nobody can tell. `emit_opencode_global`
1007
- * writes into `<cfg>` and takes `out` ONLY as the legacy bundle to migrate a memory or
1008
- * notebook store from; the CLI's `--out` defaults to a directory this emit never writes a
1009
- * byte into. A port that derived the legacy source from `<cfg>` would pass every cell that
1010
- * leaves them coincident — the `claude/bundle-in-subfolder` finding at a third call site.
1011
- *
1012
- * `agentPath` travels for the same reason `emitOpencodeRender`'s does: it is WIRE's one
1013
- * input, and `Path.as_posix()` is the Python side's spelling of it.
1014
- */
1015
- export function emitOpencodeGlobalRender(cfg, job) {
1016
- phaseLog('RENDER');
1017
- const {
1018
- theme: themeName, cfgDir, out, footprint, nativeCatalog, oldOwned, agentPath,
1019
- } = job;
1020
-
1021
- // No `lawsPrefix`: the standalone laws dir sits beside AGENT.md in <cfg>, so the lean
1022
- // pointer's relative `laws/universal.md` resolves with no prefix.
1023
- const { theme, items } = renderAll(cfg, themeName, { footprint, nativeCatalog });
1024
- assertSourceComplete(cfg, 'opencode-global');
1025
- mkdirSync(cfgDir, { recursive: true });
1026
-
1027
- const owned = [];
1028
- const agentText = items.find((i) => i.rel === 'AGENT.md' && i.text !== null)?.text ?? null;
1029
- if (agentText !== null) {
1030
- // OpenCode loads agents/skills natively, so drop AGENT.md's per-row spec links to
1031
- // plain names. Memory links stay RELATIVE: in the global layout AGENT.md and the
1032
- // store are siblings, so `memory/` resolves from AGENT.md's own location and stays
1033
- // hermetic — no absolute path a doctor check would flag as an escape.
1034
- writeText(path.join(cfgDir, 'AGENT.md'), stripCapabilityLinks(cfg, agentText));
1035
- owned.push('AGENT.md');
1036
- }
1037
-
1038
- ensureAgentOverridesStub(cfg, cfgDir);
1039
- const overrides = loadAgentOverrides(cfgDir);
1040
-
1041
- // `manifestExisted` is deliberately not passed — the Python does not pass it either, so
1042
- // the pre-manifest header line is unreachable from this emit on both sides.
1043
- const { nAgents, nSkills, written } = writeNativeLayer(
1044
- items, path.join(cfgDir, 'agents'), path.join(cfgDir, 'skills'), overrides,
1045
- { host: 'opencode', oldOwned, cfg: cfgDir, theme, src: cfg.src });
1046
- for (const p of written) owned.push(relPosix(cfgDir, p));
1047
-
1048
- const primary = writePrimaryAgent(cfg, path.join(cfgDir, 'agents'), overrides);
1049
- if (primary) owned.push(relPosix(cfgDir, primary));
1050
-
1051
- const commands = writeCommandLayer(cfg, items, path.join(cfgDir, 'command'));
1052
- commands.push(writePonytailCommand(path.join(cfgDir, 'command'))); // always-on /ponytail
1053
- for (const p of commands) owned.push(relPosix(cfgDir, p));
1054
-
1055
- owned.push(relPosix(cfgDir, writeTheme(path.join(cfgDir, 'themes'), themeName, theme)));
1056
- for (const p of writeColorThemes(cfg, path.join(cfgDir, 'themes'))) {
1057
- owned.push(relPosix(cfgDir, p));
1058
- }
1059
-
1060
- const nPlugins = copyPlugins(cfg, path.join(cfgDir, 'plugins'), owned);
1061
- const nWorkflows = copyWorkflows(cfg, path.join(cfgDir, 'workflows'), owned);
1062
-
1063
- const memStatus = globalMemory(cfgDir, items, out, cfg.src);
1064
- ensureMemoryIndex(path.join(cfgDir, 'memory'));
1065
- const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
1066
- ensureNotebookIndex(path.join(cfgDir, 'notebook'));
1067
- ensureWikiStub(cfgDir);
1068
- ensureRulesStub(cfgDir);
1069
- ensureProfileStub(cfgDir);
1070
- ensureExcludesStub(cfgDir);
1071
-
1072
- writeVersion(cfg, cfgDir);
1073
- owned.push(VERSION_MARKER);
1074
-
1075
- shipLeanLaws(items, theme, cfgDir, owned, footprint);
1076
-
1077
- // WIRE — the one file of this emit the user co-owns, and the last render is now behind
1078
- // us. Inlined the way `emitOpencodeRender`'s is (one call, the same merge, the same
1079
- // file name), while the Python side keeps it a separate `_opencode_global_wire_py`:
1080
- // that split is what `tests/test_emit_phase_order.py` walks, and it walks the Python.
1081
- phaseLog('WIRE');
1082
- const cfgName = path.basename(mergeOpencodeJson(path.join(cfgDir, 'opencode.json'),
1083
- agentPath, cfg.doctrines, cfg.excludeRules));
1084
-
1085
- return {
1086
- owned,
1087
- stats: {
1088
- nAgents, nSkills, nPlugins, nWorkflows, nCommands: commands.length, primary: !!primary,
1089
- },
1090
- memStatus,
1091
- nbStatus,
1092
- cfgName,
1093
- };
1094
- }
1095
-
1096
- // ---------------------------------------------------------------------------
1097
- // _emit_claude_core — both halves
1098
- // ---------------------------------------------------------------------------
1099
-
1100
- /**
1101
- * The RENDER half of `_build_global._emit_claude_core`: everything up to (not including)
1102
- * the CLAUDE.md managed-block merge. WIRE, PRUNE, MANIFEST and VERIFY stay in Python.
1103
- *
1104
- * Six emits route through here — claude, bob and copilot at both scopes — so this one
1105
- * job is what puts two thirds of the matrix on the seam.
1106
- *
1107
- * `claudeMdText` is the payload's one item that is not a file this process wrote. WIRE
1108
- * needs the managed block's text, and that text is a RENDER (`prefixedAgentText`
1109
- * re-renders AGENT.md with every store dir prefixed), so it is computed here and merged
1110
- * there. `emitOpencodeRender` needed nothing of the kind — its wired file is derived from
1111
- * a path, not from a render.
1112
- */
1113
- export function emitClaudeRender(cfg, job) {
1114
- phaseLog('RENDER');
1115
- const {
1116
- theme: themeName, cfgDir, claudeMd, scope, out, footprint, host, nativeCatalog,
1117
- oldOwned,
1118
- } = job;
1119
-
1120
- // `os.path.relpath(cfg, claude_md.parent)` — note the reversed argument order, and
1121
- // that Python answers '.' for an identical pair where `path.relative` answers ''.
1122
- const relCfg = path.relative(path.dirname(claudeMd), cfgDir).split(path.sep).join('/')
1123
- || '.';
1124
- const lawsPrefix = relCfg === '.' ? '' : `${relCfg}/`;
1125
- const { theme, items } = renderAll(cfg, themeName, { footprint, lawsPrefix, nativeCatalog });
1126
- assertSourceComplete(cfg, `claude-${scope}`);
1127
- mkdirSync(cfgDir, { recursive: true });
1128
-
1129
- /** `_emit_claude_core._prefixed_agent_text`. */
1130
- const prefixedAgentText = (prefix) => {
1131
- const tmplItem = items.find((i) => i.rel === 'AGENT.md');
1132
- if (tmplItem === undefined) return null;
1133
- // Deliberately NOT the same lookup as `agentText` below: that one skips an
1134
- // AGENT.md whose text is null, this one does not. Unreachable today (`.md` is a
1135
- // text suffix, so the render is never null) and reproduced anyway, because the two
1136
- // spellings sit four lines apart in the Python and only one of them filters.
1137
- //
1138
- // The fast path itself is byte-inert, measured: removing it re-renders with a theme
1139
- // whose DIR_* tokens gained an EMPTY prefix and with the same lawsPrefix, so it
1140
- // produces exactly the item's own text. The mutation stays green, and that is
1141
- // "nothing to detect" rather than "the gate cannot see it" — recorded the way
1142
- // `themed_rel` and `lstripNewlines` are, so the two verdicts stay distinguishable.
1143
- if (!prefix) return tmplItem.text;
1144
- const ptheme = { ...theme };
1145
- for (const tok of ['DIR_LAWS', 'DIR_AGENTS', 'DIR_SKILLS', 'DIR_MEMORY', 'DIR_NOTEBOOK']) {
1146
- ptheme[tok] = prefix + (ptheme[tok] ?? tok.split('_').slice(1).join('_').toLowerCase());
1147
- }
1148
- // Same catalogue decision as the renderAll above — a re-render that forgot it would
1149
- // quietly put the stripped tables back.
1150
- return renderFile(cfg, tmplItem.src, ptheme, footprint, '', new Set(), nativeCatalog);
1151
- };
1152
-
1153
- const owned = [];
1154
- const agentText = items.find((i) => i.rel === 'AGENT.md' && i.text !== null)?.text ?? null;
1155
- const isBob = host === 'bob';
1156
- const isCopilot = host === 'copilot';
1157
-
1158
- // Bob's always-injected channel is the rules folder. At GLOBAL scope rules/geneseed.md
1159
- // IS the preamble (a global ~/.bob/AGENTS.md is not auto-loaded), and its pointers need
1160
- // a `../` prefix because it sits one level below the stores; at PROJECT scope it is the
1161
- // slim shadow stub whose only job is to have the same filename.
1162
- if (isBob && agentText !== null) {
1163
- const rulesMd = path.join(cfgDir, 'rules', 'geneseed.md');
1164
- mkdirSync(path.dirname(rulesMd), { recursive: true });
1165
- writeText(rulesMd, scope === 'project' ? BOB_RULES_STUB
1166
- : stripCapabilityLinks(cfg, prefixedAgentText('../') || agentText));
1167
- owned.push('rules/geneseed.md');
1168
- }
1169
-
1170
- ensureAgentOverridesStub(cfg, cfgDir);
1171
- // Bob's agents/skills use the Claude dialect verbatim; Copilot has its own frontmatter.
1172
- // `manifestExisted` is deliberately not passed: the Python does not pass it either, so
1173
- // the pre-manifest header line is unreachable from this emit on both sides.
1174
- const { nAgents, nSkills, written } = writeNativeLayer(
1175
- items, path.join(cfgDir, 'agents'), path.join(cfgDir, 'skills'),
1176
- loadAgentOverrides(cfgDir),
1177
- { host: isCopilot ? 'copilot' : 'claude', oldOwned, cfg: cfgDir, src: cfg.src });
1178
- for (const p of written) owned.push(relPosix(cfgDir, p));
1179
-
1180
- const memStatus = globalMemory(cfgDir, items, out, cfg.src);
1181
- ensureMemoryIndex(path.join(cfgDir, 'memory'));
1182
- const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
1183
- ensureNotebookIndex(path.join(cfgDir, 'notebook'));
1184
- ensureWikiStub(cfgDir);
1185
- ensureRulesStub(cfgDir);
1186
- ensureProfileStub(cfgDir);
1187
- ensureExcludesStub(cfgDir);
1188
-
1189
- // Project hygiene, claim-on-create: an existing (possibly user-authored) .gitignore is
1190
- // never rewritten, but one we created stays owned across re-emits.
1191
- if (scope === 'project') {
1192
- const gi = path.join(cfgDir, '.gitignore');
1193
- const giLines = (host === 'claude' ? ['settings.local.json'] : [])
1194
- .concat(['wiki.jsonc', 'agent-overrides.json']);
1195
- if (!existsSync(gi)) {
1196
- writeText(gi, `${giLines.join('\n')}\n`);
1197
- owned.push('.gitignore');
1198
- } else if (oldOwned.includes('.gitignore')) {
1199
- owned.push('.gitignore');
1200
- }
1201
- }
1202
-
1203
- writeVersion(cfg, cfgDir);
1204
- owned.push(VERSION_MARKER);
1205
-
1206
- shipLeanLaws(items, theme, cfgDir, owned, footprint);
1207
-
1208
- // The last render of the emit, and the only one whose product is not written here.
1209
- // Bob at GLOBAL scope gets no managed block at all, so nothing is rendered for it —
1210
- // the condition is the merge's, kept here so RENDER produces exactly what WIRE
1211
- // consumes and no more.
1212
- let claudeMdText = null;
1213
- if (agentText !== null && !(isBob && scope === 'global')) {
1214
- claudeMdText = stripCapabilityLinks(cfg, prefixedAgentText(lawsPrefix) || agentText);
1215
- }
1216
-
1217
- // WIRE — see `claudeWire` below. One child per emit, so the two halves are two
1218
- // functions rather than two spawns; the seam between them is a call, not a process.
1219
- const managed = claudeWire(job, claudeMdText, agentText !== null, cfg.doctrines,
1220
- cfg.excludeRules);
1221
-
1222
- return {
1223
- owned,
1224
- stats: { nAgents, nSkills },
1225
- memStatus,
1226
- nbStatus,
1227
- hasAgentText: agentText !== null,
1228
- claudeMdText,
1229
- managed,
1230
- };
1231
- }
1232
-
1233
- /**
1234
- * `_build_global._claude_wire_py` — the files the Claude-shaped emits do NOT own: the
1235
- * CLAUDE.md/AGENTS.md managed block and settings(.local).json.
1236
- *
1237
- * Everything the render half wrote, it wrote wholesale. Everything here reconciles
1238
- * Geneseed's claim with content it did not write. The return value is `managed`, the claim
1239
- * set Python records in the manifest so every teardown path unwires exactly what was wired
1240
- * — which is why WIRE must precede MANIFEST, and why `managed` travels back rather than
1241
- * being recomputed.
1242
- *
1243
- * `preambleExclude` arrives decided and is NOT derived here. Python computes it from
1244
- * `_PREAMBLE_CONFIG_DIR`, which resolves through `_build_core._claude_config_dir` — an
1245
- * `_OWNED` name precisely because the suite redirects it at a sandbox. Resolving `~/.claude`
1246
- * on this side would write the developer's REAL CLAUDE.md path into a test's exclude list
1247
- * and suppress it. Third phase running that a name had to travel rather than be re-derived
1248
- * (`STRUCTURE` at P2d, `capabilityLinkRe` at P2e); this one edits a file the user co-owns.
1249
- *
1250
- * `GENESEED_STACK_GLOBAL` is read from the environment on BOTH sides — the child inherits
1251
- * `process.env`, so the opt-out reaches here without travelling in the job. `pyTruthy` is
1252
- * not needed for it: Python tests `os.environ.get(...)` for truthiness and an env var is
1253
- * always a string, so `''` is the only falsy value either language sees.
1254
- */
1255
- function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRules = []) {
1256
- phaseLog('WIRE');
1257
- const { cfgDir, claudeMd, scope, host, oldManaged, preambleExclude, hookOpts } = job;
1258
- const old = oldManaged && typeof oldManaged === 'object' && !Array.isArray(oldManaged)
1259
- ? oldManaged : {};
1260
- const isBob = host === 'bob';
1261
- const isCopilot = host === 'copilot';
1262
- const managed = {};
1263
-
1264
- // CLAUDE.md — Claude auto-loads it by location; merge as a delimited block so any user
1265
- // prose around it survives. `claudeMdText` is the render half's product; it is null
1266
- // exactly when no block is to be written.
1267
- // Exception — Bob GLOBAL: Bob never auto-loads a global ~/.bob/AGENTS.md, so a global
1268
- // copy is pure disk weight; none is written, and a re-emit self-heals the one an older
1269
- // install carries.
1270
- if (claudeMdText !== null) {
1271
- managedBlockWrite(claudeMd, claudeMdText);
1272
- // No sticky "whole" flag: teardown always excises the block and deletes the file only
1273
- // when nothing else remains — a whole-file delete would eat prose the user added AFTER
1274
- // Geneseed created the file.
1275
- //
1276
- // `os.path.relpath(claude_md, cfg).replace(os.sep, '/')`. Python answers '.' where
1277
- // `path.relative` answers '' for an identical pair, the same guard `lawsPrefix` needs
1278
- // above — unreachable here (CLAUDE.md is a file, cfgDir a directory, so the pair is
1279
- // never identical) and spelled anyway, because the two call sites are 400 lines apart
1280
- // and only one of them is obviously safe.
1281
- const rel = path.relative(cfgDir, claudeMd).split(path.sep).join('/') || '.';
1282
- managed.claude_md = { rel };
1283
- } else if (isBob && scope === 'global' && pyTruthy(get(old, 'claude_md'))) {
1284
- const oldCm = isDict(get(old, 'claude_md')) ? get(old, 'claude_md') : {};
1285
- // `.resolve()` on the Python side; `path.resolve` is its counterpart and both are
1286
- // applied to a path already built from an absolute cfgDir.
1287
- const victim = path.resolve(cfgDir, get(oldCm, 'rel') || path.basename(claudeMd));
1288
- managedBlockRemove(victim);
1289
- }
1290
-
1291
- // Hooks embed machine-absolute paths. At PROJECT scope for Claude they go into
1292
- // settings.local.json — the personal, untracked file — never the team-shared
1293
- // settings.json, which would hand every teammate failing hooks pointing at this machine's
1294
- // python. (Bob documents no local variant, so it keeps settings.json.) Copilot has NO
1295
- // settings.json and no hook mechanism at all, so the whole stage is skipped: nothing
1296
- // written, no settings_* keys recorded for the lifecycle to unwire.
1297
- if (!isCopilot) {
1298
- const settingsName = scope === 'project' && !isBob ? 'settings.local.json' : 'settings.json';
1299
- const settingsPath = path.join(cfgDir, settingsName);
1300
- managed.settings_file = settingsName;
1301
- // Migration: an older install wired hooks/excludes into a different file — unwire the
1302
- // recorded claims there, or they linger (and run) forever.
1303
- const oldSf = get(old, 'settings_file') || 'settings.json';
1304
- if (oldSf !== settingsName) {
1305
- unwireClaudeSettings(path.join(cfgDir, oldSf), get(old, 'settings_hooks') || []);
1306
- unwireClaudeExcludes(path.join(cfgDir, oldSf), get(old, 'settings_excludes') || []);
1307
- }
1308
- // The merge prunes recorded groups that are no longer canonical and returns the
1309
- // COMPLETE current claim set — store it as-is; unioning with prior would resurrect the
1310
- // stale claims.
1311
- // `doctrines` decides whether the git-gate group is part of the canonical claim set. It
1312
- // travels from `cfg` rather than being re-read off the deployment: this is the emit that
1313
- // DECIDES the install's packs, so the marker on disk is still the previous build's.
1314
- const [, managedHooks] = mergeClaudeSettings(
1315
- settingsPath, scope, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
1316
- doctrines, excludeRules,
1317
- );
1318
- managed.settings_hooks = managedHooks;
1319
-
1320
- // Project-bypasses-global (Claude only): a PROJECT install suppresses the GLOBAL
1321
- // ~/.claude/CLAUDE.md while cwd is this repo, via Claude's native claudeMdExcludes.
1322
- // Written only when this run actually emitted the project's own preamble (never
1323
- // suppress with no replacement); GENESEED_STACK_GLOBAL=1 opts out, and a re-emit with
1324
- // it set strips a prior exclude. Bob never gets one: its bypass is the same-named
1325
- // workspace rules file.
1326
- const priorRaw = get(old, 'settings_excludes');
1327
- const priorExcl = Array.isArray(priorRaw) ? priorRaw : [];
1328
- if (scope === 'project' && hasAgentText && preambleExclude) {
1329
- const wantExcl = [preambleExclude];
1330
- if (process.env.GENESEED_STACK_GLOBAL) {
1331
- unwireClaudeExcludes(settingsPath, wantExcl);
1332
- managed.settings_excludes = [];
1333
- } else {
1334
- const addedExcl = wireClaudeExcludes(settingsPath, wantExcl);
1335
- // Claim only what Geneseed itself wired (prior + newly added) — folding `wantExcl`
1336
- // in unconditionally would claim a user's own pre-existing exclude, and uninstall
1337
- // would then strip it. `sorted(set(a) | set(b))` — Python sorts strings by code
1338
- // point and so does the default `Array.sort`, which is why no comparator is passed.
1339
- managed.settings_excludes = [...new Set([...priorExcl, ...addedExcl])].sort();
1340
- }
1341
- } else if (priorExcl.length && isBob) {
1342
- // Self-heal older Bob installs: earlier versions wrote the global AGENTS.md into
1343
- // claudeMdExcludes here. The key is Claude-only and its Bob semantics are unknown, so
1344
- // a re-emit removes it instead of carrying it forward.
1345
- unwireClaudeExcludes(settingsPath, priorExcl);
1346
- } else if (priorExcl.length) {
1347
- managed.settings_excludes = priorExcl;
1348
- }
1349
- }
1350
- return managed;
1351
- }
1352
-
1353
- // ---------------------------------------------------------------------------
1354
- // The CLI — one job in, one JSON document out
1355
- // ---------------------------------------------------------------------------
1356
-
1357
- const KINDS = {
1358
- build: (cfg, job) => {
1359
- build(cfg, job.theme, job.out,
1360
- { footprint: job.footprint, nativeCatalog: job.nativeCatalog });
1361
- return {};
1362
- },
1363
- opencode: emitOpencodeRender,
1364
- 'opencode-global': emitOpencodeGlobalRender,
1365
- claude: emitClaudeRender,
1366
- };
1367
-
1368
- function main(argv) {
1369
- const captured = { stdout: '', stderr: '' };
1370
- const realWrite = process.stdout.write.bind(process.stdout);
1371
-
1372
- // Both streams become buffers for the whole run. See the module header: this is what
1373
- // makes "the protocol owns stdout" a structural property rather than a convention
1374
- // every future contributor has to remember.
1375
- const sink = (key) => (chunk, encoding, cb) => {
1376
- captured[key] += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8');
1377
- const done = typeof encoding === 'function' ? encoding : cb;
1378
- if (done) done();
1379
- return true;
1380
- };
1381
- process.stdout.write = sink('stdout');
1382
- process.stderr.write = sink('stderr');
1383
-
1384
- let payload;
1385
- try {
1386
- const job = parseJson(readFileSync(argv[0], 'utf8'));
1387
- const run = KINDS[job.kind];
1388
- if (!run) throw new Error(`unknown job kind ${JSON.stringify(job.kind)}`);
1389
- payload = { ok: true, ...run(job.cfg, job) };
1390
- } catch (e) {
1391
- // `exitCode` marks a deliberate refusal (assertSourceComplete) whose message is
1392
- // already on the captured stderr; anything else is a crash and carries its stack, so
1393
- // a port bug surfaces as a diagnosable failure instead of a truncated tree.
1394
- payload = e && e.exitCode
1395
- ? { ok: false, exit: e.exitCode }
1396
- : { ok: false, exit: 1, error: (e && e.stack) || String(e) };
1397
- }
1398
- payload.stdout = captured.stdout;
1399
- payload.stderr = captured.stderr;
1400
-
1401
- process.stdout.write = realWrite;
1402
- realWrite(JSON.stringify(payload));
1403
- return 0;
1404
- }
1405
-
1406
- // Library AND entry point: the parity gates import `build` directly, and running `main`
1407
- // on import would eat their argv and print a payload nobody reads. `import.meta.main` is
1408
- // Node >= 24 and this machine runs v22, so the comparison is spelled out.
1409
- if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
1410
- process.exitCode = main(process.argv.slice(2));
1411
- }