geneseed 3.1.1 → 3.1.3

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 (106) hide show
  1. package/CHANGELOG.md +123 -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/docs/web/exclusions.md +1 -1
  12. package/docs/web/footprint.md +1 -1
  13. package/docs/web/foreman-mode.md +2 -2
  14. package/docs/web/themes-author.md +1 -1
  15. package/docs/web-ui.md +41 -29
  16. package/harness.config.json +1 -1
  17. package/js/README.md +203 -0
  18. package/js/build/bundle.mjs +231 -0
  19. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  20. package/js/build/emit-claude.mjs +287 -0
  21. package/js/build/emit-common.mjs +255 -0
  22. package/js/build/emit-opencode.mjs +214 -0
  23. package/js/{generate.mjs → build/generate.mjs} +27 -28
  24. package/js/{render.mjs → build/render.mjs} +13 -9
  25. package/js/{checkout.mjs → build/source.mjs} +9 -9
  26. package/js/build/stubs.mjs +256 -0
  27. package/js/{themes.mjs → build/themes.mjs} +24 -24
  28. package/js/build/version.mjs +155 -0
  29. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  30. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  31. package/js/{installs.mjs → hosts/installs.mjs} +18 -16
  32. package/js/{link.mjs → hosts/link.mjs} +29 -28
  33. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  34. package/js/{native.mjs → hosts/native.mjs} +24 -22
  35. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  36. package/js/{settings.mjs → hosts/settings.mjs} +63 -55
  37. package/js/inspect/checks-authoring.mjs +761 -0
  38. package/js/inspect/checks-build.mjs +231 -0
  39. package/js/inspect/checks-repo.mjs +308 -0
  40. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  41. package/js/inspect/doctor.mjs +286 -0
  42. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  43. package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
  44. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  45. package/js/inspect/scan.mjs +125 -0
  46. package/js/{status.mjs → inspect/status.mjs} +27 -25
  47. package/js/inspect/validate.mjs +199 -0
  48. package/js/lib/fs.mjs +37 -759
  49. package/js/lib/json.mjs +344 -0
  50. package/js/lib/paths.mjs +242 -0
  51. package/js/lib/proc.mjs +3 -3
  52. package/js/lib/text.mjs +181 -0
  53. package/js/lib/udiff.mjs +1 -1
  54. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  55. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  56. package/js/{setup.mjs → maintain/setup.mjs} +34 -32
  57. package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
  58. package/js/{update.mjs → maintain/update.mjs} +51 -51
  59. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  60. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  61. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  62. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  63. package/js/web/actions.mjs +84 -85
  64. package/js/web/activity.mjs +22 -21
  65. package/js/web/api.mjs +154 -51
  66. package/js/web/daemon.mjs +319 -0
  67. package/js/web/docs.mjs +24 -21
  68. package/js/web/graph.mjs +0 -0
  69. package/js/web/handler.mjs +442 -0
  70. package/js/web/jobs.mjs +21 -19
  71. package/js/web/routes.mjs +245 -0
  72. package/js/web/server.mjs +56 -987
  73. package/package.json +11 -3
  74. package/src/doctrines/README.md +1 -1
  75. package/themes/opencode/README.md +1 -1
  76. package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-B49fE_ug.js} +1 -1
  77. package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-abt7hzD2.js} +1 -1
  78. package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-D3_bVkih.js} +1 -1
  79. package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-hXp7srsx.js} +1 -1
  80. package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-DvYMtlvl.js} +1 -1
  81. package/web/dist/assets/Harness-BIWei7N5.js +1 -0
  82. package/web/dist/assets/Laws-CxmnRdwt.js +3 -0
  83. package/web/dist/assets/{Library-DXhTN9ju.js → Library-MjbOkHIW.js} +1 -1
  84. package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-Dx_w0faR.js} +1 -1
  85. package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-BRFvv2gr.js} +1 -1
  86. package/web/dist/assets/{Rules-eND7hJv9.js → Rules-CfsV1EVy.js} +1 -1
  87. package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DArA6IHS.js} +1 -1
  88. package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-C9bZOEZk.js} +1 -1
  89. package/web/dist/assets/{activity-Dmb0FTAx.js → activity-Cc0n2jwx.js} +1 -1
  90. package/web/dist/assets/index-Bx3oxZ1o.css +1 -0
  91. package/web/dist/assets/index-DC3-GlCY.js +1 -0
  92. package/web/dist/assets/index-DP035wPa.js +12 -0
  93. package/web/dist/assets/{index-Dg58ScBe.js → index-dHi-7M-F.js} +1 -1
  94. package/web/dist/index.html +2 -2
  95. package/web/src/pages/Laws.jsx +5 -19
  96. package/docs/declined.md +0 -200
  97. package/docs/limits.md +0 -329
  98. package/js/doctor.mjs +0 -1750
  99. package/js/emit.mjs +0 -1411
  100. package/web/dist/assets/About-DyK7yRy7.js +0 -1
  101. package/web/dist/assets/Harnesses-DgRyOviS.js +0 -1
  102. package/web/dist/assets/Laws-CWk3Mpri.js +0 -3
  103. package/web/dist/assets/Themes-CoAWuIeM.js +0 -2
  104. package/web/dist/assets/index-B-mXquBZ.css +0 -1
  105. package/web/dist/assets/index-CH_Z725C.js +0 -12
  106. package/web/dist/assets/index-jrY4m9jP.js +0 -1
@@ -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
+
@@ -0,0 +1,255 @@
1
+ /**
2
+ * What every emit shares — the constants that name what this tool owns, the small readers it
3
+ * walks a tree with, and the three writers both GLOBAL emits call.
4
+ *
5
+ * Split out of `emit.mjs` because each of `globalMemory`, `globalNotebook`, `shipLeanLaws`
6
+ * and `stripCapabilityLinks` has two callers in two different host emits. A helper with two
7
+ * hosts belongs to neither, and putting it in one of them is how a file grows to 1400 lines.
8
+ *
9
+ * Nothing here decides WHICH host is being emitted. `emit-opencode.mjs` and
10
+ * `emit-claude.mjs` do that; `emit.mjs` renders the bundle they read from.
11
+ */
12
+ import path from 'node:path';
13
+ import { copyFile, writeText } from '../lib/fs.mjs';
14
+ import { SRC_DIR_TOKENS, destRel } from './render.mjs';
15
+ import { mkdirSync, readdirSync, statSync } from 'node:fs';
16
+
17
+ /** `_build_render.SRC_DIRS_MARKER`. */
18
+ export const SRC_DIRS_MARKER = '.geneseed-srcdirs.json';
19
+ /**
20
+ * `_build_render.OWNED_SRC_DIRS` — wiped and regenerated each run.
21
+ *
22
+ * `ontology` and `doctrines` are appended, not inserted: this array's iteration order is the
23
+ * key order of the `.geneseed-srcdirs.json` marker it fills, and appending keeps the three
24
+ * original keys where an existing install's marker already has them.
25
+ *
26
+ * They belong here for the same reason `laws` does — they are GENERATED, so an install must
27
+ * not keep a copy the source no longer produces. Without them a build writes the dirs on
28
+ * every run and never wipes them, which turns the doctrines toggle into a one-way switch:
29
+ * disable a pack, rebuild, and its rendered file sits in the install forever, contradicting
30
+ * the `Active packs:` line in the AGENT.md right beside it.
31
+ */
32
+ export const OWNED_SRC_DIRS = ['laws', 'agents', 'skills', 'ontology', 'doctrines'];
33
+
34
+ /**
35
+ * The constitution dirs a LEAN emit re-ships in full text — see `shipLeanLaws`.
36
+ *
37
+ * Not all of `OWNED_SRC_DIRS`: `agents` and `skills` are already written as whole files by
38
+ * the native layer, and only the three constitution dirs are inlined into AGENT.md in a
39
+ * form (terse, or pack-filtered) that loses text the reader may still need.
40
+ */
41
+ const LEAN_FULL_TEXT_DIRS = ['laws', 'ontology', 'doctrines'];
42
+
43
+ /**
44
+ * `_build_core.CAPABILITY_LINK_RE` — the DEFAULT only.
45
+ *
46
+ * `cfg.capabilityLinkRe` overrides it and the driver always supplies one, for the same
47
+ * reason `cfg.structure` exists: the constant is in `_OWNED` because a doctor test
48
+ * redirects it to the pre-fix form and asserts the emit then renders dead links, and a
49
+ * redirect that stops at the process boundary half-works silently. The literal stays as
50
+ * the fallback for the parity harnesses, which drive this module in-process.
51
+ */
52
+ const CAPABILITY_LINK_RE =
53
+ /\[([^\]]+)\]\((?:(?!https?:\/\/|\/)[A-Za-z0-9_.-]+\/)*(?:agents|skills)\/[A-Za-z0-9_-]+\.md\)/g;
54
+
55
+ /** `_build_render._TMPL_SPEC_RE`. */
56
+ export const TMPL_SPEC_RE = /\{\{DIR_(AGENTS|SKILLS)\}\}\/([A-Za-z0-9_-]+)\.md/g;
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Small filesystem predicates. Python spells these `Path.is_file()` / `is_dir()`, both
60
+ // of which answer False for a missing path rather than raising; `statSync` throws.
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export function isFile(p) {
64
+ try { return statSync(p).isFile(); } catch { return false; }
65
+ }
66
+
67
+ export function isDir(p) {
68
+ try { return statSync(p).isDirectory(); } catch { return false; }
69
+ }
70
+
71
+ /** `dict.get(key)` with Python's semantics — OWN properties only.
72
+ *
73
+ * The same hazard `js/hosts/settings.mjs` and `js/hosts/native.mjs` both spell out: the keys here come
74
+ * out of a manifest the user can edit, and `old['constructor']` hands back
75
+ * `Object.prototype`'s member where Python's `.get` returns None. */
76
+ export function get(obj, key) {
77
+ return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
78
+ }
79
+
80
+ export function isDict(v) {
81
+ return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
82
+ }
83
+
84
+ /** `Path.rglob("*")` restricted to files, skipping `__pycache__` as Python's callers do. */
85
+ export function rglobFiles(root, out = []) {
86
+ for (const name of readdirSync(root)) {
87
+ if (name === '__pycache__') continue;
88
+ const full = path.join(root, name);
89
+ if (isDir(full)) rglobFiles(full, out);
90
+ else out.push(full);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * `Path.rglob("*")` restricted to files, keeping EVERYTHING — including `__pycache__`.
97
+ *
98
+ * Not a duplicate of `rglobFiles` by accident: `source_fingerprint`'s Python filters
99
+ * `__pycache__` and the legacy-store migration in `_global_memory` does not, so a single
100
+ * walk would be wrong for one caller either way. A user's legacy bundle really can carry
101
+ * one (the harness ships `.py` skill scripts), and copying it or not is a byte difference
102
+ * the two runtimes would disagree on silently.
103
+ */
104
+ function rglobAllFiles(root, out = []) {
105
+ for (const name of readdirSync(root)) {
106
+ const full = path.join(root, name);
107
+ if (isDir(full)) rglobAllFiles(full, out);
108
+ else out.push(full);
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** `p.relative_to(base).as_posix()`. */
114
+ export function relPosix(base, p) {
115
+ return path.relative(base, p).split(path.sep).join('/');
116
+ }
117
+
118
+ /**
119
+ * `_build_emit._strip_capability_links` — `re.sub(r"\1")`, so the pattern needs `g`.
120
+ *
121
+ * Exported for `js/inspect/checks-build.mjs`: `_rendered_problems` applies the same de-linking before
122
+ * comparing an OpenCode bundle's AGENT.md against a fresh render, because the emit puts
123
+ * that difference back by design and a check that did not would report the file stale on
124
+ * every run forever.
125
+ */
126
+ export function stripCapabilityLinks(cfg, text) {
127
+ const re = cfg.capabilityLinkRe
128
+ ? new RegExp(cfg.capabilityLinkRe, 'g')
129
+ : CAPABILITY_LINK_RE;
130
+ return text.replace(re, '$1');
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // The global-store helpers, shared by the `opencode-global` and `claude` jobs
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * `_build_global._global_memory` — ensure `<cfg>/memory` exists, without ever touching a
139
+ * store that already holds something.
140
+ *
141
+ * The three outcomes are the returned status string, which the emit prints, so they are
142
+ * compared through stdout as well as through the tree. Python's `theme` parameter is
143
+ * dropped: the store dir is ALWAYS the classic English `memory/`, never themed (the
144
+ * OpenCode config dir uses fixed names), so the argument was never read.
145
+ *
146
+ * The migration branch copies arbitrary USER files out of a legacy bundle, which is the
147
+ * reason this is not a plain translation: everything it touches is the user's, and the
148
+ * only thing keeping it safe is that it runs at all only when the destination is empty.
149
+ */
150
+ export function globalMemory(cfgDir, items, legacy, srcRoot) {
151
+ const memName = 'memory';
152
+ const memDir = path.join(cfgDir, memName);
153
+ if (isDir(memDir) && readdirSync(memDir).length) return `kept ${memName}/`;
154
+ mkdirSync(memDir, { recursive: true });
155
+ if (legacy) {
156
+ // `dict.fromkeys([mem_name, "memory", "anamnesis"])` — de-duplicated, order kept.
157
+ // `mem_name` is the literal 'memory' and the Python docstring beside it says the
158
+ // store name is NEVER themed, so the first two entries always collapse and
159
+ // `anamnesis` — an older themed install's name — is the only live alias. The dedupe
160
+ // is what keeps the duplicate harmless rather than copying the same dir twice.
161
+ for (const nm of [...new Set([memName, 'memory', 'anamnesis'])]) {
162
+ const src = path.join(legacy, nm);
163
+ if (isDir(src) && readdirSync(src).length) {
164
+ for (const f of rglobAllFiles(src)) {
165
+ const dest = path.join(memDir, path.relative(src, f));
166
+ mkdirSync(path.dirname(dest), { recursive: true });
167
+ copyFile(f, dest);
168
+ }
169
+ return `migrated ${nm}/ -> ${memName}/`;
170
+ }
171
+ }
172
+ }
173
+ for (const { text, src } of items) {
174
+ const sp = relPosix(srcRoot, src).split('/');
175
+ if (sp[0] === 'memory' && sp.length > 1) {
176
+ // `destRel`, because this seeds from the SOURCE path and not from the item's `rel`
177
+ // (which is themed, and `memory/` never is). Without it the store would be seeded
178
+ // with the on-disk name `gitignore` — see `js/build/render.mjs`'s `destRel`.
179
+ const dest = path.join(memDir, destRel(path.join(...sp.slice(1))));
180
+ mkdirSync(path.dirname(dest), { recursive: true });
181
+ if (text !== null) writeText(dest, text);
182
+ else copyFile(src, dest);
183
+ }
184
+ }
185
+ return `seeded ${memName}/`;
186
+ }
187
+
188
+ /** `_build_global._global_notebook` — the same shape, with no `anamnesis` alias. */
189
+ export function globalNotebook(cfgDir, items, legacy, srcRoot) {
190
+ const nbName = 'notebook';
191
+ const nbDir = path.join(cfgDir, nbName);
192
+ if (isDir(nbDir) && readdirSync(nbDir).length) return `kept ${nbName}/`;
193
+ mkdirSync(nbDir, { recursive: true });
194
+ if (legacy) {
195
+ const src = path.join(legacy, nbName);
196
+ if (isDir(src) && readdirSync(src).length) {
197
+ for (const f of rglobAllFiles(src)) {
198
+ const dest = path.join(nbDir, path.relative(src, f));
199
+ mkdirSync(path.dirname(dest), { recursive: true });
200
+ copyFile(f, dest);
201
+ }
202
+ return `migrated ${nbName}/`;
203
+ }
204
+ }
205
+ for (const { text, src } of items) {
206
+ const sp = relPosix(srcRoot, src).split('/');
207
+ if (sp[0] === nbName && sp.length > 1) {
208
+ // `destRel` — same reason as `globalMemory` above.
209
+ const dest = path.join(nbDir, destRel(path.join(...sp.slice(1))));
210
+ mkdirSync(path.dirname(dest), { recursive: true });
211
+ if (text !== null) writeText(dest, text);
212
+ else copyFile(src, dest);
213
+ }
214
+ }
215
+ return `seeded ${nbName}/`;
216
+ }
217
+
218
+ /**
219
+ * `_build_global._ship_lean_laws`.
220
+ *
221
+ * Historically the one RENDER that ran AFTER a WIRE, which is why the phase-order
222
+ * normalisation had to move it before the seam could be cut. Re-derived rather than
223
+ * trusted: its inputs are `items` and `theme` (both from `render_all`) and its only
224
+ * output besides the files is what it APPENDS to `owned` — which travels back in the
225
+ * payload for the manifest and the prune. It reads nothing any wiring stage writes, so
226
+ * its new position is not merely legal, it has no dependency on the old one.
227
+ *
228
+ * NO LONGER LEAN-ONLY, and the name is kept because the lean case is still the whole reason
229
+ * it exists. `doctrines` ships at BOTH footprints, because the two footprints lose different
230
+ * things: lean loses text (every rule is truncated to its first sentence, so the full text has
231
+ * to be on disk), while FULL loses whole packs (only the active ones are inlined, so the
232
+ * inactive ones have to be on disk). AGENT.md tells the agent it may read a rule in a pack
233
+ * that is not active, and `src/doctrines/README.md` rests cross-pack citations on exactly
234
+ * that — a claim the emit has to make true, not merely the `files` bundle.
235
+ */
236
+ export function shipLeanLaws(items, theme, cfgDir, owned, footprint) {
237
+ // Themed names, resolved through the same token table `build`'s wipe resolves, so the dir
238
+ // a lean emit re-ships and the dir it prunes can never be two different spellings.
239
+ const wanted = footprint === 'lean' ? LEAN_FULL_TEXT_DIRS : ['doctrines'];
240
+ const dirs = new Set(wanted.map((d) => theme[SRC_DIR_TOKENS[d]] ?? d));
241
+ for (const { rel, text } of items) {
242
+ const parts = rel.split('/');
243
+ // `parts.length` mirrors Python's `parts and parts[0]`, where `Path("").parts` really
244
+ // is empty. `String.split` never returns an empty array, so this half can never be
245
+ // false here — kept for the mirror, and its mutation stays green because there is
246
+ // nothing to detect, not because the gate cannot see it.
247
+ if (text !== null && parts.length && dirs.has(parts[0])) {
248
+ const dest = path.join(cfgDir, ...parts);
249
+ mkdirSync(path.dirname(dest), { recursive: true });
250
+ writeText(dest, text);
251
+ owned.push(rel);
252
+ }
253
+ }
254
+ }
255
+