geneseed 3.13.0 → 3.15.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 (131) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +23 -9
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +45 -10
  6. package/adapters/opencode/plugins/geneseed-notify.js +37 -19
  7. package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
  8. package/adapters/opencode/workflows/_runtime.js +16 -3
  9. package/bin/geneseed-cli.mjs +32 -34
  10. package/bin/geneseed-hook.mjs +28 -18
  11. package/docs/extending.md +5 -5
  12. package/harness.config.json +1 -1
  13. package/js/README.md +28 -15
  14. package/js/build/args.mjs +375 -0
  15. package/js/build/bundle.mjs +10 -10
  16. package/js/build/driver.mjs +145 -560
  17. package/js/build/emit-claude.mjs +5 -6
  18. package/js/build/emit-common.mjs +2 -14
  19. package/js/build/emit-opencode.mjs +12 -8
  20. package/js/build/generate.mjs +21 -14
  21. package/js/build/render.mjs +6 -10
  22. package/js/build/source.mjs +9 -17
  23. package/js/build/stubs.mjs +6 -5
  24. package/js/build/version.mjs +21 -2
  25. package/js/hosts/hooks-context.mjs +432 -0
  26. package/js/hosts/hooks-learn.mjs +275 -0
  27. package/js/hosts/hooks-prims.mjs +108 -0
  28. package/js/hosts/hooks.mjs +50 -977
  29. package/js/hosts/hosts.mjs +31 -25
  30. package/js/hosts/installs.mjs +38 -20
  31. package/js/hosts/link.mjs +75 -26
  32. package/js/hosts/mcp.mjs +12 -48
  33. package/js/hosts/memory-files.mjs +190 -0
  34. package/js/hosts/native.mjs +106 -48
  35. package/js/hosts/opencode.mjs +44 -36
  36. package/js/hosts/settings.mjs +135 -414
  37. package/js/hosts/shim.mjs +375 -0
  38. package/js/inspect/checks-authoring.mjs +21 -828
  39. package/js/inspect/checks-build.mjs +4 -4
  40. package/js/inspect/checks-constitution.mjs +493 -0
  41. package/js/inspect/checks-counts.mjs +242 -0
  42. package/js/inspect/checks-repo.mjs +17 -8
  43. package/js/inspect/diff.mjs +9 -7
  44. package/js/inspect/doctor.mjs +2 -2
  45. package/js/inspect/excludes.mjs +10 -5
  46. package/js/inspect/inventory.mjs +4 -4
  47. package/js/inspect/scan.mjs +1 -1
  48. package/js/inspect/status.mjs +19 -22
  49. package/js/inspect/validate.mjs +50 -37
  50. package/js/lib/fs.mjs +12 -11
  51. package/js/lib/json.mjs +4 -4
  52. package/js/lib/paths.mjs +27 -33
  53. package/js/lib/proc.mjs +3 -3
  54. package/js/lib/prompt.mjs +132 -0
  55. package/js/lib/text.mjs +18 -4
  56. package/js/lib/udiff.mjs +3 -2
  57. package/js/loop/catalog.mjs +5 -3
  58. package/js/loop/cli.mjs +26 -12
  59. package/js/loop/graph.mjs +1 -1
  60. package/js/loop/registry.mjs +10 -15
  61. package/js/loop/score.mjs +1 -1
  62. package/js/loop/state.mjs +35 -11
  63. package/js/maintain/memory.mjs +6 -7
  64. package/js/maintain/migrate.mjs +22 -33
  65. package/js/maintain/setup.mjs +9 -131
  66. package/js/maintain/uninstall.mjs +57 -97
  67. package/js/maintain/update.mjs +115 -34
  68. package/js/ui/anim.mjs +8 -12
  69. package/js/ui/cli.mjs +37 -43
  70. package/js/ui/menu.mjs +7 -13
  71. package/js/ui/tui.mjs +14 -123
  72. package/js/web/actions.mjs +109 -403
  73. package/js/web/activity.mjs +1 -1
  74. package/js/web/api.mjs +41 -725
  75. package/js/web/catalog.mjs +527 -0
  76. package/js/web/daemon.mjs +21 -3
  77. package/js/web/docs.mjs +29 -72
  78. package/js/web/handler.mjs +51 -27
  79. package/js/web/jobs.mjs +24 -2
  80. package/js/web/routes.mjs +61 -6
  81. package/js/web/server.mjs +4 -4
  82. package/js/web/state.mjs +138 -0
  83. package/js/web/user-files.mjs +339 -0
  84. package/package.json +1 -1
  85. package/web/dist/assets/Activity-kpWxd70F.js +1 -0
  86. package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
  87. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  88. package/web/dist/assets/Docs-BHyaggFy.js +5 -0
  89. package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
  90. package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
  91. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  92. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  93. package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
  94. package/web/dist/assets/Library-MBxadRzn.js +1 -0
  95. package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
  96. package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
  97. package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
  98. package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
  99. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  100. package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
  101. package/web/dist/assets/api-CxgfYu18.js +1 -0
  102. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  103. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  104. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  105. package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
  106. package/web/dist/assets/sections-GalMS0Zv.js +1 -0
  107. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  108. package/web/dist/index.html +5 -6
  109. package/web/src/pages/Laws.jsx +0 -1
  110. package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
  111. package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
  112. package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
  113. package/web/dist/assets/Docs-BzccCllG.js +0 -5
  114. package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
  115. package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
  116. package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
  117. package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
  118. package/web/dist/assets/Laws-BANxq_I_.js +0 -3
  119. package/web/dist/assets/Library-DW4x0fbh.js +0 -1
  120. package/web/dist/assets/Loops-DEBibbBP.js +0 -2
  121. package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
  122. package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
  123. package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
  124. package/web/dist/assets/activity-CObqyxkL.js +0 -1
  125. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  126. package/web/dist/assets/drawable-C_x5xncq.js +0 -1
  127. package/web/dist/assets/index-CMPgezFm.js +0 -2
  128. package/web/dist/assets/index-DMsT4Aa6.css +0 -1
  129. package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  130. package/web/dist/assets/sections-Dg23Hel5.js +0 -1
  131. package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
@@ -66,8 +66,12 @@ export function sovereignBypass(root) {
66
66
  // `withDiscardableStderr` because `expanduser` writes its refusal at the RAISE SITE
67
67
  // and this caller catches: the reference prints nothing here, so replaying the
68
68
  // message would be a stderr divergence on every hook call for one bad entry.
69
+ //
70
+ // `resolvePath`, the same resolution as `cwd` above: symlinks and junctions are followed on
71
+ // BOTH sides, so an entry spelled through a link matches the real directory the session
72
+ // stands in (and the OpenCode plugins' `norm` applies the identical rule).
69
73
  base = withDiscardableStderr(
70
- () => normcase(toPlatformPath(expanduser(raw.trim()))).replace(/[\\/]+$/, ''));
74
+ () => normcase(resolvePath(raw.trim())).replace(/[\\/]+$/, ''));
71
75
  } catch {
72
76
  // PER ENTRY, and the loop continues. One unusable line in a file the USER hand-edits
73
77
  // must not decide the whole function — `expanduser` refuses a `~user` form, and the
@@ -208,14 +212,13 @@ export function openclaudeConfigDir() {
208
212
  }
209
213
 
210
214
  /**
211
- * `_build_global.HOSTS`, reduced to the two columns a non-emitting caller needs, and IN ITS
212
- * ORDER — opencode, claude, bob, openclaude.
215
+ * `_build_global.HOSTS` — one row per host, IN ITS ORDER: opencode, claude, bob, openclaude.
216
+ * A new host is one row here; `EMIT_HOST_SCOPE` (`js/hosts/installs.mjs`) and `CLAUDE_STYLE`
217
+ * below are derived from it.
213
218
  *
214
219
  * An array rather than an object because the order is observable output, not an
215
220
  * implementation detail: `harness exclude add` walks it and prints one message per host, so
216
- * a reordering is a diff in stderr. (`emit_global` and `native_catalog` stay out;
217
- * `bin/build-driver.mjs` receives those decisions from its own dispatch rather than reading a
218
- * table.)
221
+ * a reordering is a diff in stderr.
219
222
  *
220
223
  * `projectMarker` joined in P5f. `_install_targets` asks "does this cwd carry a project
221
224
  * install of host H", and `_claude_cfg` asks "which subdir holds a project install's
@@ -228,24 +231,34 @@ export function openclaudeConfigDir() {
228
231
  * block`). It is a column and not a literal in the message for the reason every other column
229
232
  * here is one — Bob answers `AGENTS.md` while Claude answers `CLAUDE.md`, and
230
233
  * a host added later must not need the message edited to stay true.
234
+ *
235
+ * `family` joined in 2026-10: `'claude'` for every host emitted through the Claude-shaped
236
+ * engine — one manifest shape, one `settings.json` hook wiring, one strict-JSON `mcpServers`
237
+ * config, one reversal. The list had been spelled out by hand in ten places, one of which
238
+ * ignored the named constant right above it.
239
+ *
240
+ * `catalog` — does the host list every skill and agent to the model by itself? See
241
+ * `hostCatalogsNatively` below for what it decides and why it is split per kind.
231
242
  */
232
243
  export const HOSTS = [
233
- { host: 'opencode', configDir: opencodeConfigDir, projectMarker: '.opencode', agentFile: 'AGENT.md' },
234
- { host: 'claude', configDir: claudeConfigDir, projectMarker: '.claude', agentFile: 'CLAUDE.md' },
235
- { host: 'bob', configDir: bobConfigDir, projectMarker: '.bob', agentFile: 'AGENTS.md' },
236
- { host: 'openclaude', configDir: openclaudeConfigDir, projectMarker: '.openclaude', agentFile: 'CLAUDE.md' },
244
+ { host: 'opencode', family: 'opencode', configDir: opencodeConfigDir, projectMarker: '.opencode', agentFile: 'AGENT.md', catalog: { skills: true, agents: true } },
245
+ { host: 'claude', family: 'claude', configDir: claudeConfigDir, projectMarker: '.claude', agentFile: 'CLAUDE.md', catalog: { skills: true, agents: true } },
246
+ { host: 'bob', family: 'claude', configDir: bobConfigDir, projectMarker: '.bob', agentFile: 'AGENTS.md', catalog: { skills: true, agents: false } },
247
+ { host: 'openclaude', family: 'claude', configDir: openclaudeConfigDir, projectMarker: '.openclaude', agentFile: 'CLAUDE.md', catalog: { skills: true, agents: true } },
237
248
  ];
238
249
 
250
+ /** The Claude-STYLE hosts (`family: 'claude'`), in `HOSTS` order. Test with `.includes(host)`. */
251
+ export const CLAUDE_STYLE = HOSTS.filter((h) => h.family === 'claude').map((h) => h.host);
252
+
239
253
  /**
240
254
  * `_build_global.host_catalogs_natively` — does `host` list every skill and agent to the
241
255
  * model by itself?
242
256
  *
243
- * The third column of `HOSTS`, and the reason the docblock above no longer says
244
- * `native_catalog` stays out: `doctor`'s `_rendered_problems` has to know, because a host
257
+ * `HOSTS`' `catalog` column. `doctor`'s `_rendered_problems` has to know, because a host
245
258
  * that catalogues natively gets AGENT.md's tables collapsed to a pointer and comparing the
246
259
  * portable shape against its bundle reports AGENT.md stale on every run forever. It is a
247
- * column and not a set literal so that the capability is still declared in exactly one place
248
- * — the Python's own reason for the helper.
260
+ * column and not a set literal so that the capability is declared in exactly one place
261
+ * — the Python's own reason for the helper. The driver reads it through this function too.
249
262
  *
250
263
  * Unknown host -> false, which is the shape that KEEPS the tables. The value arrives from a
251
264
  * user-editable `.geneseed-emit` marker, so an unrecognised one must degrade to the portable
@@ -256,19 +269,12 @@ export const HOSTS = [
256
269
  * single boolean was wrong both ways: `false` shipped the §4 Skills table on top of a
257
270
  * catalogue Bob already had (~1.7k tokens twice), `true` would have stripped the §3 Agents
258
271
  * table that is Bob's ONLY agent catalogue. Each CATALOG block in AGENT.md names its kind and
259
- * resolves against its own flag. Mirrored in
260
- * `bin/build-driver.mjs` (which cannot import this module's spawning neighbours).
272
+ * resolves against its own flag.
273
+ *
274
+ * Returns `{ skills, agents }` for a known host, `false` for an unknown one.
261
275
  */
262
- const NATIVE_CATALOG = {
263
- opencode: { skills: true, agents: true },
264
- claude: { skills: true, agents: true },
265
- bob: { skills: true, agents: false },
266
- openclaude: { skills: true, agents: true },
267
- };
268
-
269
- /** `{ skills, agents }` for a known host, `false` for an unknown one. */
270
276
  export function hostCatalogsNatively(host) {
271
- return NATIVE_CATALOG[host] || false;
277
+ return HOSTS.find((h) => h.host === host)?.catalog || false;
272
278
  }
273
279
 
274
280
  /** `_harness_learn.MEMORY_DIR_NAMES` — the neutral name and the imperial theme's. */
@@ -36,10 +36,10 @@ import { existsSync, readdirSync } from 'node:fs';
36
36
  import path from 'node:path';
37
37
 
38
38
  import { CONFIG, PACK_ORDER, THEMES, discoverNames } from '../build/source.mjs';
39
- import { GLOBAL_MANIFEST, HOSTS, resolvePath } from './hosts.mjs';
39
+ import { CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, resolvePath } from './hosts.mjs';
40
40
  import { registryRoots } from '../inspect/registry.mjs';
41
41
  import { printErr, readText, isFile, isDir } from '../lib/fs.mjs';
42
- import { formatRepr } from '../lib/json.mjs';
42
+ import { formatRepr, isDict } from '../lib/json.mjs';
43
43
  import { comparePaths } from '../lib/paths.mjs';
44
44
 
45
45
  /**
@@ -107,8 +107,8 @@ export const defaultMode = () => configuredDefault('mode', 'direct');
107
107
  * `_build_render.theme_files` — shipped themes, `_`-prefixed scaffolds excluded.
108
108
  *
109
109
  * `dir` defaults to the checkout's own `themes/`, which is every caller but one: P2's
110
- * `syncThemes` is driven over a FIXTURE directory by the cross-implementation corpus in
111
- * `tests/test_maintainer_tools_parity.py`, because the tool it belongs to rewrites committed
110
+ * `syncThemes` is driven over a FIXTURE directory by the corpus in
111
+ * `tests/unit/maintainer_tools.test.mjs`, because the tool it belongs to rewrites committed
112
112
  * files and cannot be gated against the real ones. A parameter, not a second enumeration —
113
113
  * a scaffold mistaken for a theme is exactly what this function exists to prevent, once.
114
114
  *
@@ -162,12 +162,26 @@ function firstCarrier(d, probe, fallback = null) {
162
162
  return fallback;
163
163
  }
164
164
 
165
- /** `_harness_setup._theme_of_dir` — the marker, else the sigil in one of five carriers. */
165
+ /**
166
+ * A marker value that can only name a file directly in the themes dir: no separator, no `..`,
167
+ * and not a `_`-prefixed scaffold.
168
+ */
169
+ const THEME_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
170
+
171
+ /**
172
+ * `_harness_setup._theme_of_dir` — the marker, else the sigil in one of five carriers.
173
+ *
174
+ * The marker's value becomes `<themes>/<name>.json` in `loadTheme`, so a path in it
175
+ * (`../harness.config`) loaded any `.json` as a theme. A value that is not a plain name falls
176
+ * through to the sigil scan, as a missing marker does. A plain name that is not shipped is
177
+ * still RETURNED: `loadTheme` then refuses it loudly, which `status`/`rebuild-all`/`migrate`
178
+ * cells pin ("unknown theme 'nosuchtheme'"), rather than a typo going unnoticed.
179
+ */
166
180
  export function themeOfDir(d) {
167
181
  const marker = path.join(d, '.geneseed-theme');
168
182
  if (isFile(marker)) {
169
183
  const name = (readMaybe(marker) ?? '').trim();
170
- if (name) return name;
184
+ if (THEME_NAME_RE.test(name)) return name;
171
185
  }
172
186
  return firstCarrier(d, (carrierPath) => themeFromAgent(carrierPath) || undefined);
173
187
  }
@@ -210,7 +224,7 @@ export function footprintOfDir(d) {
210
224
  * the string, so a posture file named `Foreman` renders `**Foreman**` and one named `FOREMAN`
211
225
  * would be scanned for as `**Foreman**` by both implementations only if this reproduces the
212
226
  * lowercasing. Every shipped name is already lowercase, which is exactly why the difference
213
- * would go unnoticed — the corpus in `tests/test_pure_function_parity.py` is what covers it.
227
+ * would go unnoticed — the former Python corpus covered it, and no test drives it now.
214
228
  */
215
229
  export function capitalize(s) {
216
230
  return s.length ? s[0].toUpperCase() + s.slice(1).toLowerCase() : s;
@@ -239,10 +253,15 @@ export const modeOfDir = (d) => leadOfDir(d, discoverNames('modes', 'direct'));
239
253
  * `Default trust preset: **<Label>**` line, and this scans the places an emit lands it: the
240
254
  * native layer (`skills/loop/SKILL.md`, in the root itself for a global install and under the
241
255
  * host's `projectMarker` for a project one) and the plain bundle (`skills/loop.md`).
256
+ *
257
+ * `host` narrows the project dirs to that host's own: in a repo carrying `.opencode/` AND
258
+ * `.claude/`, the Claude row otherwise read OpenCode's preset, since OpenCode comes first in
259
+ * `HOSTS`. Without it every host's dir is tried, in `HOSTS` order — the caller that has no host.
242
260
  */
243
261
  const TRUST_RE = /^Default trust preset: \*\*(Prudent|Balanced|Aggressive)\*\*/m;
244
- export function trustOfDir(d) {
245
- for (const base of ['', ...HOSTS.map((h) => h.projectMarker)]) {
262
+ export function trustOfDir(d, host = null) {
263
+ const rows = host === null ? HOSTS : HOSTS.filter((h) => h.host === host);
264
+ for (const base of ['', ...rows.map((h) => h.projectMarker)]) {
246
265
  for (const rel of [['skills', 'loop', 'SKILL.md'], ['skills', 'loop.md']]) {
247
266
  const m = TRUST_RE.exec(readMaybe(path.join(d, base, ...rel)) ?? '');
248
267
  if (m) return m[1].toLowerCase();
@@ -394,7 +413,7 @@ export function excludedRulesOfDir(d) {
394
413
  /** `_harness_mcp._claude_read_manifest`. */
395
414
  export function claudeReadManifest(cfgDir) {
396
415
  const data = readJsonMaybe(path.join(cfgDir, GLOBAL_MANIFEST));
397
- return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
416
+ return isDict(data) ? data : {};
398
417
  }
399
418
 
400
419
  /**
@@ -403,17 +422,16 @@ export function claudeReadManifest(cfgDir) {
403
422
  * Claude-style reversal.
404
423
  */
405
424
  export function manifestIsClaude(cfgDir) {
406
- const mg = claudeReadManifest(cfgDir).managed;
407
- return typeof mg === 'object' && mg !== null && !Array.isArray(mg);
425
+ return isDict(claudeReadManifest(cfgDir).managed);
408
426
  }
409
427
 
410
- /** `_harness_mcp._EMIT_HOST_SCOPE` — a marker's emit name fixes (host, scope). */
411
- export const EMIT_HOST_SCOPE = new Map([
412
- ['opencode', ['opencode', 'project']], ['opencode-global', ['opencode', 'global']],
413
- ['claude', ['claude', 'project']], ['claude-global', ['claude', 'global']],
414
- ['bob', ['bob', 'project']], ['bob-global', ['bob', 'global']],
415
- ['openclaude', ['openclaude', 'project']], ['openclaude-global', ['openclaude', 'global']],
416
- ]);
428
+ /**
429
+ * `_harness_mcp._EMIT_HOST_SCOPE` — a marker's emit name fixes (host, scope). Derived from
430
+ * `HOSTS`, whose emit names are `<host>` and `<host>-global`, so a new host is one row there.
431
+ */
432
+ export const EMIT_HOST_SCOPE = new Map(HOSTS.flatMap(({ host }) => [
433
+ [host, [host, 'project']], [`${host}-global`, [host, 'global']],
434
+ ]));
417
435
 
418
436
  /** `_harness_mcp._emit_host_scope_of` — `root`'s own marker, resolved, or null. */
419
437
  export function emitHostScopeOf(root) {
@@ -454,7 +472,7 @@ export function installKind(root) {
454
472
 
455
473
  /** `_harness_mcp._install_state` — 'active' | 'disabled' | 'absent'. */
456
474
  export function installState(root, host = 'opencode', scope = 'global') {
457
- if (['claude', 'bob', 'openclaude'].includes(host)) return claudeState(root, scope, host);
475
+ if (CLAUDE_STYLE.includes(host)) return claudeState(root, scope, host);
458
476
  if (isDir(path.join(root, DISABLED_STASH))) return 'disabled';
459
477
  return installKind(root) !== null ? 'active' : 'absent';
460
478
  }
package/js/hosts/link.mjs CHANGED
@@ -57,8 +57,8 @@
57
57
  * never needs to:
58
58
  *
59
59
  * * `winUserPathScript` is PURE — the escaping, the split and the idempotence live there
60
- * and `tests/test_win_user_path.py` runs it over a corpus (apostrophes, trailing
61
- * separators, UNC roots) against the Python twin, byte for byte.
60
+ * and `tests/unit/win_user_path.test.mjs` runs it over a corpus of hazards (apostrophes,
61
+ * typographic quotes, trailing separators, UNC roots) and asserts each property absolutely.
62
62
  * * `cmdLink` SHORT-CIRCUITS when the bin dir is already on `PATH`, so a cell that seeds
63
63
  * `PATH` with its own sandboxed bin dir exercises the whole verb and never spawns.
64
64
  * * `cmdUnlink` has no such short-circuit, so its cells seed a `PATH` with no `powershell`
@@ -81,9 +81,11 @@ import os from 'node:os';
81
81
  import { ROOT } from '../build/source.mjs';
82
82
  import { readMaybe } from './installs.mjs';
83
83
  import { printOut, printErr, writeText } from '../lib/fs.mjs';
84
- import { toPlatformPath } from '../lib/paths.mjs';
84
+ import { toPlatformPath, which } from '../lib/paths.mjs';
85
85
 
86
86
  const IS_WIN = process.platform === 'win32';
87
+ /** What `cmdUnlink` recognises a written Unix launcher by — see `isOurLauncher`. */
88
+ const SHIM_MARKER = 'GENESEED_LINK_SHIM';
87
89
 
88
90
  /** `_harness_lifecycle._win_bin_dir`. */
89
91
  function winBinDir() {
@@ -97,12 +99,18 @@ function winBinDir() {
97
99
  * `_harness_lifecycle._win_user_path_script` — the PowerShell one-liner, built but not run.
98
100
  *
99
101
  * Every quoting hazard in this verb is in this string, which is why it is a separate
100
- * function on both sides: `''` is PowerShell's single-quote escape, the `;` split drops
102
+ * function: `''` is PowerShell's single-quote escape, the `;` split drops
101
103
  * empty segments so a trailing separator cannot append an empty entry, and `-notcontains`
102
104
  * is what makes `link` idempotent.
105
+ *
106
+ * THE ESCAPE COVERS FIVE CHARACTERS, NOT ONE. PowerShell also reads the typographic quotes
107
+ * U+2018–U+201B as single quotes, so one of them left bare in a directory name closes the
108
+ * string exactly as `'` does and turns the rest of the path into code. Each is doubled with
109
+ * ITSELF — `‘‘` is the escape for `‘` — which is what PowerShell's own `CodeGeneration.
110
+ * EscapeSingleQuotedStringContent` does.
103
111
  */
104
112
  export function winUserPathScript(action, directory) {
105
- const d = directory.replaceAll("'", "''"); // PS single-quote escape (O'Brien)
113
+ const d = directory.replace(/['‘-‛]/g, '$&$&'); // PS single-quote escape (O'Brien)
106
114
  if (action === 'add') {
107
115
  return `$d='${d}';`
108
116
  + "$p=[Environment]::GetEnvironmentVariable('Path','User');"
@@ -134,13 +142,56 @@ export function winUserPathScript(action, directory) {
134
142
  * false rather than crashing the verb.
135
143
  */
136
144
  function winUserPath(action, directory) {
137
- const r = spawnSync('powershell',
145
+ // `which`, never a bare name: Windows resolves a bare name against the cwd first, and
146
+ // `which` never searches the cwd implicitly (see its docblock).
147
+ const ps = which('powershell');
148
+ if (!ps) return false;
149
+ const r = spawnSync(ps,
138
150
  ['-NoProfile', '-Command', winUserPathScript(action, directory)],
139
151
  { stdio: 'inherit', windowsHide: true });
140
152
  if (r.error) return false;
141
153
  return r.status === 0;
142
154
  }
143
155
 
156
+ /**
157
+ * Is `dir` one of the ENTRIES of a Windows PATH string?
158
+ *
159
+ * Entry-wise, not a substring test: `…\Geneseed\bin` is a substring of `…\Geneseed\bin2` and of
160
+ * `…\Geneseed\bin\old`, and a substring hit skipped the registry write while the shim's own dir
161
+ * stayed off PATH. Trailing separators and case are both noise on Windows.
162
+ */
163
+ export function winPathHas(pathEnv, dir) {
164
+ const norm = (s) => s.replace(/[\\/]+$/, '').toLowerCase();
165
+ const want = norm(dir);
166
+ return pathEnv.split(';').some((e) => e && norm(e) === want);
167
+ }
168
+
169
+ /**
170
+ * The Unix launcher's text. Single-quoted, never double: inside `"…"` the shell still expands
171
+ * `$`, backticks and `\`, so an install path holding any of them ran as code each time
172
+ * `geneseed` started. `'\''` is the one escape single quotes need.
173
+ */
174
+ export function unixShimText(exe, entry) {
175
+ const q = (s) => `'${s.replaceAll("'", "'\\''")}'`;
176
+ return `#!/bin/sh\n# ${SHIM_MARKER}\nexec ${q(exe)} ${q(entry)} "$@"\n`;
177
+ }
178
+
179
+ /**
180
+ * Is the file at `f` a launcher `geneseed link` made? Two shapes exist on disk. (a) A symlink,
181
+ * judged by `os.readlink(f)` raw, then `.name` — NOT a resolved path: the question is whether
182
+ * the link's own target BASENAME is `geneseed`, so a symlink pointing at some other program
183
+ * that happens to sit in a `geneseed` directory is left alone. That is every install made
184
+ * before the shim. (b) A regular file carrying the marker — never "any regular file called
185
+ * geneseed", which would be npm's own bin link or a stranger's program.
186
+ *
187
+ * ONE OWNER FOR BOTH VERBS. `unlink` always refused what is not ours; `link` used to delete
188
+ * whatever sat at its destination before writing, so the two disagreed about the same file.
189
+ */
190
+ function isOurLauncher(f) {
191
+ if (isSymlink(f)) return path.basename(readlinkSync(f)) === 'geneseed';
192
+ return existsSync(f) && (readMaybe(f) ?? '').includes(SHIM_MARKER);
193
+ }
194
+
144
195
  /** `_harness_lifecycle.cmd_link` — put `geneseed` on PATH so it runs from any directory. */
145
196
  export function cmdLink(args) {
146
197
  const here = ROOT;
@@ -159,8 +210,7 @@ export function cmdLink(args) {
159
210
  return 1;
160
211
  }
161
212
  printOut(`geneseed: wrote shim ${shim}\n`);
162
- const on_path = (process.env.PATH || '').toLowerCase().includes(bindir.toLowerCase());
163
- if (on_path || winUserPath('add', bindir)) {
213
+ if (winPathHas(process.env.PATH || '', bindir) || winUserPath('add', bindir)) {
164
214
  printOut(`geneseed: '${bindir}' is on your user PATH — open a NEW terminal, then run \`geneseed\`.\n`);
165
215
  } else {
166
216
  printOut(`geneseed: add '${bindir}' to your PATH manually, then run \`geneseed\` from anywhere.\n`);
@@ -174,8 +224,8 @@ export function cmdLink(args) {
174
224
  // PATH=` line) as well as comparing it against `PATH.split(os.pathsep)`, so an argument
175
225
  // that is not already normalised diverges in three places at once. A trailing slash is the
176
226
  // cheapest one: `str(Path('/x/bin/'))` is `/x/bin` and the raw string is not.
177
- // `harness_golden`'s `link/an-explicit-dir-argument-is-used-instead-of-the-default` passes
178
- // one, and it is the cell that found this.
227
+ // The retired cell `link/an-explicit-dir-argument-is-used-instead-of-the-default` passed
228
+ // one, and it is what found this.
179
229
  let targetDir = args.dir ? toPlatformPath(args.dir) : null;
180
230
  if (targetDir === null) {
181
231
  const local = path.join(os.homedir(), '.local', 'bin');
@@ -191,16 +241,23 @@ export function cmdLink(args) {
191
241
  try {
192
242
  mkdirSync(targetDir, { recursive: true });
193
243
  // `dest.is_symlink() or dest.exists()` — the first half is what catches a BROKEN
194
- // symlink, which `existsSync` follows and reports absent.
195
- if (isSymlink(dest) || existsSync(dest)) rmSync(dest, { force: true });
244
+ // symlink, which `existsSync` follows and reports absent. Only OUR launcher is replaced:
245
+ // anything else there — npm's own bin link, a stranger's program — is refused, the same
246
+ // judgement `cmdUnlink` makes about the same file.
247
+ if (isSymlink(dest) || existsSync(dest)) {
248
+ if (!isOurLauncher(dest)) {
249
+ printErr(`geneseed: ${dest} exists and is not a launcher geneseed wrote — leaving it `
250
+ + 'alone. Remove it yourself, or pick another dir: geneseed link <dir>\n');
251
+ return 1;
252
+ }
253
+ rmSync(dest, { force: true });
254
+ }
196
255
  // A WRITTEN SHIM, not a symlink to `ROOT/geneseed`. The bash launcher execs a Python
197
256
  // interpreter, so symlinking it puts Python on PATH from the Node verb. The Windows
198
- // arm above has always written a shim; this is the same shape. `GENESEED_LINK_SHIM`
199
- // is the marker `cmdUnlink` recognises — without it, unlink cannot tell our file from
257
+ // arm above has always written a shim; this is the same shape. `SHIM_MARKER`
258
+ // is what `cmdUnlink` recognises — without it, unlink cannot tell our file from
200
259
  // a stranger's and would have to refuse every regular file, which is a no-op.
201
- writeText(dest, '#!/bin/sh\n'
202
- + '# GENESEED_LINK_SHIM\n'
203
- + `exec "${process.execPath}" "${entry}" "$@"\n`);
260
+ writeText(dest, unixShimText(process.execPath, entry));
204
261
  chmodSync(dest, 0o755);
205
262
  } catch (e) {
206
263
  printErr(`geneseed: could not write ${dest} (${asOsError(e)}) — pick a writable dir: `
@@ -246,15 +303,7 @@ export function cmdUnlink() {
246
303
  if (seen.has(d)) continue;
247
304
  seen.add(d);
248
305
  const f = path.join(d, 'geneseed');
249
- // TWO shapes, because two shapes exist on disk. (a) `os.readlink(f)` raw, then `.name`
250
- // — NOT a resolved path: the reference asks whether the link's own target BASENAME is
251
- // `geneseed`, so a symlink pointing at some other program that happens to sit in a
252
- // `geneseed` directory is left alone. That is every install made before the shim.
253
- // (b) a regular file we wrote, identified by its marker — never "any regular file
254
- // called geneseed", which would delete a stranger's program.
255
- const ours = (isSymlink(f) && path.basename(readlinkSync(f)) === 'geneseed')
256
- || (!isSymlink(f) && existsSync(f) && (readMaybe(f) ?? '').includes('GENESEED_LINK_SHIM'));
257
- if (ours) {
306
+ if (isOurLauncher(f)) {
258
307
  try {
259
308
  rmSync(f);
260
309
  printOut(`geneseed: removed ${f}\n`);
package/js/hosts/mcp.mjs CHANGED
@@ -11,12 +11,6 @@
11
11
  * `_mcp_install_targets`, `_mcp_known_names` and `_mcp_meta` are 168 lines of Python, all
12
12
  * new. The endpoints on top of them are the small half.
13
13
  *
14
- * AND `settings.mjs`'s ATOMIC WRITE IS NOT THIS ONE. `atomicWriteJson` is
15
- * `_build_settings._atomic_write_json`; `_mcp_save` is a different function with a different
16
- * temp suffix (`.tmp`, not `.geneseed-tmp`) that also creates the parent directory. Reusing
17
- * the first would be right until the moment a write fails, which is the moment the temp
18
- * file's name becomes the only evidence of what happened.
19
- *
20
14
  * THE ROUND TRIP IS THE HAZARD, and it is why every read here goes through `parseJson`.
21
15
  * `_mcp_save` rewrites the WHOLE config, not the servers map — and for a Claude global
22
16
  * install that config is `~/.claude.json`, which holds projects and history far beyond MCP
@@ -28,23 +22,23 @@
28
22
  * the wrapper rather than testing `typeof v === 'object'`. Python's `isinstance(x, dict)` is
29
23
  * false for a float; the JS twin has to be false for its wrapper.
30
24
  */
31
- import { existsSync, mkdirSync, renameSync } from 'node:fs';
25
+ import { existsSync } from 'node:fs';
32
26
  import os from 'node:os';
33
27
  import path from 'node:path';
34
28
 
35
- import { bobConfigDir, resolvePath } from './hosts.mjs';
29
+ import { CLAUDE_STYLE, bobConfigDir, resolvePath } from './hosts.mjs';
36
30
  import { installState, installTargets } from './installs.mjs';
37
- import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
38
- import { isDict, jsonDumpsIndent, parseJson } from '../lib/json.mjs';
31
+ import { printOut, printErr } from '../lib/fs.mjs';
32
+ import { isDict } from '../lib/json.mjs';
39
33
  import { padEndToWidth } from '../lib/text.mjs';
40
- import { opencodeTarget, readJsonc } from './settings.mjs';
34
+ import { atomicWriteJson, loadJsonObject, opencodeTarget } from './settings.mjs';
41
35
 
42
36
 
43
37
  /** `dict.get(key, default)`. */
44
38
  const dget = (obj, key, dflt) => (isDict(obj) && Object.hasOwn(obj, key) ? obj[key] : dflt);
45
39
 
46
40
  /** The hosts whose MCP config is strict JSON under `mcpServers`, with no `enabled` flag. */
47
- const FLAGLESS = ['claude', 'bob', 'openclaude'];
41
+ const FLAGLESS = CLAUDE_STYLE;
48
42
 
49
43
  /**
50
44
  * `_harness_mcp._MCP_PRESETS` — the ready-to-wire servers the MCP screen can toggle.
@@ -175,49 +169,19 @@ export function mcpSetEnabled(config, name, enabled, host = 'opencode') {
175
169
  * version of this note said otherwise, and `generate.test.mjs` pins the string-aware case.)
176
170
  */
177
171
  export function mcpLoad(p, host = 'opencode') {
178
- if (!existsSync(p)) return {};
179
- let text;
180
- try {
181
- text = readText(p);
182
- } catch {
183
- return {};
184
- }
185
- if (FLAGLESS.includes(host)) {
186
- let data;
187
- try {
188
- data = parseJson(text);
189
- } catch {
190
- return {};
191
- }
192
- return isDict(data) ? data : {};
193
- }
194
- const [data] = readJsonc(text);
195
- return isDict(data) ? data : {};
172
+ const { state, data } = loadJsonObject(p, { strict: FLAGLESS.includes(host) });
173
+ return state === 'ok' ? data : {};
196
174
  }
197
175
 
198
176
  /** `_mcp_commented` — an existing `.jsonc` that carries comments, which must not be rewritten. */
199
177
  export function mcpCommented(p) {
200
- if (path.extname(p) !== '.jsonc' || !existsSync(p)) return false;
201
- try {
202
- const [, had] = readJsonc(readText(p));
203
- return had;
204
- } catch {
205
- return false;
206
- }
178
+ if (path.extname(p) !== '.jsonc') return false;
179
+ return loadJsonObject(p).hadComments;
207
180
  }
208
181
 
209
- /**
210
- * `_mcp_save` — pretty JSON, written ATOMICALLY through a sibling temp file.
211
- *
212
- * Not `atomicWriteJson`: that is `_build_settings._atomic_write_json`, whose temp file is
213
- * named `.geneseed-tmp` and which does not create the parent directory. Two Python
214
- * functions, two twins.
215
- */
182
+ /** `_mcp_save` — pretty JSON, written ATOMICALLY. The one writer, under its MCP name. */
216
183
  export function mcpSave(p, config) {
217
- mkdirSync(path.dirname(p), { recursive: true });
218
- const tmp = `${p}.tmp`;
219
- writeText(tmp, `${jsonDumpsIndent(config)}\n`);
220
- renameSync(tmp, p);
184
+ atomicWriteJson(p, config);
221
185
  }
222
186
 
223
187
  /**