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
@@ -0,0 +1,231 @@
1
+ /**
2
+ * `build` — render one theme's sources into a bundle directory, and the two checks that
3
+ * decide whether it may.
4
+ *
5
+ * The LAYER BOTH HOST EMITS SIT ON, and that is why it is not in `emit.mjs` with them: the
6
+ * first map left it there, and the derived imports came back with `emit-opencode.mjs` and
7
+ * `emit-claude.mjs` importing `emit.mjs` while `emit.mjs` imported them back. A bundle is
8
+ * produced before any host is chosen, so it belongs UNDER the emitters, not beside them.
9
+ *
10
+ * `assertSourceComplete` scans the TEMPLATE rather than the render, and the src-dirs marker
11
+ * is how a re-emit knows which directory names the last one used.
12
+ */
13
+ import path from 'node:path';
14
+ import { VERSION_MARKER } from '../hosts/hosts.mjs';
15
+ import { copyFile, readText, writeText } from '../lib/fs.mjs';
16
+ import { jsonDumpsIndent, parseJson, formatReprAscii, isTruthy } from '../lib/json.mjs';
17
+ import { OWNED_SRC_DIRS, SRC_DIRS_MARKER, TMPL_SPEC_RE, isDir, isFile } from './emit-common.mjs';
18
+ import { SRC_DIR_TOKENS, renderAll } from './render.mjs';
19
+ import {
20
+ ensureBundleGitignore, ensureContextStub, ensureMemoryIndex, ensureNotebookIndex,
21
+ ensureProfileStub, ensureRulesStub, ensureWikiStub,
22
+ } from './stubs.mjs';
23
+ import { writeVersion } from './version.mjs';
24
+ import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Source completeness
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** `_build_render._missing_referenced_specs` — scans the TEMPLATE, not the render. */
31
+ function missingReferencedSpecs(cfg) {
32
+ let tmpl;
33
+ try {
34
+ tmpl = readText(path.join(cfg.src, 'AGENT.md.tmpl'));
35
+ } catch {
36
+ return [];
37
+ }
38
+ const missing = new Set();
39
+ for (const [, kind, name] of tmpl.matchAll(TMPL_SPEC_RE)) {
40
+ const dir = kind.toLowerCase();
41
+ if (name !== '_template' && !isFile(path.join(cfg.src, dir, `${name}.md`))) {
42
+ missing.add(`${dir}/${name}.md`);
43
+ }
44
+ }
45
+ return [...missing].sort();
46
+ }
47
+
48
+ /**
49
+ * `_build_render.assert_source_complete`.
50
+ *
51
+ * Python raises `SystemExit(1)` after writing to stderr. Here the message goes to the
52
+ * captured stderr buffer and the throw carries the exit code, which the driver turns
53
+ * into a process status by `bin/build-driver.mjs`'s `main` — so the caller sees the same stream
54
+ * bytes and the same exit status whichever runtime rendered.
55
+ */
56
+ export function assertSourceComplete(cfg, context = '') {
57
+ const missing = missingReferencedSpecs(cfg);
58
+ if (!missing.length) return;
59
+ const where = context ? ` (${context})` : '';
60
+ process.stderr.write(
61
+ `[geneseed][E-INCOMPLETE] ✗ source is incomplete${where}: AGENT.md references `
62
+ + `${missing.length} spec(s) with no file under src/:\n`
63
+ + missing.map((m) => ` - ${m}\n`).join('')
64
+ + '[geneseed] ✗ Refusing to emit — a partial source would write dead links '
65
+ + 'and a global re-emit would delete the good copies in an existing install.\n'
66
+ + '[geneseed] ✗ Re-sync the source (./geneseed update, or re-run the upgrade) '
67
+ + 'and try again.\n');
68
+ const e = new Error('source is incomplete');
69
+ e.exitCode = 1;
70
+ throw e;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // The owned-dir marker
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /** `_build_render._read_prior_src_dirs` — `{}` on anything unreadable or not an object. */
78
+ function readPriorSrcDirs(out) {
79
+ let data;
80
+ try {
81
+ data = parseJson(readText(path.join(out, SRC_DIRS_MARKER)));
82
+ } catch {
83
+ return {};
84
+ }
85
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
86
+ }
87
+
88
+ /** `_build_render._write_src_dirs_marker` — temp + replace. */
89
+ function writeSrcDirsMarker(out, resolved) {
90
+ const tmp = path.join(out, `${SRC_DIRS_MARKER}.tmp`);
91
+ writeText(tmp, `${jsonDumpsIndent(resolved)}\n`);
92
+ renameSync(tmp, path.join(out, SRC_DIRS_MARKER));
93
+ }
94
+
95
+ /**
96
+ * The guard on the prune of a RENAMED owned dir — `build`'s one destructive path driven
97
+ * by file content rather than by code.
98
+ *
99
+ * `.geneseed-srcdirs.json` is a plain file a user (or another tool) can edit, and the
100
+ * name in it is joined onto `out` and handed to a recursive delete. `".."` would take out
101
+ * the bundle's PARENT, an absolute path replaces `out` entirely under Python's
102
+ * `Path.__truediv__`, and `"a/b"` reaches into nested content. Only a plain
103
+ * single-segment name resolving directly under `out` is ever deleted.
104
+ */
105
+ function safePriorDirName(out, priorName) {
106
+ return typeof priorName === 'string'
107
+ && priorName !== '.' && priorName !== '..'
108
+ && !path.isAbsolute(priorName)
109
+ && path.basename(priorName) === priorName
110
+ && path.dirname(path.resolve(out, priorName)) === path.resolve(out);
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // build
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * `_build_render.build` — render the bundle into `out`.
119
+ *
120
+ * `out` arrives as the STRING Python already resolved, and is echoed into the progress
121
+ * lines verbatim: Python interpolates `str(Path)`, which is backslash-separated on
122
+ * Windows, and re-deriving it here would differ by separator in every message.
123
+ */
124
+ /**
125
+ * THE PHASE BOUNDARY MARKER — T8, and the whole of what this port owes
126
+ * `tests/test_emit_phase_order.py`.
127
+ *
128
+ * Every emit runs its stages in one order: RENDER* → WIRE* → PRUNE → MANIFEST → VERIFY. RENDER
129
+ * writes files Geneseed owns wholesale; WIRE reconciles files the USER co-owns (the
130
+ * CLAUDE.md/AGENTS.md managed block, settings(.local).json, opencode.json) by reading what is
131
+ * already there and merging into it. WIRE must precede MANIFEST because wiring is what fills the
132
+ * `managed` record the manifest writes, and every teardown path unwires exactly what was wired.
133
+ *
134
+ * WHY A RUNTIME LOG AND NOT A SOURCE WALK. The reference gated this with five `ast` walks over
135
+ * its own generator, and could, because it keeps `_claude_render` / `_claude_wire` as two
136
+ * dispatchers and therefore two statements at every call site — a shape its own docstring calls
137
+ * "not incidental, it is what leaves this walker something to check". THIS PORT DOES NOT HAVE
138
+ * THAT SHAPE: `claudeWire` is a real function but the two OpenCode emits inline their merge
139
+ * (`emitOpencodeRender`, `emitOpencodeGlobalRender`), and PRUNE/MANIFEST/VERIFY are not in this
140
+ * module at all — they are in `bin/build-driver.mjs`'s driver bodies. A walker would have to span two
141
+ * files and would still be reading source rather than watching a run.
142
+ *
143
+ * So the order is made OBSERVABLE instead, which is strictly the better gate: it reports what
144
+ * actually executed, in the order it executed, including the branch a given host took.
145
+ *
146
+ * ⚠ BEHIND AN ENV VAR THE THREE REPLAYERS DO NOT SET, and that is the entire safety argument.
147
+ * `cellEnv` clears every `GENESEED_*` knob by prefix, so no recorded cell can turn this on and
148
+ * the emit corpus cannot move by one byte. It writes to STDERR, never stdout: stdout is the
149
+ * decision channel every Geneseed hook signals through, and a stray byte there turns a blocking
150
+ * gate into a silently permissive one.
151
+ *
152
+ * Read at CALL time, not at import, so one process can drive several emits under it.
153
+ */
154
+ export function phaseLog(phase) {
155
+ if (!process.env.GENESEED_PHASE_LOG) return;
156
+ process.stderr.write(`[geneseed:phase] ${phase}\n`);
157
+ }
158
+
159
+ export function build(cfg, themeName, out, { footprint = 'full', nativeCatalog = false } = {}) {
160
+ phaseLog('RENDER');
161
+ const { theme, items } = renderAll(cfg, themeName, { footprint, nativeCatalog });
162
+ assertSourceComplete(cfg, `theme '${themeName}'`);
163
+ mkdirSync(out, { recursive: true });
164
+
165
+ // Wipe the owned dirs ONLY inside an established bundle: a first render into an
166
+ // arbitrary repo (`--out .`) must never delete an agents/ or skills/ the USER owns.
167
+ const isBundle = isFile(path.join(out, '.geneseed-theme')) || isFile(path.join(out, VERSION_MARKER));
168
+ const priorSrcDirs = isBundle ? readPriorSrcDirs(out) : {};
169
+ const resolvedSrcDirs = {};
170
+ for (const srcDir of OWNED_SRC_DIRS) {
171
+ // `theme.get(token, srcDir)` — `??` agrees with it except for an explicit JSON null,
172
+ // where Python would go on to join `None` onto a path and raise. No theme has one.
173
+ const dirname = theme[SRC_DIR_TOKENS[srcDir]] ?? srcDir;
174
+ resolvedSrcDirs[srcDir] = dirname;
175
+ const managed = path.join(out, dirname);
176
+
177
+ // `isTruthy`, not `priorName &&`: the marker is user-editable JSON, and Python's
178
+ // `if prior_name` is false for `[]`, `{}` and `0` where JS's is true for the first
179
+ // two. Getting that wrong warns about a value Python skips in silence — a stderr
180
+ // divergence reachable only from a hand-edited file, which is the one place nobody
181
+ // would look for it.
182
+ const priorName = priorSrcDirs[srcDir];
183
+ if (isBundle && isTruthy(priorName) && priorName !== dirname) {
184
+ if (safePriorDirName(out, priorName)) {
185
+ const stale = path.join(out, priorName);
186
+ if (isDir(stale)) rmSync(stale, { recursive: true, force: true });
187
+ } else {
188
+ process.stderr.write('[geneseed] WARN: ignoring suspicious prior dir name '
189
+ + `${formatReprAscii(priorName)} recorded in ${SRC_DIRS_MARKER} - not pruned.\n`);
190
+ }
191
+ }
192
+
193
+ if (!isDir(managed)) continue;
194
+ if (isBundle) {
195
+ rmSync(managed, { recursive: true, force: true });
196
+ } else {
197
+ // STDOUT, and ASCII-only: this print crashed with UnicodeEncodeError on a cp1252
198
+ // Windows console when it carried a warning-sign emoji.
199
+ process.stdout.write(`[geneseed] WARN: ${managed} already exists and ${out} is not a `
200
+ + 'Geneseed bundle — keeping it; rendered files merge into it.\n');
201
+ }
202
+ }
203
+
204
+ const nbDirname = theme[SRC_DIR_TOKENS.notebook] ?? 'notebook';
205
+ for (const { rel, text, src } of items) {
206
+ const dest = path.join(out, rel);
207
+ mkdirSync(path.dirname(dest), { recursive: true });
208
+ // The notebook is the agent's sovereign space: seeded files are written once and
209
+ // never re-emitted, so the agent may rewrite its own rules. Only `.gitignore` is
210
+ // re-asserted — the one law it cannot lift.
211
+ const parts = rel.split('/');
212
+ if (parts[0] === nbDirname && path.basename(rel) !== '.gitignore' && existsSync(dest)) {
213
+ continue;
214
+ }
215
+ if (text !== null) writeText(dest, text);
216
+ else copyFile(src, dest);
217
+ }
218
+
219
+ writeText(path.join(out, '.geneseed-theme'), `${themeName}\n`);
220
+ writeVersion(cfg, out);
221
+ writeSrcDirsMarker(out, resolvedSrcDirs);
222
+ ensureContextStub(out);
223
+ ensureWikiStub(out);
224
+ ensureRulesStub(out);
225
+ ensureProfileStub(out);
226
+ ensureBundleGitignore(out);
227
+ ensureMemoryIndex(path.join(out, theme[SRC_DIR_TOKENS.memory] ?? 'memory'));
228
+ ensureNotebookIndex(path.join(out, theme[SRC_DIR_TOKENS.notebook] ?? 'notebook'));
229
+ process.stdout.write(`[geneseed] built theme '${themeName}' -> ${out} (${items.length} files)\n`);
230
+ }
231
+
@@ -23,11 +23,11 @@
23
23
  * NOTHING HERE PAINTS. No cursor moves, no clears, no alternate screen: this writes lines
24
24
  * and stops, exactly as `status` does. That is what keeps the dropped panel dropped.
25
25
  */
26
- import { helpWidth } from './cli.mjs';
27
- import { tuiInventory } from './inventory.mjs';
28
- import { defaultTheme, installedDefaults } from './installs.mjs';
29
- import { pyPrint } from './lib/fs.mjs';
30
- import { dwidth, fit, icon, truncd, tuiEntries } from './tui.mjs';
26
+ import { helpWidth } from '../ui/cli.mjs';
27
+ import { tuiInventory } from '../inspect/inventory.mjs';
28
+ import { defaultTheme, installedDefaults } from '../hosts/installs.mjs';
29
+ import { printOut } from '../lib/fs.mjs';
30
+ import { dwidth, fit, icon, truncd, tuiEntries } from '../ui/tui.mjs';
31
31
 
32
32
  /**
33
33
  * The listing's section names, and the row kind each one selects.
@@ -129,9 +129,9 @@ export function cmdCatalog(args) {
129
129
  const lines = catalogLines(tuiInventory(theme, inst.doctrines), args.section,
130
130
  helpWidth() + 2, args.source);
131
131
  if (!lines.length) {
132
- pyPrint(`[catalog] nothing to list${args.section ? ` under ${args.section}` : ''}.\n`);
132
+ printOut(`[catalog] nothing to list${args.section ? ` under ${args.section}` : ''}.\n`);
133
133
  return 1;
134
134
  }
135
- for (const line of lines) pyPrint(`${line}\n`);
135
+ for (const line of lines) printOut(`${line}\n`);
136
136
  return 0;
137
137
  }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * The Claude and Bob emits — one renderer, and the settings merge that wires it in.
3
+ *
4
+ * Split out of `emit.mjs` beside `emit-opencode.mjs`, over the shared writers in
5
+ * `emit-common.mjs`. `claudeWire` is the half that touches a file the USER also edits, and
6
+ * its prior-claim pruning is the reason `deepEquals` exists — read it before changing anything here.
7
+ */
8
+ import path from 'node:path';
9
+ import { VERSION_MARKER } from '../hosts/hosts.mjs';
10
+ import { loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
11
+ import { ensureAgentOverridesStub } from '../hosts/opencode.mjs';
12
+ import {
13
+ managedBlockRemove, managedBlockWrite, mergeClaudeSettings, unwireClaudeExcludes,
14
+ unwireClaudeSettings, wireClaudeExcludes,
15
+ } from '../hosts/settings.mjs';
16
+ import { writeText } from '../lib/fs.mjs';
17
+ import { isTruthy } from '../lib/json.mjs';
18
+ import { assertSourceComplete, phaseLog } from './bundle.mjs';
19
+ import {
20
+ get, globalMemory, globalNotebook, isDict, relPosix, shipLeanLaws, stripCapabilityLinks,
21
+ } from './emit-common.mjs';
22
+ import { renderAll, renderFile } from './render.mjs';
23
+ import {
24
+ BOB_RULES_STUB, ensureExcludesStub, ensureMemoryIndex, ensureNotebookIndex, ensureProfileStub,
25
+ ensureRulesStub, ensureWikiStub,
26
+ } from './stubs.mjs';
27
+ import { writeVersion } from './version.mjs';
28
+ import { existsSync, mkdirSync } from 'node:fs';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // _emit_claude_core — both halves
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * RENDER and WIRE for `_build_global._emit_claude_core`: this renders wholesale, then calls
36
+ * `claudeWire` below for the CLAUDE.md managed-block merge and the settings file. PRUNE,
37
+ * MANIFEST and VERIFY are the driver's, in `bin/build-driver.mjs`.
38
+ *
39
+ * Six emits route through here — claude, bob and copilot at both scopes — so this one
40
+ * job is what puts two thirds of the matrix on the seam.
41
+ *
42
+ * `claudeMdText` is the payload's one item that is not a file this process wrote. WIRE
43
+ * needs the managed block's text, and that text is a RENDER (`prefixedAgentText`
44
+ * re-renders AGENT.md with every store dir prefixed), so it is computed here and merged
45
+ * there. `emitOpencodeRender` needed nothing of the kind — its wired file is derived from
46
+ * a path, not from a render.
47
+ */
48
+ export function emitClaudeRender(cfg, job) {
49
+ phaseLog('RENDER');
50
+ const {
51
+ theme: themeName, cfgDir, claudeMd, scope, out, footprint, host, nativeCatalog,
52
+ oldOwned,
53
+ } = job;
54
+
55
+ // `os.path.relpath(cfg, claude_md.parent)` — note the reversed argument order, and
56
+ // that Python answers '.' for an identical pair where `path.relative` answers ''.
57
+ const relCfg = path.relative(path.dirname(claudeMd), cfgDir).split(path.sep).join('/')
58
+ || '.';
59
+ const lawsPrefix = relCfg === '.' ? '' : `${relCfg}/`;
60
+ const { theme, items } = renderAll(cfg, themeName, { footprint, lawsPrefix, nativeCatalog });
61
+ assertSourceComplete(cfg, `claude-${scope}`);
62
+ mkdirSync(cfgDir, { recursive: true });
63
+
64
+ /** `_emit_claude_core._prefixed_agent_text`. */
65
+ const prefixedAgentText = (prefix) => {
66
+ const tmplItem = items.find((i) => i.rel === 'AGENT.md');
67
+ if (tmplItem === undefined) return null;
68
+ // Deliberately NOT the same lookup as `agentText` below: that one skips an
69
+ // AGENT.md whose text is null, this one does not. Unreachable today (`.md` is a
70
+ // text suffix, so the render is never null) and reproduced anyway, because the two
71
+ // spellings sit four lines apart in the Python and only one of them filters.
72
+ //
73
+ // The fast path itself is byte-inert, measured: removing it re-renders with a theme
74
+ // whose DIR_* tokens gained an EMPTY prefix and with the same lawsPrefix, so it
75
+ // produces exactly the item's own text. The mutation stays green, and that is
76
+ // "nothing to detect" rather than "the gate cannot see it" — recorded the way
77
+ // `themed_rel` and `lstripNewlines` are, so the two verdicts stay distinguishable.
78
+ if (!prefix) return tmplItem.text;
79
+ const ptheme = { ...theme };
80
+ for (const tok of ['DIR_LAWS', 'DIR_AGENTS', 'DIR_SKILLS', 'DIR_MEMORY', 'DIR_NOTEBOOK']) {
81
+ ptheme[tok] = prefix + (ptheme[tok] ?? tok.split('_').slice(1).join('_').toLowerCase());
82
+ }
83
+ // Same catalogue decision as the renderAll above — a re-render that forgot it would
84
+ // quietly put the stripped tables back.
85
+ return renderFile(cfg, tmplItem.src, ptheme, footprint, '', new Set(), nativeCatalog);
86
+ };
87
+
88
+ const owned = [];
89
+ const agentText = items.find((i) => i.rel === 'AGENT.md' && i.text !== null)?.text ?? null;
90
+ const isBob = host === 'bob';
91
+ const isCopilot = host === 'copilot';
92
+
93
+ // Bob's always-injected channel is the rules folder. At GLOBAL scope rules/geneseed.md
94
+ // IS the preamble (a global ~/.bob/AGENTS.md is not auto-loaded), and its pointers need
95
+ // a `../` prefix because it sits one level below the stores; at PROJECT scope it is the
96
+ // slim shadow stub whose only job is to have the same filename.
97
+ if (isBob && agentText !== null) {
98
+ const rulesMd = path.join(cfgDir, 'rules', 'geneseed.md');
99
+ mkdirSync(path.dirname(rulesMd), { recursive: true });
100
+ writeText(rulesMd, scope === 'project' ? BOB_RULES_STUB
101
+ : stripCapabilityLinks(cfg, prefixedAgentText('../') || agentText));
102
+ owned.push('rules/geneseed.md');
103
+ }
104
+
105
+ ensureAgentOverridesStub(cfg, cfgDir);
106
+ // Bob's agents/skills use the Claude dialect verbatim; Copilot has its own frontmatter.
107
+ // `manifestExisted` is deliberately not passed: the Python does not pass it either, so
108
+ // the pre-manifest header line is unreachable from this emit on both sides.
109
+ const { nAgents, nSkills, written } = writeNativeLayer(
110
+ items, path.join(cfgDir, 'agents'), path.join(cfgDir, 'skills'),
111
+ loadAgentOverrides(cfgDir),
112
+ { host: isCopilot ? 'copilot' : 'claude', oldOwned, cfg: cfgDir, src: cfg.src });
113
+ for (const p of written) owned.push(relPosix(cfgDir, p));
114
+
115
+ const memStatus = globalMemory(cfgDir, items, out, cfg.src);
116
+ ensureMemoryIndex(path.join(cfgDir, 'memory'));
117
+ const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
118
+ ensureNotebookIndex(path.join(cfgDir, 'notebook'));
119
+ ensureWikiStub(cfgDir);
120
+ ensureRulesStub(cfgDir);
121
+ ensureProfileStub(cfgDir);
122
+ ensureExcludesStub(cfgDir);
123
+
124
+ // Project hygiene, claim-on-create: an existing (possibly user-authored) .gitignore is
125
+ // never rewritten, but one we created stays owned across re-emits.
126
+ if (scope === 'project') {
127
+ const gi = path.join(cfgDir, '.gitignore');
128
+ const giLines = (host === 'claude' ? ['settings.local.json'] : [])
129
+ .concat(['wiki.jsonc', 'agent-overrides.json']);
130
+ if (!existsSync(gi)) {
131
+ writeText(gi, `${giLines.join('\n')}\n`);
132
+ owned.push('.gitignore');
133
+ } else if (oldOwned.includes('.gitignore')) {
134
+ owned.push('.gitignore');
135
+ }
136
+ }
137
+
138
+ writeVersion(cfg, cfgDir);
139
+ owned.push(VERSION_MARKER);
140
+
141
+ shipLeanLaws(items, theme, cfgDir, owned, footprint);
142
+
143
+ // The last render of the emit, and the only one whose product is not written here.
144
+ // Bob at GLOBAL scope gets no managed block at all, so nothing is rendered for it —
145
+ // the condition is the merge's, kept here so RENDER produces exactly what WIRE
146
+ // consumes and no more.
147
+ let claudeMdText = null;
148
+ if (agentText !== null && !(isBob && scope === 'global')) {
149
+ claudeMdText = stripCapabilityLinks(cfg, prefixedAgentText(lawsPrefix) || agentText);
150
+ }
151
+
152
+ // WIRE — see `claudeWire` below. One child per emit, so the two halves are two
153
+ // functions rather than two spawns; the seam between them is a call, not a process.
154
+ const managed = claudeWire(job, claudeMdText, agentText !== null, cfg.doctrines,
155
+ cfg.excludeRules);
156
+
157
+ return {
158
+ owned,
159
+ stats: { nAgents, nSkills },
160
+ memStatus,
161
+ nbStatus,
162
+ hasAgentText: agentText !== null,
163
+ claudeMdText,
164
+ managed,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * `_build_global._claude_wire_py` — the files the Claude-shaped emits do NOT own: the
170
+ * CLAUDE.md/AGENTS.md managed block and settings(.local).json.
171
+ *
172
+ * Everything the render half wrote, it wrote wholesale. Everything here reconciles
173
+ * Geneseed's claim with content it did not write. The return value is `managed`, the claim
174
+ * set `bin/build-driver.mjs` records in the manifest so every teardown path unwires what was wired
175
+ * — which is why WIRE must precede MANIFEST, and why `managed` travels back rather than
176
+ * being recomputed.
177
+ *
178
+ * `preambleExclude` arrives decided and is NOT derived here. `bin/build-driver.mjs` computes
179
+ * it from the Claude config dir it resolved, and the suite redirects that at a sandbox.
180
+ * Resolving `~/.claude` on this side would write the developer's REAL CLAUDE.md path into a
181
+ * test's exclude list and suppress it. Third phase running that a name had to travel rather than be re-derived
182
+ * (`STRUCTURE` at P2d, `capabilityLinkRe` at P2e); this one edits a file the user co-owns.
183
+ *
184
+ * `GENESEED_STACK_GLOBAL` is read from the environment on BOTH sides — the child inherits
185
+ * `process.env`, so the opt-out reaches here without travelling in the job. `isTruthy` is
186
+ * not needed for it: Python tests `os.environ.get(...)` for truthiness and an env var is
187
+ * always a string, so `''` is the only falsy value either language sees.
188
+ */
189
+ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRules = []) {
190
+ phaseLog('WIRE');
191
+ const { cfgDir, claudeMd, scope, host, oldManaged, preambleExclude, hookOpts } = job;
192
+ const old = oldManaged && typeof oldManaged === 'object' && !Array.isArray(oldManaged)
193
+ ? oldManaged : {};
194
+ const isBob = host === 'bob';
195
+ const isCopilot = host === 'copilot';
196
+ const managed = {};
197
+
198
+ // CLAUDE.md — Claude auto-loads it by location; merge as a delimited block so any user
199
+ // prose around it survives. `claudeMdText` is the render half's product; it is null
200
+ // exactly when no block is to be written.
201
+ // Exception — Bob GLOBAL: Bob never auto-loads a global ~/.bob/AGENTS.md, so a global
202
+ // copy is pure disk weight; none is written, and a re-emit self-heals the one an older
203
+ // install carries.
204
+ if (claudeMdText !== null) {
205
+ managedBlockWrite(claudeMd, claudeMdText);
206
+ // No sticky "whole" flag: teardown always excises the block and deletes the file only
207
+ // when nothing else remains — a whole-file delete would eat prose the user added AFTER
208
+ // Geneseed created the file.
209
+ //
210
+ // `os.path.relpath(claude_md, cfg).replace(os.sep, '/')`. Python answers '.' where
211
+ // `path.relative` answers '' for an identical pair, the same guard `lawsPrefix` needs
212
+ // above — unreachable here (CLAUDE.md is a file, cfgDir a directory, so the pair is
213
+ // never identical) and spelled anyway, because the two call sites are 400 lines apart
214
+ // and only one of them is obviously safe.
215
+ const rel = path.relative(cfgDir, claudeMd).split(path.sep).join('/') || '.';
216
+ managed.claude_md = { rel };
217
+ } else if (isBob && scope === 'global' && isTruthy(get(old, 'claude_md'))) {
218
+ const oldCm = isDict(get(old, 'claude_md')) ? get(old, 'claude_md') : {};
219
+ // `.resolve()` on the Python side; `path.resolve` is its counterpart and both are
220
+ // applied to a path already built from an absolute cfgDir.
221
+ const victim = path.resolve(cfgDir, get(oldCm, 'rel') || path.basename(claudeMd));
222
+ managedBlockRemove(victim);
223
+ }
224
+
225
+ // Hooks embed machine-absolute paths. At PROJECT scope for Claude they go into
226
+ // settings.local.json — the personal, untracked file — never the team-shared
227
+ // settings.json, which would hand every teammate failing hooks pointing at this machine's
228
+ // node and this machine's checkout. (Bob documents no local variant, so it keeps
229
+ // settings.json.) Copilot has NO
230
+ // settings.json and no hook mechanism at all, so the whole stage is skipped: nothing
231
+ // written, no settings_* keys recorded for the lifecycle to unwire.
232
+ if (!isCopilot) {
233
+ const settingsName = scope === 'project' && !isBob ? 'settings.local.json' : 'settings.json';
234
+ const settingsPath = path.join(cfgDir, settingsName);
235
+ managed.settings_file = settingsName;
236
+ // Migration: an older install wired hooks/excludes into a different file — unwire the
237
+ // recorded claims there, or they linger (and run) forever.
238
+ const oldSf = get(old, 'settings_file') || 'settings.json';
239
+ if (oldSf !== settingsName) {
240
+ unwireClaudeSettings(path.join(cfgDir, oldSf), get(old, 'settings_hooks') || []);
241
+ unwireClaudeExcludes(path.join(cfgDir, oldSf), get(old, 'settings_excludes') || []);
242
+ }
243
+ // The merge prunes recorded groups that are no longer canonical and returns the
244
+ // COMPLETE current claim set — store it as-is; unioning with prior would resurrect the
245
+ // stale claims.
246
+ // `doctrines` decides whether the git-gate group is part of the canonical claim set. It
247
+ // travels from `cfg` rather than being re-read off the deployment: this is the emit that
248
+ // DECIDES the install's packs, so the marker on disk is still the previous build's.
249
+ const [, managedHooks] = mergeClaudeSettings(
250
+ settingsPath, scope, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
251
+ doctrines, excludeRules,
252
+ );
253
+ managed.settings_hooks = managedHooks;
254
+
255
+ // Project-bypasses-global (Claude only): a PROJECT install suppresses the GLOBAL
256
+ // ~/.claude/CLAUDE.md while cwd is this repo, via Claude's native claudeMdExcludes.
257
+ // Written only when this run actually emitted the project's own preamble (never
258
+ // suppress with no replacement); GENESEED_STACK_GLOBAL=1 opts out, and a re-emit with
259
+ // it set strips a prior exclude. Bob never gets one: its bypass is the same-named
260
+ // workspace rules file.
261
+ const priorRaw = get(old, 'settings_excludes');
262
+ const priorExcl = Array.isArray(priorRaw) ? priorRaw : [];
263
+ if (scope === 'project' && hasAgentText && preambleExclude) {
264
+ const wantExcl = [preambleExclude];
265
+ if (process.env.GENESEED_STACK_GLOBAL) {
266
+ unwireClaudeExcludes(settingsPath, wantExcl);
267
+ managed.settings_excludes = [];
268
+ } else {
269
+ const addedExcl = wireClaudeExcludes(settingsPath, wantExcl);
270
+ // Claim only what Geneseed itself wired (prior + newly added) — folding `wantExcl`
271
+ // in unconditionally would claim a user's own pre-existing exclude, and uninstall
272
+ // would then strip it. `sorted(set(a) | set(b))` — Python sorts strings by code
273
+ // point and so does the default `Array.sort`, which is why no comparator is passed.
274
+ managed.settings_excludes = [...new Set([...priorExcl, ...addedExcl])].sort();
275
+ }
276
+ } else if (priorExcl.length && isBob) {
277
+ // Self-heal older Bob installs: earlier versions wrote the global AGENTS.md into
278
+ // claudeMdExcludes here. The key is Claude-only and its Bob semantics are unknown, so
279
+ // a re-emit removes it instead of carrying it forward.
280
+ unwireClaudeExcludes(settingsPath, priorExcl);
281
+ } else if (priorExcl.length) {
282
+ managed.settings_excludes = priorExcl;
283
+ }
284
+ }
285
+ return managed;
286
+ }
287
+