geneseed 3.1.3 → 3.2.0

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 (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +3 -3
  4. package/bin/build-driver.mjs +127 -79
  5. package/bin/geneseed-cli.mjs +42 -84
  6. package/bin/geneseed-hook.mjs +5 -2
  7. package/harness.config.json +1 -1
  8. package/js/README.md +1 -10
  9. package/js/build/emit-claude.mjs +4 -5
  10. package/js/build/emit-common.mjs +63 -114
  11. package/js/build/emit-opencode.mjs +74 -66
  12. package/js/build/generate.mjs +4 -6
  13. package/js/build/render.mjs +13 -13
  14. package/js/build/source.mjs +7 -6
  15. package/js/build/stubs.mjs +25 -33
  16. package/js/build/themes.mjs +1 -1
  17. package/js/build/version.mjs +1 -3
  18. package/js/hosts/hooks.mjs +3 -3
  19. package/js/hosts/hosts.mjs +2 -4
  20. package/js/hosts/installs.mjs +35 -28
  21. package/js/hosts/link.mjs +1 -1
  22. package/js/hosts/native.mjs +37 -19
  23. package/js/hosts/opencode.mjs +39 -28
  24. package/js/hosts/settings.mjs +33 -110
  25. package/js/inspect/checks-authoring.mjs +5 -4
  26. package/js/inspect/checks-build.mjs +1 -1
  27. package/js/inspect/diff.mjs +1 -3
  28. package/js/inspect/doctor.mjs +59 -44
  29. package/js/inspect/excludes.mjs +4 -10
  30. package/js/inspect/inventory.mjs +37 -55
  31. package/js/inspect/registry.mjs +4 -7
  32. package/js/inspect/scan.mjs +8 -15
  33. package/js/inspect/status.mjs +1 -1
  34. package/js/lib/fs.mjs +34 -0
  35. package/js/lib/json.mjs +28 -1
  36. package/js/lib/paths.mjs +0 -10
  37. package/js/lib/udiff.mjs +11 -16
  38. package/js/maintain/memory.mjs +2 -4
  39. package/js/maintain/migrate.mjs +3 -3
  40. package/js/maintain/uninstall.mjs +90 -78
  41. package/js/maintain/update.mjs +2 -2
  42. package/js/ui/anim.mjs +1 -1
  43. package/js/ui/menu.mjs +1 -1
  44. package/js/ui/tui.mjs +3 -20
  45. package/js/web/actions.mjs +230 -217
  46. package/js/web/activity.mjs +71 -89
  47. package/js/web/api.mjs +133 -224
  48. package/js/web/daemon.mjs +92 -110
  49. package/js/web/docs.mjs +66 -111
  50. package/js/web/graph.mjs +0 -0
  51. package/js/web/handler.mjs +78 -97
  52. package/js/web/jobs.mjs +124 -165
  53. package/js/web/routes.mjs +73 -152
  54. package/js/web/server.mjs +47 -82
  55. package/package.json +1 -1
  56. package/src/agents/explorer.md +2 -1
  57. package/src/doctrines/process.md +4 -2
  58. package/src/laws/universal.md +55 -43
  59. package/src/skills/clarify.md +1 -1
  60. package/src/skills/forge-mcp.md +2 -1
  61. package/src/skills/fresh-eyes.md +1 -1
  62. package/src/skills/ingest.md +2 -1
  63. package/src/skills/parallel-agents.md +1 -1
  64. package/src/skills/ponytail.md +2 -2
  65. package/src/skills/research.md +4 -2
  66. package/themes/_TEMPLATE.json +2 -0
  67. package/themes/biker.json +2 -0
  68. package/themes/commentator.json +2 -0
  69. package/themes/cyberpunk.json +2 -0
  70. package/themes/gamer.json +2 -0
  71. package/themes/imperial.json +2 -0
  72. package/themes/joker.json +2 -0
  73. package/themes/marvin.json +2 -0
  74. package/themes/mean.json +2 -0
  75. package/themes/military.json +2 -0
  76. package/themes/neutral.json +2 -0
  77. package/themes/pirate.json +2 -0
  78. package/themes/sports.json +2 -0
  79. package/themes/verstappen.json +2 -0
  80. package/themes/wizard.json +2 -0
  81. package/web/dist/assets/{Activity-B49fE_ug.js → Activity-BTCG2gMl.js} +1 -1
  82. package/web/dist/assets/{ActivityDetail-abt7hzD2.js → ActivityDetail-julsmPDB.js} +1 -1
  83. package/web/dist/assets/CatalogRow-DqS4e5XM.js +1 -0
  84. package/web/dist/assets/Diff-CIrGbiqm.js +5 -0
  85. package/web/dist/assets/Doctor-CpI53oHY.js +1 -0
  86. package/web/dist/assets/{Graph-DvYMtlvl.js → Graph-CS4tA7YS.js} +1 -1
  87. package/web/dist/assets/Harness-vRYs11kX.js +1 -0
  88. package/web/dist/assets/Laws-BjbHJrJt.js +3 -0
  89. package/web/dist/assets/Library-CjC7BAWN.js +1 -0
  90. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-kgpg9EQM.js} +15 -15
  91. package/web/dist/assets/Profile-CHr-PPf6.js +1 -0
  92. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-CsC6hfqk.js} +1 -1
  93. package/web/dist/assets/Skills-BvatJGfe.js +1 -0
  94. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-gRrak0nO.js} +1 -1
  95. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-B0-FXZCj.js} +1 -1
  96. package/web/dist/assets/index-BnRWQbZb.js +12 -0
  97. package/web/dist/assets/index-Bx3I7cRW.js +2 -0
  98. package/web/dist/assets/index-D0f9tJlA.css +1 -0
  99. package/web/dist/assets/index-DJOhOwwR.js +1 -0
  100. package/web/dist/index.html +2 -2
  101. package/web/src/pages/Laws.jsx +42 -71
  102. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  103. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  104. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  105. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  106. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  107. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  108. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  109. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  110. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  111. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  112. package/web/dist/assets/index-DP035wPa.js +0 -12
  113. package/web/dist/assets/index-dHi-7M-F.js +0 -2
package/js/README.md CHANGED
@@ -172,7 +172,7 @@ the deployed harness as JSON.
172
172
  | `activity.mjs` | The `activity/` session files: pid-liveness prune, list, detail, and the on/off flag |
173
173
 
174
174
  **Before editing:** `makeHandler(state, jm, token, dist, holder)`'s arity is load-bearing — three
175
- test call sites construct one directly and drive it without a socket. Four of `routes.mjs`'s
175
+ test call sites construct one directly and drive it without a socket. Five of `routes.mjs`'s
176
176
  exported sets are deliberately EMPTY and must stay declared: each is half of a partition test.
177
177
  `graph.mjs` contains a literal NUL byte, so `grep`/`ripgrep` treat it as binary and skip it.
178
178
 
@@ -192,12 +192,3 @@ exported sets are deliberately EMPTY and must stay declared: each is half of a p
192
192
  | a console doc page | `docs/web/<id>.md` — the stem is the id | `docs/extending.md` §4c. Only `kind: "concept"` gets `{N_*}` substitution |
193
193
  | an OpenCode plugin | `adapters/opencode/plugins/geneseed-<name>.js` | `docs/extending.md` §4a — the `geneseed-` prefix is mechanically load-bearing |
194
194
  | to change a `js/lib/` primitive | `tests/fixtures/pure_probe.mjs` **first** | the docblock says which Node default it deliberately departs from — change that sentence too, or the next reader restores the default |
195
-
196
- ---
197
-
198
- ## Known debris
199
-
200
- * **`js/hosts/settings.mjs` carries a private second copy of Python's whitespace class**, duplicating
201
- `WHITESPACE` in `js/lib/text.mjs`. `text.mjs` is the owner.
202
- * **`web/src/pages/Skills.jsx`'s `SKILL_CATS` is a fourth, ungated copy of the skill taxonomy** —
203
- nothing compares it to `SKILL_CLASS`, and an unknown category renders as Build with no warning.
@@ -54,8 +54,7 @@ export function emitClaudeRender(cfg, job) {
54
54
 
55
55
  // `os.path.relpath(cfg, claude_md.parent)` — note the reversed argument order, and
56
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
- || '.';
57
+ const relCfg = relPosix(path.dirname(claudeMd), cfgDir) || '.';
59
58
  const lawsPrefix = relCfg === '.' ? '' : `${relCfg}/`;
60
59
  const { theme, items } = renderAll(cfg, themeName, { footprint, lawsPrefix, nativeCatalog });
61
60
  assertSourceComplete(cfg, `claude-${scope}`);
@@ -98,7 +97,7 @@ export function emitClaudeRender(cfg, job) {
98
97
  const rulesMd = path.join(cfgDir, 'rules', 'geneseed.md');
99
98
  mkdirSync(path.dirname(rulesMd), { recursive: true });
100
99
  writeText(rulesMd, scope === 'project' ? BOB_RULES_STUB
101
- : stripCapabilityLinks(cfg, prefixedAgentText('../') || agentText));
100
+ : stripCapabilityLinks(prefixedAgentText('../') || agentText));
102
101
  owned.push('rules/geneseed.md');
103
102
  }
104
103
 
@@ -146,7 +145,7 @@ export function emitClaudeRender(cfg, job) {
146
145
  // consumes and no more.
147
146
  let claudeMdText = null;
148
147
  if (agentText !== null && !(isBob && scope === 'global')) {
149
- claudeMdText = stripCapabilityLinks(cfg, prefixedAgentText(lawsPrefix) || agentText);
148
+ claudeMdText = stripCapabilityLinks(prefixedAgentText(lawsPrefix) || agentText);
150
149
  }
151
150
 
152
151
  // WIRE — see `claudeWire` below. One child per emit, so the two halves are two
@@ -212,7 +211,7 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRu
212
211
  // above — unreachable here (CLAUDE.md is a file, cfgDir a directory, so the pair is
213
212
  // never identical) and spelled anyway, because the two call sites are 400 lines apart
214
213
  // and only one of them is obviously safe.
215
- const rel = path.relative(cfgDir, claudeMd).split(path.sep).join('/') || '.';
214
+ const rel = relPosix(cfgDir, claudeMd) || '.';
216
215
  managed.claude_md = { rel };
217
216
  } else if (isBob && scope === 'global' && isTruthy(get(old, 'claude_md'))) {
218
217
  const oldCm = isDict(get(old, 'claude_md')) ? get(old, 'claude_md') : {};
@@ -10,9 +10,17 @@
10
10
  * `emit-claude.mjs` do that; `emit.mjs` renders the bundle they read from.
11
11
  */
12
12
  import path from 'node:path';
13
- import { copyFile, writeText } from '../lib/fs.mjs';
13
+ import { copyFile, writeText, isFile, isDir } from '../lib/fs.mjs';
14
+ import { get, isDict } from '../lib/json.mjs';
14
15
  import { SRC_DIR_TOKENS, destRel } from './render.mjs';
15
- import { mkdirSync, readdirSync, statSync } from 'node:fs';
16
+ import { mkdirSync, readdirSync, cpSync } from 'node:fs';
17
+
18
+ // `isFile`/`isDir`/`get`/`isDict` are owned by `js/lib` now (single owner across `hosts/`,
19
+ // `build/`, `inspect/` and `web/` — layering forbids `build/` reaching into `inspect/scan.mjs`
20
+ // for them, so `js/lib` is the one home both sides can reach). Re-exported here so this
21
+ // module's own existing callers (`emit-claude.mjs`, `emit-opencode.mjs`, `version.mjs`,
22
+ // `bundle.mjs`) keep importing them from `./emit-common.mjs` unchanged.
23
+ export { isFile, isDir, get, isDict };
16
24
 
17
25
  /** `_build_render.SRC_DIRS_MARKER`. */
18
26
  export const SRC_DIRS_MARKER = '.geneseed-srcdirs.json';
@@ -41,13 +49,15 @@ export const OWNED_SRC_DIRS = ['laws', 'agents', 'skills', 'ontology', 'doctrine
41
49
  const LEAN_FULL_TEXT_DIRS = ['laws', 'ontology', 'doctrines'];
42
50
 
43
51
  /**
44
- * `_build_core.CAPABILITY_LINK_RE` — the DEFAULT only.
52
+ * `_build_core.CAPABILITY_LINK_RE`.
45
53
  *
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.
54
+ * NOT OVERRIDABLE. Python's `cfg.capabilityLinkRe` was the driver's seam for tests that
55
+ * monkeypatch `_build_core.CAPABILITY_LINK_RE`; `bin/build-driver.mjs` deliberately never
56
+ * sends one (see its docblock), so the override branch that used to sit in
57
+ * `stripCapabilityLinks` never took its left-hand side and was deleted as dead code in the
58
+ * over-engineering cleanup. `tests/unit/harness.test.mjs`'s planted-fault test drives this
59
+ * exact constant by rewriting the module's source text instead, which is what "no injection
60
+ * point left" means there.
51
61
  */
52
62
  const CAPABILITY_LINK_RE =
53
63
  /\[([^\]]+)\]\((?:(?!https?:\/\/|\/)[A-Za-z0-9_.-]+\/)*(?:agents|skills)\/[A-Za-z0-9_-]+\.md\)/g;
@@ -55,59 +65,18 @@ const CAPABILITY_LINK_RE =
55
65
  /** `_build_render._TMPL_SPEC_RE`. */
56
66
  export const TMPL_SPEC_RE = /\{\{DIR_(AGENTS|SKILLS)\}\}\/([A-Za-z0-9_-]+)\.md/g;
57
67
 
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
68
  /**
96
- * `Path.rglob("*")` restricted to files, keeping EVERYTHING — including `__pycache__`.
69
+ * `Path.rglob("*")` restricted to files, skipping `__pycache__` as Python's callers do.
97
70
  *
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.
71
+ * `recursive: true, withFileTypes: true` (Node >= 20.1, `parentPath` >= 21.4 — this repo
72
+ * requires >= 22.3) replaces a hand-rolled walk; the order it yields is NOT the walk's
73
+ * order, but `sourceFingerprint`, this function's one caller, re-sorts every result by a
74
+ * plain string key before it is ever observed, so no caller depends on it.
103
75
  */
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;
76
+ export function rglobFiles(root) {
77
+ return readdirSync(root, { recursive: true, withFileTypes: true })
78
+ .filter((d) => d.isFile() && !d.parentPath.split(path.sep).includes('__pycache__'))
79
+ .map((d) => path.join(d.parentPath, d.name));
111
80
  }
112
81
 
113
82
  /** `p.relative_to(base).as_posix()`. */
@@ -123,11 +92,8 @@ export function relPosix(base, p) {
123
92
  * that difference back by design and a check that did not would report the file stale on
124
93
  * every run forever.
125
94
  */
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');
95
+ export function stripCapabilityLinks(text) {
96
+ return text.replace(CAPABILITY_LINK_RE, '$1');
131
97
  }
132
98
 
133
99
  // ---------------------------------------------------------------------------
@@ -135,84 +101,67 @@ export function stripCapabilityLinks(cfg, text) {
135
101
  // ---------------------------------------------------------------------------
136
102
 
137
103
  /**
138
- * `_build_global._global_memory` — ensure `<cfg>/memory` exists, without ever touching a
139
- * store that already holds something.
104
+ * `_build_global._global_memory`/`_global_notebook` — ensure `<cfg>/<name>` exists, without
105
+ * ever touching a store that already holds something.
140
106
  *
141
107
  * The three outcomes are the returned status string, which the emit prints, so they are
142
108
  * 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
109
+ * dropped for both: the store dir is ALWAYS the classic English name, never themed (the
144
110
  * OpenCode config dir uses fixed names), so the argument was never read.
145
111
  *
112
+ * THE MIGRATED MESSAGE'S SHAPE TRACKS THE ALIAS COUNT, not a caller-supplied format flag.
113
+ * `memory` is the only store with more than one legacy name — `anamnesis`, an older themed
114
+ * install's — so its status names which one matched: `migrated anamnesis/ -> memory/`.
115
+ * `notebook` has exactly one candidate and its status has never named an arrow:
116
+ * `migrated notebook/`. One alias is nothing a rename could be reporting, so the arrow is
117
+ * dropped rather than printed as a no-op `notebook/ -> notebook/`.
118
+ *
146
119
  * The migration branch copies arbitrary USER files out of a legacy bundle, which is the
147
120
  * reason this is not a plain translation: everything it touches is the user's, and the
148
121
  * only thing keeping it safe is that it runs at all only when the destination is empty.
149
122
  */
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 });
123
+ function globalStore(cfgDir, items, legacy, srcRoot, name, aliases = [name]) {
124
+ const dir = path.join(cfgDir, name);
125
+ if (isDir(dir) && readdirSync(dir).length) return `kept ${name}/`;
126
+ mkdirSync(dir, { recursive: true });
155
127
  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'])]) {
128
+ // `dict.fromkeys(aliases)` — de-duplicated, order kept. The dedupe is what keeps a
129
+ // caller that lists `name` itself among its aliases (as `memory` does) from copying
130
+ // the same dir twice.
131
+ for (const nm of [...new Set(aliases)]) {
162
132
  const src = path.join(legacy, nm);
163
133
  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}/`;
134
+ // `cpSync(..., {preserveTimestamps: true})` — the recursive-copy equivalent of
135
+ // `copyFile` above (byte copy + carried mtime, no `shutil.copy2` mode bits; see
136
+ // that function's docblock in `js/lib/fs.mjs`), replacing a walk-then-copyFile
137
+ // loop that visited every legacy file one at a time.
138
+ cpSync(src, dir, { recursive: true, preserveTimestamps: true });
139
+ return aliases.length > 1 ? `migrated ${nm}/ -> ${name}/` : `migrated ${name}/`;
170
140
  }
171
141
  }
172
142
  }
173
143
  for (const { text, src } of items) {
174
144
  const sp = relPosix(srcRoot, src).split('/');
175
- if (sp[0] === 'memory' && sp.length > 1) {
145
+ if (sp[0] === name && sp.length > 1) {
176
146
  // `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))));
147
+ // (which is themed, and the store dir never is). Without it the store would be
148
+ // seeded with the on-disk name `gitignore` — see `js/build/render.mjs`'s `destRel`.
149
+ const dest = path.join(dir, destRel(path.join(...sp.slice(1))));
180
150
  mkdirSync(path.dirname(dest), { recursive: true });
181
151
  if (text !== null) writeText(dest, text);
182
152
  else copyFile(src, dest);
183
153
  }
184
154
  }
185
- return `seeded ${memName}/`;
155
+ return `seeded ${name}/`;
156
+ }
157
+
158
+ export function globalMemory(cfgDir, items, legacy, srcRoot) {
159
+ return globalStore(cfgDir, items, legacy, srcRoot, 'memory', ['memory', 'anamnesis']);
186
160
  }
187
161
 
188
162
  /** `_build_global._global_notebook` — the same shape, with no `anamnesis` alias. */
189
163
  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}/`;
164
+ return globalStore(cfgDir, items, legacy, srcRoot, 'notebook');
216
165
  }
217
166
 
218
167
  /**
@@ -27,14 +27,71 @@ import {
27
27
  import { writeVersion } from './version.mjs';
28
28
  import { mkdirSync } from 'node:fs';
29
29
 
30
+ /**
31
+ * The layer both OpenCode emits share once RENDER has `items`/`theme` in hand: overrides, the
32
+ * native agents/skills layer, the primary agent, commands, themes, plugins/workflows, and the
33
+ * final `opencode.json` WIRE. `dir` is the emit's own directory — `.opencode/` for the
34
+ * per-repo emit, `cfgDir` for the global one — which is why `wireBase` (where `opencode.json`
35
+ * itself lives) travels separately: the per-repo config sits at the project ROOT, one level
36
+ * above `dir`, while the global config sits IN `cfgDir`, i.e. `dir` itself.
37
+ *
38
+ * Folding WIRE in here moves it earlier in the global emit's own write order (it used to run
39
+ * after the memory/notebook/laws steps, now before them) — harmless, because none of those
40
+ * later steps read `opencode.json` back, and the phase-order gate only requires WIRE to log no
41
+ * earlier than RENDER, which still holds since nothing after this logs a phase at all.
42
+ *
43
+ * `manifestExisted` only ever arrives from the per-repo caller; the global emit does not pass
44
+ * it, matching the Python side, so the pre-manifest header line stays unreachable from there on
45
+ * both sides.
46
+ *
47
+ * `overridesDir` is a THIRD directory, and it is not `dir` for the per-repo emit: the
48
+ * per-repo `agent-overrides.json` lives beside the portable bundle in `out`, while every other
49
+ * write in this layer goes under `.opencode/`. The global emit has no such split — `out` there
50
+ * is only the legacy memory/notebook source, never a write target — so its `overridesDir`
51
+ * equals `dir`.
52
+ */
53
+ function opencodeLayer(cfg, items, themeName, theme, dir, owned, opts) {
54
+ const {
55
+ oldOwned, manifestExisted, agentPath, wireBase, overridesDir,
56
+ } = opts;
57
+ ensureAgentOverridesStub(cfg, overridesDir);
58
+ const overrides = loadAgentOverrides(overridesDir);
59
+
60
+ const { nAgents, nSkills, written } = writeNativeLayer(
61
+ items, path.join(dir, 'agents'), path.join(dir, 'skills'), overrides,
62
+ { host: 'opencode', oldOwned, cfg: dir, manifestExisted, theme, src: cfg.src });
63
+ for (const p of written) owned.push(relPosix(dir, p));
64
+
65
+ const primary = writePrimaryAgent(cfg, path.join(dir, 'agents'), overrides);
66
+ if (primary) owned.push(relPosix(dir, primary));
67
+
68
+ const commands = writeCommandLayer(cfg, items, path.join(dir, 'command'));
69
+ commands.push(writePonytailCommand(path.join(dir, 'command'))); // always-on /ponytail
70
+ for (const p of commands) owned.push(relPosix(dir, p));
71
+
72
+ owned.push(relPosix(dir, writeTheme(path.join(dir, 'themes'), themeName, theme)));
73
+ for (const p of writeColorThemes(cfg, path.join(dir, 'themes'))) owned.push(relPosix(dir, p));
74
+
75
+ const nPlugins = copyPlugins(cfg, path.join(dir, 'plugins'), owned);
76
+ const nWorkflows = copyWorkflows(cfg, path.join(dir, 'workflows'), owned);
77
+
78
+ // WIRE — the one file of this layer the user co-owns.
79
+ phaseLog('WIRE');
80
+ const cfgName = path.basename(mergeOpencodeJson(path.join(wireBase, 'opencode.json'),
81
+ agentPath, cfg.doctrines, cfg.excludeRules));
82
+
83
+ return {
84
+ nAgents, nSkills, nPlugins, nWorkflows, primary, nCommands: commands.length, cfgName,
85
+ };
86
+ }
87
+
30
88
  // ---------------------------------------------------------------------------
31
89
  // emit_opencode — the RENDER stage only
32
90
  // ---------------------------------------------------------------------------
33
91
 
34
92
  /**
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`.
93
+ * RENDER and WIRE for `_build_emit.emit_opencode` — everything through `opencodeLayer`'s
94
+ * `mergeOpencodeJson` call. PRUNE and MANIFEST are the driver's, in `bin/build-driver.mjs`.
38
95
  *
39
96
  * `oldOwned` and `manifestExisted` arrive in the job rather than being read here. The
40
97
  * manifest is the DRIVER's stage: it reads the file, hands the prior claim in, and prunes
@@ -56,43 +113,21 @@ export function emitOpencodeRender(cfg, job) {
56
113
  // OpenCode loads agents/skills natively, so strip AGENT.md's per-row spec links to
57
114
  // plain names (the portable build keeps them). A deliberate de-link, not a fix.
58
115
  const agentMd = path.join(out, 'AGENT.md');
59
- if (isFile(agentMd)) writeText(agentMd, stripCapabilityLinks(cfg, readText(agentMd)));
116
+ if (isFile(agentMd)) writeText(agentMd, stripCapabilityLinks(readText(agentMd)));
60
117
 
61
118
  const owned = [];
62
119
  const { theme, items } = renderAll(cfg, _theme);
63
120
 
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));
121
+ const {
122
+ nAgents, nSkills, nPlugins, nWorkflows, primary, nCommands, cfgName,
123
+ } = opencodeLayer(cfg, items, _theme, theme, oc, owned, {
124
+ oldOwned, manifestExisted, agentPath, wireBase: root, overridesDir: out,
125
+ });
91
126
 
92
127
  return {
93
128
  owned,
94
129
  stats: {
95
- nAgents, nSkills, nPlugins, nWorkflows, nCommands: commands.length, primary: !!primary,
130
+ nAgents, nSkills, nPlugins, nWorkflows, nCommands, primary: !!primary,
96
131
  },
97
132
  cfgName,
98
133
  };
@@ -149,34 +184,17 @@ export function emitOpencodeGlobalRender(cfg, job) {
149
184
  // plain names. Memory links stay RELATIVE: in the global layout AGENT.md and the
150
185
  // store are siblings, so `memory/` resolves from AGENT.md's own location and stays
151
186
  // hermetic — no absolute path a doctor check would flag as an escape.
152
- writeText(path.join(cfgDir, 'AGENT.md'), stripCapabilityLinks(cfg, agentText));
187
+ writeText(path.join(cfgDir, 'AGENT.md'), stripCapabilityLinks(agentText));
153
188
  owned.push('AGENT.md');
154
189
  }
155
190
 
156
- ensureAgentOverridesStub(cfg, cfgDir);
157
- const overrides = loadAgentOverrides(cfgDir);
158
-
159
191
  // `manifestExisted` is deliberately not passed — the Python does not pass it either, so
160
192
  // 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);
193
+ const {
194
+ nAgents, nSkills, nPlugins, nWorkflows, primary, nCommands, cfgName,
195
+ } = opencodeLayer(cfg, items, themeName, theme, cfgDir, owned, {
196
+ oldOwned, agentPath, wireBase: cfgDir, overridesDir: cfgDir,
197
+ });
180
198
 
181
199
  const memStatus = globalMemory(cfgDir, items, out, cfg.src);
182
200
  ensureMemoryIndex(path.join(cfgDir, 'memory'));
@@ -192,23 +210,13 @@ export function emitOpencodeGlobalRender(cfg, job) {
192
210
 
193
211
  shipLeanLaws(items, theme, cfgDir, owned, footprint);
194
212
 
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
213
  return {
205
214
  owned,
206
215
  stats: {
207
- nAgents, nSkills, nPlugins, nWorkflows, nCommands: commands.length, primary: !!primary,
216
+ nAgents, nSkills, nPlugins, nWorkflows, nCommands, primary: !!primary,
208
217
  },
209
218
  memStatus,
210
219
  nbStatus,
211
220
  cfgName,
212
221
  };
213
222
  }
214
-
@@ -42,7 +42,7 @@ import {
42
42
  } from '../hosts/installs.mjs';
43
43
  import { colorThemeFiles, colorThemeJson, PALETTE_ROLES } from '../hosts/opencode.mjs';
44
44
  import { renderAll } from './render.mjs';
45
- import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
45
+ import { printOut, printErr, readText, writeText, isDir } from '../lib/fs.mjs';
46
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. */
@@ -68,8 +68,6 @@ function sysExit(msg) {
68
68
  throw e;
69
69
  }
70
70
 
71
- const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
72
-
73
71
  // --------------------------------------------------------------------------------------
74
72
  // build
75
73
  // --------------------------------------------------------------------------------------
@@ -322,7 +320,7 @@ export function cmdPrompt(args) {
322
320
  // --------------------------------------------------------------------------------------
323
321
 
324
322
  /** `_harness_build._resolve_themes_dir` — explicit `--dir`, else a repo `.opencode/`, else global. */
325
- export function resolveThemesDir(args) {
323
+ function resolveThemesDir(args) {
326
324
  // `Path(args.dir).expanduser().resolve()` — `resolvePath` already expands a leading `~`.
327
325
  if (args.dir) return resolvePath(args.dir);
328
326
  const repo = path.join(process.cwd(), '.opencode');
@@ -352,7 +350,7 @@ export function resolveThemesDir(args) {
352
350
  * escaping rule) traded for the ORDER OF TWO NAMES in an error message that both
353
351
  * implementations still emit, for the same values, with the same exit code.
354
352
  */
355
- export function loadUserPalette(args, cfg) {
353
+ function loadUserPalette(args, cfg) {
356
354
  const pal = new Map();
357
355
  if (args.fromTheme) {
358
356
  const src = path.join(cfg.colorThemes, `${args.fromTheme}.json`);
@@ -369,7 +367,7 @@ export function loadUserPalette(args, cfg) {
369
367
  // `Path(args.palette)` — relative to cwd, with no expanduser and no resolve.
370
368
  const raw = parseJson(readText(args.palette));
371
369
  // `raw.get("palette", raw)` — a document with no "palette" key IS the role map.
372
- const map = Object.prototype.hasOwnProperty.call(raw, 'palette') ? raw.palette : raw;
370
+ const map = Object.hasOwn(raw, 'palette') ? raw.palette : raw;
373
371
  for (const [k, v] of Object.entries(map)) pal.set(k, v);
374
372
  }
375
373
  if (pal.size === 0) {
@@ -81,17 +81,17 @@ function suffixOf(name) {
81
81
  return 0 < i && i < name.length - 1 ? name.slice(i) : '';
82
82
  }
83
83
 
84
- /** `Path.rglob("*")` restricted to files, then `sorted()`. */
84
+ /**
85
+ * `Path.rglob("*")` restricted to files, then `sorted()`.
86
+ *
87
+ * `recursive: true, withFileTypes: true` replaces the hand-rolled walk — the order it
88
+ * yields is not the walk's order, but the explicit `.sort(comparePaths)` below is what
89
+ * every caller actually observes, so a different collection order changes nothing.
90
+ */
85
91
  function sortedSourceFiles(src) {
86
- const out = [];
87
- (function walk(dir) {
88
- for (const name of readdirSync(dir)) {
89
- const full = path.join(dir, name);
90
- if (name === '__pycache__') continue; // `"__pycache__" in path.parts`
91
- if (statSync(full).isDirectory()) walk(full); // rglob yields dirs; render_all skips them
92
- else out.push(full);
93
- }
94
- })(src);
92
+ const out = readdirSync(src, { recursive: true, withFileTypes: true })
93
+ .filter((d) => d.isFile() && !d.parentPath.split(path.sep).includes('__pycache__'))
94
+ .map((d) => path.join(d.parentPath, d.name));
95
95
  return out.sort(comparePaths); // `sorted()` — case-folded on Windows only
96
96
  }
97
97
 
@@ -126,7 +126,7 @@ export function loadTheme(cfg, name) {
126
126
  /** `_build_render.substitute`. */
127
127
  export function substitute(text, theme) {
128
128
  return text.replace(TOKEN_RE, (whole, key) => {
129
- if (!Object.prototype.hasOwnProperty.call(theme, key)) return whole; // visible for debugging
129
+ if (!Object.hasOwn(theme, key)) return whole; // visible for debugging
130
130
  const v = theme[key];
131
131
  // Python renders `str(theme[key])`, which agrees with JS only for strings —
132
132
  // `str(True)`/`String(true)` and `str({...})`/`String({...})` do not. Every one of
@@ -143,7 +143,7 @@ export function substitute(text, theme) {
143
143
  }
144
144
 
145
145
  /** `_build_render._resolve_catalogs`. */
146
- export function resolveCatalogs(text, nativeCatalog) {
146
+ function resolveCatalogs(text, nativeCatalog) {
147
147
  return text.replace(CATALOG_BLOCK_RE, (_m, table, pointer) => (nativeCatalog ? pointer : table));
148
148
  }
149
149
 
@@ -397,7 +397,7 @@ export function effectiveTheme(cfg, themeName, { footprint = 'full', lawsPrefix
397
397
  /** `_build_render.themed_rel` — rename the top-level folder of an output path per theme. */
398
398
  export function themedRel(rel, theme) {
399
399
  const parts = rel.split(path.sep);
400
- if (parts.length && Object.prototype.hasOwnProperty.call(SRC_DIR_TOKENS, parts[0])) {
400
+ if (parts.length && Object.hasOwn(SRC_DIR_TOKENS, parts[0])) {
401
401
  parts[0] = theme[SRC_DIR_TOKENS[parts[0]]] ?? parts[0];
402
402
  }
403
403
  return parts.join(path.sep);
@@ -12,11 +12,13 @@
12
12
  * `build.render_all(theme)`. So the CLI has to build the same `cfg` the driver builds, and
13
13
  * until this move only the driver could.
14
14
  *
15
- * THE TWO ABSENT KEYS ARE STILL ABSENT, and the reason is bin/build-driver.mjs's, unchanged:
16
- * `_build_core.js_cfg()` always sends `structure` and `capabilityLinkRe` because the Python
17
- * originals are module-level names TESTS MUTATE (`_OWNED` membership asked one level out).
18
- * A Node process has no Python module to mutate, so it sends neither and
15
+ * THE ABSENT KEY IS STILL ABSENT, and the reason is bin/build-driver.mjs's, unchanged:
16
+ * `_build_core.js_cfg()` always sent `structure` and `capabilityLinkRe` because the Python
17
+ * originals were module-level names TESTS MUTATE (`_OWNED` membership asked one level out).
18
+ * A Node process has no Python module to mutate, so it sends no `structure`, and
19
19
  * `js/build/render.mjs`'s `cfg.structure ?? STRUCTURE` takes its right-hand branch.
20
+ * `capabilityLinkRe`'s equivalent override in `stripCapabilityLinks` had no producer either
21
+ * and was deleted outright in the over-engineering cleanup, `cfg` param and all.
20
22
  *
21
23
  * The move is safe for the reason P5c's was: `tests/golden.py` drives `bin/build-driver.mjs`
22
24
  * over 259 cells and compares the tree byte-for-byte, and every one of them builds a cfg
@@ -24,7 +26,6 @@
24
26
  */
25
27
  import { readdirSync, readFileSync } from 'node:fs';
26
28
  import path from 'node:path';
27
- import { fileURLToPath } from 'node:url';
28
29
 
29
30
  /**
30
31
  * `_build_core.ROOT` — the checkout, from this file's own location (`js/..`).
@@ -37,7 +38,7 @@ import { fileURLToPath } from 'node:url';
37
38
  * which is what makes them comparable and what makes them unfenceable. See the
38
39
  * status/version section of `tests/harness_golden.py`.
39
40
  */
40
- export const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
41
+ export const ROOT = path.resolve(import.meta.dirname, '../..');
41
42
  export const SRC = path.join(ROOT, 'src');
42
43
  export const CONFIG = path.join(ROOT, 'harness.config.json');
43
44
  export const THEMES = path.join(ROOT, 'themes');