geneseed 4.10.0 → 4.15.1

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 (147) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/README.md +5 -4
  3. package/SHIPPED.md +1 -1
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -1
  5. package/adapters/opencode/plugins/geneseed-learn.js +28 -12
  6. package/adapters/opencode/plugins/geneseed-workflow.js +19 -2
  7. package/docs/concepts/kits.md +14 -2
  8. package/docs/concepts/skills.md +1 -1
  9. package/docs/extending.md +3 -4
  10. package/docs/guides/install-bob.md +3 -1
  11. package/docs/guides/run-anywhere.md +2 -2
  12. package/docs/guides/upgrade.md +2 -2
  13. package/docs/reference/troubleshoot-install.md +1 -1
  14. package/docs/web-ui.md +6 -1
  15. package/harness.config.json +1 -1
  16. package/js/build/emit-claude.mjs +4 -2
  17. package/js/build/stubs.mjs +13 -0
  18. package/js/hosts/link.mjs +88 -1
  19. package/js/inspect/checks-build.mjs +8 -2
  20. package/js/inspect/cost.mjs +1 -1
  21. package/js/inspect/diff.mjs +53 -7
  22. package/js/inspect/inventory.mjs +1 -0
  23. package/js/lib/prompt.mjs +19 -2
  24. package/js/maintain/setup.mjs +92 -28
  25. package/js/maintain/update.mjs +2 -2
  26. package/js/web/actions.mjs +8 -0
  27. package/js/web/api.mjs +39 -17
  28. package/js/web/catalog.mjs +17 -2
  29. package/js/web/handler.mjs +1 -0
  30. package/js/web/server.mjs +11 -5
  31. package/js/web/state.mjs +38 -3
  32. package/package.json +1 -1
  33. package/registry.json +28 -0
  34. package/src/AGENT.md.tmpl +5 -5
  35. package/src/agents/_self-improvement.md +11 -0
  36. package/src/agents/_template.md +1 -8
  37. package/src/agents/advocate.md +1 -8
  38. package/src/agents/architect.md +1 -8
  39. package/src/agents/developer.md +1 -8
  40. package/src/agents/devops-engineer.md +1 -9
  41. package/src/agents/docker-expert.md +1 -9
  42. package/src/agents/docs.md +1 -8
  43. package/src/agents/empiricist.md +1 -8
  44. package/src/agents/explorer.md +1 -8
  45. package/src/agents/framer.md +1 -8
  46. package/src/agents/historian.md +1 -8
  47. package/src/agents/kubernetes-specialist.md +1 -9
  48. package/src/agents/memory-curator.md +1 -8
  49. package/src/agents/operator.md +1 -8
  50. package/src/agents/performance.md +1 -8
  51. package/src/agents/pragmatist.md +1 -8
  52. package/src/agents/react-engineer.md +38 -0
  53. package/src/agents/react-reviewer.md +42 -0
  54. package/src/agents/researcher.md +5 -10
  55. package/src/agents/reviewer.md +1 -8
  56. package/src/agents/security.md +1 -8
  57. package/src/agents/skeptic.md +1 -8
  58. package/src/agents/spring-boot-engineer.md +1 -9
  59. package/src/agents/spring-reviewer.md +1 -9
  60. package/src/agents/spring-security.md +1 -9
  61. package/src/agents/steward.md +1 -8
  62. package/src/agents/tester.md +1 -8
  63. package/src/agents/user-advocate.md +1 -8
  64. package/src/agents/visionary.md +1 -8
  65. package/src/doctrines/comms.md +21 -0
  66. package/src/kits/react/rules.md +13 -0
  67. package/src/kits/react.json +18 -0
  68. package/src/licenses/everything-claude-code.txt +21 -0
  69. package/src/skills/_self-improvement.md +22 -5
  70. package/src/skills/brainstorm.md +7 -2
  71. package/src/skills/codebase-design.md +6 -2
  72. package/src/skills/debug.md +1 -1
  73. package/src/skills/develop.md +4 -2
  74. package/src/skills/handoff.md +1 -1
  75. package/src/skills/parallel-agents.md +3 -1
  76. package/src/skills/plan.md +5 -2
  77. package/src/skills/prototype.md +4 -2
  78. package/src/skills/react/a11y.md +33 -0
  79. package/src/skills/react/vite.md +35 -0
  80. package/src/skills/react/webpack.md +40 -0
  81. package/src/skills/react-testing.md +68 -0
  82. package/src/skills/react.md +63 -0
  83. package/src/skills/research.md +4 -2
  84. package/src/skills/rule.md +4 -2
  85. package/src/skills/ship.md +6 -4
  86. package/src/skills/skill-forge.md +10 -5
  87. package/src/skills/wayfinder.md +6 -2
  88. package/src/skills/wiki.md +18 -5
  89. package/src/upstreams.json +7 -0
  90. package/themes/_TEMPLATE.json +5 -0
  91. package/themes/biker.json +5 -0
  92. package/themes/commentator.json +5 -0
  93. package/themes/cyberpunk.json +5 -0
  94. package/themes/gamer.json +5 -0
  95. package/themes/imperial.json +5 -0
  96. package/themes/joker.json +5 -0
  97. package/themes/marvin.json +5 -0
  98. package/themes/mean.json +5 -0
  99. package/themes/military.json +5 -0
  100. package/themes/neutral.json +5 -0
  101. package/themes/pirate.json +5 -0
  102. package/themes/sports.json +5 -0
  103. package/themes/verstappen.json +5 -0
  104. package/themes/wizard.json +5 -0
  105. package/web/dist/assets/Activity-M5qj6CxJ.js +1 -0
  106. package/web/dist/assets/ActivityDetail-DVCdNy73.js +1 -0
  107. package/web/dist/assets/Dashboard-B1rpgTz-.js +2 -0
  108. package/web/dist/assets/{Docs-DLVckkew.js → Docs-D-EixVuX.js} +3 -3
  109. package/web/dist/assets/FilterInput-CZREvrjX.js +1 -0
  110. package/web/dist/assets/GreenhouseView-BvA4t12P.js +2 -0
  111. package/web/dist/assets/Installs-DP4WjTpD.js +3 -0
  112. package/web/dist/assets/JournalView-hf2eO133.js +1 -0
  113. package/web/dist/assets/KitApply-aORiqjFC.js +1 -0
  114. package/web/dist/assets/Kits-DpIEozdv.js +1 -0
  115. package/web/dist/assets/{Laws-BkKnZOBI.js → Laws-cM_4CG9i.js} +2 -2
  116. package/web/dist/assets/Library-I1zEH3UW.js +1 -0
  117. package/web/dist/assets/Loops-CDa_mxP9.js +2 -0
  118. package/web/dist/assets/{Markdown-iHvBrpRN.js → Markdown-zaXXXHz9.js} +13 -13
  119. package/web/dist/assets/OperatorHudView-CMf1DQMB.js +2 -0
  120. package/web/dist/assets/Personal-BqU9Pzt1.js +1 -0
  121. package/web/dist/assets/Tabs-JZ2FQBcz.js +1 -0
  122. package/web/dist/assets/activity-Jw8XcPiP.js +1 -0
  123. package/web/dist/assets/{drawable-CgYi0Dtg.js → drawable-svc6-2Gp.js} +1 -1
  124. package/web/dist/assets/index-CZFrNe4N.js +2 -0
  125. package/web/dist/assets/{index-BCJz4_8C.css → index-jYlWihN-.css} +1 -1
  126. package/web/dist/assets/{react-BOF6yL40.js → react-DlYQp10C.js} +1 -1
  127. package/web/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
  128. package/web/dist/assets/sections-ChwaobfW.js +1 -0
  129. package/web/dist/assets/usePoll-bDFN-Hr6.js +1 -0
  130. package/web/dist/index.html +5 -4
  131. package/web/src/pages/Laws.jsx +5 -0
  132. package/web/dist/assets/Activity-BjyJaf8e.js +0 -1
  133. package/web/dist/assets/ActivityDetail-Bo9nRDNV.js +0 -1
  134. package/web/dist/assets/Dashboard-BQJGU7vE.js +0 -2
  135. package/web/dist/assets/FilterInput-zLFXoSt6.js +0 -1
  136. package/web/dist/assets/GreenhouseView--DML7CW3.js +0 -2
  137. package/web/dist/assets/Installs-CxCiln1F.js +0 -3
  138. package/web/dist/assets/JournalView-ymgZleHi.js +0 -1
  139. package/web/dist/assets/Library-C8ercIZv.js +0 -1
  140. package/web/dist/assets/Loops-C95zhbFl.js +0 -2
  141. package/web/dist/assets/OperatorHudView-B9qwc3w3.js +0 -2
  142. package/web/dist/assets/Personal-DyR72AF-.js +0 -1
  143. package/web/dist/assets/Tabs-ChCr_HqV.js +0 -1
  144. package/web/dist/assets/activity-DvPxoS4Y.js +0 -1
  145. package/web/dist/assets/index-B32EvfDT.js +0 -2
  146. package/web/dist/assets/sections-BET3c1WT.js +0 -1
  147. package/web/dist/assets/usePoll-NSTzLOF4.js +0 -1
package/js/hosts/link.mjs CHANGED
@@ -74,6 +74,7 @@
74
74
  import {
75
75
  chmodSync, existsSync, lstatSync, mkdirSync, readlinkSync, rmSync,
76
76
  } from 'node:fs';
77
+ import { isatty } from 'node:tty';
77
78
  import { spawnSync } from 'node:child_process';
78
79
  import path from 'node:path';
79
80
  import os from 'node:os';
@@ -81,12 +82,84 @@ import os from 'node:os';
81
82
  import { ROOT } from '../build/source.mjs';
82
83
  import { readMaybe } from './installs.mjs';
83
84
  import { printOut, printErr, writeText } from '../lib/fs.mjs';
85
+ import { confirm } from '../lib/prompt.mjs';
84
86
  import { toPlatformPath, which } from '../lib/paths.mjs';
85
87
 
86
88
  const IS_WIN = process.platform === 'win32';
87
89
  /** What `cmdUnlink` recognises a written Unix launcher by — see `isOurLauncher`. */
88
90
  const SHIM_MARKER = 'GENESEED_LINK_SHIM';
89
91
 
92
+ /** The lines that open and close the PATH block `link` writes into a shell startup file. */
93
+ const RC_BEGIN = '# >>> geneseed >>> (added by `geneseed link`; `geneseed unlink` removes it)';
94
+ const RC_END = '# <<< geneseed <<<';
95
+
96
+ /**
97
+ * The startup file to put the bin dir on PATH in, for this login shell — or null when it is
98
+ * not one we know how to edit (fish, csh, none), where `link` prints the line instead.
99
+ *
100
+ * zsh reads `$ZDOTDIR/.zshrc` (default `~`). bash reads `~/.bashrc` on Linux, but macOS
101
+ * Terminal opens LOGIN shells, which read `~/.bash_profile` and not `~/.bashrc`. Pure, for the
102
+ * test: nothing here may touch the real user's files.
103
+ */
104
+ export function shellRcFile({
105
+ shell = process.env.SHELL || '', home = os.homedir(), zdotdir = process.env.ZDOTDIR,
106
+ platform = process.platform,
107
+ } = {}) {
108
+ const name = path.basename(shell);
109
+ if (name === 'zsh') return path.join(zdotdir || home, '.zshrc');
110
+ if (name === 'bash') return path.join(home, platform === 'darwin' ? '.bash_profile' : '.bashrc');
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * `text` with the PATH block for `dir` appended, or unchanged when a block is already there.
116
+ *
117
+ * The dir goes inside double quotes with `\`, `"`, `$` and backtick escaped, so a home path
118
+ * holding any of them stays one literal word to the shell rather than becoming code.
119
+ */
120
+ export function withPathBlock(text, dir) {
121
+ if (text.includes(RC_BEGIN)) return text;
122
+ const quoted = dir.replace(/[\\"$`]/g, (c) => `\\${c}`);
123
+ const sep = text === '' || text.endsWith('\n') ? '' : '\n';
124
+ return `${text}${sep}${RC_BEGIN}\nexport PATH="${quoted}:$PATH"\n${RC_END}\n`;
125
+ }
126
+
127
+ /** `text` without the PATH block `withPathBlock` wrote; unchanged when there is none. */
128
+ export function withoutPathBlock(text) {
129
+ const start = text.indexOf(RC_BEGIN);
130
+ if (start < 0) return text;
131
+ const end = text.indexOf(RC_END, start);
132
+ if (end < 0) return text;
133
+ let stop = end + RC_END.length;
134
+ if (text[stop] === '\n') stop += 1;
135
+ return text.slice(0, start) + text.slice(stop);
136
+ }
137
+
138
+ /**
139
+ * Put `dir` on PATH for future shells, ASKING first — macOS zsh does not search
140
+ * `~/.local/bin`, so a linked `geneseed` was not found (reported 2026-10). Only in a terminal:
141
+ * the web console's button and every test run without one, and get the printed line instead,
142
+ * so no gate can edit the developer's own startup file. Returns whether it wrote.
143
+ */
144
+ function offerShellPath(dir) {
145
+ const rc = shellRcFile();
146
+ if (!rc || !isatty(0)) return false;
147
+ const text = readMaybe(rc) ?? '';
148
+ if (text.includes(RC_BEGIN)) {
149
+ printOut(`geneseed: ${rc} already adds it — open a NEW terminal, then run 'geneseed'.\n`);
150
+ return true;
151
+ }
152
+ if (!confirm(`geneseed: add '${dir}' to your PATH in ${rc}?`, true)) return false;
153
+ try {
154
+ writeText(rc, withPathBlock(text, dir));
155
+ } catch (e) {
156
+ printErr(`geneseed: could not write ${rc} (${asOsError(e)})\n`);
157
+ return false;
158
+ }
159
+ printOut(`geneseed: added it to ${rc} — open a NEW terminal, then run 'geneseed'.\n`);
160
+ return true;
161
+ }
162
+
90
163
  /** `_harness_lifecycle._win_bin_dir`. */
91
164
  function winBinDir() {
92
165
  // `os.environ.get(...) or str(Path.home())` — an EMPTY LOCALAPPDATA falls back too,
@@ -267,7 +340,7 @@ export function cmdLink(args) {
267
340
  printOut(`geneseed: linked ${dest} -> ${entry}\n`);
268
341
  if ((process.env.PATH || '').split(path.delimiter).includes(targetDir)) {
269
342
  printOut(`geneseed: '${targetDir}' is on PATH — run 'geneseed' from anywhere.\n`);
270
- } else {
343
+ } else if (!offerShellPath(targetDir)) {
271
344
  printOut(`geneseed: NOTE '${targetDir}' is not on your PATH. Add it, e.g.:\n`);
272
345
  printOut(` echo 'export PATH="${targetDir}:$PATH"' >> ~/.zshrc # or ~/.bashrc\n`);
273
346
  }
@@ -311,6 +384,20 @@ export function cmdUnlink() {
311
384
  } catch { /* `except OSError: pass` */ }
312
385
  }
313
386
  }
387
+ // The PATH block `link` may have written, from each startup file it could have chosen.
388
+ const home = os.homedir();
389
+ const rcs = new Set([shellRcFile({ shell: 'zsh' }), path.join(home, '.bashrc'),
390
+ path.join(home, '.bash_profile')]);
391
+ for (const rc of rcs) {
392
+ const text = readMaybe(rc);
393
+ if (text === null || withoutPathBlock(text) === text) continue;
394
+ try {
395
+ writeText(rc, withoutPathBlock(text));
396
+ printOut(`geneseed: removed the PATH line from ${rc}\n`);
397
+ } catch (e) {
398
+ printErr(`geneseed: could not edit ${rc} (${asOsError(e)})\n`);
399
+ }
400
+ }
314
401
  if (!removed) printOut('geneseed: no linked launcher found on PATH\n');
315
402
  return 0;
316
403
  }
@@ -10,7 +10,7 @@ import path from 'node:path';
10
10
  import { stripCapabilityLinks } from '../build/emit-common.mjs';
11
11
  import { STRUCTURE, renderAll } from '../build/render.mjs';
12
12
  import { CONFIG, CREDIT_RE, makeCfg } from '../build/source.mjs';
13
- import { hostCatalogsNatively, resolvePath } from '../hosts/hosts.mjs';
13
+ import { CLAUDE_STYLE, hostCatalogsNatively, resolvePath } from '../hosts/hosts.mjs';
14
14
  import { EMIT_HOST_SCOPE, footprintOfDir, themeFiles } from '../hosts/installs.mjs';
15
15
  import { isVendoredPath } from '../hosts/native.mjs';
16
16
  import { PALETTE_ROLES, colorThemeFiles } from '../hosts/opencode.mjs';
@@ -217,7 +217,13 @@ export function renderedProblems(bundle) {
217
217
  else themeName = 'neutral';
218
218
  let emit;
219
219
  try { emit = readText(path.join(bundle, '.geneseed-emit')).trim(); } catch { emit = ''; }
220
- const host = (EMIT_HOST_SCOPE.get(emit) ?? ['', ''])[0];
220
+ const [host, scope] = EMIT_HOST_SCOPE.get(emit) ?? ['', ''];
221
+ // A Claude-shaped PROJECT install is not a bundle: its tree is `AGENTS.md`/`CLAUDE.md` plus a
222
+ // `.bob/`/`.claude/`/`.openclaude/` data dir, so every rendered file reads "missing" and no
223
+ // rebuild can clear it. The console's onboarding once put a Bob project install exactly here
224
+ // (`./Harness` under the checkout, reported 2026-10: 178 such lines). Its own drift is
225
+ // `geneseed diff`'s and `status`'s to report, not this committed-bundle check's.
226
+ if (scope === 'project' && CLAUDE_STYLE.includes(host)) return [];
221
227
  let items;
222
228
  try {
223
229
  // THE FIRST CALLER IN THE PORT THAT CATCHES A REFUSAL, and that is why the stderr is
@@ -35,7 +35,7 @@ import { isFile, readText } from '../lib/fs.mjs';
35
35
  * console draws it on the cost chart, so it lives here, once, where both read it. Raise it by
36
36
  * exactly the measured overrun and say why in that test's docblock, which keeps its history.
37
37
  */
38
- export const CARRIER_CEILING = { full: 62_219, lean: 39_111 };
38
+ export const CARRIER_CEILING = { full: 63_266, lean: 39_628 };
39
39
 
40
40
  /**
41
41
  * A frontmatter value as the host shows it. The emit writes descriptions JSON-escaped
@@ -321,8 +321,38 @@ export function diffCollect({
321
321
  /** The records that are drift: everything but a `linked` path, which is the user's own. */
322
322
  export const drifted = (files) => files.filter((f) => f.status !== 'linked');
323
323
 
324
+ /** Where the self-improvement loops write, relative to an install's `notebook/`. */
325
+ export const NOTES_DIRS = ['skill-notes', 'agent-notes'];
326
+
327
+ /**
328
+ * The self-improvement notes an install has accumulated, as `{rel, body}` sorted by `rel`.
329
+ *
330
+ * WHY THESE ARE NOT DRIFT. The loops used to edit the rendered skill and agent files in place,
331
+ * which the doctor reported as stale and the next rebuild erased. They now write a sidecar
332
+ * under the notebook — seed-once and agent-owned, never in the manifest, so no rebuild touches
333
+ * it. That makes the notes the back-port QUEUE rather than an endangered edit: `diff` lists
334
+ * them and `--out` carries them so they can reach `src/`, but the upgrade export does not
335
+ * fire on them alone, because there is nothing for the rebuild to destroy. Empty files are
336
+ * skipped — an agent that cleared its notes has nothing to back-port.
337
+ */
338
+ export function collectNotes(dir) {
339
+ const notes = [];
340
+ for (const sub of NOTES_DIRS) {
341
+ const d = path.join(dir, 'notebook', sub);
342
+ let names;
343
+ try { names = readdirSync(d); } catch { continue; }
344
+ for (const n of names.filter((x) => x.endsWith('.md')).sort()) {
345
+ const a = path.join(d, n);
346
+ if (!isFile(a)) continue;
347
+ const body = readText(a);
348
+ if (body.trim()) notes.push({ rel: `notebook/${sub}/${n}`, body });
349
+ }
350
+ }
351
+ return notes;
352
+ }
353
+
324
354
  /** `_harness_diff._improvements_md` — pure; `when` is the caller's timestamp. */
325
- export function improvementsMd(target, theme, allFiles, when) {
355
+ export function improvementsMd(target, theme, allFiles, when, notes = []) {
326
356
  // A linked path is never a back-port item: the file is the user's, not a local edit of ours.
327
357
  const files = drifted(allFiles);
328
358
  const edited = files.filter((f) => f.status === 'edited');
@@ -337,8 +367,8 @@ export function improvementsMd(target, theme, allFiles, when) {
337
367
  `- ${edited.length} edited · ${added.length} added in deployed · `
338
368
  + `${missing.length} missing from deployed`,
339
369
  '',
340
- 'The deployed harness drifted from a fresh render of `src/` — typically the',
341
- 'self-improvement loops editing agent/skill files in place. Hand this file to',
370
+ 'The deployed harness drifted from a fresh render of `src/` — typically',
371
+ 'agent/skill files edited in place, by hand or by an older harness. Hand this file to',
342
372
  'an agent in the Geneseed source repo and ask it to fold the changes below',
343
373
  'back into `src/`. Diffs read source -> deployed; the expected copy was',
344
374
  'rendered in the deployed theme, so only genuine local edits appear.',
@@ -352,6 +382,17 @@ export function improvementsMd(target, theme, allFiles, when) {
352
382
  for (const f of files) {
353
383
  lines.push(`## \`${f.rel}\` (${label[f.status]})`, '', '```diff', ...f.diff, '```', '');
354
384
  }
385
+ // Only when there are notes, so a report without them stays byte-identical.
386
+ if (notes.length) {
387
+ lines.push('# Self-improvement notes', '',
388
+ 'Corrections the agent recorded against a skill or agent instead of editing it.',
389
+ 'Fold each one into the matching file under `src/`, then clear the note. Judge it by',
390
+ 'its `seen:` evidence — a line without one is a claim, not a lesson — and weigh a',
391
+ '`[general]` line as a law, doctrine or memory rather than an edit to that one file.', '');
392
+ for (const n of notes) {
393
+ lines.push(`## \`${n.rel}\``, '', '```markdown', ...splitLines(n.body), '```', '');
394
+ }
395
+ }
355
396
  return `${lines.join('\n')}\n`;
356
397
  }
357
398
 
@@ -385,7 +426,9 @@ function stamp(d) {
385
426
  * the same instant, so a run that crosses a second boundary cannot name a file one second
386
427
  * away from what it says inside.
387
428
  */
388
- export function writeImprovements(target, theme, files, outPath = null) {
429
+ export function writeImprovements(
430
+ target, theme, files, outPath = null, notes = collectNotes(target),
431
+ ) {
389
432
  const now = stamp(new Date());
390
433
  // `Path(out_path).expanduser()` — again NOT resolved, and again observable: the path is
391
434
  // printed, so `--out p.md` reports `p.md`.
@@ -393,7 +436,7 @@ export function writeImprovements(target, theme, files, outPath = null) {
393
436
  ? expanduser(outPath)
394
437
  : path.join(target, 'improvements', now.file);
395
438
  mkdirSync(path.dirname(p), { recursive: true });
396
- writeText(p, improvementsMd(target, theme, files, now.when));
439
+ writeText(p, improvementsMd(target, theme, files, now.when, notes));
397
440
  return p;
398
441
  }
399
442
 
@@ -477,18 +520,21 @@ export function cmdDiff(args) {
477
520
  + `${missing.length} missing-from-deployed\n`);
478
521
  // Its own line, printed only when there is one, so the summary above is unchanged otherwise.
479
522
  if (linked.length) printOut(`[diff] ${linked.length} linked (yours, left alone)\n`);
523
+ const notes = collectNotes(target);
524
+ if (notes.length) printOut(`[diff] ${notes.length} self-improvement note(s) to back-port\n`);
480
525
  for (const f of edited) printOut(` ~ ${f.rel} (edited in deployed — review to back-port)\n`);
481
526
  for (const f of added) printOut(` + ${f.rel} (only in deployed — your addition)\n`);
482
527
  for (const f of missing) printOut(` - ${f.rel} (in source, not deployed — re-emit to add)\n`);
483
528
  for (const f of linked) printOut(` = ${f.rel} (a link — yours, left alone)\n`);
529
+ for (const n of notes) printOut(` * ${n.rel} (self-improvement note — review to back-port)\n`);
484
530
  if (args.out) {
485
- if (drifted(files).length) {
531
+ if (drifted(files).length || notes.length) {
486
532
  // Bare `--out` is argparse's `nargs="?"` const: True, meaning "the default timestamped
487
533
  // path". `parse()` gives a flag `true` and an option a string, so the two arrive
488
534
  // distinguishable here without a third kind of entry in the VERBS table.
489
535
  const outPath = args.out === true ? null : args.out;
490
536
  printOut(`[diff] improvements file written: ${
491
- writeImprovements(target, theme, files, outPath)}\n`);
537
+ writeImprovements(target, theme, files, outPath, notes)}\n`);
492
538
  } else {
493
539
  printOut('[diff] no differences — nothing written.\n');
494
540
  }
@@ -97,6 +97,7 @@ export const SKILL_CLASS = {
97
97
  'frontend-design': 'build', prototype: 'build', deck: 'build',
98
98
  'forge-mcp': 'build', bruno: 'build', worktree: 'build',
99
99
  'spring-boot': 'build', 'spring-data-jpa': 'build', 'spring-testing': 'build',
100
+ react: 'build', 'react-testing': 'build',
100
101
  'geneseed-code-review': 'review', 'fresh-eyes': 'review', 'security-audit': 'review',
101
102
  'roast-me': 'review', 'review-response': 'review', ponytail: 'review',
102
103
  commit: 'ship', ship: 'ship', release: 'ship', handoff: 'ship',
package/js/lib/prompt.mjs CHANGED
@@ -12,6 +12,9 @@ import { readSync } from 'node:fs';
12
12
  import { printOut } from './fs.mjs';
13
13
  import { parseIntStrict } from './text.mjs';
14
14
 
15
+ /** A cell to `Atomics.wait` on: the synchronous pause between `readLine`'s EAGAIN retries. */
16
+ const PAUSE = new Int32Array(new SharedArrayBuffer(4));
17
+
15
18
  /**
16
19
  * `_harness_setup._ask` — `input(f"{prompt}{suffix}: ").strip()`, or the default at EOF.
17
20
  *
@@ -71,14 +74,28 @@ export function promptLine(prompt) {
71
74
  * the wizard's first non-ASCII answer is what exposed it, and the corpus caught it on the
72
75
  * first run. Scanning for the terminators a byte at a time is still correct: `\n` and `\r`
73
76
  * are ASCII, and no continuation byte of a UTF-8 sequence is below 0x80.
77
+ *
78
+ * EAGAIN IS "NOTHING TYPED YET", NOT EOF. Touching `process.stdin` — even `.isTTY`, which
79
+ * `cmdSetup` reads first — makes libuv put a terminal fd 0 in non-blocking mode, and from then
80
+ * on `readSync` throws EAGAIN until a key is pressed. Caught as EOF, every prompt took its
81
+ * default the instant it printed: setup's "Continue without one? (y/N)" answered itself N and
82
+ * the install stopped before anyone could type (reported 2026-10, reproduced under a pty). So
83
+ * it waits 20 ms and reads again; any other error is still the end of input. `read` is the
84
+ * test seam.
74
85
  */
75
- function readLine() {
86
+ export function readLine(read = readSync) {
76
87
  const buf = Buffer.alloc(1);
77
88
  const bytes = [];
78
89
  let eof = false;
79
90
  for (;;) {
80
91
  let n = 0;
81
- try { n = readSync(0, buf, 0, 1, null); } catch { eof = true; break; }
92
+ try {
93
+ n = read(0, buf, 0, 1, null);
94
+ } catch (e) {
95
+ if (e && e.code === 'EAGAIN') { Atomics.wait(PAUSE, 0, 0, 20); continue; }
96
+ eof = true;
97
+ break;
98
+ }
82
99
  if (n === 0) { eof = true; break; }
83
100
  if (buf[0] === 0x0a) break;
84
101
  if (buf[0] !== 0x0d) bytes.push(buf[0]);
@@ -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);