geneseed 4.10.0 → 4.14.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 (139) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +4 -3
  3. package/SHIPPED.md +1 -1
  4. package/docs/concepts/kits.md +14 -2
  5. package/docs/concepts/skills.md +1 -1
  6. package/docs/extending.md +3 -4
  7. package/docs/guides/run-anywhere.md +2 -2
  8. package/docs/guides/upgrade.md +2 -2
  9. package/docs/reference/troubleshoot-install.md +1 -1
  10. package/docs/web-ui.md +6 -1
  11. package/harness.config.json +1 -1
  12. package/js/hosts/link.mjs +88 -1
  13. package/js/inspect/checks-build.mjs +8 -2
  14. package/js/inspect/cost.mjs +1 -1
  15. package/js/inspect/diff.mjs +53 -7
  16. package/js/inspect/inventory.mjs +1 -0
  17. package/js/lib/prompt.mjs +19 -2
  18. package/js/maintain/setup.mjs +92 -28
  19. package/js/maintain/update.mjs +2 -2
  20. package/js/web/actions.mjs +8 -0
  21. package/js/web/api.mjs +39 -17
  22. package/js/web/catalog.mjs +17 -2
  23. package/js/web/handler.mjs +1 -0
  24. package/js/web/server.mjs +11 -5
  25. package/js/web/state.mjs +38 -3
  26. package/package.json +1 -1
  27. package/registry.json +28 -0
  28. package/src/AGENT.md.tmpl +5 -5
  29. package/src/agents/_self-improvement.md +11 -0
  30. package/src/agents/_template.md +1 -8
  31. package/src/agents/advocate.md +1 -8
  32. package/src/agents/architect.md +1 -8
  33. package/src/agents/developer.md +1 -8
  34. package/src/agents/devops-engineer.md +1 -9
  35. package/src/agents/docker-expert.md +1 -9
  36. package/src/agents/docs.md +1 -8
  37. package/src/agents/empiricist.md +1 -8
  38. package/src/agents/explorer.md +1 -8
  39. package/src/agents/framer.md +1 -8
  40. package/src/agents/historian.md +1 -8
  41. package/src/agents/kubernetes-specialist.md +1 -9
  42. package/src/agents/memory-curator.md +1 -8
  43. package/src/agents/operator.md +1 -8
  44. package/src/agents/performance.md +1 -8
  45. package/src/agents/pragmatist.md +1 -8
  46. package/src/agents/react-engineer.md +38 -0
  47. package/src/agents/react-reviewer.md +42 -0
  48. package/src/agents/researcher.md +5 -10
  49. package/src/agents/reviewer.md +1 -8
  50. package/src/agents/security.md +1 -8
  51. package/src/agents/skeptic.md +1 -8
  52. package/src/agents/spring-boot-engineer.md +1 -9
  53. package/src/agents/spring-reviewer.md +1 -9
  54. package/src/agents/spring-security.md +1 -9
  55. package/src/agents/steward.md +1 -8
  56. package/src/agents/tester.md +1 -8
  57. package/src/agents/user-advocate.md +1 -8
  58. package/src/agents/visionary.md +1 -8
  59. package/src/kits/react/rules.md +13 -0
  60. package/src/kits/react.json +18 -0
  61. package/src/licenses/everything-claude-code.txt +21 -0
  62. package/src/skills/_self-improvement.md +22 -5
  63. package/src/skills/brainstorm.md +7 -2
  64. package/src/skills/codebase-design.md +6 -2
  65. package/src/skills/debug.md +1 -1
  66. package/src/skills/develop.md +4 -2
  67. package/src/skills/handoff.md +1 -1
  68. package/src/skills/parallel-agents.md +3 -1
  69. package/src/skills/plan.md +5 -2
  70. package/src/skills/prototype.md +4 -2
  71. package/src/skills/react/a11y.md +33 -0
  72. package/src/skills/react/vite.md +35 -0
  73. package/src/skills/react/webpack.md +40 -0
  74. package/src/skills/react-testing.md +68 -0
  75. package/src/skills/react.md +63 -0
  76. package/src/skills/research.md +4 -2
  77. package/src/skills/rule.md +4 -2
  78. package/src/skills/ship.md +6 -4
  79. package/src/skills/skill-forge.md +10 -5
  80. package/src/skills/wayfinder.md +6 -2
  81. package/src/skills/wiki.md +18 -5
  82. package/src/upstreams.json +7 -0
  83. package/themes/_TEMPLATE.json +4 -0
  84. package/themes/biker.json +4 -0
  85. package/themes/commentator.json +4 -0
  86. package/themes/cyberpunk.json +4 -0
  87. package/themes/gamer.json +4 -0
  88. package/themes/imperial.json +4 -0
  89. package/themes/joker.json +4 -0
  90. package/themes/marvin.json +4 -0
  91. package/themes/mean.json +4 -0
  92. package/themes/military.json +4 -0
  93. package/themes/neutral.json +4 -0
  94. package/themes/pirate.json +4 -0
  95. package/themes/sports.json +4 -0
  96. package/themes/verstappen.json +4 -0
  97. package/themes/wizard.json +4 -0
  98. package/web/dist/assets/Activity-44rbWUQI.js +1 -0
  99. package/web/dist/assets/ActivityDetail-Dk9k893a.js +1 -0
  100. package/web/dist/assets/Dashboard-DCoUN60W.js +2 -0
  101. package/web/dist/assets/{Docs-DLVckkew.js → Docs-C_je2lRG.js} +3 -3
  102. package/web/dist/assets/FilterInput-CZREvrjX.js +1 -0
  103. package/web/dist/assets/GreenhouseView-BXAOIznW.js +2 -0
  104. package/web/dist/assets/Installs-DAXF403D.js +3 -0
  105. package/web/dist/assets/JournalView-DZ184wKB.js +1 -0
  106. package/web/dist/assets/KitApply-pjhvGha0.js +1 -0
  107. package/web/dist/assets/Kits-BCCKjSIY.js +1 -0
  108. package/web/dist/assets/{Laws-BkKnZOBI.js → Laws-BgBUpRkE.js} +1 -1
  109. package/web/dist/assets/Library-CW0Zl6RF.js +1 -0
  110. package/web/dist/assets/Loops-BvFOzjEw.js +2 -0
  111. package/web/dist/assets/{Markdown-iHvBrpRN.js → Markdown-zaXXXHz9.js} +13 -13
  112. package/web/dist/assets/OperatorHudView-Bs4unPNi.js +2 -0
  113. package/web/dist/assets/Personal-C23XaT-a.js +1 -0
  114. package/web/dist/assets/Tabs-b2XFu4OS.js +1 -0
  115. package/web/dist/assets/activity-Jw8XcPiP.js +1 -0
  116. package/web/dist/assets/{drawable-CgYi0Dtg.js → drawable-BERkKhx_.js} +1 -1
  117. package/web/dist/assets/index-C9R_bRw_.js +2 -0
  118. package/web/dist/assets/{index-BCJz4_8C.css → index-jYlWihN-.css} +1 -1
  119. package/web/dist/assets/{react-BOF6yL40.js → react-DlYQp10C.js} +1 -1
  120. package/web/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
  121. package/web/dist/assets/sections-ChwaobfW.js +1 -0
  122. package/web/dist/assets/usePoll-bDFN-Hr6.js +1 -0
  123. package/web/dist/index.html +5 -4
  124. package/web/dist/assets/Activity-BjyJaf8e.js +0 -1
  125. package/web/dist/assets/ActivityDetail-Bo9nRDNV.js +0 -1
  126. package/web/dist/assets/Dashboard-BQJGU7vE.js +0 -2
  127. package/web/dist/assets/FilterInput-zLFXoSt6.js +0 -1
  128. package/web/dist/assets/GreenhouseView--DML7CW3.js +0 -2
  129. package/web/dist/assets/Installs-CxCiln1F.js +0 -3
  130. package/web/dist/assets/JournalView-ymgZleHi.js +0 -1
  131. package/web/dist/assets/Library-C8ercIZv.js +0 -1
  132. package/web/dist/assets/Loops-C95zhbFl.js +0 -2
  133. package/web/dist/assets/OperatorHudView-B9qwc3w3.js +0 -2
  134. package/web/dist/assets/Personal-DyR72AF-.js +0 -1
  135. package/web/dist/assets/Tabs-ChCr_HqV.js +0 -1
  136. package/web/dist/assets/activity-DvPxoS4Y.js +0 -1
  137. package/web/dist/assets/index-B32EvfDT.js +0 -2
  138. package/web/dist/assets/sections-BET3c1WT.js +0 -1
  139. package/web/dist/assets/usePoll-NSTzLOF4.js +0 -1
@@ -41,7 +41,8 @@
41
41
  * a cell could not have varied the answers at all.
42
42
  */
43
43
  import { spawnSync } from 'node:child_process';
44
- import { existsSync } from 'node:fs';
44
+ import { existsSync, readdirSync } from 'node:fs';
45
+ import os from 'node:os';
45
46
  import path from 'node:path';
46
47
 
47
48
  import { main as driverMain, resolveOut } from '../build/driver.mjs';
@@ -228,9 +229,11 @@ function askDoctrines(deployed = null) {
228
229
  }
229
230
 
230
231
  /**
231
- * The Kits question (spec A5): one pick, a kit or `none`, defaulting to what the install has on
232
- * (`deployed`, its `Active kits:` marker). Asked ONLY when this checkout ships a kit, so a kit-less
233
- * wizard reads exactly the stdin it always did (and has no kit to carry: `kitsOfDir` drops an unshipped one).
232
+ * The Kits questions (spec A5): one yes/no per shipped kit, each defaulting to whether the install
233
+ * has it on (`deployed`, its `Active kits:` marker), so holding Enter keeps the installed set. A
234
+ * single pick could not hold two kits at once, which is why the menu became per kit when the second
235
+ * kit shipped. Asked ONLY when this checkout ships a kit, so a kit-less wizard reads exactly the
236
+ * stdin it always did (and has no kit to carry: `kitsOfDir` drops an unshipped one).
234
237
  * Returns the kit list, or null for none (`setupBuildArgs` omits `--kits`).
235
238
  */
236
239
  export function askKits(deployed = null, kits = null) {
@@ -239,14 +242,9 @@ export function askKits(deployed = null, kits = null) {
239
242
  return null;
240
243
  }
241
244
  const on = (deployed ?? []).filter((k) => kits.has(k));
242
- if (!kits.size) return null;
243
- const opts = [...kits].map(([k, m]) => [k, m.description ?? '']);
244
- // shortcut: one pick per run, so several deployed kits are offered as one "keep" option; ask
245
- // per kit once a second kit ships.
246
- if (on.length > 1) opts.push([on.join(','), 'keep the installed kits']);
247
- opts.push(['none', 'no kit']);
248
- const pick = askChoice('Kits', opts, on.length ? on.join(',') : 'none');
249
- return pick === 'none' ? null : pick.split(',');
245
+ const picked = [...kits].filter(([k, m]) => askChoice(`Kit ${k} — ${m.description ?? k}`,
246
+ [['yes', `install the ${k} kit`], ['no', `leave ${k} out`]], on.includes(k) ? 'yes' : 'no') === 'yes');
247
+ return picked.length ? picked.map(([k]) => k) : null;
250
248
  }
251
249
 
252
250
  /**
@@ -521,8 +519,8 @@ export function setupLines() {
521
519
  doctrines = null, excludeRules = null, excludeSkills = null, gates = {}, kits = null,
522
520
  } = sel;
523
521
  if (emit === 'opencode-global') {
524
- // The build below overwrites the deployed global harness, and the self-improvement loops
525
- // may have edited it in place. Preserve that drift first.
522
+ // The build below overwrites the deployed global harness, which may carry hand edits (or
523
+ // in-place edits from a harness older than the notebook notes). Preserve that drift first.
526
524
  try {
527
525
  const [ipath] = exportImprovements();
528
526
  if (ipath) {
@@ -575,26 +573,83 @@ export function cmdSetup() {
575
573
  }
576
574
 
577
575
  /**
578
- * The AI coding tools Geneseed installs into, as `[label, present, url]` rows in HOSTS order.
576
+ * The AI coding tools Geneseed installs into, as `[label, present, url, where]` rows in HOSTS
577
+ * order. `where` is the path found OFF the PATH (see `userInstall`), else null.
579
578
  *
580
- * PRESENT MEANS THE COMMAND IS ON PATH, and nothing else. The config dir looked like a second
581
- * signal — a desktop-only install has one and no command — but GENESEED ITSELF CREATES IT: a
582
- * `bob-global` emit writes `~/.bob` on a machine that never had Bob, and the first run of this
583
- * check reported Bob present on exactly such a machine. A wrong "ok" sends the user to an
584
- * install mode for a tool they cannot open; a wrong "not found" costs one line they can ignore.
579
+ * PRESENT MEANS THE TOOL ITSELF WAS FOUND — its command on PATH, or its program in a per-user
580
+ * install location — and never its config dir. That dir looked like a signal — a desktop-only
581
+ * install has one and no command — but GENESEED ITSELF CREATES IT: a `bob-global` emit writes
582
+ * `~/.bob` on a machine that never had Bob, and the first run of this check reported Bob present
583
+ * on exactly such a machine. A wrong "ok" sends the user to an install mode for a tool they
584
+ * cannot open; a wrong "not found" costs a line, and `toolsReport` lets them name the path.
585
585
  *
586
586
  * NO SPAWN, deliberately. `which` walks PATH by hand, so asking costs nothing on the CLI's
587
587
  * child_process allow-list — a `claude --version` probe would have been a third entry there to
588
- * learn nothing `which` does not already say. `lookup` is the seam for the test, which cannot
589
- * rely on what this machine happens to have installed.
588
+ * learn nothing `which` does not already say. `lookup` and `userLookup` are the seams for the
589
+ * test, which cannot rely on what this machine happens to have installed.
590
590
  */
591
- export function hostTools({ lookup = which } = {}) {
591
+ export function hostTools({ lookup = which, userLookup = userInstall } = {}) {
592
592
  return [
593
593
  ['OpenCode', 'opencode', 'https://opencode.ai/docs/'],
594
594
  ['Claude Code', 'claude', 'https://code.claude.com/docs/en/setup'],
595
595
  ['IBM Bob', 'bob', 'https://bob.ibm.com/'],
596
596
  ['OpenClaude', 'openclaude', 'https://openclaude.gitlawb.com/'],
597
- ].map(([label, cmd, url]) => [label, Boolean(lookup(cmd)), url]);
597
+ ].map(([label, cmd, url]) => {
598
+ if (lookup(cmd)) return [label, true, url, null];
599
+ const where = userLookup(cmd);
600
+ return [label, Boolean(where), url, where || null];
601
+ });
602
+ }
603
+
604
+ /**
605
+ * Per-host off-PATH places: a bin dir a tool's own installer uses, and a name pattern for the
606
+ * desktop app folder. Only Bob ships as a desktop IDE; matching `claude` against app folders
607
+ * would find the Claude desktop chat app, which is not Claude Code.
608
+ */
609
+ const USER_BIN_DIRS = {
610
+ opencode: ['.opencode/bin'],
611
+ claude: ['.claude/local'],
612
+ bob: [],
613
+ openclaude: [],
614
+ };
615
+ const APP_PATTERN = { bob: /\bbob\b/i };
616
+
617
+ /**
618
+ * Where a tool installed FOR ONE USER lives when its installer did not put it on PATH — the
619
+ * report of 2026-10 was IBM Bob installed under the user's own profile, which `which` never sees.
620
+ *
621
+ * Three kinds of place, each one the tool's own program, never its config dir:
622
+ * * a per-user bin dir: `~/.local/bin` and `~/bin` for every tool, plus the dir its own
623
+ * installer uses (`USER_BIN_DIRS`) — an executable file, through `which` and its PATHEXT;
624
+ * * on Windows, `%APPDATA%\npm` (a per-user `npm i -g`) and a desktop app folder under
625
+ * `%LOCALAPPDATA%\Programs` whose name matches `APP_PATTERN`;
626
+ * * on macOS, a matching `.app` in `/Applications` or `~/Applications`.
627
+ * The first hit wins. Everything a machine could differ on is a parameter, for the test.
628
+ */
629
+ export function userInstall(cmd, {
630
+ home = os.homedir(), env = process.env, platform = process.platform,
631
+ find = which, list = listDir,
632
+ } = {}) {
633
+ const bins = ['.local/bin', 'bin', ...(USER_BIN_DIRS[cmd] ?? [])].map((d) => path.join(home, d));
634
+ if (platform === 'win32' && env.APPDATA) bins.push(path.join(env.APPDATA, 'npm'));
635
+ const hit = find(cmd, bins.join(path.delimiter));
636
+ if (hit) return hit;
637
+ const pattern = APP_PATTERN[cmd];
638
+ if (!pattern) return null;
639
+ const appDirs = platform === 'win32'
640
+ ? (env.LOCALAPPDATA ? [path.join(env.LOCALAPPDATA, 'Programs')] : [])
641
+ : platform === 'darwin' ? ['/Applications', path.join(home, 'Applications')] : [];
642
+ for (const dir of appDirs) {
643
+ const name = list(dir).find((n) => pattern.test(n)
644
+ && (platform !== 'darwin' || n.endsWith('.app')));
645
+ if (name) return path.join(dir, name);
646
+ }
647
+ return null;
648
+ }
649
+
650
+ /** `readdirSync`, with a missing or unreadable dir as no entries. */
651
+ function listDir(dir) {
652
+ try { return readdirSync(dir); } catch { return []; }
598
653
  }
599
654
 
600
655
  /**
@@ -602,16 +657,25 @@ export function hostTools({ lookup = which } = {}) {
602
657
  * answer knowing it — and when NONE is, ask whether to stop and install one first. It never
603
658
  * installs one: on a managed machine that is IT's call, and each tool has its own installer.
604
659
  * A missing tool among several present is a line, not a question; nobody needs all four.
660
+ * When none is found, the user can name where theirs is before being asked to stop — the
661
+ * search covers PATH and the usual per-user places, not every folder an installer may pick.
605
662
  * `false` is the user choosing to stop.
606
663
  */
607
664
  export function toolsReport(tools = hostTools()) {
608
665
  printOut('AI coding tools on this machine:\n');
609
- for (const [label, present, url] of tools) {
610
- printOut(present ? ` ok ${label}\n` : ` -- ${label} not on PATH — install: ${url}\n`);
666
+ for (const [label, present, url, where] of tools) {
667
+ if (!present) printOut(` -- ${label} not found — install: ${url}\n`);
668
+ else printOut(where ? ` ok ${label} (${where}, not on PATH)\n` : ` ok ${label}\n`);
611
669
  }
612
670
  if (tools.some(([, present]) => present)) { printOut('\n'); return true; }
613
- printOut('\nNone of them is installed. Install the one you use (links above), then run setup '
614
- + 'again — or go on and pick the plain bundle, which any AGENT.md tool can read.\n');
671
+ printOut('\nNone of them was found on PATH or in the usual per-user folders.\n');
672
+ // Quotes stripped: a path dragged into a Windows console arrives wrapped in them.
673
+ const given = ask('Installed somewhere else? Path to the tool (Enter to skip)')
674
+ .replace(/^"(.*)"$/, '$1');
675
+ if (given && existsSync(given)) { printOut(` ok ${given}\n\n`); return true; }
676
+ if (given) printOut(` -- nothing at ${given}\n`);
677
+ printOut('Install the one you use (links above), then run setup again — or go on and pick the '
678
+ + 'plain bundle, which any AGENT.md tool can read.\n');
615
679
  if (confirm('Continue without one?', false)) { printOut('\n'); return true; }
616
680
  printOut('Stopped. Run setup again once a tool is installed.\n');
617
681
  return false;
@@ -796,8 +796,8 @@ export async function upgrade(ref = null, themeArg = null) {
796
796
  * `_harness_lifecycle.cmd_upgrade` — the harness verb, which is `export_improvements()` and
797
797
  * then `upgrade()`.
798
798
  *
799
- * THE EXPORT RUNS FIRST AND MUST NEVER BLOCK. The self-improvement loops edit the deployed
800
- * harness in place and the rebuild is about to overwrite those edits, so the drift has to be
799
+ * THE EXPORT RUNS FIRST AND MUST NEVER BLOCK. A deployed harness can carry in-place edits (by
800
+ * hand, or by a harness older than the notebook notes) and the rebuild is about to overwrite them, so the drift has to be
801
801
  * captured against the PRE-refresh source. Anything it throws is a warning on stderr and the
802
802
  * upgrade continues — a failed export is a lost note, a blocked upgrade is a broken install.
803
803
  */
@@ -475,6 +475,14 @@ export function buildActionCmds(state, body) {
475
475
  const [, , ...rest] = installProfile(host, scope, state.root).argv;
476
476
  return actionCommands('build', { buildArgv: ['--theme', theme, ...rest] });
477
477
  }
478
+ // Only a GLOBAL emit has a home of its own. Any other carries no folder on this route, so the
479
+ // generator fell back to its default `./Harness` — and jobs run with the Geneseed checkout as
480
+ // cwd, so a Bob "project" install landed inside the clone (reported 2026-10), where the doctor's
481
+ // bundle check then read it as 178 missing files. The `deploy` action takes a chosen folder.
482
+ if (!emit.endsWith('-global')) {
483
+ return { error: `${emit} installs into a folder you choose — use Deploy with a folder, `
484
+ + 'not Build' };
485
+ }
478
486
  return actionCommands('build', {
479
487
  theme, emit, footprint: state.footprint, posture: state.posture, mode: state.mode,
480
488
  trust: state.trust,
package/js/web/api.mjs CHANGED
@@ -8,21 +8,21 @@
8
8
  * bind paths to these functions are `routes.mjs`. This file imports none of its route siblings,
9
9
  * which is what keeps the old `api ↔ actions` and `api ↔ activity` cycles from coming back.
10
10
  */
11
- import { statSync } from 'node:fs';
11
+ import { readdirSync, statSync } from 'node:fs';
12
12
  import path from 'node:path';
13
13
 
14
- import { CONFIG, ROOT, THEMES, discoverNames, loadKits, makeCfg } from '../build/source.mjs';
14
+ import { CONFIG, CREDIT_RE, ROOT, SRC, THEMES, discoverNames, loadKits, makeCfg, upstreamOf } from '../build/source.mjs';
15
15
  import { readVersion, sourceFingerprint } from '../build/version.mjs';
16
16
  import { sourceReleaseVersion } from '../hosts/opencode.mjs';
17
17
  import { alwaysOnCost } from '../inspect/cost.mjs';
18
18
  import { installProfile } from '../build/generate.mjs';
19
19
  import { diffCollect, renderMemo } from '../inspect/diff.mjs';
20
20
  import { excludesSnapshot } from '../inspect/excludes.mjs';
21
- import { CLAUDE_STYLE, HOSTS, VERSION_MARKER, resolvePath } from '../hosts/hosts.mjs';
21
+ import { VERSION_MARKER, resolvePath } from '../hosts/hosts.mjs';
22
22
  import {
23
23
  footprintOfDir, gatesOfDir, installState, installTargets, legacyProcessCarrierOfDir, modeOfDir,
24
24
  trustOfDir,
25
- postureOfDir, readJsonMaybe, themeOfDir,
25
+ postureOfDir, readJsonMaybe, readMaybe, themeOfDir,
26
26
  } from '../hosts/installs.mjs';
27
27
  import { PRESETS } from '../loop/score.mjs';
28
28
  import { loadCatalog, catalogProblems } from '../loop/catalog.mjs';
@@ -34,7 +34,7 @@ import { comparePaths, normcase } from '../lib/paths.mjs';
34
34
  import {
35
35
  configItems, deployed, dget, memoryItems, notebookItems, wikiItems,
36
36
  } from './catalog.mjs';
37
- import { stampMinute } from './state.mjs';
37
+ import { stampMinute, viewCfg } from './state.mjs';
38
38
  import { updateStatus } from './update-check.mjs';
39
39
 
40
40
  /**
@@ -108,16 +108,9 @@ export function apiDiff(state) {
108
108
  /** The union across every global install, verbatim. */
109
109
  export const apiExcludes = () => excludesSnapshot();
110
110
 
111
- /**
112
- * The data dir an install's inventory/memory/diff is read from. Host-driven, so a new
113
- * nested-marker host cannot silently read the bare root.
114
- */
115
- export function viewCfg(host, scope, root) {
116
- if (scope === 'project' && CLAUDE_STYLE.includes(host)) {
117
- return path.join(root, HOSTS.find((h) => h.host === host).projectMarker);
118
- }
119
- return root;
120
- }
111
+ // `viewCfg` moved to `state.mjs`, which needs it to pick a default view and cannot import
112
+ // this module back (this one imports `stampMinute` from it).
113
+ export { viewCfg } from './state.mjs';
121
114
 
122
115
  const DAY_MS = 24 * 60 * 60 * 1000;
123
116
 
@@ -233,6 +226,31 @@ export function apiFleet(state) {
233
226
  return { installs, source_fp: sourceFp, doctor: state.doctor };
234
227
  }
235
228
 
229
+ /** Source files of a kit member: `<dir>/<name>.md`, or every `.md` in a `<dir>/<name>/` folder. */
230
+ function memberTexts(dir, name) {
231
+ const one = readMaybe(path.join(SRC, dir, `${name}.md`));
232
+ if (one !== null && one !== undefined) return [one];
233
+ const d = path.join(SRC, dir, name);
234
+ try { return readdirSync(d).filter((f) => f.endsWith('.md')).map((f) => readMaybe(path.join(d, f)) ?? ''); } catch { return []; }
235
+ }
236
+
237
+ /** Upstream credits cited by a kit's rules and member sources; an unknown key is skipped, never a 500. */
238
+ function kitCredits(m, rules) {
239
+ const texts = [rules, ...(m.skills ?? []).flatMap((n) => memberTexts('skills', n)),
240
+ ...(m.agents ?? []).flatMap((n) => memberTexts('agents', n))];
241
+ const keys = new Set();
242
+ for (const t of texts) for (const x of t.matchAll(CREDIT_RE)) keys.add(x[1]);
243
+ const out = [];
244
+ for (const key of [...keys].sort()) {
245
+ let u;
246
+ try { u = upstreamOf(key); } catch { u = null; }
247
+ if (!u) continue;
248
+ out.push({ key, repo: u.repo, author: u.author, license: u.license, commit: u.commit,
249
+ compareUrl: `https://github.com/${u.repo}/compare/${u.commit}...HEAD` });
250
+ }
251
+ return out;
252
+ }
253
+
236
254
  /**
237
255
  * EVERY INSTALL'S KITS — the Kits tab. Its own route, like `apiFleet`: it re-reads each carrier
238
256
  * and the kit manifests, which the sidebar's `/api/installs` refetch must not pay for.
@@ -247,8 +265,12 @@ export function apiKits() {
247
265
  try { known = loadKits(); } catch (e) {
248
266
  return { kits: [], installs: [], error: String(e?.message ?? e) };
249
267
  }
250
- const kits = [...known].map(([name, m]) => ({
251
- name, description: m.description ?? '', skills: m.skills ?? [], agents: m.agents ?? [] }));
268
+ const kits = [...known].map(([name, m]) => {
269
+ const rules = readMaybe(path.join(SRC, 'kits', name, 'rules.md')) ?? '';
270
+ return { name, description: m.description ?? '', skills: m.skills ?? [], agents: m.agents ?? [],
271
+ rules, rulesWordBudget: m.rulesWordBudget ?? null, banned: m.banned ?? [],
272
+ credits: kitCredits(m, rules) };
273
+ });
252
274
  const installs = [];
253
275
  for (const [host, scope, root] of installTargets()) {
254
276
  const st = installState(root, host, scope);
@@ -24,6 +24,7 @@ import { readText, withDiscardableStderr, isDir, isFile } from '../lib/fs.mjs';
24
24
  import { comparePaths, normcase, within } from '../lib/paths.mjs';
25
25
  import { stripWhitespace } from '../lib/text.mjs';
26
26
  import { readJsonc } from '../hosts/settings.mjs';
27
+ import { loadKits } from '../build/source.mjs';
27
28
 
28
29
  /** A requested catalog section or item that does not exist. */
29
30
  export class NotFound extends Error {}
@@ -466,16 +467,29 @@ function constitutionItems(inv) {
466
467
  ];
467
468
  }
468
469
 
470
+ /** `Map<'skills/x' | 'agents/y', kit>` from the shipped manifests; a broken manifest means no badges, not a 500. */
471
+ function kitOwners() {
472
+ const owner = new Map();
473
+ try {
474
+ for (const [kit, m] of loadKits()) {
475
+ for (const n of m.skills ?? []) owner.set(`skill:${n}`, kit);
476
+ for (const n of m.agents ?? []) owner.set(`agent:${n}`, kit);
477
+ }
478
+ } catch { /* shortcut: unreadable manifests hide the badge; apiKits reports the error */ }
479
+ return owner;
480
+ }
481
+
469
482
  export function apiCatalog(state, section) {
470
483
  if (!SECTIONS.includes(section)) throw new NotFound(section);
471
484
  const inv = state.inventory;
472
485
  let items;
486
+ const owner = kitOwners();
473
487
  if (section === 'agents') {
474
488
  items = inv.agents.map((e) => ({ name: e.name, title: e.name, desc: e.desc,
475
- source: e.source ?? null, status: e.status ?? 'unknown' }));
489
+ source: e.source ?? null, status: e.status ?? 'unknown', kit: owner.get(`agent:${e.name}`) ?? null }));
476
490
  } else if (section === 'skills') {
477
491
  items = inv.skills.map((e) => ({ name: e.name, title: e.name, desc: e.desc,
478
- source: e.source ?? null, klass: e.klass ?? 'build', status: e.status ?? 'unknown' }));
492
+ source: e.source ?? null, klass: e.klass ?? 'build', status: e.status ?? 'unknown', kit: owner.get(`skill:${e.name}`) ?? null }));
479
493
  } else if (section === 'laws') {
480
494
  items = constitutionItems(inv);
481
495
  } else if (section === 'memory') {
@@ -499,6 +513,7 @@ export function apiItem(state, type, name) {
499
513
  links: resolveLinks(state, e.body), source: e.source ?? null };
500
514
  if (type === 'skill') out.klass = e.klass ?? 'build';
501
515
  out.status = e.status ?? 'unknown';
516
+ out.kit = kitOwners().get(`${type}:${name}`) ?? null;
502
517
  return out;
503
518
  }
504
519
  if (type === 'law') {
@@ -303,6 +303,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
303
303
  // Build resolves its argv off the install (`buildActionCmds`); the other actions take no
304
304
  // axes and self-resolve the deployed theme downstream.
305
305
  const cmds = action === 'build' ? buildActionCmds(state, body) : actionCommands(action);
306
+ if (cmds && Object.hasOwn(cmds, 'error')) return sendJson(res, cmds, 400, ae);
306
307
  if (!isTruthy(cmds)) {
307
308
  return sendJson(res, { error: `unknown action ${action}` }, 404, ae);
308
309
  }
package/js/web/server.mjs CHANGED
@@ -27,7 +27,7 @@ import { isFile, printOut } from '../lib/fs.mjs';
27
27
  import { which } from '../lib/paths.mjs';
28
28
  import { parseIntStrict } from '../lib/text.mjs';
29
29
  import { promptLine } from '../lib/prompt.mjs';
30
- import { warmDoctor, webState } from './state.mjs';
30
+ import { fallbackView, warmDoctor, webState } from './state.mjs';
31
31
  import { clearDaemon, codeStamp, openUrl, readDaemon, restartDaemon, startDaemon, statusDaemon, stopDaemon, writeDaemon } from './daemon.mjs';
32
32
  import { makeHandler } from './handler.mjs';
33
33
  import { JobManager } from './jobs.mjs';
@@ -132,6 +132,12 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
132
132
  }
133
133
  }
134
134
  const state = webState(theme);
135
+ // The daemon record and the job file stay at the DEFAULT target, captured before the view
136
+ // moves: `geneseed web stop`/`status` find them through `webState(theme).target`, which never
137
+ // moves, and a view that followed a Bob-only install into ~/.bob would hide the record.
138
+ const home = state.target;
139
+ const fallback = fallbackView(home);
140
+ if (fallback) state.selectView(...fallback);
135
141
  if (!existsSync(join(state.target, GLOBAL_MANIFEST))) {
136
142
  printOut(`[web] no deployed harness at ${state.target}.\n`);
137
143
  printOut(' Run `geneseed setup` first — serving anyway (read-only UI).\n');
@@ -143,7 +149,7 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
143
149
  const holder = {};
144
150
  // The console's job list survives a reload and a restart because it is a FILE on disk,
145
151
  // not held in process memory.
146
- const jm = new JobManager(join(state.target, '.geneseed-web-runs.json'));
152
+ const jm = new JobManager(join(home, '.geneseed-web-runs.json'));
147
153
  const srv = createServer(makeHandler(state, jm, token, dist, holder));
148
154
  holder.srv = srv;
149
155
  return new Promise((done) => {
@@ -157,7 +163,7 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
157
163
  const hostPort = /** @type {import('node:net').AddressInfo} */ (srv.address()).port;
158
164
  const url = `http://127.0.0.1:${hostPort}`;
159
165
  if (daemon) {
160
- writeDaemon(state.target, {
166
+ writeDaemon(home, {
161
167
  pid: process.pid, port: hostPort, url,
162
168
  token, theme: state.theme, started: Math.floor(Date.now() / 1000), code: startCode,
163
169
  });
@@ -177,8 +183,8 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
177
183
  });
178
184
  srv.on('close', () => {
179
185
  if (daemon) {
180
- const st = readDaemon(state.target);
181
- if (!st || st.pid === process.pid) clearDaemon(state.target);
186
+ const st = readDaemon(home);
187
+ if (!st || st.pid === process.pid) clearDaemon(home);
182
188
  }
183
189
  done(0);
184
190
  });
package/js/web/state.mjs CHANGED
@@ -8,10 +8,10 @@
8
8
  import { existsSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
 
11
- import { opencodeConfigDir } from '../hosts/hosts.mjs';
11
+ import { CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, opencodeConfigDir } from '../hosts/hosts.mjs';
12
12
  import {
13
- EMIT_HOST_SCOPE, footprintOfDir, installedDefaults, modeOfDir, postureOfDir, themeOfDir,
14
- trustOfDir,
13
+ EMIT_HOST_SCOPE, footprintOfDir, installState, installTargets, installedDefaults, modeOfDir,
14
+ postureOfDir, themeOfDir, trustOfDir,
15
15
  } from '../hosts/installs.mjs';
16
16
  import { doctorCollect } from '../inspect/doctor.mjs';
17
17
  import { DEFAULT_PRESET } from '../loop/score.mjs';
@@ -19,6 +19,36 @@ import { readText, isFile } from '../lib/fs.mjs';
19
19
  import { stripWhitespace } from '../lib/text.mjs';
20
20
  import { inventoryFor } from './catalog.mjs';
21
21
 
22
+ /**
23
+ * The data dir an install's inventory/memory/diff is read from. Host-driven, so a new
24
+ * nested-marker host cannot silently read the bare root.
25
+ */
26
+ export function viewCfg(host, scope, root) {
27
+ if (scope === 'project' && CLAUDE_STYLE.includes(host)) {
28
+ return path.join(root, HOSTS.find((h) => h.host === host).projectMarker);
29
+ }
30
+ return root;
31
+ }
32
+
33
+ /**
34
+ * `[target, root]` for the install the console should show when nothing picked one, or null
35
+ * when the current target is already deployed or nothing is.
36
+ *
37
+ * The console used to open on the OpenCode global dir unconditionally. On a machine with only
38
+ * IBM Bob (reported 2026-10) nothing is deployed there, so Overview showed the onboarding form
39
+ * over a perfectly good `~/.bob`, and the selector that could re-point it hides itself below two
40
+ * installs. So when `current` holds no manifest, the first ACTIVE install `installTargets`
41
+ * finds is shown instead — a global before a project, in its host order.
42
+ */
43
+ export function fallbackView(current, targets = installTargets()) {
44
+ if (existsSync(path.join(current, GLOBAL_MANIFEST))) return null;
45
+ const active = targets.filter(([host, scope, root]) => {
46
+ try { return installState(root, host, scope) === 'active'; } catch { return false; }
47
+ });
48
+ const pick = active.find(([, scope]) => scope === 'global') || active[0];
49
+ return pick ? [viewCfg(...pick), pick[2]] : null;
50
+ }
51
+
22
52
  /**
23
53
  * `YYYY-MM-DD HH:MM`, LOCAL time — `toISOString` would be UTC, which reads wrong in every
24
54
  * timezone but one for a user-facing timestamp.
@@ -154,6 +184,11 @@ export function webState(theme = null, target = null) {
154
184
  st._doctor = null;
155
185
  };
156
186
  st.refresh = ({ doctor = true } = {}) => {
187
+ // A job may have deployed the first install while the view still points at an empty
188
+ // default (the onboarding Build), or removed the one in view: follow it to one that exists.
189
+ // Only on a full refresh: `{ doctor: false }` marks a mutation that cannot change installs.
190
+ const fallback = doctor ? fallbackView(st.target) : null;
191
+ if (fallback) { st.selectView(...fallback); return; }
157
192
  st._inv = null;
158
193
  if (doctor) st._doctor = null;
159
194
  st.emit = st.detectEmit() || st.emit;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
3
3
  "name": "geneseed",
4
- "version": "4.10.0",
4
+ "version": "4.14.0",
5
5
  "description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
6
6
  "keywords": [
7
7
  "ai",
package/registry.json CHANGED
@@ -99,6 +99,20 @@
99
99
  "added": "2026-06-08",
100
100
  "last_verified": ""
101
101
  },
102
+ "agents/react-engineer": {
103
+ "status": "experimental",
104
+ "version": "1.0.0",
105
+ "owner": "Guillaume Lemer",
106
+ "added": "2026-10-21",
107
+ "last_verified": ""
108
+ },
109
+ "agents/react-reviewer": {
110
+ "status": "experimental",
111
+ "version": "1.0.0",
112
+ "owner": "Guillaume Lemer",
113
+ "added": "2026-10-21",
114
+ "last_verified": ""
115
+ },
102
116
  "agents/researcher": {
103
117
  "status": "approved",
104
118
  "version": "1.0.0",
@@ -400,6 +414,20 @@
400
414
  "added": "2026-09-06",
401
415
  "last_verified": ""
402
416
  },
417
+ "skills/react": {
418
+ "status": "experimental",
419
+ "version": "1.0.0",
420
+ "owner": "Guillaume Lemer",
421
+ "added": "2026-10-21",
422
+ "last_verified": ""
423
+ },
424
+ "skills/react-testing": {
425
+ "status": "experimental",
426
+ "version": "1.0.0",
427
+ "owner": "Guillaume Lemer",
428
+ "added": "2026-10-21",
429
+ "last_verified": ""
430
+ },
403
431
  "skills/react-view-transitions": {
404
432
  "status": "approved",
405
433
  "version": "1.0.0",
package/src/AGENT.md.tmpl CHANGED
@@ -187,9 +187,9 @@ contract you do not already know.
187
187
  **Rule of delegation:** read-only investigation can be dispatched freely; any
188
188
  {{AGENT}} that *writes* must return a summary of exactly what it changed. Every
189
189
  {{AGENT}} spec ends with a **self-improvement loop**: when a report carries a
190
- `spec-feedback:` line, weigh it — fold a real flaw back into that {{AGENT}}'s
191
- file (with the user's assent); {{MEMORY}} gets a record only when it clears
192
- {{DOCTRINE:persist-insight}}'s bar.
190
+ `spec-feedback:` line, weigh it — record a real flaw (with the user's assent) in
191
+ `{{DIR_NOTEBOOK}}/agent-notes/<name>.md` and pass it to later dispatches;
192
+ {{MEMORY}} gets a record only when it clears {{DOCTRINE:persist-insight}}'s bar.
193
193
 
194
194
  <!-- LEAN:begin -->
195
195
  **Dispatching a multi-part goal.** When a goal spans several capabilities,
@@ -293,8 +293,8 @@ When a task repeats and no {{SKILL}} covers it, forge one through the
293
293
  [skill-forge]({{DIR_SKILLS}}/skill-forge.md) {{SKILL}}: copy
294
294
  [`{{DIR_SKILLS}}/_template.md`]({{DIR_SKILLS}}/_template.md), keep it to one coherent
295
295
  domain ({{DOCTRINE:automate-repetition}}), and list it wherever this file carries a catalogue. Every {{SKILL}} ends with a
296
- **self-improvement loop** — honour it: fold what a run teaches back into the
297
- file, or into {{MEMORY}} ({{DOCTRINE:persist-insight}}).
296
+ **self-improvement loop** — honour it: a lesson goes to its notes, never into
297
+ the file, or to {{MEMORY}} ({{DOCTRINE:persist-insight}}).
298
298
 
299
299
  ---
300
300
 
@@ -0,0 +1,11 @@
1
+ ## Self-improvement
2
+
3
+ If this spec misled you — an input you needed but were not given, a boundary
4
+ that proved wrong, a step you could not execute — end your report with one line:
5
+ `spec-feedback: [agent|general] <the one-line fix> — seen: <what happened, against
6
+ what this spec led you to expect>`. `general` marks a fix other {{AGENTS}} need too.
7
+ Omit the line when there is no friction. The caller weighs the feedback and, with the
8
+ user's assent, writes a real flaw, dated, into `{{DIR_NOTEBOOK}}/agent-notes/<name>.md`
9
+ — never into this rendered file, which a rebuild overwrites — and hands those notes
10
+ to every later dispatch of this {{AGENT}}. It records the feedback to {{MEMORY}} only if it clears
11
+ {{DOCTRINE:persist-insight}}'s bar — most reports carry no feedback at all.
@@ -67,11 +67,4 @@ tells pipeline orchestration who to recruit; it changes nothing about how this
67
67
  - **Delivers:** what it must hand the next seat or the parent — same shape as
68
68
  its own output contract above, nothing looser.
69
69
 
70
- ## Self-improvement
71
-
72
- If this spec misled you — an input you needed but were not given, a boundary
73
- that proved wrong, a step you could not execute — end your report with one line:
74
- `spec-feedback: <what failed — the one-line fix>`. Omit it when there is no
75
- friction. The caller weighs the feedback, folds a real flaw back into this file
76
- with the user's assent, and records it to {{MEMORY}} only if it clears
77
- {{DOCTRINE:persist-insight}}'s bar — most reports carry no feedback at all.
70
+ <!-- INCLUDE: agents/_self-improvement.md -->
@@ -26,11 +26,4 @@
26
26
  - A tight brief: the position in one line, the 2–4 load-bearing arguments for it, the key supporting evidence (`file:line` or facts), and the one objection it must beat. No hedging, no filler.
27
27
  - If the motion has fewer than two defensible upsides, report that the case cannot honestly be made — an invented argument loses the council more than a conceded seat.
28
28
 
29
- ## Self-improvement
30
-
31
- If this spec misled you — an input you needed but were not given, a boundary
32
- that proved wrong, a step you could not execute — end your report with one line:
33
- `spec-feedback: <what failed — the one-line fix>`. Omit it when there is no
34
- friction. The caller weighs the feedback, folds a real flaw back into this file
35
- with the user's assent, and records it to {{MEMORY}} only if it clears
36
- {{DOCTRINE:persist-insight}}'s bar — most reports carry no feedback at all.
29
+ <!-- INCLUDE: agents/_self-improvement.md -->
@@ -48,11 +48,4 @@ independently.)*
48
48
  - **Delivers:** the plan for the developer seat to implement, same shape as its
49
49
  own output contract above.
50
50
 
51
- ## Self-improvement
52
-
53
- If this spec misled you — an input you needed but were not given, a boundary
54
- that proved wrong, a step you could not execute — end your report with one line:
55
- `spec-feedback: <what failed — the one-line fix>`. Omit it when there is no
56
- friction. The caller weighs the feedback, folds a real flaw back into this file
57
- with the user's assent, and records it to {{MEMORY}} only if it clears
58
- {{DOCTRINE:persist-insight}}'s bar — most reports carry no feedback at all.
51
+ <!-- INCLUDE: agents/_self-improvement.md -->
@@ -56,11 +56,4 @@ independently.)*
56
56
  - **Delivers:** the changed files and a one-line summary, handed to the tester
57
57
  seat for validation.
58
58
 
59
- ## Self-improvement
60
-
61
- If this spec misled you — an input you needed but were not given, a boundary
62
- that proved wrong, a step you could not execute — end your report with one line:
63
- `spec-feedback: <what failed — the one-line fix>`. Omit it when there is no
64
- friction. The caller weighs the feedback, folds a real flaw back into this file
65
- with the user's assent, and records it to {{MEMORY}} only if it clears
66
- {{DOCTRINE:persist-insight}}'s bar — most reports carry no feedback at all.
59
+ <!-- INCLUDE: agents/_self-improvement.md -->