geneseed 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +4 -4
  3. package/SHIPPED.md +3 -3
  4. package/adapters/claude-code/README.md +0 -21
  5. package/adapters/opencode/plugins/geneseed-guard.js +31 -0
  6. package/docs/concepts/agents.md +1 -0
  7. package/docs/guides/uninstall.md +2 -0
  8. package/docs/guides/upgrade.md +2 -0
  9. package/docs/token-footprint.md +1 -1
  10. package/docs/understand/take-it-out.md +1 -1
  11. package/harness.config.json +1 -1
  12. package/js/README.md +6 -0
  13. package/js/build/driver.mjs +47 -11
  14. package/js/build/emit-claude.mjs +22 -10
  15. package/js/build/emit-common.mjs +9 -3
  16. package/js/build/emit-opencode.mjs +18 -11
  17. package/js/build/source.mjs +1 -0
  18. package/js/build/version.mjs +10 -3
  19. package/js/cli-table.json +15 -0
  20. package/js/hosts/hooks.mjs +4 -0
  21. package/js/hosts/installs.mjs +1 -1
  22. package/js/hosts/native.mjs +38 -6
  23. package/js/hosts/owned.mjs +159 -0
  24. package/js/hosts/settings.mjs +4 -0
  25. package/js/hosts/usage.mjs +62 -0
  26. package/js/inspect/checks-repo.mjs +76 -1
  27. package/js/inspect/cost.mjs +83 -0
  28. package/js/inspect/doctor.mjs +2 -1
  29. package/js/inspect/inventory.mjs +1 -1
  30. package/js/maintain/uninstall.mjs +77 -42
  31. package/js/web/api.mjs +66 -3
  32. package/js/web/gates-api.mjs +44 -0
  33. package/js/web/routes.mjs +8 -2
  34. package/js/web/update-check.mjs +61 -0
  35. package/js/web/usage-api.mjs +95 -0
  36. package/package.json +1 -1
  37. package/registry.json +25 -25
  38. package/src/AGENT.md.tmpl +1 -1
  39. package/src/agents/performance.md +72 -0
  40. package/src/laws/universal.md +7 -5
  41. package/src/skills/council.md +1 -1
  42. package/src/skills/develop.md +9 -0
  43. package/src/skills/geneseed-code-review.md +5 -1
  44. package/src/skills/security-audit.md +7 -1
  45. package/src/skills/wayfinder.md +25 -6
  46. package/src/skills/worktree.md +5 -5
  47. package/themes/_TEMPLATE.json +1 -1
  48. package/themes/biker.json +1 -1
  49. package/themes/commentator.json +1 -1
  50. package/themes/cyberpunk.json +1 -1
  51. package/themes/gamer.json +1 -1
  52. package/themes/imperial.json +1 -1
  53. package/themes/joker.json +1 -1
  54. package/themes/marvin.json +1 -1
  55. package/themes/mean.json +1 -1
  56. package/themes/military.json +1 -1
  57. package/themes/neutral.json +1 -1
  58. package/themes/pirate.json +1 -1
  59. package/themes/sports.json +1 -1
  60. package/themes/verstappen.json +1 -1
  61. package/themes/wizard.json +1 -1
  62. package/web/dist/assets/{Activity-CqVt2u9s.js → Activity-DDTRkc4M.js} +1 -1
  63. package/web/dist/assets/{ActivityDetail-Bui5Gomc.js → ActivityDetail-C41RsH-S.js} +1 -1
  64. package/web/dist/assets/Dashboard-B0DJ2oUk.js +2 -0
  65. package/web/dist/assets/Docs-DBj7-n9F.js +5 -0
  66. package/web/dist/assets/{GreenhouseView-DgNY8QKU.js → GreenhouseView-Bz-Q1Xl-.js} +1 -1
  67. package/web/dist/assets/{Installs-DZwZOUZJ.js → Installs-BNlWdJJE.js} +2 -2
  68. package/web/dist/assets/{JournalView-DXpFbKOG.js → JournalView-BkwmfkEy.js} +1 -1
  69. package/web/dist/assets/{Laws-Bh7ztekk.js → Laws-CzvMZCUL.js} +2 -2
  70. package/web/dist/assets/Library-BgIpOTHi.js +1 -0
  71. package/web/dist/assets/{Loops-CvFMZ7_P.js → Loops-s_NsuNaQ.js} +1 -1
  72. package/web/dist/assets/{OperatorHudView-FPTUH1CB.js → OperatorHudView-CgeWJnPI.js} +1 -1
  73. package/web/dist/assets/{Personal-DcVA502F.js → Personal-DMM6jfE0.js} +1 -1
  74. package/web/dist/assets/{Tabs-DtyR4h75.js → Tabs-CPpcsYDc.js} +1 -1
  75. package/web/dist/assets/api-Bp5eW_6m.js +1 -0
  76. package/web/dist/assets/{drawable-CNSPVZxX.js → drawable-D-fIT_ow.js} +1 -1
  77. package/web/dist/assets/{index-Ctp-4poP.css → index-CO-9wMJY.css} +1 -1
  78. package/web/dist/assets/index-DXIa0ZG_.js +2 -0
  79. package/web/dist/index.html +3 -3
  80. package/src/skills/herdr.md +0 -154
  81. package/web/dist/assets/Dashboard-VlIx1-At.js +0 -2
  82. package/web/dist/assets/Docs-6cYYkVeA.js +0 -5
  83. package/web/dist/assets/Library-DBLX-1SD.js +0 -1
  84. package/web/dist/assets/api-CRqWld2H.js +0 -1
  85. package/web/dist/assets/index-BmjcLIOr.js +0 -2
@@ -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);
package/js/web/api.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The web console's dashboard and settings reads — `overview`, `recent`, `themes`, `setup`,
3
- * `doctor`, `diff`, `installs`, `excludes` and the two `loops` reads.
3
+ * `doctor`, `diff`, `installs`, `fleet`, `update`, `excludes` and the two `loops` reads.
4
4
  *
5
5
  * WHAT IS NOT HERE, AND WHERE IT WENT: the Library read model (catalog sections, items, wiki,
6
6
  * memory, `NotFound`) is `catalog.mjs`; the `WebState` every endpoint reads from is `state.mjs`;
@@ -11,12 +11,14 @@
11
11
  import { statSync } from 'node:fs';
12
12
  import path from 'node:path';
13
13
 
14
- import { CONFIG, ROOT, THEMES, discoverNames } from '../build/source.mjs';
14
+ import { CONFIG, ROOT, THEMES, discoverNames, makeCfg } from '../build/source.mjs';
15
+ import { readVersion, sourceFingerprint } from '../build/version.mjs';
15
16
  import { sourceReleaseVersion } from '../hosts/opencode.mjs';
17
+ import { alwaysOnCost } from '../inspect/cost.mjs';
16
18
  import { diffCollect } from '../inspect/diff.mjs';
17
19
  import { doctorCollect } from '../inspect/doctor.mjs';
18
20
  import { excludesSnapshot } from '../inspect/excludes.mjs';
19
- import { CLAUDE_STYLE, HOSTS, resolvePath } from '../hosts/hosts.mjs';
21
+ import { CLAUDE_STYLE, HOSTS, VERSION_MARKER, resolvePath } from '../hosts/hosts.mjs';
20
22
  import {
21
23
  footprintOfDir, installState, installTargets, modeOfDir, trustOfDir,
22
24
  postureOfDir, readJsonMaybe, themeOfDir,
@@ -32,6 +34,7 @@ import {
32
34
  configItems, deployed, dget, memoryItems, notebookItems, wikiItems,
33
35
  } from './catalog.mjs';
34
36
  import { stampMinute } from './state.mjs';
37
+ import { updateStatus } from './update-check.mjs';
35
38
 
36
39
  /**
37
40
  * `js/web/docs.mjs` imports these three from HERE, and that file is outside this split — so
@@ -141,6 +144,63 @@ export function apiInstalls(state) {
141
144
  }
142
145
 
143
146
 
147
+ /**
148
+ * EVERY INSTALLED HARNESS ON THE MACHINE, EACH JUDGED ON ITS OWN — the Overview's fleet table.
149
+ *
150
+ * Installed means `active` or `disabled`: a host with nothing installed is not listed, because
151
+ * installing belongs to the Installs page with its other settings (user, 2026-10-13).
152
+ *
153
+ * Its own route, not more fields on `/api/installs`: the sidebar refetches that list on every
154
+ * data revision, and the drift check below re-renders a whole emit per global install.
155
+ * ponytail: one `diffCollect` render per global install per request, fine for the handful a
156
+ * machine carries; cache per (root, build mtime) if a fleet ever grows past that.
157
+ *
158
+ * Each row reads ITS OWN `.geneseed-version`. `statusData().installed_fp` is the first carrier
159
+ * a fixed walk finds, which describes the wrong install as soon as two hosts are deployed.
160
+ * Every unknown stays `null` — `verdict: 'unknown'`, `drift: null` — never a reassuring zero.
161
+ */
162
+ export function apiFleet(state) {
163
+ const sourceFp = sourceFingerprint(makeCfg());
164
+ const installs = [];
165
+ for (const [host, scope, root] of installTargets()) {
166
+ const st = installState(root, host, scope);
167
+ if (st !== 'active' && st !== 'disabled') continue;
168
+ const dir = viewCfg(host, scope, root);
169
+ const fp = readVersion(dir);
170
+ let drift = null;
171
+ if (st === 'active') {
172
+ try {
173
+ const { files } = diffCollect({ target: dir });
174
+ if (files !== null) {
175
+ drift = {
176
+ edited: files.filter((f) => f.status === 'edited').length,
177
+ added: files.filter((f) => f.status === 'added').length,
178
+ missing: files.filter((f) => f.status === 'missing').length,
179
+ };
180
+ }
181
+ } catch { /* an install that cannot be re-rendered reports no drift figure, not zero */ }
182
+ }
183
+ let built = null;
184
+ try { built = Math.floor(statSync(path.join(dir, VERSION_MARKER)).mtimeMs / 1000); } catch { /* never built */ }
185
+ installs.push({
186
+ id: `${host}:${scope}:${root}`, host, scope, path: root, state: st,
187
+ theme: themeOfDir(root, host),
188
+ footprint: footprintOfDir(root),
189
+ installed_fp: fp,
190
+ verdict: fp === null ? 'unknown' : fp === sourceFp ? 'synced' : 'behind',
191
+ // A project install has no drift check at all (`diffCollect` answers `files: null`), which
192
+ // the console must say as "not checkable" — so the fact travels apart from the figure.
193
+ diffable: drift !== null,
194
+ drift,
195
+ built_epoch: built,
196
+ // What this install injects every session, by part (characters); null when its carrier
197
+ // is missing, so the console says "not measured" rather than drawing an empty bar.
198
+ cost: alwaysOnCost({ host, root, dir, footprint: footprintOfDir(root) }),
199
+ });
200
+ }
201
+ return { installs, source_fp: sourceFp, doctor: state.doctor };
202
+ }
203
+
144
204
  /** The dashboard aggregate. */
145
205
  export function apiOverview(state) {
146
206
  const inv = state.inventory;
@@ -354,6 +414,9 @@ export function apiLoopsActive() {
354
414
  }
355
415
 
356
416
  /** The source's release label, or null when it cannot be read. */
417
+ /** Is a newer release on npm than the source this console serves? See update-check.mjs. */
418
+ export const apiUpdate = (state) => updateStatus(state, releaseLabel());
419
+
357
420
  function releaseLabel() {
358
421
  const v = sourceReleaseVersion({ config: CONFIG });
359
422
  return v && v !== '0.0.0' ? v : null;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * GET /api/gates — gate asks per rule per day over the last 30 days, across every installed
3
+ * harness: the Overview's "Gates" sparklines. The ledger (`notebook/gates.jsonl`, written by
4
+ * js/hosts/hooks.mjs and the OpenCode guard plugin) already stamps each ask with an ISO `ts`
5
+ * and a `rule` (`law-1`, `process-1`, `wiki`…); this only reads it. `gateSummary` keeps its
6
+ * count-only shape for the CLI's status panel.
7
+ *
8
+ * `{ days: ['YYYY-MM-DD' × 30], rules: [{ rule, n, per_day[30] }] }`, busiest rule first.
9
+ * Garbage lines are skipped; nothing here writes (the writers already trim at 1000 lines).
10
+ */
11
+ import { readFileSync } from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ import { GATE_LEDGER } from '../hosts/hosts.mjs';
15
+ import { installState, installTargets } from '../hosts/installs.mjs';
16
+ import { viewCfg } from './api.mjs';
17
+
18
+ const DAY_MS = 24 * 60 * 60 * 1000;
19
+ const WINDOW = 30;
20
+
21
+ export function gateDays({ now = Date.now(), targets = installTargets() } = {}) {
22
+ const days = Array.from({ length: WINDOW }, (_, i) =>
23
+ new Date(now - (WINDOW - 1 - i) * DAY_MS).toISOString().slice(0, 10));
24
+ const index = new Map(days.map((d, i) => [d, i]));
25
+ const by = new Map();
26
+ for (const [host, scope, root] of targets) {
27
+ if (installState(root, host, scope) !== 'active') continue;
28
+ let raw;
29
+ try { raw = readFileSync(path.join(viewCfg(host, scope, root), GATE_LEDGER), 'utf8'); } catch { continue; }
30
+ for (const line of raw.split('\n')) {
31
+ let e;
32
+ try { e = JSON.parse(line); } catch { continue; }
33
+ const i = typeof e?.ts === 'string' ? index.get(e.ts.slice(0, 10)) : undefined;
34
+ if (i === undefined || typeof e.rule !== 'string') continue;
35
+ const row = by.get(e.rule) || { rule: e.rule, n: 0, per_day: new Array(WINDOW).fill(0) };
36
+ row.n += 1;
37
+ row.per_day[i] += 1;
38
+ by.set(e.rule, row);
39
+ }
40
+ }
41
+ return { days, rules: [...by.values()].sort((a, b) => b.n - a.n || a.rule.localeCompare(b.rule)) };
42
+ }
43
+
44
+ export const apiGates = () => gateDays();
package/js/web/routes.mjs CHANGED
@@ -21,10 +21,12 @@ import { percentDecode } from '../lib/text.mjs';
21
21
  import { apiExcludesMutate, apiInstallToggle, apiLoopsPresetMutate, apiMcp, apiMemoryDelete, apiSelectView } from './actions.mjs';
22
22
  import { apiActivity, apiActivityDetail, apiActivityToggle } from './activity.mjs';
23
23
  import {
24
- apiDiff, apiDoctor, apiExcludes, apiInstalls, apiLoops, apiLoopsActive, apiOverview,
25
- apiRecent, apiSetup, apiThemes,
24
+ apiDiff, apiDoctor, apiExcludes, apiFleet, apiInstalls, apiLoops, apiLoopsActive, apiOverview,
25
+ apiRecent, apiSetup, apiThemes, apiUpdate,
26
26
  } from './api.mjs';
27
27
  import { NotFound, apiCatalog, apiItem } from './catalog.mjs';
28
+ import { apiUsage } from './usage-api.mjs';
29
+ import { apiGates } from './gates-api.mjs';
28
30
  import { apiProfile, apiProfileSave, apiRules, apiRulesMutate, apiRulesPromote } from './user-files.mjs';
29
31
 
30
32
  /**
@@ -146,6 +148,10 @@ export const STATE_ROUTES = {
146
148
  '/api/setup': apiSetup,
147
149
  '/api/doctor': apiDoctor,
148
150
  '/api/installs': apiInstalls,
151
+ '/api/fleet': apiFleet,
152
+ '/api/update': apiUpdate,
153
+ '/api/usage': apiUsage,
154
+ '/api/gates': apiGates,
149
155
  '/api/excludes': apiExcludes,
150
156
  '/api/profile': apiProfile,
151
157
  '/api/diff': apiDiff,
@@ -0,0 +1,61 @@
1
+ /**
2
+ * IS A NEWER GENESEED RELEASED? — the Overview's "4.2.0 available" chip.
3
+ *
4
+ * Route handlers answer synchronously, so this never waits on the network: it answers from the
5
+ * last result it holds and, when that is missing or a day old, starts ONE background fetch whose
6
+ * answer the next poll reads. The console refetches after every mutation and on mount, so the
7
+ * chip appears within a poll or two of the daemon starting.
8
+ *
9
+ * Every failure is `latest: null` and says nothing more: a corporate proxy, an offline laptop and
10
+ * a registry outage all read "update check unavailable", never an error. `GENESEED_NO_UPDATE_CHECK`
11
+ * turns the fetch off for a machine that must not phone out at all.
12
+ *
13
+ * ponytail: the cache lives on the daemon's state, in memory. A restart costs one more request;
14
+ * a disk cache would be a file to version for a figure that is free to re-ask.
15
+ */
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+ /** A failed ask is retried sooner than a good answer is refreshed: a proxy hiccup is not a day. */
18
+ const RETRY_MS = 60 * 60 * 1000;
19
+ const REGISTRY = 'https://registry.npmjs.org/geneseed/latest';
20
+
21
+ /** x.y.z only; a pre-release tag or a malformed label compares as nothing newer. */
22
+ export function semverGreater(a, b) {
23
+ const parse = (v) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(String(v || '').trim()) || []).slice(1).map(Number);
24
+ const x = parse(a);
25
+ const y = parse(b);
26
+ if (x.length !== 3 || y.length !== 3) return false;
27
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
28
+ return false;
29
+ }
30
+
31
+ async function npmLatest() {
32
+ const res = await fetch(REGISTRY, { signal: AbortSignal.timeout(3000) });
33
+ if (!res.ok) return null;
34
+ const body = await res.json();
35
+ return typeof body?.version === 'string' ? body.version : null;
36
+ }
37
+
38
+ /**
39
+ * `{ current, latest, available, checked_at }`. `available` is true only when `latest` is
40
+ * semver-greater than `current`, so a checkout ahead of the last release never shows one;
41
+ * null when either side is unknown. `fetcher` and `now` are injectable for the tests, which
42
+ * must never reach the network.
43
+ */
44
+ export function updateStatus(state, current, { fetcher = npmLatest, now = Date.now() } = {}) {
45
+ const c = state._update || (state._update = { latest: null, checked_at: null, inflight: false });
46
+ const stale = c.checked_at === null
47
+ || now - c.checked_at > (c.latest === null ? RETRY_MS : DAY_MS);
48
+ // Under `node --test` (NODE_TEST_CONTEXT) the real registry is never asked: the route probes in
49
+ // tests/unit/web_server.test.mjs reach this handler, and a suite must not depend on the network.
50
+ const off = process.env.GENESEED_NO_UPDATE_CHECK
51
+ || (fetcher === npmLatest && process.env.NODE_TEST_CONTEXT);
52
+ if (stale && !c.inflight && !off) {
53
+ c.inflight = true;
54
+ Promise.resolve()
55
+ .then(fetcher)
56
+ .then((v) => { c.latest = v ?? null; }, () => { c.latest = null; })
57
+ .finally(() => { c.checked_at = now; c.inflight = false; });
58
+ }
59
+ const available = current && c.latest ? semverGreater(c.latest, current) : null;
60
+ return { current: current ?? null, latest: c.latest, available, checked_at: c.checked_at };
61
+ }