geneseed 3.10.0 → 3.12.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 (227) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/DESIGN.md +1 -1
  3. package/README.md +27 -45
  4. package/SHIPPED.md +2 -2
  5. package/adapters/bob/README.md +1 -1
  6. package/adapters/claude-code/README.md +17 -12
  7. package/adapters/openclaude/README.md +1 -1
  8. package/adapters/opencode/README.md +4 -4
  9. package/adapters/opencode/plugins/geneseed-guard.js +157 -2
  10. package/bin/build-driver.mjs +2 -0
  11. package/bin/geneseed-cli.mjs +21 -0
  12. package/bin/geneseed-hook.mjs +2 -0
  13. package/docs/README.md +42 -0
  14. package/docs/_groups.json +18 -0
  15. package/docs/concepts/agents.md +44 -0
  16. package/docs/concepts/collaboration.md +62 -0
  17. package/docs/concepts/exclusions.md +55 -0
  18. package/docs/concepts/footprint.md +52 -0
  19. package/docs/concepts/foreman-mode.md +69 -0
  20. package/docs/concepts/hooks.md +101 -0
  21. package/docs/concepts/hosts.md +55 -0
  22. package/docs/concepts/loops.md +165 -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 +32 -21
  30. package/docs/guides/autostart.md +70 -0
  31. package/docs/guides/choose-your-setup.md +134 -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 +37 -0
  47. package/docs/reference/opencode-plugins.md +133 -0
  48. package/docs/reference/troubleshooting.md +350 -0
  49. package/docs/understand/a-day.md +77 -0
  50. package/docs/understand/enforced-vs-asked.md +64 -0
  51. package/docs/understand/harness.md +36 -0
  52. package/docs/understand/on-your-machine.md +76 -0
  53. package/docs/web-ui.md +13 -2
  54. package/harness.config.json +1 -1
  55. package/js/README.md +27 -4
  56. package/js/build/driver.mjs +14 -8
  57. package/js/build/generate.mjs +22 -7
  58. package/js/build/render.mjs +5 -0
  59. package/js/build/source.mjs +11 -1
  60. package/js/cli-table.json +236 -1
  61. package/js/hosts/gitref.mjs +99 -0
  62. package/js/hosts/hooks.mjs +88 -1
  63. package/js/hosts/installs.mjs +21 -0
  64. package/js/hosts/mcp.mjs +1 -1
  65. package/js/hosts/settings.mjs +13 -1
  66. package/js/inspect/checks-authoring.mjs +15 -3
  67. package/js/inspect/diff.mjs +3 -0
  68. package/js/inspect/doctor.mjs +3 -2
  69. package/js/inspect/inventory.mjs +2 -1
  70. package/js/inspect/registry.mjs +20 -3
  71. package/js/inspect/status.mjs +8 -3
  72. package/js/lib/node-floor.mjs +30 -0
  73. package/js/loop/catalog.mjs +116 -0
  74. package/js/loop/cli.mjs +151 -0
  75. package/js/loop/graph.mjs +145 -0
  76. package/js/loop/score.mjs +143 -0
  77. package/js/loop/state.mjs +322 -0
  78. package/js/maintain/migrate.mjs +4 -2
  79. package/js/maintain/setup.mjs +109 -21
  80. package/js/maintain/update.mjs +4 -15
  81. package/js/ui/cli.mjs +2 -2
  82. package/js/web/actions.mjs +9 -3
  83. package/js/web/api.mjs +41 -2
  84. package/js/web/daemon.mjs +94 -4
  85. package/js/web/docs.mjs +149 -65
  86. package/js/web/handler.mjs +1 -0
  87. package/js/web/jobs.mjs +3 -1
  88. package/js/web/server.mjs +10 -5
  89. package/package.json +6 -4
  90. package/registry.json +21 -0
  91. package/src/AGENT.md.tmpl +6 -0
  92. package/src/bricks/README.md +32 -0
  93. package/src/bricks/_template.md +32 -0
  94. package/src/bricks/apply.md +17 -0
  95. package/src/bricks/baseline-green.md +17 -0
  96. package/src/bricks/bruno-test.md +16 -0
  97. package/src/bricks/ci-fix.md +17 -0
  98. package/src/bricks/deps-audit.md +14 -0
  99. package/src/bricks/docs-update.md +15 -0
  100. package/src/bricks/identify.md +20 -0
  101. package/src/bricks/lint.md +14 -0
  102. package/src/bricks/plan.md +17 -0
  103. package/src/bricks/reproduce.md +16 -0
  104. package/src/bricks/review.md +17 -0
  105. package/src/bricks/security-scan.md +17 -0
  106. package/src/bricks/test.md +14 -0
  107. package/src/doctrines/process.md +11 -8
  108. package/src/loops/bugfix.json +22 -0
  109. package/src/loops/feature.json +21 -0
  110. package/src/loops/refactor.json +20 -0
  111. package/src/postures/mentor.md +1 -0
  112. package/src/skills/brick-forge.md +46 -0
  113. package/src/skills/geneseed.md +1 -1
  114. package/src/skills/learn-mode.md +89 -0
  115. package/src/skills/loop.md +99 -0
  116. package/src/skills/pipeline.md +2 -2
  117. package/src/skills/quiz.md +2 -1
  118. package/src/skills/teach.md +2 -1
  119. package/themes/_TEMPLATE.json +3 -0
  120. package/themes/biker.json +3 -0
  121. package/themes/commentator.json +3 -0
  122. package/themes/cyberpunk.json +3 -0
  123. package/themes/gamer.json +3 -0
  124. package/themes/imperial.json +3 -0
  125. package/themes/joker.json +3 -0
  126. package/themes/marvin.json +3 -0
  127. package/themes/mean.json +3 -0
  128. package/themes/military.json +3 -0
  129. package/themes/neutral.json +3 -0
  130. package/themes/pirate.json +3 -0
  131. package/themes/sports.json +3 -0
  132. package/themes/verstappen.json +3 -0
  133. package/themes/wizard.json +3 -0
  134. package/web/dist/assets/Activity-GaaZWXTY.js +1 -0
  135. package/web/dist/assets/ActivityDetail-4t7d45Du.js +1 -0
  136. package/web/dist/assets/Dashboard-CYn7Tp16.js +2 -0
  137. package/web/dist/assets/Docs-BeFKLg2T.js +5 -0
  138. package/web/dist/assets/{GreenhouseView-D0cbCJ5m.js → GreenhouseView-C6ibc8IE.js} +1 -1
  139. package/web/dist/assets/Installs-DRYjYnxs.js +2 -0
  140. package/web/dist/assets/JournalView-1HbU1mfg.js +1 -0
  141. package/web/dist/assets/Laws-DFoG8JWB.js +3 -0
  142. package/web/dist/assets/Library-DDjgNwz_.js +1 -0
  143. package/web/dist/assets/Loops-D9VEoO0F.js +1 -0
  144. package/web/dist/assets/{Markdown-CkFyfgNf.js → Markdown-qjM2_ofS.js} +5 -5
  145. package/web/dist/assets/{OperatorHudView-CiAkAkQL.js → OperatorHudView-Bs7paWmh.js} +1 -1
  146. package/web/dist/assets/Personal-BY5n4gTw.js +1 -0
  147. package/web/dist/assets/Tabs-ho-EYD8i.js +1 -0
  148. package/web/dist/assets/api-CyMRbU2x.js +1 -0
  149. package/web/dist/assets/drawable-CLS9drMb.js +1 -0
  150. package/web/dist/assets/index-CX_hXMi5.css +1 -0
  151. package/web/dist/assets/index-Cjr7XCA3.js +2 -0
  152. package/web/dist/index.html +3 -2
  153. package/QUICKSTART.md +0 -50
  154. package/SETUP.md +0 -1148
  155. package/docs/web/_groups.json +0 -45
  156. package/docs/web/about.md +0 -6
  157. package/docs/web/adapters-claude-code.md +0 -8
  158. package/docs/web/adapters-opencode-loads.md +0 -8
  159. package/docs/web/adapters-opencode-spec.md +0 -8
  160. package/docs/web/adapters-opencode.md +0 -8
  161. package/docs/web/agents.md +0 -8
  162. package/docs/web/authoring.md +0 -11
  163. package/docs/web/autostart.md +0 -9
  164. package/docs/web/claude-hooks.md +0 -37
  165. package/docs/web/cli.md +0 -6
  166. package/docs/web/collaboration.md +0 -27
  167. package/docs/web/configure-wiki.md +0 -9
  168. package/docs/web/design.md +0 -7
  169. package/docs/web/env-knobs.md +0 -9
  170. package/docs/web/exclusions.md +0 -26
  171. package/docs/web/first-session.md +0 -28
  172. package/docs/web/footprint.md +0 -38
  173. package/docs/web/foreman-mode.md +0 -33
  174. package/docs/web/glossary.md +0 -6
  175. package/docs/web/harnesses.md +0 -30
  176. package/docs/web/headless.md +0 -9
  177. package/docs/web/ingest-docs.md +0 -9
  178. package/docs/web/install-paths.md +0 -9
  179. package/docs/web/install-quick.md +0 -35
  180. package/docs/web/lsp-overview.md +0 -37
  181. package/docs/web/mcp-claude-code.md +0 -10
  182. package/docs/web/mcp-filesystem.md +0 -9
  183. package/docs/web/mcp-gitlab.md +0 -9
  184. package/docs/web/mcp-markitdown.md +0 -9
  185. package/docs/web/mcp-overview.md +0 -34
  186. package/docs/web/mcp-trouble.md +0 -9
  187. package/docs/web/mcp-verify.md +0 -9
  188. package/docs/web/migrate.md +0 -39
  189. package/docs/web/model.md +0 -20
  190. package/docs/web/pipelines.md +0 -24
  191. package/docs/web/plugin-activity.md +0 -23
  192. package/docs/web/plugin-context.md +0 -26
  193. package/docs/web/plugin-guard.md +0 -24
  194. package/docs/web/plugin-learn.md +0 -27
  195. package/docs/web/plugin-notify.md +0 -24
  196. package/docs/web/plugin-ponytail.md +0 -23
  197. package/docs/web/plugin-setup.md +0 -7
  198. package/docs/web/plugin-workflow.md +0 -25
  199. package/docs/web/plugins.md +0 -17
  200. package/docs/web/project-context.md +0 -9
  201. package/docs/web/rules.md +0 -30
  202. package/docs/web/run-anywhere.md +0 -9
  203. package/docs/web/self-improve.md +0 -8
  204. package/docs/web/setup-choices.md +0 -64
  205. package/docs/web/skills.md +0 -8
  206. package/docs/web/themes-author.md +0 -20
  207. package/docs/web/themes.md +0 -9
  208. package/docs/web/token-footprint.md +0 -7
  209. package/docs/web/trouble.md +0 -38
  210. package/docs/web/upgrade.md +0 -9
  211. package/docs/web/verify.md +0 -30
  212. package/docs/web/wiki.md +0 -7
  213. package/docs/web/worktree.md +0 -10
  214. package/docs/wiki.md +0 -43
  215. package/web/dist/assets/Activity-BjVp522q.js +0 -1
  216. package/web/dist/assets/ActivityDetail-8ep1bspW.js +0 -1
  217. package/web/dist/assets/Dashboard-OkC_PCH6.js +0 -2
  218. package/web/dist/assets/Docs-CKPWz00o.js +0 -2
  219. package/web/dist/assets/Installs-BtGGdoTt.js +0 -2
  220. package/web/dist/assets/JournalView-Ckl4dmzR.js +0 -1
  221. package/web/dist/assets/Laws-CLpdRRk8.js +0 -3
  222. package/web/dist/assets/Library-wPPuKQrI.js +0 -1
  223. package/web/dist/assets/Personal-D8CnDo9v.js +0 -1
  224. package/web/dist/assets/Tabs-B4HNrrCs.js +0 -1
  225. package/web/dist/assets/drawable-BiwaOo6x.js +0 -1
  226. package/web/dist/assets/index-C1yil5j1.js +0 -2
  227. package/web/dist/assets/index-CDPDk2vj.css +0 -1
package/js/web/api.mjs CHANGED
@@ -21,10 +21,12 @@ import { excludesSnapshot } from '../inspect/excludes.mjs';
21
21
  import { GLOBAL_MANIFEST, HOSTS, opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
22
22
  import {
23
23
  doctrinesForBuild, excludedRulesOfDir, footprintOfDir, installState, installTargets,
24
- installedDefaults, modeOfDir,
24
+ installedDefaults, modeOfDir, trustOfDir,
25
25
  postureOfDir, readJsonMaybe, readMaybe, themeOfDir,
26
26
  } from '../hosts/installs.mjs';
27
27
  import { frontmatter } from '../hosts/hooks.mjs';
28
+ import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
29
+ import { loadCatalog, catalogProblems } from '../loop/catalog.mjs';
28
30
  import {
29
31
  SKILL_CLASS, entityStatus, loadRegistry, tuiInventory,
30
32
  } from '../inspect/inventory.mjs';
@@ -87,6 +89,7 @@ export function webState(theme = null, target = null) {
87
89
  footprint: null,
88
90
  posture: null,
89
91
  mode: null,
92
+ trust: null,
90
93
  _inv: null,
91
94
  _doctor: null,
92
95
  };
@@ -98,6 +101,7 @@ export function webState(theme = null, target = null) {
98
101
  st.footprint = footprintOfDir(st.target); // 'full' when no marker
99
102
  st.posture = postureOfDir(st.target) || 'peer';
100
103
  st.mode = modeOfDir(st.target) || 'direct';
104
+ st.trust = trustOfDir(st.target) || DEFAULT_PRESET;
101
105
 
102
106
  Object.defineProperty(st, 'inventory', {
103
107
  get() {
@@ -154,6 +158,7 @@ export function webState(theme = null, target = null) {
154
158
  st.footprint = footprintOfDir(st.root);
155
159
  st.posture = postureOfDir(st.root) || 'peer';
156
160
  st.mode = modeOfDir(st.root) || 'direct';
161
+ st.trust = trustOfDir(st.root) || DEFAULT_PRESET;
157
162
  st._inv = null;
158
163
  st._doctor = null;
159
164
  };
@@ -165,6 +170,7 @@ export function webState(theme = null, target = null) {
165
170
  st.footprint = footprintOfDir(st.root);
166
171
  st.posture = postureOfDir(st.root) || 'peer';
167
172
  st.mode = modeOfDir(st.root) || 'direct';
173
+ st.trust = trustOfDir(st.root) || DEFAULT_PRESET;
168
174
  };
169
175
  return st;
170
176
  }
@@ -741,11 +747,12 @@ export function apiInstalls(state) {
741
747
  footprint: footprintOfDir(root),
742
748
  posture: postureOfDir(root),
743
749
  mode: modeOfDir(root),
750
+ trust: trustOfDir(root),
744
751
  selected: samePath(viewCfg(host, scope, root), state.target),
745
752
  });
746
753
  }
747
754
  return { installs: out, postures: discoverNames('postures', 'peer'),
748
- modes: discoverNames('modes', 'direct') };
755
+ modes: discoverNames('modes', 'direct'), trusts: Object.keys(PRESETS) };
749
756
  }
750
757
 
751
758
  /** Beside the deployed AGENT.md. */
@@ -819,6 +826,9 @@ export function apiOverview(state) {
819
826
  install,
820
827
  target: state.target,
821
828
  deployed: deployed(state),
829
+ // True when the files on disk changed under this server since it started (an upgrade).
830
+ // `serve` installs the comparison; a state built without a server has none and is false.
831
+ daemon_stale: Boolean(state.daemonStale?.()),
822
832
  counts: {
823
833
  agents: inv.agents.length,
824
834
  skills: inv.skills.length,
@@ -845,6 +855,9 @@ export function apiOverview(state) {
845
855
  notebook: notebookItems(state).length,
846
856
  wiki: wikiItems(state).length,
847
857
  config: configItems(state).length,
858
+ // The rail's Loops badge: templates only, from the same catalogue `/api/loops` reads —
859
+ // a brick is a part, the template is what a person runs.
860
+ loops: loadCatalog({ projectRoot: state.root }).templates.size,
848
861
  },
849
862
  doctor: state.doctor,
850
863
  diff,
@@ -930,6 +943,31 @@ export function apiRecent(state) {
930
943
  return { items: items.slice(0, RECENT_LIMIT), skipped, limit: RECENT_LIMIT };
931
944
  }
932
945
 
946
+ /**
947
+ * The Loops page: the brick and template catalogue the `geneseed loop` CLI reads, resolved from
948
+ * the install ROOT — for a project install that is the repo, where `<repo>/.geneseed/` lives; for
949
+ * a global one it is the config dir, which has no `.geneseed/` and so adds nothing. Read-only:
950
+ * the page draws templates and lists bricks, it never writes one.
951
+ *
952
+ * A template's graph is passed whole (`graph`) rather than flattened, because the page's ring
953
+ * layout needs `nodes`, `edges` and `loops` exactly as the engine reads them.
954
+ */
955
+ export function apiLoops(state) {
956
+ const { bricks, templates, overridden } = loadCatalog({ projectRoot: state.root });
957
+ const byName = ([a], [b]) => (a < b ? -1 : 1);
958
+ return {
959
+ templates: [...templates].sort(byName).map(([name, { origin, ...graph }]) => ({
960
+ name, description: graph.description || '', origin, graph,
961
+ })),
962
+ bricks: [...bricks].sort(byName).map(([, b]) => ({ reason: null, ...b })),
963
+ overridden,
964
+ // A brick or template the catalogue skipped (bad frontmatter, not JSON, misnamed), and a
965
+ // template that fails the graph rules: the page says so — the same list `loop check`
966
+ // prints — or a team's broken override would just silently not be there.
967
+ problems: catalogProblems({ projectRoot: state.root }),
968
+ };
969
+ }
970
+
933
971
  /** The source's release label, or null when it cannot be read. */
934
972
  function releaseLabel() {
935
973
  const v = sourceReleaseVersion({ config: CONFIG });
@@ -979,4 +1017,5 @@ export const STATE_ROUTES = {
979
1017
  // answers — a daemon whose POST worked and whose GET 501'd would have no way to obtain one.
980
1018
  '/api/rules': apiRules,
981
1019
  '/api/mcp': apiMcp,
1020
+ '/api/loops': apiLoops,
982
1021
  };
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) {
@@ -243,9 +267,42 @@ export async function startDaemon(theme, port, openBrowser = true) {
243
267
  return 1;
244
268
  }
245
269
 
246
- export async function stopDaemon(theme = null) {
270
+ /**
271
+ * `--port` NAMES WHICH DAEMON, and the record holds only one. With no `--port` (`port` null)
272
+ * the verbs act on whatever the record names, as they always have. With one, they act only if
273
+ * the record is on that port: `stop --port 4791` once POSTed shutdown to the recorded url and
274
+ * stopped the console on 4747. So a record on another port is refused — no request sent, record
275
+ * left in place — and a server answering on the named port that is NOT the record is explained,
276
+ * because its token is not on record and there is nothing to guess it from.
277
+ *
278
+ * Null when the verb may go ahead; otherwise the exit code, already explained on stdout.
279
+ */
280
+ async function portRefusal(st, port, verb) {
281
+ if (port === null || port === undefined) return null;
282
+ const recorded = isTruthy(st) && isTruthy(st.url) ? Number(st.port) : null;
283
+ if (recorded === port) return null;
284
+ if (recorded !== null) {
285
+ if (verb === 'status') {
286
+ printOut(`[web] not running on port ${port} (the recorded daemon is on port ${recorded}).\n`);
287
+ } else {
288
+ printOut(`[web] the recorded daemon is on port ${recorded}, not ${port} — not touching it.\n`);
289
+ printOut(` \`geneseed web ${verb} --port ${recorded}\` (or no --port) ${verb}s that one.\n`);
290
+ }
291
+ }
292
+ if (await probe(`http://127.0.0.1:${port}`)) {
293
+ printOut(`[web] a server answers on port ${port} but is not the recorded daemon, so its `
294
+ + 'token is unknown — stop it from its own page (Stop) or end its process.\n');
295
+ return 1;
296
+ }
297
+ // Nothing recorded and nothing on the named port: the verb's own no-daemon arm says so.
298
+ return recorded === null ? null : 1;
299
+ }
300
+
301
+ export async function stopDaemon(theme = null, port = null) {
247
302
  const { target } = webState(theme);
248
303
  const st = readDaemon(target);
304
+ const refused = await portRefusal(st, port, 'stop');
305
+ if (refused !== null) return refused;
249
306
  if (!isTruthy(st) || !isTruthy(st.url)) {
250
307
  // NO `clearDaemon` HERE — deliberately asymmetric with `liveDaemon`/`statusDaemon`,
251
308
  // which DO delete a record naming no url. Unifying the two would delete a file this
@@ -263,11 +320,14 @@ export async function stopDaemon(theme = null) {
263
320
  return 0;
264
321
  }
265
322
 
266
- export async function statusDaemon(theme = null) {
323
+ export async function statusDaemon(theme = null, port = null) {
267
324
  const { target } = webState(theme);
325
+ const refused = await portRefusal(readDaemon(target), port, 'status');
326
+ if (refused !== null) return refused;
268
327
  const st = await liveDaemon(target);
269
328
  if (st) {
270
329
  printOut(`[web] running on ${st.url} (theme: ${none(st.theme)}, pid ${none(st.pid)})\n`);
330
+ if (daemonStale(st)) printOut(`${STALE_LINE}\n`);
271
331
  return 0;
272
332
  }
273
333
  // EXIT 1, which is the verb's whole contract for a script. The snapshot's `<exit>`
@@ -276,13 +336,43 @@ export async function statusDaemon(theme = null) {
276
336
  return 1;
277
337
  }
278
338
 
279
- export async function restartDaemon(theme = null, port = 4747, openBrowser = true,
339
+ /**
340
+ * Bounce a running console so it serves the code now on disk — `upgrade` after it rebuilt,
341
+ * `rebuild-all` when the running one is stale. EXCEPT inside a web job (`GENESEED_WEB_JOB`,
342
+ * set by `js/web/jobs.mjs`): restarting here would kill the process tracking that very job,
343
+ * leaving the console stuck on 'running'; the server restarts itself after an update job, and
344
+ * shows its stale banner otherwise. `onlyIfStale` is `rebuild-all`'s: after an `upgrade`
345
+ * (which bounces, then runs `rebuild-all`) the console is current, and bouncing it again
346
+ * would drop the open tab for nothing. Never throws — no rebuild fails over a console.
347
+ */
348
+ export async function bounceDaemonIfRunning(theme, log, { onlyIfStale = false } = {}) {
349
+ if (onlyIfStale) {
350
+ const st = readDaemon(webState(theme).target);
351
+ if (!st || !daemonStale(st)) return;
352
+ }
353
+ if (process.env.GENESEED_WEB_JOB) {
354
+ log(onlyIfStale
355
+ ? '[geneseed] the web console is running old code — use its Restart banner.'
356
+ : '[geneseed] web daemon will restart itself after this job to load the new code.');
357
+ return;
358
+ }
359
+ try {
360
+ await restartDaemon(theme, null, false, true);
361
+ } catch (e) {
362
+ log(`[geneseed] ⚠️ could not refresh the web daemon (${e && e.message ? e.message : e}) — \`geneseed web restart\` manually if it was running.`);
363
+ }
364
+ }
365
+
366
+ /** `port` null keeps the recorded daemon's port (4747 if none); see `portRefusal`. */
367
+ export async function restartDaemon(theme = null, port = null, openBrowser = true,
280
368
  onlyIfRunning = false) {
281
369
  const { target } = webState(theme);
282
370
  const st = readDaemon(target);
371
+ const refused = await portRefusal(st, port, 'restart');
372
+ if (refused !== null) return refused;
283
373
  const live = (await liveDaemon(target)) !== null;
284
374
  if (onlyIfRunning && !live) return 0;
285
- const usePort = (st && isTruthy(st.port) ? st.port : null) || port;
375
+ const usePort = port ?? ((st && isTruthy(st.port) ? st.port : null) || 4747);
286
376
  let open = openBrowser;
287
377
  if (live) {
288
378
  // A live daemon means a tab is already open on this (preserved) port and will
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),
@@ -295,6 +295,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
295
295
  // packs — never to `harness.config.json`, which would drop the consent gate.
296
296
  const cmds = actionCommands(action, {
297
297
  theme, emit, footprint: state.footprint, posture: state.posture, mode: state.mode,
298
+ trust: state.trust,
298
299
  doctrines: doctrinesForBuild(state.target),
299
300
  });
300
301
  if (!isTruthy(cmds)) {
package/js/web/jobs.mjs CHANGED
@@ -88,6 +88,7 @@ import path from 'node:path';
88
88
 
89
89
  import { ROOT, PACK_ORDER } from '../build/source.mjs';
90
90
  import { setupBuildArgs } from '../build/generate.mjs';
91
+ import { DEFAULT_PRESET } from '../loop/score.mjs';
91
92
  import { isDict } from '../hosts/mcp.mjs';
92
93
  import { readText, writeText, isFile } from '../lib/fs.mjs';
93
94
  import { jsonDumpsCompact, parseJson, isTruthy } from '../lib/json.mjs';
@@ -402,9 +403,10 @@ export const INLINE_ACTIONS = ['restore', 'install', 'deploy'];
402
403
  function actionTable({
403
404
  theme = 'neutral', emit = 'opencode-global', footprint = 'full',
404
405
  posture = 'peer', mode = 'direct', doctrines = [...PACK_ORDER], excludeRules = [],
406
+ trust = DEFAULT_PRESET,
405
407
  } = {}) {
406
408
  const buildArgv = setupBuildArgs(theme, emit, null, null, footprint, posture, mode,
407
- doctrines, PACK_ORDER, excludeRules);
409
+ doctrines, PACK_ORDER, excludeRules, trust);
408
410
  return {
409
411
  doctor: [[NODE(), CLI(), 'doctor']],
410
412
  build: [[NODE(), GEN(), ...buildArgv]],