geneseed 3.1.0 → 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.
- package/CHANGELOG.md +110 -0
- package/DESIGN.md +31 -20
- package/README.md +2 -2
- package/SETUP.md +30 -3
- package/SHIPPED.md +1 -1
- package/bin/{geneseed.mjs → build-driver.mjs} +88 -42
- package/bin/geneseed-cli.mjs +33 -32
- package/bin/geneseed-hook.mjs +7 -7
- package/docs/design-history.md +40 -40
- package/docs/extending.md +113 -85
- package/docs/web/rules.md +2 -0
- package/docs/web/setup-choices.md +13 -1
- package/harness.config.json +1 -1
- package/js/README.md +203 -0
- package/js/build/bundle.mjs +231 -0
- package/js/{catalog.mjs → build/catalog.mjs} +7 -7
- package/js/build/emit-claude.mjs +287 -0
- package/js/build/emit-common.mjs +255 -0
- package/js/build/emit-opencode.mjs +214 -0
- package/js/{generate.mjs → build/generate.mjs} +45 -31
- package/js/{render.mjs → build/render.mjs} +72 -11
- package/js/{checkout.mjs → build/source.mjs} +52 -11
- package/js/build/stubs.mjs +256 -0
- package/js/{themes.mjs → build/themes.mjs} +24 -24
- package/js/build/version.mjs +155 -0
- package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
- package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
- package/js/{installs.mjs → hosts/installs.mjs} +68 -16
- package/js/{link.mjs → hosts/link.mjs} +29 -28
- package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
- package/js/{native.mjs → hosts/native.mjs} +24 -22
- package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
- package/js/{settings.mjs → hosts/settings.mjs} +94 -69
- package/js/inspect/checks-authoring.mjs +761 -0
- package/js/inspect/checks-build.mjs +231 -0
- package/js/inspect/checks-repo.mjs +308 -0
- package/js/{diff.mjs → inspect/diff.mjs} +34 -31
- package/js/inspect/doctor.mjs +286 -0
- package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
- package/js/{inventory.mjs → inspect/inventory.mjs} +34 -20
- package/js/{registry.mjs → inspect/registry.mjs} +12 -10
- package/js/inspect/scan.mjs +125 -0
- package/js/{status.mjs → inspect/status.mjs} +27 -25
- package/js/inspect/validate.mjs +199 -0
- package/js/lib/fs.mjs +37 -759
- package/js/lib/json.mjs +344 -0
- package/js/lib/paths.mjs +242 -0
- package/js/lib/proc.mjs +3 -3
- package/js/lib/text.mjs +181 -0
- package/js/lib/udiff.mjs +1 -1
- package/js/{memory.mjs → maintain/memory.mjs} +10 -10
- package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
- package/js/{setup.mjs → maintain/setup.mjs} +49 -37
- package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
- package/js/{update.mjs → maintain/update.mjs} +51 -51
- package/js/{anim.mjs → ui/anim.mjs} +30 -28
- package/js/{cli.mjs → ui/cli.mjs} +10 -10
- package/js/{menu.mjs → ui/menu.mjs} +10 -9
- package/js/{tui.mjs → ui/tui.mjs} +36 -34
- package/js/web/actions.mjs +124 -89
- package/js/web/activity.mjs +22 -21
- package/js/web/api.mjs +88 -58
- package/js/web/daemon.mjs +319 -0
- package/js/web/docs.mjs +24 -21
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +442 -0
- package/js/web/jobs.mjs +29 -21
- package/js/web/routes.mjs +223 -0
- package/js/web/server.mjs +56 -987
- package/package.json +11 -3
- package/src/AGENT.md.tmpl +1 -1
- package/src/doctrines/README.md +1 -1
- package/themes/opencode/README.md +1 -1
- package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
- package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
- package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
- package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
- package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
- package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
- package/web/dist/assets/Laws-BK07NKw9.js +3 -0
- package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
- package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
- package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
- package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
- package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
- package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
- package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
- package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
- package/web/dist/assets/index-BE226Gv6.css +1 -0
- package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
- package/web/dist/assets/index-BgIs5eIa.js +12 -0
- package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +135 -69
- package/docs/declined.md +0 -200
- package/docs/limits.md +0 -329
- package/js/doctor.mjs +0 -1712
- package/js/emit.mjs +0 -1410
- package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
- package/web/dist/assets/index-1pPSz7Kl.css +0 -1
- package/web/dist/assets/index-C-gb-q-r.js +0 -12
|
@@ -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
|
-
*
|
|
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/
|
|
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/
|
|
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/
|
|
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,18 +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 '
|
|
36
|
-
import { makeCfg, PACK_ORDER } from './
|
|
37
|
-
import { opencodeConfigDir,
|
|
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
|
-
footprintOfDir, installState, installTargets, modeOfDir, postureOfDir,
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
excludedRulesOfDir, footprintOfDir, installState, installTargets, modeOfDir, postureOfDir,
|
|
41
|
+
readMaybe, themeOfDir,
|
|
42
|
+
} from '../hosts/installs.mjs';
|
|
43
|
+
import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from '../hosts/opencode.mjs';
|
|
43
44
|
import { renderAll } from './render.mjs';
|
|
44
|
-
import {
|
|
45
|
-
|
|
46
|
-
} from './lib/fs.mjs';
|
|
45
|
+
import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
|
|
46
|
+
import { jsonDumpsIndent, parseJson, formatRepr } from '../lib/json.mjs';
|
|
47
47
|
|
|
48
48
|
/** `_harness_build._HEX_RE`. Anchored at BOTH ends — `#123` and `#1122334` both fail. */
|
|
49
49
|
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
|
@@ -51,18 +51,18 @@ const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
|
|
51
51
|
/**
|
|
52
52
|
* `raise SystemExit(msg)` — the message on stderr, exit 1.
|
|
53
53
|
*
|
|
54
|
-
* The convention `js/
|
|
54
|
+
* The convention `js/build/bundle.mjs`'s `assertSourceComplete` set and `bin/geneseed-cli.mjs`
|
|
55
55
|
* honours: write at the raise site, mark the throw as a DELIBERATE refusal with `exitCode`,
|
|
56
56
|
* and let anything unmarked keep its stack. Python prints a traceback for an unhandled
|
|
57
57
|
* exception, so dressing a crash as a one-liner would hide a bug.
|
|
58
58
|
*/
|
|
59
59
|
function sysExit(msg) {
|
|
60
|
-
// `
|
|
60
|
+
// `printErr`, NOT a local newline translation. The first draft inlined the `\n` ->
|
|
61
61
|
// `\r\n` rule here and every cell passed, because `harness_golden` reads stderr through
|
|
62
62
|
// `subprocess`'s universal-newline decode and cannot see the difference — the same
|
|
63
63
|
// transport hole P5b measured. A second copy of a translation is exactly what P5d's
|
|
64
|
-
// `pyStrPath`/`
|
|
65
|
-
|
|
64
|
+
// `pyStrPath`/`toPlatformPath` split was, and it is ungated in precisely the same way.
|
|
65
|
+
printErr(`${msg}\n`);
|
|
66
66
|
const e = new Error(msg);
|
|
67
67
|
e.exitCode = 1;
|
|
68
68
|
throw e;
|
|
@@ -122,7 +122,8 @@ export function cmdBuild(args) {
|
|
|
122
122
|
* accepts (an empty `--doctrines ` is a usage error there, so it can never be produced here).
|
|
123
123
|
*/
|
|
124
124
|
export function setupBuildArgs(theme, emit, out = null, root = null, footprint = 'lean',
|
|
125
|
-
posture = 'peer', mode = 'direct', doctrines = null, allPacks = PACK_ORDER
|
|
125
|
+
posture = 'peer', mode = 'direct', doctrines = null, allPacks = PACK_ORDER,
|
|
126
|
+
excludeRules = null) {
|
|
126
127
|
const argv = ['--theme', theme, '--emit', emit];
|
|
127
128
|
if (!['opencode-global', 'claude-global', 'bob-global', 'copilot-global'].includes(emit)) {
|
|
128
129
|
if (out) argv.push('--out', out);
|
|
@@ -138,6 +139,13 @@ export function setupBuildArgs(theme, emit, out = null, root = null, footprint =
|
|
|
138
139
|
const picked = allPacks.filter((p) => doctrines.includes(p));
|
|
139
140
|
argv.push('--doctrines', picked.length ? picked.join(',') : 'none');
|
|
140
141
|
}
|
|
142
|
+
// The second axis, emitted the same way and for the same reason — but ALWAYS when the
|
|
143
|
+
// caller passed a list, empty included. An omitted flag reads as "no opinion" and the
|
|
144
|
+
// driver's config fallback fills it in; `none` is the caller saying "exclude nothing",
|
|
145
|
+
// which is exactly what a rebuild that just re-enabled the last rule has to say.
|
|
146
|
+
if (Array.isArray(excludeRules)) {
|
|
147
|
+
argv.push('--exclude-rules', excludeRules.length ? excludeRules.join(',') : 'none');
|
|
148
|
+
}
|
|
141
149
|
return argv;
|
|
142
150
|
}
|
|
143
151
|
|
|
@@ -179,7 +187,7 @@ export const DEFAULT_EMIT = new Map([
|
|
|
179
187
|
export function cmdRebuildAll() {
|
|
180
188
|
const targets = installTargets().filter(([h, s, r]) => installState(r, h, s) === 'active');
|
|
181
189
|
if (!targets.length) {
|
|
182
|
-
|
|
190
|
+
printOut('[rebuild-all] no active installs detected.\n');
|
|
183
191
|
return 0;
|
|
184
192
|
}
|
|
185
193
|
const failures = [];
|
|
@@ -201,22 +209,28 @@ export function cmdRebuildAll() {
|
|
|
201
209
|
// resolving that silence out of a machine-wide config narrows every such install on its
|
|
202
210
|
// first upgrade. `doctrinesForBuild` resolves unknown to ALL packs — see its docblock.
|
|
203
211
|
const doctrines = doctrinesForBuild(root);
|
|
204
|
-
|
|
212
|
+
// SEVEN. The per-rule axis is preserved for the same reason and with sharper teeth: an
|
|
213
|
+
// upgrade that dropped it would quietly hand back every rule its owner switched off, and
|
|
214
|
+
// `process 5` is one of them — the consent gate would return to a harness that had been
|
|
215
|
+
// built without it. No `doctrinesForBuild` rescue is needed here; see `apiInstallCmd`.
|
|
216
|
+
const excludeRules = excludedRulesOfDir(root);
|
|
217
|
+
const argv = setupBuildArgs(theme, emit, out, out, footprint, posture, mode, doctrines,
|
|
218
|
+
PACK_ORDER, excludeRules);
|
|
205
219
|
const label = `${host}:${scope} (${root})`;
|
|
206
|
-
|
|
220
|
+
printOut(`[rebuild-all] ${label}: theme=${theme} emit=${emit} footprint=${footprint} `
|
|
207
221
|
+ `posture=${posture} mode=${mode}\n`);
|
|
208
222
|
const rc = driverMain(argv);
|
|
209
223
|
if (rc !== 0) {
|
|
210
224
|
failures.push(label);
|
|
211
|
-
|
|
225
|
+
printErr(`[rebuild-all] FAILED ${label} (exit ${rc})\n`);
|
|
212
226
|
}
|
|
213
227
|
}
|
|
214
228
|
if (failures.length) {
|
|
215
|
-
|
|
229
|
+
printErr(`[rebuild-all] ${failures.length}/${targets.length} install(s) failed: `
|
|
216
230
|
+ `${failures.join(', ')}\n`);
|
|
217
231
|
return 1;
|
|
218
232
|
}
|
|
219
|
-
|
|
233
|
+
printOut(`[rebuild-all] rebuilt ${targets.length} install(s).\n`);
|
|
220
234
|
return 0;
|
|
221
235
|
}
|
|
222
236
|
|
|
@@ -296,9 +310,9 @@ export function cmdPrompt(args) {
|
|
|
296
310
|
if (dest) mkdirSync(dest, { recursive: true });
|
|
297
311
|
writeText(args.out, text);
|
|
298
312
|
// The RAW argument, not the resolved path — `f"[prompt] wrote {args.out}"`.
|
|
299
|
-
|
|
313
|
+
printOut(`[prompt] wrote ${args.out} (${themeName})\n`);
|
|
300
314
|
} else {
|
|
301
|
-
|
|
315
|
+
printOut(text);
|
|
302
316
|
}
|
|
303
317
|
return 0;
|
|
304
318
|
}
|
|
@@ -309,8 +323,8 @@ export function cmdPrompt(args) {
|
|
|
309
323
|
|
|
310
324
|
/** `_harness_build._resolve_themes_dir` — explicit `--dir`, else a repo `.opencode/`, else global. */
|
|
311
325
|
export function resolveThemesDir(args) {
|
|
312
|
-
// `Path(args.dir).expanduser().resolve()` — `
|
|
313
|
-
if (args.dir) return
|
|
326
|
+
// `Path(args.dir).expanduser().resolve()` — `resolvePath` already expands a leading `~`.
|
|
327
|
+
if (args.dir) return resolvePath(args.dir);
|
|
314
328
|
const repo = path.join(process.cwd(), '.opencode');
|
|
315
329
|
if (!args.globalDir && isDir(repo)) return path.join(repo, 'themes');
|
|
316
330
|
return path.join(opencodeConfigDir(), 'themes');
|
|
@@ -368,7 +382,7 @@ export function loadUserPalette(args, cfg) {
|
|
|
368
382
|
}
|
|
369
383
|
// `f"{r}={pal[r]!r}"` — Python's repr, so a string is single-quoted and an int is bare.
|
|
370
384
|
const bad = [...pal].filter(([, v]) => !(typeof v === 'string' && HEX_RE.test(v)))
|
|
371
|
-
.map(([r, v]) => `${r}=${
|
|
385
|
+
.map(([r, v]) => `${r}=${formatRepr(v)}`);
|
|
372
386
|
if (bad.length) sysExit(`[theme] non-#rrggbb value(s): ${bad.join(', ')}`);
|
|
373
387
|
return pal;
|
|
374
388
|
}
|
|
@@ -406,8 +420,8 @@ export function cmdTheme(args) {
|
|
|
406
420
|
writeText(dest, `${jsonDumpsIndent(colorThemeJson(Object.fromEntries(pal), transparent))}\n`);
|
|
407
421
|
written.push(dest);
|
|
408
422
|
}
|
|
409
|
-
|
|
410
|
-
|
|
423
|
+
printOut(`[theme] wrote ${written.map((p) => path.basename(p)).join(', ')} to ${destDir}\n`);
|
|
424
|
+
printOut(`[theme] select in OpenCode with: /theme ${full}`
|
|
411
425
|
+ (args.solidOnly ? '' : ` (or /theme ${full}-transparent)`) + '\n');
|
|
412
426
|
return 0;
|
|
413
427
|
}
|
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
* RENDER* -> WIRE* -> PRUNE -> MANIFEST -> VERIFY (see `_build_global._emit_claude_core`
|
|
11
11
|
* and tests/test_emit_phase_order.py). RENDER produces files Geneseed owns wholesale;
|
|
12
12
|
* WIRE reconciles files the user co-owns (settings.json, opencode.json, the CLAUDE.md
|
|
13
|
-
* managed block) and
|
|
13
|
+
* managed block) and lives in `js/hosts/settings.mjs`, driven from the two emit modules.
|
|
14
|
+
* THIS module is only RENDER
|
|
14
15
|
* because it is the only stage whose output is a pure function of the source tree.
|
|
15
16
|
*
|
|
16
17
|
* PARITY IS THE CONTRACT, NOT THE INTENT. Every function here has a named Python
|
|
17
|
-
* counterpart and
|
|
18
|
+
* counterpart and had to produce byte-identical output for it. The reference is gone, so
|
|
19
|
+
* what holds the line is `tests/golden.mjs --against`, replaying recorded emit cells;
|
|
18
20
|
* drives both over every theme x footprint x posture x mode and byte-compares the
|
|
19
21
|
* written trees. Where Python and JS semantics differ silently — path sort order,
|
|
20
22
|
* `.suffix`, `splitlines`, universal newlines — the difference is spelled out at the
|
|
@@ -28,9 +30,11 @@
|
|
|
28
30
|
*/
|
|
29
31
|
import { readdirSync, statSync, existsSync } from 'node:fs';
|
|
30
32
|
import path from 'node:path';
|
|
31
|
-
import { readText
|
|
32
|
-
|
|
33
|
-
import {
|
|
33
|
+
import { readText } from '../lib/fs.mjs';
|
|
34
|
+
import { parseJson } from '../lib/json.mjs';
|
|
35
|
+
import { normcase, comparePaths } from '../lib/paths.mjs';
|
|
36
|
+
// `js/build/source.mjs` imports nothing but node builtins, so this direction cannot cycle.
|
|
37
|
+
import { PACK_ORDER } from './source.mjs';
|
|
34
38
|
|
|
35
39
|
/** Document STRUCTURE is theme-INDEPENDENT — mirrors `_build_render.STRUCTURE`. */
|
|
36
40
|
export const STRUCTURE = {
|
|
@@ -47,7 +51,7 @@ export const STRUCTURE = {
|
|
|
47
51
|
|
|
48
52
|
/**
|
|
49
53
|
* Source top-level dirs whose OUTPUT name is themed. Mirrors `SRC_DIR_TOKENS`.
|
|
50
|
-
* Exported because `js/
|
|
54
|
+
* Exported because `js/build/bundle.mjs`'s `build` resolves the same tokens to decide which
|
|
51
55
|
* dirs it owns and wipes — one owner, so the two cannot disagree about a folder name.
|
|
52
56
|
*/
|
|
53
57
|
export const SRC_DIR_TOKENS = {
|
|
@@ -101,7 +105,7 @@ export function loadTheme(cfg, name) {
|
|
|
101
105
|
// `sys.exit(<str>)` — the message on stderr, exit 1 — spelled the way
|
|
102
106
|
// `assertSourceComplete` spells it: written at the raise site, and the throw carries
|
|
103
107
|
// only the marker. ⚠ It was once described as an unreachable nicety under
|
|
104
|
-
// `bin/
|
|
108
|
+
// `bin/build-driver.mjs`, on the grounds that `--theme` is validated against `choices` before
|
|
105
109
|
// anything renders — it is not: `--theme` takes a value and has no `choices` entry, so THIS
|
|
106
110
|
// is the refusal, on every path. It is also reachable from `geneseed status`, which takes
|
|
107
111
|
// its theme from a `.geneseed-theme`
|
|
@@ -253,7 +257,7 @@ function registerBody(cfg, theme, dir, selected, fallback) {
|
|
|
253
257
|
* entry point and an emit that quietly dropped a pack the user asked for is exactly the
|
|
254
258
|
* failure the `Active packs:` marker would then attest to.
|
|
255
259
|
*
|
|
256
|
-
* Both throw with `exitCode`, the marker `bin/
|
|
260
|
+
* Both throw with `exitCode`, the marker `bin/build-driver.mjs`'s `main` turns into a status.
|
|
257
261
|
*/
|
|
258
262
|
function activeDoctrines(cfg) {
|
|
259
263
|
const dir = path.join(cfg.src, 'doctrines');
|
|
@@ -265,7 +269,7 @@ function activeDoctrines(cfg) {
|
|
|
265
269
|
const stray = onDisk.filter((n) => !PACK_ORDER.includes(n));
|
|
266
270
|
if (stray.length) {
|
|
267
271
|
const e = new Error(`doctrine pack file(s) ${stray.join(', ')} exist under src/doctrines/ `
|
|
268
|
-
+ 'but are missing from PACK_ORDER (js/
|
|
272
|
+
+ 'but are missing from PACK_ORDER (js/build/source.mjs) — add them there, or they render '
|
|
269
273
|
+ 'into no install at all');
|
|
270
274
|
e.exitCode = 1;
|
|
271
275
|
throw e;
|
|
@@ -279,7 +283,20 @@ function activeDoctrines(cfg) {
|
|
|
279
283
|
throw e;
|
|
280
284
|
}
|
|
281
285
|
}
|
|
282
|
-
|
|
286
|
+
// ⚠ A PACK WHOSE EVERY RULE IS EXCLUDED IS NOT A PACK, it is a heading over nothing. The
|
|
287
|
+
// console only ever offers per-RULE switches, so "turn this whole pack off" arrives here as
|
|
288
|
+
// seven exclusions rather than as a missing pack name — and rendering `**Observance** — how
|
|
289
|
+
// a task is run.` with no rules under it would be the one shape the reader cannot
|
|
290
|
+
// interpret. Dropped here rather than in `doctrinesBody` so `DOCTRINES_LIST` — the
|
|
291
|
+
// `Active packs:` marker a later reader parses back — agrees with what was rendered.
|
|
292
|
+
const excluded = new Set(cfg.excludeRules ?? []);
|
|
293
|
+
const survives = (pack) => {
|
|
294
|
+
const ids = [...readText(path.join(dir, `${pack}.md`))
|
|
295
|
+
.matchAll(/^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/gm)]
|
|
296
|
+
.map((m) => `${m[1]}.${Number(m[2])}`);
|
|
297
|
+
return ids.some((id) => !excluded.has(id));
|
|
298
|
+
};
|
|
299
|
+
return PACK_ORDER.filter((p) => wanted.includes(p)).filter(survives);
|
|
283
300
|
}
|
|
284
301
|
|
|
285
302
|
/**
|
|
@@ -292,10 +309,40 @@ function activeDoctrines(cfg) {
|
|
|
292
309
|
* pack — closes with one pointer at the full catalogue, which ships in every bundle whether
|
|
293
310
|
* or not a pack is active.
|
|
294
311
|
*/
|
|
312
|
+
/** `### {{DOCTRINE}} <pack> <n> —` in the UNRENDERED source, which is where the address is. */
|
|
313
|
+
const SRC_RULE_HEADING_RE = /^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/;
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Drop the individual rules an install excluded, leaving the pack's lead line and the rest.
|
|
317
|
+
*
|
|
318
|
+
* ⚠ FILTERED BEFORE SUBSTITUTION, ON THE SOURCE. The rendered heading spells the tier noun in
|
|
319
|
+
* the theme's own voice — `Doctrina process 7` on imperial, `Custom process 7` on pirate — so
|
|
320
|
+
* matching after substitution means matching fourteen spellings, and a theme whose noun
|
|
321
|
+
* happened not to match would silently exclude nothing at all. The source spelling is one
|
|
322
|
+
* literal and is the same in every voice.
|
|
323
|
+
*
|
|
324
|
+
* The pack's lead line (`**{{PACK_CRAFT}}** — how code is written.`) sits before the first
|
|
325
|
+
* heading and always survives: `splitAtLawHeadings` returns it as the leading chunk, and a
|
|
326
|
+
* pack whose every rule is excluded is not rendered at all — `activeDoctrines` drops it from
|
|
327
|
+
* the selection, so a header with nothing under it cannot occur.
|
|
328
|
+
*/
|
|
329
|
+
function dropExcludedRules(text, pack, excluded) {
|
|
330
|
+
if (!excluded.size) return text;
|
|
331
|
+
return splitAtLawHeadings(text)
|
|
332
|
+
.filter((block) => {
|
|
333
|
+
const m = SRC_RULE_HEADING_RE.exec(block);
|
|
334
|
+
return !m || !excluded.has(`${m[1]}.${Number(m[2])}`);
|
|
335
|
+
})
|
|
336
|
+
.join('');
|
|
337
|
+
}
|
|
338
|
+
|
|
295
339
|
function doctrinesBody(cfg, theme, active, footprint, lawsPrefix) {
|
|
296
340
|
if (!active.length) return '';
|
|
341
|
+
const excluded = new Set(cfg.excludeRules ?? []);
|
|
297
342
|
const parts = active.map((name) => {
|
|
298
|
-
const
|
|
343
|
+
const raw = dropExcludedRules(readText(path.join(cfg.src, 'doctrines', `${name}.md`)),
|
|
344
|
+
name, excluded);
|
|
345
|
+
const body = substitute(raw, theme).trim();
|
|
299
346
|
return footprint === 'lean' ? terseBlocks(body) : body;
|
|
300
347
|
});
|
|
301
348
|
if (footprint === 'lean') {
|
|
@@ -330,6 +377,20 @@ export function effectiveTheme(cfg, themeName, { footprint = 'full', lawsPrefix
|
|
|
330
377
|
const active = activeDoctrines(cfg);
|
|
331
378
|
theme.DOCTRINES_BODY = doctrinesBody(cfg, theme, active, footprint, lawsPrefix);
|
|
332
379
|
theme.DOCTRINES_LIST = active.length ? active.join(', ') : 'none';
|
|
380
|
+
// ⚠ EMITTED ONLY WHEN SOMETHING IS EXCLUDED, and that is not cosmetic. An unconditional
|
|
381
|
+
// second marker line would put a new line into EVERY carrier — 261 golden cells, both
|
|
382
|
+
// footprints, nine emit modes — to say "nothing was excluded", which is what the line's
|
|
383
|
+
// absence already says. Keeping the default build byte-identical is what lets this ship
|
|
384
|
+
// without re-blessing anything, and it is why `excludedRulesOfDir` reads a MISSING line as
|
|
385
|
+
// `[]` rather than as unknown: absence is a real answer here, unlike the pack list.
|
|
386
|
+
//
|
|
387
|
+
// Rendered `process 7`, not `process.7`: the dotted form is the console's address, the
|
|
388
|
+
// spaced form is how every citation in the corpus already spells a rule, and this line is
|
|
389
|
+
// read by people first and by `excludedRulesOfDir` second.
|
|
390
|
+
const dropped = [...(cfg.excludeRules ?? [])].sort();
|
|
391
|
+
theme.EXCLUDED_RULES_LINE = dropped.length
|
|
392
|
+
? `\nExcluded rules: ${dropped.map((id) => id.replace('.', ' ')).join(', ')}`
|
|
393
|
+
: '';
|
|
333
394
|
return theme;
|
|
334
395
|
}
|
|
335
396
|
|