geneseed 3.14.0 → 3.15.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 (88) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +23 -9
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +37 -11
  6. package/adapters/opencode/plugins/geneseed-notify.js +37 -19
  7. package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
  8. package/adapters/opencode/workflows/_runtime.js +16 -3
  9. package/bin/geneseed-cli.mjs +22 -20
  10. package/bin/geneseed-hook.mjs +6 -3
  11. package/harness.config.json +1 -1
  12. package/js/README.md +9 -5
  13. package/js/build/args.mjs +4 -3
  14. package/js/build/bundle.mjs +10 -10
  15. package/js/build/driver.mjs +8 -7
  16. package/js/build/emit-claude.mjs +3 -2
  17. package/js/build/emit-common.mjs +2 -14
  18. package/js/build/emit-opencode.mjs +12 -8
  19. package/js/build/generate.mjs +15 -11
  20. package/js/build/render.mjs +1 -1
  21. package/js/build/source.mjs +6 -6
  22. package/js/build/stubs.mjs +6 -5
  23. package/js/build/version.mjs +21 -2
  24. package/js/hosts/hooks-context.mjs +2 -1
  25. package/js/hosts/hooks-prims.mjs +2 -2
  26. package/js/hosts/hosts.mjs +24 -26
  27. package/js/hosts/installs.mjs +33 -14
  28. package/js/hosts/link.mjs +75 -26
  29. package/js/hosts/memory-files.mjs +13 -4
  30. package/js/hosts/native.mjs +106 -48
  31. package/js/hosts/opencode.mjs +44 -36
  32. package/js/hosts/shim.mjs +4 -2
  33. package/js/inspect/diff.mjs +5 -6
  34. package/js/inspect/excludes.mjs +10 -5
  35. package/js/inspect/status.mjs +18 -21
  36. package/js/inspect/validate.mjs +2 -2
  37. package/js/lib/fs.mjs +12 -11
  38. package/js/lib/json.mjs +4 -4
  39. package/js/lib/paths.mjs +27 -33
  40. package/js/lib/proc.mjs +3 -3
  41. package/js/lib/prompt.mjs +132 -0
  42. package/js/lib/text.mjs +18 -4
  43. package/js/lib/udiff.mjs +3 -2
  44. package/js/maintain/memory.mjs +5 -6
  45. package/js/maintain/migrate.mjs +4 -3
  46. package/js/maintain/setup.mjs +9 -131
  47. package/js/maintain/uninstall.mjs +10 -9
  48. package/js/maintain/update.mjs +115 -34
  49. package/js/ui/anim.mjs +8 -12
  50. package/js/ui/cli.mjs +29 -35
  51. package/js/ui/menu.mjs +7 -13
  52. package/js/ui/tui.mjs +14 -123
  53. package/js/web/actions.mjs +82 -386
  54. package/js/web/activity.mjs +1 -1
  55. package/js/web/api.mjs +33 -717
  56. package/js/web/catalog.mjs +527 -0
  57. package/js/web/daemon.mjs +21 -3
  58. package/js/web/handler.mjs +51 -27
  59. package/js/web/jobs.mjs +24 -2
  60. package/js/web/routes.mjs +61 -6
  61. package/js/web/server.mjs +4 -4
  62. package/js/web/state.mjs +138 -0
  63. package/js/web/user-files.mjs +339 -0
  64. package/package.json +1 -1
  65. package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
  66. package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
  67. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  68. package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
  69. package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
  70. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  71. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  72. package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
  73. package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
  74. package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
  75. package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
  76. package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
  77. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  78. package/web/dist/assets/api-CxgfYu18.js +1 -0
  79. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  80. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  81. package/web/dist/index.html +2 -2
  82. package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
  83. package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
  84. package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
  85. package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
  86. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  87. package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
  88. package/web/dist/assets/index-KHt3wE_K.js +0 -2
@@ -0,0 +1,527 @@
1
+ /**
2
+ * The Library's READ MODEL — what the console lists and opens: the agent/skill inventory (read
3
+ * off the deployed install, else rendered from source), the constitution's three tiers, and the
4
+ * hand-written stores beside the install (memory, notebook, wiki, the two setup manifests).
5
+ * `apiCatalog` and `apiItem` are its two endpoints; `routes.mjs` binds them to paths.
6
+ *
7
+ * `NotFound` lives here because this is where it is raised most, and because `state.mjs` imports
8
+ * this file (for the inventory it caches) and nothing here imports `state.mjs` back.
9
+ *
10
+ * `flatName`'s traversal refusal and `CONFIG_META`'s allowlist are the two reasons a GET — which
11
+ * carries no token — cannot read an arbitrary file through `/api/item/`.
12
+ */
13
+ import { existsSync, readdirSync } from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ import { GLOBAL_MANIFEST, resolvePath } from '../hosts/hosts.mjs';
17
+ import { doctrinesForBuild, excludedRulesOfDir, readMaybe } from '../hosts/installs.mjs';
18
+ import { frontmatter } from '../hosts/memory-files.mjs';
19
+ import {
20
+ SKILL_CLASS, entityStatus, loadRegistry, tuiInventory,
21
+ } from '../inspect/inventory.mjs';
22
+ import { firstBlockquote } from '../hosts/native.mjs';
23
+ import { readText, withDiscardableStderr, isDir, isFile } from '../lib/fs.mjs';
24
+ import { comparePaths, normcase, within } from '../lib/paths.mjs';
25
+ import { stripWhitespace } from '../lib/text.mjs';
26
+ import { readJsonc } from '../hosts/settings.mjs';
27
+
28
+ /** A requested catalog section or item that does not exist. */
29
+ export class NotFound extends Error {}
30
+
31
+ /** `??` would also swallow an explicit `null` the file declared; this does not. */
32
+ export const dget = (obj, key, dflt) => (Object.hasOwn(obj, key) ? obj[key] : dflt);
33
+
34
+ export function deployed(state) {
35
+ return existsSync(path.join(state.target, GLOBAL_MANIFEST));
36
+ }
37
+
38
+ /**
39
+ * A deployed spec's one-line purpose.
40
+ *
41
+ * The `> blockquote` convention every rendered skill and agent carries, then the
42
+ * frontmatter `description`, then the first prose paragraph. The fallbacks are for the
43
+ * VENDORED skill folders, which ride in verbatim with no blockquote and would otherwise
44
+ * show a blank Purpose cell.
45
+ */
46
+ export function specDesc(fm, body) {
47
+ const bq = firstBlockquote(body);
48
+ if (bq) return bq;
49
+ const desc = String(fm.has('description') ? fm.get('description') : '').trim();
50
+ if (desc) return desc.split(/\s+/).filter(Boolean).join(' ');
51
+ for (const para of body.split('\n\n')) {
52
+ const s = para.split(/\s+/).filter(Boolean).join(' ');
53
+ if (s && !(s.startsWith('#') || s.startsWith('---') || s.startsWith('<!--'))) return s;
54
+ }
55
+ return '';
56
+ }
57
+
58
+ /**
59
+ * Agent/skill specs read straight off a DEPLOYED harness dir.
60
+ *
61
+ * Agents are flat `<root>/<name>.md` (skipping `_*` templates); skills use OpenCode's
62
+ * folder layout `<root>/<name>/SKILL.md`. Matched case-INSENSITIVE on Windows via
63
+ * `normcase`, not a bare `endsWith`. The frontmatter is stripped because it is host
64
+ * plumbing rather than prose, which is what makes a deployed entry the same shape as a
65
+ * source-rendered one and every consumer indifferent to the origin.
66
+ */
67
+ function specEntries(root, nested) {
68
+ const out = [];
69
+ if (!isDir(root)) return out;
70
+ let names;
71
+ try { names = readdirSync(root); } catch { return out; }
72
+ const files = nested
73
+ ? names.sort(comparePaths).filter((n) => isDir(path.join(root, n)))
74
+ .map((n) => path.join(root, n, 'SKILL.md'))
75
+ : names.filter((n) => normcase(n).endsWith('.md') && !n.startsWith('_'))
76
+ .sort(comparePaths).map((n) => path.join(root, n));
77
+ for (const p of files) {
78
+ if (!isFile(p)) continue;
79
+ const [fm, body] = frontmatter(readMaybe(p) ?? '');
80
+ const name = nested ? path.basename(path.dirname(p)) : path.basename(p, '.md');
81
+ out.push({ name, desc: specDesc(fm, body), body, source: resolvePath(p) });
82
+ }
83
+ out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * The agents and skills actually installed at `state.target`, not a fresh render of `src/`.
89
+ *
90
+ * All three constitutional tiers still come from the render: once deployed they live inside
91
+ * AGENT.md rather than as separate files, so the deployed arm replaces the two ENTITY rosters
92
+ * and none of the constitution. A deployed spec file carries neither a category nor a
93
+ * lifecycle status, so each is tagged BY NAME from the same two sources the source render
94
+ * uses — and an entity the registry does not know reads "personal" for both, which is the
95
+ * honest answer rather than a borrowed one: filing it under the `build` fallback would claim
96
+ * a Geneseed taxonomy slot it was never given.
97
+ *
98
+ * ⚠ THE PACK SELECTION IS THE ONE THING THE RENDER CANNOT KNOW. `renderAll` emits all four
99
+ * pack files whatever `cfg.doctrines` says — measured: `makeCfg({doctrines:['craft']})` yields
100
+ * the same 105 items — because the filtering happens when AGENT.md's §2 is assembled, not in
101
+ * the walk. So the catalogue is always whole, and which packs this INSTALL built in comes from
102
+ * its own carrier via `doctrinesForBuild`, which resolves "no marker" to every pack.
103
+ */
104
+ function deployedInventory(state) {
105
+ const render = tuiInventory(state.theme, doctrinesForBuild(state.target),
106
+ excludedRulesOfDir(state.target));
107
+ const registry = loadRegistry();
108
+ const skills = specEntries(path.join(state.target, 'skills'), true);
109
+ for (const e of skills) {
110
+ e.status = entityStatus(registry, `skills/${e.name}`);
111
+ e.klass = e.status === 'personal' ? 'personal'
112
+ : (Object.hasOwn(SKILL_CLASS, e.name) ? SKILL_CLASS[e.name] : 'build');
113
+ }
114
+ const agents = specEntries(path.join(state.target, 'agents'), false);
115
+ for (const e of agents) e.status = entityStatus(registry, `agents/${e.name}`);
116
+ return { agents,
117
+ skills,
118
+ laws: render.laws,
119
+ ontology: render.ontology,
120
+ doctrines: render.doctrines,
121
+ theme: state.theme };
122
+ }
123
+
124
+ /** The deployed record when there is one, else the source render. */
125
+ export function inventoryFor(state) {
126
+ return deployed(state) ? deployedInventory(state) : tuiInventory(state.theme);
127
+ }
128
+
129
+ // ---- the catalog stores overview counts ---------------------------------------------------
130
+
131
+ /**
132
+ * Always `<target>/memory` — never the CWD-scanning resolver. The ONE copy: `actions.mjs` and
133
+ * `user-files.mjs` delete and promote facts in the same directory this lists.
134
+ */
135
+ export const memoryDir = (state) => path.join(state.target, 'memory');
136
+ const notebookDir = (state) => path.join(state.target, 'notebook');
137
+
138
+ /**
139
+ * Matched case-insensitively on Windows.
140
+ *
141
+ * `comparePaths` and NOT a bare `.sort()`: on Windows, filenames sort case-insensitively, so
142
+ * `MEMORY.md` sorts under `m` and lands after `a-fact.md`; JS's default comparator is UTF-16
143
+ * code units, so `M` (0x4D) sorts before `a` (0x61) and the whole catalog comes back in a
144
+ * different order. The `normcase` in the filter beside it only fixes matching, not
145
+ * ordering — this is the other half.
146
+ */
147
+ function globMd(dir) {
148
+ if (!isDir(dir)) return [];
149
+ let names;
150
+ try { names = readdirSync(dir); } catch { return []; }
151
+ return names.filter((n) => normcase(n).endsWith('.md') && isFile(path.join(dir, n)))
152
+ .sort(comparePaths);
153
+ }
154
+
155
+ /** Filename without its extension — single owner, imported by `js/web/activity.mjs` too. */
156
+ export const stemOf = (name) => name.slice(0, name.length - path.extname(name).length);
157
+
158
+ export function memoryItems(state) {
159
+ const d = memoryDir(state);
160
+ if (!isDir(d)) return [];
161
+ return globMd(d).map((n) => {
162
+ const p = path.join(d, n);
163
+ const [fm] = frontmatter(readMaybe(p) ?? '');
164
+ const stem = stemOf(n);
165
+ return {
166
+ name: stem,
167
+ title: fm.has('name') ? fm.get('name') : stem,
168
+ desc: fm.has('description') ? fm.get('description') : '',
169
+ source: resolvePath(p),
170
+ };
171
+ });
172
+ }
173
+
174
+ export function notebookItems(state) {
175
+ const d = notebookDir(state);
176
+ if (!isDir(d)) return [];
177
+ return globMd(d).map((n) => ({
178
+ name: stemOf(n), title: stemOf(n), desc: '', source: resolvePath(path.join(d, n)),
179
+ }));
180
+ }
181
+
182
+ /** The two setup manifests, in listing order. */
183
+ const CONFIG_META = {
184
+ 'context.json': ['Project context', 'what the agent loads for this project'],
185
+ 'wiki.jsonc': ['Wiki manifest', 'your machine-wide knowledge base(s)'],
186
+ };
187
+
188
+ export function configItems(state) {
189
+ const out = [];
190
+ for (const fname of ['context.json', 'wiki.jsonc']) {
191
+ const p = path.join(state.target, fname);
192
+ if (!isFile(p)) continue;
193
+ const [title, desc] = CONFIG_META[fname] ?? [fname, ''];
194
+ out.push({ name: fname, title, desc, type: 'config', kind: 'manifest',
195
+ source: resolvePath(p) });
196
+ }
197
+ return out;
198
+ }
199
+
200
+ const WIKI_FILE_CAP = 5000;
201
+
202
+ /**
203
+ * `resolvePath` for a path that came out of a HAND-MAINTAINED manifest — `null`, never a throw.
204
+ *
205
+ * The three wiki sites below, and `apiDeployCmd` in `js/web/actions.mjs`, are the
206
+ * request-path callers of `expanduser`, and it REFUSES a `~user` form by printing and throwing
207
+ * (`js/hosts/hosts.mjs`'s docblock). None of them may let that escape: `js/web/handler.mjs`
208
+ * wraps the whole GET in a blanket `catch` → a JSON 500, so an unguarded refusal would turn
209
+ * ONE bad line in a file the user hand-edits into a dead Knowledge section. That is the same
210
+ * argument `sovereignBypass` makes in `js/hosts/hosts.mjs` — contain the refusal per entry
211
+ * and take the site's OWN degrade — and each of the three already has one: `[]`, `continue`,
212
+ * `continue`-into-404.
213
+ *
214
+ * `withDiscardableStderr` because `expanduser` prints its refusal at the RAISE SITE, and
215
+ * this caller wants that swallowed along with the exception rather than logged.
216
+ *
217
+ * DELIBERATE PRODUCT DECISION: a `~otherUser` path is refused rather than resolved, even
218
+ * where the account happens to exist — reading another account's files through a
219
+ * hand-edited wiki manifest is not a feature.
220
+ */
221
+ function wikiPath(p) {
222
+ try {
223
+ return withDiscardableStderr(() => resolvePath(p));
224
+ } catch {
225
+ return null;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * `$GENESEED_WIKI` first, else `wiki.jsonc` beside the deployed bundle, read with the
231
+ * harness's generic JSONC loader.
232
+ *
233
+ * `resolvePath`, not merely `expanduser`: harmless here because `p` is consumed by `isFile`
234
+ * and `mcpLoad` and never reaches a response body, and both of those follow symlinks and
235
+ * resolve a relative path against the same cwd anyway.
236
+ */
237
+ function wikiManifest(state) {
238
+ const cand = process.env.GENESEED_WIKI;
239
+ const p = cand ? wikiPath(cand) : path.join(state.target, 'wiki.jsonc');
240
+ if (!p || !isFile(p)) return [];
241
+ const cfg = mcpLoad(p);
242
+ const wikis = cfg.wikis;
243
+ return Array.isArray(wikis) ? wikis : [];
244
+ }
245
+
246
+ /**
247
+ * The COMMENT-TOLERANT dict loader, `{}` for a file that is missing, unreadable,
248
+ * unparseable, or not an object.
249
+ *
250
+ * Comment-tolerant is the whole point, and getting this wrong is silent: the two files it
251
+ * reads are `wiki.jsonc` and `context.json`, hand-maintained, so a `//` line is expected.
252
+ * Plain `JSON.parse` would throw on it and this loader would then answer `{}` — the wiki
253
+ * section listing nothing and the config item's `manifest` coming back empty, with no
254
+ * error surfaced anywhere.
255
+ */
256
+ function mcpLoad(p) {
257
+ if (!isFile(p)) return {};
258
+ let text;
259
+ try { text = readText(p); } catch { return {}; }
260
+ const [data] = readJsonc(text);
261
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
262
+ }
263
+
264
+ /**
265
+ * Every `.md` under `dir`, recursively.
266
+ *
267
+ * `recursive: true` replaces a hand-rolled walk; its one caller re-sorts the result with
268
+ * `comparePaths` before using it, so the order this returns is never observed.
269
+ */
270
+ function rglobMd(dir) {
271
+ let entries;
272
+ try { entries = readdirSync(dir, { recursive: true, withFileTypes: true }); } catch { return []; }
273
+ return entries
274
+ .filter((e) => e.isFile() && normcase(e.name).endsWith('.md'))
275
+ .map((e) => path.join(e.parentPath, e.name));
276
+ }
277
+
278
+ export function wikiItems(state) {
279
+ const items = [];
280
+ const seen = new Set();
281
+ for (const w of wikiManifest(state)) {
282
+ if (!w || typeof w !== 'object' || Array.isArray(w)) continue;
283
+ const wname = String(dget(w, 'name', null) || 'wiki');
284
+ // PER ENTRY, and the loop continues — one unusable `path` in the manifest must not
285
+ // blank every OTHER vault in it.
286
+ const root = wikiPath(String(dget(w, 'path', null) || ''));
287
+ if (!root || !isDir(root)) continue;
288
+ // A hand-edited `entries` that is not a list (`{}`, a string) is a malformed vault, not a
289
+ // crash: `/api/status` counts these, and one bad manifest must not fail the dashboard.
290
+ const rawEntries = dget(w, 'entries', null);
291
+ const entries = (Array.isArray(rawEntries) ? rawEntries : [])
292
+ .filter((e) => e && typeof e === 'object' && !Array.isArray(e));
293
+ const excludes = entries.filter((e) => dget(e, 'load', null) === 'exclude')
294
+ .map((e) => String(dget(e, 'path', null) || '').replace(/^\/+|\/+$/g, '').replace(/\\/g, '/'));
295
+ const excluded = (rel) => excludes.some((x) => x && (rel === x || rel.startsWith(`${x}/`)));
296
+
297
+ for (const e of entries) {
298
+ if (dget(e, 'load', null) === 'exclude') continue;
299
+ const rel = String(dget(e, 'path', null) || '').replace(/^\/+|\/+$/g, '').replace(/\\/g, '/');
300
+ const desc = String(dget(e, 'description', null) || '');
301
+ const fp = path.join(root, rel);
302
+ let mds;
303
+ if (isFile(fp) && path.extname(fp) === '.md') mds = [fp];
304
+ // Capped and sorted via `comparePaths`, for the reason `globMd` carries.
305
+ else if (isDir(fp)) mds = rglobMd(fp).sort(comparePaths).slice(0, WIKI_FILE_CAP);
306
+ else continue;
307
+ for (const md of mds) {
308
+ const r = path.relative(root, md).split(path.sep).join('/');
309
+ const key = `${wname}:${r}`;
310
+ if (seen.has(key) || excluded(r)) continue;
311
+ seen.add(key);
312
+ items.push({ name: key, title: path.basename(md, '.md'),
313
+ desc: mds.length === 1 ? desc : r, type: 'wiki', kind: 'page', group: wname,
314
+ source: resolvePath(md) });
315
+ }
316
+ }
317
+ }
318
+ return items;
319
+ }
320
+
321
+ /** One page by `<wiki>:<relpath>`, never outside the vault. */
322
+ function apiWikiItem(state, name) {
323
+ const at = name.indexOf(':');
324
+ const wname = at < 0 ? name : name.slice(0, at);
325
+ const rel = (at < 0 ? '' : name.slice(at + 1))
326
+ .replace(/^\/+|\/+$/g, '').replace(/\\/g, '/');
327
+ for (const w of wikiManifest(state)) {
328
+ if (!w || typeof w !== 'object' || Array.isArray(w)) continue;
329
+ if (String(dget(w, 'name', null) || 'wiki') !== wname) continue;
330
+ // An unresolvable or refused root falls through to the `NotFound` below, the same as
331
+ // one that simply does not exist.
332
+ const root = wikiPath(String(dget(w, 'path', null) || ''));
333
+ if (!root) continue;
334
+ const p = resolvePath(path.join(root, rel));
335
+ if (rel && path.extname(p) === '.md' && within(p, root) && isFile(p)) {
336
+ const body = readMaybe(p) ?? '';
337
+ return { type: 'wiki', name, title: path.basename(p, '.md'), desc: '',
338
+ body, links: resolveLinks(state, body), source: p };
339
+ }
340
+ }
341
+ throw new NotFound(name);
342
+ }
343
+
344
+
345
+ /**
346
+ * Catalog names are flat basenames.
347
+ *
348
+ * A separator, a `..` or a drive colon in the URL segment is someone steering the join
349
+ * outside the catalog dir. A GET carries no token, so before this check the endpoint was
350
+ * an arbitrary-file read; it raises rather than resolving.
351
+ */
352
+ function flatName(name) {
353
+ if (!name || name.includes('/') || name.includes('\\') || name.includes('..')
354
+ || name.includes(':')) {
355
+ throw new NotFound(name);
356
+ }
357
+ }
358
+
359
+ const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
360
+
361
+ /**
362
+ * `[[name]]` matched against known agent and skill names.
363
+ *
364
+ * AGENTS FIRST, SKILLS SECOND, into ONE map — so a name that is both resolves as a SKILL.
365
+ * This ordering is deliberate and must not be swapped.
366
+ */
367
+ export function resolveLinks(state, body) {
368
+ const inv = state.inventory;
369
+ const known = new Map();
370
+ for (const e of inv.agents) known.set(e.name, 'agent');
371
+ for (const e of inv.skills) known.set(e.name, 'skill');
372
+ const links = [];
373
+ const seen = new Set();
374
+ for (const m of body.matchAll(WIKILINK_RE)) {
375
+ // `stripWhitespace`, not `trim()` — the two whitespace classes differ, and both call
376
+ // sites on this regex must answer with the same rule.
377
+ const label = stripWhitespace(m[1]);
378
+ if (known.has(label) && !seen.has(label)) {
379
+ seen.add(label);
380
+ links.push({ label, type: known.get(label), name: label });
381
+ }
382
+ }
383
+ return links;
384
+ }
385
+
386
+ /**
387
+ * A setup file parsed into the shape the detail pane renders as cards, or `null` so the
388
+ * caller falls back to the raw body.
389
+ */
390
+ function configManifest(name, p) {
391
+ const cfg = mcpLoad(p);
392
+ if (name === 'wiki.jsonc') {
393
+ const wikis = cfg.wikis;
394
+ return { kind: 'wiki', wikis: Array.isArray(wikis) ? wikis : [] };
395
+ }
396
+ if (name === 'context.json') {
397
+ const ctx = cfg.context;
398
+ return { kind: 'context', context: Array.isArray(ctx) ? ctx : [] };
399
+ }
400
+ return null;
401
+ }
402
+
403
+ /**
404
+ * A closed list; anything else is a 404.
405
+ *
406
+ * ⚠ `laws` STAYS ONE SECTION FOR ALL THREE TIERS, and that is a decision rather than an
407
+ * oversight. The console has ONE Constitution entry, not three, so a reader keeps the "read it
408
+ * top to bottom" property; the id stays `laws` because `tests/helpers/cli_golden.mjs`
409
+ * hard-requires `web/src/pages/Laws.jsx` and because a route rename buys nothing and costs a
410
+ * redirect. The tier lives on the ITEM (`tier: 'ontology' | 'invariant' | 'doctrine'`), which
411
+ * is where a consumer can act on it.
412
+ */
413
+ export const SECTIONS = ['agents', 'skills', 'laws', 'memory', 'notebook', 'wiki', 'config'];
414
+
415
+ /**
416
+ * The `laws` section's items — the whole constitution, in constitutional order, one flat list.
417
+ *
418
+ * FLAT AND NOT NESTED, because every other section is `{section, items}` and a second shape
419
+ * for one section is a second client path for one roster. The pack's grouping metadata rides
420
+ * on each doctrine row (`pack`, `packTitle`, `packDesc`, `active`), so a consumer groups by
421
+ * `pack` and has everything a header needs without a second fetch.
422
+ *
423
+ * Names are ADDRESSES and the three shapes cannot collide: `ont:<slug>` carries a colon no
424
+ * numeral has, an invariant is `[IVXLCDM]+`, a doctrine rule is `<pack>.<n>`. `apiItem`
425
+ * resolves all three off the same list.
426
+ */
427
+ function constitutionItems(inv) {
428
+ return [
429
+ ...(inv.ontology ?? []).map((e) => ({ name: `ont:${e.id}`, title: e.title, desc: '',
430
+ tier: 'ontology' })),
431
+ ...inv.laws.map((e) => ({ name: e.num, title: `Rule ${e.num} — ${e.title}`, desc: '',
432
+ klass: e.klass ?? 'craft', tier: 'invariant' })),
433
+ ...(inv.doctrines ?? []).flatMap((p) => p.rules.map((r) => ({
434
+ name: `${r.pack}.${r.n}`,
435
+ title: `Doctrine ${r.pack} ${r.n} — ${r.title}`,
436
+ desc: '',
437
+ klass: r.klass ?? p.pack,
438
+ tier: 'doctrine',
439
+ pack: p.pack,
440
+ packTitle: p.title,
441
+ packDesc: p.desc,
442
+ // ⚠ EVERY RULE IS LISTED WHETHER OR NOT IT IS BUILT IN, and `active` is the RULE's own
443
+ // — a pack can be on with one rule excluded. A row that vanished from the payload would
444
+ // be indistinguishable from one that never shipped, and the console could not then
445
+ // offer the switch that turns it back on.
446
+ active: r.active !== false,
447
+ packActive: p.active,
448
+ }))),
449
+ ];
450
+ }
451
+
452
+ export function apiCatalog(state, section) {
453
+ if (!SECTIONS.includes(section)) throw new NotFound(section);
454
+ const inv = state.inventory;
455
+ let items;
456
+ if (section === 'agents') {
457
+ items = inv.agents.map((e) => ({ name: e.name, title: e.name, desc: e.desc,
458
+ source: e.source ?? null, status: e.status ?? 'unknown' }));
459
+ } else if (section === 'skills') {
460
+ items = inv.skills.map((e) => ({ name: e.name, title: e.name, desc: e.desc,
461
+ source: e.source ?? null, klass: e.klass ?? 'build', status: e.status ?? 'unknown' }));
462
+ } else if (section === 'laws') {
463
+ items = constitutionItems(inv);
464
+ } else if (section === 'memory') {
465
+ items = memoryItems(state);
466
+ } else if (section === 'notebook') {
467
+ items = notebookItems(state);
468
+ } else if (section === 'wiki') {
469
+ items = wikiItems(state);
470
+ } else {
471
+ items = configItems(state);
472
+ }
473
+ return { section, items };
474
+ }
475
+
476
+ export function apiItem(state, type, name) {
477
+ const inv = state.inventory;
478
+ if (type === 'agent' || type === 'skill') {
479
+ const e = (type === 'agent' ? inv.agents : inv.skills).find((x) => x.name === name);
480
+ if (!e) throw new NotFound(name);
481
+ const out = { type, name, title: name, desc: e.desc, body: e.body,
482
+ links: resolveLinks(state, e.body), source: e.source ?? null };
483
+ if (type === 'skill') out.klass = e.klass ?? 'build';
484
+ out.status = e.status ?? 'unknown';
485
+ return out;
486
+ }
487
+ if (type === 'law') {
488
+ // ONE ARM FOR THREE TIERS, resolved off the same list the catalogue publishes — so a name
489
+ // that appears in `/api/catalog/laws` always opens, and one that does not always 404s.
490
+ // Building the list twice is what would let the two drift.
491
+ const e = constitutionItems(inv).find((x) => x.name === name);
492
+ if (!e) throw new NotFound(name);
493
+ const body = e.tier === 'ontology'
494
+ ? (inv.ontology.find((s) => `ont:${s.id}` === name)?.body ?? '')
495
+ : (e.tier === 'invariant'
496
+ ? (inv.laws.find((x) => x.num === name)?.body ?? '')
497
+ : (inv.doctrines.flatMap((p) => p.rules)
498
+ .find((r) => `${r.pack}.${r.n}` === name)?.body ?? ''));
499
+ // `links: []` — a constitution body is not link-resolved. It cites its siblings by
500
+ // ADDRESS (`Rule IV`, `Doctrine ops 1`, `Ontology: Telos`), not by path, and there is no
501
+ // file behind those to resolve to.
502
+ return { ...e, type, name, body, links: [] };
503
+ }
504
+ if (type === 'memory' || type === 'notebook') {
505
+ flatName(name);
506
+ const d = type === 'notebook' ? notebookDir(state) : memoryDir(state);
507
+ const p = path.join(d, `${name}.md`);
508
+ if (!isFile(p)) throw new NotFound(name);
509
+ const body = readMaybe(p) ?? '';
510
+ return { type, name, title: name, desc: '', body,
511
+ links: resolveLinks(state, body), source: resolvePath(p) };
512
+ }
513
+ if (type === 'wiki') return apiWikiItem(state, name);
514
+ if (type === 'config') {
515
+ // THE ALLOWLIST, NOT `flatName`: a flat name still reached every top-level file of the
516
+ // install — `.geneseed-web.json` (this daemon's own CSRF token) and `settings.json` among
517
+ // them — over a GET that carries no token. Only the manifests the catalogue lists can open.
518
+ if (!Object.hasOwn(CONFIG_META, name)) throw new NotFound(name);
519
+ const p = path.join(state.target, name);
520
+ if (!isFile(p)) throw new NotFound(name);
521
+ const raw = readMaybe(p) ?? '';
522
+ const [title, desc] = CONFIG_META[name];
523
+ return { type, name, title, desc, manifest: configManifest(name, p),
524
+ body: `\`\`\`json\n${raw}\n\`\`\``, links: [], source: resolvePath(p) };
525
+ }
526
+ throw new NotFound(type);
527
+ }
package/js/web/daemon.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { printOut, writeText } from '../lib/fs.mjs';
13
13
  import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
14
- import { webState } from './api.mjs';
14
+ import { webState } from './state.mjs';
15
15
  import { spawn } from 'node:child_process';
16
16
  import { createHash } from 'node:crypto';
17
17
  import { chmodSync, mkdirSync, openSync, readFileSync, unlinkSync } from 'node:fs';
@@ -190,11 +190,18 @@ export function restartArgs(theme) {
190
190
 
191
191
  /**
192
192
  * A detached `web restart`, so the new server survives the exit of the very process asking
193
- * for it. Called by POST `/api/restart`.
193
+ * for it. Called by POST `/api/restart` and after a successful update job.
194
+ *
195
+ * ONLY FROM THE DAEMON THE RECORD NAMES — `false`, and nothing spawned, otherwise. A plain
196
+ * `geneseed web` serves in the foreground and writes no record, so `web restart` found nothing
197
+ * to stop, started a fresh daemon on 4747 while this process still held it, lost the bind,
198
+ * fell back to a random port, and left a background console nobody had asked for.
194
199
  */
195
200
  export function requestRestart(theme) {
196
201
  const target = webState(theme).target;
202
+ if (readDaemon(target)?.pid !== process.pid) return false;
197
203
  spawnDetached(restartArgs(theme), join(target, '.geneseed-web.log'));
204
+ return true;
198
205
  }
199
206
 
200
207
  /**
@@ -315,6 +322,14 @@ export async function stopDaemon(theme = null, port = null) {
315
322
  printOut(`[web] stopped (pid ${none(st.pid)}).\n`);
316
323
  return 0;
317
324
  }
325
+ // A FAILED SHUTDOWN IS NOT A DEAD SERVER. The POST times out at 3 s, a synchronous handler
326
+ // (a doctor run) can hold the event loop longer, and a console with a job running answers
327
+ // 409 — deleting the record then left a live daemon nothing could find again.
328
+ if (await probe(st.url)) {
329
+ printOut(`[web] the server on ${st.url} did not stop (pid ${none(st.pid)}) — it may be `
330
+ + 'busy with a job; try again once it finishes.\n');
331
+ return 1;
332
+ }
318
333
  clearDaemon(target);
319
334
  printOut('[web] no live server (cleared a stale record).\n');
320
335
  return 0;
@@ -378,7 +393,10 @@ export async function restartDaemon(theme = null, port = null, openBrowser = tru
378
393
  // A live daemon means a tab is already open on this (preserved) port and will
379
394
  // reconnect on its own — no need to open a fresh browser window here.
380
395
  open = false;
381
- await stopDaemon(theme);
396
+ // A daemon that refused to stop is still on the port: starting another would find it live
397
+ // and report "already running" over a restart that never happened.
398
+ const stopped = await stopDaemon(theme);
399
+ if (stopped !== 0) return stopped;
382
400
  for (let i = 0; i < 50; i += 1) {
383
401
 
384
402
  if (!await probe(`http://127.0.0.1:${usePort}`, 0.2)) break;