geneseed 3.20.0 → 4.1.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 (125) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +9 -1
  3. package/SHIPPED.md +1 -1
  4. package/adapters/bob/README.md +31 -12
  5. package/adapters/claude-code/README.md +24 -17
  6. package/adapters/claude-code/settings.json +4 -6
  7. package/adapters/openclaude/README.md +13 -5
  8. package/adapters/opencode/HOW-OPENCODE-LOADS.md +35 -0
  9. package/adapters/opencode/plugins/geneseed-activity.js +105 -2
  10. package/adapters/opencode/plugins/geneseed-context.js +96 -1
  11. package/adapters/opencode/plugins/geneseed-guard.js +324 -175
  12. package/adapters/opencode/plugins/geneseed-learn.js +127 -14
  13. package/adapters/opencode/plugins/geneseed-notify.js +46 -1
  14. package/adapters/opencode/plugins/geneseed-ponytail.js +44 -2
  15. package/adapters/opencode/plugins/geneseed-workflow.js +124 -24
  16. package/bin/geneseed-hook.mjs +36 -5
  17. package/docs/README.md +3 -1
  18. package/docs/candidate-hosts.md +0 -8
  19. package/docs/concepts/enforcement-table.md +66 -0
  20. package/docs/concepts/hook-gates.md +2 -2
  21. package/docs/concepts/hooks.md +6 -6
  22. package/docs/concepts/host-matrix.md +4 -3
  23. package/docs/concepts/loop-branch.md +6 -1
  24. package/docs/concepts/rules.md +1 -1
  25. package/docs/design-history.md +59 -0
  26. package/docs/extending.md +11 -3
  27. package/docs/guides/install-opencode.md +1 -1
  28. package/docs/guides/loop-running.md +1 -1
  29. package/docs/guides/mcp-filesystem.md +2 -2
  30. package/docs/guides/mcp-gitlab.md +2 -2
  31. package/docs/guides/mcp-markitdown.md +3 -3
  32. package/docs/guides/mcp.md +7 -7
  33. package/docs/guides/project-context.md +1 -1
  34. package/docs/reference/env-context.md +3 -2
  35. package/docs/reference/environment.md +2 -1
  36. package/docs/reference/opencode-plugins.md +9 -0
  37. package/docs/reference/plugin-guard.md +3 -3
  38. package/docs/reference/troubleshoot-hooks.md +34 -0
  39. package/docs/understand/design-choices-engineering.md +29 -0
  40. package/docs/understand/design-choices-gates.md +23 -0
  41. package/docs/understand/design-choices-hosts.md +21 -0
  42. package/docs/understand/design-choices-install.md +19 -0
  43. package/docs/understand/design-choices-shape.md +21 -0
  44. package/docs/understand/design-choices.md +34 -0
  45. package/docs/understand/enforced-vs-asked.md +5 -4
  46. package/docs/understand/machine-bob.md +1 -1
  47. package/docs/understand/machine-opencode.md +2 -2
  48. package/harness.config.json +1 -1
  49. package/js/build/driver.mjs +42 -16
  50. package/js/build/emit-claude.mjs +24 -8
  51. package/js/build/emit-opencode.mjs +33 -15
  52. package/js/build/generate.mjs +38 -9
  53. package/js/build/source.mjs +39 -0
  54. package/js/cli-table.json +7 -7
  55. package/js/hosts/gitref.mjs +3 -3
  56. package/js/hosts/hooks-context.mjs +11 -30
  57. package/js/hosts/hooks-learn.mjs +89 -8
  58. package/js/hosts/hooks.mjs +212 -18
  59. package/js/hosts/hosts.mjs +217 -13
  60. package/js/hosts/installs.mjs +259 -46
  61. package/js/hosts/mcp.mjs +23 -152
  62. package/js/hosts/native.mjs +48 -12
  63. package/js/hosts/opencode.mjs +48 -18
  64. package/js/hosts/settings.mjs +366 -42
  65. package/js/hosts/shim.mjs +37 -0
  66. package/js/inspect/checks-authoring.mjs +81 -1
  67. package/js/inspect/checks-constitution.mjs +2 -1
  68. package/js/inspect/checks-repo.mjs +68 -4
  69. package/js/inspect/doctor.mjs +40 -9
  70. package/js/inspect/scan.mjs +5 -4
  71. package/js/inspect/status.mjs +62 -12
  72. package/js/lib/text.mjs +23 -0
  73. package/js/loop/catalog.mjs +7 -2
  74. package/js/maintain/migrate.mjs +42 -5
  75. package/js/maintain/uninstall.mjs +150 -38
  76. package/js/maintain/update.mjs +22 -2
  77. package/js/web/actions.mjs +43 -87
  78. package/js/web/api.mjs +13 -4
  79. package/js/web/handler.mjs +2 -2
  80. package/js/web/routes.mjs +1 -2
  81. package/js/web/state.mjs +28 -15
  82. package/package.json +1 -1
  83. package/src/agents/_template.md +3 -2
  84. package/src/agents/researcher.md +2 -1
  85. package/src/agents/reviewer.md +9 -4
  86. package/src/agents/tester.md +3 -3
  87. package/src/bricks/_template.md +1 -1
  88. package/src/doctrines/ops.md +9 -7
  89. package/src/doctrines/rigor.md +8 -7
  90. package/src/laws/universal.md +1 -1
  91. package/src/skills/_template.md +3 -3
  92. package/src/skills/brainstorm/builder-check.md +16 -0
  93. package/src/skills/brainstorm.md +19 -11
  94. package/src/skills/daydream/SKILL.md +1 -1
  95. package/src/skills/daydream/VENDOR.md +4 -0
  96. package/src/skills/debug/ci.md +4 -4
  97. package/src/skills/debug.md +4 -4
  98. package/src/skills/develop.md +6 -4
  99. package/src/skills/fresh-eyes.md +1 -1
  100. package/src/skills/geneseed-code-review.md +8 -2
  101. package/src/skills/handoff.md +1 -1
  102. package/src/skills/herdr.md +1 -1
  103. package/src/skills/parallel-agents.md +4 -4
  104. package/src/skills/pipeline.md +3 -3
  105. package/src/skills/plan.md +18 -6
  106. package/src/skills/review-response.md +5 -2
  107. package/src/skills/ship.md +2 -1
  108. package/src/skills/worktree.md +2 -0
  109. package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-CqVt2u9s.js} +1 -1
  110. package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Bui5Gomc.js} +1 -1
  111. package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-VlIx1-At.js} +2 -2
  112. package/web/dist/assets/{Docs-CypIfDBX.js → Docs-6cYYkVeA.js} +1 -1
  113. package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DgNY8QKU.js} +1 -1
  114. package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-DZwZOUZJ.js} +1 -1
  115. package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-DXpFbKOG.js} +1 -1
  116. package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-Bh7ztekk.js} +1 -1
  117. package/web/dist/assets/{Library-BraUKPyQ.js → Library-DBLX-1SD.js} +1 -1
  118. package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-CvFMZ7_P.js} +1 -1
  119. package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-FPTUH1CB.js} +1 -1
  120. package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-DcVA502F.js} +1 -1
  121. package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-DtyR4h75.js} +1 -1
  122. package/web/dist/assets/{api-CxgfYu18.js → api-CRqWld2H.js} +1 -1
  123. package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-CNSPVZxX.js} +1 -1
  124. package/web/dist/assets/{index-CPi22b7y.js → index-BmjcLIOr.js} +2 -2
  125. package/web/dist/index.html +2 -2
@@ -50,18 +50,20 @@ import path from 'node:path';
50
50
  import { hookRunnerEntry } from '../hosts/shim.mjs';
51
51
  import { confirm } from '../lib/prompt.mjs';
52
52
  import {
53
- claudeCfg, claudeReadManifest, doctrinesOfDir, emitHostScopeOf, excludedRulesOfDir, installKind,
54
- installState,
53
+ claudeCfg, claudeReadManifest, doctrinesOfDir, EMIT_HOST_SCOPE, emitHostScopeOf,
54
+ excludedRulesOfDir, installKind, installState,
55
55
  registeredTargets, DISABLED_STASH,
56
56
  } from '../hosts/installs.mjs';
57
57
  import {
58
- CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
58
+ CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, isHostGlobalDir, opencodeConfigDir,
59
+ resolvePath, settingsFile,
59
60
  } from '../hosts/hosts.mjs';
60
61
  import { mcpCommented, mcpLoad } from '../hosts/mcp.mjs';
61
62
  import {
62
63
  atomicWriteJson, managedBlockRead, managedBlockRemove, managedBlockWrite,
63
- loadJsonObject, mergeClaudeSettings, opencodeTarget,
64
- settingsIntegrityCheck, wireClaudeExcludes, unwireClaudeExcludes, unwireClaudeSettings,
64
+ loadJsonObject, mergeClaudeSettings, opencodeTarget, OPENCODE_SENTINEL, opencodeSentinelWrite,
65
+ settingsIntegrityCheck, wireClaudeExcludes, unwireClaudeExcludes,
66
+ unwireClaudeSettings,
65
67
  } from '../hosts/settings.mjs';
66
68
  import { printOut, printErr, readText, writeText, isFile, isDir, isOsError } from '../lib/fs.mjs';
67
69
  import { indexOfDeepEqual, isDict, jsonDumps, deepEquals } from '../lib/json.mjs';
@@ -212,27 +214,63 @@ function warnMarkersKept() {
212
214
  const REVERSAL_MARKERS = [GLOBAL_MANIFEST, '.geneseed-theme', '.geneseed-emit',
213
215
  '.geneseed-footprint', VERSION_MARKER];
214
216
 
217
+ /**
218
+ * The CURRENT manifest's `skill_denies` — the `permission.skill` denies Geneseed itself added
219
+ * (`opencodeLayer` in emit-opencode.mjs records them; a deny the user wrote, even on a Geneseed
220
+ * skill, is never in it). Task 4 (O-1) wires one per user-only skill, and unlike the Law IV
221
+ * `permission.bash` gates (permanent invariants, never taken back — see
222
+ * `reconcileOpencodePermission`), a skill-visibility deny is reversible: once uninstalled there
223
+ * is no skill left to hide, and the name is the user's to reuse. No record takes back nothing.
224
+ */
225
+ function skillPermissionNames(man) {
226
+ return Array.isArray(man.skill_denies) ? man.skill_denies.filter((n) => typeof n === 'string') : [];
227
+ }
228
+
215
229
  /**
216
230
  * `_harness_mcp._unmerge_opencode_json` — drop one `instructions` entry, leave every other
217
231
  * key intact.
218
232
  *
219
- * A COMMENTED `.jsonc` is not rewritten and the user is told to do it by hand: rewriting it
233
+ * `denyNames`, Task 4 (O-1): the denies this install's manifest records writing
234
+ * (`skill_denies`) — remove `permission.skill[name]` for each one, but ONLY where the value is
235
+ * still exactly `"deny"`, and drop the `skill` object once it is empty. Ownership comes from the
236
+ * record, not the value; the value check on top guards a user who re-scoped a recorded name to
237
+ * their own `"allow"`/`"ask"`. A
238
+ * caller passing `[]` (every non-uninstall unwire — `installDeactivate`, same as
239
+ * `permission.bash`) leaves `permission.skill` untouched, so a paused install keeps the model
240
+ * blind to its skills rather than re-exposing them until the next `geneseed build`.
241
+ *
242
+ * The sweep runs even when the `instructions` entry is already gone — an uninstall after a
243
+ * deactivate, which removed the entry but kept the denies (PR #241 review) — so either half
244
+ * alone is a change, and only "neither" declines.
245
+ *
246
+ * A COMMENTED `.jsonc` is not rewritten and the user is told to do it by hand — the entry AND
247
+ * the recorded denies still set to `"deny"`, each named: rewriting it
220
248
  * would drop the comments. That branch returns False, so the caller's `unmerged` reports
221
249
  * REALITY rather than intent.
222
250
  */
223
- export function unmergeOpencodeJson(p, entry) {
251
+ export function unmergeOpencodeJson(p, entry, denyNames = []) {
224
252
  const target = opencodeTarget(p);
225
253
  // Absent, unreadable (the Python's `except OSError: return False`) or not an object: decline.
226
254
  const { state, data: cfg, hadComments } = loadJsonObject(target);
227
255
  if (state !== 'ok') return false;
228
256
  const instr = cfg.instructions;
229
- if (!Array.isArray(instr) || !instr.includes(entry)) return false;
257
+ const hasEntry = Array.isArray(instr) && instr.includes(entry);
258
+ // The deny sweep does NOT hang off the entry: `installDeactivate` already dropped the entry
259
+ // (and kept the denies), so an uninstall after a deactivate finds no entry but still owns them.
260
+ const skill = isDict(cfg.permission) && isDict(cfg.permission.skill) ? cfg.permission.skill : null;
261
+ const denies = skill ? denyNames.filter((n) => skill[n] === 'deny') : [];
262
+ if (!hasEntry && !denies.length) return false;
230
263
  if (path.extname(target) === '.jsonc' && hadComments) {
231
- printOut(`[uninstall] ${path.basename(target)} has comments — not rewriting it. Remove `
232
- + `this from its "instructions" by hand: ${jsonDumps(entry)}\n`);
264
+ printOut(`[uninstall] ${path.basename(target)} has comments — not rewriting it.`
265
+ + (hasEntry ? ` Remove this from its "instructions" by hand: ${jsonDumps(entry)}` : '')
266
+ + (denies.length ? ` Remove these from its "permission.skill" by hand: `
267
+ + `${denies.map((n) => jsonDumps(n)).join(', ')}` : '')
268
+ + '\n');
233
269
  return false;
234
270
  }
235
- cfg.instructions = instr.filter((i) => i !== entry);
271
+ if (hasEntry) cfg.instructions = instr.filter((i) => i !== entry);
272
+ for (const name of denies) delete skill[name];
273
+ if (skill && denies.length && !Object.keys(skill).length) delete cfg.permission.skill;
236
274
  atomicWriteJson(target, cfg);
237
275
  return true;
238
276
  }
@@ -265,16 +303,9 @@ export function archiveStore(store) {
265
303
  return dest;
266
304
  }
267
305
 
268
- /**
269
- * `_harness_mcp._settings_file` — the file this install's hooks were actually wired into.
270
- *
271
- * `settings.local.json` for a Claude PROJECT install (personal, untracked), `settings.json`
272
- * everywhere else, and the manifest is the authority. Every lifecycle path must target the
273
- * file the EMIT wrote, or the hooks linger in one file while the claims chase another.
274
- */
275
- function settingsFile(cfg, managed) {
276
- return path.join(cfg, (managed && managed.settings_file) || 'settings.json');
277
- }
306
+ // `settingsFile` lives in `hosts.mjs` (the hook stand-down reads it, and must not import this
307
+ // module); re-exported here for `migrate`.
308
+ export { settingsFile };
278
309
 
279
310
  /** `_harness_mcp._claude_md_path` — where the manifest says the managed block lives. */
280
311
  function claudeMdPath(cfg, managed) {
@@ -343,7 +374,8 @@ export function uninstallGlobal(target, archiveMemory, host = 'opencode') {
343
374
  if (isDir(p) && isEmptyDir(p)) rmdirQuiet(p);
344
375
  }
345
376
  const unmerged = unmergeOpencodeJson(path.join(target, 'opencode.json'),
346
- path.join(target, 'AGENT.md').split(path.sep).join('/'));
377
+ path.join(target, 'AGENT.md').split(path.sep).join('/'), skillPermissionNames(man));
378
+ managedBlockRemove(path.join(target, OPENCODE_SENTINEL));
347
379
  if (failed.length) warnMarkersKept();
348
380
  else for (const m of REVERSAL_MARKERS) unlinkQuiet(path.join(target, m));
349
381
  let archived = null;
@@ -403,10 +435,11 @@ function opencodeProjectUninstall(root) {
403
435
  const entry = installAgentEntry(root, 'project');
404
436
  let removed = 0;
405
437
  let failed = [];
438
+ let man = null;
406
439
  const oc = path.join(root, '.opencode');
407
440
  const manifestPath = path.join(oc, GLOBAL_MANIFEST);
408
441
  if (isFile(manifestPath)) {
409
- const man = claudeReadManifest(oc);
442
+ man = claudeReadManifest(oc);
410
443
  [removed, failed] = unlinkOwned(oc, ownedOf(man), '.opencode/');
411
444
  if (failed.length) warnSurvivors(failed);
412
445
  // Survivors gate, mirroring the other two reversals: `_project_qualifies` keys off the
@@ -427,7 +460,8 @@ function opencodeProjectUninstall(root) {
427
460
  }
428
461
  const am = path.join(root, 'AGENT.md');
429
462
  if (isFile(am)) { unlinkQuiet(am); removed += 1; }
430
- const unmerged = unmergeOpencodeJson(path.join(root, 'opencode.json'), entry);
463
+ const unmerged = unmergeOpencodeJson(path.join(root, 'opencode.json'), entry,
464
+ man ? skillPermissionNames(man) : []);
431
465
  const result = { removed, unmerged, archived: null };
432
466
  if (failed.length) result.failed = failed;
433
467
  return result;
@@ -519,10 +553,75 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
519
553
  if (archived.length) out.archived = archived;
520
554
  return out;
521
555
  }
522
- for (const m of ['.geneseed-emit', '.geneseed-theme', VERSION_MARKER]) {
523
- unlinkQuiet(path.join(root, m));
556
+ // The root markers are ONE PER ROOT (`generate.mjs`'s "THE MARKER IS TRUSTED ONLY FOR ITS
557
+ // OWN HOST") and `registryRoots` keeps a row alive only while `.geneseed-emit` exists there
558
+ // — so in a repo sharing two project installs (`.claude/` + `.opencode/` at the same cwd,
559
+ // last deploy wins the marker), deleting it unconditionally because THIS host is being
560
+ // uninstalled would silently deregister the OTHER host's still-live install, regardless of
561
+ // which host's name the marker happened to carry.
562
+ //
563
+ // Delete every marker ONLY when nothing else is installed here. Otherwise:
564
+ // - `.geneseed-emit` carries HOST IDENTITY (`EMIT_HOST_SCOPE`'s key names a (host, scope)
565
+ // pair) and `installProfile`/`migrateSurvey` trust it only for the host it names
566
+ // (`generate.mjs`'s own docblock). If it currently names the host just removed, REWRITE
567
+ // it to the survivor's own emit name — the same string `writeMarkers` (`driver.mjs`)
568
+ // would have written for that survivor — rather than deleting it and leaving the
569
+ // registry row to self-prune a live install. If it already names a survivor, it is
570
+ // already correct; leave it untouched.
571
+ // - `.geneseed-theme` and `VERSION_MARKER` carry NO host identity — a bare theme name and
572
+ // a version/fingerprint string — so there is no survivor value to rewrite them TO; the
573
+ // only question is whether they are THIS host's own litter to clear. At PROJECT scope
574
+ // `.geneseed-theme` here is OpenCode's alone: `driver.mjs`'s own comment says the
575
+ // claude/bob/openclaude project emits never write it, and `themeOfDir`'s host-narrowing
576
+ // (host-compat B1 round 3, `js/hosts/installs.mjs`) means no Claude-style sibling ever
577
+ // reads it either — so delete it only when OpenCode is the host being removed, and
578
+ // leave it for a Claude-style uninstall (it belongs to a surviving or absent OpenCode
579
+ // install either way, never to the host that just left). `VERSION_MARKER` is written at
580
+ // `cfgDir`, never bare `root`, by every PROJECT emit (`js/build/version.mjs`'s callers),
581
+ // so a project root never actually carries this file regardless of host — deleting it
582
+ // here is always a no-op there. At GLOBAL scope neither file is ever shared (each host
583
+ // owns its own config dir), so both are always this host's own and always safe to drop.
584
+ // PROJECT scope uses `projectQualifies` (the same predicate `uninstallResolve` already
585
+ // trusts for exactly this question), never `installState`: `installState`'s OpenCode
586
+ // branch answers 'active' off a bare `.opencode/` DIRECTORY's existence alone, with no
587
+ // manifest or marker check, so an unrelated non-Geneseed `.opencode/` sitting next to a
588
+ // real Claude install would read as a "surviving" OpenCode install and keep the marker
589
+ // alive for a host that was never actually here.
590
+ const survivor = HOSTS.find(({ host: h }) => h !== host && (
591
+ scope === 'project'
592
+ ? projectQualifies(root, h)
593
+ // GLOBAL: each host owns a distinct config dir, so no two hosts' global installs ever
594
+ // share a root — this can only match THIS host's own global install, and by the time
595
+ // execution reaches here (step 3) step 1 has already unlinked THIS host's own
596
+ // manifest/markers, so the lookback never mistakes its own just-removed install for a
597
+ // surviving sibling.
598
+ : installState(root, h, scope) !== 'absent'
599
+ ));
600
+ if (!survivor) {
601
+ for (const m of ['.geneseed-emit', '.geneseed-theme', VERSION_MARKER]) {
602
+ unlinkQuiet(path.join(root, m));
603
+ }
604
+ } else {
605
+ const markerScope = emitHostScopeOf(root);
606
+ if (markerScope !== null && markerScope[0] === host) {
607
+ const survivorEmit = [...EMIT_HOST_SCOPE.entries()]
608
+ .find(([, hs]) => hs[0] === survivor.host && hs[1] === scope)?.[0];
609
+ if (survivorEmit) {
610
+ writeText(path.join(root, '.geneseed-emit'), `${survivorEmit}\n`);
611
+ } else {
612
+ // Defensive: every (host, scope) pair in `HOSTS` has an `EMIT_HOST_SCOPE` entry, so
613
+ // this should be unreachable — but silently leaving a marker naming the just-removed
614
+ // host would deregister the survivor exactly as before, so it is loud instead of quiet.
615
+ printErr(`[uninstall] WARN: could not resolve an emit name for the surviving `
616
+ + `${survivor.host}:${scope} install here — the shared .geneseed-emit marker still `
617
+ + `names ${host}, which was just removed. Re-run \`geneseed rebuild-all\` or `
618
+ + `re-emit ${survivor.host} to refresh it.\n`);
619
+ }
620
+ }
621
+ if (scope === 'global' || host === 'opencode') unlinkQuiet(path.join(root, '.geneseed-theme'));
622
+ unlinkQuiet(path.join(root, VERSION_MARKER));
524
623
  }
525
- // 4. Tidy an emptied marker dir (.claude/.bob) so no husk lingers in the repo.
624
+ // 4. Tidy an emptied marker dir (.claude/.bob/.openclaude) so no husk lingers in the repo.
526
625
  if (data !== root && isDir(data) && isEmptyDir(data)) rmdirQuiet(data);
527
626
  const out = { ok: true, removed: summary.removed ?? 0, memory };
528
627
  if (archived.length) out.archived = archived;
@@ -532,8 +631,9 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
532
631
  /**
533
632
  * `_harness_mcp._project_qualifies` — does `root` carry a REAL Geneseed project install?
534
633
  *
535
- * The marker dir exists, is not the host's global config dir seen from its parent (the
536
- * `installTargets` aliasing guard), and shows Geneseed's own tracks: the manifest, or the
634
+ * The marker dir exists, is not the host's global seen from its parent (`isHostGlobalDir`, the
635
+ * `installTargets` aliasing guard — the default `~/.claude` too, under `$CLAUDE_CONFIG_DIR`),
636
+ * and shows Geneseed's own tracks: the manifest, or the
537
637
  * root `.geneseed-emit` naming this host's project emit for a pre-manifest legacy install.
538
638
  * A bare non-Geneseed `.claude/` is very common and must never hijack the resolve.
539
639
  */
@@ -542,7 +642,7 @@ export function projectQualifies(root, host) {
542
642
  const cfg = path.join(root, spec.projectMarker);
543
643
  if (!isDir(cfg)) return false;
544
644
  try {
545
- if (resolvePath(cfg) === resolvePath(spec.configDir())) return false;
645
+ if (isHostGlobalDir(host, cfg)) return false;
546
646
  } catch { /* as the Python's bare `except Exception: pass` */ }
547
647
  if (isFile(path.join(cfg, GLOBAL_MANIFEST))) return true;
548
648
  const hs = emitHostScopeOf(root);
@@ -556,7 +656,8 @@ export function projectQualifies(root, host) {
556
656
  * is NAMED like a project marker and is the global install, never `claude:project` rooted at
557
657
  * `$HOME`, so the global-config-dir case is checked before the marker-name case.
558
658
  *
559
- * 1. `--target` IS a host's global config dir.
659
+ * 1. `--target` IS a host's global (`isHostGlobalDir`: its env-resolved dir, its default
660
+ * `~/<marker>`, or a dir whose emit marker names that host's `-global` emit).
560
661
  * 2. `--target` IS a project marker dir itself (…/.claude) — root is its parent.
561
662
  * 3. `--target` (as a root) carries a Geneseed project install.
562
663
  * 4. `--target` given and unrecognised — null, and the caller reports it.
@@ -566,7 +667,7 @@ export function uninstallResolve(targetArg) {
566
667
  const globalHit = (p) => {
567
668
  for (const spec of HOSTS) {
568
669
  try {
569
- if (p === resolvePath(spec.configDir())) return [spec.host, 'global', p];
670
+ if (isHostGlobalDir(spec.host, p)) return [spec.host, 'global', p];
570
671
  } catch { continue; }
571
672
  }
572
673
  return null;
@@ -673,12 +774,17 @@ export function cmdUninstall(args) {
673
774
  const stores = ['memory', 'notebook'].filter((n) => isDir(path.join(data, n)));
674
775
  printOut(`[uninstall] target: ${root} (${host}:${scope})\n`);
675
776
  if (CLAUDE_STYLE.includes(host)) {
777
+ // Not always settings.json: a Claude/OpenClaude PROJECT install wires settings.local.json
778
+ // (host-compat B4) — read back from the manifest `settingsFile` already resolves, rather
779
+ // than restate the rule here a second time.
780
+ const cfgForSf = claudeCfg(root, scope, host);
781
+ const sfName = path.basename(settingsFile(cfgForSf, managedOf(claudeReadManifest(cfgForSf))));
676
782
  printOut('[uninstall] removes: agents/, skills/, markers, the '
677
783
  + `${hostSpec(host).agentFile} managed block, and Geneseed's `
678
- + 'settings.json hooks/excludes (your own keys/hooks are kept).\n');
784
+ + `${sfName} hooks/excludes (your own keys/hooks are kept).\n`);
679
785
  } else if (scope === 'global') {
680
- printOut('[uninstall] removes: AGENT.md, agents/, skills/, plugins/, markers, and the '
681
- + 'opencode.json instructions entry.\n');
786
+ printOut('[uninstall] removes: AGENT.md, the AGENTS.md managed block, agents/, skills/, '
787
+ + 'plugins/, markers, and the opencode.json instructions entry.\n');
682
788
  } else {
683
789
  printOut('[uninstall] removes: AGENT.md, .opencode/, laws/, agents/, skills/, and the '
684
790
  + 'opencode.json instructions entry.\n');
@@ -949,6 +1055,8 @@ export function installDeactivate(root, host = 'opencode', scope = 'global') {
949
1055
  return { ok: false, failed, rolled_back: done.length };
950
1056
  }
951
1057
  unmergeOpencodeJson(path.join(root, 'opencode.json'), installAgentEntry(root, kind));
1058
+ // The sentinel goes too, or a disabled install keeps the user's ~/.claude/CLAUDE.md from loading.
1059
+ if (kind === 'global') managedBlockRemove(path.join(root, OPENCODE_SENTINEL));
952
1060
  for (const rel of done) pruneAncestors(path.dirname(path.join(root, rel)), root);
953
1061
  return { ok: true, kind, moved: done.length };
954
1062
  }
@@ -975,6 +1083,7 @@ export function installReactivate(root, host = 'opencode', scope = 'global') {
975
1083
  if (leftovers.length) return { ok: false, failed: leftovers, moved };
976
1084
  const kind = installKind(root) || 'global';
977
1085
  installReaddEntry(target, installAgentEntry(root, kind));
1086
+ if (kind === 'global') opencodeSentinelWrite(root);
978
1087
  rmtreeQuiet(stash);
979
1088
  return { ok: true, kind, moved };
980
1089
  }
@@ -1026,11 +1135,14 @@ function remergeClaudeHooks(cfg, root = cfg, host = 'claude') {
1026
1135
  // unconditionally (fail-closed, so not a hole — but wrong, and it made the toggle one-way
1027
1136
  // for project installs). `root` first, `cfg` second: on a global install the two are the
1028
1137
  // same directory anyway. Both silent ⇒ `null` ⇒ the gate stays.
1029
- const doctrines = doctrinesOfDir(root) ?? doctrinesOfDir(cfg);
1138
+ // Both reads are narrowed to `host`'s OWN carrier (host-compat B1): in a repo shared with
1139
+ // another Claude-style host, an un-narrowed scan can answer with a SIBLING host's carrier —
1140
+ // the same failure `installProfile` had, reached here because `root` is the shared repo.
1141
+ const doctrines = doctrinesOfDir(root, host) ?? doctrinesOfDir(cfg, host);
1030
1142
  // Same two-carrier read for the second axis, and its own default: an absent line means
1031
1143
  // NOTHING excluded, so a reactivate can only ever restore a gate, never remove one.
1032
- const excluded = excludedRulesOfDir(root).length ? excludedRulesOfDir(root)
1033
- : excludedRulesOfDir(cfg);
1144
+ const excluded = excludedRulesOfDir(root, host).length ? excludedRulesOfDir(root, host)
1145
+ : excludedRulesOfDir(cfg, host);
1034
1146
  // `host` reaches the group builder: a Bob reactivate must re-wire Gemini-named groups.
1035
1147
  const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed),
1036
1148
  managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host, cfg);
@@ -46,6 +46,7 @@ import path from 'node:path';
46
46
  import { ROOT } from '../build/source.mjs';
47
47
  import { exportImprovements, flushExportNotes } from '../inspect/diff.mjs';
48
48
  import { opencodeConfigDir } from '../hosts/hosts.mjs';
49
+ import { legacyProcessCarrierOfDir } from '../hosts/installs.mjs';
49
50
  import { appendText, copyFile, printOut, printErr, readText, writeText } from '../lib/fs.mjs';
50
51
  import { comparePaths, toPlatformPath, which } from '../lib/paths.mjs';
51
52
  import { WHITESPACE, parseIntStrict, stripWhitespace } from '../lib/text.mjs';
@@ -617,9 +618,18 @@ async function drainStdout() {
617
618
  * Only the TAIL of the argv is kept: theme and emit are `upgrade`'s to decide (a theme argument
618
619
  * may override the marker), and `setupBuildArgs` opens with exactly those four tokens when it
619
620
  * is given no out and no root.
621
+ *
622
+ * `null` — distinct from `[]`'s "never built, nothing to carry over" — means `dir` carries a
623
+ * pre-comms carrier (same guard as `rebuild-all`/`migrate`/the web console, reusing
624
+ * `legacyProcessCarrierOfDir`): `installProfile` would read it exactly as written, and feeding
625
+ * that into a rebuild would write the loss back PERMANENTLY. `[]` here would be WORSE, not
626
+ * neutral — it resets every axis to the generator's config default, the exact failure this
627
+ * function exists to prevent, on top of the comms/process-address loss. `upgrade` must skip the
628
+ * bundle rebuild entirely on `null`, never fall through to an empty axes list.
620
629
  */
621
630
  export async function installAxes(dir, { installProfile, setupBuildArgs }, notify = null) {
622
631
  if (!existsSync(path.join(String(dir), '.geneseed-emit'))) return [];
632
+ if (legacyProcessCarrierOfDir(String(dir), 'opencode')) return null;
623
633
  const p = installProfile('opencode', 'global', String(dir), notify);
624
634
  return setupBuildArgs('', 'files', null, null, p.footprint, p.posture, p.mode, p.doctrines,
625
635
  undefined, p.excludeRules, p.trust, p.excludeSkills).slice(4);
@@ -756,8 +766,18 @@ export async function upgrade(ref = null, themeArg = null) {
756
766
  } catch (e) {
757
767
  log(`[geneseed] ⚠️ could not migrate the old in-folder bundle (${e && e.message ? e.message : e}) — continuing.`);
758
768
  }
759
- let rc = await rebuildBundle(here, out, theme, emit, rootDir, log, axes,
760
- bounceDaemonIfRunning);
769
+ let rc = 0;
770
+ if (axes === null) {
771
+ // `installAxes` found a pre-comms carrier at this bundle's own target — skip the rebuild
772
+ // entirely rather than fall through with no axes (which would reset it to the generator's
773
+ // config default, worse than the loss this guard exists to stop). `rebuildInstalls` below
774
+ // reaches the same directory through `rebuild-all`'s own, identically-reasoned skip.
775
+ log(`[geneseed] SKIPPED bundle rebuild: pre-comms carrier at `
776
+ + `${emit === 'opencode-global' ? cfg : out} (names 'process 8') — run `
777
+ + '`geneseed setup` for it.');
778
+ } else {
779
+ rc = await rebuildBundle(here, out, theme, emit, rootDir, log, axes, bounceDaemonIfRunning);
780
+ }
761
781
  if (rc !== 0) {
762
782
  log(`[geneseed][E-BUILD] ✗ the bundle build FAILED (theme: ${theme || 'default'}, emit: ${emit}).`);
763
783
  return 1;
@@ -42,20 +42,18 @@ import { setupBuildArgs } from '../build/generate.mjs';
42
42
  import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
43
43
  import { activeLoops, setLoopPreset } from '../loop/registry.mjs';
44
44
  import {
45
- CLAUDE_STYLE, HOSTS, bobConfigDir, claudeConfigDir, expanduser, openclaudeConfigDir,
45
+ HOSTS, bobConfigDir, claudeConfigDir, expanduser, openclaudeConfigDir,
46
46
  opencodeConfigDir, resolvePath,
47
47
  } from '../hosts/hosts.mjs';
48
48
  import {
49
49
  EMIT_HOST_SCOPE, doctrinesForBuild, excludedRulesOfDir, excludedSkillsOfDir, footprintOfDir,
50
- installState,
51
- installTargets, modeOfDir, postureOfDir, trustOfDir,
50
+ installState, installTargets, legacyProcessCarrierOfDir, modeOfDir, postureOfDir, trustOfDir,
52
51
  } from '../hosts/installs.mjs';
53
52
  import {
54
- MCP_PRESETS, isDict, mcpApply, mcpCommented, mcpInstallTargets, mcpKnownNames, mcpLoad,
55
- mcpMeta, mcpPresetBlock, mcpSave, mcpSetEnabled, mcpState,
53
+ isDict, mcpCommented, mcpInstallTargets, mcpLoad, mcpServerNames, mcpState,
56
54
  } from '../hosts/mcp.mjs';
57
- import { readText, isFile, isDir } from '../lib/fs.mjs';
58
- import { parseJson, formatRepr, formatValue, isTruthy } from '../lib/json.mjs';
55
+ import { isFile, isDir } from '../lib/fs.mjs';
56
+ import { formatRepr, formatValue, isTruthy } from '../lib/json.mjs';
59
57
  import { NO_WINDOW } from '../lib/proc.mjs';
60
58
  import { normcase, which, within } from '../lib/paths.mjs';
61
59
  import { stripWhitespace } from '../lib/text.mjs';
@@ -349,18 +347,20 @@ export function apiLoopsPresetMutate(state, body) {
349
347
 
350
348
  // ---- MCP ----------------------------------------------------------------------------------
351
349
 
350
+ /**
351
+ * The MCP servers each active install ALREADY has, and nothing it could have. Read-only by
352
+ * decision (2026-10): which servers to wire depends on the user and on the host, so the console
353
+ * shows what is in each config and where that file lives, and the user edits it. There is no
354
+ * preset to add and no toggle, so nothing here ever rewrites a config — including
355
+ * `~/.claude.json`, which holds far more than MCP wiring.
356
+ */
352
357
  export function apiMcp() {
353
358
  const out = [];
354
359
  for (const [label, p, host, scope, root] of mcpInstallTargets()) {
355
360
  if (installState(root, host, scope) !== 'active') continue;
356
361
  const cfg = mcpLoad(p, host);
357
- const servers = [];
358
- for (const name of mcpKnownNames(cfg, host)) {
359
- const [lbl, desc] = mcpMeta(name);
360
- servers.push({ name, label: lbl, desc,
361
- preset: Object.hasOwn(MCP_PRESETS, name),
362
- state: mcpState(cfg, name, host) });
363
- }
362
+ const servers = mcpServerNames(cfg, host)
363
+ .map((name) => ({ name, state: mcpState(cfg, name, host) }));
364
364
  out.push({ label, path: p, host, root, exists: isFile(p),
365
365
  commented: mcpCommented(p), servers });
366
366
  }
@@ -371,10 +371,8 @@ export function apiMcp() {
371
371
  /**
372
372
  * The MCP config paths a request body is allowed to name, as `path -> [path, host]`.
373
373
  *
374
- * LIFTED OUT OF `apiMcpToggle` BECAUSE IT NOW HAS A SECOND CALLER, not for tidiness. `/api/reveal`
375
- * hands a path to the desktop's file manager, and the allowlist is the whole security of that
376
- * endpoint exactly as it is of the toggle: an unlisted path must 404 before anything is opened.
377
- * Two copies of a security check drift, and the copy that drifts is the one nobody re-read.
374
+ * `/api/reveal` hands a path to the desktop's file manager, and this allowlist is the whole
375
+ * security of that endpoint: an unlisted path must 404 before anything is opened.
378
376
  *
379
377
  * It is rebuilt per call rather than cached: installs come and go while the daemon runs, and a
380
378
  * stale allowlist is wrong in both directions — refusing a real target, or naming a dead one.
@@ -387,67 +385,6 @@ export function mcpTargetPaths() {
387
385
  return known;
388
386
  }
389
387
 
390
- /**
391
- * Enable / disable / first-add one server.
392
- *
393
- * THE PATH COMES OUT OF THE REQUEST BODY, so the allowlist is the whole security of this
394
- * endpoint: `known` is built from the detected install targets and an unlisted path is a 404
395
- * before anything is read or written. Without it this is "write arbitrary JSON to an
396
- * arbitrary file", behind a CSRF token and nothing else.
397
- *
398
- * The Claude-shaped hosts are parsed ONCE, STRICTLY, and that exact object is what gets
399
- * rewritten — so the safety check and the value saved come from the same read, with no
400
- * time-of-check/time-of-use gap and no comment-stripper touching a string that contains
401
- * `,]`. A file that will not parse is refused rather than clobbered: it may be
402
- * `~/.claude.json`, which holds projects and history far beyond MCP wiring.
403
- */
404
- export function apiMcpToggle(state, body) {
405
- const name = strOr(bget(body, 'name'));
406
- const want = isTruthy(bget(body, 'enabled'));
407
- const pathArg = strOr(bget(body, 'path'));
408
- const known = mcpTargetPaths();
409
- const hit = known.get(pathArg);
410
- if (hit === undefined || !name) {
411
- throw new NotFound(`mcp target ${pathArg || '(none)'}`);
412
- }
413
- const [p, host] = hit;
414
- if (mcpCommented(p)) {
415
- return { ok: false, error: 'config holds comments — edit it by hand to keep them' };
416
- }
417
- let cfg;
418
- if (CLAUDE_STYLE.includes(host)) {
419
- if (isFile(p)) {
420
- let parsed;
421
- try {
422
- parsed = parseJson(readText(p));
423
- } catch {
424
- parsed = null;
425
- }
426
- if (!isDict(parsed)) {
427
- return { ok: false,
428
- error: "couldn't parse this config — edit it by hand to avoid data loss" };
429
- }
430
- cfg = parsed;
431
- } else {
432
- cfg = {};
433
- }
434
- } else {
435
- cfg = mcpLoad(p);
436
- }
437
- if (mcpState(cfg, name, host) === 'absent') {
438
- if (!Object.hasOwn(MCP_PRESETS, name)) {
439
- return { ok: false, error: `unknown server '${name}'` };
440
- }
441
- if (!want) return { ok: false, error: `'${name}' is not configured` };
442
- cfg = mcpApply(cfg, name, mcpPresetBlock(name, host), host);
443
- cfg = mcpSetEnabled(cfg, name, true, host);
444
- } else {
445
- cfg = mcpSetEnabled(cfg, name, want, host);
446
- }
447
- mcpSave(p, cfg);
448
- return { ok: true, name, state: mcpState(cfg, name, host) };
449
- }
450
-
451
388
  // ---- the harness selector ------------------------------------------------------------------
452
389
 
453
390
  /**
@@ -605,26 +542,38 @@ export function apiInstallCmd(state, body) {
605
542
  if (installState(root, host, scope) === 'disabled') {
606
543
  return { error: 'install is disabled — reactivate it before (re)building' };
607
544
  }
545
+ // A LEGACY CARRIER IS REFUSED, NEVER REBUILT — same guard as `rebuild-all`/`migrate` (see
546
+ // `legacyProcessCarrierOfDir`'s docblock): `doctrinesForBuild`/`excludedRulesOfDir` below
547
+ // read a pre-comms carrier exactly as written, and this endpoint's `{cmd}` is EXECUTED by
548
+ // `startJob` (`js/web/handler.mjs`'s `install` action) — building that argv here would write
549
+ // the same permanent loss a console click away.
550
+ if (legacyProcessCarrierOfDir(root, host)) {
551
+ return { error: `${host}:${scope} (${root}) predates the comms pack (its carrier names `
552
+ + "'process 8') — run `geneseed setup` for it before rebuilding from the console" };
553
+ }
608
554
  const emit = EMIT_FOR.get(`${host} ${scope}`);
609
555
  if (emit === undefined) return { error: `no install mode for ${host}:${scope}` };
556
+ // Every read-back below is narrowed to `host`'s OWN carrier (host-compat B1) — see the
557
+ // comment at `installProfile` in `js/build/generate.mjs`.
610
558
  const { theme, footprint: fp, posture: pos, mode, trust } = bodyAxes(body, {
611
- theme: state.theme, footprint: footprintOfDir(root), posture: postureOfDir(root) || 'peer',
612
- mode: modeOfDir(root) || 'direct', trust: trustOfDir(root, host) || DEFAULT_PRESET });
559
+ theme: state.theme, footprint: footprintOfDir(root),
560
+ posture: postureOfDir(root, host) || 'peer',
561
+ mode: modeOfDir(root, host) || 'direct', trust: trustOfDir(root, host) || DEFAULT_PRESET });
613
562
  // Unspecified means "keep what this install already has", exactly as theme, footprint,
614
563
  // posture and mode above do — a rebuild through the console is not a place to silently
615
564
  // re-decide the constitution. ⚠ AND A CARRIER WITH NO `Active packs:` MARKER (a pre-2.3
616
565
  // install) MUST NOT LAND ON `harness.config.json`: `doctrinesOfDir` answers `null` there,
617
566
  // `null` elided the flag, and the generator's config fallback then narrowed the install and
618
567
  // took its consent gate with it. `doctrinesForBuild` resolves unknown to ALL packs.
619
- const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root);
568
+ const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root, host);
620
569
  // The per-rule axis needs NO `doctrinesForBuild`-style rescue: the marker it reads is only
621
570
  // written when something is excluded, so its absence is a statement ("nothing") rather than
622
571
  // the silence a pre-2.3 carrier gives on packs. Unspecified therefore keeps the install's
623
572
  // own answer, and a pre-marker install answers the empty list — which is what it has.
624
- const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
573
+ const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root, host);
625
574
  const out = scope === 'global' ? null : String(root);
626
575
  const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines,
627
- PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root));
576
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, null, host));
628
577
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
629
578
  }
630
579
 
@@ -704,6 +653,13 @@ export function apiDeployCmd(state, body) {
704
653
  if (cfgdirs.has(root)) {
705
654
  return { error: "that's a host global config dir — use its existing row to build a global install" };
706
655
  }
656
+ // Same guard as `apiInstallCmd` above: a landing folder that already holds a pre-comms
657
+ // install must not be rebuilt through `doctrinesForBuild`/`excludedRulesOfDir` below. A
658
+ // fresh, never-built folder has no carrier at all, so this is a no-op for the common case.
659
+ if (legacyProcessCarrierOfDir(root, host)) {
660
+ return { error: `${root} predates the comms pack (its carrier names 'process 8') — `
661
+ + 'run `geneseed setup` for it before deploying over it' };
662
+ }
707
663
  // A fresh deploy is full, at the default register, mode and trust.
708
664
  const { theme, footprint: fp, posture: pos, mode, trust } = bodyAxes(body, {
709
665
  theme: state.theme, footprint: 'full', posture: 'peer', mode: 'direct',
@@ -714,10 +670,10 @@ export function apiDeployCmd(state, body) {
714
670
  // the flag off, the generator fell back to `harness.config.json`, and deploying onto an
715
671
  // existing all-four Claude install dropped it to one pack — measured: 6 hook groups became 5
716
672
  // and `PreToolUse::Bash` went with them. `doctrinesForBuild` resolves unknown to ALL packs.
717
- const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root);
718
- const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
673
+ const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root, host);
674
+ const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root, host);
719
675
  // project-scope emit name == host name (opencode / claude / bob / openclaude)
720
676
  const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines,
721
- PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root));
677
+ PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, null, host));
722
678
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
723
679
  }
package/js/web/api.mjs CHANGED
@@ -113,16 +113,25 @@ export function viewCfg(host, scope, root) {
113
113
  return root;
114
114
  }
115
115
 
116
+ /**
117
+ * The row `id` carries the ROOT, not only `host:scope`: two project installs of one host (two
118
+ * repos on Bob, say) are both `bob:project`, and the console keys its selection, its React rows
119
+ * and its per-install picks on this id — with a shared one, both rows read as selected and every
120
+ * pick resolved to whichever came first. `installTargets()` de-duplicates on (host, root), so
121
+ * this is unique by construction.
122
+ */
116
123
  export function apiInstalls(state) {
117
124
  const out = [];
118
125
  for (const [host, scope, root] of installTargets()) {
119
126
  out.push({
120
- id: `${host}:${scope}`, host, scope, path: root,
127
+ id: `${host}:${scope}:${root}`, host, scope, path: root,
121
128
  state: installState(root, host, scope),
122
- theme: themeOfDir(root),
129
+ // Narrowed to this row's own host (host-compat B1): a shared repo otherwise answers
130
+ // every row with whichever host's carrier the host-agnostic scan favours.
131
+ theme: themeOfDir(root, host),
123
132
  footprint: footprintOfDir(root),
124
- posture: postureOfDir(root),
125
- mode: modeOfDir(root),
133
+ posture: postureOfDir(root, host),
134
+ mode: modeOfDir(root, host),
126
135
  trust: trustOfDir(root, host),
127
136
  selected: samePath(viewCfg(host, scope, root), state.target),
128
137
  });
@@ -371,8 +371,8 @@ export function makeHandler(state, jm, token, dist, holder = null) {
371
371
  try {
372
372
  return doPost(req, res, path, ae, buf);
373
373
  } catch (e) {
374
- // `apiMcpToggle`, `apiSelectView`, `apiMemoryDelete`, `apiRulesMutate` and
375
- // `apiRulesPromote` all raise `NotFound` for an unknown target, mapped to 404
374
+ // `apiSelectView`, `apiMemoryDelete`, `apiRulesMutate` and `apiRulesPromote` all
375
+ // raise `NotFound` for an unknown target, mapped to 404
376
376
  // here; anything else falls to the 500 arm below, carrying its own message.
377
377
  if (e instanceof NotFound) {
378
378
  return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
package/js/web/routes.mjs CHANGED
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { formatValue, isTruthy, parseJson } from '../lib/json.mjs';
20
20
  import { percentDecode } from '../lib/text.mjs';
21
- import { apiExcludesMutate, apiInstallToggle, apiLoopsPresetMutate, apiMcp, apiMcpToggle, apiMemoryDelete, apiSelectView } from './actions.mjs';
21
+ import { apiExcludesMutate, apiInstallToggle, apiLoopsPresetMutate, apiMcp, apiMemoryDelete, apiSelectView } from './actions.mjs';
22
22
  import { apiActivity, apiActivityDetail, apiActivityToggle } from './activity.mjs';
23
23
  import {
24
24
  apiDiff, apiDoctor, apiExcludes, apiInstalls, apiLoops, apiLoopsActive, apiOverview,
@@ -43,7 +43,6 @@ import { apiProfile, apiProfileSave, apiRules, apiRulesMutate, apiRulesPromote }
43
43
  */
44
44
  /** @type {Map<string, [(state: any, body: any) => any, boolean]>} */
45
45
  export const POST_ROUTES = new Map([
46
- ['/api/mcp', [apiMcpToggle, true]],
47
46
  ['/api/install', [apiInstallToggle, true]],
48
47
  ['/api/excludes', [apiExcludesMutate, true]],
49
48
  ['/api/view', [apiSelectView, true]],