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,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
+
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The OpenCode emits — a project's `.opencode/` and the global config directory.
3
+ *
4
+ * Split out of `emit.mjs` beside `emit-claude.mjs`, over the shared writers in
5
+ * `emit-common.mjs`. The two are siblings on purpose: an emit that is right for one host and
6
+ * wrong for the other is the defect these have historically carried, and it reads far more
7
+ * plainly as two files with the same shape than as two sections of one.
8
+ */
9
+ import path from 'node:path';
10
+ import { VERSION_MARKER } from '../hosts/hosts.mjs';
11
+ import { loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
12
+ import {
13
+ copyPlugins, copyWorkflows, ensureAgentOverridesStub, writeColorThemes, writeCommandLayer,
14
+ writePonytailCommand, writePrimaryAgent, writeTheme,
15
+ } from '../hosts/opencode.mjs';
16
+ import { mergeOpencodeJson } from '../hosts/settings.mjs';
17
+ import { readText, writeText } from '../lib/fs.mjs';
18
+ import { assertSourceComplete, build, phaseLog } from './bundle.mjs';
19
+ import {
20
+ globalMemory, globalNotebook, isFile, relPosix, shipLeanLaws, stripCapabilityLinks,
21
+ } from './emit-common.mjs';
22
+ import { renderAll } from './render.mjs';
23
+ import {
24
+ ensureExcludesStub, ensureMemoryIndex, ensureNotebookIndex, ensureProfileStub, ensureRulesStub,
25
+ ensureWikiStub,
26
+ } from './stubs.mjs';
27
+ import { writeVersion } from './version.mjs';
28
+ import { mkdirSync } from 'node:fs';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // emit_opencode — the RENDER stage only
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * RENDER and WIRE for `_build_emit.emit_opencode` — everything through the
36
+ * `mergeOpencodeJson` call at the bottom. PRUNE and MANIFEST are the driver's, in
37
+ * `bin/build-driver.mjs`.
38
+ *
39
+ * `oldOwned` and `manifestExisted` arrive in the job rather than being read here. The
40
+ * manifest is the DRIVER's stage: it reads the file, hands the prior claim in, and prunes
41
+ * and rewrites it after this returns. One owner of the file, and this function is not it.
42
+ *
43
+ * `nativeCatalog` likewise arrives decided. It is `HOSTS['opencode']['native_catalog']` from
44
+ * `js/hosts/hosts.mjs`, resolved by the driver before the call; passing the decision rather
45
+ * than re-reading the registry keeps one owner of it.
46
+ */
47
+ export function emitOpencodeRender(cfg, job) {
48
+ phaseLog('RENDER');
49
+ const {
50
+ theme: _theme, out, root, footprint, nativeCatalog, oldOwned, manifestExisted, agentPath,
51
+ } = job;
52
+ const oc = path.join(root, '.opencode');
53
+
54
+ build(cfg, _theme, out, { footprint, nativeCatalog });
55
+
56
+ // OpenCode loads agents/skills natively, so strip AGENT.md's per-row spec links to
57
+ // plain names (the portable build keeps them). A deliberate de-link, not a fix.
58
+ const agentMd = path.join(out, 'AGENT.md');
59
+ if (isFile(agentMd)) writeText(agentMd, stripCapabilityLinks(cfg, readText(agentMd)));
60
+
61
+ const owned = [];
62
+ const { theme, items } = renderAll(cfg, _theme);
63
+
64
+ ensureAgentOverridesStub(cfg, out);
65
+ const overrides = loadAgentOverrides(out);
66
+
67
+ const { nAgents, nSkills, written } = writeNativeLayer(
68
+ items, path.join(oc, 'agents'), path.join(oc, 'skills'), overrides,
69
+ { host: 'opencode', oldOwned, cfg: oc, manifestExisted, theme, src: cfg.src });
70
+ for (const p of written) owned.push(relPosix(oc, p));
71
+
72
+ const primary = writePrimaryAgent(cfg, path.join(oc, 'agents'), overrides);
73
+ if (primary) owned.push(relPosix(oc, primary));
74
+
75
+ const commands = writeCommandLayer(cfg, items, path.join(oc, 'command'));
76
+ commands.push(writePonytailCommand(path.join(oc, 'command'))); // always-on /ponytail
77
+ for (const p of commands) owned.push(relPosix(oc, p));
78
+
79
+ owned.push(relPosix(oc, writeTheme(path.join(oc, 'themes'), _theme, theme)));
80
+ for (const p of writeColorThemes(cfg, path.join(oc, 'themes'))) owned.push(relPosix(oc, p));
81
+
82
+ const nPlugins = copyPlugins(cfg, path.join(oc, 'plugins'), owned);
83
+ const nWorkflows = copyWorkflows(cfg, path.join(oc, 'workflows'), owned);
84
+
85
+ // WIRE — the one file of this emit the user co-owns. `agentPath` arrives decided
86
+ // (`_rel_under` is the Python side by design), and only the BASENAME is consumed
87
+ // downstream: `opencode.json`, or the `.jsonc` sibling when that is what is on disk.
88
+ phaseLog('WIRE');
89
+ const cfgName = path.basename(mergeOpencodeJson(path.join(root, 'opencode.json'), agentPath,
90
+ cfg.doctrines, cfg.excludeRules));
91
+
92
+ return {
93
+ owned,
94
+ stats: {
95
+ nAgents, nSkills, nPlugins, nWorkflows, nCommands: commands.length, primary: !!primary,
96
+ },
97
+ cfgName,
98
+ };
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // emit_opencode_global — the ninth, and the last emit to cross
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * BOTH halves of `_build_global.emit_opencode_global`, the "everything global, zero
107
+ * per-repo" OpenCode deployment. PRUNE and MANIFEST are the driver's; there is no VERIFY,
108
+ * because this emit writes no settings file.
109
+ *
110
+ * ASSEMBLY, NOT TRANSLATION. Every unit it needs was already here — 58 of the 65 functions
111
+ * in its Python closure, counted with `ast` rather than by name-mapping, which is how the
112
+ * scout that produced this list first reported `_posture_body` and `_mode_body` as missing
113
+ * when both are `registerBody` in `js/build/render.mjs`. What is new is the assembly and the
114
+ * three values that had to be decided on the Python side.
115
+ *
116
+ * `cfgDir` IS THE FIRST OF THEM AND THE REASON THE OTHERS WERE ASKED ABOUT.
117
+ * `_build_core._opencode_config_dir` is an `_OWNED` name — the suite and `harness diff`
118
+ * both redirect it at a sandbox — so resolving it here would render 135 files into the
119
+ * developer's real `~/.config/opencode`. Python's entry point resolves it once, before
120
+ * anything else, and sends the answer. Fourth phase running (`STRUCTURE` at P2d,
121
+ * `capabilityLinkRe` at P2e, `_PREAMBLE_CONFIG_DIR` at P3b): send the decision, never the
122
+ * resolver.
123
+ *
124
+ * `out` IS NOT THE TARGET, and with the default nobody can tell. `emit_opencode_global`
125
+ * writes into `<cfg>` and takes `out` ONLY as the legacy bundle to migrate a memory or
126
+ * notebook store from; the CLI's `--out` defaults to a directory this emit never writes a
127
+ * byte into. A port that derived the legacy source from `<cfg>` would pass every cell that
128
+ * leaves them coincident — the `claude/bundle-in-subfolder` finding at a third call site.
129
+ *
130
+ * `agentPath` travels for the same reason `emitOpencodeRender`'s does: it is WIRE's one
131
+ * input, and `Path.as_posix()` is the Python side's spelling of it.
132
+ */
133
+ export function emitOpencodeGlobalRender(cfg, job) {
134
+ phaseLog('RENDER');
135
+ const {
136
+ theme: themeName, cfgDir, out, footprint, nativeCatalog, oldOwned, agentPath,
137
+ } = job;
138
+
139
+ // No `lawsPrefix`: the standalone laws dir sits beside AGENT.md in <cfg>, so the lean
140
+ // pointer's relative `laws/universal.md` resolves with no prefix.
141
+ const { theme, items } = renderAll(cfg, themeName, { footprint, nativeCatalog });
142
+ assertSourceComplete(cfg, 'opencode-global');
143
+ mkdirSync(cfgDir, { recursive: true });
144
+
145
+ const owned = [];
146
+ const agentText = items.find((i) => i.rel === 'AGENT.md' && i.text !== null)?.text ?? null;
147
+ if (agentText !== null) {
148
+ // OpenCode loads agents/skills natively, so drop AGENT.md's per-row spec links to
149
+ // plain names. Memory links stay RELATIVE: in the global layout AGENT.md and the
150
+ // store are siblings, so `memory/` resolves from AGENT.md's own location and stays
151
+ // hermetic — no absolute path a doctor check would flag as an escape.
152
+ writeText(path.join(cfgDir, 'AGENT.md'), stripCapabilityLinks(cfg, agentText));
153
+ owned.push('AGENT.md');
154
+ }
155
+
156
+ ensureAgentOverridesStub(cfg, cfgDir);
157
+ const overrides = loadAgentOverrides(cfgDir);
158
+
159
+ // `manifestExisted` is deliberately not passed — the Python does not pass it either, so
160
+ // the pre-manifest header line is unreachable from this emit on both sides.
161
+ const { nAgents, nSkills, written } = writeNativeLayer(
162
+ items, path.join(cfgDir, 'agents'), path.join(cfgDir, 'skills'), overrides,
163
+ { host: 'opencode', oldOwned, cfg: cfgDir, theme, src: cfg.src });
164
+ for (const p of written) owned.push(relPosix(cfgDir, p));
165
+
166
+ const primary = writePrimaryAgent(cfg, path.join(cfgDir, 'agents'), overrides);
167
+ if (primary) owned.push(relPosix(cfgDir, primary));
168
+
169
+ const commands = writeCommandLayer(cfg, items, path.join(cfgDir, 'command'));
170
+ commands.push(writePonytailCommand(path.join(cfgDir, 'command'))); // always-on /ponytail
171
+ for (const p of commands) owned.push(relPosix(cfgDir, p));
172
+
173
+ owned.push(relPosix(cfgDir, writeTheme(path.join(cfgDir, 'themes'), themeName, theme)));
174
+ for (const p of writeColorThemes(cfg, path.join(cfgDir, 'themes'))) {
175
+ owned.push(relPosix(cfgDir, p));
176
+ }
177
+
178
+ const nPlugins = copyPlugins(cfg, path.join(cfgDir, 'plugins'), owned);
179
+ const nWorkflows = copyWorkflows(cfg, path.join(cfgDir, 'workflows'), owned);
180
+
181
+ const memStatus = globalMemory(cfgDir, items, out, cfg.src);
182
+ ensureMemoryIndex(path.join(cfgDir, 'memory'));
183
+ const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
184
+ ensureNotebookIndex(path.join(cfgDir, 'notebook'));
185
+ ensureWikiStub(cfgDir);
186
+ ensureRulesStub(cfgDir);
187
+ ensureProfileStub(cfgDir);
188
+ ensureExcludesStub(cfgDir);
189
+
190
+ writeVersion(cfg, cfgDir);
191
+ owned.push(VERSION_MARKER);
192
+
193
+ shipLeanLaws(items, theme, cfgDir, owned, footprint);
194
+
195
+ // WIRE — the one file of this emit the user co-owns, and the last render is now behind
196
+ // us. Inlined the way `emitOpencodeRender`'s is (one call, the same merge, the same file
197
+ // name). Nothing walks the SOURCE for that split now: `tests/unit/emit_phase_order.test.mjs`
198
+ // reads the runtime `GENESEED_PHASE_LOG` trace instead, so the `phaseLog('WIRE')` below is
199
+ // what the gate sees — and deleting it is mutation M30.
200
+ phaseLog('WIRE');
201
+ const cfgName = path.basename(mergeOpencodeJson(path.join(cfgDir, 'opencode.json'),
202
+ agentPath, cfg.doctrines, cfg.excludeRules));
203
+
204
+ return {
205
+ owned,
206
+ stats: {
207
+ nAgents, nSkills, nPlugins, nWorkflows, nCommands: commands.length, primary: !!primary,
208
+ },
209
+ memStatus,
210
+ nbStatus,
211
+ cfgName,
212
+ };
213
+ }
214
+
@@ -1,19 +1,19 @@
1
1
  /**
2
- * `rituals/_harness_build.py`'s three cheap verbs — `build`, `prompt` and `theme`.
2
+ * The three cheap generator verbs — `build`, `prompt` and `theme`.
3
3
  *
4
4
  * These are the harness's GENERATOR FACE: the subcommands whose whole job is to drive the
5
- * thing `bin/geneseed.mjs` already is. That is why they are the cheapest slice left and why
5
+ * thing `bin/build-driver.mjs` already is. That is why they are the cheapest slice left and why
6
6
  * they are the last one that can be measured by a closure walk alone — `cmd_build`'s body is
7
7
  * three lines because the work is a `subprocess`, and a name walk cannot see through one.
8
8
  *
9
9
  * WHY `build` IMPORTS THE DRIVER RATHER THAN SPAWNING IT. The Python is
10
10
  * `run([sys.executable, BUILD, *extra]).returncode` — a passthrough to a second process.
11
- * Reproducing that shape here would mean spawning `node bin/geneseed.mjs`, and
11
+ * Reproducing that shape here would mean spawning `node bin/build-driver.mjs`, and
12
12
  * `test_the_cli_reaches_no_child_process_module` forbids it: the ban is transitive over this
13
13
  * entry's imports, and P5c's argument for keeping it is that `web`, `upgrade` and `setup`
14
14
  * will genuinely need to spawn, so the ban should outlive them rather than be dismantled by
15
15
  * a verb that does not. It is also the wrong shape on its own terms. Python needs a second
16
- * process because `build.py` is a different PROGRAM; `bin/geneseed.mjs` is a module in this
16
+ * process because `build.py` is a different PROGRAM; `bin/build-driver.mjs` is a module in this
17
17
  * one, and a Node CLI spawning a Node driver to do work it could do in-process is exactly
18
18
  * the passthrough the whole port has been removing. So the driver's `main` is exported and
19
19
  * called directly, and the observable contract — the tree, the streams, the exit code — is
@@ -32,19 +32,18 @@
32
32
  import { existsSync, mkdirSync, statSync } from 'node:fs';
33
33
  import path from 'node:path';
34
34
 
35
- import { main as driverMain } from '../bin/geneseed.mjs';
36
- import { makeCfg, PACK_ORDER } from './checkout.mjs';
37
- import { opencodeConfigDir, pyResolve } from './hosts.mjs';
35
+ import { main as driverMain } from '../../bin/build-driver.mjs';
36
+ import { makeCfg, PACK_ORDER } from './source.mjs';
37
+ import { opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
38
38
  import {
39
39
  EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
40
40
  excludedRulesOfDir, footprintOfDir, installState, installTargets, modeOfDir, postureOfDir,
41
41
  readMaybe, themeOfDir,
42
- } from './installs.mjs';
43
- import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from './opencode.mjs';
42
+ } from '../hosts/installs.mjs';
43
+ import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from '../hosts/opencode.mjs';
44
44
  import { renderAll } from './render.mjs';
45
- import {
46
- jsonDumpsIndent, parseJson, pyPrint, pyPrintErr, pyRepr, readText, writeText,
47
- } from './lib/fs.mjs';
45
+ import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
46
+ import { jsonDumpsIndent, parseJson, formatRepr } from '../lib/json.mjs';
48
47
 
49
48
  /** `_harness_build._HEX_RE`. Anchored at BOTH ends — `#123` and `#1122334` both fail. */
50
49
  const HEX_RE = /^#[0-9a-fA-F]{6}$/;
@@ -52,18 +51,18 @@ const HEX_RE = /^#[0-9a-fA-F]{6}$/;
52
51
  /**
53
52
  * `raise SystemExit(msg)` — the message on stderr, exit 1.
54
53
  *
55
- * The convention `js/emit.mjs`'s `assertSourceComplete` set and `bin/geneseed-cli.mjs`
54
+ * The convention `js/build/bundle.mjs`'s `assertSourceComplete` set and `bin/geneseed-cli.mjs`
56
55
  * honours: write at the raise site, mark the throw as a DELIBERATE refusal with `exitCode`,
57
56
  * and let anything unmarked keep its stack. Python prints a traceback for an unhandled
58
57
  * exception, so dressing a crash as a one-liner would hide a bug.
59
58
  */
60
59
  function sysExit(msg) {
61
- // `pyPrintErr`, NOT a local newline translation. The first draft inlined the `\n` ->
60
+ // `printErr`, NOT a local newline translation. The first draft inlined the `\n` ->
62
61
  // `\r\n` rule here and every cell passed, because `harness_golden` reads stderr through
63
62
  // `subprocess`'s universal-newline decode and cannot see the difference — the same
64
63
  // transport hole P5b measured. A second copy of a translation is exactly what P5d's
65
- // `pyStrPath`/`pyPathStr` split was, and it is ungated in precisely the same way.
66
- pyPrintErr(`${msg}\n`);
64
+ // `pyStrPath`/`toPlatformPath` split was, and it is ungated in precisely the same way.
65
+ printErr(`${msg}\n`);
67
66
  const e = new Error(msg);
68
67
  e.exitCode = 1;
69
68
  throw e;
@@ -188,7 +187,7 @@ export const DEFAULT_EMIT = new Map([
188
187
  export function cmdRebuildAll() {
189
188
  const targets = installTargets().filter(([h, s, r]) => installState(r, h, s) === 'active');
190
189
  if (!targets.length) {
191
- pyPrint('[rebuild-all] no active installs detected.\n');
190
+ printOut('[rebuild-all] no active installs detected.\n');
192
191
  return 0;
193
192
  }
194
193
  const failures = [];
@@ -218,20 +217,20 @@ export function cmdRebuildAll() {
218
217
  const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
219
218
  PACK_ORDER, excludeRules);
220
219
  const label = `${host}:${scope} (${root})`;
221
- pyPrint(`[rebuild-all] ${label}: theme=${theme} emit=${emit} footprint=${footprint} `
220
+ printOut(`[rebuild-all] ${label}: theme=${theme} emit=${emit} footprint=${footprint} `
222
221
  + `posture=${posture} mode=${mode}\n`);
223
222
  const rc = driverMain(argv);
224
223
  if (rc !== 0) {
225
224
  failures.push(label);
226
- pyPrintErr(`[rebuild-all] FAILED ${label} (exit ${rc})\n`);
225
+ printErr(`[rebuild-all] FAILED ${label} (exit ${rc})\n`);
227
226
  }
228
227
  }
229
228
  if (failures.length) {
230
- pyPrintErr(`[rebuild-all] ${failures.length}/${targets.length} install(s) failed: `
229
+ printErr(`[rebuild-all] ${failures.length}/${targets.length} install(s) failed: `
231
230
  + `${failures.join(', ')}\n`);
232
231
  return 1;
233
232
  }
234
- pyPrint(`[rebuild-all] rebuilt ${targets.length} install(s).\n`);
233
+ printOut(`[rebuild-all] rebuilt ${targets.length} install(s).\n`);
235
234
  return 0;
236
235
  }
237
236
 
@@ -311,9 +310,9 @@ export function cmdPrompt(args) {
311
310
  if (dest) mkdirSync(dest, { recursive: true });
312
311
  writeText(args.out, text);
313
312
  // The RAW argument, not the resolved path — `f"[prompt] wrote {args.out}"`.
314
- pyPrint(`[prompt] wrote ${args.out} (${themeName})\n`);
313
+ printOut(`[prompt] wrote ${args.out} (${themeName})\n`);
315
314
  } else {
316
- pyPrint(text);
315
+ printOut(text);
317
316
  }
318
317
  return 0;
319
318
  }
@@ -324,8 +323,8 @@ export function cmdPrompt(args) {
324
323
 
325
324
  /** `_harness_build._resolve_themes_dir` — explicit `--dir`, else a repo `.opencode/`, else global. */
326
325
  export function resolveThemesDir(args) {
327
- // `Path(args.dir).expanduser().resolve()` — `pyResolve` already expands a leading `~`.
328
- if (args.dir) return pyResolve(args.dir);
326
+ // `Path(args.dir).expanduser().resolve()` — `resolvePath` already expands a leading `~`.
327
+ if (args.dir) return resolvePath(args.dir);
329
328
  const repo = path.join(process.cwd(), '.opencode');
330
329
  if (!args.globalDir && isDir(repo)) return path.join(repo, 'themes');
331
330
  return path.join(opencodeConfigDir(), 'themes');
@@ -383,7 +382,7 @@ export function loadUserPalette(args, cfg) {
383
382
  }
384
383
  // `f"{r}={pal[r]!r}"` — Python's repr, so a string is single-quoted and an int is bare.
385
384
  const bad = [...pal].filter(([, v]) => !(typeof v === 'string' && HEX_RE.test(v)))
386
- .map(([r, v]) => `${r}=${pyRepr(v)}`);
385
+ .map(([r, v]) => `${r}=${formatRepr(v)}`);
387
386
  if (bad.length) sysExit(`[theme] non-#rrggbb value(s): ${bad.join(', ')}`);
388
387
  return pal;
389
388
  }
@@ -421,8 +420,8 @@ export function cmdTheme(args) {
421
420
  writeText(dest, `${jsonDumpsIndent(colorThemeJson(Object.fromEntries(pal), transparent))}\n`);
422
421
  written.push(dest);
423
422
  }
424
- pyPrint(`[theme] wrote ${written.map((p) => path.basename(p)).join(', ')} to ${destDir}\n`);
425
- pyPrint(`[theme] select in OpenCode with: /theme ${full}`
423
+ printOut(`[theme] wrote ${written.map((p) => path.basename(p)).join(', ')} to ${destDir}\n`);
424
+ printOut(`[theme] select in OpenCode with: /theme ${full}`
426
425
  + (args.solidOnly ? '' : ` (or /theme ${full}-transparent)`) + '\n');
427
426
  return 0;
428
427
  }