geneseed 3.9.0 → 3.11.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 (219) hide show
  1. package/CHANGELOG.md +89 -1
  2. package/DESIGN.md +1 -1
  3. package/README.md +52 -71
  4. package/SHIPPED.md +3 -3
  5. package/adapters/bob/README.md +18 -2
  6. package/adapters/claude-code/README.md +17 -12
  7. package/adapters/openclaude/README.md +2 -2
  8. package/adapters/opencode/README.md +4 -4
  9. package/adapters/opencode/plugins/geneseed-context.js +78 -8
  10. package/bin/build-driver.mjs +2 -0
  11. package/bin/geneseed-cli.mjs +17 -0
  12. package/bin/geneseed-hook.mjs +3 -1
  13. package/docs/README.md +42 -0
  14. package/docs/_groups.json +18 -0
  15. package/docs/candidate-hosts.md +12 -5
  16. package/docs/concepts/agents.md +44 -0
  17. package/docs/concepts/collaboration.md +62 -0
  18. package/docs/concepts/exclusions.md +55 -0
  19. package/docs/concepts/footprint.md +52 -0
  20. package/docs/concepts/foreman-mode.md +69 -0
  21. package/docs/concepts/hooks.md +101 -0
  22. package/docs/concepts/hosts.md +55 -0
  23. package/docs/concepts/lsp.md +50 -0
  24. package/docs/{web → concepts}/memory.md +2 -2
  25. package/docs/{web → concepts}/notebook.md +2 -2
  26. package/docs/concepts/rules.md +52 -0
  27. package/docs/concepts/skills.md +49 -0
  28. package/docs/concepts/themes.md +50 -0
  29. package/docs/extending.md +27 -21
  30. package/docs/guides/autostart.md +70 -0
  31. package/docs/guides/choose-your-setup.md +120 -0
  32. package/docs/guides/headless.md +22 -0
  33. package/docs/guides/install.md +161 -0
  34. package/docs/guides/mcp.md +195 -0
  35. package/docs/guides/migrate.md +41 -0
  36. package/docs/guides/project-context.md +87 -0
  37. package/docs/guides/run-anywhere.md +45 -0
  38. package/docs/guides/uninstall.md +51 -0
  39. package/docs/guides/upgrade.md +65 -0
  40. package/docs/guides/verify.md +65 -0
  41. package/docs/guides/wiki.md +69 -0
  42. package/docs/guides/worktree.md +47 -0
  43. package/docs/reference/about.md +7 -0
  44. package/docs/reference/cli.md +7 -0
  45. package/docs/reference/environment.md +99 -0
  46. package/docs/reference/glossary.md +33 -0
  47. package/docs/reference/opencode-plugins.md +131 -0
  48. package/docs/reference/troubleshooting.md +350 -0
  49. package/docs/token-footprint.md +26 -21
  50. package/docs/understand/a-day.md +77 -0
  51. package/docs/understand/enforced-vs-asked.md +64 -0
  52. package/docs/understand/harness.md +36 -0
  53. package/docs/understand/on-your-machine.md +76 -0
  54. package/docs/web-ui.md +2 -2
  55. package/harness.config.json +1 -1
  56. package/js/README.md +7 -6
  57. package/js/build/driver.mjs +26 -95
  58. package/js/build/emit-claude.mjs +76 -81
  59. package/js/build/generate.mjs +12 -2
  60. package/js/build/render.mjs +4 -0
  61. package/js/build/source.mjs +6 -0
  62. package/js/build/stubs.mjs +18 -2
  63. package/js/cli-table.json +6 -6
  64. package/js/hosts/hooks.mjs +86 -54
  65. package/js/hosts/hosts.mjs +5 -21
  66. package/js/hosts/installs.mjs +4 -6
  67. package/js/hosts/mcp.mjs +4 -14
  68. package/js/hosts/native.mjs +27 -50
  69. package/js/hosts/opencode.mjs +1 -1
  70. package/js/hosts/settings.mjs +17 -79
  71. package/js/inspect/checks-authoring.mjs +9 -4
  72. package/js/inspect/checks-repo.mjs +45 -1
  73. package/js/inspect/diff.mjs +11 -3
  74. package/js/inspect/doctor.mjs +4 -4
  75. package/js/inspect/excludes.mjs +0 -4
  76. package/js/inspect/inventory.mjs +1 -1
  77. package/js/inspect/registry.mjs +20 -3
  78. package/js/inspect/status.mjs +11 -6
  79. package/js/lib/node-floor.mjs +30 -0
  80. package/js/maintain/migrate.mjs +5 -5
  81. package/js/maintain/setup.mjs +112 -26
  82. package/js/maintain/uninstall.mjs +8 -42
  83. package/js/maintain/update.mjs +4 -15
  84. package/js/ui/cli.mjs +2 -2
  85. package/js/web/actions.mjs +4 -5
  86. package/js/web/api.mjs +7 -4
  87. package/js/web/daemon.mjs +52 -0
  88. package/js/web/docs.mjs +149 -65
  89. package/js/web/server.mjs +5 -2
  90. package/package.json +6 -5
  91. package/registry.json +7 -0
  92. package/src/AGENT.md.tmpl +73 -52
  93. package/src/laws/universal.md +3 -0
  94. package/src/ontology/universal.md +1 -1
  95. package/src/postures/mentor.md +1 -0
  96. package/src/skills/daydream/SKILL.md +10 -3
  97. package/src/skills/daydream/VENDOR.md +10 -2
  98. package/src/skills/daydream/instructions.md +2 -2
  99. package/src/skills/explain-changes/SKILL.md +1 -1
  100. package/src/skills/explain-changes/VENDOR.md +1 -1
  101. package/src/skills/geneseed.md +1 -1
  102. package/src/skills/learn-mode.md +89 -0
  103. package/src/skills/pipeline.md +2 -2
  104. package/src/skills/quiz.md +2 -1
  105. package/src/skills/react-view-transitions/SKILL.md +1 -1
  106. package/src/skills/react-view-transitions/VENDOR.md +7 -2
  107. package/src/skills/teach.md +2 -1
  108. package/src/skills/token-report/SKILL.md +2 -3
  109. package/src/skills/token-report/VENDOR.md +2 -3
  110. package/src/skills/token-report/scripts/token_report.mjs +7 -121
  111. package/src/skills/workflow.md +1 -1
  112. package/themes/_TEMPLATE.json +1 -0
  113. package/themes/biker.json +1 -0
  114. package/themes/commentator.json +1 -0
  115. package/themes/cyberpunk.json +1 -0
  116. package/themes/gamer.json +1 -0
  117. package/themes/imperial.json +1 -0
  118. package/themes/joker.json +1 -0
  119. package/themes/marvin.json +1 -0
  120. package/themes/mean.json +1 -0
  121. package/themes/military.json +1 -0
  122. package/themes/neutral.json +1 -0
  123. package/themes/pirate.json +1 -0
  124. package/themes/sports.json +1 -0
  125. package/themes/verstappen.json +1 -0
  126. package/themes/wizard.json +1 -0
  127. package/web/dist/assets/Activity-C52LEb-2.js +1 -0
  128. package/web/dist/assets/ActivityDetail-CjNLRvXg.js +1 -0
  129. package/web/dist/assets/Dashboard-B6_sSCHi.js +2 -0
  130. package/web/dist/assets/Docs-Dj7BmCSA.js +5 -0
  131. package/web/dist/assets/{GreenhouseView-Bq1QmaGO.js → GreenhouseView-BALwjQsA.js} +1 -1
  132. package/web/dist/assets/Installs-BbffDQm3.js +2 -0
  133. package/web/dist/assets/JournalView-JpAQ8Hqe.js +1 -0
  134. package/web/dist/assets/Laws-DK7eCWSm.js +3 -0
  135. package/web/dist/assets/Library-CoamHelE.js +1 -0
  136. package/web/dist/assets/{Markdown-CkFyfgNf.js → Markdown-qjM2_ofS.js} +5 -5
  137. package/web/dist/assets/{OperatorHudView-B-HVBqfY.js → OperatorHudView-DVx1HlBx.js} +1 -1
  138. package/web/dist/assets/Personal-eaXb1zcj.js +1 -0
  139. package/web/dist/assets/{Tabs-_pjUFIEI.js → Tabs-BmGtt8E2.js} +1 -1
  140. package/web/dist/assets/api-Dc8f3ca-.js +1 -0
  141. package/web/dist/assets/drawable-Du_PZrQj.js +1 -0
  142. package/web/dist/assets/index-Bz_KMRDH.css +1 -0
  143. package/web/dist/assets/index-CNFH7o55.js +2 -0
  144. package/web/dist/index.html +3 -2
  145. package/QUICKSTART.md +0 -50
  146. package/SETUP.md +0 -1183
  147. package/adapters/copilot/README.md +0 -105
  148. package/docs/web/_groups.json +0 -45
  149. package/docs/web/about.md +0 -6
  150. package/docs/web/adapters-claude-code.md +0 -8
  151. package/docs/web/adapters-opencode-loads.md +0 -8
  152. package/docs/web/adapters-opencode-spec.md +0 -8
  153. package/docs/web/adapters-opencode.md +0 -8
  154. package/docs/web/agents.md +0 -8
  155. package/docs/web/authoring.md +0 -11
  156. package/docs/web/autostart.md +0 -9
  157. package/docs/web/claude-hooks.md +0 -37
  158. package/docs/web/cli.md +0 -6
  159. package/docs/web/collaboration.md +0 -27
  160. package/docs/web/configure-wiki.md +0 -9
  161. package/docs/web/design.md +0 -7
  162. package/docs/web/env-knobs.md +0 -9
  163. package/docs/web/exclusions.md +0 -27
  164. package/docs/web/first-session.md +0 -28
  165. package/docs/web/footprint.md +0 -38
  166. package/docs/web/foreman-mode.md +0 -33
  167. package/docs/web/glossary.md +0 -6
  168. package/docs/web/harnesses.md +0 -30
  169. package/docs/web/headless.md +0 -9
  170. package/docs/web/ingest-docs.md +0 -9
  171. package/docs/web/install-paths.md +0 -9
  172. package/docs/web/install-quick.md +0 -35
  173. package/docs/web/lsp-overview.md +0 -37
  174. package/docs/web/mcp-claude-code.md +0 -10
  175. package/docs/web/mcp-filesystem.md +0 -9
  176. package/docs/web/mcp-gitlab.md +0 -9
  177. package/docs/web/mcp-markitdown.md +0 -9
  178. package/docs/web/mcp-overview.md +0 -34
  179. package/docs/web/mcp-trouble.md +0 -9
  180. package/docs/web/mcp-verify.md +0 -9
  181. package/docs/web/migrate.md +0 -39
  182. package/docs/web/model.md +0 -20
  183. package/docs/web/pipelines.md +0 -24
  184. package/docs/web/plugin-activity.md +0 -23
  185. package/docs/web/plugin-context.md +0 -26
  186. package/docs/web/plugin-guard.md +0 -24
  187. package/docs/web/plugin-learn.md +0 -27
  188. package/docs/web/plugin-notify.md +0 -24
  189. package/docs/web/plugin-ponytail.md +0 -23
  190. package/docs/web/plugin-setup.md +0 -7
  191. package/docs/web/plugin-workflow.md +0 -25
  192. package/docs/web/plugins.md +0 -17
  193. package/docs/web/project-context.md +0 -9
  194. package/docs/web/rules.md +0 -30
  195. package/docs/web/run-anywhere.md +0 -9
  196. package/docs/web/self-improve.md +0 -8
  197. package/docs/web/setup-choices.md +0 -64
  198. package/docs/web/skills.md +0 -8
  199. package/docs/web/themes-author.md +0 -20
  200. package/docs/web/themes.md +0 -9
  201. package/docs/web/token-footprint.md +0 -7
  202. package/docs/web/trouble.md +0 -38
  203. package/docs/web/upgrade.md +0 -9
  204. package/docs/web/verify.md +0 -30
  205. package/docs/web/wiki.md +0 -7
  206. package/docs/web/worktree.md +0 -10
  207. package/docs/wiki.md +0 -43
  208. package/web/dist/assets/Activity-DAzV-qQj.js +0 -1
  209. package/web/dist/assets/ActivityDetail-CwEwMtKl.js +0 -1
  210. package/web/dist/assets/Dashboard-C2ELBW-X.js +0 -2
  211. package/web/dist/assets/Docs-iZrvQP8J.js +0 -2
  212. package/web/dist/assets/Installs-BiqR8xdO.js +0 -2
  213. package/web/dist/assets/JournalView-6wilG5Zd.js +0 -1
  214. package/web/dist/assets/Laws-D8YXD_XE.js +0 -3
  215. package/web/dist/assets/Library-DwuTZ2-L.js +0 -1
  216. package/web/dist/assets/Personal-BbUTmZQR.js +0 -1
  217. package/web/dist/assets/drawable-CoIulEH2.js +0 -1
  218. package/web/dist/assets/index-BMwSp8BP.js +0 -2
  219. package/web/dist/assets/index-CDPDk2vj.css +0 -1
@@ -41,7 +41,7 @@ import { excludeAdd, excludeRemove } from '../inspect/excludes.mjs';
41
41
  import { setupBuildArgs } from '../build/generate.mjs';
42
42
  import { frontmatter, memoryDropIndex } from '../hosts/hooks.mjs';
43
43
  import {
44
- HOSTS, bobConfigDir, claudeConfigDir, copilotConfigDir, expanduser, openclaudeConfigDir,
44
+ HOSTS, bobConfigDir, claudeConfigDir, expanduser, openclaudeConfigDir,
45
45
  opencodeConfigDir, resolvePath,
46
46
  } from '../hosts/hosts.mjs';
47
47
  import {
@@ -644,7 +644,7 @@ export function apiMcpToggle(state, body) {
644
644
  return { ok: false, error: 'config holds comments — edit it by hand to keep them' };
645
645
  }
646
646
  let cfg;
647
- if (['claude', 'bob', 'copilot', 'openclaude'].includes(host)) {
647
+ if (['claude', 'bob', 'openclaude'].includes(host)) {
648
648
  if (isFile(p)) {
649
649
  let parsed;
650
650
  try {
@@ -816,7 +816,6 @@ const EMIT_FOR = new Map([
816
816
  ['opencode global', 'opencode-global'], ['opencode project', 'opencode'],
817
817
  ['claude global', 'claude-global'], ['claude project', 'claude'],
818
818
  ['bob global', 'bob-global'], ['bob project', 'bob'],
819
- ['copilot global', 'copilot-global'], ['copilot project', 'copilot'],
820
819
  ['openclaude global', 'openclaude-global'], ['openclaude project', 'openclaude'],
821
820
  ]);
822
821
 
@@ -952,7 +951,7 @@ export function apiDeployCmd(state, body) {
952
951
  return { error: `folder not writable: ${root}` };
953
952
  }
954
953
  const cfgdirs = new Set();
955
- for (const fn of [opencodeConfigDir, claudeConfigDir, bobConfigDir, copilotConfigDir,
954
+ for (const fn of [opencodeConfigDir, claudeConfigDir, bobConfigDir,
956
955
  openclaudeConfigDir]) {
957
956
  try {
958
957
  cfgdirs.add(resolvePath(fn()));
@@ -978,7 +977,7 @@ export function apiDeployCmd(state, body) {
978
977
  // and `PreToolUse::Bash` went with them. `doctrinesForBuild` resolves unknown to ALL packs.
979
978
  const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root);
980
979
  const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
981
- // project-scope emit name == host name (opencode / claude / bob / copilot / openclaude)
980
+ // project-scope emit name == host name (opencode / claude / bob / openclaude)
982
981
  const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines,
983
982
  PACK_ORDER, excludeRules);
984
983
  return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
package/js/web/api.mjs CHANGED
@@ -141,8 +141,8 @@ export function webState(theme = null, target = null) {
141
141
  * Re-point the console at another detected install's data dir.
142
142
  *
143
143
  * `root` is the install ROOT the markers and sigils live at. It defaults to `target` and
144
- * differs only for claude/bob/copilot PROJECT installs, where the data sits under
145
- * `<repo>/.claude|.bob|.github` while `.geneseed-emit`/`-theme`/`-footprint` land at
144
+ * differs only for claude/bob/openclaude PROJECT installs, where the data sits under
145
+ * `<repo>/.claude|.bob|.openclaude` while `.geneseed-emit`/`-theme`/`-footprint` land at
146
146
  * `<repo>/` — reading them from the data dir mis-detects the install as opencode/neutral,
147
147
  * and a Diff or a Restore would then overwrite it in the wrong dialect.
148
148
  */
@@ -725,7 +725,7 @@ export const apiExcludes = () => excludesSnapshot();
725
725
  * nested-marker host cannot silently read the bare root.
726
726
  */
727
727
  export function viewCfg(host, scope, root) {
728
- if (scope === 'project' && ['claude', 'bob', 'copilot', 'openclaude'].includes(host)) {
728
+ if (scope === 'project' && ['claude', 'bob', 'openclaude'].includes(host)) {
729
729
  return path.join(root, HOSTS.find((h) => h.host === host).projectMarker);
730
730
  }
731
731
  return root;
@@ -795,7 +795,7 @@ export function apiOverview(state) {
795
795
  // can re-emit exactly it. Mirrors `viewCfg`'s rule, spelled out separately here.
796
796
  let install = null;
797
797
  for (const [host, scope, root] of installTargets()) {
798
- const data = (scope === 'project' && ['claude', 'bob', 'copilot', 'openclaude'].includes(host))
798
+ const data = (scope === 'project' && ['claude', 'bob', 'openclaude'].includes(host))
799
799
  ? path.join(root, HOSTS.find((h) => h.host === host).projectMarker) : root;
800
800
  try {
801
801
  if (samePath(data, state.target)) {
@@ -819,6 +819,9 @@ export function apiOverview(state) {
819
819
  install,
820
820
  target: state.target,
821
821
  deployed: deployed(state),
822
+ // True when the files on disk changed under this server since it started (an upgrade).
823
+ // `serve` installs the comparison; a state built without a server has none and is false.
824
+ daemon_stale: Boolean(state.daemonStale?.()),
822
825
  counts: {
823
826
  agents: inv.agents.length,
824
827
  skills: inv.skills.length,
package/js/web/daemon.mjs CHANGED
@@ -13,6 +13,7 @@ import { printOut, writeText } from '../lib/fs.mjs';
13
13
  import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
14
14
  import { webState } from './api.mjs';
15
15
  import { spawn } from 'node:child_process';
16
+ import { createHash } from 'node:crypto';
16
17
  import { chmodSync, mkdirSync, openSync, readFileSync, unlinkSync } from 'node:fs';
17
18
  import { join, resolve as pathResolve } from 'node:path';
18
19
 
@@ -20,6 +21,29 @@ const ROOT = pathResolve(import.meta.dirname, '..', '..');
20
21
 
21
22
  // ---- the daemon record -----------------------------------------------------
22
23
 
24
+ /**
25
+ * Which code a console serves: the package version and a hash of the `web/dist/index.html` it
26
+ * would load. A daemon writes the stamp it started with into its record; an npm upgrade or a
27
+ * `git pull` that replaces `web/dist/` changes the stamp on disk, and the daemon — still
28
+ * serving the old `index.html`, whose chunk hashes may no longer exist — is then STALE.
29
+ * Read fresh on every call: the comparison is against the files as they are NOW.
30
+ */
31
+ export function codeStamp(root = ROOT) {
32
+ const read = (rel) => { try { return readFileSync(join(root, rel)); } catch { return ''; } };
33
+ let version = 'unknown';
34
+ try { version = JSON.parse(read('package.json')).version ?? version; } catch { /* unknown */ }
35
+ const html = createHash('sha256').update(read(join('web', 'dist', 'index.html'))).digest('hex');
36
+ return `${version}:${html.slice(0, 12)}`;
37
+ }
38
+
39
+ /** A record written by an older daemon carries no `code` — and is stale by definition. */
40
+ export function daemonStale(record, root = ROOT) {
41
+ return record?.code !== codeStamp(root);
42
+ }
43
+
44
+ export const STALE_LINE = '[web] running OLD code (started before the last upgrade) — '
45
+ + 'geneseed web restart';
46
+
23
47
  const statePath = (target) => join(target, '.geneseed-web.json');
24
48
 
25
49
  export function readDaemon(target) {
@@ -268,6 +292,7 @@ export async function statusDaemon(theme = null) {
268
292
  const st = await liveDaemon(target);
269
293
  if (st) {
270
294
  printOut(`[web] running on ${st.url} (theme: ${none(st.theme)}, pid ${none(st.pid)})\n`);
295
+ if (daemonStale(st)) printOut(`${STALE_LINE}\n`);
271
296
  return 0;
272
297
  }
273
298
  // EXIT 1, which is the verb's whole contract for a script. The snapshot's `<exit>`
@@ -276,6 +301,33 @@ export async function statusDaemon(theme = null) {
276
301
  return 1;
277
302
  }
278
303
 
304
+ /**
305
+ * Bounce a running console so it serves the code now on disk — `upgrade` after it rebuilt,
306
+ * `rebuild-all` when the running one is stale. EXCEPT inside a web job (`GENESEED_WEB_JOB`,
307
+ * set by `js/web/jobs.mjs`): restarting here would kill the process tracking that very job,
308
+ * leaving the console stuck on 'running'; the server restarts itself after an update job, and
309
+ * shows its stale banner otherwise. `onlyIfStale` is `rebuild-all`'s: after an `upgrade`
310
+ * (which bounces, then runs `rebuild-all`) the console is current, and bouncing it again
311
+ * would drop the open tab for nothing. Never throws — no rebuild fails over a console.
312
+ */
313
+ export async function bounceDaemonIfRunning(theme, log, { onlyIfStale = false } = {}) {
314
+ if (onlyIfStale) {
315
+ const st = readDaemon(webState(theme).target);
316
+ if (!st || !daemonStale(st)) return;
317
+ }
318
+ if (process.env.GENESEED_WEB_JOB) {
319
+ log(onlyIfStale
320
+ ? '[geneseed] the web console is running old code — use its Restart banner.'
321
+ : '[geneseed] web daemon will restart itself after this job to load the new code.');
322
+ return;
323
+ }
324
+ try {
325
+ await restartDaemon(theme, 4747, false, true);
326
+ } catch (e) {
327
+ log(`[geneseed] ⚠️ could not refresh the web daemon (${e && e.message ? e.message : e}) — \`geneseed web restart\` manually if it was running.`);
328
+ }
329
+ }
330
+
279
331
  export async function restartDaemon(theme = null, port = 4747, openBrowser = true,
280
332
  onlyIfRunning = false) {
281
333
  const { target } = webState(theme);
package/js/web/docs.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The web console's Docs pages, and the registry built from `docs/web/`.
2
+ * The web console's Docs pages, and the registry built from `docs/<folder>/` (DOC_FOLDERS).
3
3
  *
4
4
  * `cli` docs render via `cliReference()` in `js/ui/cli.mjs`, which reads
5
5
  * `js/cli-table.json` — the CLI's own metadata, since `harness.build_argparser()`'s parser
@@ -7,9 +7,9 @@
7
7
  * the same file, so there is exactly one description of the CLI surface, not a second
8
8
  * transcription of it.
9
9
  *
10
- * `tests/unit/web_server.test.mjs` holds `KINDS` against a written-out list of the five
10
+ * `tests/unit/web_server.test.mjs` holds `KINDS` against a written-out list of the six
11
11
  * kinds this daemon dispatches on — same declaration-vs-dispatch shape as the route table,
12
- * which is why it is a table here too and not five `if`s.
12
+ * which is why it is a table here too and not six `if`s.
13
13
  *
14
14
  * THE `?harness=` QUERY PARAM IS the Docs selector, and it is the ONLY input to these
15
15
  * endpoints that is not the checkout itself — the one thing a test can vary. Every
@@ -18,7 +18,7 @@
18
18
  import { readdirSync } from 'node:fs';
19
19
  import path from 'node:path';
20
20
 
21
- import { ROOT, THEMES } from '../build/source.mjs';
21
+ import { DOCS, DOC_FOLDERS, ROOT, THEMES } from '../build/source.mjs';
22
22
  import { cliReference } from '../ui/cli.mjs';
23
23
  import { readJsonMaybe, readMaybe } from '../hosts/installs.mjs';
24
24
  import { resolvePath } from '../hosts/hosts.mjs';
@@ -30,8 +30,6 @@ import { statusData } from '../inspect/status.mjs';
30
30
  import { originDisplay } from '../maintain/update.mjs';
31
31
  import { NotFound, deployed, resolveLinks } from './api.mjs';
32
32
 
33
- const DOC_DIR = path.join(ROOT, 'docs', 'web');
34
-
35
33
  /**
36
34
  * A docs page split into its frontmatter map and its body.
37
35
  *
@@ -61,24 +59,45 @@ function docFrontmatter(text) {
61
59
  }
62
60
 
63
61
  /**
64
- * The Docs registry, built from `docs/web/`.
62
+ * Every docs page source as `{ id, rel }`, `rel` relative to ROOT with forward slashes.
63
+ *
64
+ * The id is the basename because it is the router's address (`#/docs/<id>`); the folder is
65
+ * GitHub's business, not the console's. Two pages with one basename would leave one of them
66
+ * unreachable, so a duplicate throws instead of shadowing.
67
+ */
68
+ export function docSources(docRoot = DOCS) {
69
+ const out = [];
70
+ const seen = new Set();
71
+ for (const folder of DOC_FOLDERS) {
72
+ let names;
73
+ try { names = readdirSync(path.join(docRoot, folder)); } catch { continue; }
74
+ for (const name of names.filter((n) => normcase(n).endsWith('.md')).sort()) {
75
+ const id = name.slice(0, -3);
76
+ if (seen.has(id)) throw new Error(`duplicate docs page id: ${id}`);
77
+ seen.add(id);
78
+ out.push({ id, rel: `docs/${folder}/${name}` });
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /**
85
+ * The Docs registry, built from `docSources()` and `docs/_groups.json`.
65
86
  *
66
87
  * A page whose file is missing or malformed is skipped rather than crashing the server; an
67
88
  * unreadable registry yields an empty Docs section, which the UI renders as "no pages".
68
89
  *
69
- * NOT cached at module load. The difference is invisible to a request — `docs/web/` cannot
90
+ * NOT cached at module load. The difference is invisible to a request — the docs tree cannot
70
91
  * change under a running daemon any more than `dist/` can — and a module-level constant
71
92
  * would freeze whatever `ROOT` was when this file was first imported.
72
93
  */
73
94
  export function docGroups() {
74
- const groups = readJsonMaybe(path.join(DOC_DIR, '_groups.json'));
95
+ const groups = readJsonMaybe(path.join(DOCS, '_groups.json'));
75
96
  if (!Array.isArray(groups)) return [];
76
97
  const byId = new Map();
77
98
  for (const g of groups) byId.set(g.id, { ...g, pages: [] });
78
- let names;
79
- try { names = readdirSync(DOC_DIR); } catch { return []; }
80
- for (const name of names.filter((n) => normcase(n).endsWith('.md')).sort()) {
81
- const text = readMaybe(path.join(DOC_DIR, name));
99
+ for (const { id, rel } of docSources()) {
100
+ const text = readMaybe(path.join(ROOT, rel));
82
101
  if (text === null) continue;
83
102
  const [meta, body] = docFrontmatter(text);
84
103
  const group = byId.get(meta.group);
@@ -86,7 +105,7 @@ export function docGroups() {
86
105
  if (group === undefined) continue;
87
106
  const order = Object.hasOwn(meta, 'order') ? meta.order : 0;
88
107
  delete meta.order;
89
- const page = { id: name.slice(0, -3), ...meta };
108
+ const page = { id, rel, ...meta };
90
109
  if (body.trim()) page.body = body.replace(/\n+$/, '');
91
110
  group.pages.push([order, page]);
92
111
  }
@@ -103,50 +122,96 @@ export function docGroups() {
103
122
  return out;
104
123
  }
105
124
 
125
+ // ---- markdown tables as data ------------------------------------------------------------
126
+
127
+ /** One table row's cells; a `\|` inside a cell is kept as a literal bar. */
128
+ function tableCells(line) {
129
+ return line.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/)
130
+ .map((c) => c.trim().replace(/\\\|/g, '|'));
131
+ }
132
+
106
133
  /**
107
- * The neutral term, the theme key whose value renames it, and the one-line description. A
108
- * key of `null` marks a term no theme renames.
134
+ * The body rows of the first table whose header, lower-cased, is exactly `head` — or `null`.
135
+ *
136
+ * A TABLE IS THE DATA so a page reads the same on GitHub as in the console: the map on
137
+ * understand page 2 and the glossary are both tables a person can read, and the console builds
138
+ * its richer view from the same rows rather than from a second copy that could drift. A row
139
+ * with the wrong number of cells is a malformed table, so the whole answer is `null`.
109
140
  */
110
- const GLOSSARY_KEYS = [
111
- // The three tiers, in constitutional order and before the entities — a reader who does not
112
- // know what an Ethos is here cannot read the rest of the glossary's first rows.
113
- //
114
- // `Ethos (Ontology)` for the same reason as `Rule (Law)` below: the neutral theme renamed
115
- // what a reader SEES, while the address the reader will meet in `src/ontology/`, in the
116
- // `ONTOLOGY` theme key and in every identifier did not move. The parenthesis is the bridge
117
- // between the two, and the glossary is the one page that owes a reader that bridge.
118
- ['Ethos (Ontology)', 'ONTOLOGY', 'the always-on worldview under the rules — telos, evidence, '
119
- + 'decisions, conduct; it holds the Pact'],
120
- ['Rule (Law)', 'LAW', 'one of the nine always-on invariants: what is never done'],
121
- ['Rules (Laws)', 'LAWS', 'the body of always-on invariants'],
122
- ['Doctrine', 'DOCTRINE', 'one practice rule, cited `<pack> <n>` — how work is done here'],
123
- ['Doctrines', 'DOCTRINES', 'the practice packs, chosen at build time; inactive packs still '
124
- + 'ship in the bundle'],
125
- ['Agent', 'AGENT', 'a capability specialist'],
126
- ['Agents', 'AGENTS', 'the roster of specialists'],
127
- ['Skill', 'SKILL', 'a repeatable workflow'],
128
- ['Skills', 'SKILLS', 'the catalogue of workflows'],
129
- ['Memory', 'MEMORY', 'durable, one-fact-per-file knowledge'],
130
- ['Notebook', 'NOTEBOOK', "the agent's sovereign space"],
131
- ['Wiki', 'WIKI', 'the machine-wide knowledge base'],
132
- // Still a token, still themed, but it names a CONCEPT INSIDE the ontology's Telos now
133
- // rather than a peer of the Rules — `src/postures/peer.md` cites it and the section that
134
- // used to carry it is the Ethos.
135
- ['Pact', 'PACT', 'the two-way collaboration contract, stated in Telos, the first Ethos '
136
- + 'section'],
137
- ['Posture', null, 'the relationship register the agent works in '
138
- + '(peer, mentor, expert, assistant, artisan)'],
139
- ['Mode', null, 'how work gets executed — direct (the agent works every '
140
- + 'task itself) or foreman (substantial tasks spawn an isolated pipeline)'],
141
- ['Footprint', null, 'how much of the Rules loads inline each turn '
142
- + '(full vs lean)'],
143
- ['Profile', null, 'who you are — seeded once, colours but never binds'],
144
- ['Memory force', null, "a memory's binding strength (constraint, "
145
- + 'choice, conviction, tempered)'],
146
- ['Tagline', 'TAGLINE', 'the one-line essence of the theme'],
147
- ['Loaded sigil', 'LOADED_SIGIL', 'what the agent emits when ready'],
148
- ['Benediction', 'BENEDICTION', 'the closing line of an install'],
149
- ];
141
+ export function tableRows(body, head) {
142
+ const lines = body.split('\n');
143
+ for (let i = 0; i + 1 < lines.length; i += 1) {
144
+ if (!lines[i].trim().startsWith('|')) continue;
145
+ const h = tableCells(lines[i]).map((c) => c.toLowerCase());
146
+ if (h.length !== head.length || h.some((c, j) => c !== head[j])) continue;
147
+ const rows = [];
148
+ for (let j = i + 2; j < lines.length && lines[j].trim().startsWith('|'); j += 1) {
149
+ const row = tableCells(lines[j]);
150
+ if (row.length !== head.length) return null;
151
+ rows.push(row);
152
+ }
153
+ return rows;
154
+ }
155
+ return null;
156
+ }
157
+
158
+ const MAP_HEAD = ['piece', 'what it does for you', 'kind', 'cost', 'turn it off'];
159
+ export const MAP_KINDS = ['enforced', 'automatic', 'asked', 'on demand'];
160
+
161
+ /** The "what lands on your machine" table as rows, or `null` when absent or malformed. */
162
+ export function parseMapTable(body) {
163
+ const rows = tableRows(body, MAP_HEAD);
164
+ if (!rows || !rows.length) return null;
165
+ const out = rows.map(([piece, does, kind, cost, off]) => (
166
+ { piece, does, kind: kind.toLowerCase(), cost, off }));
167
+ return out.every((r) => MAP_KINDS.includes(r.kind)) ? out : null;
168
+ }
169
+
170
+ const GLOSSARY_HEAD = ['term', 'theme key', 'meaning', 'in dev terms'];
171
+
172
+ /**
173
+ * The glossary table: term, the theme key whose value renames it (`—` when no theme does),
174
+ * the meaning, and the developer analogy (`—` when there is none).
175
+ */
176
+ export function parseGlossaryTable(body) {
177
+ const rows = tableRows(body, GLOSSARY_HEAD);
178
+ if (!rows) return [];
179
+ const dash = (s) => (s === '—' || s === '-' || s === '' ? null : s);
180
+ return rows.map(([label, key, desc, analogy]) => (
181
+ { label, key: dash(key), desc, analogy: dash(analogy) }));
182
+ }
183
+
184
+ // ---- links between pages ----------------------------------------------------------------
185
+
186
+ const MD_LINK_RE = /\]\((?!https?:|#|mailto:)([^)\s]+?\.md)(#[^)\s]*)?\)/g;
187
+ // ponytail: the canonical repo, not the install's origin; a fork's console links upstream.
188
+ const GITHUB_BLOB = 'https://github.com/Arylmera/Geneseed/blob/main/';
189
+
190
+ /**
191
+ * Relative `.md` links, which is how the pages link on GitHub, rewritten for the console.
192
+ *
193
+ * A target that is a docs page becomes `#/docs/<id>` (its `#anchor` dropped — the console
194
+ * addresses pages, not headings); any other repo file — a maintainer doc, a README under
195
+ * `src/` — opens on GitHub, since the console does not serve it. Absolute URLs, in-page
196
+ * anchors and fenced examples are left alone.
197
+ */
198
+ export function rewriteDocLinks(body, fromRel, idByRel) {
199
+ if (!body.includes('.md')) return body;
200
+ let inFence = false;
201
+ return body.split('\n').map((line) => {
202
+ if (line.startsWith('```')) { inFence = !inFence; return line; }
203
+ if (inFence) return line;
204
+ return line.replace(MD_LINK_RE, (m, target, anchor) => {
205
+ const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(fromRel), target));
206
+ const id = idByRel.get(resolved);
207
+ return id ? `](#/docs/${id})` : `](${GITHUB_BLOB}${resolved}${anchor ?? ''})`;
208
+ });
209
+ }).join('\n');
210
+ }
211
+
212
+ function idsByRel() {
213
+ return new Map(docSources().map((s) => [s.rel, s.id]));
214
+ }
150
215
 
151
216
  // ---- counts substituted into concept bodies -------------------------------------------
152
217
 
@@ -239,6 +304,13 @@ export function harnessBlocksBalanced(lines) {
239
304
  return !open;
240
305
  }
241
306
 
307
+ /**
308
+ * The visible `*(OpenCode only)*` line a block opens with. It exists for GitHub, which hides
309
+ * the marker; the console has already filtered the block to the reader's host, so there the
310
+ * label only repeats what the host selector says, and it goes with the marker.
311
+ */
312
+ const HOST_LABEL_RE = /^\s*\*\((OpenCode|Claude Code) only\)\*\s*$/;
313
+
242
314
  export function stripHarnessBlocks(body, harnessName) {
243
315
  if (!HARNESS_HINT_RE.test(body)) return body;
244
316
  const lines = splitLines(body);
@@ -246,7 +318,10 @@ export function stripHarnessBlocks(body, harnessName) {
246
318
  const out = [];
247
319
  let keep = true;
248
320
  let inFence = false;
321
+ let afterOpen = false;
249
322
  for (const line of lines) {
323
+ const labelSlot = afterOpen;
324
+ afterOpen = false;
250
325
  if (line.startsWith('```')) {
251
326
  inFence = !inFence;
252
327
  if (keep) out.push(line);
@@ -254,8 +329,9 @@ export function stripHarnessBlocks(body, harnessName) {
254
329
  }
255
330
  if (!inFence) {
256
331
  const m = HARNESS_OPEN_RE.exec(line);
257
- if (m) { keep = m[1] === harnessName; continue; }
332
+ if (m) { keep = m[1] === harnessName; afterOpen = true; continue; }
258
333
  if (HARNESS_CLOSE_RE.test(line)) { keep = true; continue; }
334
+ if (labelSlot && HOST_LABEL_RE.test(line)) continue;
259
335
  }
260
336
  if (keep) out.push(line);
261
337
  }
@@ -363,8 +439,8 @@ function readDocSource(rel) {
363
439
  return readMaybe(target) ?? '';
364
440
  }
365
441
 
366
- /** The deployed theme's words beside the neutral ones. */
367
- function glossary(state) {
442
+ /** The deployed theme's words beside the neutral ones, from `docs/reference/glossary.md`. */
443
+ function glossary(state, page) {
368
444
  const load = (theme) => {
369
445
  const doc = readJsonMaybe(path.join(THEMES, `${theme}.json`));
370
446
  return doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : {};
@@ -372,10 +448,10 @@ function glossary(state) {
372
448
  const neutral = load('neutral');
373
449
  const themed = state.theme !== 'neutral' ? load(state.theme) : neutral;
374
450
  const rows = [];
375
- for (const [label, key, desc] of GLOSSARY_KEYS) {
451
+ for (const { label, key, desc, analogy } of parseGlossaryTable(page.body ?? '')) {
376
452
  if (key === null) {
377
453
  const term = label.toLowerCase();
378
- rows.push({ label, neutral: term, themed: term, desc });
454
+ rows.push({ label, neutral: term, themed: term, desc, analogy });
379
455
  continue;
380
456
  }
381
457
  rows.push({
@@ -383,6 +459,7 @@ function glossary(state) {
383
459
  neutral: String(Object.hasOwn(neutral, key) ? neutral[key] : '').trim(),
384
460
  themed: String(Object.hasOwn(themed, key) ? themed[key] : '').trim(),
385
461
  desc,
462
+ analogy,
386
463
  });
387
464
  }
388
465
  return { theme: state.theme, rows };
@@ -452,7 +529,7 @@ export function apiDocsPage(state, pageId, harnessName = null) {
452
529
  }
453
530
 
454
531
  /**
455
- * The five kinds, as a TABLE and not five `if`s.
532
+ * The six kinds, as a TABLE and not six `if`s.
456
533
  *
457
534
  * `tests/unit/web_server.test.mjs` holds `Object.keys(KIND_ROUTES)` against a written-out
458
535
  * list of the kinds `apiDocsPage` must dispatch on — this table is what the dispatcher
@@ -470,17 +547,24 @@ const KIND_ROUTES = {
470
547
  body = sliced;
471
548
  if (ok) anchor = null;
472
549
  }
473
- body = stripHarnessBlocks(body, hn);
550
+ body = rewriteDocLinks(stripHarnessBlocks(body, hn), page.source, idsByRel());
474
551
  return { id: pageId, title: page.title, kind: 'markdown', body,
475
552
  source: page.source, anchor, links: resolveLinks(state, body) };
476
553
  },
477
554
  concept: (state, pageId, page, hn) => {
478
- const body = subCounts(state, stripHarnessBlocks(page.body ?? '', hn));
555
+ const body = rewriteDocLinks(subCounts(state, stripHarnessBlocks(page.body ?? '', hn)),
556
+ page.rel, idsByRel());
479
557
  return { id: pageId, title: page.title, kind: 'concept', body,
480
558
  link: page.link ?? null, links: resolveLinks(state, body) };
481
559
  },
560
+ // A concept page plus the rows of its own map table, for the console's map view.
561
+ map: (state, pageId, page, hn) => {
562
+ const body = rewriteDocLinks(stripHarnessBlocks(page.body ?? '', hn), page.rel, idsByRel());
563
+ return { id: pageId, title: page.title, kind: 'map', body, rows: parseMapTable(body),
564
+ links: resolveLinks(state, body) };
565
+ },
482
566
  glossary: (state, pageId, page) => ({
483
- id: pageId, title: page.title, kind: 'glossary', ...glossary(state),
567
+ id: pageId, title: page.title, kind: 'glossary', ...glossary(state, page),
484
568
  }),
485
569
  about: (state, pageId, page) => ({
486
570
  id: pageId, title: page.title, kind: 'about', ...about(state),
package/js/web/server.mjs CHANGED
@@ -28,7 +28,7 @@ import { which } from '../lib/paths.mjs';
28
28
  import { parseIntStrict } from '../lib/text.mjs';
29
29
  import { promptLine } from '../maintain/setup.mjs';
30
30
  import { webState } from './api.mjs';
31
- import { clearDaemon, openUrl, readDaemon, restartDaemon, startDaemon, statusDaemon, stopDaemon, writeDaemon } from './daemon.mjs';
31
+ import { clearDaemon, codeStamp, openUrl, readDaemon, restartDaemon, startDaemon, statusDaemon, stopDaemon, writeDaemon } from './daemon.mjs';
32
32
  import { isFile, makeHandler } from './handler.mjs';
33
33
  import { JobManager } from './jobs.mjs';
34
34
  import { spawnSync } from 'node:child_process';
@@ -137,6 +137,9 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
137
137
  printOut(' Run `geneseed setup` first — serving anyway (read-only UI).\n');
138
138
  }
139
139
  const token = randomBytes(24).toString('base64url');
140
+ // The code this server loaded, compared on every overview against the files on disk now.
141
+ const startCode = codeStamp();
142
+ state.daemonStale = () => startCode !== codeStamp();
140
143
  const holder = {};
141
144
  // The console's job list survives a reload and a restart because it is a FILE on disk,
142
145
  // not held in process memory.
@@ -156,7 +159,7 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
156
159
  if (daemon) {
157
160
  writeDaemon(state.target, {
158
161
  pid: process.pid, port: hostPort, url,
159
- token, theme: state.theme, started: Math.floor(Date.now() / 1000),
162
+ token, theme: state.theme, started: Math.floor(Date.now() / 1000), code: startCode,
160
163
  });
161
164
  }
162
165
  printOut(`[web] Geneseed UI on ${url} (theme: ${state.theme})\n`);
package/package.json CHANGED
@@ -1,14 +1,13 @@
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": "3.9.0",
4
+ "version": "3.11.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",
8
8
  "agent",
9
9
  "claude-code",
10
10
  "opencode",
11
- "copilot",
12
11
  "openclaude",
13
12
  "agents-md",
14
13
  "skills",
@@ -39,7 +38,11 @@
39
38
  "themes/",
40
39
  "adapters/",
41
40
  "docs/*.md",
42
- "docs/web/",
41
+ "docs/_groups.json",
42
+ "docs/understand/",
43
+ "docs/guides/",
44
+ "docs/concepts/",
45
+ "docs/reference/",
43
46
  "web/dist/",
44
47
  "web/src/pages/Laws.jsx",
45
48
  "harness.config.json",
@@ -48,8 +51,6 @@
48
51
  "geneseed.cmd",
49
52
  "CHANGELOG.md",
50
53
  "DESIGN.md",
51
- "QUICKSTART.md",
52
- "SETUP.md",
53
54
  "SHIPPED.md"
54
55
  ],
55
56
  "devDependencies": {
package/registry.json CHANGED
@@ -295,6 +295,13 @@
295
295
  "added": "2026-06-07",
296
296
  "last_verified": "2026-09-06"
297
297
  },
298
+ "skills/learn-mode": {
299
+ "status": "experimental",
300
+ "version": "1.0.0",
301
+ "owner": "Guillaume Lemer",
302
+ "added": "2026-10-02",
303
+ "last_verified": ""
304
+ },
298
305
  "skills/migrate": {
299
306
  "status": "approved",
300
307
  "version": "1.0.0",