geneseed 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +3 -3
  4. package/adapters/claude-code/README.md +0 -21
  5. package/adapters/opencode/HOW-OPENCODE-LOADS.md +35 -0
  6. package/adapters/opencode/plugins/geneseed-activity.js +105 -2
  7. package/adapters/opencode/plugins/geneseed-context.js +96 -1
  8. package/adapters/opencode/plugins/geneseed-guard.js +64 -1
  9. package/adapters/opencode/plugins/geneseed-learn.js +127 -14
  10. package/adapters/opencode/plugins/geneseed-notify.js +46 -1
  11. package/adapters/opencode/plugins/geneseed-ponytail.js +44 -2
  12. package/adapters/opencode/plugins/geneseed-workflow.js +124 -24
  13. package/docs/guides/mcp-filesystem.md +2 -2
  14. package/docs/guides/mcp-gitlab.md +2 -2
  15. package/docs/guides/mcp-markitdown.md +3 -3
  16. package/docs/guides/mcp.md +7 -7
  17. package/docs/guides/project-context.md +1 -1
  18. package/docs/guides/uninstall.md +2 -0
  19. package/docs/guides/upgrade.md +2 -0
  20. package/docs/reference/opencode-plugins.md +9 -0
  21. package/docs/token-footprint.md +1 -1
  22. package/docs/understand/take-it-out.md +1 -1
  23. package/harness.config.json +1 -1
  24. package/js/README.md +6 -0
  25. package/js/build/driver.mjs +47 -11
  26. package/js/build/emit-claude.mjs +22 -10
  27. package/js/build/emit-common.mjs +9 -3
  28. package/js/build/emit-opencode.mjs +18 -11
  29. package/js/build/source.mjs +1 -0
  30. package/js/build/version.mjs +10 -3
  31. package/js/cli-table.json +16 -1
  32. package/js/hosts/hooks.mjs +4 -0
  33. package/js/hosts/installs.mjs +1 -1
  34. package/js/hosts/mcp.mjs +23 -152
  35. package/js/hosts/native.mjs +38 -6
  36. package/js/hosts/opencode.mjs +1 -1
  37. package/js/hosts/owned.mjs +159 -0
  38. package/js/hosts/settings.mjs +4 -0
  39. package/js/hosts/usage.mjs +62 -0
  40. package/js/inspect/checks-repo.mjs +76 -1
  41. package/js/inspect/cost.mjs +83 -0
  42. package/js/inspect/doctor.mjs +2 -1
  43. package/js/inspect/inventory.mjs +1 -1
  44. package/js/maintain/uninstall.mjs +77 -42
  45. package/js/web/actions.mjs +15 -77
  46. package/js/web/api.mjs +74 -4
  47. package/js/web/gates-api.mjs +44 -0
  48. package/js/web/handler.mjs +2 -2
  49. package/js/web/routes.mjs +9 -4
  50. package/js/web/update-check.mjs +61 -0
  51. package/js/web/usage-api.mjs +95 -0
  52. package/package.json +1 -1
  53. package/registry.json +18 -25
  54. package/src/AGENT.md.tmpl +0 -1
  55. package/src/agents/_template.md +2 -1
  56. package/src/agents/researcher.md +2 -1
  57. package/src/agents/reviewer.md +9 -4
  58. package/src/agents/tester.md +3 -3
  59. package/src/doctrines/rigor.md +8 -7
  60. package/src/skills/brainstorm/builder-check.md +16 -0
  61. package/src/skills/brainstorm.md +19 -11
  62. package/src/skills/daydream/SKILL.md +1 -1
  63. package/src/skills/daydream/VENDOR.md +4 -0
  64. package/src/skills/debug/ci.md +4 -4
  65. package/src/skills/debug.md +4 -4
  66. package/src/skills/develop.md +15 -4
  67. package/src/skills/fresh-eyes.md +1 -1
  68. package/src/skills/geneseed-code-review.md +13 -3
  69. package/src/skills/handoff.md +1 -1
  70. package/src/skills/parallel-agents.md +4 -4
  71. package/src/skills/pipeline.md +3 -3
  72. package/src/skills/plan.md +18 -6
  73. package/src/skills/review-response.md +5 -2
  74. package/src/skills/security-audit.md +7 -1
  75. package/src/skills/ship.md +2 -1
  76. package/src/skills/wayfinder.md +25 -6
  77. package/src/skills/worktree.md +7 -5
  78. package/themes/_TEMPLATE.json +0 -1
  79. package/themes/biker.json +0 -1
  80. package/themes/commentator.json +0 -1
  81. package/themes/cyberpunk.json +0 -1
  82. package/themes/gamer.json +0 -1
  83. package/themes/imperial.json +0 -1
  84. package/themes/joker.json +0 -1
  85. package/themes/marvin.json +0 -1
  86. package/themes/mean.json +0 -1
  87. package/themes/military.json +0 -1
  88. package/themes/neutral.json +0 -1
  89. package/themes/pirate.json +0 -1
  90. package/themes/sports.json +0 -1
  91. package/themes/verstappen.json +0 -1
  92. package/themes/wizard.json +0 -1
  93. package/web/dist/assets/{Activity-BCtqxjfy.js → Activity-ClEuDk9q.js} +1 -1
  94. package/web/dist/assets/{ActivityDetail-Ba3Oo4dE.js → ActivityDetail-CS9IuJlw.js} +1 -1
  95. package/web/dist/assets/Dashboard-BiRFjkBS.js +2 -0
  96. package/web/dist/assets/Docs-CD20mrVv.js +5 -0
  97. package/web/dist/assets/{GreenhouseView-DnJ1Ggp1.js → GreenhouseView-CL3PubNb.js} +1 -1
  98. package/web/dist/assets/{Installs-CWoNKFBk.js → Installs-BFLOVV0r.js} +2 -2
  99. package/web/dist/assets/{JournalView-g6-bw4c8.js → JournalView-BvsSkjSn.js} +1 -1
  100. package/web/dist/assets/{Laws-DkVWR1tb.js → Laws-BVoYOkVW.js} +2 -2
  101. package/web/dist/assets/{Library-CK9HZzjV.js → Library-Bl7xqOlh.js} +1 -1
  102. package/web/dist/assets/{Loops-BP98SMOH.js → Loops-Cnpep8FX.js} +1 -1
  103. package/web/dist/assets/{OperatorHudView-DI_n12M9.js → OperatorHudView-B0AMVmtV.js} +1 -1
  104. package/web/dist/assets/{Personal-Bk89f13a.js → Personal-CBg_x2kA.js} +1 -1
  105. package/web/dist/assets/{Tabs-3WuYIE1b.js → Tabs-2gX8I69E.js} +1 -1
  106. package/web/dist/assets/api-Bp5eW_6m.js +1 -0
  107. package/web/dist/assets/{drawable-oCUeq9e5.js → drawable-KPn0rKI7.js} +1 -1
  108. package/web/dist/assets/{index-Ctp-4poP.css → index-CO-9wMJY.css} +1 -1
  109. package/web/dist/assets/index-DoD_g2CM.js +2 -0
  110. package/web/dist/index.html +3 -3
  111. package/src/skills/herdr.md +0 -154
  112. package/web/dist/assets/Dashboard-DpHza68-.js +0 -2
  113. package/web/dist/assets/Docs-Bw_-UK0O.js +0 -5
  114. package/web/dist/assets/api-CxgfYu18.js +0 -1
  115. package/web/dist/assets/index-CiXwChvf.js +0 -2
@@ -10,7 +10,7 @@ import path from 'node:path';
10
10
  import {
11
11
  ALIASES_RE, PLUGIN_SRC, RETIRED_SKILL_IDS, ROOT, SRC, THEMES, WORKFLOW_SRC, aliasesOf, knownSkillIds,
12
12
  } from '../build/source.mjs';
13
- import { CLAUDE_STYLE, liveRecordedHooks } from '../hosts/hosts.mjs';
13
+ import { CLAUDE_STYLE, HOSTS, liveRecordedHooks } from '../hosts/hosts.mjs';
14
14
  import { claudeCfg, installTargets } from '../hosts/installs.mjs';
15
15
  import { VENDORED_SKILL_DIRS } from '../hosts/native.mjs';
16
16
  import { COMMAND_SET } from '../hosts/opencode.mjs';
@@ -507,6 +507,81 @@ export function moduleMapProblems() {
507
507
  return problems;
508
508
  }
509
509
 
510
+ /**
511
+ * Each host's label as the two hand-written tables below spell it, keyed by `HOSTS`' `host` id.
512
+ *
513
+ * Local to this arm rather than a column on `HOSTS` (`js/hosts/hosts.mjs`): `HOSTS` is the
514
+ * machine-facing table (config dirs, project markers, catalog flags), and a doc-table LABEL is
515
+ * a fact about two Markdown files, not about an install.
516
+ */
517
+ const HOST_LABELS = { opencode: 'OpenCode', claude: 'Claude Code', bob: 'Bob', openclaude: 'OpenClaude' };
518
+
519
+ /**
520
+ * The two hand-written, one-column-per-host tables, and the non-host columns each one carries
521
+ * (a header cell that is neither a `HOST_LABELS` value nor one of these is an unknown label
522
+ * that LOOKS like it was meant to be a host column).
523
+ */
524
+ const HOST_MATRIX_FILES = [
525
+ { rel: 'docs/concepts/host-matrix.md', otherCols: new Set(['Capability']) },
526
+ { rel: 'docs/concepts/enforcement-table.md', otherCols: new Set(['id', 'Pack']) },
527
+ ];
528
+
529
+ /** The first `| … |` table row of `text`, as trimmed, non-empty cells — its header. */
530
+ function firstTableHeader(text) {
531
+ for (const line of text.split('\n')) {
532
+ const t = line.trim();
533
+ if (t.startsWith('|')) return t.split('|').map((c) => c.trim()).filter((c) => c.length > 0);
534
+ }
535
+ return null;
536
+ }
537
+
538
+ /**
539
+ * `js/README.md`'s sibling gate for the two hand-written host tables: each one's header row
540
+ * must name exactly the hosts `HOSTS` lists, by their `HOST_LABELS` spelling — no fewer (a new
541
+ * host shipped with no column) and no stray cell that merely LOOKS like a host label (a typo,
542
+ * or a host renamed in `HOST_LABELS` but not in the doc).
543
+ *
544
+ * A host with no `HOST_LABELS` entry is its own problem, independent of either file: nothing
545
+ * in a table header can be compared against a label that does not exist.
546
+ *
547
+ * `root` is a parameter (default: `ROOT`) so a test can point this at a temp copy with one
548
+ * column removed, rather than mutating the real docs tree.
549
+ */
550
+ export function hostMatrixProblems(root = ROOT) {
551
+ const problems = [];
552
+ const expected = new Set();
553
+ for (const h of HOSTS) {
554
+ const label = HOST_LABELS[h.host];
555
+ if (!label) { problems.push(`[authoring] host ${h.host} has no HOST_LABELS entry`); continue; }
556
+ expected.add(label);
557
+ }
558
+ for (const { rel, otherCols } of HOST_MATRIX_FILES) {
559
+ let text;
560
+ try {
561
+ text = readText(path.join(root, ...rel.split('/')));
562
+ } catch (e) {
563
+ problems.push(`[authoring] ${rel} is unreadable: ${e.message}`);
564
+ continue;
565
+ }
566
+ const header = firstTableHeader(text);
567
+ if (!header) {
568
+ problems.push(`[authoring] ${rel} has no table header row`);
569
+ continue;
570
+ }
571
+ const present = new Set(header);
572
+ for (const label of [...expected].sort()) {
573
+ if (!present.has(label)) problems.push(`[authoring] ${rel} has no column for ${label}`);
574
+ }
575
+ for (const cell of header) {
576
+ if (!expected.has(cell) && !otherCols.has(cell)) {
577
+ problems.push(`[authoring] ${rel} column '${cell}' is not a host HOST_LABELS names or `
578
+ + 'a known non-host column — a stale or misspelled label');
579
+ }
580
+ }
581
+ }
582
+ return problems;
583
+ }
584
+
510
585
  /**
511
586
  * The AI Harness Scorecard checks this repo passes, written out — the ratchet's floor.
512
587
  *
@@ -0,0 +1,83 @@
1
+ /**
2
+ * WHAT ONE INSTALL COSTS EVERY SESSION, read off the files it emitted — the Overview's
3
+ * always-on figure. Characters, not tokens: the console shows ≈ chars ÷ 4 and says it is an
4
+ * estimate, because a tokenizer would be a vendored dependency for a number that only has to
5
+ * be honest about its size.
6
+ *
7
+ * Four parts, each the thing the host loads before the user types:
8
+ * - `carrier` the root instruction file (`AGENT.md`, `CLAUDE.md`, Bob's `rules/geneseed.md` or
9
+ * `AGENTS.md`), CRLF decoded exactly as the smoke test decodes it, so the figure
10
+ * and `CARRIER_CEILING` are the same measurement.
11
+ * - `skills` the name + description every native skill lists (aliases included: they are
12
+ * SKILL.md files the host lists like any other).
13
+ * - `agents` the same for native agents; Bob has none, its agents table is in the carrier.
14
+ * - `context` the session files the context hook injects (untouched seeds already skipped).
15
+ * The per-repo project context is NOT counted: it changes with the working dir.
16
+ */
17
+ import path from 'node:path';
18
+
19
+ import { sessionFiles } from '../hosts/hooks-context.mjs';
20
+ import { carriersFor } from '../hosts/installs.mjs';
21
+ import { frontmatter } from '../hosts/memory-files.mjs';
22
+ import { listDir } from '../hosts/hooks-prims.mjs';
23
+ import { isFile, readText } from '../lib/fs.mjs';
24
+
25
+ /**
26
+ * THE ANTI-BLOAT CEILING on the carrier, per footprint — the most a root file may weigh with
27
+ * every doctrine pack active. `tests/unit/emit_smoke.test.mjs` gates every emit against it and the
28
+ * console draws it on the cost chart, so it lives here, once, where both read it. Raise it by
29
+ * exactly the measured overrun and say why in that test's docblock, which keeps its history.
30
+ */
31
+ export const CARRIER_CEILING = { full: 62_076, lean: 38_890 };
32
+
33
+ const decode = (p) => readText(p).split('\r\n').join('\n');
34
+
35
+ /**
36
+ * A frontmatter value as the host shows it. The emit writes descriptions JSON-escaped
37
+ * (`—` for one em dash), and `frontmatter()` has already stripped the quotes, so the
38
+ * escapes are decoded here or one character counts as six.
39
+ */
40
+ function fmValue(raw) {
41
+ if (!raw) return '';
42
+ if (raw.includes('\\')) {
43
+ try { return JSON.parse(`"${raw}"`); } catch { /* not a JSON escape after all: count as written */ }
44
+ }
45
+ return raw;
46
+ }
47
+
48
+ /** name + description of one spec file; `fallbackName` for agents, whose name is the file stem. */
49
+ function listedChars(file, fallbackName) {
50
+ let fm;
51
+ try { [fm] = frontmatter(decode(file)); } catch { return 0; }
52
+ const name = fmValue(fm.get('name')) || fallbackName;
53
+ return name.length + fmValue(fm.get('description')).length;
54
+ }
55
+
56
+ /**
57
+ * `root` is the install's root (`installTargets`), `dir` its data dir (`viewCfg`), which holds
58
+ * `skills/`, `agents/` and the session files. Every part that cannot be read counts 0; a missing
59
+ * carrier answers `null` for the whole cost, so the console shows "not measured", not a tiny bar.
60
+ */
61
+ export function alwaysOnCost({ host, root, dir, footprint }) {
62
+ const carrierPath = carriersFor(host).map((rel) => path.join(root, rel)).find(isFile);
63
+ if (!carrierPath) return null;
64
+ const carrier = decode(carrierPath).length;
65
+
66
+ let skills = 0;
67
+ const skillsDir = path.join(dir, 'skills');
68
+ for (const name of listDir(skillsDir)) {
69
+ const f = path.join(skillsDir, name, 'SKILL.md');
70
+ if (isFile(f)) skills += listedChars(f, name);
71
+ }
72
+ let agents = 0;
73
+ if (host !== 'bob') {
74
+ const agentsDir = path.join(dir, 'agents');
75
+ for (const name of listDir(agentsDir)) {
76
+ if (name.endsWith('.md')) agents += listedChars(path.join(agentsDir, name), name.slice(0, -3));
77
+ }
78
+ }
79
+ let context = 0;
80
+ try { for (const s of sessionFiles(dir)) context += s.text.length; } catch { /* none */ }
81
+
82
+ return { carrier, skills, agents, context, ceiling: CARRIER_CEILING[footprint] ?? null };
83
+ }
@@ -42,7 +42,7 @@ import { authoringProblems, loopProblems } from './checks-authoring.mjs';
42
42
  import {
43
43
  checkBuild, colorThemeProblems, renderedProblems, themeParityProblems,
44
44
  } from './checks-build.mjs';
45
- import { moduleMapProblems, scorecardProblems, shimProblems } from './checks-repo.mjs';
45
+ import { hostMatrixProblems, moduleMapProblems, scorecardProblems, shimProblems } from './checks-repo.mjs';
46
46
  import { NOTE, isDoctorNote, sortedProblems, sortedUnique, stemOf, withTempDir } from './scan.mjs';
47
47
 
48
48
  /**
@@ -249,6 +249,7 @@ export function doctorCollect({
249
249
  problems = problems.concat(ran('openclaude_dual', 'OpenClaude dual harness',
250
250
  openclaudeDualHarnessNotes()));
251
251
  problems = problems.concat(ran('map', 'Module map', moduleMapProblems()));
252
+ problems = problems.concat(ran('host_matrix', 'Host tables', hostMatrixProblems()));
252
253
  problems = problems.concat(ran('scorecard', 'Scorecard floor', scorecardProblems()));
253
254
  // P10c's `cli` check is GONE, and the reason is not that it stopped mattering. It hashed
254
255
  // `rituals/harness.py` and compared that against a digest baked into `cli.json`, to catch a
@@ -106,7 +106,7 @@ export const SKILL_CLASS = {
106
106
  // knowledge base, terminal) rather than about the user's code. They sat under
107
107
  // `understand` before, which made that class a grab-bag.
108
108
  wiki: 'harness', geneseed: 'harness', rule: 'harness', profile: 'harness',
109
- herdr: 'harness', 'skill-forge': 'harness',
109
+ 'skill-forge': 'harness',
110
110
  teach: 'learn', quiz: 'learn', 'learn-mode': 'learn',
111
111
  };
112
112
 
@@ -59,6 +59,7 @@ import {
59
59
  resolvePath, settingsFile,
60
60
  } from '../hosts/hosts.mjs';
61
61
  import { mcpCommented, mcpLoad } from '../hosts/mcp.mjs';
62
+ import { hashesOf, ownedState, ownedWithin } from '../hosts/owned.mjs';
62
63
  import {
63
64
  atomicWriteJson, managedBlockRead, managedBlockRemove, managedBlockWrite,
64
65
  loadJsonObject, mergeClaudeSettings, opencodeTarget, OPENCODE_SENTINEL, opencodeSentinelWrite,
@@ -67,7 +68,7 @@ import {
67
68
  } from '../hosts/settings.mjs';
68
69
  import { printOut, printErr, readText, writeText, isFile, isDir, isOsError } from '../lib/fs.mjs';
69
70
  import { indexOfDeepEqual, isDict, jsonDumps, deepEquals } from '../lib/json.mjs';
70
- import { comparePaths, isAbsolutePath, within } from '../lib/paths.mjs';
71
+ import { comparePaths, isAbsolutePath } from '../lib/paths.mjs';
71
72
 
72
73
  const hostSpec = (host) => HOSTS.find((h) => h.host === host);
73
74
 
@@ -145,7 +146,7 @@ function pruneAncestors(start, stop) {
145
146
 
146
147
  /**
147
148
  * The manifest's `owned` list, unlinked one by one with the ancestor prune, returning
148
- * `[removed, failed]`.
149
+ * `[removed, failed, kept]`.
149
150
  *
150
151
  * The third copy of this loop in `_harness_mcp.py` and the reason it is one function here:
151
152
  * `_uninstall_global` (rooted at the target), `_claude_uninstall` (rooted at the cfg dir) and
@@ -157,15 +158,28 @@ function pruneAncestors(start, stop) {
157
158
  * `ownedWithin` first, the same containment boundary deactivate draws: the manifest is a file
158
159
  * on disk, and without it an `owned` entry of `../../.bashrc` was unlinked — and the prune
159
160
  * then climbed from ITS parent, which never meets `base`, removing empty directories as it went.
160
- * @returns {[number, string[]]}
161
+ *
162
+ * Task 5 (O-1's uninstall half): `hashes` is the manifest's recorded `hashes` map (`hashesOf`,
163
+ * `{}` for an older manifest — the constraint that keeps a hash-less install's behaviour
164
+ * unchanged). Unless `force`, a rel whose `ownedState` reads `'modified'` is left on disk rather
165
+ * than unlinked and lands in `kept`, not `failed` — it is not an error, the file just is not
166
+ * Geneseed's to delete any more once the user has edited it. `'unknown'` and `'missing'` are
167
+ * NOT `'modified'` and are unlinked exactly as before, so an older, hash-less manifest's
168
+ * uninstall is byte-identical to today's.
169
+ * @returns {[number, string[], string[]]}
161
170
  */
162
- function unlinkOwned(base, owned, label = '') {
171
+ function unlinkOwned(base, owned, label = '', hashes = {}, force = false) {
163
172
  let removed = 0;
164
173
  const failed = [];
174
+ const kept = [];
165
175
  for (const rel of ownedWithin(base, owned)) {
166
176
  const victim = path.join(base, rel);
167
177
  try {
168
178
  if (isFile(victim)) {
179
+ if (!force && ownedState(base, rel, hashes) === 'modified') {
180
+ kept.push(`${label}${rel}`);
181
+ continue;
182
+ }
169
183
  unlinkSync(victim);
170
184
  removed += 1;
171
185
  pruneAncestors(path.dirname(victim), base);
@@ -178,24 +192,7 @@ function unlinkOwned(base, owned, label = '') {
178
192
  failed.push(`${label}${rel} (${e && e.message ? e.message : e})`);
179
193
  }
180
194
  }
181
- return [removed, failed];
182
- }
183
-
184
- /**
185
- * The manifest rels that resolve INSIDE `base` — the one security boundary every walk over
186
- * `owned` goes through (uninstall, both deactivates).
187
- *
188
- * `path.resolve(rroot, r)` and NOT `path.join`, which is the one line here that is a security
189
- * boundary rather than a translation. The Python is `(rroot / r).resolve()`, and pathlib's
190
- * `/` REPLACES the base when the right operand is absolute — so a manifest entry naming
191
- * `C:/evil.md` resolves outside the root and `_within` rejects it. `path.join` would have
192
- * concatenated it into `<root>/C:/evil.md`, which passes containment and would then be acted
193
- * on. `path.resolve` has pathlib's semantics for exactly this case, and collapses `..`
194
- * besides, which is why the guard runs on the resolved path rather than the lexical one.
195
- */
196
- function ownedWithin(base, rels) {
197
- const rroot = resolvePath(base);
198
- return rels.filter((r) => r && within(resolvePath(path.resolve(rroot, r)), rroot));
195
+ return [removed, failed, kept];
199
196
  }
200
197
 
201
198
  /** The two WARNs every reversal prints when an owned file survives. Identical in all three. */
@@ -204,6 +201,19 @@ function warnSurvivors(failed) {
204
201
  + `${failed.length} owned file(s): ${failed.join(', ')}\n`);
205
202
  }
206
203
 
204
+ /**
205
+ * The message a reversal prints for every rel `unlinkOwned` left on disk because the user had
206
+ * edited it and `--force` was not given. Separate from `warnSurvivors`: a kept file is not an
207
+ * error (nothing failed) — it is the user's now, exactly like a refused claim on re-emit, and
208
+ * the uninstall completes around it. So the hint is `--force` on the uninstall itself, not a
209
+ * retry: once it completes there is no install left to rerun against.
210
+ */
211
+ function warnKept(kept) {
212
+ printErr(`[uninstall] kept ${kept.length} file(s) you edited: ${kept.join(', ')}`
213
+ + ' — they are yours now (uninstall with --force to delete edited files too)\n');
214
+ }
215
+
216
+ /** Only a FAILED unlink keeps the manifest and markers — a kept edit never does. */
207
217
  function warnMarkersKept() {
208
218
  printErr('[uninstall] WARN: the manifest and markers were KEPT so '
209
219
  + '`geneseed uninstall` can be retried once the file(s) are '
@@ -332,11 +342,12 @@ function ownedOf(man) {
332
342
  * 'absent' and the retry the WARN promises would bounce off `cmdUninstall`'s own gate,
333
343
  * stranding the leftovers forever.
334
344
  */
335
- export function claudeUninstall(cfg, archiveMemory) {
345
+ export function claudeUninstall(cfg, archiveMemory, force = false) {
336
346
  const man = claudeReadManifest(cfg);
337
347
  const managed = managedOf(man);
338
- const [removed, failed] = unlinkOwned(cfg, ownedOf(man));
348
+ const [removed, failed, kept] = unlinkOwned(cfg, ownedOf(man), '', hashesOf(man), force);
339
349
  if (failed.length) warnSurvivors(failed);
350
+ if (kept.length) warnKept(kept);
340
351
  const hooks = managed.settings_hooks || [];
341
352
  const sf = settingsFile(cfg, managed);
342
353
  const unwired = unwireClaudeSettings(sf, hooks);
@@ -354,7 +365,7 @@ export function claudeUninstall(cfg, archiveMemory) {
354
365
  archived = archiveStore(path.join(cfg, 'memory'));
355
366
  }
356
367
  // `unmerged` reports REALITY: hooks were recorded AND the unwire actually rewrote the file.
357
- return { removed, unmerged: Boolean(hooks.length) && unwired, archived, failed };
368
+ return { removed, unmerged: Boolean(hooks.length) && unwired, archived, failed, kept };
358
369
  }
359
370
 
360
371
  /**
@@ -364,11 +375,12 @@ export function claudeUninstall(cfg, archiveMemory) {
364
375
  * install and an OpenCode one can share one entry point: the Claude family has no
365
376
  * opencode.json to unmerge and a settings.json plus a managed block instead.
366
377
  */
367
- export function uninstallGlobal(target, archiveMemory, host = 'opencode') {
368
- if (CLAUDE_STYLE.includes(host)) return claudeUninstall(target, archiveMemory);
378
+ export function uninstallGlobal(target, archiveMemory, host = 'opencode', force = false) {
379
+ if (CLAUDE_STYLE.includes(host)) return claudeUninstall(target, archiveMemory, force);
369
380
  const man = claudeReadManifest(target);
370
- const [removed, failed] = unlinkOwned(target, ownedOf(man));
381
+ const [removed, failed, kept] = unlinkOwned(target, ownedOf(man), '', hashesOf(man), force);
371
382
  if (failed.length) warnSurvivors(failed);
383
+ if (kept.length) warnKept(kept);
372
384
  for (const d of ['agents', 'skills', 'plugins']) {
373
385
  const p = path.join(target, d);
374
386
  if (isDir(p) && isEmptyDir(p)) rmdirQuiet(p);
@@ -382,7 +394,7 @@ export function uninstallGlobal(target, archiveMemory, host = 'opencode') {
382
394
  if (archiveMemory && isDir(path.join(target, 'memory'))) {
383
395
  archived = archiveStore(path.join(target, 'memory'));
384
396
  }
385
- return { removed, unmerged, archived, failed };
397
+ return { removed, unmerged, archived, failed, kept };
386
398
  }
387
399
 
388
400
  /**
@@ -431,19 +443,22 @@ export function installAgentEntryOf(instr) {
431
443
  * describing a file that no longer exists, which the fallback would then paper over with the
432
444
  * canonical spelling — and a bundle sub-dir layout would be left wired.
433
445
  */
434
- function opencodeProjectUninstall(root) {
446
+ function opencodeProjectUninstall(root, force = false) {
435
447
  const entry = installAgentEntry(root, 'project');
436
448
  let removed = 0;
437
449
  let failed = [];
450
+ let kept = [];
438
451
  let man = null;
439
452
  const oc = path.join(root, '.opencode');
440
453
  const manifestPath = path.join(oc, GLOBAL_MANIFEST);
441
454
  if (isFile(manifestPath)) {
442
455
  man = claudeReadManifest(oc);
443
- [removed, failed] = unlinkOwned(oc, ownedOf(man), '.opencode/');
456
+ [removed, failed, kept] = unlinkOwned(oc, ownedOf(man), '.opencode/', hashesOf(man), force);
444
457
  if (failed.length) warnSurvivors(failed);
458
+ if (kept.length) warnKept(kept);
445
459
  // Survivors gate, mirroring the other two reversals: `_project_qualifies` keys off the
446
460
  // manifest, so deleting it while an owned file survives makes the install unfindable.
461
+ // A kept (edited) file is not a survivor: it is the user's now, not this install's.
447
462
  if (!failed.length) {
448
463
  unlinkQuiet(manifestPath);
449
464
  if (isDir(oc) && isEmptyDir(oc)) rmdirQuiet(oc);
@@ -464,6 +479,7 @@ function opencodeProjectUninstall(root) {
464
479
  man ? skillPermissionNames(man) : []);
465
480
  const result = { removed, unmerged, archived: null };
466
481
  if (failed.length) result.failed = failed;
482
+ if (kept.length) result.kept = kept;
467
483
  return result;
468
484
  }
469
485
 
@@ -506,8 +522,16 @@ export function ownedDirsFor(root, host, scope, data) {
506
522
  * plugins), so a locked owned file OUTSIDE those (Bob's `rules/geneseed.md`) would slip the
507
523
  * gate while the reversal kept its manifest, and deleting the root markers here would then
508
524
  * contradict that "KEPT" promise. The reversal's own `failed` list closes that hole.
525
+ *
526
+ * A KEPT file (Task 5: the user edited it, no `--force`) is the opposite case: it is the
527
+ * user's now, exactly like a refused claim on re-emit, so it must NOT hold the install open —
528
+ * a manifest kept for it left the install 'active' and the next `rebuild-all`/`update`
529
+ * re-wired hooks into a repo the user had uninstalled. So a watched directory whose only
530
+ * remaining files are kept ones is not a survivor, and `kept` never gates the markers; it is
531
+ * returned so a caller (the web's remove) can name the files and the `--force` way out.
509
532
  */
510
- export function installUninstall(root, host = 'opencode', scope = 'global', memory = 'keep') {
533
+ export function installUninstall(root, host = 'opencode', scope = 'global', memory = 'keep',
534
+ force = false) {
511
535
  if (!['keep', 'archive', 'delete'].includes(memory)) memory = 'keep';
512
536
  if (installState(root, host, scope) === 'absent') {
513
537
  return { ok: false, error: 'nothing installed here' };
@@ -517,13 +541,13 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
517
541
  // install's, and they go with it.
518
542
  let summary;
519
543
  if (CLAUDE_STYLE.includes(host)) {
520
- summary = claudeUninstall(data, false);
544
+ summary = claudeUninstall(data, false, force);
521
545
  rmtreeQuiet(path.join(data, DISABLED_STASH, host));
522
546
  } else if (installKind(root) === 'global') {
523
- summary = uninstallGlobal(root, false, 'opencode');
547
+ summary = uninstallGlobal(root, false, 'opencode', force);
524
548
  rmtreeQuiet(path.join(root, DISABLED_STASH));
525
549
  } else {
526
- summary = opencodeProjectUninstall(root);
550
+ summary = opencodeProjectUninstall(root, force);
527
551
  rmtreeQuiet(path.join(root, DISABLED_STASH));
528
552
  }
529
553
  // 2. Memory + notebook, independent of the owned-file removal above.
@@ -536,7 +560,14 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
536
560
  }
537
561
  // 3. The root markers, but only once the reversal's dirs are actually gone.
538
562
  const failed = summary.failed || [];
539
- const survivors = ownedDirsFor(root, host, scope, data).filter((d) => existsSync(d));
563
+ const kept = summary.kept || [];
564
+ // `kept` rels are spelled relative to the reversal's own base: the cfg dir for the Claude
565
+ // family, the root for OpenCode (whose project reversal already prefixes `.opencode/`).
566
+ const keptAbs = new Set(kept.map((r) => path.join(CLAUDE_STYLE.includes(host) ? data : root, r)));
567
+ const survivors = ownedDirsFor(root, host, scope, data).filter((d) => existsSync(d)
568
+ && !(keptAbs.size && readdirSync(d, { recursive: true, withFileTypes: true })
569
+ .every((e) => e.isDirectory() || keptAbs.has(path.join(e.parentPath, e.name)))));
570
+ const withKept = (o) => (kept.length ? { ...o, kept } : o);
540
571
  if (survivors.length || failed.length) {
541
572
  if (survivors.length && !failed.length) {
542
573
  // With `failed` set the reversal already warned twice — no third overlapping WARN.
@@ -544,12 +575,15 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
544
575
  + `${survivors.join(', ')}. The install marker was KEPT so you can retry `
545
576
  + '`geneseed uninstall` once the file(s) are unlocked/removable.\n');
546
577
  }
547
- const out = {
578
+ // `incomplete` names every reason the root markers stayed — directory survivors and a
579
+ // failed unlink — deduped.
580
+ const named = survivors.concat(failed);
581
+ const out = withKept({
548
582
  ok: true,
549
583
  removed: summary.removed ?? 0,
550
584
  memory,
551
- incomplete: survivors.concat(failed.filter((f) => !survivors.includes(f))),
552
- };
585
+ incomplete: named.filter((f, i) => named.indexOf(f) === i),
586
+ });
553
587
  if (archived.length) out.archived = archived;
554
588
  return out;
555
589
  }
@@ -623,7 +657,7 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
623
657
  }
624
658
  // 4. Tidy an emptied marker dir (.claude/.bob/.openclaude) so no husk lingers in the repo.
625
659
  if (data !== root && isDir(data) && isEmptyDir(data)) rmdirQuiet(data);
626
- const out = { ok: true, removed: summary.removed ?? 0, memory };
660
+ const out = withKept({ ok: true, removed: summary.removed ?? 0, memory });
627
661
  if (archived.length) out.archived = archived;
628
662
  return out;
629
663
  }
@@ -806,7 +840,7 @@ export function cmdUninstall(args) {
806
840
  }
807
841
  }
808
842
  const memory = args.archiveMemory ? 'archive' : 'keep';
809
- const s = installUninstall(root, host, scope, memory);
843
+ const s = installUninstall(root, host, scope, memory, Boolean(args.force));
810
844
  if (!(s.ok ?? true)) {
811
845
  printErr(`[uninstall] failed: ${s.error || 'unknown error'}\n`);
812
846
  return 1;
@@ -820,7 +854,8 @@ export function cmdUninstall(args) {
820
854
  + 'the install marker was kept — retry `geneseed uninstall` once they\'re '
821
855
  + `removable. ${cfgfile} updated where needed; memory/notebook ${mem}.\n`);
822
856
  } else {
823
- printOut(`[uninstall] done — removed ${s.removed} file(s); ${cfgfile} updated where `
857
+ const keptNote = s.kept ? `, kept ${s.kept.length} you edited` : '';
858
+ printOut(`[uninstall] done — removed ${s.removed} file(s)${keptNote}; ${cfgfile} updated where `
824
859
  + `needed; memory/notebook ${mem}. Start a new session to apply.\n`);
825
860
  }
826
861
  if (scope === 'global') printSurvivingProjectInventory(root);
@@ -42,7 +42,7 @@ 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 {
@@ -50,11 +50,10 @@ import {
50
50
  installState, installTargets, legacyProcessCarrierOfDir, modeOfDir, postureOfDir, trustOfDir,
51
51
  } from '../hosts/installs.mjs';
52
52
  import {
53
- MCP_PRESETS, isDict, mcpApply, mcpCommented, mcpInstallTargets, mcpKnownNames, mcpLoad,
54
- mcpMeta, mcpPresetBlock, mcpSave, mcpSetEnabled, mcpState,
53
+ isDict, mcpCommented, mcpInstallTargets, mcpLoad, mcpServerNames, mcpState,
55
54
  } from '../hosts/mcp.mjs';
56
- import { readText, isFile, isDir } from '../lib/fs.mjs';
57
- 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';
58
57
  import { NO_WINDOW } from '../lib/proc.mjs';
59
58
  import { normcase, which, within } from '../lib/paths.mjs';
60
59
  import { stripWhitespace } from '../lib/text.mjs';
@@ -348,18 +347,20 @@ export function apiLoopsPresetMutate(state, body) {
348
347
 
349
348
  // ---- MCP ----------------------------------------------------------------------------------
350
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
+ */
351
357
  export function apiMcp() {
352
358
  const out = [];
353
359
  for (const [label, p, host, scope, root] of mcpInstallTargets()) {
354
360
  if (installState(root, host, scope) !== 'active') continue;
355
361
  const cfg = mcpLoad(p, host);
356
- const servers = [];
357
- for (const name of mcpKnownNames(cfg, host)) {
358
- const [lbl, desc] = mcpMeta(name);
359
- servers.push({ name, label: lbl, desc,
360
- preset: Object.hasOwn(MCP_PRESETS, name),
361
- state: mcpState(cfg, name, host) });
362
- }
362
+ const servers = mcpServerNames(cfg, host)
363
+ .map((name) => ({ name, state: mcpState(cfg, name, host) }));
363
364
  out.push({ label, path: p, host, root, exists: isFile(p),
364
365
  commented: mcpCommented(p), servers });
365
366
  }
@@ -370,10 +371,8 @@ export function apiMcp() {
370
371
  /**
371
372
  * The MCP config paths a request body is allowed to name, as `path -> [path, host]`.
372
373
  *
373
- * LIFTED OUT OF `apiMcpToggle` BECAUSE IT NOW HAS A SECOND CALLER, not for tidiness. `/api/reveal`
374
- * hands a path to the desktop's file manager, and the allowlist is the whole security of that
375
- * endpoint exactly as it is of the toggle: an unlisted path must 404 before anything is opened.
376
- * 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.
377
376
  *
378
377
  * It is rebuilt per call rather than cached: installs come and go while the daemon runs, and a
379
378
  * stale allowlist is wrong in both directions — refusing a real target, or naming a dead one.
@@ -386,67 +385,6 @@ export function mcpTargetPaths() {
386
385
  return known;
387
386
  }
388
387
 
389
- /**
390
- * Enable / disable / first-add one server.
391
- *
392
- * THE PATH COMES OUT OF THE REQUEST BODY, so the allowlist is the whole security of this
393
- * endpoint: `known` is built from the detected install targets and an unlisted path is a 404
394
- * before anything is read or written. Without it this is "write arbitrary JSON to an
395
- * arbitrary file", behind a CSRF token and nothing else.
396
- *
397
- * The Claude-shaped hosts are parsed ONCE, STRICTLY, and that exact object is what gets
398
- * rewritten — so the safety check and the value saved come from the same read, with no
399
- * time-of-check/time-of-use gap and no comment-stripper touching a string that contains
400
- * `,]`. A file that will not parse is refused rather than clobbered: it may be
401
- * `~/.claude.json`, which holds projects and history far beyond MCP wiring.
402
- */
403
- export function apiMcpToggle(state, body) {
404
- const name = strOr(bget(body, 'name'));
405
- const want = isTruthy(bget(body, 'enabled'));
406
- const pathArg = strOr(bget(body, 'path'));
407
- const known = mcpTargetPaths();
408
- const hit = known.get(pathArg);
409
- if (hit === undefined || !name) {
410
- throw new NotFound(`mcp target ${pathArg || '(none)'}`);
411
- }
412
- const [p, host] = hit;
413
- if (mcpCommented(p)) {
414
- return { ok: false, error: 'config holds comments — edit it by hand to keep them' };
415
- }
416
- let cfg;
417
- if (CLAUDE_STYLE.includes(host)) {
418
- if (isFile(p)) {
419
- let parsed;
420
- try {
421
- parsed = parseJson(readText(p));
422
- } catch {
423
- parsed = null;
424
- }
425
- if (!isDict(parsed)) {
426
- return { ok: false,
427
- error: "couldn't parse this config — edit it by hand to avoid data loss" };
428
- }
429
- cfg = parsed;
430
- } else {
431
- cfg = {};
432
- }
433
- } else {
434
- cfg = mcpLoad(p);
435
- }
436
- if (mcpState(cfg, name, host) === 'absent') {
437
- if (!Object.hasOwn(MCP_PRESETS, name)) {
438
- return { ok: false, error: `unknown server '${name}'` };
439
- }
440
- if (!want) return { ok: false, error: `'${name}' is not configured` };
441
- cfg = mcpApply(cfg, name, mcpPresetBlock(name, host), host);
442
- cfg = mcpSetEnabled(cfg, name, true, host);
443
- } else {
444
- cfg = mcpSetEnabled(cfg, name, want, host);
445
- }
446
- mcpSave(p, cfg);
447
- return { ok: true, name, state: mcpState(cfg, name, host) };
448
- }
449
-
450
388
  // ---- the harness selector ------------------------------------------------------------------
451
389
 
452
390
  /**