geneseed 3.1.3 → 3.3.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 (150) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/README.md +24 -14
  3. package/SETUP.md +38 -7
  4. package/SHIPPED.md +6 -5
  5. package/adapters/bob/README.md +40 -10
  6. package/adapters/claude-code/README.md +5 -0
  7. package/adapters/claude-code/settings.json +8 -1
  8. package/adapters/copilot/README.md +50 -24
  9. package/adapters/opencode/README.md +5 -1
  10. package/adapters/opencode/plugins/geneseed-guard.js +40 -6
  11. package/bin/build-driver.mjs +143 -89
  12. package/bin/geneseed-cli.mjs +63 -106
  13. package/bin/geneseed-hook.mjs +26 -16
  14. package/docs/candidate-hosts.md +74 -0
  15. package/docs/extending.md +6 -5
  16. package/docs/token-footprint.md +32 -21
  17. package/docs/web-ui.md +5 -13
  18. package/harness.config.json +1 -1
  19. package/js/README.md +5 -15
  20. package/js/build/emit-claude.mjs +26 -12
  21. package/js/build/emit-common.mjs +63 -114
  22. package/js/build/emit-opencode.mjs +74 -66
  23. package/js/build/generate.mjs +4 -6
  24. package/js/build/render.mjs +50 -32
  25. package/js/build/source.mjs +29 -8
  26. package/js/build/stubs.mjs +25 -33
  27. package/js/build/themes.mjs +1 -1
  28. package/js/build/version.mjs +1 -3
  29. package/js/cli-table.json +84 -0
  30. package/js/hosts/hooks.mjs +243 -81
  31. package/js/hosts/hosts.mjs +57 -5
  32. package/js/hosts/installs.mjs +35 -28
  33. package/js/hosts/link.mjs +2 -2
  34. package/js/hosts/mcp.mjs +3 -1
  35. package/js/hosts/native.mjs +54 -19
  36. package/js/hosts/opencode.mjs +39 -28
  37. package/js/hosts/settings.mjs +206 -115
  38. package/js/inspect/checks-authoring.mjs +5 -4
  39. package/js/inspect/checks-build.mjs +1 -1
  40. package/js/inspect/diff.mjs +1 -3
  41. package/js/inspect/doctor.mjs +59 -44
  42. package/js/inspect/excludes.mjs +5 -11
  43. package/js/inspect/inventory.mjs +38 -56
  44. package/js/inspect/registry.mjs +4 -7
  45. package/js/inspect/scan.mjs +8 -15
  46. package/js/inspect/status.mjs +51 -3
  47. package/js/lib/fs.mjs +34 -0
  48. package/js/lib/json.mjs +28 -1
  49. package/js/lib/paths.mjs +0 -10
  50. package/js/lib/udiff.mjs +11 -16
  51. package/js/maintain/memory.mjs +2 -4
  52. package/js/maintain/migrate.mjs +3 -3
  53. package/js/maintain/uninstall.mjs +127 -88
  54. package/js/maintain/update.mjs +4 -4
  55. package/js/ui/anim.mjs +1 -1
  56. package/js/ui/menu.mjs +1 -1
  57. package/js/ui/tui.mjs +3 -20
  58. package/js/web/actions.mjs +230 -217
  59. package/js/web/activity.mjs +71 -89
  60. package/js/web/api.mjs +130 -226
  61. package/js/web/daemon.mjs +92 -110
  62. package/js/web/docs.mjs +59 -126
  63. package/js/web/handler.mjs +94 -123
  64. package/js/web/jobs.mjs +116 -178
  65. package/js/web/routes.mjs +47 -192
  66. package/js/web/server.mjs +47 -82
  67. package/package.json +1 -1
  68. package/registry.json +14 -0
  69. package/src/AGENT.md.tmpl +55 -16
  70. package/src/agents/explorer.md +2 -1
  71. package/src/doctrines/README.md +3 -3
  72. package/src/doctrines/process.md +4 -2
  73. package/src/doctrines/rigor.md +13 -0
  74. package/src/laws/universal.md +133 -43
  75. package/src/ontology/universal.md +38 -13
  76. package/src/skills/_template.md +5 -1
  77. package/src/skills/bruno-collection-generator.md +63 -0
  78. package/src/skills/bruno-test-writer.md +62 -0
  79. package/src/skills/clarify.md +1 -1
  80. package/src/skills/crash-course.md +1 -0
  81. package/src/skills/decode.md +1 -0
  82. package/src/skills/drill.md +1 -0
  83. package/src/skills/feynman.md +1 -0
  84. package/src/skills/forge-mcp.md +2 -1
  85. package/src/skills/fresh-eyes.md +1 -1
  86. package/src/skills/herdr.md +1 -0
  87. package/src/skills/ingest.md +2 -1
  88. package/src/skills/learning-path.md +1 -0
  89. package/src/skills/opencode-theme.md +1 -0
  90. package/src/skills/parallel-agents.md +1 -1
  91. package/src/skills/ponytail.md +2 -2
  92. package/src/skills/research.md +4 -2
  93. package/themes/_TEMPLATE.json +5 -0
  94. package/themes/biker.json +5 -0
  95. package/themes/commentator.json +5 -0
  96. package/themes/cyberpunk.json +5 -0
  97. package/themes/gamer.json +5 -0
  98. package/themes/imperial.json +5 -0
  99. package/themes/joker.json +5 -0
  100. package/themes/marvin.json +5 -0
  101. package/themes/mean.json +5 -0
  102. package/themes/military.json +5 -0
  103. package/themes/neutral.json +5 -0
  104. package/themes/pirate.json +5 -0
  105. package/themes/sports.json +5 -0
  106. package/themes/verstappen.json +5 -0
  107. package/themes/wizard.json +5 -0
  108. package/web/dist/assets/Activity-BfVcLrda.js +1 -0
  109. package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
  110. package/web/dist/assets/CatalogRow-DALEQImZ.js +1 -0
  111. package/web/dist/assets/Diff-BIjxXx46.js +5 -0
  112. package/web/dist/assets/Doctor-CJLbgsrw.js +1 -0
  113. package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
  114. package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
  115. package/web/dist/assets/Harness-Dm5CCU-v.js +1 -0
  116. package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
  117. package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
  118. package/web/dist/assets/Library-Dao3T9na.js +1 -0
  119. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-C__nuWX5.js} +15 -15
  120. package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
  121. package/web/dist/assets/Profile-BlXtUlh2.js +1 -0
  122. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-DIre6gxR.js} +1 -1
  123. package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
  124. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-wW_RS_my.js} +1 -1
  125. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-DpYPKNrA.js} +1 -1
  126. package/web/dist/assets/index-BEZvPvpF.js +1 -0
  127. package/web/dist/assets/index-DNQE5ydy.js +10 -0
  128. package/web/dist/assets/index-DzdQjvM1.css +1 -0
  129. package/web/dist/assets/index-PmQGRS1X.js +2 -0
  130. package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
  131. package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
  132. package/web/dist/index.html +2 -2
  133. package/web/src/pages/Laws.jsx +119 -130
  134. package/js/web/graph.mjs +0 -0
  135. package/web/dist/assets/Activity-B49fE_ug.js +0 -1
  136. package/web/dist/assets/ActivityDetail-abt7hzD2.js +0 -1
  137. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  138. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  139. package/web/dist/assets/Graph-DvYMtlvl.js +0 -1
  140. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  141. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  142. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  143. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  144. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  145. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  146. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  147. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  148. package/web/dist/assets/index-DP035wPa.js +0 -12
  149. package/web/dist/assets/index-dHi-7M-F.js +0 -2
  150. package/web/dist/assets/roman-DkYN0LX9.js +0 -1
@@ -10,8 +10,8 @@ import { VERSION_MARKER } from '../hosts/hosts.mjs';
10
10
  import { loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
11
11
  import { ensureAgentOverridesStub } from '../hosts/opencode.mjs';
12
12
  import {
13
- managedBlockRemove, managedBlockWrite, mergeClaudeSettings, unwireClaudeExcludes,
14
- unwireClaudeSettings, wireClaudeExcludes,
13
+ managedBlockRemove, managedBlockWrite, mergeClaudeSettings, mergeCopilotSettings,
14
+ unwireClaudeExcludes, unwireClaudeSettings, wireClaudeExcludes,
15
15
  } from '../hosts/settings.mjs';
16
16
  import { writeText } from '../lib/fs.mjs';
17
17
  import { isTruthy } from '../lib/json.mjs';
@@ -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') : {};
@@ -226,12 +225,19 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRu
226
225
  // settings.local.json — the personal, untracked file — never the team-shared
227
226
  // settings.json, which would hand every teammate failing hooks pointing at this machine's
228
227
  // node and this machine's checkout. (Bob documents no local variant, so it keeps
229
- // settings.json.) Copilot has NO
230
- // settings.json and no hook mechanism at all, so the whole stage is skipped: nothing
231
- // written, no settings_* keys recorded for the lifecycle to unwire.
228
+ // settings.json.) Copilot's settings are a different shape entirely (one command per
229
+ // event, no matcher groups) and take the `else` branch below; its PROJECT scope wires
230
+ // nothing, because the CLI documents hooks in `~/.copilot/settings.json` only and a
231
+ // machine-absolute hook committed into `.github/` would fail on every teammate's machine.
232
232
  if (!isCopilot) {
233
- const settingsName = scope === 'project' && !isBob ? 'settings.local.json' : 'settings.json';
233
+ // Bob GLOBAL: its hooks doc puts the global file at `~/.bob/settings/settings.json`
234
+ // (nested), where an older emit wrote `~/.bob/settings.json`; the `oldSf !==
235
+ // settingsName` branch below unwires the old file, so a re-emit migrates. Bob's project
236
+ // file stays `.bob/settings.json`.
237
+ const settingsName = scope === 'project' && !isBob ? 'settings.local.json'
238
+ : isBob && scope === 'global' ? path.join('settings', 'settings.json') : 'settings.json';
234
239
  const settingsPath = path.join(cfgDir, settingsName);
240
+ mkdirSync(path.dirname(settingsPath), { recursive: true });
235
241
  managed.settings_file = settingsName;
236
242
  // Migration: an older install wired hooks/excludes into a different file — unwire the
237
243
  // recorded claims there, or they linger (and run) forever.
@@ -248,7 +254,7 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRu
248
254
  // DECIDES the install's packs, so the marker on disk is still the previous build's.
249
255
  const [, managedHooks] = mergeClaudeSettings(
250
256
  settingsPath, scope, oldSf === settingsName ? get(old, 'settings_hooks') : null, hookOpts,
251
- doctrines, excludeRules,
257
+ doctrines, excludeRules, host,
252
258
  );
253
259
  managed.settings_hooks = managedHooks;
254
260
 
@@ -281,6 +287,14 @@ function claudeWire(job, claudeMdText, hasAgentText, doctrines = null, excludeRu
281
287
  } else if (priorExcl.length) {
282
288
  managed.settings_excludes = priorExcl;
283
289
  }
290
+ } else if (scope === 'global') {
291
+ // `copilot_hooks`, not `settings_hooks`: a distinct key so the Claude-shaped unwire and
292
+ // integrity code, which walks `settings_hooks` as event→group arrays, never sees a
293
+ // Copilot record it would misread.
294
+ const settingsPath = path.join(cfgDir, 'settings.json');
295
+ managed.settings_file = 'settings.json';
296
+ const [, claims] = mergeCopilotSettings(settingsPath, get(old, 'copilot_hooks'), hookOpts);
297
+ managed.copilot_hooks = claims;
284
298
  }
285
299
  return managed;
286
300
  }
@@ -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) {